Skip to content

fix(capytone): review fixes — the generate card honours the picked colour (P0) - #108

Merged
unfoldingdimensions merged 24 commits into
mainfrom
fix/capytone-review
Oct 11, 2026
Merged

unfoldingdimensions merged 24 commits into
mainfrom
fix/capytone-review

Conversation

@unfoldingdimensions

@unfoldingdimensions unfoldingdimensions commented Oct 11, 2026 •

Copy link
Copy Markdown
Owner

What

Fixes finding 1 (P0) of the CapyTone comprehensive review (2026-10-11, docs/research/capytone-review-2026-10-11.md, owner-local): Generate mode now honours the selected colour instead of keeping only its hue. This PR collects the review fixes — each lands as its own commit; the P1s follow.

The defect

CapyTone.tsx fed only toOklchOrNull(genHex)?.h into harmonyPalette — the picked colour's lightness and chroma were discarded and every role re-drawn from fixed bands. Picking dark navy #1a1a2e rendered a pale lavender card (field L 0.88); a partial or unparseable hex silently became hue 0 (red family) mid-typing.

The fix

  • harmony.ts — harmonyPalette accepts the picked colour as baseColor: it is the field (lightness and chroma verbatim, gamut-clamped at its own (L, H)), and role lightness keeps each band's internal spacing relative to it, so mid-tone picks don't wash out. New fieldForBaseLightness() maps a pick to Light (L ≥ 0.55) or Deep (L ≤ 0.45) and defers mid-tones. When the Field pill is set against the colour, the field's lightness is pulled into the regime (windows [0.55, 0.98] light / [0.08, 0.45] deep) so the composition and the ≥ 4.5 ink contract hold. Harmony hue offsets are untouched and still pinned.
  • CapyTone.tsx — the hex box keeps a draft state; the engine only ever sees parseable colours, so mid-typing freezes the last valid card (with the existing "Not a colour this can read" note) instead of flipping red. The Field pill auto-follows the pick.
  • 5 new tests in tests/capytone-hub.test.ts pin the colour-fidelity contract (within one 8-bit step of the picked hex), the forced-regime coherence (roles in physical range, contrast ≥ 4.5), hue offsets with a base colour, determinism, and the field mapping.

Verification

  • npx vitest run — 2053/2053 passed (94 files), including the 5 new.
  • npm run lint — clean. npx next typegen && npx tsc --noEmit — clean.
  • Live page check (driven Chrome on the dev server): #1a1a2e → card field pixel rgb(26,26,46) with the Field pill auto-flipped to Deep; #8e9b7e → rgb(141,155,125); typing zzz or #1a → card stays put with the note shown.

Co-Authored-By: GLM-5.3-Flash noreply@z.ai


Review follow-ups (4 commits, Claude Opus 5.5)

The PR also carries the review's P1/P2 fixes (19 commits after the P0 — see the commit list). A review of the whole branch found:

  • P0 regression, fixed (b234fdb) — translating every role band by (field − band middle) slid the accent onto the ink: #2f4a80 deep → accent/ink 1.01:1; #8a3b2a forced light → near-black accent beside black ink. Roles now keep their proportional place between the field and the ink pole. Worst case across 9 picks × 2 fields × 6 rules × 3 seeds: accent/ink 1.38, accent/field 1.67 (the no-pick engine's own floor is 1.57); pinned by a new test that fails on the previous code.
  • APCA ladder (cb42f4d) — the branch dropped Lc 60; restored as "Other content text minimum (60+)", so the ladder is the published 90/75/60/45/30/15.
  • House rule (9e14df2) — image-swatch Check/Generate pills were hand-styled (no hover fill, no focus ring); now BTN.
  • Field pill (6604a62) — typing #6b6b6b passed through #6b6 (light) and left a mid-tone on the light field; 3/4-digit shorthands no longer move the pill.

Verified: vitest 2059/2059, eslint and next typegen && tsc --noEmit clean; in the dev server #2f4a80 gives a navy field with a gold accent distinct from the pale ink, and the field pill follows to Deep.

Known, left as is: Space no longer remixes while a mood pill has focus (correct for keyboard users).

unfoldingdimensions and others added 24 commits October 11, 2026 19:50
…ld (P0)

Generate mode kept only the base colour's hue — lightness and chroma were
discarded and every role re-drawn from fixed bands, so picking #1a1a2e
produced a pale lavender card. The picked colour now passes into
harmonyPalette whole: it IS the field (lightness and chroma verbatim,
gamut-clamped at its own (L, H)), role lightness keeps the band's internal
spacing relative to it, and the Field pill follows dark and light picks —
overriding it pulls the colour into the chosen regime (L windows
[0.55, 0.98] light / [0.08, 0.45] deep) so the composition stays coherent.
Mid/accent hues still land exactly on the harmony offsets; the ink-vs-bg
contrast ≥ 4.5 contract is unchanged and still pinned.

A partial or unparseable hex no longer silently falls back to hue 0 (red):
the text box keeps the draft, the engine keeps the last valid colour, and
the existing "Not a colour" note explains the frozen card.

Verified: full vitest suite (2053 passed, incl. 5 new tests pinning the
colour-fidelity contract), lint, next typegen + tsc, and a live page check
(#1a1a2e renders rgb(26,26,46) with the field pill on Deep; invalid input
freezes the card).

Review: docs/research/capytone-review-2026-10-11.md, finding 1.

Co-Authored-By: GLM-5.3-Flash <noreply@z.ai>
families.ts derived each anchor's hue family from a naive arithmetic mean of
its hueAnchors, so any anchor straddling the 0° wrap mis-filed:
cherry-blossom's [20°, 350°] averaged to 185° (teal) and the pink pastel was
listed under the Cool filter. The circular mean — already used by this
module's own blessed-hue check, with a comment naming exactly this trap —
puts the same anchors at 5°, squarely pink, and cherry-blossom now files
under Warm. carnival-night keeps its existing override (now consistent with
the derivation rather than masking it); no other anchor's family changes.

Review: docs/research/capytone-review-2026-10-11.md, finding 2.

Co-Authored-By: GLM-5.3-Flash <noreply@z.ai>
…chroma (P1)

The "popping" filter's blurb is "festival-grade chroma", but the tag keyed on
chromaRange's TOP (>= 0.115), so any quiet mood whose accent may spike filed
in beside carnival and confetti: fireside-night, cinnamon-cafe,
honeyed-afternoon, harvest-fair, brass-band-evening, moss-carpet and
fern-gully all appeared under Popping.

Keying on the range FLOOR (>= 0.085) keeps exactly the anchors that are
always loud — the floors separate the festival trio (diwali 0.09,
carnival 0.10, confetti 0.11) from every other anchor (<= 0.08). A floor is
the honest reading of the blurb: chroma this mood is guaranteed to wear, not
chroma it may reach. Every demoted anchor stays reachable from its other
filters (warm/dusk), pinned by test.

Review: docs/research/capytone-review-2026-10-11.md, finding 3.

Co-Authored-By: GLM-5.3-Flash <noreply@z.ai>
For radial gradients the fallback head carried no direction clause, and the
stop list was joined with an unconditional comma: the fallback emitted
"radial-gradient(, #121212 0%, ..." - a comma directly after the paren is
invalid CSS, and Chrome drops the whole declaration (verified via computed
style during the review). The exact engines the hex fallback exists for
painted nothing at all. Linear and conic keep their comma (their heads
carry the angle / from-clause); the radial fallback now joins its stop
list directly. The radial golden gains the fallback shape, and a sweep
over all 3 shapes x 4 interpolation spaces pins that no fallback ever
opens with a bare comma.

Review: docs/research/capytone-review-2026-10-11.md, finding 4.

Co-Authored-By: GLM-5.3-Flash <noreply@z.ai>
…e 0 (P1)

The page-change effect skipped page 0 (a mount double-draw guard), so when
the 8-page cycle wrapped from the short last page back to page 0 nothing
repainted: the persistent canvases kept the previous page's art under
page-0 labels and links, and the two canvases the short page had unmounted
came back never drawn - blank. Every full loop reproduced it for the whole
4.2 s dwell (caught live during the review).
One effect now draws on mount AND every page change, which is strictly
simpler than the guard it replaces.

Review: docs/research/capytone-review-2026-10-11.md, finding 5.

Co-Authored-By: GLM-5.3-Flash <noreply@z.ai>
The feel-mode spacebar shortcut exempted only HTMLInputElement, so with a
mood submitted and a mood pill focused, keydown Space ran preventDefault -
which cancels a button's space activation - and remixed instead. A
keyboard user could not activate mood pills at all; Space silently
re-rolled the card (caught live during the review: seed changed with a
pill focused). The shortcut now fires only when the page itself has focus
(body/documentElement); on any focused control Space keeps its native
job. The Remix button remains for pointer and keyboard users alike.

Review: docs/research/capytone-review-2026-10-11.md, finding 6.

Co-Authored-By: GLM-5.3-Flash <noreply@z.ai>
The hub passed only the FEEL palette to CheckMode and BlendMode, so a
palette just built in Generate was unreachable there: switching to Check
after tuning a harmony card silently showed the last mood card - or the
bare defaults when no mood was up. The hub now tracks which card-owning
mode was visited last (cardSource) and hands Check and Blend that card,
so the mode you left is the pair you get to interrogate. Extract's
jump-to-feel routes through the same tracking. Image and Extract palettes
still live inside their modes; their bridge out lands in a later commit.

Review: docs/research/capytone-review-2026-10-11.md, finding 7.

Co-Authored-By: GLM-5.3-Flash <noreply@z.ai>
…(P1)

Feel and Generate keep their state across mode switches (it lives in the
hub) while Check, Blend and Extract destroyed yours on unmount - build a
gradient, glance at Feel, come back to defaults. That asymmetry is what
made the hub feel arbitrary. Their state is lifted to the hub now:

- Check: the text/background pair (plus its card-role loaders)
- Blend: stops, shape, angle and interpolation space
- Extract: the url and the whole extraction status, result included

Untouched state follows the current card - the seeded view the components
used to initialise with, and what a ?mood= share link expects - and once
anything is edited, it persists per field. Image keeps its own state for
now (a decoded photo is not serialisable hub state); its palette gets a
bridge out in the next commit.

Review: docs/research/capytone-review-2026-10-11.md, finding 8.

Co-Authored-By: GLM-5.3-Flash <noreply@z.ai>
Extract's swatches could deep-jump into Feel, but Image's could reach
nothing: every hex out of a photo went through the clipboard by hand. The
hub now hands ImageMode two bridges - each swatch and the picked pixel
gain Check (the hex lands in Check mode as the text colour) and Generate
(the hex becomes Generate's base colour, field auto-following as
everywhere else). The swatch card restructures from a bare copy button
into a card with the copy button plus the two bridge pills, since a
button cannot nest a button.

The token-export dialect (numeric role names 1..12 under the "image"
slug) stays as-is on purpose: a photo palette has no bg/accent semantics,
and the names already follow the shared cssVariables convention.

Review: docs/research/capytone-review-2026-10-11.md, finding 9.

Co-Authored-By: GLM-5.3-Flash <noreply@z.ai>
…(P1)

check.ts's APCA path trimmed its input and measured 0-255 clamped
channels, while the WCAG path (engine/color.ts contrastRatio) parsed the
string raw and linearised whatever culori returned. Two consequences:
a pasted leading space failed the WCAG parse and read as a 1:1 Fail
beside a healthy APCA Lc - contradictory verdicts on the same string -
and a wide-gamut input measured unclamped, able to exceed the 21:1
ceiling WCAG defines as impossible. Both parsers trim now, and
contrastRatio clamps channels to 0-1 before linearising, matching APCA.
toOklchOrNull trims too, so ColorPick's own gate agrees with both
engines. In-gamut hexes are untouched - ensureContrast, the guardrails
and every golden keep their values.

Review: docs/research/capytone-review-2026-10-11.md, finding 10.

Co-Authored-By: GLM-5.3-Flash <noreply@z.ai>
The pill dots showed each anchor's hand-tuned BLESSED hexes, but the
synthesizer never uses them - it re-draws hue, chroma and lightness from
the anchor's ranges at the phrase's seed. Worst drift: coastal-fog's pill
showed pale fog #C9CFCC over a mid-grey #7b8a86 card (deltaE 21);
cozy-autumn-morning drifted 24 degrees into maroon. The pill now renders
the five roles of the SAME deterministic call the card makes
(generatePalette(phrase) at the derived seed), so what you tap is
byte-for-byte what you get and the two can never disagree again. The
blessed hexes stay in the lexicon as the tuning ground truth the family
tests measure against.

Review: docs/research/capytone-review-2026-10-11.md, finding 11.

Co-Authored-By: GLM-5.3-Flash <noreply@z.ai>
The stop hex was a promise the synthesizer only partly kept: the pinned
hue wandered +/-18 degrees (Poppy #E0576B landed pink at #c9688e, Festival
Gold khaki) and the field-chroma ceiling sat at the mood engine's 0.15,
pastelising vivid stops. Stops now wander just +/-8 degrees (harmony gates
still hold: 8 + the accent's 14 < 30), the chroma ceiling is the stop's
OWN chroma (a hex is in-gamut by construction; an explicitly tapped vivid
color is the user's call), and the chroma draw floor rises from 0.75x to
0.85x so the seed lottery cannot wash the stop out. Measured worsts
across all 30 stops x derived seed: deltaE 11.6 -> 6.7, hue drift
18 -> 8 degrees, chroma drop 32% -> 24%. The fidelity test pins the
tighter bounds; the guardrail test still passes unchanged (the ceiling
only exceeds 0.15 for poppy, within the measured epsilon).

Review: docs/research/capytone-review-2026-10-11.md, finding 12.

Co-Authored-By: GLM-5.3-Flash <noreply@z.ai>

Port-pin note: the "start from warm orange" literal is regenerated with the
new stop behaviour (the anchor pins are untouched - the mood engine is).
Check mode's five 'From the card' role swatches all loaded into the TEXT
slot - tapping 'Field' put the field colour where the text goes, and the
natural ink-vs-field check required typing the field hex by hand. A
second row loads the same roles as the background; the first row is
relabeled 'as text' to say which slot it feeds. The mode's lifted state
makes this a two-line change.

Review: docs/research/capytone-review-2026-10-11.md, finding 13.

Co-Authored-By: GLM-5.3-Flash <noreply@z.ai>
…0/15 (P2)

The review flagged the band ladder as polarity-blind; checking the
published source says otherwise - APCA in a Nutshell (Myndex/SAPC-APCA)
publishes ONE polarity-neutral ladder, and the tool had TWO levels of it
wrong: 75 was labelled 'preferred body text' when the published preferred
level for fluent body text is 90 (75 is the minimum for columns of body
text), and the 60 tier appears in no current published table. Re-keyed to
the published ladder (90/75/45/30/15), labels updated, band-table test
pinned to the boundaries including 60 landing in large-text. The APCA
readme cross-check confirms no polarity-specific thresholds - the metric
is polarity-aware (the Lc sign), the guidance table is not; the UI's
polarity note stays as is.

Review: docs/research/capytone-review-2026-10-11.md, finding 14 (its
polarity premise does not survive the source check; the mislabeled
ladder it pointed at did).

Co-Authored-By: GLM-5.3-Flash <noreply@z.ai>
…lias (P2)

cssTokens appended '/* camelCase alias: rainyTuesday */' to its output - a
comment promising an alias the snippet does not contain. The variables
already carry the slug, and nothing consumes a camelCase form, so the
honest fix is removing the line (and with it the now-unused slug
computation); the byte-for-byte pin in the image tests loses the line
with it.

Review: docs/research/capytone-review-2026-10-11.md, finding 15.

Co-Authored-By: GLM-5.3-Flash <noreply@z.ai>
…k (P2)

A proxy 502 HTML page or a truncated body made response.json() throw,
which landed in the catch and reported 'The wire went quiet' - a network
drop - for what is really the server answering with something unreadable.
The body is parsed defensively now: unparseable or lacking a palette
resolves to the 'upstream' failure kind ('The site said no.'), and only a
true fetch-level throw reports network. JSON error kinds from the route
are untouched.

Review: docs/research/capytone-review-2026-10-11.md, finding 16.

Co-Authored-By: GLM-5.3-Flash <noreply@z.ai>
Every error in the whole pipeline - decode refusals included - surfaced as
'The browser's decoder refused it', which would be wrong for a palette-
build or canvas failure. The catch now distinguishes DecodeFailedError
(the decoder copy, unchanged) from everything else ('That file wouldn't
read' - a tool fault, not a file fault), each with its own error card.

Review: docs/research/capytone-review-2026-10-11.md, finding 17.

Co-Authored-By: GLM-5.3-Flash <noreply@z.ai>
ColorPick pushed every keystroke to the parent, so typing a hex made
Check's verdicts and Blend's ramp vanish and reappear character by
character, and the native picker silently displayed black for any
half-typed text. Invalid text now lives in a local draft: the note shows
beside the field while the parent keeps its last valid colour, the picker
shows that last valid colour too, valid text pushes through immediately,
and blur reverts the draft. All three consumers (check, blend, generate)
benefit; their state-gating from the earlier fixes is untouched.

Review: docs/research/capytone-review-2026-10-11.md, finding 18.

Co-Authored-By: GLM-5.3-Flash <noreply@z.ai>
Canvas text never triggers @font-face loads, so document.fonts.ready -
which resolves when currently-pending loads finish - could settle before
Fraunces or Albert Sans had ever fetched, and the card silently painted
fallbacks. ensureFontsReady now asks for each face first (fonts.load at
the weights the renderers use: 300 display, 500 mono, 700 sans), then
awaits ready; a refused load falls through honestly. ExampleCards routes
through it instead of its own bare ready. Pattern seconded from
capystamp/fonts.ts.

Review: docs/research/capytone-review-2026-10-11.md, finding 19.

Co-Authored-By: GLM-5.3-Flash <noreply@z.ai>
31 picks over a 2x2-by-4 grid meant the cycle's last page showed three
cards and one empty slot, every loop. A 32nd entry - 'sakura', a real
keyword route to cherry-blossom, rendered editorial where 'cherry
blossom' itself is minimal - makes 8 pages of exactly 4. No anchor is
duplicated in the visible set; the deep link resolves like any pill.

Review: docs/research/capytone-review-2026-10-11.md, finding 20.

Co-Authored-By: GLM-5.3-Flash <noreply@z.ai>
…ield and ink

Translating every role band by (field - band middle) slid the accent onto the ink on fields far from the band's middle (#2f4a80 deep: accent/ink 1.01:1). Roles now map proportionally between the field and the ink pole; a test pins accent/ink >= 1.35 and accent/field >= 1.6 across picks, fields, rules and seeds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…/30/15)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… focus ring)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@unfoldingdimensions
unfoldingdimensions merged commit 8f10c1b into main Oct 11, 2026
3 checks passed
@unfoldingdimensions
unfoldingdimensions deleted the fix/capytone-review branch October 11, 2026 10:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant