diff options
Diffstat (limited to 'scripts')
| -rwxr-xr-x | scripts/agenda-render-cache | 50 | ||||
| -rwxr-xr-x | scripts/bootstrap-packages.sh | 133 | ||||
| -rwxr-xr-x | scripts/calendar-sync-run | 63 | ||||
| -rwxr-xr-x | scripts/remote-repository-reset.sh | 19 | ||||
| -rw-r--r-- | scripts/theme-studio/app.js | 3 | ||||
| -rw-r--r-- | scripts/theme-studio/browser-gates.js | 31 | ||||
| -rw-r--r-- | scripts/theme-studio/generate.py | 6 | ||||
| -rw-r--r-- | scripts/theme-studio/seed-core.js | 249 | ||||
| -rw-r--r-- | scripts/theme-studio/test-seed-core.mjs | 173 | ||||
| -rw-r--r-- | scripts/theme-studio/theme-studio.html | 279 |
10 files changed, 987 insertions, 19 deletions
diff --git a/scripts/agenda-render-cache b/scripts/agenda-render-cache new file mode 100755 index 00000000..b1e4d490 --- /dev/null +++ b/scripts/agenda-render-cache @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +# Write the agenda render cache for an external renderer. +# +# Runs a batch Emacs rather than talking to the daemon, because the surface +# that reads the cache has to keep working while Emacs is down -- and +# emacsclient is exactly the thing that cannot. Nothing here touches a running +# Emacs, so it is safe to fire from a timer alongside an active session. +# +# The agenda file list is resolved the same way the editor resolves it, by +# loading the config's own resolver rather than restating the list here. A +# second copy of that list would drift the first time a calendar source is +# added. +# +# Usage: agenda-render-cache +# Env: EMACS_D -- config directory (default ~/.emacs.d) +# EMACS -- emacs binary (default emacs) +# AGENDA_RENDER_FILES -- colon-separated org files to read instead of +# the configured agenda list. Lets a caller ask +# about a known set, and lets the tests run +# against a fixture rather than whatever happens +# to be on the machine's real agenda today. + +set -euo pipefail + +EMACS_D="${EMACS_D:-$HOME/.emacs.d}" +EMACS="${EMACS:-emacs}" + +if [ ! -d "$EMACS_D/modules" ]; then + echo "agenda-render-cache: no modules directory at $EMACS_D/modules" >&2 + exit 1 +fi + +# -Q keeps the daemon's init out of it: this needs three modules, not a full +# editor. load-prefer-newer stops a stale .elc from answering for changed +# source, which would silently write yesterday's logic. +exec "$EMACS" --batch -Q \ + --eval '(setq load-prefer-newer t)' \ + -L "$EMACS_D/modules" \ + --eval '(progn + (require (quote user-constants)) + (require (quote org-agenda-config)) + (setq org-todo-keywords cj/org-todo-keywords) + (require (quote agenda-query)) + (setq org-agenda-files + (let ((override (getenv "AGENDA_RENDER_FILES"))) + (if (and override (not (string-empty-p override))) + (split-string override ":" t) + (cj/--org-agenda-scan-files)))) + (princ (cj/agenda-render-cache-update)) + (terpri))' diff --git a/scripts/bootstrap-packages.sh b/scripts/bootstrap-packages.sh new file mode 100755 index 00000000..9ba9fc69 --- /dev/null +++ b/scripts/bootstrap-packages.sh @@ -0,0 +1,133 @@ +#!/usr/bin/env bash +# +# Install every package this config asks for, headlessly, before first launch. +# +# On a fresh machine init.el pulls ~190 packages over the network one at a +# time. A single dead download used to abort startup outright; +# modules/package-resilience.el now records the failure and lets init finish, +# and this script is what turns that into a completed install: load init.el in +# batch, retry whatever is still missing, and repeat while progress is being +# made. Doing it here rather than in a GUI session means a fresh install never +# meets the debugger. +# +# Usage: scripts/bootstrap-packages.sh +# Exit: 0 when every package is installed, non-zero otherwise. +# +# Environment: +# EMACS emacs binary to use (default: emacs) +# BOOTSTRAP_PASSES maximum passes over the set (default: 4) +# BOOTSTRAP_TIMEOUT seconds allowed per pass (default: 1800) +# +# Note: a pass loads the whole config in batch, so every :config block runs +# headlessly. stdin is closed and each pass is bounded by a timeout so a +# prompt or a hung network fetch fails the pass instead of stalling forever. + +set -uo pipefail + +emacs_bin="${EMACS:-emacs}" +max_passes="${BOOTSTRAP_PASSES:-4}" +pass_timeout="${BOOTSTRAP_TIMEOUT:-1800}" + +emacs_dir="${BOOTSTRAP_DIR:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}" +log_dir="$(mktemp -d -t emacs-bootstrap-XXXXXX)" + +cleanup() { rm -rf "$log_dir"; } +trap cleanup EXIT + +# --batch implies -q, which skips early-init.el. That file is where the package +# archives, use-package-always-ensure, and package-resilience all live, so a +# pass that loaded only init.el would install almost nothing and would not even +# have cj/package-bootstrap-batch defined. Load both, in the order a real +# startup does. user-emacs-directory is set first so the pass bootstraps the +# checkout this script lives in rather than whatever $HOME/.emacs.d happens to +# be. +load_form="$(cat <<EOF +(progn + (setq load-prefer-newer t) + (setq user-emacs-directory "${emacs_dir}/") + (setq package-user-dir (expand-file-name "elpa" user-emacs-directory)) + (load (expand-file-name "early-init.el" user-emacs-directory) nil t) + (load (expand-file-name "init.el" user-emacs-directory) nil t) + (cj/package-bootstrap-batch)) +EOF +)" + +echo "bootstrap: installing packages for $emacs_dir" +echo "bootstrap: up to $max_passes passes, ${pass_timeout}s each" + +# use-package calls its ensure function at macro-expansion time when a file is +# being byte-compiled, and emits no runtime call at all. So a pass that loads +# .elc files installs nothing and would still exit 0 -- a false pass, the same +# shape as every other gate in this repo that was green because it never ran. +# Refuse rather than warn: there is no use for a bootstrap that cannot install, +# and a warning above a success line is read as a success. A genuinely fresh +# machine has no .elc and never sees this. +# Every directory the config puts on its load-path, not just modules/, since a +# use-package form anywhere in them would be consumed the same way. +if compgen -G "$emacs_dir/modules/*.elc" >/dev/null 2>&1 \ + || compgen -G "$emacs_dir/custom/*.elc" >/dev/null 2>&1 \ + || compgen -G "$emacs_dir/assets/*.elc" >/dev/null 2>&1 \ + || compgen -G "$emacs_dir/*.elc" >/dev/null 2>&1; then + echo "bootstrap: REFUSING - byte-compiled modules are present." >&2 + echo "bootstrap: use-package consumes :ensure at compile time, so a pass over" >&2 + echo "bootstrap: .elc files installs nothing and would report success anyway." >&2 + echo "bootstrap: run 'make clean-compiled' first, then bootstrap." >&2 + exit 2 +fi + +pass=1 +passes_run=0 +status=1 +while [ "$pass" -le "$max_passes" ]; do + log="$log_dir/pass-$pass.log" + echo "bootstrap: pass $pass of $max_passes ..." + + timeout "$pass_timeout" "$emacs_bin" --batch \ + --eval "$load_form" </dev/null >"$log" 2>&1 + status=$? + passes_run=$((passes_run + 1)) + + case "$status" in + 0) + echo "bootstrap: every package is installed (pass $pass)" + break + ;; + 1) + # Exit 1 is only meaningful when the pass actually said what is + # missing. Anything else exiting 1 is a different failure, and + # retrying it four times then blaming packages would be a lie. + if grep -E '^package-bootstrap: [0-9]+ missing:' "$log"; then + : # another pass can clear them; installing one unblocks others + else + echo "bootstrap: pass $pass exited 1 without reporting missing packages" >&2 + tail -30 "$log" >&2 + break + fi + ;; + 124) + echo "bootstrap: pass $pass hit the ${pass_timeout}s timeout" >&2 + tail -20 "$log" >&2 + ;; + *) + # init itself failed for some reason other than a missing package. + echo "bootstrap: pass $pass failed to load init (exit $status)" >&2 + tail -30 "$log" >&2 + break + ;; + esac + + pass=$((pass + 1)) +done + +if [ "$status" -ne 0 ]; then + echo "bootstrap: FAILED after $passes_run pass(es)" >&2 + # No pass ran at all when the ceiling is zero, and the glob would then match + # nothing and print a tail error over the real message. + if [ "$passes_run" -gt 0 ]; then + echo "bootstrap: tail of the last pass follows" >&2 + tail -30 "$log" >&2 + fi + exit "$status" +fi + +exit 0 diff --git a/scripts/calendar-sync-run b/scripts/calendar-sync-run new file mode 100755 index 00000000..12181b8d --- /dev/null +++ b/scripts/calendar-sync-run @@ -0,0 +1,63 @@ +#!/usr/bin/env bash +# Sync every configured calendar from its .ics feed, once, and wait for it. +# +# Runs a batch Emacs rather than talking to the daemon, for the same reason +# agenda-render-cache does: the org files this writes feed the agenda, waybar +# and the projected wallpaper, and none of those should go stale because the +# editor happens to be down. Nothing here touches a running Emacs, so it is +# safe to fire from a timer alongside an active session. +# +# Why a script at all: calendar-sync used to start its hourly timer from +# `org-agenda-mode-hook', so a session where the agenda was never opened never +# synced at all. That is not a rare corner -- it is every reboot until the +# first agenda call. The timer owns the schedule now, and the editor's own +# auto-start is off. +# +# The exit code is the point. `calendar-sync-batch-run-and-report' waits for +# every calendar to leave the syncing state and returns non-zero if any did +# not land, so a failed fetch shows up in `systemctl --user status' instead of +# being swallowed. +# +# Usage: calendar-sync-run +# Env: EMACS_D -- config directory (default ~/.emacs.d) +# EMACS -- emacs binary (default emacs) +# CALENDAR_SYNC_CONFIG -- private config file holding the calendar +# list, instead of the configured default. +# CALENDAR_SYNC_STATE -- sync-state file, instead of the configured +# default. Lets a test run without writing +# the real session's persisted state. +# CALENDAR_SYNC_TIMEOUT -- seconds to wait for all calendars to settle. + +set -euo pipefail + +EMACS_D="${EMACS_D:-$HOME/.emacs.d}" +EMACS="${EMACS:-emacs}" + +if [ ! -d "$EMACS_D/modules" ]; then + echo "calendar-sync-run: no modules directory at $EMACS_D/modules" >&2 + exit 1 +fi + +# The overrides are set before the module loads on purpose: the private config +# is read at load time, and `defvar'/`defcustom' both leave an already-bound +# value alone. This is the same seam the in-editor conversion worker uses. +pre="" +if [ -n "${CALENDAR_SYNC_CONFIG:-}" ]; then + pre="$pre (setq calendar-sync-private-config-file \"$CALENDAR_SYNC_CONFIG\")" +fi +if [ -n "${CALENDAR_SYNC_STATE:-}" ]; then + pre="$pre (setq calendar-sync--state-file \"$CALENDAR_SYNC_STATE\")" +fi +if [ -n "${CALENDAR_SYNC_TIMEOUT:-}" ]; then + pre="$pre (setq calendar-sync-batch-timeout $CALENDAR_SYNC_TIMEOUT)" +fi + +# -Q keeps the daemon's init out of it: this needs the calendar-sync modules, +# not a full editor. load-prefer-newer stops a stale .elc from answering for +# changed source, which would silently sync with yesterday's logic. +exec "$EMACS" --batch -Q \ + --eval "(progn (setq load-prefer-newer t)$pre)" \ + -L "$EMACS_D/modules" \ + --eval '(progn + (require (quote calendar-sync)) + (kill-emacs (calendar-sync-batch-run-and-report)))' diff --git a/scripts/remote-repository-reset.sh b/scripts/remote-repository-reset.sh deleted file mode 100755 index e9a243a8..00000000 --- a/scripts/remote-repository-reset.sh +++ /dev/null @@ -1,19 +0,0 @@ -#!/bin/sh -# Craig Jennings -# post archsetup step to reset remote upstream repositories on emacs -# configuration and doftiles. - -cd ~/emacs.d/ -git remote remove origin -git remote add github git@github.com:cjennings/dotemacs.git -git remote add origin git@cjennings.net:dotemacs.git -git branch -M main -git push -u origin main - - -cd ~/.dotfiles/ -git remote remove origin -git remote add github git@github.com:cjennings/dotfiles.git -git remote add origin git@cjennings.net:dotfiles.git -git branch -M main -git push -u origin main diff --git a/scripts/theme-studio/app.js b/scripts/theme-studio/app.js index 44a01514..6a2daad2 100644 --- a/scripts/theme-studio/app.js +++ b/scripts/theme-studio/app.js @@ -40,6 +40,9 @@ APP_CORE_J // Pure color/UI-boundary helpers (normHex, ratingColor, textOn), inlined from // app-util.js. textOn uses rl from the colormath core above. APP_UTIL_J +// 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_J // Pure palette-generator planner and browser-side generator panel. PALETTE_GENERATOR_CORE_J PALETTE_GENERATOR_UI_J diff --git a/scripts/theme-studio/browser-gates.js b/scripts/theme-studio/browser-gates.js index 3ccec8ea..e0132bcd 100644 --- a/scripts/theme-studio/browser-gates.js +++ b/scripts/theme-studio/browser-gates.js @@ -77,6 +77,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 diff --git a/scripts/theme-studio/generate.py b/scripts/theme-studio/generate.py index b0fafefd..477407ee 100644 --- a/scripts/theme-studio/generate.py +++ b/scripts/theme-studio/generate.py @@ -146,6 +146,11 @@ APP_CORE_BODY=strip_exports(read_text('app-core.js')) # test-app-util.mjs. Its `import rl` line is stripped on inline (rl is already in # the page from the colormath core). APP_UTIL_BODY=strip_exports(read_text('app-util.js')) +# The seeding engine (seed-core.js): the seed model as data and the pure seed(). +# Inlined below the colormath core (its only dependency) so the browser #seedtest +# runs the same code the Node tests import. Its `import` from colormath is stripped +# on inline, where oklchOf/oklch2hex are already present. +SEED_CORE_BODY=strip_exports(read_text('seed-core.js')) # Pure palette-generator planner and its browser UI panel, split from the shared # app core so generation behavior and panel wiring can evolve locally. PALETTE_GENERATOR_CORE_BODY=strip_exports(read_text('palette-generator-core.js')) @@ -422,6 +427,7 @@ def _build(): .replace("CONTROLS_J",CONTROLS_BODY) .replace("PREVIEWS_J",PREVIEWS_BODY) .replace("APP_UTIL_J",APP_UTIL_BODY) + .replace("SEED_CORE_J",SEED_CORE_BODY) .replace("PALETTE_GENERATOR_CORE_J",PALETTE_GENERATOR_CORE_BODY) .replace("PALETTE_GENERATOR_UI_J",PALETTE_GENERATOR_UI_BODY) .replace("PALETTE_ACTIONS_J",PALETTE_ACTIONS_BODY) diff --git a/scripts/theme-studio/seed-core.js b/scripts/theme-studio/seed-core.js new file mode 100644 index 00000000..fb7362d4 --- /dev/null +++ b/scripts/theme-studio/seed-core.js @@ -0,0 +1,249 @@ +// 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(). +import { oklchOf, oklch2hex } from './colormath.js'; + +// --- 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']; + +export { ANCHORS, buildModel, seed, ROLES, SYNTAX_ROLES, ORG_FACES, CATS_KEYS }; diff --git a/scripts/theme-studio/test-seed-core.mjs b/scripts/theme-studio/test-seed-core.mjs new file mode 100644 index 00000000..8ad6fc60 --- /dev/null +++ b/scripts/theme-studio/test-seed-core.mjs @@ -0,0 +1,173 @@ +// Unit tests for the seeding engine (seed-core.js): the seed model as data and +// the pure seed() operation. seed() projects theme-coloring-guide.org's role/seed +// table onto the three owned tiers (syntax, UI, org), reusing colormath.js OKLCH +// generation for the palette shades and heading ramp. +// Run: node --test scripts/theme-studio/ + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { buildModel, seed, ROLES } from './seed-core.js'; +import { oklchOf } from './colormath.js'; + +const here = fileURLToPath(new URL('.', import.meta.url)); +const HEX = /^#[0-9a-f]{6}$/; + +// --- the model: OKLCH-generated palette shades --------------------------- + +test('buildModel: every swatch is a valid in-gamut hex', () => { + const m = buildModel(); + for (const [name, hex] of Object.entries(m.swatch)) { + assert.match(hex, HEX, `swatch ${name} is not a hex: ${hex}`); + } +}); + +test('buildModel: builtin blue-grey is the blue hue at lower chroma and lightness', () => { + const m = buildModel(); + const blue = oklchOf(m.swatch.blue), grey = oklchOf(m.swatch['blue-grey']); + assert.ok(grey.C < blue.C, 'blue-grey should have lower chroma than blue'); + assert.ok(grey.L < blue.L, 'blue-grey should be darker than blue'); + assert.ok(Math.abs(grey.H - blue.H) < 15, 'blue-grey should keep the blue hue'); +}); + +test('buildModel: the heading ramp descends in lightness, level 1 strongest', () => { + const m = buildModel(); + assert.equal(m.ramp.length, 4); + for (let i = 1; i < m.ramp.length; i++) { + assert.ok(oklchOf(m.ramp[i - 1]).L > oklchOf(m.ramp[i]).L, + `ramp step ${i} should be darker than step ${i - 1}`); + } +}); + +// --- seed(): syntax tier ------------------------------------------------- + +test('seed: syntax builtin (bi) resolves to blue-grey', () => { + const m = buildModel(), s = seed(m).syntax; + assert.equal(s.bi.fg, m.swatch['blue-grey']); + assert.notEqual(s.bi.weight, 'bold'); +}); + +test('seed: syntax definition (fnd) is gold and bold; call (fnc) is quieter gold, not bold', () => { + const m = buildModel(), s = seed(m).syntax; + assert.equal(s.fnd.fg, m.swatch.gold); + assert.equal(s.fnd.weight, 'bold'); + assert.equal(s.fnc.fg, m.swatch['gold-quiet']); + assert.notEqual(s.fnc.weight, 'bold'); +}); + +test('seed: syntax base + structure + keyword + literal + string land on their swatches', () => { + const m = buildModel(), s = seed(m).syntax; + assert.equal(s.var.fg, m.swatch.fg); // base identity + assert.equal(s.p.fg, m.swatch.fg); + assert.equal(s.op.fg, m.swatch['muted-fg']); // structure + assert.equal(s.punc.fg, m.swatch['muted-fg']); + assert.equal(s.kw.fg, m.swatch.blue); // control + assert.equal(s.kw.weight, 'bold'); + assert.equal(s.num.fg, m.swatch.terracotta); // literal + assert.equal(s.con.fg, m.swatch.terracotta); + assert.equal(s.str.fg, m.swatch.sage); // string + assert.equal(s.ty.fg, m.swatch.regal); // type +}); + +test('seed: docstring and comment take italic; comment is the low-contrast lane', () => { + const m = buildModel(), s = seed(m).syntax; + assert.equal(s.doc.slant, 'italic'); + assert.equal(s.cm.slant, 'italic'); + assert.equal(s.cm.fg, m.swatch.comment); +}); + +test('seed: regexp and escape use the teal/bright-green lanes; bg is the ground', () => { + const m = buildModel(), s = seed(m).syntax; + assert.equal(s.re.fg, m.swatch.teal); + assert.equal(s.rxgb.fg, m.swatch.teal); + assert.equal(s.esc.fg, m.swatch['sage-bright']); + assert.equal(s.bg.fg, m.swatch.ground); +}); + +// --- seed(): UI tier ----------------------------------------------------- + +test('seed: transient state faces are background-only (no foreground)', () => { + const m = buildModel(), u = seed(m).ui; + for (const f of ['region', 'hl-line', 'highlight', 'show-paren-match']) { + assert.ok(u[f].bg, `${f} should carry a background tint`); + assert.ok(!u[f].fg, `${f} should not set a foreground`); + } +}); + +test('seed: link is blue and underlined (redundant encoding)', () => { + const m = buildModel(), u = seed(m).ui; + assert.equal(u.link.fg, m.swatch.blue); + assert.ok(u.link.underline, 'link should be underlined'); +}); + +test('seed: signal faces sit on the convention hues, with weight for redundancy', () => { + const m = buildModel(), u = seed(m).ui; + assert.equal(u.error.fg, m.swatch.red); + assert.equal(u.error.weight, 'bold'); + assert.equal(u.warning.fg, m.swatch.amber); + assert.equal(u.success.fg, m.swatch.green); + assert.equal(u['isearch-fail'].fg, m.swatch.red); +}); + +test('seed: active chrome differs from idle chrome', () => { + const m = buildModel(), u = seed(m).ui; + assert.notEqual(u['mode-line'].fg, u['mode-line-inactive'].fg); + assert.notEqual(u['line-number-current-line'].fg, u['line-number'].fg); +}); + +// --- seed(): org package tier ------------------------------------------- + +test('seed: packages carries only org-mode (non-org bespoke packages untouched)', () => { + const m = buildModel(), p = seed(m).packages; + assert.deepEqual(Object.keys(p), ['org-mode']); +}); + +test('seed: org headings ramp — level 1 strongest and bold, deeper levels quieter', () => { + const m = buildModel(), org = seed(m).packages['org-mode']; + assert.equal(org['org-level-1'].weight, 'bold'); + assert.ok(oklchOf(org['org-level-1'].fg).L > oklchOf(org['org-level-2'].fg).L, + 'org-level-1 should be lighter (stronger) than org-level-2'); +}); + +test('seed: org code-like faces reuse the syntax literal lane', () => { + const m = buildModel(), org = seed(m).packages['org-mode']; + assert.equal(org['org-code'].fg, m.swatch.terracotta); + assert.equal(org['org-code'].inherit, 'fixed-pitch'); +}); + +test('seed: org link underlined; org-done receded with strikethrough; org-todo warm', () => { + const m = buildModel(), org = seed(m).packages['org-mode']; + assert.ok(org['org-link'].underline, 'org-link should be underlined'); + assert.ok(org['org-done'].strike, 'org-done should be struck through'); + assert.equal(org['org-todo'].fg, m.swatch.red); +}); + +// --- purity -------------------------------------------------------------- + +test('seed: pure — two calls deep-equal and the model is not mutated', () => { + const m = buildModel(); + const before = JSON.stringify(m); + const a = seed(m), b = seed(m); + assert.deepEqual(a, b); + assert.equal(JSON.stringify(m), before, 'seed() must not mutate the model'); +}); + +test('ROLES: the table exposes the guide roles as data', () => { + assert.equal(ROLES.builtin.swatch, 'blue-grey'); + assert.equal(ROLES.def.swatch, 'gold'); + assert.equal(ROLES.def.weight, 'bold'); + assert.equal(ROLES.state.channel, 'bg'); + assert.equal(ROLES.sig_link.underline, true); +}); + +// --- inline-integrity ---------------------------------------------------- +// The page must carry seed-core.js's body (sans import/export) verbatim — the +// same strip generate.py applies. Requires `python3 generate.py`. +import { stripInlinedBody } from './inline-strip.mjs'; + +test('inline-integrity: theme-studio.html contains the seed-core.js body verbatim', () => { + const body = stripInlinedBody(readFileSync(here + 'seed-core.js', 'utf8')); + const html = readFileSync(here + 'theme-studio.html', 'utf8'); + assert.ok(html.includes(body), 'generated page is missing the seed-core.js body verbatim'); +}); 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 |
