1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
|
#+TITLE: theme-selector — package faces (tier 3), starting with org-mode
#+AUTHOR: Craig Jennings
#+DATE: 2026-06-07
* Status
Spec / Craig's first-round answers folded in (2026-06-07). Proposes a third tier
for the theme-selector (scripts/theme-selector/) that lets a theme colorize
package-specific faces, built one application at a time. v1 apps: org-mode
(incl. org-agenda), magit, elfeed. Two items still open: Craig's confirm on the
inheritance representation, and whether to build the custom color picker before,
during, or after tier 3. A Codex review file was referenced but is not present
in the repo (see iteration history); when it lands as
=theme-selector-package-faces-review.org=, run spec-response against it.
* Background — the three tiers
The theme-selector already models two tiers of faces:
1. *Syntax* — the font-lock / tree-sitter categories (keyword, string, type,
comment, etc.), in the "code/color assignments" table.
2. *UI* — Emacs's built-in interface faces (cursor, region, mode-line, fringe,
line numbers, isearch, and the rest), in the "ui faces" table with the live
mock-frame preview.
Tier 3 is *package faces*: faces a package declares with =defface= so a theme
can color the package as it wishes. The running config has 1,146 such faces
across 186 packages (magit 111, lsp-mode 97, telega 91, web-mode 82, org ~30
core, and a long tail). No theme colors all of them; quality themes hand-pick
the packages the user actually lives in and theme those.
This spec adds a tier-3 section to the tool, structured so applications are
added one at a time. org-mode ships first.
* Goal
A new "package faces" section with:
1. An *application dropdown* — pick which package's faces to edit. v1 ships
org-mode (including org-agenda), magit, and elfeed; the rest of Craig's
packages (calibredb, ghostel, mu4e, IRC, org-drill, dirvish + dired, slack)
follow one at a time.
2. A *face table* for the selected app — one row per curated face, each with a
foreground dropdown, a background dropdown, and bold / italic toggles, all
drawing from the same palette as the other tables.
3. A *preview pane* for the selected app — a realistic mock of that package
rendered with the live theme, the way the ui-faces mock-frame shows the UI
faces in a buffer. org-mode gets a mock org document.
The export (=theme.json=) gains a =packages= object so the build step can set
these faces too.
* UI placement
A new top-level section under the ui-faces row:
#+begin_example
<h1>package faces</h1>
[ application: (org-mode v) ]
<div class="cols stretch">
left = the selected app's face table (fg / bg / B / I per face)
right = the selected app's preview pane (e.g. the org document mock)
</div>
#+end_example
Same two-column stretch layout as the ui-faces row, so the preview matches the
table's height.
* Data model
A single data structure drives everything, keyed by application:
#+begin_src js
APPS = {
"org-mode": {
label: "org-mode",
faces: [
// face, human label, default {fg, bg, bold, italic}
["org-document-title", "document title", {fg:"gold", bold:true}],
["org-level-1", "heading 1", {fg:"blue", bold:true}],
["org-level-2", "heading 2", {fg:"gold"}],
["org-level-3", "heading 3", {fg:"regal"}],
["org-todo", "TODO keyword", {fg:"terracotta", bold:true}],
["org-done", "DONE keyword", {fg:"sage", bold:true}],
["org-link", "link", {fg:"blue"}], // base `link`
["org-code", "inline code", {fg:"terracotta"}],
["org-verbatim", "verbatim", {fg:"steel"}],
["org-block", "src block body", {fg:"white", bg:"bg-dim"}],
["org-block-begin-line","block delim", {fg:"pewter", bg:"bg-dim"}],
["org-table", "table", {fg:"steel"}],
["org-date", "timestamp", {fg:"steel"}],
["org-tag", "tag", {fg:"tan"}],
["org-special-keyword","keyword/drawer", {fg:"pewter"}],
["org-meta-line", "#+meta line", {fg:"pewter"}],
["org-checkbox", "checkbox", {fg:"gold"}],
["org-headline-done", "done headline", {fg:"pewter"}],
],
preview: "org" // names the preview renderer
},
// magit, elfeed, ... added later with the same shape
}
#+end_src
Defaults reference palette *names* (blue, gold, ...) resolved to hexes at load,
so a curated app seeds sensibly from the current palette. The user reassigns
any face from the palette dropdowns exactly like the other tables.
State mirrors the other tiers: a =PKGMAP= of
={app: {face: {fg, bg, bold, italic, inherit}}}=, edited live, rendered into
the table and the preview. The =APPS= block above shows ~18 org faces only as a
shape illustration; the real org entry is the complete set below.
** Data model — org face set (complete)
Per the completeness decision, org's table lists org's entire own =defface= set
(org-faces.el + org-agenda.el), ~88 faces, grouped. Seed defaults for the
prominent groups; the long tail seeds to fg or an =inherit= of its group base,
which the user overrides. The groups (face names verbatim from the running
Emacs):
- *Document:* org-document-title, org-document-info, org-document-info-keyword
- *Headings:* org-level-1 .. org-level-8, org-headline-todo, org-headline-done
- *Status / keywords:* org-todo, org-done, org-priority, org-tag, org-tag-group,
org-special-keyword, org-drawer, org-property-value, org-checkbox,
org-checkbox-statistics-todo, org-checkbox-statistics-done, org-warning
- *Links / dates / refs:* org-link, org-footnote, org-date, org-sexp-date,
org-date-selected, org-target, org-macro, org-cite, org-cite-key
- *Blocks / code / quote:* org-block, org-block-begin-line, org-block-end-line,
org-code, org-verbatim, org-inline-src-block, org-quote, org-verse,
org-latex-and-related
- *Tables / columns:* org-table, org-table-header, org-table-row, org-formula,
org-column, org-column-title
- *Lists / meta / structure:* org-list-dt, org-meta-line, org-ellipsis,
org-hide, org-indent, org-archived, org-default, org-dispatcher-highlight
- *Agenda — structure & dates:* org-agenda-structure,
org-agenda-structure-secondary, org-agenda-structure-filter, org-agenda-date,
org-agenda-date-today, org-agenda-date-weekend, org-agenda-date-weekend-today,
org-agenda-current-time, org-agenda-done, org-agenda-dimmed-todo-face
- *Agenda — calendar & filters:* org-agenda-calendar-event,
org-agenda-calendar-sexp, org-agenda-calendar-daterange, org-agenda-diary,
org-agenda-clocking, org-agenda-column-dateline, org-agenda-restriction-lock,
org-agenda-filter-category, org-agenda-filter-effort, org-agenda-filter-regexp,
org-agenda-filter-tags
- *Scheduling / deadlines / clock:* org-scheduled, org-scheduled-today,
org-scheduled-previously, org-upcoming-deadline, org-upcoming-distant-deadline,
org-imminent-deadline, org-time-grid, org-clock-overlay, org-mode-line-clock,
org-mode-line-clock-overrun
The org *preview* below stays a curated document exercising the prominent
faces; the *table* carries the complete set so every face is assignable, even
the ones the preview doesn't draw. magit and elfeed get the same treatment
(complete own-defface set in the table, a bespoke preview for the common faces).
* The org preview
A mock org document painted from PKGMAP["org-mode"] plus the palette ground/fg.
One bespoke renderer (=renderOrgPreview()=) drawing a representative document:
#+begin_example
#+TITLE: Project Notes <- org-document-title
#+AUTHOR: ... <- org-meta-line / document-info
* Inbox :work: <- org-level-1 + org-tag
** TODO Draft the spec <- org-level-2 + org-todo
SCHEDULED: <2026-06-08 Sun> <- org-special-keyword + org-date
** DONE Ship the tool <- org-level-2 + org-done (headline-done)
*** Heading three <- org-level-3
A line with =inline code=, <- org-code
~verbatim~, and a [[link]]. <- org-verbatim + org-link
- [X] a checkbox item <- org-checkbox
#+begin_src elisp <- org-block-begin-line
(message "hi") <- org-block
#+end_src <- org-block-end-line
| name | hex | <- org-table (header row org-table-header)
|------+---------|
| blue | #67809c |
#+end_example
Each marked element is a span colored from the corresponding PKGMAP face. The
preview rebuilds whenever a package face or the palette changes, same as the
mock frame.
org, magit, and elfeed get bespoke preview renderers (magit -> a status buffer
mock, elfeed -> a search-list mock). Every *other* package is still fully
themeable: its face *table* is always present and editable, only the rich
*preview* is replaced by a generic fallback — each face's name rendered in its
own colors on the ground. So a user can theme every package they have the
moment its face list is added; the bespoke preview is a polish layer on top, not
a gate. This is the v1 answer to "some will want to touch every package."
* Export schema
=theme.json= gains a =packages= key:
#+begin_src json
{
"name": "dupre",
"palette": [...],
"assignments": {...},
"bold": [...], "italic": [...],
"ui": {...},
"packages": {
"org-mode": {
"org-level-1": {"fg":"#67809c","bg":null,"bold":true,"italic":false,"inherit":null},
"org-level-2": {"fg":"#e8bd30","bg":null,"bold":false,"italic":false,"inherit":"org-level-1"},
"org-todo": {"fg":"#cb6b4d","bg":null,"bold":true,"italic":false,"inherit":null}
}
}
}
#+end_src
=inherit= is optional and =null= when absent. When set, the converter writes
=:inherit PARENT= plus only the overridden attributes.
Only faces the user actually touched (or the curated defaults) are written. The
build step's converter sets each as a normal face. Backward compatible: a file
without =packages= loads fine.
* Build-step consumption
The eventual =theme.json= -> =dupre-*.el= converter already owns tiers 1 and 2.
Tier 3 adds, per package face:
#+begin_src elisp
(org-level-1 ((t (:foreground "#67809c" :weight bold))))
(org-todo ((t (:foreground "#cb6b4d" :weight bold))))
#+end_src
No new converter machinery — package faces are just more faces. This is the
TDD-worthy part (JSON in, valid faces out), same as the rest of the converter.
* Scope for v1
- Build the section, the app dropdown, and the face tables + previews for the
three v1 apps: org-mode (incl. org-agenda), magit, elfeed.
- org's table carries its complete own-defface set (~88 faces, grouped above),
seeded with defaults; the org preview draws the prominent ones.
- Every other installed package is reachable in the dropdown with an editable
face table and the generic fallback preview, so any package can be themed.
- Wire export/import of the =packages= key (with the optional =inherit= field).
- Leave the converter for the separate build-step task (Elisp, per Craig); the
spec only needs the schema to be right.
* Extensibility (adding the next app)
1. Add an entry to =APPS= (label, curated face list with palette-name defaults,
preview key).
2. Optionally write a bespoke preview renderer; until then the generic fallback
renders.
3. Nothing else changes — the dropdown, table, export, and import are all
data-driven off =APPS= / =PKGMAP=.
* Agreed decisions
Craig's answers to the first review round, baked in (the body sections above
reflect these; this records the decisions):
1. *Curated set is complete, not iterative.* For org, list its *entire* own
defface set (org-faces.el + org-agenda.el), ~88 faces, not a hand-picked
~18. The user wants every choice present, not a set that grows on demand.
See "Data model — org face set" for the full grouped list.
2. *Seed curated defaults.* Seed sensible fg/bg and weight per face (headings,
title, TODO/DONE bold; agenda dates and deadlines colored by role). The user
reassigns from there.
3. *App order: org, magit, elfeed for v1.* Then the rest one at a time, drawn
from the packages Craig actually runs: calibredb, ghostel, mu4e, the IRC
client, org-drill, dirvish + dired, slack. A finite "most-used" list gets
picked later; we do not try to do everything at once.
4. *Generic fallback is real, not display-only.* Any package not given a
bespoke preview still gets a fully editable face table (so a user can theme
*every* package they have); only the rich preview is missing, replaced by a
swatch-in-context fallback. Bespoke previews ship for org, magit, elfeed.
* Inheritance representation (answer to Craig's question)
Craig asked how inheritance would be represented. Proposal:
Each face carries an optional =inherit= field naming another face (or =null=).
The face's own =fg/bg/bold/italic= are *overrides* layered on top of what it
inherits.
#+begin_src js
["org-level-2", "heading 2", {inherit:"org-level-1", fg:"gold"}]
// exports as: (org-level-2 ((t (:inherit org-level-1 :foreground "#e8bd30"))))
["org-agenda-date-today", "agenda today", {inherit:"org-agenda-date", bold:true}]
// exports as: (org-agenda-date-today ((t (:inherit org-agenda-date :weight bold))))
#+end_src
In the tool, an inheriting row shows an "inherits <face>" chip; attributes left
unset render greyed (they come from the parent) until the user overrides one.
The converter writes =:inherit PARENT= followed by only the overridden
attributes.
*Recommendation: default to absolute values, offer inherit as opt-in.* Emacs
face inheritance surprises people — an inherited background or weight rides
along silently — so seeding every face with absolute attributes is the
predictable default. Inheritance is available for the cases where a cascade is
genuinely wanted (all heading levels off one base; agenda-date variants off
=org-agenda-date=), expressed with the =inherit= field above. This keeps the
common path obvious and the export deterministic, while still letting a user
model the relationships org itself uses.
Decision pending Craig's confirm: absolute-default with opt-in inherit (above),
or model inheritance for the face families that have it out of the box.
* Custom color picker (proposal)
Craig wants a custom in-page color picker to replace the native browser swatch.
The native =<input type=color>= opens the OS color chooser, which the page
cannot size or restyle; a custom picker is the only way to get a larger,
on-theme picker and to show the palette/contrast in the picker itself.
Proposed widget — a popup anchored to the swatch, drawn in-page:
- A *saturation/value square* (click or drag to set S and V) plus a *hue
slider* down the side. Standard HSV picker geometry.
- A *hex field* synced both ways with the square/slider (already exists in the
add-color row; the picker writes to it).
- The current *palette* shown as clickable chips along the bottom, so picking
an existing color is one click and the overlap problem (many roles, one
color) is visible while choosing.
- A live *contrast readout* against the current background (ratio + AAA / AA /
FAIL) updating as the color moves, so a color is judged for legibility at
pick time, not after assignment.
- Sized generously (the native popup's size was the original complaint); opens
on click of the swatch, closes on pick or click-away.
Implementation: ~120 lines of vanilla JS/canvas (or CSS gradients) for the
square + slider, reusing the existing =rl()= / =contrast()= / =rating()=
helpers for the readout and =normHex()= for the field sync. No dependency. It
replaces the =<input type=color>= in the add-color row and, later, becomes the
picker the package-face dropdowns can also invoke.
Open question for Craig: build the custom picker as its own task before tier 3,
fold it into the tier-3 build, or after. It is independent of package faces, so
any order works.
* Files touched
- =scripts/theme-selector/generate.py= — the section, =APPS= data, the package
face table, =renderOrgPreview()=, export/import of =packages=.
- =scripts/theme-selector/theme-selector.html= — regenerated.
- (later) the =theme.json= -> =dupre-*.el= converter (Elisp) — consumes
=packages=.
* Review and iteration history
** 2026-06-07 Sun @ 18:17:14 -0500 — Claude Code (emacs-d) — author + responder
- *What:* Folded Craig's first-round cj-comment answers into the body. Curated
org set changed from ~18 to org's complete own-defface set (~88, grouped, incl.
org-agenda). v1 apps fixed to org/magit/elfeed with the rest deferred to a
one-at-a-time list. Generic fallback clarified as a fully editable table for
every package (only the rich preview is bespoke). Answered the inheritance
question with an optional =inherit= field (absolute-default, opt-in cascade)
and added it to the schema. Added a custom-color-picker proposal at Craig's
request. Converted Open questions to Agreed decisions; two genuine opens
remain (inheritance confirm, picker timing).
- *Why:* Craig answered the five open questions inline and asked for the
spec-response fold plus a custom-picker proposal. Completeness over iterative
growth was his explicit call on the curated set.
- *Artifacts:* This spec. The converter language (Elisp) confirmed by Craig.
Note: Craig referenced a Codex spec review, but no
=theme-selector-package-faces-review.org= (or any =*-review.org=) exists in the
repo — the review file could not be found, so only the author-side cj-comment
fold was done; the Codex review remains to be processed when its file is
located.
|