diff options
Diffstat (limited to 'docs')
| -rw-r--r-- | docs/design/dupre-clear-theme.org | 89 | ||||
| -rw-r--r-- | docs/design/module-inventory.org | 2 | ||||
| -rw-r--r-- | docs/design/theme-studio-package-faces-spec.org | 586 |
3 files changed, 676 insertions, 1 deletions
diff --git a/docs/design/dupre-clear-theme.org b/docs/design/dupre-clear-theme.org new file mode 100644 index 00000000..3b88a7d0 --- /dev/null +++ b/docs/design/dupre-clear-theme.org @@ -0,0 +1,89 @@ +#+TITLE: dupre-clear — a contrast-first AAA sibling theme +#+AUTHOR: Craig Jennings +#+DATE: 2026-06-07 + +* Status + +Spec / not started. Working name *dupre-clear* (final name TBD — see Open Questions). Sibling to the in-progress *dupre revision* (see "Relationship" below). Linked from a task in =todo.org= under Emacs Open Work. + +* One-line concept + +Take dupre's color identity and rebuild it the way Prot built modus: *contrast-first*. Where the dupre revision optimizes for mood and depth (lands at WCAG AA), dupre-clear optimizes for legibility (targets WCAG AAA, ~7:1 on the ground) — the same soul, dialed for maximum clarity. + +* Motivation + +This came out of a long 2026-06-07 design session that produced the *dupre revision* (an elegant, AA-level theme — see the dupre-redesign entry in =.ai/session-context.org= for the full palette + mapping). Near the end we analyzed how Prot actually generated the modus palette, and the finding reframes the whole approach: + +- We built our palette *aesthetics-first*: pick a beautiful/deep/dusty color, accept whatever contrast falls out. Result: a rich palette that mostly lands at AA (4.5–6.5:1), with a couple of colors needing a nudge to clear AA at all. +- Prot built modus *contrast-first*: the ~7:1 AAA floor is the non-negotiable starting constraint; he then hand-picks the nicest color that clears it for each role. + +dupre-clear is the "what if we applied Prot's discipline to dupre's colors" theme. It's not a fix to the dupre revision — both are valid, they're just tuned for different priorities. Some people (and some lighting / monitor / eyesight conditions) want the maximally-legible version; this is that version, without abandoning dupre's character. + +* The Prot methodology (evidence, so we don't re-derive it) + +Pulled from =/usr/share/emacs/30.2/etc/themes/modus-vivendi-theme.el= on 2026-06-07. modus has 6 hue families (red, green, yellow, blue, magenta, cyan), each with base + =-warmer= / =-cooler= / =-faint= / =-intense=, plus bg/fg roles and a large semantic-mapping layer (~128 named colors, ~177 mappings). + +Key finding: *the variants are NOT algorithmically derived from the base.* If they were (e.g. warmer = hue rotate by N, faint = saturation × 0.5), the HSL numbers inside each family would move regularly. They don't: + +- "faint" is not a consistent saturation cut: =red-faint= is S100 (fully saturated, just lighter), =green-faint= is S38 (heavily desaturated), yellow/blue/magenta/cyan faint land at S48/74/47/53. +- "cooler" is not a consistent lightness move: =red-cooler= is lighter (L67→75), =green-cooler= is darker (L50→38). + +What IS systematic is *contrast*. Every modus-vivendi color clears roughly 7:1 on the =#000000= background (red family 7.0–9.9, green 8.5–11.9, cyan 11–14, the lowest being blue-intense at 6.5). So the invariant is the AAA contrast floor; the colors are individually hand-curated to (a) read as their named relationship and (b) clear the floor. The variant names are *descriptions of perceptual roles*, not outputs of a formula. + +Implication for dupre-clear: don't write an HSL-transform generator. Set the 7:1 floor, then hand-pick (or constraint-solve) the richest dupre-flavored color that clears it for each role. + +* Design principles for dupre-clear + +1. *Keep dupre's identity*: the warm near-black ground =#0d0b0a=, the warm-grey/metallic neutrals, and the hue families (the dupre blue, emerald, gold, terracotta, regal violet, mint). The HUES stay recognizably dupre; the brightness/saturation change to meet contrast. +2. *Contrast-first*: target ~7:1 AAA on the ground for all foreground syntax text. Comments may sit at AA-large (de-emphasis is intentional). Fills (navy, regal purple) are exempt — they carry light text, so their own ground-contrast is irrelevant. +3. *Accept the cost*: the deep/dusty choices from the dupre revision will have to brighten to reach AAA. dupre-clear is allowed to be more vivid than the revision — that's the point. Don't try to keep both depth and AAA on the same black; they pull opposite ways (proven repeatedly in the session). +4. *Same mapping, brighter values*: reuse the dupre revision's role assignments (below); only the color values move. +5. *Same modus two-layer structure*: a raw palette + a semantic-mapping layer, so it can retarget cleanly and read like a real systematized theme. + +* Starting point: the dupre revision palette + mapping (the AA version to brighten) + +These are the dupre-revision (AA) values as of 2026-06-07. dupre-clear keeps the roles, brightens the values to AAA. Ground =#0d0b0a=, default/fg silver =#d8d8d8=. + +| role | dupre revision (AA) | contrast | dupre-clear target | +|------+---------------------+----------+--------------------| +| keyword (BOLD) | blue #67809c | 4.8 | a dupre-blue bright enough to clear ~7:1 as bold text (note: a deep blue can't be AAA on near-black — may need to lighten meaningfully, or keep bold + accept ~AA for blue as the one exception, OR lift the ground; this is the hardest slot) | +| function | gold metallic #e8bd30 | 11.0 | already AAA — keep | +| type | regal violet #9b5fd0 | 4.6 | brighten toward ~7:1 (the L57→L66 sweep showed #ab79d8 ≈ 6.0; go a touch brighter for 7) | +| string | emerald dusty #2ba178 | 6.1 | brighten/saturate to ~7:1 (the vivid #1bb17d was 7.1) | +| constant/number | terracotta #cb6b4d | 5.4 | brighten toward 7:1 (toward the lighter terracotta #d19475 ≈ 7.7, or re-pick) | +| default / vars / punct | silver #d8d8d8 | 13.8 | already AAA — keep | +| comment | warm-dim #6f655a | 3.4 | intentionally recessive; AA-large is fine even in the clear theme | +| docstring | muted emerald #5d9b86 | 6.1 | brighten to ~7 | +| spare | mint #8dc4af | 10.0 | already AAA | + +Structural / fills (unchanged role): metallic greyscale ramp (gunmetal #2f343a → pewter #5e6770 → steel #838d97 → silver), navy fill #264364, regal-purple fill #562d76. Silver text on navy/regal both clear ~7:1. + +The hardest slot is *blue keywords*: a deep dupre blue (#67809c) is intrinsically sub-AAA on near-black (depth and AAA are mutually exclusive there — proven in-session). Options to decide at build time: (a) brighten the blue toward a lighter steel (loses depth), (b) keep blue bold at AA as the single deliberate exception (modus-vivendi itself has blue-intense at 6.5), or (c) lift the ground slightly so a deeper blue clears AAA (changes dupre's signature warm near-black). Worth a focused decision. + +* Build approach + +1. Decide whether dupre-clear is its own =themes/dupre-clear-*.el= (palette + faces + theme) or shares structure with the dupre revision. Likely its own files: a =dupre-clear-palette.el= + =dupre-clear-faces.el= + =dupre-clear-theme.el=, mirroring the dupre file layout. +2. Pick each color contrast-first per the table above; verify every foreground color clears the AAA floor with the WCAG helper. +3. Wire the same semantic mapping (keyword=blue bold, function=gold, type=violet, string=emerald, const=terracotta, comment=warm-grey, default=silver, structural=metallic/navy/regal). +4. TDD via a =tests/test-dupre-clear-theme.el=, including a WCAG-contrast assertion that every syntax face clears 7:1 (the inverse of the AA test the dupre revision gets — here it's a hard AAA gate). Reuse the contrast helper pattern from =tests/test-dupre-theme.el= (=dupre-test--contrast= etc.). +5. Live-reload + screenshot to verify per =emacs.md=. + +* Relationship to the dupre revision + +- *dupre revision* (in progress, 2026-06-07): the elegant-AA reinterpretation of the current dupre theme — mood/depth first. This is the one being built first. Full design in =.ai/session-context.org= (the dupre-redesign entry). +- *dupre-clear* (this spec): the contrast-first AAA sibling — legibility first, same hues brightened. +- They share the hue identity and the role mapping; they differ in brightness/saturation and in the contrast target (AA vs AAA). Build the revision first; dupre-clear reuses its hue choices as the starting point and brightens them. + +* Tooling + references (so this is resumable cold) + +- The session's exploration tooling was a set of throwaway =/tmp/gen-*.py= scripts that render palette + 4-language code samples to HTML and open them in a browser; they include WCAG-contrast and CIEDE2000 (perceptual distance) helpers. Those /tmp files won't survive a reboot — re-derive the helpers (WCAG: relative luminance with the sRGB linearization, contrast = (L1+0.05)/(L2+0.05); CIEDE2000 for separation). The math is also embedded in =tests/test-dupre-theme.el= (the WCAG half). +- modus reference palette: =/usr/share/emacs/30.2/etc/themes/modus-vivendi-theme.el= (and the operandi/tinted variants alongside it). +- dupre lineage: dupre ← distinguished (emacs, Kim Silkebaekken) ← vim-distinguished. The dupre palette lives in =themes/dupre-palette.el= + =themes/dupre-faces.el=; swatch PNG at =themes/dupre-palette.png=. +- The key perceptual lessons from the session (also in the anchor): thin colored text desaturates (muted hues grey out as glyphs — bold helps); a near-black ground forces depth-vs-AAA as a hard tradeoff; Hyprland inactive-window dimming silently shifts colors (disable with =hyprctl keyword decoration:dim_inactive false= during color work). + +* Open questions + +1. *Name.* "dupre-clear" is a working placeholder; Craig wants a different final name. Candidates to brainstorm: something in the distinguished/dupre lineage that signals "the legible/clear one." Decide at build start. +2. *Blue keywords at AAA.* The depth-vs-AAA conflict on the dupre blue (see the build-approach note) — pick (a) brighten, (b) keep AA as the deliberate exception, or (c) lift the ground. +3. *File sharing vs separate.* Whether dupre-clear shares any palette/faces machinery with the dupre revision or stands fully alone. +4. *Light variant?* Modus ships both vivendi (dark) and operandi (light). Out of scope for v1, but worth noting whether a dupre-clear-light is ever wanted. diff --git a/docs/design/module-inventory.org b/docs/design/module-inventory.org index 385bdbd5..2d4baf81 100644 --- a/docs/design/module-inventory.org +++ b/docs/design/module-inventory.org @@ -220,7 +220,7 @@ owns the intentional end-of-startup buffer-bury timer. | Module | Layer | Cat | Current | Target | Runtime requires | Top-level side effects | Direct load | |--------+-------+-----+---------+--------+------------------+------------------------+-------------| -| =linear-config= | 3 | D/P | eager | command | system-lib | package config | yes | +| =pearl-config= | 3 | D/P | eager | command | system-lib | package config | yes | | =local-repository= | 4 | O/D/P | eager | command | elpa-mirror | none | yes | | =lorem-optimum= | 4 | O/L | eager | command | cl-lib | none | yes | | =mail-config= | 3 | D/P | eager | command | user-constants, system-lib, mu4e-attachments, keybindings | cj/email-map under cj/custom-keymap, add-hook, 2 advice, 1 global key | yes | diff --git a/docs/design/theme-studio-package-faces-spec.org b/docs/design/theme-studio-package-faces-spec.org new file mode 100644 index 00000000..7f00b327 --- /dev/null +++ b/docs/design/theme-studio-package-faces-spec.org @@ -0,0 +1,586 @@ +#+TITLE: theme-studio — 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-studio (scripts/theme-studio/) that lets a theme colorize +package-specific faces, built one application at a time. v1 apps: org-mode +(incl. org-agenda), magit, elfeed. Codex review incorporated (2026-06-07): added +implementation phases, acceptance criteria, the package-face inventory source +(hybrid, split), and state/export semantics. Rubric now =Ready=. +All opens resolved (Craig, 2026-06-07/08): inheritance is modeled (show each +face's resolved color in the table + preview, override what looks bad); inventory +is hybrid-and-split (org/magit/elfeed bespoke first, generated all-package +inventory as a later phase); the custom color picker is built after tier 3. +Implementation tasks live in =todo.org=. + +* Background — the three tiers + +The theme-studio 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 face in the app's complete + set, each with a foreground dropdown, a background dropdown, bold / italic + toggles, an optional inherit, and a relative-height stepper, all drawing from + the same palette as the other tables. Grouped, with a text filter for the + large apps. +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, height, source}}}=, 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,"height":1.3}, + "org-level-2": {"fg":"#e8bd30","bg":null,"bold":false,"italic":false,"inherit":"org-level-1","height":1.2}, + "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= and + =height= fields). +- Leave the converter for the separate build-step task (Elisp, per Craig); the + spec only needs the schema to be right. + +* Implementation phases + +Phased so each step ships without a broken intermediate, and the three bespoke +apps don't wait on the all-package inventory. + +1. *State + schema.* Add =PKGMAP= + ({app:{face:{fg,bg,bold,italic,inherit,height,source}}}) and the =APPS= + registry. Extend export/import with the =packages= key; old JSON (no + =packages=) still imports cleanly. No UI yet. +2. *Curated app data.* Complete own-defface face lists + seeded defaults for org + (incl. org-agenda), magit, elfeed, in =APPS= — including heading heights and + the fixed-pitch inherits. Pure data. +3. *Package face table UI.* App selector; grouped rows; fg/bg dropdowns + bold / + italic toggles + optional inherit + a relative-height stepper; per-face and + per-app reset; a text filter (org/magit are large); a contrast readout per + fg/bg. Built on a generalized face-control helper shared with the ui-faces + table, not a fork of =uiSelect=. +4. *Org preview.* =renderOrgPreview()=, live, refreshing on palette/face change. +5. *Magit + elfeed previews.* Bespoke mocks (magit status buffer, elfeed search + list). +6. *Generated all-package inventory* (the "theme every package" path). A build + step queries Emacs for installed packages' faces grouped by package, writes a + data file =generate.py= embeds; the dropdown then lists every package with an + editable table + the generic fallback preview. Lands after phases 1-5 without + blocking the three bespoke apps. +7. *Docs + validation.* README =packages= schema + inventory-refresh command; + regenerate HTML; fixtures + manual checklist. + +Phases 1-5 deliver the three high-value apps fully; phase 6 opens the long tail; +phase 7 documents. + +* Package face inventory source + +*Hybrid, split across phases.* Curated app metadata (org/magit/elfeed: complete +face lists, seeded defaults, bespoke previews) is hand-maintained in =APPS= and +ships in phases 2-5. A *generated* =PACKAGE_FACE_INVENTORY= — produced by a build +step that asks the running Emacs for each installed package's faces grouped by +package, written to a JSON/Python data file =generate.py= embeds — supplies the +generic fallback packages and ships in phase 6. + +Why hybrid and split: the static generator can't discover packages at runtime in +the browser, so "theme every package" needs a generated inventory; but making the +full inventory a prerequisite for the three bespoke apps invites the scope +explosion the review flagged. Splitting it lets v1's core ship first; the +inventory is additive. + +The generated inventory is an *input artifact* to =generate.py= (a committed data +file refreshed by an explicit command), never browser-side discovery. The refresh +command's dependency on a loaded Emacs config is documented. + +Decided (Craig, 2026-06-08): hybrid-and-split, as above. + +* State and export policy + +Each package face object carries a =source= marker so export can tell a seeded +default from a user edit from a deliberate clear: + +#+begin_src js +{ fg:"#67809c", bg:null, bold:true, italic:false, underline:false, strike:false, inherit:null, height:1.0, source:"default" } +// underline / strike: booleans -> the converter writes :underline t / :strike-through t +// height: float multiplier off the base font (1.0 = unchanged); see Relative height +// source: "default" (seeded) | "user" (edited) | "cleared" (user removed a default) +#+end_src + +Export policy: + +- Write =default= and =user= entries. +- Write =cleared= entries — they must suppress a curated default on reload. +- Omit untouched faces that have no default. +- When =inherit= is set, write =inherit= plus only the explicit overrides. +- Write =height= only when it differs from 1.0. +- Preserve package faces present in an imported file but absent from the current + inventory (or warn) — don't silently drop them. + +Import tolerates a missing =packages= key, unknown app keys, unknown face keys, +a missing =inherit=, and a missing =height= (defaults 1.0). A deleted palette +color leaves package face references in the same "(gone)" recoverable state +syntax colors use. Inheritance cycles are rejected (treated as no inheritance) +during preview resolution. + +* Relative height + +Some faces want to be bigger than body text — org headings above all, also +=org-document-title=. A face's =height= field is a *float multiplier* off the +base font (=1.3= = 1.3× the running font, whatever it is), never an absolute +point size, so it stays portable across fonts and machines. =1.0= means +unchanged. The base monospace family is *not* a theme/tool concern — it lives in +=modules/font-config.el=; the tool owns only relative size. + +*Height does not cascade through =inherit=.* This is the one attribute resolved +directly off the face, not through its inherit chain. Emacs multiplies float +heights along an inherit chain, so a level-2 that inherits level-1 (1.3) and +also sets 1.1 would render at 1.43 — almost never what's wanted. Headings should +each size off the *body*, so the seeded defaults set =org-level-1= 1.3, +=org-level-2= 1.2, =org-level-3= 1.15, etc., each independent, and the tool reads +=height= from the face while still resolving *color* through inherit. + +- *Schema:* the =height= float on the face object (above), default 1.0, omitted + from export when 1.0. +- *UI:* a small numeric stepper in the face row (range ~0.8–2.0, step 0.05); + meaningful only for the size-bearing faces but shown on every row at 1.0. +- *Preview:* the row renders at the scaled =font-size= so a heading visibly + grows in the mock. +- *Converter:* writes =:height 1.3= into the face spec when ≠ 1.0. + +Related, same mechanism: org's mixed-pitch faces (=org-block=, =org-code=, +=org-verbatim=, =org-table=, =org-meta-line=, =org-date=) seed =inherit: +"fixed-pitch"= so they stay monospace when a buffer switches to a proportional +font via =variable-pitch-mode= / =mixed-pitch=. The proportional family itself +stays in =font-config.el= (the presets already carry =:variable-pitch-family=); +the tool only carries the fixed-pitch inherit relationship, shown like any other +inherited value. + +* Acceptance criteria + +- Existing =dupre.json= (no =packages= key) imports cleanly. +- Export includes =packages= once defaults or edits exist; + =fg/bg/bold/italic/inherit/height/source= round-trip through import/export. +- A face =height= renders as a scaled font-size in the preview (heading visibly + grows) and is read off the face, not cascaded through =inherit=. +- org, magit, elfeed appear in the app selector with complete grouped face tables. +- (phase 6) generic inventory packages appear with editable tables + fallback + previews, the fallback visibly labeled as generic. +- A palette color update propagates to package faces the same way it does to + syntax / ui faces. +- =python3 scripts/theme-studio/generate.py= rebuilds =theme-studio.html=. +- README documents the =packages= schema, inheritance, and the inventory source. + +* 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 (decided) + +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 + +*Decision (Craig, 2026-06-07): model inheritance, show the resolved result, +override what looks bad.* The point is to see what a face ends up looking like +when it inherits, judge it in the preview, and fix only the ones that look +wrong: + +- Each face's *effective* color is resolved through its inherit chain and shown + in its table row, visibly marked "inherited from <face>" so it reads as + not-explicitly-set. The face's own =fg/bg/bold/italic= are overrides layered + on top. +- The mock preview on the right renders every face with its effective color, so + inherited faces are judged in context, not in the abstract. +- Overriding is one action: assign a color (or toggle weight) and the row flips + from inherited to explicit (=source: "user"=), shown at once in the table and + preview. +- Export writes =:inherit PARENT= for faces left inherited (carrying the + relationship, so they follow the parent the theme also sets) and explicit + attributes for the ones overridden — never a frozen copy of an inherited + color. + +Seeded defaults express the inherit relationships org itself uses out of the box +(heading levels off a base, =org-agenda-date= variants off =org-agenda-date=, +=org-code= / =org-verbatim= off =fixed-pitch=), so the table opens showing +org's real cascade, which the user then tunes. Inheritance cycles resolve to no +inheritance. + +* 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. + +It stays *off* the tier-3 critical path: a separate task before or after the +package-face build, not folded into it, since folding it in widens the blast +radius for no dependency benefit. Build it only sooner if package-face editing +proves painful with the native swatch. + +Decided (Craig, 2026-06-08): after tier 3, as its own task. + +* Files touched + +- =scripts/theme-studio/generate.py= — the section, =APPS= data, the package + face table, =renderOrgPreview()=, export/import of =packages=. +- =scripts/theme-studio/theme-studio.html= — regenerated. +- (later) the =theme.json= -> =dupre-*.el= converter (Elisp) — consumes + =packages=. + +* Review dispositions + +Codex review (2026-06-07), =Not ready=. Findings processed: + +- *Modified — generated inventory (high, blocking).* Codex recommended a hybrid + inventory so every installed package is reachable. Accepted the hybrid, but + *split* it: the generated all-package inventory is its own phase (6), after the + three bespoke apps (phases 1-5), rather than a v1 prerequisite. Reason: Codex + named scope explosion as the main risk, and gating org/magit/elfeed on a + full-inventory mechanism is exactly that. The split keeps v1's core shippable + and makes "theme every package" additive. Confirm-with-Craig flagged as an + open. +- *Modified — preview depth (UX obs).* Codex suggested level 4-8 examples in the + org preview. The preview stays a curated document drawing the prominent faces + (incl. a couple of deeper levels as representative); the complete level set + lives in the *table*, which is where every face is assignable. A full 8-level + preview block would bloat the mock without adding assignability. + +Everything else in the review accepted as written: implementation phases, +acceptance criteria, the =source= state field + export policy, curated-vs-complete +wording, keeping the custom picker off the critical path, unknown-import +preservation, the test-strategy fixtures, and the UX/architecture/robustness +observations (grouping + filter, reset controls, package-fg/bg contrast readout, +generalized face-control helper, package style kept inside the package object, +"(gone)" recoverable state, inheritance-cycle rejection). + +* 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-studio-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. + +** 2026-06-07 Sun @ 18:20:16 -0500 — Codex — reviewer +- *What changed or was recommended:* Created the missing formal review and + marked the spec =Not ready=. The review recommends adding implementation + phases, acceptance criteria, an explicit package-face inventory source, and + default/user/cleared/inherited export semantics. +- *Why:* The direction is coherent and the first-round decisions are folded in, + but v1 now depends on behavior that is not yet implementable from the current + static generator without a defined inventory and state/export contract. +- *Artifacts:* [[file:theme-studio-package-faces-spec-review.org][theme-studio-package-faces-spec-review.org]] + +** 2026-06-07 Sun @ 18:28:02 -0500 — Claude Code (emacs-d) — responder +- *What:* Ran spec-response against the Codex review. Added Implementation + phases (7), Package face inventory source (hybrid, split), State and export + policy (=source= field + export rules), and Acceptance criteria. Wove in the + UX/architecture/robustness observations. Two reasoned modifies (generated + inventory split into its own phase; org preview stays curated rather than + all-8-levels), everything else accepted — see Review dispositions. Status + moved to =Ready with caveats=. Deleted the review file. +- *Why:* The four blocking findings were real implementation-contract gaps; the + inventory split answers Codex's own scope-explosion warning while still + reaching "theme every package." +- *Artifacts:* This spec (Review dispositions section); review file deleted per + the spec-response close-out. Three opens remain for Craig (inheritance confirm, + hybrid-inventory confirm, picker timing). + +** 2026-06-08 Mon @ 00:12:38 -0500 — Codex — reviewer +- *What changed or was recommended:* Reran spec-review after the response pass. + Assigned rubric =Ready= and did not create a new review file. The previous + blockers are now addressed: implementation phases, acceptance criteria, + hybrid/split inventory source, package-face state/export semantics, task + tracking, and the open inheritance/inventory/picker decisions are resolved. +- *Why:* The spec now gives an implementer concrete behavior, phase boundaries, + validation criteria, and deferred-work handling without forcing product + decisions during implementation. +- *Artifacts:* This spec; implementation tasks in [[file:../../todo.org][todo.org]]. + +** 2026-06-08 Mon @ 00:38:23 -0500 — Claude Code (emacs-d) — author +- *What:* Added a relative =height= field to the face schema (float multiplier + off the base font, default 1.0, omitted at 1.0), a new "Relative height" + section, a per-face stepper in the table, preview scaling, and converter + output. Established the rule that =height= is read off the face and does *not* + cascade through =inherit= (Emacs multiplies float heights along the chain). + Noted the mixed-pitch =fixed-pitch= inherits as the same-mechanism companion. + Brought Phase 1's shipped schema plumbing in line with the new field. +- *Why:* Craig asked to fold height in — it matters for org headings above all. + Font *family* stays in =modules/font-config.el=; the theme owns relative size + and the fixed-pitch inherit relationships only. +- *Artifacts:* This spec; =scripts/theme-studio/generate.py= phase-1 plumbing. |
