diff options
| author | Craig Jennings <c@cjennings.net> | 2026-07-04 18:08:01 -0500 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-07-04 18:08:01 -0500 |
| commit | f407959b4c30fa809722b62266640711c916a772 (patch) | |
| tree | a94d5285ac7e656ccc30d6a20b0be23fbbd205e1 /scripts/theme-studio/theme-studio.html | |
| parent | 389a4005b48d186fe4956f0455605b6fdb1dbb65 (diff) | |
| download | dotemacs-f407959b4c30fa809722b62266640711c916a772.tar.gz dotemacs-f407959b4c30fa809722b62266640711c916a772.zip | |
feat(theme-studio): add seeding-engine model and pure seed() (phase 1)
I turned the coloring guide's role/seed table into an executable engine. A new pure module, seed-core.js, holds the seed model as data: a named palette whose accent shades are OKLCH-generated from the dupre anchors (reusing the colormath core), the role-to-treatment table, and a face-to-role map per owned tier, plus a pure seed() that projects the table onto syntax, UI, and org faces.
seed() owns three default sources: syntax, UI, and org among packages. It returns packages.org-mode only, so the roughly twenty non-org bespoke packages keep their curated APPS seeds untouched. The output already matches the shape the import path consumes, so phase 2 can wire it to open-seeded without a new format.
Builtins land on blue-grey and calls on a quieter gold, the two shades dupre lacked. Definitions come out gold and bold, state faces tint the background with no foreground, links underline, and the org heading ramp descends in lightness with level 1 strongest.
The module inlines into the page below the colormath core like the other pure cores, so the browser runs the code the Node tests import. #seedtest asserts the representative faces resolve correctly and that magit keeps its curated seed. test-seed-core.mjs covers the model, each tier, and seed()'s purity.
Diffstat (limited to 'scripts/theme-studio/theme-studio.html')
| -rw-r--r-- | scripts/theme-studio/theme-studio.html | 279 |
1 files changed, 279 insertions, 0 deletions
diff --git a/scripts/theme-studio/theme-studio.html b/scripts/theme-studio/theme-studio.html index 18f3a540..43c1af12 100644 --- a/scripts/theme-studio/theme-studio.html +++ b/scripts/theme-studio/theme-studio.html @@ -1355,6 +1355,254 @@ function contrastTitle(r){ if(r>=4.5) return n+' (grey): passes WCAG AA, not AAA'; return n+' (red): fails WCAG AA'; } +// The seeding engine (seed model + pure seed()), inlined from seed-core.js. Uses +// oklchOf/oklch2hex from the colormath core above; the #seedtest gate runs seed(). +// seed-core.js — the theme-studio seeding engine (Phase 1). +// +// The seed model as data and the pure seed() operation. This is +// theme-coloring-guide.org made executable: a named palette (OKLCH-generated +// shades over a handful of dupre anchor hues), a role-to-treatment table (the +// guide's seed table), and a face-to-role map for each of the three owned tiers +// (syntax, UI, org). seed(model) classifies every face and applies the table, +// producing default assignments in the shape the import path already consumes. +// +// Pure: no DOM, no side effects. node imports this module for its unit tests; +// generate.py strips the import line and inlines the body into the page (below +// the colormath core, so oklchOf/oklch2hex are already present) so the browser +// #seedtest runs the same code. One source of truth, like colormath.js. +// +// Scope (Package scope in the spec): seed() owns syntax, UI, and org among +// packages. The other ~20 bespoke packages keep their curated APPS seeds, so +// seed().packages carries only org-mode; the rest flow through seedPkgmap(). + +// --- anchors ------------------------------------------------------------- +// The base hues, taken from the bundled dupre palette. Each accent family is +// anchored here; its quieter/brighter shades are OKLCH-derived (below). Neutrals +// are taken directly — no ground is pure-white text, and pure black stays the +// ground only (guide principle 5). +const ANCHORS = { + ground: '#000000', 'bg-dim': '#1a1714', + fg: '#a9b2bb', // base identity (dupre silver): comfortable, not pure white + 'muted-fg': '#838d97', // structure lane (dupre steel) + comment: '#5e6770', // low-contrast comment lane (dupre pewter) + blue: '#67809c', gold: '#e8bd30', regal: '#9b5fd0', + sage: '#5d9b86', terracotta: '#cb6b4d', +}; + +// --- OKLCH shade helpers ------------------------------------------------- +// Step an anchor by a lightness delta and a chroma multiplier, hue held. A +// quieter shade is darker + lower chroma; a brighter shade is the reverse. +function shade(hex, dL, cMul) { + const { L, C, H } = oklchOf(hex); + return oklch2hex(clampL(L + dL), Math.max(0, C * cMul), H).hex; +} +// A color placed by absolute OKLCH — used for the signal hues, which are +// conventional angles rather than shifts of a syntax accent. +function atHue(L, C, H) { return oklch2hex(clampL(L), C, ((H % 360) + 360) % 360).hex; } +function clampL(L) { return L < 0 ? 0 : L > 1 ? 1 : L; } + +// The heading ramp: one hue across four descending lightness steps (level 1 +// strongest). Deeper org levels cycle through these past level 4. +function headingRamp(anchorHex) { + const { C, H } = oklchOf(anchorHex); + return [0.78, 0.68, 0.58, 0.48].map((L) => oklch2hex(L, C, H).hex); +} + +// Build the named swatch set + heading ramp from the anchors. The blue-grey +// builtin and gold-quiet call are the two shades dupre lacks and gains here. +function buildModel(anchors = ANCHORS) { + const a = anchors; + const swatch = { + ground: a.ground, 'bg-dim': a['bg-dim'], fg: a.fg, + 'muted-fg': a['muted-fg'], comment: a.comment, + blue: a.blue, + 'blue-grey': shade(a.blue, -0.05, 0.5), // builtin: blue hue, lower chroma/lightness + gold: a.gold, + 'gold-quiet': shade(a.gold, -0.08, 0.6), // call: quieter same-hue gold + regal: a.regal, + sage: a.sage, + 'sage-muted': shade(a.sage, -0.03, 0.6), // docstring + 'sage-bright': shade(a.sage, 0.08, 1.15), // escape + teal: (() => { const { L, C, H } = oklchOf(a.sage); return oklch2hex(clampL(L + 0.05), C * 1.1, H - 25).hex; })(), // regexp + terracotta: a.terracotta, + red: atHue(0.62, 0.15, 29), // signal: error / deletion + amber: atHue(0.80, 0.14, 75), // signal: warning / modified + green: atHue(0.72, 0.14, 145),// signal: success / addition + tint: atHue(0.32, 0.03, oklchOf(a.blue).H), // transient state bg (quiet) + 'tint-strong': atHue(0.42, 0.06, oklchOf(a.blue).H), // active match chip + }; + return { swatch, ramp: headingRamp(a.blue), roles: ROLES }; +} + +// --- the role-to-treatment table (the guide's seed table as data) -------- +// Each role maps to a swatch, an optional weight/slant/underline, and a channel +// (fg is identity, bg is state). channel defaults to fg. +const ROLES = { + base: { swatch: 'fg' }, + structure: { swatch: 'muted-fg' }, + control: { swatch: 'blue', weight: 'bold' }, + builtin: { swatch: 'blue-grey' }, + def: { swatch: 'gold', weight: 'bold' }, + call: { swatch: 'gold-quiet' }, + type: { swatch: 'regal' }, + string: { swatch: 'sage' }, + docstring: { swatch: 'sage-muted', slant: 'italic' }, + escape: { swatch: 'sage-bright' }, + regexp: { swatch: 'teal' }, + literal: { swatch: 'terracotta' }, + comment: { swatch: 'comment', slant: 'italic' }, + sig_error: { swatch: 'red' }, + sig_warn: { swatch: 'amber' }, + sig_ok: { swatch: 'green' }, + sig_link: { swatch: 'blue', underline: true }, + state: { swatch: 'tint', channel: 'bg' }, +}; + +// A blank full face spec; seed fills only the fields a role sets. +function blankSpec() { + return { fg: null, bg: null, weight: null, slant: null, underline: null, strike: null, inherit: null, height: null }; +} + +// Resolve a ROLES role against the model into a face spec. +function resolveRole(model, role) { + const r = model.roles[role]; + const hex = model.swatch[r.swatch]; + const s = blankSpec(); + if (r.channel === 'bg') s.bg = hex; else s.fg = hex; + if (r.weight) s.weight = r.weight; + if (r.slant) s.slant = r.slant; + if (r.underline) s.underline = { style: 'line', color: null }; + return s; +} + +// --- face-to-role maps --------------------------------------------------- + +// Syntax: CATS key -> role. bg is handled specially (the ground). +const SYNTAX_ROLES = { + p: 'base', var: 'base', + op: 'structure', punc: 'structure', neg: 'structure', cmd: 'structure', + kw: 'control', pp: 'control', + bi: 'builtin', + fnd: 'def', fnc: 'call', + dec: 'type', ty: 'type', prop: 'type', + con: 'literal', num: 'literal', + str: 'string', doc: 'docstring', + esc: 'escape', dmark: 'escape', + re: 'regexp', rxgb: 'regexp', rxgc: 'regexp', + cm: 'comment', + warn: 'sig_warn', +}; + +// UI: face -> either a ROLES role (state/signal/link/control) or an inline +// chrome spec. Chrome is inherently multi-attribute (fg + bg, active vs idle), +// so it does not force through the single-swatch role resolver. +function uiSeed(model) { + const sw = model.swatch, out = {}; + const role = (r) => resolveRole(model, r); + const spec = (o) => Object.assign(blankSpec(), o); + // Transient state: background tint, no foreground. lazy-highlight (other + // matches) shares the quiet tint; isearch (current match) gets a louder chip. + for (const f of ['region', 'hl-line', 'highlight', 'show-paren-match', 'lazy-highlight']) out[f] = role('state'); + out.isearch = spec({ bg: sw['tint-strong'] }); // active match, louder chip + // Signals (convention hues) with a weight for redundancy. + out.error = spec({ fg: sw.red, weight: 'bold' }); + out.warning = spec({ fg: sw.amber, weight: 'bold' }); + out.success = spec({ fg: sw.green, weight: 'bold' }); + out['isearch-fail'] = spec({ fg: sw.red, weight: 'bold' }); + out['show-paren-mismatch'] = spec({ bg: sw.red }); // shape + color, not color alone + out.link = role('sig_link'); + // Chrome: active brighter than idle (guide principle 3). + out['mode-line'] = spec({ fg: sw.fg, bg: sw['bg-dim'] }); + out['mode-line-inactive'] = spec({ fg: sw['muted-fg'], bg: sw['bg-dim'] }); + out['mode-line-highlight'] = spec({ fg: sw.fg }); + for (const f of ['header-line', 'tab-bar', 'tab-line']) out[f] = spec({ fg: sw['muted-fg'], bg: sw['bg-dim'] }); + out['line-number'] = spec({ fg: sw.comment }); + out['line-number-current-line'] = spec({ fg: sw.fg }); + out.fringe = spec({ fg: sw.comment }); + out['vertical-border'] = spec({ fg: sw['bg-dim'] }); + out['minibuffer-prompt'] = role('control'); + out.cursor = spec({ bg: sw.fg }); + return out; +} + +// Org: face -> role/heading/inline. Faces not named here seed to base. +const ORG_MARKUP = ['org-meta-line', 'org-drawer', 'org-special-keyword', 'org-property-value', + 'org-block-begin-line', 'org-block-end-line', 'org-ellipsis', 'org-tag', 'org-date', + 'org-document-info-keyword', 'org-macro', 'org-target', 'org-footnote-def']; +const ORG_CODE = ['org-code', 'org-verbatim', 'org-inline-src-block']; +const ORG_LINK = ['org-link', 'org-cite', 'org-cite-key', 'org-footnote']; +const ORG_EMPHASIS = ['org-quote', 'org-verse']; + +function orgSeed(model, orgFaces) { + const sw = model.swatch, out = {}; + const role = (r) => resolveRole(model, r); + const spec = (o) => Object.assign(blankSpec(), o); + for (const face of orgFaces) { + const lvl = /^org-level-([1-8])$/.exec(face); + if (lvl) { + const i = Number(lvl[1]); + out[face] = spec({ fg: model.ramp[(i - 1) % model.ramp.length], weight: i === 1 ? 'bold' : null }); + } else if (face === 'org-document-title') { + out[face] = spec({ fg: sw.gold, weight: 'bold' }); + } else if (ORG_CODE.includes(face)) { + out[face] = spec({ fg: sw.terracotta, inherit: 'fixed-pitch' }); + } else if (face === 'org-block') { + out[face] = spec({ bg: sw['bg-dim'], inherit: 'fixed-pitch' }); + } else if (ORG_LINK.includes(face)) { + out[face] = spec({ fg: sw.blue, underline: { style: 'line', color: null } }); + } else if (ORG_MARKUP.includes(face)) { + out[face] = spec({ fg: sw['muted-fg'] }); + } else if (face === 'org-todo' || face === 'org-imminent-deadline') { + out[face] = spec({ fg: sw.red, weight: 'bold' }); + } else if (face === 'org-upcoming-deadline') { + out[face] = spec({ fg: sw.amber }); + } else if (face === 'org-scheduled' || face === 'org-scheduled-today') { + out[face] = spec({ fg: sw.comment }); + } else if (face === 'org-done' || face === 'org-headline-done' || face === 'org-agenda-done') { + out[face] = spec({ fg: sw.comment, strike: { color: null } }); + } else if (ORG_EMPHASIS.includes(face)) { + out[face] = spec({ slant: 'italic' }); + } else { + out[face] = role('base'); + } + } + return out; +} + +// The org faces the engine seeds. Kept in step with ORG_FACES in face_data.py; +// a face present here but absent there (or the reverse) simply seeds/omits it. +// The representative set the guide names is what matters for the tier. +const ORG_FACES = ('org-document-title org-document-info org-document-info-keyword ' + + 'org-level-1 org-level-2 org-level-3 org-level-4 org-level-5 org-level-6 org-level-7 org-level-8 ' + + 'org-headline-done org-todo org-done org-priority org-tag org-special-keyword org-drawer ' + + 'org-property-value org-warning org-link org-cite org-cite-key org-footnote org-date ' + + 'org-macro org-target org-block org-block-begin-line org-block-end-line org-code org-verbatim ' + + 'org-inline-src-block org-quote org-verse org-meta-line org-ellipsis ' + + 'org-scheduled org-scheduled-today org-upcoming-deadline org-imminent-deadline ' + + 'org-agenda-done org-table org-formula').split(' '); + +// --- seed(): apply the table through each tier's face-to-role map -------- +// Returns {syntax, ui, packages} default assignments. packages carries only +// org-mode (Package scope); the non-org curated defaults flow through +// seedPkgmap() over the APPS dicts, untouched by the engine. +function seed(model, opts = {}) { + const cats = opts.cats || CATS_KEYS; + const orgFaces = opts.orgFaces || ORG_FACES; + const syntax = {}; + for (const k of cats) { + if (k === 'bg') { syntax.bg = Object.assign(blankSpec(), { fg: model.swatch.ground }); continue; } + const rname = SYNTAX_ROLES[k] || 'base'; + syntax[k] = resolveRole(model, rname); + } + return { syntax, ui: uiSeed(model), packages: { 'org-mode': orgSeed(model, orgFaces) } }; +} + +// The syntax categories the engine seeds, kept in step with CATS in generate.py. +// Passed explicitly by the page (opts.cats) from the live CATS so the two never +// drift; this literal is the standalone/test default. +const CATS_KEYS = ['bg', 'p', 'kw', 'bi', 'pp', 'fnd', 'fnc', 'dec', 'ty', 'prop', + 'con', 'num', 'str', 'esc', 're', 'rxgb', 'rxgc', 'doc', 'dmark', 'cm', 'cmd', + 'var', 'op', 'neg', 'punc', 'warn']; // Pure palette-generator planner and browser-side generator panel. // Pure palette-generator planner. It depends on the shared palette-column model // from app-core.js, but owns candidate hue selection, naming, contrast filtering, @@ -4241,6 +4489,37 @@ function pkgSelftest(){ const d=document.createElement('div');d.id='selftest';d.textContent='SELFTEST '+verdict+' roundtrip='+roundtrip+' oldjson='+oldjson+' inherit='+inherited+' height='+height+' cleared='+cleared+' unknown='+unknown+' cycle='+cyc;document.body.appendChild(d); } if(location.hash==='#selftest')pkgSelftest(); +// Seeding-engine gate (open with #seedtest): the pure seed() projects the guide's +// role table onto the three owned tiers. Assert representative faces land on the +// right swatch/weight/channel, and that a non-org bespoke package (magit) keeps +// its curated APPS seed (seed() owns org among packages, nothing else). +if(location.hash==='#seedtest')gate('seedtest',A=>{ + const m=buildModel(); + const s=seed(m,{cats:CATS.map(c=>c[0])}); + // syntax tier + A(s.syntax.bi.fg===m.swatch['blue-grey'],'bi=blue-grey'); + A(s.syntax.fnd.fg===m.swatch.gold&&s.syntax.fnd.weight==='bold','fnd=gold+bold'); + A(s.syntax.fnc.fg===m.swatch['gold-quiet']&&s.syntax.fnc.weight!=='bold','fnc=gold-quiet'); + A(s.syntax.var.fg===m.swatch.fg,'var=base'); + A(s.syntax.op.fg===m.swatch['muted-fg']&&s.syntax.punc.fg===m.swatch['muted-fg'],'op/punc=structure'); + A(s.syntax.kw.fg===m.swatch.blue&&s.syntax.kw.weight==='bold','kw=control'); + A(s.syntax.doc.slant==='italic','doc=italic'); + A(s.syntax.bg.fg===m.swatch.ground,'bg=ground'); + // UI tier + A(!!s.ui.region.bg&&!s.ui.region.fg,'region bg-only'); + A(!!s.ui.link.underline&&s.ui.link.fg===m.swatch.blue,'link underlined'); + A(s.ui.error.fg===m.swatch.red&&s.ui.warning.fg===m.swatch.amber&&s.ui.success.fg===m.swatch.green,'signals on convention hues'); + A(s.ui['mode-line'].fg!==s.ui['mode-line-inactive'].fg,'active!=idle chrome'); + // org tier + const org=s.packages['org-mode']; + A(Object.keys(s.packages).length===1,'packages=org-mode only'); + A(oklchOf(org['org-level-1'].fg).L>oklchOf(org['org-level-2'].fg).L&&org['org-level-1'].weight==='bold','org-level-1 strongest+bold'); + A(org['org-code'].fg===m.swatch.terracotta&&org['org-code'].inherit==='fixed-pitch','org-code literal lane'); + A(!!org['org-done'].strike,'org-done struck'); + // non-org bespoke package keeps its curated seed (untouched by seed()) + const mag=seedPkgmap()['magit']; + A(!!mag&&!!mag['magit-section-heading']&&!!mag['magit-section-heading'].fg,'magit keeps curated seed'); +}); // Lock-mechanism gate (open with #locktest): two behaviors the refactor must // preserve, across all three tiers. (1) Locking a row disables its controls via // the shared mkLockCell. (2) reset/erase batch actions update editable rows but |
