From ec8576e7b0b950979f475e4d5600847b0a921576 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Jul 2026 08:51:15 +0000 Subject: [PATCH 1/3] fix(configurator): track ratio tokens in the scale cards' override state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every ClampField that carries the modular-scale ratio block also owns the base min/max pair, but its `overridden` flag and its reset only ever looked at the base tokens. Two consequences, in the spacing card and both typography cards: - A ratio-only change produced no override marker and no reset affordance at all, so the card presented itself as untouched and the ratio could not be reverted from where it was set — only via the All-tokens tab or Reset all. - After changing base and ratio, the card's reset cleared the base and silently left the ratio override in place, so the card read as pristine while the generated scale stayed shifted. Include the ratio tokens in both. --sf-text-ratio-* is deliberately shared between the text and display generators, so resetting from either typography card clears it for both — the mirror image of editing from either card setting it for both, which is already the documented behaviour. Verified by driving the built configurator in a browser: on a ratio-only change the card's reset link goes from absent (before) to present (after), and clicking it clears --sf-space-ratio-min back to the framework default. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DftXg4tjRkey2c3ybxAWjN --- .../src/components/panels/SpacingPanel.svelte | 13 ++++++++-- .../components/panels/TypographyPanel.svelte | 24 +++++++++++++++---- 2 files changed, 30 insertions(+), 7 deletions(-) diff --git a/configurator/src/components/panels/SpacingPanel.svelte b/configurator/src/components/panels/SpacingPanel.svelte index 5de7e814..a87329ec 100644 --- a/configurator/src/components/panels/SpacingPanel.svelte +++ b/configurator/src/components/panels/SpacingPanel.svelte @@ -141,14 +141,23 @@ onMaxChange={(v) => onSet("--sf-fluid-max-vw", String(v))} /> + { onReset("--sf-space-base-min"); onReset("--sf-space-base-max"); }} + overridden={"--sf-space-base-min" in overrides || "--sf-space-base-max" in overrides + || "--sf-space-ratio-min" in overrides || "--sf-space-ratio-max" in overrides} + onReset={() => { + onReset("--sf-space-base-min"); onReset("--sf-space-base-max"); + onReset("--sf-space-ratio-min"); onReset("--sf-space-ratio-max"); + }} onMinChange={(v) => onSet("--sf-space-base-min", String(v))} onMaxChange={(v) => onSet("--sf-space-base-max", String(v))} ratioPresets={RATIO_PRESETS} diff --git a/configurator/src/components/panels/TypographyPanel.svelte b/configurator/src/components/panels/TypographyPanel.svelte index 8da43bb4..9111bbd3 100644 --- a/configurator/src/components/panels/TypographyPanel.svelte +++ b/configurator/src/components/panels/TypographyPanel.svelte @@ -422,15 +422,25 @@ onMaxChange={(v) => onSet("--sf-fluid-max-vw", String(v))} /> - + { onReset("--sf-text-base-min"); onReset("--sf-text-base-max"); }} + overridden={"--sf-text-base-min" in overrides || "--sf-text-base-max" in overrides + || "--sf-text-ratio-min" in overrides || "--sf-text-ratio-max" in overrides} + onReset={() => { + onReset("--sf-text-base-min"); onReset("--sf-text-base-max"); + onReset("--sf-text-ratio-min"); onReset("--sf-text-ratio-max"); + }} onMinChange={(v) => onSet("--sf-text-base-min", String(v))} onMaxChange={(v) => onSet("--sf-text-base-max", String(v))} ratioPresets={RATIO_PRESETS} @@ -456,8 +466,12 @@ min={1.5} max={6} step={0.05} unit="rem" minLabel="Mobile" maxLabel="Desktop" previewKind="type" - overridden={"--sf-text-display-base-min" in overrides || "--sf-text-display-base-max" in overrides} - onReset={() => { onReset("--sf-text-display-base-min"); onReset("--sf-text-display-base-max"); }} + overridden={"--sf-text-display-base-min" in overrides || "--sf-text-display-base-max" in overrides + || "--sf-text-ratio-min" in overrides || "--sf-text-ratio-max" in overrides} + onReset={() => { + onReset("--sf-text-display-base-min"); onReset("--sf-text-display-base-max"); + onReset("--sf-text-ratio-min"); onReset("--sf-text-ratio-max"); + }} onMinChange={(v) => onSet("--sf-text-display-base-min", String(v))} onMaxChange={(v) => onSet("--sf-text-display-base-max", String(v))} ratioPresets={RATIO_PRESETS} From 8becbda20e09f2450e3d3a8e9954e2a1bc368a8c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Jul 2026 12:17:57 +0000 Subject: [PATCH 2/3] feat(configurator): warn when fixed step values shadow a generated scale MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A concrete per-step value in the override map (--sf-space-m, --sf-text-l, …) beats the generated one on purpose: a fine-tuned rung should survive the knob that would otherwise produce it, and the live preview and the WordPress plugin's PHP emitter implement the same precedence. But when a whole ladder is stored — from an older settings page, an imported theme, or hand edits in the All-tokens tab — every control in the scale section goes inert while still reading back its own values. The panel shows the knobs, the page reports them at :root, the preview bars redraw, and nothing moves. Nothing said why. That state was diagnosed in the field only by fitting the framework's formula to the measured ladder and finding that no viewport could explain it. Surface it in the panel instead: report how many fixed values are shadowing the scale, say that fixed values win, list them on demand, and offer to clear them. The notice sits above the fold rather than inside the scale section, which is collapsed by default — a warning explaining why those controls do nothing is useless behind the disclosure the user has not opened. tests/scale-shadow-steps.test.js pins the step lists to core/tokens.css by deriving them from the generator inputs each token's value reads, so a new rung cannot ship with the guard blind to it. Verified by driving the built configurator against the reported override map: the notice appears for both scales, clearing restores --sf-space-m to the generated clamp() — now honouring the base-max the fixed ladder had been suppressing — and leaves the knobs themselves untouched. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DftXg4tjRkey2c3ybxAWjN --- .../inputs/ScaleShadowNotice.svelte | 50 ++++++++++ .../src/components/panels/SpacingPanel.svelte | 16 ++++ .../components/panels/TypographyPanel.svelte | 19 ++++ configurator/src/lib/scaleShadow.ts | 53 +++++++++++ tests/scale-shadow-steps.test.js | 94 +++++++++++++++++++ 5 files changed, 232 insertions(+) create mode 100644 configurator/src/components/inputs/ScaleShadowNotice.svelte create mode 100644 configurator/src/lib/scaleShadow.ts create mode 100644 tests/scale-shadow-steps.test.js diff --git a/configurator/src/components/inputs/ScaleShadowNotice.svelte b/configurator/src/components/inputs/ScaleShadowNotice.svelte new file mode 100644 index 00000000..9477c5f4 --- /dev/null +++ b/configurator/src/components/inputs/ScaleShadowNotice.svelte @@ -0,0 +1,50 @@ + + +{#if tokens.length} +
+

+ {tokens.length} + {one ? 'token holds a fixed value that overrides' : 'tokens hold fixed values that override'} + the generated {scaleLabel} scale, so the controls below + {one ? 'do not affect that step' : 'have no effect'}. + Fixed values win over the scale that would generate them. +

+
+ + +
+ {#if expanded} +
    + {#each tokens as token (token)} +
  • {token}
  • + {/each} +
+ {/if} +
+{/if} diff --git a/configurator/src/components/panels/SpacingPanel.svelte b/configurator/src/components/panels/SpacingPanel.svelte index a87329ec..9429df80 100644 --- a/configurator/src/components/panels/SpacingPanel.svelte +++ b/configurator/src/components/panels/SpacingPanel.svelte @@ -5,7 +5,9 @@ import SliderRow from '../inputs/SliderRow.svelte'; import ClampField from '../inputs/ClampField.svelte'; import Section from '../inputs/Section.svelte'; + import ScaleShadowNotice from '../inputs/ScaleShadowNotice.svelte'; import { SPACE_SCALE } from '../../lib/variableScales'; + import { SPACE_STEP_TOKENS, shadowingSteps } from '../../lib/scaleShadow'; let { overrides, onSet, onReset }: { tokens: SlashedToken[]; @@ -52,6 +54,10 @@ return midBase * Math.pow(ratio, 5) * spaceScale; }); + // Concrete per-step values stored in the override map silently outrank every + // control in the Modular scale section (see lib/scaleShadow.ts). + let shadowedSpaceSteps = $derived(shadowingSteps(overrides, SPACE_STEP_TOKENS)); + let showLayoutGap = $state(false); let showModularScale = $state(false); let showAdvanced = $state(false); @@ -59,6 +65,16 @@
+ + shadowedSpaceSteps.forEach((t) => onReset(t))} + /> +
Space scale preview
diff --git a/configurator/src/components/panels/TypographyPanel.svelte b/configurator/src/components/panels/TypographyPanel.svelte index 9111bbd3..7a82a2be 100644 --- a/configurator/src/components/panels/TypographyPanel.svelte +++ b/configurator/src/components/panels/TypographyPanel.svelte @@ -4,6 +4,8 @@ import ClampField from '../inputs/ClampField.svelte'; import Section from '../inputs/Section.svelte'; import TypeSpecimenRow from '../inputs/TypeSpecimenRow.svelte'; + import ScaleShadowNotice from '../inputs/ScaleShadowNotice.svelte'; + import { TEXT_STEP_TOKENS, DISPLAY_STEP_TOKENS, shadowingSteps } from '../../lib/scaleShadow'; import { themeState } from '../../lib/theme.svelte'; import GOOGLE_FONTS_ALL from '../../data/google-fonts.generated.json'; @@ -164,6 +166,14 @@ // Sorted section toggles — fonts → scale → rhythm → tracking → weights → // elements → measures. Every control lives in exactly one of these. + // Concrete per-step values stored in the override map silently outrank the + // whole Fluid scale section (see lib/scaleShadow.ts). Text and display are + // reported together: both generators live in that one section. + let shadowedTypeSteps = $derived([ + ...shadowingSteps(overrides, TEXT_STEP_TOKENS), + ...shadowingSteps(overrides, DISPLAY_STEP_TOKENS), + ]); + let showFonts = $state(false); let showScale = $state(false); let showLineHeights = $state(false); @@ -260,6 +270,15 @@
+ + shadowedTypeSteps.forEach((t) => onReset(t))} + /> +
{#each [ diff --git a/configurator/src/lib/scaleShadow.ts b/configurator/src/lib/scaleShadow.ts new file mode 100644 index 00000000..2b93fecb --- /dev/null +++ b/configurator/src/lib/scaleShadow.ts @@ -0,0 +1,53 @@ +/** + * Detects overrides that shadow a generative scale. + * + * The space and text scales are generated: the framework computes every step + * (--sf-space-m, --sf-text-l, …) from a handful of source knobs (base, ratio, + * scale, fluid viewport) via clamp()/pow() at :root. The scale panels edit those + * knobs, never the steps. + * + * A concrete value stored for a STEP wins over the generated one — deliberately: + * a fine-tuned rung should survive the knob that would otherwise produce it, and + * both the live preview and the WordPress plugin's PHP emitter implement that + * same precedence. But when a whole ladder of steps is stored (an older settings + * page, an imported theme, or hand edits in the All-tokens tab), the knobs go + * completely inert while still reading back their own values everywhere — the + * panel shows them, the page reports them at :root, and nothing moves. There is + * no way to tell from the scale card that this is happening. + * + * These helpers let the scale cards say so, and offer to clear the shadowing + * entries. + */ + +/** Steps of the fluid space scale, in ladder order. Excludes the deliberately + * non-generative --sf-space-none / --sf-space-px. */ +export const SPACE_STEP_TOKENS = [ + '--sf-space-2xs', '--sf-space-xs', '--sf-space-s', '--sf-space-m', + '--sf-space-l', '--sf-space-xl', '--sf-space-2xl', '--sf-space-3xl', + '--sf-space-4xl', +] as const; + +/** Steps of the fluid text scale. */ +export const TEXT_STEP_TOKENS = [ + '--sf-text-2xs', '--sf-text-xs', '--sf-text-s', '--sf-text-m', + '--sf-text-l', '--sf-text-xl', '--sf-text-2xl', '--sf-text-3xl', + '--sf-text-4xl', +] as const; + +/** Steps of the display scale, generated from the display base + the shared + * text ratio. */ +export const DISPLAY_STEP_TOKENS = [ + '--sf-text-display-s', '--sf-text-display-m', '--sf-text-display-l', +] as const; + +/** + * Which of `steps` are present in the override map — i.e. stored as concrete + * values that shadow the generated ones. Order follows `steps`, so the result + * reads as a ladder rather than in insertion order. + */ +export function shadowingSteps( + overrides: Record, + steps: readonly string[], +): string[] { + return steps.filter((token) => token in overrides); +} diff --git a/tests/scale-shadow-steps.test.js b/tests/scale-shadow-steps.test.js new file mode 100644 index 00000000..3e605b71 --- /dev/null +++ b/tests/scale-shadow-steps.test.js @@ -0,0 +1,94 @@ +/** + * Contract test for configurator/src/lib/scaleShadow.ts. + * + * The scale panels warn when a concrete per-step value in the override map + * shadows the generated scale, and offer to clear those entries. That guard is + * only as good as its list of step tokens: a step the list misses keeps + * silently overriding the knobs with no warning — the exact failure the guard + * exists to surface — and a step that no longer exists would offer to "clear" a + * token nothing reads. + * + * The lists are therefore pinned to the source of truth: every token in + * core/tokens.css whose value is built from the corresponding generator inputs + * IS a generated step, by definition. Add a rung to the framework (a 5xl, say) + * and this test fails until the guard learns about it. + * + * Runs in the root unit suite (node --test → CI), like + * tests/configurator-data-contract.test.js, so a TypeScript source in the + * configurator package is checked without pulling in a TS toolchain: the lists + * are plain string-literal arrays and are read as text. + * + * Run: node --test tests/scale-shadow-steps.test.js + */ +import { test, describe } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { stripComments } from '../scripts/lib/parse.js'; + +const ROOT = path.resolve(import.meta.dirname, '..'); +const TOKENS_CSS = path.join(ROOT, 'core', 'tokens.css'); +const SCALE_SHADOW = path.join(ROOT, 'configurator', 'src', 'lib', 'scaleShadow.ts'); + +/** Every `--sf-*: value;` declaration in core/tokens.css, as [name, value]. */ +function declarations() { + const css = stripComments(fs.readFileSync(TOKENS_CSS, 'utf8')); + const out = []; + for (const line of css.split('\n')) { + const m = line.match(/^\s*(--sf-[a-z0-9-]+)\s*:\s*(.+);\s*$/); + if (m) out.push([m[1], m[2]]); + } + // Guard the parse itself: if the file's formatting ever changes so that + // declarations stop being one-per-line, every set below would come back empty + // and each assertion would vacuously pass. + assert.ok(out.length > 100, `parsed only ${out.length} declarations from core/tokens.css — parser is stale`); + return out; +} + +/** Tokens whose value is generated from `input` (e.g. --sf-space-base-min). */ +function generatedFrom(decls, input) { + return decls.filter(([, value]) => value.includes(`var(${input})`)).map(([name]) => name).sort(); +} + +/** Read an exported string-literal array out of the TypeScript source. */ +function listFromSource(source, exportName) { + const m = source.match(new RegExp(`export const ${exportName} = \\[([\\s\\S]*?)\\]`)); + assert.ok(m, `${exportName} not found in scaleShadow.ts`); + return [...m[1].matchAll(/'(--sf-[a-z0-9-]+)'/g)].map((x) => x[1]).sort(); +} + +describe('scaleShadow step lists match the generated scales', () => { + const decls = declarations(); + const source = fs.readFileSync(SCALE_SHADOW, 'utf8'); + + // The display steps are generated from --sf-text-display-base-min AND read the + // shared --sf-text-ratio-*; the plain text steps use --sf-text-base-min. Both + // sets are disjoint because the base inputs differ. + const cases = [ + ['SPACE_STEP_TOKENS', '--sf-space-base-min'], + ['TEXT_STEP_TOKENS', '--sf-text-base-min'], + ['DISPLAY_STEP_TOKENS', '--sf-text-display-base-min'], + ]; + + for (const [exportName, input] of cases) { + test(`${exportName} covers exactly the tokens generated from ${input}`, () => { + const generated = generatedFrom(decls, input); + assert.ok(generated.length > 0, `no tokens in core/tokens.css are generated from ${input}`); + assert.deepEqual( + listFromSource(source, exportName), + generated, + `${exportName} is out of sync with core/tokens.css — the shadow guard would ` + + 'either miss a step (no warning, knobs silently inert) or offer to clear a dead token', + ); + }); + } + + test('the non-generative spacing tokens are excluded', () => { + // --sf-space-none / --sf-space-px are fixed values, not rungs of the scale: + // overriding them cannot shadow the knobs, so offering to "restore the + // scale" for them would be wrong. + const list = listFromSource(source, 'SPACE_STEP_TOKENS'); + assert.ok(!list.includes('--sf-space-none')); + assert.ok(!list.includes('--sf-space-px')); + }); +}); From ea8b3c7bd274cbe490c977cec6b80536e3c40c4b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Jul 2026 12:47:19 +0000 Subject: [PATCH 3/3] fix(configurator): correct shadow-notice wording and label its disclosure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review feedback on #663. The plural copy claimed the controls "have no effect", but a partial ladder only shadows the steps it covers — the knobs still drive every unshadowed rung. Overstating this invites clearing fixed values that were deliberate, so say the controls do not affect *those steps*. The show/hide toggle had no accessible name beyond its visible word, which does not identify what is being disclosed, and did not expose its state. Add aria-expanded, a descriptive aria-label naming the scale, and aria-controls pointing at the token list. The id is per-instance because the spacing and type notices can be mounted at the same time. Verified in a browser: the disclosure is reachable by its accessible name, aria-expanded flips on toggle, aria-controls resolves to the list element, and ids stay unique across mounted notices. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DftXg4tjRkey2c3ybxAWjN --- .../src/components/inputs/ScaleShadowNotice.svelte | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/configurator/src/components/inputs/ScaleShadowNotice.svelte b/configurator/src/components/inputs/ScaleShadowNotice.svelte index 9477c5f4..19037e48 100644 --- a/configurator/src/components/inputs/ScaleShadowNotice.svelte +++ b/configurator/src/components/inputs/ScaleShadowNotice.svelte @@ -14,6 +14,9 @@ let expanded = $state(false); let one = $derived(tokens.length === 1); + // aria-controls needs a stable id, and two notices (spacing + type) can be + // mounted at once, so it must be unique per instance rather than a constant. + const listId = `sf-shadowed-tokens-${crypto.randomUUID()}`; {#if tokens.length} @@ -22,7 +25,7 @@ {tokens.length} {one ? 'token holds a fixed value that overrides' : 'tokens hold fixed values that override'} the generated {scaleLabel} scale, so the controls below - {one ? 'do not affect that step' : 'have no effect'}. + {one ? 'do not affect that step' : 'do not affect those steps'}. Fixed values win over the scale that would generate them.

@@ -34,13 +37,16 @@
{#if expanded} -
    +
      {#each tokens as token (token)}
    • {token}
    • {/each}