diff --git a/configurator/src/components/inputs/ScaleShadowNotice.svelte b/configurator/src/components/inputs/ScaleShadowNotice.svelte new file mode 100644 index 00000000..19037e48 --- /dev/null +++ b/configurator/src/components/inputs/ScaleShadowNotice.svelte @@ -0,0 +1,56 @@ + + +{#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' : 'do not affect those steps'}. + Fixed values win over the scale that would generate them. +

+
+ + +
+ {#if expanded} + + {/if} +
+{/if} diff --git a/configurator/src/components/panels/SpacingPanel.svelte b/configurator/src/components/panels/SpacingPanel.svelte index 5de7e814..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
@@ -141,14 +157,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..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 [ @@ -422,15 +441,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 +485,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} 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')); + }); +});