| Commit message (Collapse) | Author | Age | Files | Lines |
| |
|
|
|
|
|
|
|
|
| |
neutral cut
Replace gap-based hue clustering and the flat neutral threshold. Chromatic colors now bucket by nearest perceptual hue anchor (red, orange, yellow, green, teal, blue, purple, pink), so adjacent categories stay separate by construction and there's no single-linkage chaining merging them through intermediate tones. The neutral cut is lightness-scaled rather than flat: a color reads as neutral below a chroma that's highest in the mid-tones and tapers toward the light end, so a faint mid gray goes neutral while an equally-faint pale tint keeps its hue.
This fixes the two concrete problems: the grays and steels consolidate into one neutral column, and pale tints (light blues) stay with their hue instead of falling into the grays. What it doesn't fix is hue-adjacent warm colors: this palette's olive-greens sit on top of the golds in OKLCH hue, so they still group together, and a ramp that drifts in hue can split across an anchor boundary. That's a real property of the colors, not a bug, and it's filed for research (a writeup of the problem and the four approaches tried lives outside the repo; the task points to it).
20 family node tests including the yellow/green split and the no-chaining case; suite green.
|
| |
|
|
| |
Each family now reads top to bottom (dark to light) as a column, with families arranged left to right, rather than horizontal rows stacked down the panel.
|
| |
|
|
|
|
| |
The palette panel is now a stack of strips: the pinned ground strip (bg, fg) first, then hue-sorted family strips, each dark to light. Grouping comes from familiesFromPalette off the hex every render, so renaming a color never moves it. The flat PALETTE stays the editable truth and chips keep their per-chip remove / rename / select; the move-arrow and drag reordering are gone since the sort is deterministic now (moveColor and the drag state with them).
Phase 3 of the color-families spec. A #familytest gate checks the ground strip pins first, families render, chips keep their controls, and a color renamed to anything stays in the same strip. Existing palette flows (delta, heal, ramp gates) stay green.
|
| |
|
|
|
|
| |
sortFamilies orders the strips for display: neutrals first by lightness, then chromatic families by base hue, ties broken by base lightness then base hex. Each family's members come back sorted dark to light. Hue is compared rounded so a sub-degree hue hair from gamut quantization doesn't outrank lightness. Sorting is display-only; the stored palette order is untouched.
Phase 2 of the color-families spec, pure logic. Four node tests cover the hue order, the neutral pin, within-family lightness order, and the (hue, then lightness) ordering invariant. Suite 91 to 95 green.
|
| |
|
|
|
|
| |
Four pure functions in app-core.js, all derived from the hex so renaming never moves a color. familiesFromPalette groups a flat palette into the ground strip (the bg/fg assignment hexes, pinned, de-duped) plus hue families: near-neutrals split off by a chroma threshold, the rest cluster by hue proximity with a 25-degree gap and a 360 wrap, each family's base its most-saturated member. regenFamily returns a family's symmetric ramp around the base (n=0 is the base alone, handled without ramp()'s 1-4 clamp). rankByLightness gives each current member a signed offset from the base, and stepRepointPlan maps old positions to new ones across a regenerate, listing the positions that drop out so the caller can leave their references a visible "(gone)".
Phase 1 of the color-families spec, pure logic, no UI. 13 node tests cover the gap split/merge, neutrals, absent and de-duped ground hexes, n=0, lightness ranking, and the survivor/removed repoint split. Suite 78 to 91 green.
|
| |
|
|
|
|
| |
Add-all inserted every step after the source, giving base, -2, -1, +1, +2. Now the darker steps go before the base and the lighter ones after, so the palette reads -2 -1 base +1 +2, matching the preview row and how a ramp reads left to right.
Inserting a darker step before the base shifted the base's index, so I bump the selected index to keep the selection (and the next preview's base) on the base color rather than drifting onto an inserted step. The #ramptest gate now checks the steps surround the base in order.
|
| |
|
|
|
|
| |
Deleting a palette color leaves any assignment pointing at it showing "(gone)". Recreating a color with the same deleted name now re-points those stranded assignments to the new color, even when its hex differs, instead of leaving them stuck on the old hex forever.
Delete records the removed name and hex; the next add of that name re-points every reference (syntax map, UI faces, package faces) to the new hex and consumes the record. The registry clears on import so a stale name from a previous theme can't re-bind across a load. I pulled the re-point loop that update-selected already used into a shared helper. A #healtest gate covers delete-then-recreate-with-a-new-hex.
|
| |
|
|
| |
A ramp step whose name already exists in the palette is skipped on add, but the only signal was a count. Now preview marks each colliding tile with a dashed outline and a badge, and the message names every collision, so you can see which steps won't add before you add them. Add-all reports the skipped names too, not just how many. The single-tile add already named its one collision; this extends the same warning to preview and add-all.
|
| |
|
|
| |
The ramp preview tiles were narrower than the palette chips, so longer hexes and names had less room. They now match the palette tile width (128px) and hold the same column count.
|
| |
|
|
| |
Each ramp step tile showed only its derived name. It now shows the hex underneath too, matching the palette chips, so you can read the generated value without hovering for the title. The #ramptest gate asserts every tile carries its hex line.
|
| |
|
|
| |
Preview captured the base only when the ramp panel first opened, so selecting a different palette color and pressing preview kept ramping the old one. Now preview re-reads the color-selection tile each time (the selected palette color, or a typed hex and name), so selecting a color then pressing preview just works, the same as reopening the panel. The #ramptest gate now asserts the base follows the tile.
|
| |
|
|
|
|
| |
The README now covers the ramp generator (the controls and their defaults, naming, and collision behavior), the worst-case-floor readout and its limiting foreground, the five covered overlay faces, the safe-lightness picker guidance, and that WCAG drives PASS/FAIL with APCA as a diagnostic. The browser-gate list is updated with the full set, including #ramptest, #contrasttest, and #safetest.
Closes the README and test-surface acceptance criteria for the palette-ramps v1 build.
|
| |
|
|
|
|
|
|
| |
The OKLCH picker gets a "safe for" selector listing the covered overlay faces. Pick one and the C×L plane shades the lightness band too light to keep that face readable over its foreground set, with the L_max ceiling as the band's lower edge. The ceiling is one marker computed via lMax at the current chroma, not a per-pixel foreground-set mask over the plane, so the existing AA/AAA mask stays single-foreground.
When no foreground is dark enough to fail, the band hides; when even black can't satisfy the target, the whole plane shades. The band only shows in OKLCH mode and clears in HSV. The cursor moved above the band so it stays visible through the shade.
Phase 5 of the palette-ramps spec, the last build phase. A #safetest browser gate pins that the band appears for a selected covered face with a positive height and hides when none is selected.
|
| |
|
|
|
|
|
|
| |
The five covered overlay faces (region, hl-line, highlight, lazy-highlight, isearch) now show the worst-case floor over their foreground set instead of one fg/bg pair. The cell starts with "worst:", names the limiting foreground by role and hex, then gives the ratio and a PASS/FAIL verdict, so a tint that clears the default text but fails the darkest token can't hide. The foreground set is the live syntax-token colors plus the default fg; the verdict is WCAG AA by default. Package and other UI rows keep their single-pair readout.
A syntax-color edit now repaints the covered faces, since their floors depend on the whole token palette, not just their own fg/bg. An out-of-scope face falls through to the single-pair cell, and a face whose set resolves empty reads "no fg set" rather than a bogus ratio.
Phase 4 of the palette-ramps spec. A #contrasttest browser gate pins the readout, the keyword-blue limiting case, the single-pair fallback, and the no-set message.
|
| |
|
|
|
|
|
|
| |
A ramp button on the palette controls opens a panel that generates a tonal ramp from the current color and previews the steps. Each step is a swatch labeled with its derived name (blue, blue+1, blue-1) and a clamp badge when the color left the sRGB gamut, so an out-of-gamut step is visible before it's added. The n, stepL, and chroma-ease controls default to 2 / 0.08 / 0.5 and re-preview live.
Clicking a step adds it to the palette; "add all" adds the lot. Steps insert adjacent to the source swatch in -n..+n order. A name collision is flagged and skipped rather than overwriting an existing color, and a generated hex that already matches another entry is added but flagged as a duplicate.
This is Phase 2, the DOM around the pure ramp() from Phase 1. A new #ramptest browser gate pins the step count, the ordered insertion after the source, the collision skip, and the clamp badge on an out-of-gamut step.
|
| |
|
|
|
|
|
|
| |
A background overlay sits behind many foregrounds at once, so its real constraint is the worst-case contrast over the whole set, not the single fg/bg pair the contrast cell shows today. Phase 3 adds three pure functions in app-core.js for that.
fgSetFor(face, state) builds a covered face's foreground set: the distinct syntax-token colors plus the default foreground, each labeled by syntax role. It returns a structured reason ('out-of-scope' or 'empty') rather than a bogus set when the face isn't covered or has no syntax assignments. floor(bgHex, fgSet) returns the minimum WCAG contrast over that set with the limiting foreground's hex and label. lMax(hue, chroma, fgSet, target) finds the lightest background that still clears the target, scanning L up from black to bracket the dark-side crossing then binary-searching it, and reports status ok/none/all/clamp.
state is passed explicitly (covered set, syntax assignments, default fg) so the functions read no globals and the Node tests stay direct. The closed five-face covered set lives here as COVERED_FACES, shared with app.js. Tests include the sterling keyword-blue worst case as a fixture, plus lMax's none/all/clamp branches.
|
| |
|
|
|
|
|
|
| |
ramp(baseHex, {n, stepL, chromaEase}) in app-core.js turns one base color into a tonal ramp: 2n steps at offsets -n..-1 and +1..+n, ordered darkest to lightest, base excluded. It holds the OKLCH hue, steps lightness by stepL, eases chroma toward the extremes so only the farthest step loses most of its color, and gamut-clamps each step with its own clamped flag.
Bad input returns a structured result rather than throwing: an unparseable base gives {steps: [], error: 'bad-hex'}, and out-of-range n/stepL/chromaEase clamp into range with the clamped knob named in adjusted. Defaults are n=2, stepL=0.08, chromaEase=0.5.
This is Phase 1 of the palette-ramps spec: pure logic, no UI. Tests cover mid/near-white/near-black bases, hue-hold, chroma easing, knob clamping, and malformed hex. The integrity stripper for app-core.js now drops import lines too, since the core imports normHex and the colormath helpers for the Node tests (stripped on inline, where both are already in scope).
|
| |
|
|
| |
sterling.json becomes the canonical default: merged sterling's palette/colors with the audited face structure (today's Fidelity fixes + boxes) and pointed generate.py's default at it. The seed reader now also applies the theme's package overrides, so the no-seed page is fully sterling. THEME_STUDIO_SEED still overrides to view another palette.
|
| |
|
|
| |
consistency-check tasks
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
| |
defaults, boxes
Acting on the per-package face audit (every package's faces checked against the live daemon and source):
Removed 11 nonexistent mu4e faces — mu4e-cited-1-face through -7-face, mu4e-attach-number-face, mu4e-compose-header-face, mu4e-moved-face. Newer mu4e dropped these (citations now inherit message-cited-text-N), so they themed nothing and showed phantom samples in the preview. Confirmed absent in the installed mu4e source before removing.
Boxed the faces that actually use :box, now that the attribute exists: magit-branch-current and magit-branch-remote-head (flat line box, matching :box t), and the released-button buttons — two flycheck list faces, the telega box-button family, and the slack button/dialog/reaction faces.
Restored the structural defaults the seeds had dropped relative to each package's own defface: underline on org-link/-cite/-cite-key, lsp-face-highlight-read/-rename, flycheck-error/-warning/-info, mu4e-header-highlight-face, calibredb-search-header-highlight-face, telega-describe-section-title, slack-message-output-header/-channel-button; bold on org-special-keyword, lsp-face-highlight-write, all four git-gutter faces, flycheck-error-list-highlight, the erc nick cluster (nick-default, button-nick-default, nick-prefix, my-nick-prefix, nick-msg, notice, pal, button), org-noter's two faces, and three slack label faces; italic on dirvish-media-info-property-key; height on lsp-details-face (0.8), shr-sup (0.8), calibredb-current-page-button (1.1).
Left the taste-call contradictions for review (shr-h3/h4-6, telega-msg-user-title, erc-action-face) — these are theme-design choices, not clear defects. dashboard, dired, org-drill, elfeed, signel, and pearl were clean.
|
| |
|
|
|
|
|
|
| |
The mode-line box in the preview was hardcoded — it showed a box the generated theme couldn't actually produce, since build-theme.el never emitted :box. Made :box a real face attribute instead: a per-face box object (style line/raised/pressed, width, color) stored on UI and package faces, set from a "box" dropdown in both tables, rendered from the attribute everywhere (the mode-line bars, the package previews via ofs, the UI table preview cells), and exported through build-theme.el's --attrs as a proper :box plist (released/pressed → :style *-button; line → :line-width + optional :color). The hardcoded box is gone; mode-line and mode-line-inactive now default to the released-button box that is the Emacs default, so the preview and the export agree.
This also gives the package faces that genuinely use :box a way to represent it — the face audit found several (magit-branch-current/-remote-head, two flycheck list faces, the telega button family, ~15 slack button/dialog faces).
Tests: build-theme gains box-conversion + ui-box-emit ERT tests (24/24); the app-core deep-equal tests account for the new box slot; all 9 browser gates, 20 python, and 55 node tests stay green.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Emacs does
The mock buffer rendered several faces wrong, verified against the running daemon and emacs -Q:
- highlight was flat monochrome text. Like region, the highlight face is background-only, so Emacs lets the syntax foreground show through (face merge: highlight over a keyword keeps the keyword color, adds the highlight background). Both now go through one overlay helper that keeps token colors and only overrides the foreground when the face sets one.
- cursor was an empty block at end-of-line. Emacs draws a box cursor on the character at point, in the frame background over the cursor color — now it sits on a real glyph.
- the overlay faces, the line-number gutter, and the paren faces ignored weight/underline/etc. They now honor bold/italic/underline/strike, matching the table preview.
- the fringe showed only its background. It now draws a continuation indicator in the fringe foreground.
- the mode line had no box. It now carries the 3D released-button box that is the Emacs default.
Defaults seeded to the theme-independent Emacs core styles (from emacs -Q): error/warning/success are bold; lazy-highlight and show-paren-match are underlined (link already was).
Added a #mocktest gate pinning these — overlay faces keep token colors, the cursor is on a glyph, styles render, the fringe indicator is present, the mode line has its box. Verified it goes red when the rendering regresses.
|
| |
|
|
|
|
|
|
| |
theme-studio is a self-contained Python + JS subproject with its own toolchain (python3, node, uvx, headless Chrome), unrelated to the root Makefile's Elisp/ERT world. Gave it a local Makefile that owns that toolchain — test, check (fast, no browser), coverage, gen, open — so the build logic lives with the code and the short target names don't collide with the root's Elisp-flavored test/coverage.
The root keeps the discoverable entry points: theme-studio-test and a new theme-studio-coverage now delegate via make -C. run-tests.sh grows a --no-browser flag so `make check` can skip the headless-Chrome gates for a fast inner loop. gen/open take an optional SEED to view a specific palette.
coverage reports both halves: node --experimental-test-coverage for the three JS modules (all 100% line, ~96% branch) and uvx coverage for generate.py (89% lines; the rest is the __main__ writer and the optional seed-env branch).
|
| |
|
|
|
|
|
|
|
|
| |
cover them
The pure helpers that were still stranded in app.js — normHex, ratingColor, textOn, and the filename-slug logic — had no unit tests because app.js can't be imported (it runs its bootstrap and references the data placeholders at load). Moved them into importable modules so they can be tested directly: a new app-util.js holds the color/UI-boundary trio, and slugify joins app-core.js. app.js keeps thin wrappers, so no call site changed and the built DOM is byte-identical.
textOn needs rl from colormath, so generate.py's inline strip now drops import lines as well as export lines — app-util.js imports rl for its tests, and the import is stripped on inline where rl is already in the page. _faces in generate.py also gets direct tests for its prefix-strip and label derivation.
New: 12 node tests (normHex, ratingColor, textOn, slugify) and 7 python tests (_faces, app-util integrity, the import strip). Coverage: app-util.js 100/100/100, app-core.js 100/94.9/100, colormath.js 100/96/100 (line/branch/func); generate.py 89% lines (the rest is the __main__ writer and the optional seed-env branch). No bugs surfaced — the logic was correct, just untested.
|
| | |
|
| |
|
|
|
|
|
|
| |
The refactor's goal was to make the app logic testable; this realizes it. Pulled the pure package-face model and the dropdown option list into app-core.js — nameToHex, buildPkgmap, packagesForExport, mergePackagesInto, effResolve (the inherit-chain resolver behind pkgEffFg/pkgEffBg), and optList — with every dependency passed as a parameter so there is no DOM and no module-global reliance. generate.py inlines it into the page the same way it inlines colormath.js (strip exports, placeholder, integrity check), so the browser runs the same code the tests import.
app.js keeps thin wrappers (pname, seedPkgmap, ddList, pkgEffFg, pkgEffBg) that pass the live PALETTE / APPS / PKGMAP into the core, so no call site changed and the built DOM is byte-identical to before.
test-app-core.mjs adds 18 Normal/Boundary/Error tests over the extracted logic — name resolution, the seed/export/merge round trip, the inherit chain including a cycle that must terminate at null, and the "(gone)" dropdown entry — plus an inline-integrity check that the page carries the core verbatim. The node suite goes 25 to 43 tests; python templating gains the app-core integrity assertion.
|
| |
|
|
|
|
|
|
| |
The three clear-unlocked functions (syntax, UI, package) repeated the same loop: walk the tier's rows, skip the locked ones, reset the rest. Pulled that into clearUnlockedRows(items, keyFn, resetFn) — keyFn returns a row's lock key or null to skip it outright (syntax bg and the default fg), resetFn does the tier-specific clearing. #locktest already pins clear-unlocked-skips-locked across all three tiers, so it guards this directly.
The "unset foreground reads as the default fg, unset background as the ground" fallback was written inline at nine sites as ||MAP['p'] / ||MAP['bg']. Replaced them with effFg(v) and effBg(v). The syntax, UI, and package render paths now resolve their raw value through the same two helpers.
Behavior-preserving: the rendered DOM (script stripped) is byte-identical to before, and every gate stays green.
|
| |
|
|
|
|
|
|
| |
The syntax table had its own sort (srt + a D{} direction map) that read state directly — MAP[kind] for the color column, cell text for elements. The UI and package tables used a separate, more general system (srtTable / cellVal / applyTableSort) that reads the rendered cells. Pointed the syntax headers at srtTable('legbody', col) and deleted srt, so all three tables share one sort.
The mapping is exact: the legtable color cell is a swatch dropdown whose data-val is the hex, which cellVal reads — same key srt sorted on — and the elements cell is text. First-click direction stays ascending. The syntax table sorts on click only; it doesn't opt into the cross-rebuild persistence the UI and package tables get from applyTableSort, which preserves its prior behavior.
Added a #sorttest gate: sort was previously untested, and this collapses two systems into one. It checks the syntax table sorts by color ascending, reverses on a second click, sorts by element name, and that the UI and package tables still sort. The asc/desc pair is self-validating — a no-op sort can't pass both.
|
| |
|
|
|
|
|
|
| |
Three tables repeated two scaffolds. The contrast-cell readout (a WCAG ratio colored by its AA/AAA rating, plus the rating word) was copy-pasted at five sites; pulled the shared formatting into crHtml(r) and called it from the syntax, UI, and package cells (the picker readout renders differently and stays as is). The B/I/U/S style-button loop was duplicated near-verbatim in the UI and package tables; pulled it into mkStyleButtons(isOn, onToggle), which returns the button list so the caller still hands them to mkLockCell.
Left the syntax table's bold/italic buttons alone — two buttons, a different state model (the BOLD/ITALIC dicts), and an in-place refresh closure make it a poor fit for the same helper. Didn't introduce a shared row scaffold either; the three tables differ enough in columns and order that one would leak.
Behavior-preserving: the runtime-rendered tables are byte-identical to before (a DOM dump diff shows only the inline-script source changing, never a built tr/td/button/span). All hash gates, the node suite, and #locktest stay green.
|
| |
|
|
|
|
| |
The UI and package tables used a native <select> for fg/bg, while the syntax table used the swatch-div dropdown (mkColorDropdown). The native select rendered its option swatch colors unreliably on Linux Chrome — the reason the swatch div exists. Routed all three tiers through mkColorDropdown and deleted colorDropdown, so every color picker now shows real swatches. The inherit column stays a select; it picks a face name, not a color.
Pulled the option-list construction into a shared ddList helper (default entry, palette, plus a "(gone)" entry when the current color left the palette) — the syntax table built that inline, the other tiers now reuse it. To keep fg/bg columns sorting by color value rather than by displayed name, the swatch dropdown exposes its value as data-val and cellVal reads it. Updated #locktest's UI assertion to the div lock mechanism (data-locked) since the UI control is no longer a select.
|
| |
|
|
|
|
|
|
| |
generate.py was 1378 lines, ~1300 of them a single triple-quoted string holding the whole app. Moved the <style> block to styles.css and the <script> body to app.js, and generate.py now inlines both through placeholders the same way it already inlines colormath.js, then fills the data placeholders. It drops to ~500 lines (the remaining bulk is the package face-data dicts, a later stage).
The generated page is byte-identical to before — every hash gate, the node suite, the spliced-script parse, and the new #locktest stay green. Two integrity tests guard the splice: styles.css inlines verbatim, and app.js reaches the page exactly as fill_data renders it. Both go red if the splice wiring is dropped.
Living in real files instead of a Python string kills the backslash-doubling bug class (str.replace is literal, so escapes survive), gives the CSS and JS real editor tooling, and opens the app logic to unit testing — the point of the whole refactor.
|
| |
|
|
| |
Adds a browser hash gate covering the two lock behaviors no existing gate touched: locking a row disables its control (syntax swatch div via data-locked, UI select via .disabled, both through the shared mkLockCell), and clear-unlocked wipes unlocked rows to default while leaving locked rows untouched across all three tiers. This is the characterization net for the generate.py extraction refactor — it proves the upcoming CSS/JS move preserves lock behavior. Verified it goes red when a lock guard is removed.
|
| |
|
|
|
|
|
|
|
|
|
|
| |
The design session added a batch of tool features. They landed together in the page generator, so this commits them as one.
A custom swatch color dropdown replaces the native select in the syntax table, showing a color square, name, and hex per row, because native option backgrounds render unreliably on Linux Chrome.
THEME_STUDIO_SEED=<file.json> seeds the tool's starting palette, assignments, bold, italic, and UI from a theme.json at generate time, so opening the tool on a given palette is one regenerate with no import step.
Lock toggles sit on all three tables. A closed lock marks a decided element/color pairing and disables that row's editing controls. Clear-unlocked resets the rest to default and leaves the locked rows alone. Locks persist in the saved theme, with keys namespaced (bare syntax, ui:, pkg:) so the tiers don't collide.
The UI faces table gained a contrast column, and the three tables now share one column order: name, lock, color, style, contrast. The bg and fg rows are renamed to "bg (ground)" and "fg".
|
| |
|
|
|
|
| |
The browser hash gates were run by hand through headless Chrome, so a picker-JS regression only surfaced if someone remembered to run them. run-tests.sh now drives the whole pyramid in one command: regenerate the page, the Python templating tests, the Node unit tests plus inline-integrity, a syntax check of the spliced page script, and the six browser hash gates. It exits non-zero on any failure, and make theme-studio-test calls it.
The browser gates need a Chromium-family browser. When none is found they report SKIPPED rather than passing, so a machine without Chrome can't turn the gates silently green.
|
| |
|
|
|
|
| |
The page generator's risky logic, the export-strip that inlines colormath.js and the placeholder substitution that fills in the sample and palette data, had no tests. A bug there ships a broken theme-studio.html the JS tests can't see, since they import colormath.js directly and never look at the assembled page.
I pulled the strip into a strip_exports function and guarded the file write behind __main__, so importing generate.py builds the page in memory without writing it. test_generate.py asserts the strip removes export lines, preserves the body, and rstrips. It asserts the assembled page has every placeholder filled and carries the colormath body verbatim. And it guards that colormath.js keeps its export on a single line, since the line-based strip would leave a multi-line export's continuation behind. That was the exact failure that bit during the first integration.
|
| |
|
|
|
|
|
|
| |
The picker's two heaviest pieces of pure logic lived as strings inside generate.py, reachable only through the single-scenario browser hash tests. I moved them into colormath.js, where they get the same direct Node testing the color math has: planeCell(L,C,H) returns a C×L plane cell's color or flags it out of gamut, and paletteWarnings(palette, threshold, cap) does the pairwise ΔE analysis and returns the too-close pairs, the overflow count, and each color's nearest neighbor. The page now calls both. The inline copies are gone.
The new Node tests cover what the hash tests never could: empty, single, and identical-color palettes; the strict threshold boundary; the cap and overflow count; closest-first ordering; the C=0 achromatic case; and a plane cell pinned to oklch2hex's clamped flag so the plane and the commit path agree on the gamut edge.
The refactor preserves behavior: the page renders identically, guarded by the existing #deltatest and #planetest characterization gates.
|
| |
|
|
|
|
|
|
| |
Perceptual-metrics Phase 4b, the last piece of the OKLCH editor. In OKLCH mode the picker's square becomes a Chroma (x) by Lightness (y) plane at the current hue. The crosshair maps to (C, L) and the hue strip selects H. The unreachable region is greyed out so the sRGB gamut boundary at that hue is visible, and the AA/AAA contrast mask overlays on top of the reachable colors.
The per-cell in-gamut test is forward-only: oklch to oklab to linear-rgb plus a channel-range check, never the binary search, which stays in oklch2hex for committing a chosen color. colormath.js now exports oklab2lrgb, inGamut, and lrgb2hex (with direct Node tests, including one that pins inGamut to oklch2hex's clamped flag so the plane and the commit path agree on the boundary). The rendered bitmap caches on hue, dimensions, mask, and background, so dragging C and L at a fixed hue reuses it.
HSV stays untouched: the square keeps its saturation/value gradient and the existing contrast mask. A #planetest headless guard asserts the crosshair lands at the color's (C, L), an out-of-gamut cell renders as the grey fill, and an in-gamut cell renders as a real color.
|
| |
|
|
|
|
|
|
| |
Perceptual-metrics Phase 4a. The picker gains an edit-model toggle (HSV / OKLCH) held in its own pkModel state, orthogonal to the existing AA/AAA contrast mask (pkMode). The two never share state: one is how you edit the color, the other is what constraint you mask.
In OKLCH mode the picker shows L, C, and H as paired range and number inputs that drive the color through oklch2hex, updating the hex field, swatch, readouts, and the HSV cursor. When the requested chroma is out of sRGB gamut the dials snap to the reachable color and a "chroma clamped to sRGB" line appears. HSV stays the default, and the SV square keeps editing in HSV (the C×L plane is Phase 4b). An SV drag in OKLCH mode refreshes the dials so the two surfaces stay consistent.
A #oklchtest headless guard asserts switching to OKLCH preserves the color, toggling the mask leaves pkModel alone, switching the model leaves pkMode alone, the dials drive the color to a known OKLCH target, and an out-of-gamut chroma raises the clamp status.
|
| |
|
|
|
|
| |
Perceptual-metrics Phase 3. renderPalette now runs a pairwise OKLab ΔE over the palette and warns on any pair below the named DELTAE_MIN threshold (0.02). The warning lists the closest pairs first, caps at five, and appends "and N more" so a noisy palette never hides the count. Each chip's tooltip gains its nearest-neighbor ΔE.
paletteDeltas computes the pairs and the per-color nearest distance in one pass, feeding both the chip titles and the warning list. Palette names go through esc before they reach the warning markup. A #deltatest headless guard asserts a near-identical pair fires and names itself, a spread palette stays quiet, and a tight cluster caps at five in ascending order with the overflow suffix.
|
| |
|
|
| |
The .pinfo2 row had the font and margin but not the flex space-between the .pinfo row above it uses, so the OKLCH and APCA spans ran together as "90°APCA Lc -39". With space-between the OKLCH coordinates sit under the hex and the APCA Lc sits under the WCAG ratio, matching the row above.
|
| |
|
|
|
|
|
|
| |
Perceptual-metrics Phase 2. The picker now shows a second readout row under the WCAG ratio: the OKLCH coordinates (L, C, hue°) and the signed APCA Lc against the ground color. WCAG and all three contrast tables are untouched, so APCA stays picker-only for v1.
APCA Lc carries its sign convention in a tooltip and in the README: positive is dark text on a light background, negative is light text on a dark background, so a light color on dupre's dark ground reads negative.
pkReadout drives the new spans from the inlined colormath functions, and a #readouttest headless guard loads dupre-blue and asserts the spans match both the live computation and the known OKLCH reference, with WCAG unchanged.
|
| |
|
|
|
|
|
|
| |
Perceptual-metrics Phase 1. generate.py inlines the colormath.js body into the page script, stripping the ES-module export so one source feeds both the browser and the Node tests. The page's own lin, rl, contrast, rating, hsv2rgb, rgb2hsv, hex2rgb, and rgb2hex copies move into colormath.js. normHex, textOn, and ratingColor stay in the page as UI-boundary helpers.
rl now reuses colormath's canonical lin (0.04045 cutoff) instead of the old 0.03928 form. The two are byte-identical on every #rrggbb: no 8-bit channel falls between the cutoffs (10/255 = 0.0392, 11/255 = 0.0431), confirmed over 200k random pairs with zero contrast change and no AA/AAA flips.
test-colormath.mjs adds Normal/Boundary/Error cases for the migrated helpers, a seeded hsv-rgb round-trip property test, and an inline-integrity check that the generated page carries the colormath.js body verbatim, so the inlined copy and the tested module can't drift.
|
| |
|
|
| |
colormath.js is the pure color-math module both theme-studio features need: OKLab/OKLCH conversions, oklch2hex with a binary-search gamut clamp, APCA (APCA-W3 0.1.9), and deltaE-OK. It's tested directly in Node (test-colormath.mjs under node --test) against the spec's fixtures (OKLab anchors, the red and dupre-blue OKLCH values, APCA at 106.0 and -107.9, the clamp invariants), at 100% line and 90% branch coverage. Next: generate.py inlines it and the existing rl/contrast/hsv helpers move in.
|
| |
|
|
| |
theme-coloring-guide.org is the design philosophy behind the tool, organized from principles out: seven laws, a role-to-treatment seed table, and three tiers (syntax, UI faces, org packages) as that table projected onto Emacs faces. It documents a shade budget per hue family, a functional signal-color convention table (not an emotion table), color-vision rules, and Emacs face specifics, and closes with canonical references. The README points to it, and dupre is the worked example.
|
| |
|
|
| |
openPicker positioned the crosshair while the picker was still display:none, so its client dimensions read 0 and the crosshair pinned to the top-left corner instead of the shown color. I moved display:block ahead of paintPicker so the element has layout when its size is read, then added a headless #cursortest that opens the picker on a saturated color and checks the crosshair lands off-corner.
|
| |
|
|
| |
Saved theme-studio exports for the dupre palette. dupre.json holds the base palette, UI, and syntax assignments; dupre-revised.json adds the full per-package face set across 51 packages. build-theme.el converts either into a loadable deftheme.
|
| |
|
|
| |
Clicking a UI or package face already called the flash, but two things hid it: the flashtok animation was scoped to #codepre and .ex cells, so the class landed on the mock-frame and package-preview spans without ever animating; and the flash never scrolled its target into view, so an element below the fold of a preview's scroll box flashed unseen. The animation is now global, and the flash scrolls its element into view (the first of several, via a new flashEls helper). Clicking a face in either tier now lands the viewport on the matching preview element and lights it up.
|
| |
|
|
| |
The tool authors themes from scratch -- palette, faces across every tier, live preview, export to a loadable deftheme. It never selects among existing themes, so "selector" mis-described it. Renamed the directory, the generated HTML and its title, the design spec, and every reference in the code, README, tests, and todo. No behavior change.
|
| |
|
|
| |
The package table (face, fg, bg, inherit, size, contrast) and the UI table (face, foreground, background) now sort on a header click, like the code/color assignments table. A generic comparator reads a select value, a numeric input, or cell text (numeric when it leads with a number, so size and contrast sort by value). The sort is remembered per table and re-applied after a rebuild, so editing a face no longer resets the order.
|
| |
|
|
| |
Import now goes through showOpenFilePicker and keeps the returned file handle, and the rename handler no longer drops the handle on every keystroke. Once a theme has been imported or saved, save writes back to that same file (the first write asks once for permission, then overwrites silently); the save button title reflects whether it will overwrite or prompt for a location. Browsers without the File System Access API fall back to the hidden file input and a download-style save, as before.
|