aboutsummaryrefslogtreecommitdiff
Commit message (Collapse)AuthorAgeFilesLines
* fix(dwim-shell): valid zip name, real backup timestamp, reachable menu keyCraig Jennings2026-06-133-5/+50
| | | | Three audit fixes. Single-file zip named the archive after the input file (<<fne>>.<<e>>), making invalid archives and a "foo." name for directories. It now builds <fne>.zip. The dated backup single-quoted $(date ...), so the stamp sat literal in the filename. Now the timestamp is interpolated in Elisp with format-time-string. The dired menu used "M-S-d", which Meta-Shift-d never emits (it sends M-D), so the menu was unreachable in plain dired. It now binds M-D, matching the dirvish sibling. Both command strings moved to top-level builders so they're unit-testable without loading the dwim-shell-command package.
* fix(markdown): restore the live preview (rename, guard, dead setq)Craig Jennings2026-06-132-6/+14
| | | | F2 ran markdown-mode's own markdown-preview instead of the custom one, so the impatient-mode strapdown preview was dead. I renamed the custom command to cj/markdown-preview and rebound F2 so markdown-mode no longer shadows it. The server guard now uses httpd-running-p (httpd-process isn't a variable), and a dead (setq imp-set-user-filter 'markdown-html) that named neither a real variable nor a real filter is gone.
* fix(completion): let orderless match, prescient sortCraig Jennings2026-06-131-0/+5
| | | | vertico-prescient-mode defaulted vertico-prescient-enable-filtering to t, which overrode completion-styles to prescient inside vertico sessions and left the orderless config dead where it mattered. Setting it nil restores orderless space-separated out-of-order matching while prescient keeps doing the sorting.
* fix(keys): destructive delete on capital D, diff on lowercase dCraig Jennings2026-06-132-2/+32
| | | | C-; b d now runs cj/diff-buffer-with-file (the op I hit most, comparing a buffer against the saved file) and C-; b D runs cj/delete-buffer-and-file. The destructive command sat on the easy lowercase key and diff on the capital. A keymap-lookup test guards the swap.
* fix: vertico-repeat C-s and dotted-repo discovery (audit bugs)Craig Jennings2026-06-134-1/+24
| | | | | | | | | Two bugs from the 2026-06 config audit. - C-s C-s never repeated a search. cj/consult-line-or-repeat calls vertico-repeat on the second press, but vertico-repeat-save was never on minibuffer-setup-hook, so it always signalled "No Vertico session". I hooked vertico-repeat-save next to the vertico block. It's autoloaded, so the load defers to the first minibuffer. - reconcile-open-repos skipped any repo with a dot in its name. cj/find-git-repos filtered children through "^[^.]+$", which matches only dot-free names, so mcp.el, capture.el, and google-contacts.el were never reconciled while M-P still reported "Complete". It now filters through directory-files-no-dot-files-regexp plus a hidden-dir guard, so dotted repos pass and .git stays out. Each fix is test-first: a failing assertion (hook membership, three dotted repos found) precedes the change.
* fix: lock-screen, confirm-tier, and mail-folder audit bugsCraig Jennings2026-06-139-13/+150
| | | | | | | | | | Lock screen: slock is X11-only and never grabbed the Wayland session, so C-; ! l silently did nothing. On Wayland the locker now runs loginctl lock-session, which logind turns into a Lock signal that hypridle handles by running hyprlock, the same path idle and before-sleep locking already use. X11 keeps slock. system-commands.el now also requires host-environment, which it used at load time but never declared. Confirmation tier: the global (fset 'yes-or-no-p 'y-or-n-p) plus use-short-answers t both flattened yes-or-no-p to a single keystroke, so the deliberate strong-confirm tier for irreversible actions was dead. A stray space could power off the machine or destroy files. I added cj/confirm-strong, which binds use-short-answers nil for one call to force a typed "yes", and routed the six irreversible sites through it (shutdown/reboot, permanent file destruction, file overwrites). I dropped the redundant fset and kept use-short-answers t so ordinary prompts stay single-key. Mail folders: the cmail context set no trash folder, so D fell back to a nonexistent /trash, and no context set a refile folder, so r targeted a nonexistent /archive everywhere. Accepting mu4e's offer to create the maildir stranded mail where mbsync never syncs it. cmail now trashes to /cmail/Trash. Refile is computed per message rather than per context, because mu4e context :vars are sticky and a per-context refile would leak one account's archive folder into another. cmail archives to /cmail/Archive. The Gmail-backed accounts have no synced archive maildir, so they signal rather than move mail into an unsynced folder. Lock and confirm-tier need a daemon restart to fully take effect. The mail changes apply on next mu4e open.
* chore(todo): file keymap + capture follow-up tasks, archive resolved workCraig Jennings2026-06-131-0/+1
| | | | New Keymap-consolidation [#B] and deferred Note/Recipe [#D] tasks, plus cross-refs on the prog-hooks and org-capture manual-test tasks. Archived the CANCELLED launcher task and two other resolved subtrees into Resolved. Gitignore the smoke.db runtime artifact.
* fix(snippets): stop electric-pair from stranding ">" after "<"-key snippetsCraig Jennings2026-06-132-0/+70
| | | | | | | | Most of the yasnippet keys start with "<" (<cj, <for, <main, ...), mirroring org-tempo. electric-pair-mode pairs "<" into "<>" wherever the mode's syntax table gives "<" paren syntax, which org and the pairing-enabled prog modes both do. So typing "<cj" lands as "<cj>", and expanding the "<cj" key strands the ">" after the snippet. The cj-comment block came out with a "#+end_src>" close fence, which breaks the cj-scan fence parser and made every respond-to-cj-comments pass hand-parse around it. I set electric-pair-inhibit-predicate globally to inhibit the open angle bracket and defer to the default for every other character. That keeps the "<"-prefixed snippet convention intact across all 14 of them, at the cost of "<>" auto-pairing where it might otherwise be wanted (C++ templates). The snippet convention is universal, so it wins. Surfaced from the smoke project, which kept hitting the malformed fence in its todo.org.
* fix(keys): revert 5bfd74fd, restore the M-S- launcher bindingsCraig Jennings2026-06-134-35/+3
| | | | | | | | 5bfd74fd rebound eww/elfeed/calibredb from M-S-e/r/b to the uppercase Meta events M-E/M-R/M-B, on the theory that the keyboard emits the uppercase event and the lowercase-shift spec never matched. It was wrong. keyboard-compat.el installs a key-translation-map entry (M-E -> M-S-e, etc.) in GUI frames, so the original M-S- bindings did reach the launchers. Binding M-E directly while that translation stayed in place rewrote the keypress to the now-unbound M-S-e, breaking all three in GUI on the next restart. The audit and the review missed it because they checked key-binding, which ignores key-translation-map, and the running daemon still held the pre-fix bindings as stale state. This restores M-S-e/r/b, deletes the key-binding-only test that certified the broken config, and reclassifies the task as not-a-bug. The real problem, that these chords are dead outside GUI, is the subject of the keybinding-console-safety spec.
* docs(spec): restructure keybinding spec to Path 2, defer the prefixCraig Jennings2026-06-131-85/+493
| | | | | | | | | | Processed the latest review comments on the keybinding-console-safety spec. Phase 0 is now a pure revert that unblocks the held push. Consolidation (migrate the common set, retire the translation block) is the primary phase. Binding the console-safe prefix becomes an optional, deferred Phase 3. The decisions gate splits to match: D2/D4/D5 gate the primary work, D1/D3 gate only Phase 3, so the work runs to the prefix-assignment point and stops there even if the prefix is never chosen. Corrected Appendix C: Meta transmits in the Linux console as an ESC prefix, so a free M-<punctuation> is a viable console-safe class, not just Control+letter. C-' is rejected: console-dead like C-;, and already bound to flyspell. Added Appendix D: every personal keybinding set outside the C-; tree and the M-S- family, as a checkbox pruning tree for a one-time audit of set-and-forgotten chords.
* fix(org-capture): make the desktop quick-capture popup behaveCraig Jennings2026-06-132-1/+403
| | | | | | | | | | The Hyprland Super+Shift+N popup opens a floating emacsclient frame named "org-capture" and runs a capture in it. Three things were wrong. The *Org Select* menu and the CAPTURE buffer split the small frame instead of filling it, so the daemon's last buffer leaked in beside two tmux-like modelines. The menu offered every template, including ones that can't work off the desktop: the pdf templates error outright, and the link and mu4e templates pull a link to whatever file the daemon last had open. And the frame only closed on a completed capture, so aborting at the menu or hitting an erroring template orphaned it. I added cj/quick-capture as the launcher's entry point instead of org-capture. It offers only Task, Bug, and Event, with Task and Bug retargeted to the global inbox since a desktop capture has no real project context, and it closes the popup frame on every exit path: finalize, abort, or error. A frame-scoped display-buffer-alist entry forces the menu and capture buffer into the frame's sole window, and an org-mks advice drops the Customize entry. Both gate on the "org-capture" frame name, so in-Emacs captures keep their windows. cj/quick-capture selects the "org-capture" frame by name before capturing rather than trusting the selected frame. The launcher runs before Hyprland settles focus on the new float, so the selected frame is still the main one and the capture would otherwise land there. Raised from the archsetup project.
* docs(spec): messenger unification — settle decisions 3/6/9, smoke ↵Craig Jennings2026-06-121-13/+34
| | | | reference backend
* docs(spec): add keybinding console-safety spec (draft for review)Craig Jennings2026-06-121-0/+531
|
* fix(dashboard): require recentf before touching recentf-excludeCraig Jennings2026-06-121-0/+1
| | | | cj/--dashboard-exclude-emms-from-recentf calls add-to-list on recentf-exclude, which is void until recentf loads. Init order guarantees it today, but a require makes the helper order-independent rather than dependent on load sequence.
* fix(ui-navigation): error clearly when undo-kill prefix exceeds the listCraig Jennings2026-06-122-2/+18
| | | | An out-of-range numeric prefix made (nth (1- arg) ...) return nil, and find-file on nil signaled a wrong-type-argument. Guard the index and raise a user-error naming how many killed files are available. A new test covers the out-of-range path.
* fix(org-drill): route drill-refile targets through the validated helperCraig Jennings2026-06-122-14/+32
| | | | The prior fix listed drill files with a raw directory-files call, bypassing cj/--drill-files-or-error, the shared validated entry point the other drill commands use. That skipped the missing/unreadable-dir user-error, fell through silently on an empty dir, and included leading-dot .org files the module otherwise excludes. Route through cj/--drill-files-or-error + expand-file-name, keeping the let binding so the session-wide org-refile-targets still survives. The test is rewritten into three: validated-helper targets, no global clobber, and a user-error on a missing drill dir.
* fix(dashboard): add EMMS exclusion to recentf-exclude without clobbering itCraig Jennings2026-06-122-1/+40
| | | | dashboard-config used setq on recentf-exclude, discarding the five exclusions system-defaults adds earlier in init order (bookmarks, elpa, recentf, ElfeedDB, airootfs). Extract the EMMS exclusion into cj/--dashboard-exclude-emms-from-recentf (the :config side-effect was not reachable for a test) and use add-to-list so prior entries survive. Two ERT tests cover preservation and the added pattern.
* fix(org-drill): let-bind org-refile-targets and target real drill filesCraig Jennings2026-06-122-10/+24
| | | | cj/drill-refile used setq, permanently replacing the session-wide org-refile-targets so every refile anywhere offered only drill targets until restart; and its (drill-dir :maxlevel . 1) entry named a bound variable, which org reads as a directory string rather than a file list, so the drill side yielded nothing. Let-bind org-refile-targets and supply (directory-files drill-dir t "\\.org$") as the file list. The stale test (which asserted the buggy drill-dir spec) is rewritten into two: file-list targets and no global clobber.
* fix(keys): bind eww/elfeed/calibredb launchers to the uppercase Meta eventCraig Jennings2026-06-124-3/+35
| | | | Meta+Shift+<letter> emits the uppercase event (M-E/M-R/M-B), so the M-S-e/M-S-r/M-S-b :bind specs on lowercase letters were never reached by the keychord and the three launchers were dead. Rebind them to M-E/M-R/M-B. Three ERT tests assert each chord resolves to eww, cj/elfeed-open, and calibredb respectively.
* fix(org-roam): put dailies #+FILETAGS and #+TITLE on separate linesCraig Jennings2026-06-122-2/+37
| | | | The "d" dailies head ran FILETAGS and TITLE together with no newline, so every C-c n d daily was malformed: Org never parsed #+TITLE and the FILETAGS value swallowed the rest of the line. Extracted the head into the cj/--org-roam-dailies-head defconst so it is unit-testable (the value was unreachable inside the use-package :custom form) and gave it real newlines. Two ERT tests assert the FILETAGS/TITLE line separation and the trailing newline.
* fix(ui-navigation): correct cj/undo-kill-buffer off-by-one on plain invocationCraig Jennings2026-06-122-12/+11
| | | | (interactive "p") forces arg >= 1, so the old (if arg (nth arg ...) (car ...)) always took the nth branch and plain M-S-z re-opened the second-most-recently-killed file. Index with (nth (1- arg) ...) so a numeric prefix is 1-based and no prefix re-opens the most recent. The two undo-kill tests now exercise the real no-prefix path (arg=1 reopens the first) and a 1-based numeric prefix; both failed against the bug and pass after. Filed a follow-up for a separate delq-vs-delete skip-visited defect found in the same function.
* chore(constants): point website-dir at code/websiteCraig Jennings2026-06-121-1/+1
|
* chore(dirvish): point phl quick-access at projects/homeCraig Jennings2026-06-121-1/+1
|
* chore(dirvish): update quick-access paths for projects/home layoutCraig Jennings2026-06-121-6/+6
|
* docs: add messenger window/key unification spec (draft)Craig Jennings2026-06-121-0/+187
|
* chore(theme-studio): record verification passes, file preview-fix tasksCraig Jennings2026-06-111-415/+584
| | | | Five manual checks verified, closing the contrast-cell and preview-bg bugs. An audit of the bespoke previews found three face mislinks (org headline-todo, erc input/default swap, flycheck delimiter swap) — filed with picker-visibility and Rust+Zig sample tasks. WIP theme picks up the revised steel selections.
* chore(dirvish): update jr-estate quick-access path to projects/homeCraig Jennings2026-06-111-1/+1
|
* chore(theme-studio): snapshot WIP palette filesCraig Jennings2026-06-116-431/+31817
|
* chore(dirvish): update clipper quick-access path to projects/homeCraig Jennings2026-06-111-1/+1
|
* feat(signal): route message toasts through the notify scriptCraig Jennings2026-06-114-1/+311
| | | | Incoming messages now notify through cj/signel--notify, installed as the fork's signel-notify-function. It suppresses the toast while that chat is in the selected window of a focused frame, collapses and truncates the body to 120 characters, and sends through the notify script (info type, --silent unless cj/signel-notify-sound is set). Without the script on PATH it falls back to notifications-notify and warns at load. The decisions are in the Notification slice addendum of docs/design/signal-client.org.
* refactor(constants): point roam-dir at the standalone roam repoCraig Jennings2026-06-111-2/+4
| | | | The org-roam KB moved out of the Syncthing share into its own git repo at ~/org/roam (cjennings.net:roam.git). journals-dir, inbox-file, and reading-notes-file derive from roam-dir and follow without edits.
* feat(org-capture): auto-close the quick-capture popup frameCraig Jennings2026-06-101-0/+14
| | | | A Hyprland keybind (Super+Shift+N) now opens an emacsclient frame named org-capture for one-shot captures. The new after-finalize hook deletes that frame on C-c C-c or C-c C-k, matching the popup-frame pattern; frames with any other name are untouched, so in-Emacs captures behave as before.
* docs(spec): draft declared-palette-columns specCraig Jennings2026-06-101-0/+119
|
* fix(theme-studio): derive box bevel colors from the face backgroundCraig Jennings2026-06-105-16/+150
| | | | The released/pressed bevel was a flat translucent white/black overlay, which reads weaker than the box Emacs draws. reliefColors in colormath.js now ports Emacs 30's x_alloc_lighter_color: highlight = bg x1.2, shadow = bg x0.6, an additive boost for dark backgrounds, and the same-color fallback for pure black and white. boxCss takes the face's effective bg and derives both edges from it. Pressed swaps the pair, and the translucent pair remains only when no bg is known. Width stays 1px because dupre's :line-width -1 draws 1px lines in Emacs too. The gap was color strength, not width. Five node tests pin hand-computed fixtures from the C source, and a new #beveltest gate pins the wiring.
* docs(design): add theme-studio color-harmony explainerCraig Jennings2026-06-101-0/+77
|
* fix(theme-studio): re-rate fallback contrast cells on default-fg changeCraig Jennings2026-06-102-2/+30
| | | | A default-fg (p) change only re-rated the covered overlay faces. UI-face and package ratios that fall back to the default fg kept their old number until something else rebuilt the tables. The p branch now runs the same repaint as a ground-bg change. The new #contrasttest assertion drives the real syntax dropdown (unlocking a locked p row for the test) so the handler wiring is pinned, not just the repaint helper.
* fix(theme-studio): scope applyGround and repaint faces on ground changeCraig Jennings2026-06-102-6/+56
| | | | The contrast cells already rated a two-color face's own fg-on-bg. They read wrong because applyGround blanketed every .ex cell (the per-face preview cells included) with the ground bg, and a ground-bg change never repainted the UI or package tables. The preview showed fg on the ground bg next to a correct fg-on-face-bg ratio, and ground-dependent ratios went stale. applyGround now blankets only the code panes and syntax example cells and repaints UI faces through paintUI. The ground-bg handler also rebuilds the package table and preview. New #contrasttest assertions pin the two-color pair in both tables, preview-bg survival, and ground-change re-rating.
* fix(theme-studio): guard Chrome profile dir and bound headless rendersCraig Jennings2026-06-101-2/+5
| | | | A headless render with an empty --user-data-dir falls back to the real Chrome profile and takes its SingletonLock. A hung render held that lock for 18 hours on 2026-06-09, blocking every interactive Chrome launch. The runner now refuses to run the browser gates when mktemp fails, and each render runs under timeout --kill-after so a wedged Chrome dies in seconds.
* feat(theme-studio): group families by lightness-conditioned complete linkageCraig Jennings2026-06-104-46/+146
| | | | | | | | | | Replace the hue-anchor bucketing and the tent neutral threshold with the model two independent reviews of color-sorting.org converged on (Codex and Fable, with Fable's harness measuring pairwise F1 0.63 → 0.96 on the real palette). Chromatic colors now cluster by complete-linkage agglomeration on a lightness-conditioned hue distance: hue must match tightly at equal lightness and may drift across a lightness gap, because a tonal ramp drifts in hue with lightness by design. A low-chroma noise term widens the tolerance where hue is ill-defined, and a chroma clause keeps a vivid accent out of a soft same-hue family. Complete linkage makes single-linkage chaining structurally impossible. The neutral threshold is floored at both ends instead of tapering to zero, which fixes two real defects: pale warm grays (gray+1, gray+2) that leaked into a color column, and pure white (C=0 at L=1) that evaded a zero threshold. On the sterling/distinguished palette this separates the gold and olive ramps (the green/yellow complaint), keeps the red and blue ramps whole including drifted tints, isolates intense-red, and consolidates every gray and steel into the neutral column. The one residual — pale yellow+2 lands on the olive ramp — is geometrically irreducible from the hex (it sits on the olive trajectory by nearest-neighbor, ramp-line fit, and eye); only its name says gold. That needs the deferred per-hex family-hint override. New node tests cover the gold/olive split, blue pale-tint cohesion, gray/white neutrality, intense-red isolation, and palette-order independence. The count gate now asserts the count action adds all ramp colors to the palette rather than that they all display in one family, since a chroma-eased extreme can sit at the neutral boundary.
* feat(theme-studio): color-families export round-trip and README close-outCraig Jennings2026-06-104-28/+72
| | | | | | | | Export stays a flat palette and import needs no reconstruction, because families are derived from the hex every render rather than stored. A #roundtriptest gate confirms export to import to export is byte-identical, and that the exported palette is still a flat [hex, name] list. Package seeding is unaffected since it reads the same flat palette. The spec's planned ramp-step warning exemption is dropped after analysis: a generated ramp's steps are a stepL apart, well above the too-similar ΔE threshold, so they never trigger the warning, and exempting same-family pairs would hide genuine near-duplicates that should be flagged (the case #deltatest checks). So the warning stays on the full palette. README documents color families: the hue grouping and its limitation, the ground strip, the per-column count control and regenerate, removed-step references reading "(gone)", and the removal of the standalone ramp panel. Phase 6, the last phase; the color-families v1 build is code-complete.
* feat(theme-studio): base-edit recolors a family; retire the ramp panelCraig Jennings2026-06-105-231/+101
| | | | | | | | Editing a family's base now recolors the whole family: update-selected on a base with a ramp regenerates the family from the new base at the same count, so references follow the new hexes (shared regenFamilyInPlace with the count control). Editing a ground swatch already writes the bg/fg assignment through the existing repoint, and the gate confirms it. The standalone ramp panel is gone — its button, panel, JS, CSS, and the #ramptest gate are removed. Fanning a color into a ramp now happens from its strip: add a color, then raise its column's count. The ramp() math stays in app-core; only the duplicate UI is retired. Phase 5 of the color-families spec. A #baseedittest gate covers the base-edit recolor (family follows, references repoint, count preserved) and the bg-swatch edit writing the assignment.
* feat(theme-studio): add the live per-family count controlCraig Jennings2026-06-105-9/+131
| | | | | | | | Each chromatic family column gets a count input (0-4) showing its current per-side reach. Setting N regenerates the family as a symmetric base ±N ramp from its most-saturated color, replacing the family's current members. A reference to a surviving step (matched by signed lightness rank) follows the new hex through repointHex; a reference to a step removed by lowering N is left on its old hex, which is no longer in the palette and renders as "(gone)" — never silently reassigned. The neutral and ground strips get no control. I also fixed the neutral threshold curve: it was flat-high through the darks, which pulled a chroma-eased dark ramp step (a dark desaturated blue) into the neutral column and broke the family. The curve now tapers toward both lightness extremes, peaking near mid, so dark and light tints both keep their hue while mid grays stay neutral. This is the symmetric form of the Munsell scaling and a strict improvement. Phase 4 of the color-families spec. A #counttest gate covers count-up adding symmetric steps, count-down dropping the extremes, the surviving-step repoint, and the removed-step "(gone)".
* feat(theme-studio): group families by hue anchor with a lightness-scaled ↵Craig Jennings2026-06-103-46/+66
| | | | | | | | | | 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.
* style(theme-studio): lay out family strips as vertical columnsCraig Jennings2026-06-102-6/+4
| | | | 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.
* feat(theme-studio): render the palette as hue family stripsCraig Jennings2026-06-104-51/+119
| | | | | | 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.
* feat(theme-studio): add color-family sortCraig Jennings2026-06-103-3/+73
| | | | | | 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.
* feat(theme-studio): add the color-families model coreCraig Jennings2026-06-103-1/+288
| | | | | | 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.
* chore(todo): mark color-families spec Ready, break out build tasksCraig Jennings2026-06-101-1/+5
| | | | Craig confirmed the spec Ready. The six phases become tracked tasks under the color-families parent — family model and sort cores, strip rendering, the count control, the ground strip plus ramp-panel retirement, and the warnings/seeding/export/README close-out — all :solo:. Three manual aesthetic checks (hue grouping matches the eye, regenerate-replace reads as deliberate, removed-step references read as "(gone)") go under the Manual testing parent.
* docs(theme-studio): record the palette-ramps spec Ready verificationCraig Jennings2026-06-091-4/+8
| | | | Codex re-reviewed the updated palette-ramps spec and confirmed it implementation-ready; the history gets a Ready-verification entry and the metadata table is realigned. The spec was already built and shipped, so this just closes its review trail.
* docs(theme-studio): fold the color-families review and pivot to hex groupingCraig Jennings2026-06-091-98/+128
| | | | | | | | Resolved both open decisions per Craig: theme.json stays flat, and the standalone ramp panel goes away in favor of the per-strip control. The bigger change is the grouping mechanism: families are now derived from OKLCH hue off the hex, never from a name convention, so renaming a color to anything never moves it between strips. That pivot designs out the Codex review's two hardest blockers. There's no step-name grammar and no import inference, because grouping isn't name-based. And the palette stays a flat, individually-editable list rather than transferring ownership to family objects, so per-chip rename/remove/edit keep working and there's no ownership contract to invent. Families are a display view over the existing palette. The rest of the review is folded as written, adapted to the flat model: the ground strip is synthesized from the bg/fg assignments (pinned, editable, de-duped by hex), removed-step references degrade to a visible "(gone)" rather than a silent jump, n=0 is handled without ramp(), and the neutral (0.02) and hue-gap (25) thresholds and sort tie-breakers are pinned. Review file consumed and deleted; dispositions and a responder entry are in the spec.