#+TITLE: theme-selector — package faces (tier 3), starting with org-mode #+AUTHOR: Craig Jennings #+DATE: 2026-06-07 * Status Spec / awaiting review. 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. org-mode is the first application. * 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 (org-mode first; magit, elfeed, dirvish, telega, marginalia, consult to follow). 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

package faces

[ application: (org-mode v) ]
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)
#+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}}}=, edited live, rendered into the table and the preview. * 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. For later apps, each gets its own preview renderer (magit -> a status buffer mock, elfeed -> a search-list mock). Until an app has a bespoke preview, it falls back to a generic "face name rendered in its own colors" list, so a new app is usable the moment its face list is added, and gets a real preview when one is written. * 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}, "org-todo": {"fg":"#cb6b4d","bg":null,"bold":true,"italic":false} } } } #+end_src 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, the org-mode face table, and the org preview. - Seed org's ~18 curated faces (above), not all 104 org-* faces (most are org-roam / superstar / agenda noise the core theme need not touch). - Wire export/import of the =packages= key. - Leave the converter for the separate build-step task; 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=. * Open questions 1. *Curated set size.* ~18 org faces proposed. Add =org-level-4..8=, quote, verse, drawer, footnote, priority? Or keep it tight and grow on demand? 2. *Bold/italic source.* org defaults carry weight (headings, todo). Seed those as the curated defaults, or start everything normal and let the user set weight? Proposed: seed the obvious ones (title, levels, todo, done bold). 3. *Inherit relationships.* Many org faces inherit (org-level-N from outline-N; org-code/verbatim from =shr= / =fixed-pitch=). Model inheritance, or set absolute values per face? Proposed: absolute values, simplest and matches how the converter writes them. 4. *App order after org.* magit (111 faces, highest payoff), elfeed, dirvish, marginalia, consult, telega — which next, and how many? 5. *Preview fidelity.* Bespoke per-app previews are the most work. Is the generic fallback acceptable for the long tail, with bespoke previews only for org and magit? * 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 — consumes =packages=.