From f7bf889c0a0a32f9550405dc3ef07ad7c206185a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 9 Jul 2026 11:07:42 +0000 Subject: [PATCH 1/5] fix(tokens): remediate source-CSS audit findings (#582) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address every verified finding from the source-CSS audit in one pass: wire the dead knobs render-safely, correct annotation/doc drift, document the intentional architecture, and add five CI gates that stop the whole class of defects from recurring. Workstream A — wire dead knobs (zero render change; every var() fallback equals today's literal): - core/print.css @page now reads --sf-print-page-size / -margin (verified in Chromium that var() is honoured inside @page descriptors; a new Chromium-only e2e asserts an override reaches the page box). - optional/forms.css field metrics resolve through --sf-field-padding-block / -inline / --sf-field-radius, then the core --sf-field-block rhythm knob, then the literal. --sf-field-block's :root default is unified to space-xs to match the shipped field padding so wiring changes nothing. Workstream B/C — docs & comments: - Fix annotation drift caught by the new value gate: the whole duration scale (fast 150ms, normal 250ms, slow 400ms), size-s (32px), size-m (~40px, not "default for buttons/inputs"), size-xl (56px), instant 100ms. - architecture.md --sf-blur-* → --sf-blur; motion.md → --sf-color-primary- source-light; llm-guide.md --sf-field-block default. - Comment the @property initial-value ≠ :root default (and its effect on fallbacks), the deliberate .sf-btn min-height ladder offset, and the .sf-h* / core heading mirror (adds overflow-wrap; margins intentionally not reset). Workstream D — new CI gates (+ negative tests): - check:dead-knobs — an annotation that promises wiring ("Maps to …") must have a real consumer. - check:annotations — ~Nms/~Npx prose must match the resolved token value. - check:hook-tokens — the fallback-only hook tokens (new scripts/hook-tokens.js) are undeclared, consumed with a fallback, and documented; no new hook escapes. - check:mirrors — @property↔:root char mirrors, 21 container-query re-derivations, and 10 SL-001 dark derivations stay in sync. - check:bundle-defs — every no-fallback token consumption ships its definition in the same bundle. - check-llm-guide now counts only declarations (not consumption) and allows the documented hook tokens (D3/D5). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_012i9DDskbZqmuMeDwQMjPjF --- .github/workflows/ci.yml | 8 + .../src/data/api-index.generated.json | 20 +- core/print.css | 9 +- core/tokens.css | 13 +- dist/css-custom-data.json | 16 +- docs/api-index.json | 18 +- docs/api-index.md | 16 +- docs/architecture.md | 2 +- docs/llm-guide.md | 2 +- docs/motion.md | 2 +- docs/token-annotations.json | 14 +- docs/token-index.json | 2 +- docs/token-index.md | 2 +- docs/tokens.md | 2 +- optional/components.css | 12 +- optional/forms.css | 13 +- optional/tokens.components.css | 7 +- optional/utilities.css | 6 +- package.json | 5 + scripts/check-annotations.js | 140 +++++++++++++ scripts/check-bundle-defs.js | 62 ++++++ scripts/check-dead-knobs.js | 92 +++++++++ scripts/check-hook-tokens.js | 99 +++++++++ scripts/check-llm-guide.js | 35 +++- scripts/check-mirrors.js | 191 ++++++++++++++++++ scripts/hook-tokens.js | 47 +++++ tests/check-llm-guide.test.js | 26 +++ tests/check-source-audit-gates.test.js | 138 +++++++++++++ tests/print.spec.js | 31 +++ 29 files changed, 961 insertions(+), 69 deletions(-) create mode 100644 scripts/check-annotations.js create mode 100644 scripts/check-bundle-defs.js create mode 100644 scripts/check-dead-knobs.js create mode 100644 scripts/check-hook-tokens.js create mode 100644 scripts/check-mirrors.js create mode 100644 scripts/hook-tokens.js create mode 100644 tests/check-source-audit-gates.test.js diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ae515957..fbab4b65 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -94,6 +94,14 @@ jobs: - run: node scripts/check-token-registry.js - run: node scripts/check-llm-guide.js - run: node scripts/check-macro-catalog.js + # #582 source-CSS audit gates: false wiring claims, annotation/value + # drift, fallback-only hook tokens, hand-maintained mirrors, and + # per-bundle token definitions. + - run: node scripts/check-dead-knobs.js + - run: node scripts/check-annotations.js + - run: node scripts/check-hook-tokens.js + - run: node scripts/check-mirrors.js + - run: node scripts/check-bundle-defs.js dependency-audit: name: Dependency vulnerability audit diff --git a/configurator/src/data/api-index.generated.json b/configurator/src/data/api-index.generated.json index 63210883..e30b8a0e 100644 --- a/configurator/src/data/api-index.generated.json +++ b/configurator/src/data/api-index.generated.json @@ -2,7 +2,7 @@ "_sync": { "generatedBy": "configurator/scripts/sync-api.mjs", "source": "docs/api-index.json", - "tokensHash": "40d1a1a35148", + "tokensHash": "883c459cd395", "bundles": [ "full", "optimal", @@ -8543,7 +8543,7 @@ "category": "Core tokens", "group": "Motion & easing", "description": "Duration and easing tokens. Build all transitions and animations from these to ensure consistent motion across the system.", - "note": "Fast duration (~100ms). Hover transitions, tooltip appear.", + "note": "Fast duration (~150ms). Hover transitions, tooltip appear.", "value": "calc(150ms * var(--sf-motion-scale))", "aliasOf": null, "registered": false, @@ -8566,7 +8566,7 @@ "category": "Core tokens", "group": "Motion & easing", "description": "Duration and easing tokens. Build all transitions and animations from these to ensure consistent motion across the system.", - "note": "Near-instant duration (~50ms). Micro-interactions and cursor-follow effects.", + "note": "Near-instant duration (~100ms). Micro-interactions and cursor-follow effects.", "value": "calc(100ms * var(--sf-motion-scale))", "aliasOf": null, "registered": false, @@ -8612,7 +8612,7 @@ "category": "Core tokens", "group": "Motion & easing", "description": "Duration and easing tokens. Build all transitions and animations from these to ensure consistent motion across the system.", - "note": "Normal duration (~200ms). Default for most transitions.", + "note": "Normal duration (~250ms). Default for most transitions.", "value": "calc(250ms * var(--sf-motion-scale))", "aliasOf": null, "registered": false, @@ -8635,7 +8635,7 @@ "category": "Core tokens", "group": "Motion & easing", "description": "Duration and easing tokens. Build all transitions and animations from these to ensure consistent motion across the system.", - "note": "Slow duration (~350ms). Emphasis transitions, drawers.", + "note": "Slow duration (~400ms). Emphasis transitions, drawers.", "value": "calc(400ms * var(--sf-motion-scale))", "aliasOf": null, "registered": false, @@ -8981,8 +8981,8 @@ "group": "Output token @property registrations", "description": "Vertical (block) padding inside form fields.", "note": "Vertical (block) padding inside form fields.", - "value": "var(--sf-space-l)", - "aliasOf": "--sf-space-l", + "value": "var(--sf-space-xs)", + "aliasOf": "--sf-space-xs", "registered": true, "syntax": "", "fallbackOnly": false, @@ -14011,7 +14011,7 @@ "category": "Core tokens", "group": "UI sizes", "description": "Fixed sizes for interactive components (buttons, inputs, chips) at S / M / L.", - "note": "Medium UI component height (~36px). Default size for buttons and inputs.", + "note": "~40px UI component height (scale rung `m`); not currently consumed by any shipped rule.", "value": "2.5rem", "aliasOf": null, "registered": false, @@ -14034,7 +14034,7 @@ "category": "Core tokens", "group": "UI sizes", "description": "Fixed sizes for interactive components (buttons, inputs, chips) at S / M / L.", - "note": "Small UI component height (~28px). Compact buttons and input variants.", + "note": "Small UI component height (~32px). Compact buttons and input variants.", "value": "2rem", "aliasOf": null, "registered": false, @@ -14057,7 +14057,7 @@ "category": "Core tokens", "group": "UI sizes", "description": "Fixed sizes for interactive components (buttons, inputs, chips) at S / M / L.", - "note": "Extra-large interactive component height (~52px). For oversized or prominently touch-friendly controls.", + "note": "Extra-large interactive component height (~56px). For oversized or prominently touch-friendly controls.", "value": "3.5rem", "aliasOf": null, "registered": false, diff --git a/core/print.css b/core/print.css index 3d6dccfb..3f562d6f 100644 --- a/core/print.css +++ b/core/print.css @@ -12,10 +12,15 @@ @media print { + /* --sf-print-page-size / -margin are read here so a :root override retunes + the printed page box. Each fallback equals the historical literal, so the + default print render is unchanged. Verified in Chromium that var() is + substituted inside @page descriptors; engines lacking general custom- + property substitution in @page fall back to the literal. */ @page { - size: a4 portrait; - margin: 2cm; + size: var(--sf-print-page-size, a4) portrait; + margin: var(--sf-print-page-margin, 2cm); } body { diff --git a/core/tokens.css b/core/tokens.css index f3959931..2fdfa50e 100644 --- a/core/tokens.css +++ b/core/tokens.css @@ -176,7 +176,16 @@ Radius + space: syntax enables CSS transitions on layout values — border-radius/padding/gap etc. now interpolate instead of snapping. Shadow: syntax:"*" (no type in spec); inherits:true so :root values - cascade normally; registers the properties formally for DevTools inspection. */ + cascade normally; registers the properties formally for DevTools inspection. + + initial-value ≠ :root default (by spec): a registered requires a + computationally-independent initial-value, so these carry `initial-value: 0` + while their real defaults are the calc()-derived values set on :root below + (see --sf-fluid-width above for the same constraint stated at length). One + consequence worth flagging: once a property is registered, the initial + value IS a value — so a fallback like `var(--sf-radius-m, 12px)` will NEVER + fire; an unset --sf-radius-m resolves to the registered `0`, not to "no + value". Author fallbacks against the token's :root default, not this 0. */ @property --sf-radius-none { syntax: ""; inherits: true; initial-value: 0; } @property --sf-radius-2xs { syntax: ""; inherits: true; initial-value: 0; } @@ -1609,7 +1618,7 @@ --sf-content-gap: var(--sf-space-s); /* tight: within content */ --sf-gutter: var(--sf-space-l); /* wide: page/section gutters */ --sf-component-pad: var(--sf-space-m); /* standard component padding */ - --sf-field-block: var(--sf-space-l); /* form-field block spacing */ + --sf-field-block: var(--sf-space-xs); /* form-field block padding — matches the shipped field default (see optional/forms.css) */ --sf-field-required-marker: " *"; /* Leading space is baked into the default so consumers can fully disable the indicator with `--sf-link-external-marker: ""` — diff --git a/dist/css-custom-data.json b/dist/css-custom-data.json index 29edf420..dd900661 100644 --- a/dist/css-custom-data.json +++ b/dist/css-custom-data.json @@ -1863,12 +1863,12 @@ }, { "name": "--sf-duration-fast", - "description": "Fast duration (~100ms). Hover transitions, tooltip appear.\n\nDefault: `calc(150ms * var(--sf-motion-scale))`", + "description": "Fast duration (~150ms). Hover transitions, tooltip appear.\n\nDefault: `calc(150ms * var(--sf-motion-scale))`", "syntax": "*" }, { "name": "--sf-duration-instant", - "description": "Near-instant duration (~50ms). Micro-interactions and cursor-follow effects.\n\nDefault: `calc(100ms * var(--sf-motion-scale))`", + "description": "Near-instant duration (~100ms). Micro-interactions and cursor-follow effects.\n\nDefault: `calc(100ms * var(--sf-motion-scale))`", "syntax": "*" }, { @@ -1878,12 +1878,12 @@ }, { "name": "--sf-duration-normal", - "description": "Normal duration (~200ms). Default for most transitions.\n\nDefault: `calc(250ms * var(--sf-motion-scale))`", + "description": "Normal duration (~250ms). Default for most transitions.\n\nDefault: `calc(250ms * var(--sf-motion-scale))`", "syntax": "*" }, { "name": "--sf-duration-slow", - "description": "Slow duration (~350ms). Emphasis transitions, drawers.\n\nDefault: `calc(400ms * var(--sf-motion-scale))`", + "description": "Slow duration (~400ms). Emphasis transitions, drawers.\n\nDefault: `calc(400ms * var(--sf-motion-scale))`", "syntax": "*" }, { @@ -1958,7 +1958,7 @@ }, { "name": "--sf-field-block", - "description": "Vertical (block) padding inside form fields.\n\nDefault: `var(--sf-space-l)`", + "description": "Vertical (block) padding inside form fields.\n\nDefault: `var(--sf-space-xs)`", "syntax": "" }, { @@ -3048,17 +3048,17 @@ }, { "name": "--sf-size-m", - "description": "Medium UI component height (~36px). Default size for buttons and inputs.\n\nDefault: `2.5rem`", + "description": "~40px UI component height (scale rung `m`); not currently consumed by any shipped rule.\n\nDefault: `2.5rem`", "syntax": "*" }, { "name": "--sf-size-s", - "description": "Small UI component height (~28px). Compact buttons and input variants.\n\nDefault: `2rem`", + "description": "Small UI component height (~32px). Compact buttons and input variants.\n\nDefault: `2rem`", "syntax": "*" }, { "name": "--sf-size-xl", - "description": "Extra-large interactive component height (~52px). For oversized or prominently touch-friendly controls.\n\nDefault: `3.5rem`", + "description": "Extra-large interactive component height (~56px). For oversized or prominently touch-friendly controls.\n\nDefault: `3.5rem`", "syntax": "*" }, { diff --git a/docs/api-index.json b/docs/api-index.json index 518ab84f..8ddbae96 100644 --- a/docs/api-index.json +++ b/docs/api-index.json @@ -10471,7 +10471,7 @@ "category": "Core tokens", "area": "core", "group": "Motion & easing", - "description": "Fast duration (~100ms). Hover transitions, tooltip appear.", + "description": "Fast duration (~150ms). Hover transitions, tooltip appear.", "value": "calc(150ms * var(--sf-motion-scale))", "aliasOf": null, "registered": false, @@ -10499,7 +10499,7 @@ "category": "Core tokens", "area": "core", "group": "Motion & easing", - "description": "Near-instant duration (~50ms). Micro-interactions and cursor-follow effects.", + "description": "Near-instant duration (~100ms). Micro-interactions and cursor-follow effects.", "value": "calc(100ms * var(--sf-motion-scale))", "aliasOf": null, "registered": false, @@ -10555,7 +10555,7 @@ "category": "Core tokens", "area": "core", "group": "Motion & easing", - "description": "Normal duration (~200ms). Default for most transitions.", + "description": "Normal duration (~250ms). Default for most transitions.", "value": "calc(250ms * var(--sf-motion-scale))", "aliasOf": null, "registered": false, @@ -10583,7 +10583,7 @@ "category": "Core tokens", "area": "core", "group": "Motion & easing", - "description": "Slow duration (~350ms). Emphasis transitions, drawers.", + "description": "Slow duration (~400ms). Emphasis transitions, drawers.", "value": "calc(400ms * var(--sf-motion-scale))", "aliasOf": null, "registered": false, @@ -11004,8 +11004,8 @@ "area": "core", "group": "Output token @property registrations", "description": "Vertical (block) padding inside form fields.", - "value": "var(--sf-space-l)", - "aliasOf": "--sf-space-l", + "value": "var(--sf-space-xs)", + "aliasOf": "--sf-space-xs", "registered": true, "animatable": true, "syntax": "", @@ -17129,7 +17129,7 @@ "category": "Core tokens", "area": "core", "group": "UI sizes", - "description": "Medium UI component height (~36px). Default size for buttons and inputs.", + "description": "~40px UI component height (scale rung `m`); not currently consumed by any shipped rule.", "value": "2.5rem", "aliasOf": null, "registered": false, @@ -17157,7 +17157,7 @@ "category": "Core tokens", "area": "core", "group": "UI sizes", - "description": "Small UI component height (~28px). Compact buttons and input variants.", + "description": "Small UI component height (~32px). Compact buttons and input variants.", "value": "2rem", "aliasOf": null, "registered": false, @@ -17185,7 +17185,7 @@ "category": "Core tokens", "area": "core", "group": "UI sizes", - "description": "Extra-large interactive component height (~52px). For oversized or prominently touch-friendly controls.", + "description": "Extra-large interactive component height (~56px). For oversized or prominently touch-friendly controls.", "value": "3.5rem", "aliasOf": null, "registered": false, diff --git a/docs/api-index.md b/docs/api-index.md index b44d41ea..61afb492 100644 --- a/docs/api-index.md +++ b/docs/api-index.md @@ -376,11 +376,11 @@ and a short description. The machine-readable companion (with all columns) is | `--sf-drop-shadow-s` | PUBLIC | consumption | drop | `drop-shadow(0 1px 2px oklch(from var(--sf-shadow-color) l c h / clamp(0, calc(var(--sf-shadow-strength) * 1.5), 0.7)))` | Small CSS filter: drop-shadow. Use filter (not box-shadow) for elements with transparent areas — SVGs, PNGs, cutout images. | | `--sf-drop-shadow-xl` | PUBLIC | consumption | drop | `drop-shadow(0 16px 32px oklch(from var(--sf-shadow-color) l c h / clamp(0, calc(var(--sf-shadow-strength) * 3), 0.7)))` | Extra-large CSS filter: drop-shadow for hero-scale illustrated or cut-out elements. | | `--sf-drop-shadow-xs` | PUBLIC | consumption | drop | `drop-shadow(0 0.5px 1px oklch(from var(--sf-shadow-color) l c h / clamp(0, calc(var(--sf-shadow-strength) * 1), 0.7)))` | Extra-small CSS filter: drop-shadow, for the subtlest depth on small icons and cutout images. | -| `--sf-duration-fast` | PUBLIC | consumption | duration | `calc(150ms * var(--sf-motion-scale))` | Fast duration (~100ms). Hover transitions, tooltip appear. | -| `--sf-duration-instant` | PUBLIC | consumption | duration | `calc(100ms * var(--sf-motion-scale))` | Near-instant duration (~50ms). Micro-interactions and cursor-follow effects. | +| `--sf-duration-fast` | PUBLIC | consumption | duration | `calc(150ms * var(--sf-motion-scale))` | Fast duration (~150ms). Hover transitions, tooltip appear. | +| `--sf-duration-instant` | PUBLIC | consumption | duration | `calc(100ms * var(--sf-motion-scale))` | Near-instant duration (~100ms). Micro-interactions and cursor-follow effects. | | `--sf-duration-none` | PUBLIC | knob | duration | `0ms` | 0ms duration. Disables transitions (respects prefers-reduced-motion). | -| `--sf-duration-normal` | PUBLIC | consumption | duration | `calc(250ms * var(--sf-motion-scale))` | Normal duration (~200ms). Default for most transitions. | -| `--sf-duration-slow` | PUBLIC | consumption | duration | `calc(400ms * var(--sf-motion-scale))` | Slow duration (~350ms). Emphasis transitions, drawers. | +| `--sf-duration-normal` | PUBLIC | consumption | duration | `calc(250ms * var(--sf-motion-scale))` | Normal duration (~250ms). Default for most transitions. | +| `--sf-duration-slow` | PUBLIC | consumption | duration | `calc(400ms * var(--sf-motion-scale))` | Slow duration (~400ms). Emphasis transitions, drawers. | | `--sf-duration-slower` | PUBLIC | consumption | duration | `calc(600ms * var(--sf-motion-scale))` | Extra-slow duration (~600ms). Page-level enter/exit transitions. | | `--sf-ease-bounce` | PUBLIC | knob | ease | `linear(0, 0.35 18%, 1 32%, 0.86 42%, 1.02 56%, 0.98 72%, 1)` | Bounce easing. Playful overshooting effect. | | `--sf-ease-elastic` | PUBLIC | knob | ease | `linear(0, 0.3, 1.2, 0.9, 1.05, 1)` | Elastic easing. Spring-like overshooting effect. | @@ -390,7 +390,7 @@ and a short description. The machine-readable companion (with all columns) is | `--sf-ease-out` | PUBLIC | knob | ease | `cubic-bezier(0.25, 0, 0.15, 1)` | Decelerating easing. Use for elements entering the viewport. | | `--sf-ease-overshoot` | PUBLIC | knob | ease | `linear(0, 0.6 30%, 1.08 55%, 0.98 75%, 1)` | Subtle overshoot easing. Softer spring effect. | | `--sf-ease-spring` | PUBLIC | knob | ease | `linear(0, 0.5, 1.1, 0.95, 1.02, 1)` | Spring easing. Natural physics-based motion. | -| `--sf-field-block` | PUBLIC | consumption | field | `var(--sf-space-l)` | Vertical (block) padding inside form fields. | +| `--sf-field-block` | PUBLIC | consumption | field | `var(--sf-space-xs)` | Vertical (block) padding inside form fields. | | `--sf-field-required-marker` | PUBLIC | knob | field | `" *"` | CSS content value for required-field markers (e.g. " *"). Used by pseudo-elements in the .required utility pattern. | | `--sf-fluid-max-vw` | PUBLIC-ADVANCED | knob | fluid | `90` | Maximum viewport width (in rem) at which the fluid scale clamps to its largest values. Default 90rem (~1440px). Part of the fluid scale engine. | | `--sf-fluid-min-vw` | PUBLIC-ADVANCED | knob | fluid | `22.5` | Minimum viewport width (in rem) at which the fluid scale clamps to its smallest values. Default 22.5rem (~360px). Part of the fluid scale engine. | @@ -564,9 +564,9 @@ and a short description. The machine-readable companion (with all columns) is | `--sf-shadow-xl` | PUBLIC | consumption | shadow | `0 2px 8px 0 oklch(from var(--sf-shadow-color) l c h / clamp(0, calc(var(--sf-shadow-strength) * 0.5), 0.7)), 0 12px 36px 0 oklch(from var(--sf-shadow-color) l c h / clamp(0, calc(var(--sf-shadow-strength) * 3.5), 0.7)), 0 24px 72px 0 oklch(from var(--sf-shadow-color) l c h / clamp(0, calc(var(--sf-shadow-strength) * 2.5), 0.7))` | Extra-large shadow for modals, dialogs, and high-elevation sheets. | | `--sf-shadow-xs` | PUBLIC | consumption | shadow | `0 1px 2px 0 oklch(from var(--sf-shadow-color) l c h / clamp(0, calc(var(--sf-shadow-strength) * 0.5), 0.7))` | Extra-small shadow for barely-elevated elements (focused inputs, small chips). | | `--sf-size-l` | PUBLIC | knob | size | `2.75rem` | Large UI component height (~44px). Touch-friendly interactive elements. | -| `--sf-size-m` | PUBLIC | knob | size | `2.5rem` | Medium UI component height (~36px). Default size for buttons and inputs. | -| `--sf-size-s` | PUBLIC | knob | size | `2rem` | Small UI component height (~28px). Compact buttons and input variants. | -| `--sf-size-xl` | PUBLIC | knob | size | `3.5rem` | Extra-large interactive component height (~52px). For oversized or prominently touch-friendly controls. | +| `--sf-size-m` | PUBLIC | knob | size | `2.5rem` | ~40px UI component height (scale rung `m`); not currently consumed by any shipped rule. | +| `--sf-size-s` | PUBLIC | knob | size | `2rem` | Small UI component height (~32px). Compact buttons and input variants. | +| `--sf-size-xl` | PUBLIC | knob | size | `3.5rem` | Extra-large interactive component height (~56px). For oversized or prominently touch-friendly controls. | | `--sf-size-xs` | PUBLIC | knob | size | `1.5rem` | Extra-small interactive component height (~24px). For very compact inline elements and micro-controls. | | `--sf-space-2xl` | PUBLIC | consumption | space | `calc(clamp(calc(var(--sf-space-base-min) * pow(var(--sf-space-ratio-min), 3) * 1rem), calc((var(--sf-space-base-max) * pow(var(--sf-space-ratio-max), 3) - var(--sf-space-base-min) * pow(var(--sf-space-ratio-min), 3)) / (var(--sf-fluid-max-vw) - var(--sf-fluid-min-vw)) * (var(--sf-fluid-width) - var(--sf-fluid-min-vw) * 1rem) + var(--sf-space-base-min) * pow(var(--sf-space-ratio-min), 3) * 1rem), calc(var(--sf-space-base-max) * pow(var(--sf-space-ratio-max), 3) * 1rem)) * var(--sf-space-scale))` | 48px-equivalent spacing. Large section spacing. | | `--sf-space-2xs` | PUBLIC | consumption | space | `calc(clamp(calc(var(--sf-space-base-min) * pow(var(--sf-space-ratio-min), -3) * 1rem), calc((var(--sf-space-base-max) * pow(var(--sf-space-ratio-max), -3) - var(--sf-space-base-min) * pow(var(--sf-space-ratio-min), -3)) / (var(--sf-fluid-max-vw) - var(--sf-fluid-min-vw)) * (var(--sf-fluid-width) - var(--sf-fluid-min-vw) * 1rem) + var(--sf-space-base-min) * pow(var(--sf-space-ratio-min), -3) * 1rem), calc(var(--sf-space-base-max) * pow(var(--sf-space-ratio-max), -3) * 1rem)) * var(--sf-space-scale))` | 4px-equivalent spacing. Tight inline gaps, icon-to-text padding. | diff --git a/docs/architecture.md b/docs/architecture.md index edd8c3f4..50d1ddaa 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -249,7 +249,7 @@ utility classes in 0.x; the layer slot is reserved for the future. ### BEM consumer-API tokens -Many tokens — `--sf-shadow-*`, `--sf-blur-*`, `--sf-gap`, `--sf-gradient-*`, +Many tokens — `--sf-shadow-*`, `--sf-blur`, `--sf-gap`, `--sf-gradient-*`, `--sf-scrollbar-*`, `--sf-optical-sizing` — are **not consumed by the framework itself**. They exist for your own BEM classes (`.card { box-shadow: var(--sf-shadow-m) }`) and are exercised in diff --git a/docs/llm-guide.md b/docs/llm-guide.md index adec7191..234bc628 100644 --- a/docs/llm-guide.md +++ b/docs/llm-guide.md @@ -637,7 +637,7 @@ Apply heading-level typography to a non-heading element with `.sf-h1` … `.sf-h --sf-content-gap: var(--sf-space-s) /* tight gap — within content (stack, flow, prose) */ --sf-gutter: var(--sf-space-l) /* wide gutter — page/section edges (center) */ --sf-component-pad: var(--sf-space-m) /* button, card padding */ ---sf-field-block: var(--sf-space-l) /* vertical spacing for form field groups */ +--sf-field-block: var(--sf-space-xs) /* form-field block padding (core-tier fallback for --sf-field-padding-block) */ ``` ### 7.4 Section padding diff --git a/docs/motion.md b/docs/motion.md index b90c3421..32311370 100644 --- a/docs/motion.md +++ b/docs/motion.md @@ -98,7 +98,7 @@ Combine with a `--sf-animation-delay-*` token for staggered sequences. | Class | Effect | |---|---| -| `.sf-color-pulse` | animates `--sf-color-primary-light` lightness via `@property` interpolation in oklch | +| `.sf-color-pulse` | animates `--sf-color-primary-source-light` lightness via `@property` interpolation in oklch | ## Keyframes diff --git a/docs/token-annotations.json b/docs/token-annotations.json index 2e8e426b..cd31493c 100644 --- a/docs/token-annotations.json +++ b/docs/token-annotations.json @@ -292,10 +292,10 @@ "--sf-border-width-3": "Thick 3px border width.", "--sf-border-width-4": "Extra-thick 4px border width for decorative borders.", "--sf-duration-none": "0ms duration. Disables transitions (respects prefers-reduced-motion).", - "--sf-duration-instant": "Near-instant duration (~50ms). Micro-interactions and cursor-follow effects.", - "--sf-duration-fast": "Fast duration (~100ms). Hover transitions, tooltip appear.", - "--sf-duration-normal": "Normal duration (~200ms). Default for most transitions.", - "--sf-duration-slow": "Slow duration (~350ms). Emphasis transitions, drawers.", + "--sf-duration-instant": "Near-instant duration (~100ms). Micro-interactions and cursor-follow effects.", + "--sf-duration-fast": "Fast duration (~150ms). Hover transitions, tooltip appear.", + "--sf-duration-normal": "Normal duration (~250ms). Default for most transitions.", + "--sf-duration-slow": "Slow duration (~400ms). Emphasis transitions, drawers.", "--sf-duration-slower": "Extra-slow duration (~600ms). Page-level enter/exit transitions.", "--sf-ease-linear": "Linear easing. Use for progress indicators and spinner rotations.", "--sf-ease-in": "Accelerating easing. Use for elements leaving the viewport.", @@ -320,8 +320,8 @@ "--sf-icon-l": "Large icon size (~24px). Standalone icon buttons.", "--sf-icon-xl": "Extra-large icon size (~32px). Feature icons.", "--sf-icon-2xl": "2× large icon size (~40px+). Hero or onboarding icons.", - "--sf-size-s": "Small UI component height (~28px). Compact buttons and input variants.", - "--sf-size-m": "Medium UI component height (~36px). Default size for buttons and inputs.", + "--sf-size-s": "Small UI component height (~32px). Compact buttons and input variants.", + "--sf-size-m": "~40px UI component height (scale rung `m`); not currently consumed by any shipped rule.", "--sf-size-l": "Large UI component height (~44px). Touch-friendly interactive elements.", "--sf-container-narrow": "Narrow content max-width — ideal for article body and blog posts (~65ch).", "--sf-container-prose": "Prose max-width — optimised for readability (~75ch).", @@ -667,7 +667,7 @@ "--sf-section-pad--xs": "Extra-small vertical section padding for compact in-page modules.", "--sf-section-pad--xl": "Extra-large vertical section padding for hero and feature sections.", "--sf-size-xs": "Extra-small interactive component height (~24px). For very compact inline elements and micro-controls.", - "--sf-size-xl": "Extra-large interactive component height (~52px). For oversized or prominently touch-friendly controls.", + "--sf-size-xl": "Extra-large interactive component height (~56px). For oversized or prominently touch-friendly controls.", "--sf-text-2xs-font-weight": "Font weight for 2×-extra-small text. Override to deviate from the global weight at this size.", "--sf-text-2xs-letter-spacing": "Letter-spacing for 2×-extra-small text.", "--sf-text-2xs-line-height": "Line height for 2×-extra-small text.", diff --git a/docs/token-index.json b/docs/token-index.json index 80c39ced..589be873 100644 --- a/docs/token-index.json +++ b/docs/token-index.json @@ -3155,7 +3155,7 @@ "files": [ "core/tokens.css" ], - "value": "var(--sf-space-l)" + "value": "var(--sf-space-xs)" }, "--sf-field-padding-block": { "tier": "PUBLIC", diff --git a/docs/token-index.md b/docs/token-index.md index 1744154a..e8d400f7 100644 --- a/docs/token-index.md +++ b/docs/token-index.md @@ -482,7 +482,7 @@ declared value (a value that references `var(--sf-…)` is a derived output): | `--sf-equal-rule-color` | PUBLIC | consumption | Layout | `var(--sf-color-border)` | | `--sf-equal-rule-style` | PUBLIC | knob | Layout | `solid` | | `--sf-equal-rule-width` | PUBLIC | knob | Layout | `0` | -| `--sf-field-block` | PUBLIC | consumption | Core | `var(--sf-space-l)` | +| `--sf-field-block` | PUBLIC | consumption | Core | `var(--sf-space-xs)` | | `--sf-field-padding-block` | PUBLIC | consumption | Components (optional, incomplete) | `var(--sf-space-xs)` | | `--sf-field-padding-inline` | PUBLIC | consumption | Components (optional, incomplete) | `var(--sf-space-s)` | | `--sf-field-radius` | PUBLIC | consumption | Components (optional, incomplete) | `var(--sf-radius-m)` | diff --git a/docs/tokens.md b/docs/tokens.md index 80d1c6ee..744ee309 100644 --- a/docs/tokens.md +++ b/docs/tokens.md @@ -352,7 +352,7 @@ rebrand workflow. | `--sf-ease-out` | `cubic-bezier(0.25, 0, 0.15, 1)` | | `--sf-ease-overshoot` | `linear(0, 0.6 30%, 1.08 55%, 0.98 75%, 1)` | | `--sf-ease-spring` | `linear(0, 0.5, 1.1, 0.95, 1.02, 1)` | -| `--sf-field-block` | `var(--sf-space-l)` | +| `--sf-field-block` | `var(--sf-space-xs)` | | `--sf-field-required-marker` | `" *"` | | `--sf-fluid-max-vw` | `90` | | `--sf-fluid-min-vw` | `22.5` | diff --git a/optional/components.css b/optional/components.css index 72b497de..9d9e6d46 100644 --- a/optional/components.css +++ b/optional/components.css @@ -298,7 +298,17 @@ } /* --- Sizes (m is the default; retune the rule-local *-size tier so a - :root override of the public knob still wins over the size) --- */ + :root override of the public knob still wins over the size) --- + + Note on the min-height ladder: the variant names (--xs/--s/--l/--xl) do + NOT map 1:1 onto the --sf-size-* scale rungs. The default (m) button + resolves min-height through --sf-touch-target (= --sf-size-l, 2.75rem) so + the baseline control always clears the touch-target floor, which pushes + the whole ladder up by one rung: --l reads --sf-size-xl and --xl adds + --sf-space-s on top of it. This offset is deliberate — a button needs a + larger hit area than the bare type-scale rung of the same name — so + --sf-size-m is intentionally not consumed here. Remapping onto the literal + rungs would resize every existing button (a visual regression); don't. */ .sf-btn--xs { --sf-btn-padding-block--size: 0.125rem; --sf-btn-padding-inline--size: var(--sf-space-xs); diff --git a/optional/forms.css b/optional/forms.css index 0735da73..988a1dbc 100644 --- a/optional/forms.css +++ b/optional/forms.css @@ -20,14 +20,21 @@ display: block; inline-size: 100%; - padding-block: var(--sf-space-xs); - padding-inline: var(--sf-space-s); + /* Field metrics resolve through the component-tier field knobs first, then + the core rhythm knob, then the literal — so overriding any of them retunes + fields without touching global spacing/radius. Every fallback equals the + shipped default, so the default render is unchanged. --sf-field-padding-* + and --sf-field-radius live in optional/tokens.components.css (present in + the -components/full bundles); --sf-field-block is the core-tier fallback + so the knob still works in the forms-only bundles. */ + padding-block: var(--sf-field-padding-block, var(--sf-field-block, var(--sf-space-xs))); + padding-inline: var(--sf-field-padding-inline, var(--sf-space-s)); font-size: var(--sf-text-m); line-height: var(--sf-leading-normal); color: var(--sf-color-text); background-color: var(--sf-color-surface); border: var(--sf-border-width-1) solid var(--sf-field-border-color, var(--sf-color-border)); - border-radius: var(--sf-radius-m); + border-radius: var(--sf-field-radius, var(--sf-radius-m)); transition: var(--sf-transition-form-field); } diff --git a/optional/tokens.components.css b/optional/tokens.components.css index 4ced009d..944becbb 100644 --- a/optional/tokens.components.css +++ b/optional/tokens.components.css @@ -39,9 +39,10 @@ /* ============================================================ * FIELD TOKENS * Override to give form fields a distinct visual language from - * buttons without touching global radius/spacing tokens. Reserved - * for a future .sf-field class (optional/forms.css) — no consumer - * ships in this layer yet. + * buttons without touching global radius/spacing tokens. Consumed + * by the classless field rules in optional/forms.css (padding-block, + * padding-inline, border-radius); each fallback there equals these + * defaults so an unset knob renders identically. * ============================================================ */ --sf-field-radius: var(--sf-radius-m); --sf-field-padding-block: var(--sf-space-xs); diff --git a/optional/utilities.css b/optional/utilities.css index 805fcbfa..8f4ef8ec 100644 --- a/optional/utilities.css +++ b/optional/utilities.css @@ -28,7 +28,10 @@ /* ============================================================ * HEADING HELPER CLASSES (.sf-h1 – .sf-h6) * Heading-level typography on any element without changing - * document semantics. Mirrors the h1–h6 rules in core/base.css. + * document semantics. Carries the typographic scale from the + * h1–h6 rules in core/base.css, including overflow-wrap; margins + * are intentionally NOT reset here (a visual class shouldn't clear + * the caller's spacing the way the base element reset does). *
Visually H2, semantically a div
* ============================================================ */ .sf-h1, @@ -41,6 +44,7 @@ font-weight: var(--sf-font-weight-heading); color: var(--sf-heading-color, var(--sf-color-heading)); text-wrap: var(--sf-heading-text-wrap); + overflow-wrap: break-word; } .sf-h1 { font-size: var(--sf-h1-size); line-height: var(--sf-h1-line-height); font-weight: var(--sf-h1-font-weight); letter-spacing: var(--sf-h1-letter-spacing); max-inline-size: var(--sf-h1-max-width); } .sf-h2 { font-size: var(--sf-h2-size); line-height: var(--sf-h2-line-height); font-weight: var(--sf-h2-font-weight); letter-spacing: var(--sf-h2-letter-spacing); max-inline-size: var(--sf-h2-max-width); } diff --git a/package.json b/package.json index 7240de10..6bd828d9 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,11 @@ "check:macros": "node scripts/check-macro-catalog.js", "check:registry": "node scripts/check-token-registry.js", "check:llm-guide": "node scripts/check-llm-guide.js", + "check:dead-knobs": "node scripts/check-dead-knobs.js", + "check:annotations": "node scripts/check-annotations.js", + "check:hook-tokens": "node scripts/check-hook-tokens.js", + "check:mirrors": "node scripts/check-mirrors.js", + "check:bundle-defs": "node scripts/check-bundle-defs.js", "configurator:sync": "node configurator/scripts/sync-api.mjs", "docs:tokens": "node scripts/gen-token-reference.js", "docs:index": "node scripts/gen-token-index.js", diff --git a/scripts/check-annotations.js b/scripts/check-annotations.js new file mode 100644 index 00000000..a11b880e --- /dev/null +++ b/scripts/check-annotations.js @@ -0,0 +1,140 @@ +#!/usr/bin/env node +/** + * CI gate (#582 D2): keep docs/token-annotations.json prose honest against the + * tokens' real declared values. + * + * token-annotations.json is the hand-authored source of every token + * description; api-index.{json,md} are generated from it. Nothing previously + * compared the prose to the value, so an annotation could claim "~50ms" for a + * 100ms token or "~36px" for a 40px one (the B1/B2 findings in #582). + * + * Heuristics (deliberately conservative — hard-fail only on the unambiguous + * cases, warn on the rest, per #582 D2): + * - HARD FAIL: an annotation stating "~N ms" whose token resolves to a + * concrete millisecond literal ≠ N. + * - HARD FAIL: an annotation stating "~N px" whose token resolves to a + * SIMPLE length (a bare `rem`/`px` value, optionally one var() alias deep) + * ≠ N (±1px for rem rounding). Fluid clamp()/calc() values are + * unresolvable here and skipped — their "~px" is a reference hint, not a + * literal. + * - WARN: an annotation asserting the token is the "default for " + * while no shipped rule consumes it. + * + * Run: + * node scripts/check-annotations.js + * npm run check:annotations + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { stripComments, readValue } from './lib/parse.js'; +import { TOKEN_FILES } from './registry-sources.js'; + +const _root = process.env.SLASHED_ROOT?.trim(); +const ROOT = _root ? path.resolve(_root) : path.resolve(import.meta.dirname, '..'); +const ANNOTATIONS = path.join(ROOT, 'docs', 'token-annotations.json'); + +const PX_PER_REM = 16; // SLASHED's :root font-size baseline +const PX_TOLERANCE = 1; // absorb rem→px rounding (e.g. 0.6875rem = 11px) + +// ── Declared values + @property initial-values ─────────────────────────────── +const declaredValue = new Map(); +const initialValue = new Map(); +for (const rel of TOKEN_FILES) { + const css = stripComments(fs.readFileSync(path.join(ROOT, rel), 'utf8')); + const re = /(--sf-[a-z0-9_-]+)\s*:/g; + let m; + while ((m = re.exec(css))) { + declaredValue.set(m[1], readValue(css, re.lastIndex - 1)); // last declaration wins + } + for (const mm of css.matchAll(/@property\s+(--sf-[a-z0-9_-]+)\s*\{[^}]*initial-value:\s*([^;}]+)[;}]/g)) { + initialValue.set(mm[1], mm[2].trim()); + } +} + +// Framework consumption corpus (for the "default for X" warning). +let corpus = ''; +for (const dir of ['core', 'optional']) { + const abs = path.join(ROOT, dir); + if (!fs.existsSync(abs)) continue; + for (const f of fs.readdirSync(abs).filter((f) => f.endsWith('.css'))) { + corpus += '\n' + stripComments(fs.readFileSync(path.join(abs, f), 'utf8')); + } +} +const isConsumed = (name) => { + const esc = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + return new RegExp(`var\\(\\s*${esc}[\\s,)]`).test(corpus); +}; + +/** Resolve a token to its base value, following at most a chain of pure var() aliases. */ +function baseValue(name, seen = new Set()) { + if (seen.has(name)) return null; + seen.add(name); + let v = declaredValue.get(name) ?? initialValue.get(name); + if (v == null) return null; + v = v.trim(); + const alias = v.match(/^var\(\s*(--sf-[a-z0-9_-]+)\s*(?:,[^)]*)?\)$/); + if (alias) return baseValue(alias[1], seen); + return v; +} + +/** Milliseconds if the base value carries a concrete ms literal, else null. */ +function resolveMs(name) { + const v = baseValue(name); + const m = v && v.match(/(-?[\d.]+)\s*ms/); + return m ? Number(m[1]) : null; +} + +/** Pixels if the base value is a simple bare rem/px length, else null. */ +function resolvePx(name) { + const v = baseValue(name); + if (v == null) return null; + let m = v.match(/^(-?[\d.]+)rem$/); + if (m) return Number(m[1]) * PX_PER_REM; + m = v.match(/^(-?[\d.]+)px$/); + if (m) return Number(m[1]); + return null; +} + +const annotations = JSON.parse(fs.readFileSync(ANNOTATIONS, 'utf8')).tokens ?? {}; + +const errors = []; +const warnings = []; + +for (const [name, note] of Object.entries(annotations)) { + if (typeof note !== 'string' || !name.startsWith('--sf-')) continue; + + const ms = note.match(/~\s*([\d.]+)\s*ms/i); + if (ms) { + const actual = resolveMs(name); + if (actual != null && Math.abs(actual - Number(ms[1])) > 0.5) { + errors.push(`${name}: annotation says ~${ms[1]}ms but the token resolves to ${actual}ms.`); + } + } + + const px = note.match(/~\s*([\d.]+)\s*px/i); + if (px) { + const actual = resolvePx(name); + if (actual != null && Math.abs(actual - Number(px[1])) > PX_TOLERANCE) { + errors.push(`${name}: annotation says ~${px[1]}px but the token resolves to ${actual}px (${baseValue(name)}).`); + } + } + + if (/\bdefault (?:size )?for\b.*\b(button|input|field|control)/i.test(note) && !isConsumed(name)) { + warnings.push(`${name}: annotation claims it's the default for controls, but no shipped rule consumes it.`); + } +} + +if (warnings.length) { + console.warn(`check:annotations WARNING — ${warnings.length} prose claim(s) worth reviewing:`); + for (const w of warnings) console.warn(` ${w}`); +} + +if (errors.length) { + console.error('check:annotations FAILED — annotation prose disagrees with the token value:'); + for (const e of errors) console.error(` ${e}`); + console.error('\nFix: correct the ~N ms/px figure in docs/token-annotations.json (then run npm run docs:api).'); + process.exit(1); +} + +console.log('check:annotations OK — every resolvable ~ms/~px figure matches its token value.'); diff --git a/scripts/check-bundle-defs.js b/scripts/check-bundle-defs.js new file mode 100644 index 00000000..81bf9dcd --- /dev/null +++ b/scripts/check-bundle-defs.js @@ -0,0 +1,62 @@ +#!/usr/bin/env node +/** + * CI gate (#582 D6): every no-fallback token consumption in a bundle must have + * its definition ship in that same bundle. + * + * The existing tests check flat cascade ORDERING, not whether a bundle actually + * contains a definition for every token it reads. A `var(--sf-x)` with no + * fallback in a bundle that never declares --sf-x resolves to the guaranteed- + * invalid/initial value — a silent breakage that only shows up at runtime in + * that specific bundle. This gate reads bundle.config.json, concatenates each + * bundle's sources, and fails if any bundle consumes a token (without a + * fallback) that it never declares (`@property --sf-x` or `--sf-x:`). + * + * Fallback consumptions — `var(--sf-x, )` — are intentionally NOT + * checked: the fallback is the safety net (that's the hook-token pattern, + * scripts/hook-tokens.js). Only bare `var(--sf-x)` needs a guaranteed def. + * + * Run: + * node scripts/check-bundle-defs.js + * npm run check:bundle-defs + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { stripComments } from './lib/parse.js'; + +const _root = process.env.SLASHED_ROOT?.trim(); +const ROOT = _root ? path.resolve(_root) : path.resolve(import.meta.dirname, '..'); +const CONFIG = path.join(ROOT, 'bundle.config.json'); + +const { bundles } = JSON.parse(fs.readFileSync(CONFIG, 'utf8')); + +const failures = []; +for (const bundle of bundles) { + let css = ''; + for (const rel of bundle.files) { + css += '\n' + stripComments(fs.readFileSync(path.join(ROOT, rel), 'utf8')); + } + + const declared = new Set(); + for (const m of css.matchAll(/@property\s+(--sf-[a-z0-9_-]+)/g)) declared.add(m[1]); + for (const m of css.matchAll(/(--sf-[a-z0-9_-]+)\s*:/g)) declared.add(m[1]); + + // Bare consumption only: var(--sf-x) with no comma/fallback. + const missing = new Set(); + for (const m of css.matchAll(/var\(\s*(--sf-[a-z0-9_-]+)\s*\)/g)) { + if (!declared.has(m[1])) missing.add(m[1]); + } + + if (missing.size) { + failures.push(`${path.basename(bundle.output)}: consumes but never defines ${[...missing].sort().join(', ')}`); + } +} + +if (failures.length) { + console.error('check:bundle-defs FAILED — a bundle reads a token it never ships a definition for:'); + for (const f of failures) console.error(` ${f}`); + console.error('\nFix: add the token\'s source file to that bundle in bundle.config.json, or give the consumer a fallback.'); + process.exit(1); +} + +console.log(`check:bundle-defs OK — all ${bundles.length} bundles define every token they consume without a fallback.`); diff --git a/scripts/check-dead-knobs.js b/scripts/check-dead-knobs.js new file mode 100644 index 00000000..39a9f328 --- /dev/null +++ b/scripts/check-dead-knobs.js @@ -0,0 +1,92 @@ +#!/usr/bin/env node +/** + * CI gate (#582 D1): a DEAD KNOB is a token whose annotation claims it is wired + * to a property/behaviour while no rule actually reads it. + * + * `scripts/audit.js --unused` already lists every token the framework never + * consumes (via var(--sf-name)), but that list is ~160 entries and is + * WARNING-only: most of those are legitimate "offer tokens" — public knobs a + * consumer sets on their own BEM classes, which the framework itself needn't + * read. That gate can't tell an offer token apart from a genuinely dead one. + * + * The discriminator is the annotation. If docs/token-annotations.json says a + * token "Maps to @page margin" / "Applied to the X property" — an active + * wiring claim — then a rule MUST consume it. A wiring claim with zero + * consumers is a false promise (the A1/A2 findings in #582): it fails here. + * Offer tokens, whose annotations only describe intent ("Backdrop blur amount + * for your frosted surfaces"), are left to the warning-level --unused report. + * + * Run: + * node scripts/check-dead-knobs.js + * npm run check:dead-knobs + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { stripComments } from './lib/parse.js'; + +const _root = process.env.SLASHED_ROOT?.trim(); +const ROOT = _root ? path.resolve(_root) : path.resolve(import.meta.dirname, '..'); +const ANNOTATIONS = path.join(ROOT, 'docs', 'token-annotations.json'); + +// Active-wiring phrases: an annotation using one of these promises the token is +// read by a shipped rule. Purely descriptive annotations don't match. Phrases +// that commonly appear NEGATED in disclaimers ("not consumed by any rule") are +// intentionally excluded so a truthful "this isn't wired" note can't trip the +// gate. +const WIRING_CLAIM = /\bmaps to\b|\bapplied (?:to|in|on)\b|\bwired to\b|\bhooks? into\b/i; + +const cssFiles = []; +for (const dir of ['core', 'optional']) { + const abs = path.join(ROOT, dir); + if (!fs.existsSync(abs)) continue; + for (const f of fs.readdirSync(abs).filter((f) => f.endsWith('.css'))) { + cssFiles.push(path.join(dir, f)); + } +} + +let corpus = ''; +const declared = new Set(); +const propertyRegistered = new Set(); +for (const rel of cssFiles) { + const css = stripComments(fs.readFileSync(path.join(ROOT, rel), 'utf8')); + corpus += '\n' + css; + for (const m of css.matchAll(/@property\s+(--sf-[a-z0-9_-]+)/g)) propertyRegistered.add(m[1]); + for (const m of css.matchAll(/(--sf-[a-z0-9_-]+)\s*:/g)) declared.add(m[1]); +} + +function isConsumed(name) { + const esc = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + return new RegExp(`var\\(\\s*${esc}[\\s,)]`).test(corpus); +} + +const annotations = JSON.parse(fs.readFileSync(ANNOTATIONS, 'utf8')); +// The annotations file is a flat { tokens: {...} } / or top-level map; support both. +const tokenNotes = annotations.tokens ?? annotations; + +const dead = []; +for (const [name, note] of Object.entries(tokenNotes)) { + if (typeof note !== 'string') continue; + if (!name.startsWith('--sf-')) continue; + if (!WIRING_CLAIM.test(note)) continue; // only annotations that PROMISE wiring + if (!declared.has(name)) continue; // undeclared hooks are handled elsewhere + // @property-registered tokens are "used" by their registration; but a wiring + // claim still requires a real consumer, so don't exempt them here. + if (isConsumed(name)) continue; // claim honoured — fine + dead.push({ name, note }); +} + +if (dead.length) { + console.error('check:dead-knobs FAILED — annotation promises wiring but no rule consumes the token:'); + for (const { name, note } of dead) { + console.error(` ${name}: "${note}"`); + } + console.error( + '\nFix: either wire the token in a rule (var(' + + dead[0].name + ', )), or rewrite the annotation to drop the ' + + 'wiring claim (see #582 A1/A2).', + ); + process.exit(1); +} + +console.log('check:dead-knobs OK — every wiring-claim annotation is backed by a real consumer.'); diff --git a/scripts/check-hook-tokens.js b/scripts/check-hook-tokens.js new file mode 100644 index 00000000..544a89d6 --- /dev/null +++ b/scripts/check-hook-tokens.js @@ -0,0 +1,99 @@ +#!/usr/bin/env node +/** + * CI gate (#582 D5): keep the fallback-only hook-token policy honest. + * + * A "hook token" is a --sf-* name that is NEVER declared in source (no + * `--sf-x:` line, no `@property` registration) and is consumed only as the + * first argument of a `var(--sf-hook, )` call. Because it has no + * default value it is deliberately left out of the generated catalogues; it is + * documented in docs/llm-guide.md as an override hook. scripts/hook-tokens.js + * is the single source of truth for that list. + * + * This gate verifies two directions so the policy can't silently rot: + * 1. Every token in HOOK_TOKENS really is (a) undeclared in source, + * (b) consumed with a fallback, and (c) mentioned in docs/llm-guide.md. + * 2. Every undeclared-but-consumed --sf-* found in source is EITHER covered + * by the registry / a declaration elsewhere, OR listed in HOOK_TOKENS — + * so a newly-introduced fallback-only hook forces a conscious entry here. + * + * Run: + * node scripts/check-hook-tokens.js + * npm run check:hook-tokens + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { stripComments } from './lib/parse.js'; +import { HOOK_TOKENS, HOOK_TOKEN_NAMES } from './hook-tokens.js'; + +const _root = process.env.SLASHED_ROOT?.trim(); +const ROOT = _root ? path.resolve(_root) : path.resolve(import.meta.dirname, '..'); +const GUIDE = path.join(ROOT, 'docs', 'llm-guide.md'); +const REGISTRY = path.join(ROOT, 'token-registry.json'); + +const cssFiles = []; +for (const dir of ['core', 'optional']) { + const abs = path.join(ROOT, dir); + if (!fs.existsSync(abs)) continue; + for (const f of fs.readdirSync(abs).filter((f) => f.endsWith('.css'))) { + cssFiles.push(path.join(dir, f)); + } +} + +// Comment-stripped corpus, and the set of names that are actually DECLARED +// (via `@property` or `--sf-x:`) anywhere in source. +let corpus = ''; +const declared = new Set(); +for (const rel of cssFiles) { + const css = stripComments(fs.readFileSync(path.join(ROOT, rel), 'utf8')); + corpus += '\n' + css; + for (const m of css.matchAll(/@property\s+(--sf-[a-z0-9_-]+)/g)) declared.add(m[1]); + for (const m of css.matchAll(/(--sf-[a-z0-9_-]+)\s*:/g)) declared.add(m[1]); +} + +const registryNames = new Set( + JSON.parse(fs.readFileSync(REGISTRY, 'utf8')).tokens + .filter((t) => !t.removed) + .map((t) => t.name), +); + +const guideText = fs.readFileSync(GUIDE, 'utf8'); + +const errors = []; + +// ── Direction 1: every listed hook token behaves like a hook ───────────────── +for (const { name } of HOOK_TOKENS) { + const esc = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + if (declared.has(name)) { + errors.push(`${name}: listed as a hook token but IS declared in source — remove it from hook-tokens.js or stop declaring it.`); + } + // Consumed with a fallback: var(--sf-hook, …). A bare var(--sf-hook) with no + // fallback would mean the hook has no safety net and shouldn't be a hook. + const withFallback = new RegExp(`var\\(\\s*${esc}\\s*,`); + if (!withFallback.test(corpus)) { + errors.push(`${name}: listed as a hook token but never consumed as var(${name}, ) in source.`); + } + if (!guideText.includes(name)) { + errors.push(`${name}: hook tokens must be documented in docs/llm-guide.md, but it is not mentioned there.`); + } +} + +// ── Direction 2: no undeclared fallback-hook escapes the allowlist ─────────── +// Any --sf-x consumed as var(--sf-x, …) that is neither declared nor catalogued +// is a de-facto hook token and must be registered in hook-tokens.js. +const consumedWithFallback = new Set(); +for (const m of corpus.matchAll(/var\(\s*(--sf-[a-z0-9_-]+)\s*,/g)) { + consumedWithFallback.add(m[1]); +} +for (const name of consumedWithFallback) { + if (declared.has(name) || registryNames.has(name) || HOOK_TOKEN_NAMES.has(name)) continue; + errors.push(`${name}: consumed only as a var() fallback and neither declared, catalogued, nor listed in scripts/hook-tokens.js. Add it there (see #582 D5).`); +} + +if (errors.length) { + console.error('check:hook-tokens FAILED:'); + for (const e of errors) console.error(` ${e}`); + process.exit(1); +} + +console.log(`check:hook-tokens OK — ${HOOK_TOKENS.length} fallback-only hook token(s) verified.`); diff --git a/scripts/check-llm-guide.js b/scripts/check-llm-guide.js index e84e3563..8b076fd3 100644 --- a/scripts/check-llm-guide.js +++ b/scripts/check-llm-guide.js @@ -5,9 +5,14 @@ * Check 1 (hard fail): every --sf-* name mentioned in the guide must exist as * a live token. The live set is the union of: * a) token-registry.json (non-removed entries) — the catalogued public API - * b) --sf-* custom property declarations in core/ and optional/ CSS files — - * catches scoped override hooks (e.g. --sf-field-border-color) that are - * actively used in the framework but not yet catalogued in the registry. + * b) --sf-* custom property DECLARATIONS in core/ and optional/ CSS files — + * `@property --sf-x` registrations and `--sf-x:` declarations only, NOT + * `var(--sf-x)` consumption. Catches scoped override hooks (e.g. + * --sf-field-border-color) that are actively declared in the framework but + * not yet catalogued in the registry, without letting a guide reference a + * name that is *only ever consumed* slip through unnoticed (#582 D3). + * c) the fallback-only hook tokens (scripts/hook-tokens.js) — deliberately + * undeclared override hooks the guide is allowed to name (#582 D5). * * Check 2 (warning): PUBLIC and PUBLIC-ADVANCED *knob* tokens absent from the * guide are reported so authors know what coverage gaps exist. This is a @@ -25,6 +30,8 @@ import fs from 'node:fs'; import path from 'node:path'; +import { stripComments } from './lib/parse.js'; +import { HOOK_TOKEN_NAMES } from './hook-tokens.js'; // SLASHED_ROOT lets negative tests run the gate against a fixture tree. An // empty/whitespace value counts as unset; a relative override is resolved to @@ -60,19 +67,29 @@ const liveTokens = new Set( registry.tokens.filter((t) => !t.removed).map((t) => t.name), ); -// Live set b): any --sf-* declared as a custom property in CSS source files. -// Matches " --sf-foo:" (declaration) and "--sf-foo," / "--sf-foo)" in -// comment-listed token inventories in tokens.css headers. -const CSS_DECL_RE = /--sf-[a-z0-9_-]+(?=\s*[:,)])/g; +// Live set b): any --sf-* DECLARED as a custom property in CSS source files. +// Only real declarations count — `@property --sf-x` registrations and `--sf-x:` +// declarations — never `var(--sf-x)` consumption. (Before #582 D3 the lookahead +// `[:,)]` also matched the `,`/`)` after a consumed token, so the guide could +// name a --sf-* that is only ever read, never declared, and still pass.) +// Comments are stripped first so a token that appears only inside a comment +// isn't mistaken for a declaration. +const PROPERTY_RE = /@property\s+(--sf-[a-z0-9_-]+)/g; +const DECL_RE = /(--sf-[a-z0-9_-]+)\s*:/g; const cssDirs = [path.join(ROOT, 'core'), path.join(ROOT, 'optional')]; for (const dir of cssDirs) { if (!fs.existsSync(dir)) continue; for (const file of fs.readdirSync(dir).filter((f) => f.endsWith('.css'))) { - const text = fs.readFileSync(path.join(dir, file), 'utf8'); - for (const m of text.matchAll(CSS_DECL_RE)) liveTokens.add(m[0]); + const text = stripComments(fs.readFileSync(path.join(dir, file), 'utf8')); + for (const m of text.matchAll(PROPERTY_RE)) liveTokens.add(m[1]); + for (const m of text.matchAll(DECL_RE)) liveTokens.add(m[1]); } } +// Live set c): fallback-only hook tokens — undeclared by design, documented in +// the guide as override hooks (see scripts/hook-tokens.js and #582 D5). +for (const name of HOOK_TOKEN_NAMES) liveTokens.add(name); + // PUBLIC + PUBLIC-ADVANCED knob tokens — the ones most likely to need docs. const publicKnobs = new Set( (Array.isArray(apiIndex.entries) ? apiIndex.entries : []) diff --git a/scripts/check-mirrors.js b/scripts/check-mirrors.js new file mode 100644 index 00000000..1c59f16d --- /dev/null +++ b/scripts/check-mirrors.js @@ -0,0 +1,191 @@ +#!/usr/bin/env node +/** + * CI gate (#582 D4): lock down the hand-maintained "keep in sync" mirrors that + * only a "do NOT edit independently" comment currently protects. Three families: + * + * A. @property ↔ :root char-for-char mirrors. Engines without @property read + * the plain :root value, so it must equal the registered initial-value + * exactly. Covers the -source-light colour registrations and the fluid + * scale engine's scalars (core/tokens.css). + * + * B. Container-query re-derivations. .sf-fluid-cq > * (core/layout.css) + * re-declares every fluid --sf-text-* / --sf-space-* / --sf-text-display-* + * formula so it re-resolves against 100cqi. Each formula must be identical + * to the token's declaration in core/tokens.css (both read + * var(--sf-fluid-width)); only --sf-fluid-width itself differs by design. + * + * C. SL-001 dark-source derivation. The -source-dark @property initial-values + * (core/tokens.css) are hand-computed from the matching -source-light value + * via the oklch clamp() formula declared in core/themes.css. This re-runs + * that formula on the light value and checks the registered dark literal + * matches (numerically). + * + * Run: + * node scripts/check-mirrors.js + * npm run check:mirrors + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { stripComments, readValue } from './lib/parse.js'; + +const _root = process.env.SLASHED_ROOT?.trim(); +const ROOT = _root ? path.resolve(_root) : path.resolve(import.meta.dirname, '..'); +const read = (rel) => stripComments(fs.readFileSync(path.join(ROOT, rel), 'utf8')); + +const tokensCss = read('core/tokens.css'); +const layoutCss = read('core/layout.css'); +const themesCss = read('core/themes.css'); + +const norm = (s) => s.replace(/\s+/g, ' ').trim(); +const errors = []; + +// ── Parse helpers ──────────────────────────────────────────────────────────── + +/** name → @property initial-value (raw string) */ +function propertyInitials(css) { + const map = new Map(); + for (const m of css.matchAll(/@property\s+(--sf-[a-z0-9_-]+)\s*\{([^}]*)\}/g)) { + const im = m[2].match(/initial-value:\s*([^;}]+)/); + if (im) map.set(m[1], norm(im[1])); + } + return map; +} + +/** name → @property syntax descriptor */ +function propertySyntax(css) { + const map = new Map(); + for (const m of css.matchAll(/@property\s+(--sf-[a-z0-9_-]+)\s*\{([^}]*)\}/g)) { + const sm = m[2].match(/syntax:\s*"([^"]*)"/); + if (sm) map.set(m[1], sm[1]); + } + return map; +} + +/** Collect the last `--name: value` declaration for each token across the file. */ +function allDeclarations(css) { + const map = new Map(); + const re = /(--sf-[a-z0-9_-]+)\s*:/g; + let m; + while ((m = re.exec(css))) { + map.set(m[1], norm(readValue(css, re.lastIndex - 1))); + } + return map; +} + +/** Collect declarations inside the first rule whose selector matches `selectorRe`. */ +function declarationsInRule(css, selectorRe) { + const start = css.search(selectorRe); + if (start === -1) return new Map(); + const open = css.indexOf('{', start); + let depth = 0, end = open; + for (let i = open; i < css.length; i++) { + if (css[i] === '{') depth++; + else if (css[i] === '}') { depth--; if (depth === 0) { end = i; break; } } + } + const body = css.slice(open + 1, end); + const map = new Map(); + const re = /(--sf-[a-z0-9_-]+)\s*:/g; + let m; + while ((m = re.exec(body))) { + map.set(m[1], norm(readValue(body, re.lastIndex - 1))); + } + return map; +} + +const initials = propertyInitials(tokensCss); +const syntaxes = propertySyntax(tokensCss); +const rootDecls = allDeclarations(tokensCss); + +// ── A. @property ↔ :root char-for-char mirrors ─────────────────────────────── +// -source-light colours + fluid scalars: both are declared plainly on +// :root with a value identical to the registered initial-value. +const mirrorNames = [...initials.keys()].filter( + (n) => /^--sf-color-.*-source-light$/.test(n) || syntaxes.get(n) === '', +); +let mirrorChecked = 0; +for (const name of mirrorNames) { + const init = initials.get(name); + const root = rootDecls.get(name); + if (root === undefined) { + errors.push(`A: ${name} is @property-registered but has no plain :root mirror.`); + continue; + } + mirrorChecked++; + if (init !== root) { + errors.push(`A: ${name} :root mirror "${root}" ≠ @property initial-value "${init}".`); + } +} + +// ── B. Container-query re-derivations ──────────────────────────────────────── +const cqDecls = declarationsInRule(layoutCss, /\.sf-fluid-cq\s*>\s*\*/); +let cqChecked = 0; +for (const [name, cqValue] of cqDecls) { + if (name === '--sf-fluid-width') continue; // intentionally 100cqi, not the vw default + const base = rootDecls.get(name); + if (base === undefined) { + errors.push(`B: .sf-fluid-cq re-declares ${name} but core/tokens.css never declares it.`); + continue; + } + cqChecked++; + if (cqValue !== base) { + errors.push(`B: .sf-fluid-cq ${name} formula drifted from core/tokens.css.\n cq: ${cqValue}\n tokens: ${base}`); + } +} + +// ── C. SL-001 dark-source derivation ───────────────────────────────────────── +// Evaluate a clamp()/calc() expression of a single variable (l or c). +function evalExpr(expr, varName, value) { + let e = expr.replace(new RegExp(`\\b${varName}\\b`, 'g'), `(${value})`); + // clamp(min, val, max) → _clamp(min, val, max) + e = e.replace(/\bclamp\(/g, '_clamp(').replace(/\bcalc\(/g, '('); + // eslint-disable-next-line no-new-func + const fn = new Function('_clamp', `return ${e};`); + return fn((min, val, max) => Math.min(Math.max(val, min), max)); +} +function parseOklch(str) { + const m = str.match(/oklch\(\s*([\d.]+)\s+([\d.]+)\s+([\d.]+)/); + return m ? { l: +m[1], c: +m[2], h: +m[3] } : null; +} + +let slChecked = 0; +// themes.css: --sf-color-X-source-dark: oklch(from var(--sf-color-X-source-light) h); +const DARK_RE = /(--sf-color-[a-z0-9-]+-source-dark)\s*:\s*oklch\(from var\(--sf-color-([a-z0-9-]+)-source-light\)\s+(.+?)\s+h\)\s*;/g; +for (const m of themesCss.matchAll(DARK_RE)) { + const darkName = m[1]; + const light = parseOklch(initials.get(`--sf-color-${m[2]}-source-light`) ?? ''); + const darkLit = parseOklch(initials.get(darkName) ?? ''); + if (!light || !darkLit) continue; + + // Split " " at the space between the two top-level terms. + const terms = m[3]; + let depth = 0, split = -1; + for (let i = 0; i < terms.length; i++) { + if (terms[i] === '(') depth++; + else if (terms[i] === ')') depth--; + else if (terms[i] === ' ' && depth === 0) { split = i; break; } + } + const lExpr = terms.slice(0, split); + const cExpr = terms.slice(split + 1); + + const expectedL = evalExpr(lExpr, 'l', light.l); + const expectedC = evalExpr(cExpr, 'c', light.c); + slChecked++; + if (Math.abs(expectedL - darkLit.l) > 1e-6 || Math.abs(expectedC - darkLit.c) > 1e-6 || light.h !== darkLit.h) { + errors.push( + `C: ${darkName} registered oklch(${darkLit.l} ${darkLit.c} ${darkLit.h}) ` + + `≠ SL-001 derivation oklch(${+expectedL.toFixed(6)} ${+expectedC.toFixed(6)} ${light.h}).`, + ); + } +} + +// ── Report ─────────────────────────────────────────────────────────────────── +if (errors.length) { + console.error('check:mirrors FAILED:'); + for (const e of errors) console.error(` ${e}`); + process.exit(1); +} +console.log( + `check:mirrors OK — ${mirrorChecked} @property↔:root mirrors, ` + + `${cqChecked} container-query re-derivations, ${slChecked} SL-001 dark derivations verified.`, +); diff --git a/scripts/hook-tokens.js b/scripts/hook-tokens.js new file mode 100644 index 00000000..265cb9dd --- /dev/null +++ b/scripts/hook-tokens.js @@ -0,0 +1,47 @@ +/** + * Canonical list of FALLBACK-ONLY HOOK TOKENS. + * + * These `--sf-*` names are never *declared* in source (no `--sf-x:` line, no + * `@property` registration). They exist only as the first argument of a + * `var(--sf-hook, )` call, so an author can set them inline/scoped to + * override a single declaration without the framework shipping a `:root` + * default. Because they are undeclared they are deliberately absent from the + * generated catalogues (token-registry.json, api-index, token-index) — the + * configurator can't surface a token that has no default value. + * + * They ARE, however, documented in docs/llm-guide.md as override hooks, which + * would otherwise trip check:llm-guide Check 1 ("every name the guide mentions + * must be a live token"). This module is the single source of truth that: + * - check-llm-guide.js adds to its live-token set (so the guide may name them) + * - check-hook-tokens.js verifies against source (each is consumed ONLY via + * fallback, is never declared, and is mentioned in the guide) + * + * Policy unification (#582 D5): this is the same "prose-only, not registered" + * treatment already documented for --sf-overlap-host-pad in docs/macros.md; + * the code-block hooks now carry it explicitly too. Adding a new hook token + * anywhere in source means adding it here (and the check will confirm it). + */ + +/** @typedef {{ name: string, file: string, note: string }} HookToken */ + +/** @type {HookToken[]} */ +export const HOOK_TOKENS = [ + { + name: '--sf-color-code-block-bg', + file: 'core/base.css', + note: 'Per-instance code-block background; falls back to --sf-color-code-bg.', + }, + { + name: '--sf-color-code-block-text', + file: 'core/base.css', + note: 'Per-instance code-block text colour; falls back to inherit.', + }, + { + name: '--sf-overlap-host-pad', + file: 'core/macros.css', + note: 'Per-instance .sf-overlap-host block-start padding; falls back to --sf-overlap-pull.', + }, +]; + +/** Just the names, as a Set — convenient for allowlist membership tests. */ +export const HOOK_TOKEN_NAMES = new Set(HOOK_TOKENS.map((h) => h.name)); diff --git a/tests/check-llm-guide.test.js b/tests/check-llm-guide.test.js index 3bd58b24..558169cb 100644 --- a/tests/check-llm-guide.test.js +++ b/tests/check-llm-guide.test.js @@ -71,6 +71,32 @@ describe('check-llm-guide failure cases', () => { assert.match(r.stderr, /llm-guide\.md not found/); }); + test('a guide ref that is only CONSUMED, never declared, is now flagged (D3)', () => { + // core/tokens.css declares --sf-space-m but only *consumes* --sf-ghost-read + // via var(); before #582 D3 the `[:,)]` lookahead counted the `)` after a + // consumed token as a declaration, so the guide could name it and pass. + const dir = buildFixture( + `# LLM guide\n\nUse \`--sf-color-text\`, \`--sf-space-m\`, and \`--sf-ghost-read\`.\n`, + ); + fs.writeFileSync( + path.join(dir, 'core', 'tokens.css'), + `:root { --sf-space-m: 1rem; gap: var(--sf-ghost-read); }\n`, + ); + const r = runGate(dir); + assert.equal(r.status, 1, 'a consumed-only token must not count as a live declaration'); + assert.match(r.stderr, /stale: --sf-ghost-read/); + }); + + test('fallback-only hook tokens are allowed even though undeclared (D5)', () => { + // The real hook tokens (scripts/hook-tokens.js) are undeclared by design; + // the guide is allowed to name them. + const dir = buildFixture( + `# LLM guide\n\n\`--sf-color-text\`, \`--sf-space-m\`, and hook \`--sf-color-code-block-bg\`.\n`, + ); + const r = runGate(dir); + assert.equal(r.status, 0, `hook tokens should be treated as live:\n${r.stderr}`); + }); + test('a bare prefix (name ending in "-") is prose, not a checked reference', () => { // "--sf-color-text--on-" is glob-like prose and must NOT be flagged stale. const dir = buildFixture( diff --git a/tests/check-source-audit-gates.test.js b/tests/check-source-audit-gates.test.js new file mode 100644 index 00000000..6b7e6027 --- /dev/null +++ b/tests/check-source-audit-gates.test.js @@ -0,0 +1,138 @@ +/** + * Negative tests for the #582 source-CSS audit gates: + * check-dead-knobs.js (D1) check-annotations.js (D2) + * check-mirrors.js (D4) check-hook-tokens.js (D5) + * check-bundle-defs.js (D6) + * + * Each gate is a CI tripwire, so each must be shown to still FAIL on the exact + * defect it guards against — not just pass on the current tree. Every test + * copies the real repo's relevant sources into a temporary SLASHED_ROOT, + * confirms the clean copy passes, then plants one defect and asserts a non-zero + * exit. Copying (rather than fixture-building) keeps the tests honest against + * the real token set. + */ +import { test, describe, after } from 'node:test'; +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +const ROOT = path.resolve(import.meta.dirname, '..'); +const tmpDirs = []; + +// Copy the subset of the repo the gates read. +function cloneRepo() { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'slashed-audit-')); + tmpDirs.push(dir); + fs.cpSync(path.join(ROOT, 'core'), path.join(dir, 'core'), { recursive: true }); + fs.cpSync(path.join(ROOT, 'optional'), path.join(dir, 'optional'), { recursive: true }); + fs.mkdirSync(path.join(dir, 'docs')); + for (const f of ['token-annotations.json', 'llm-guide.md']) { + fs.copyFileSync(path.join(ROOT, 'docs', f), path.join(dir, 'docs', f)); + } + for (const f of ['token-registry.json', 'bundle.config.json']) { + fs.copyFileSync(path.join(ROOT, f), path.join(dir, f)); + } + return dir; +} + +function runGate(gate, dir) { + return spawnSync(process.execPath, [path.join(ROOT, 'scripts', gate)], { + encoding: 'utf8', + env: { ...process.env, SLASHED_ROOT: dir }, + }); +} + +const readAnn = (dir) => JSON.parse(fs.readFileSync(path.join(dir, 'docs', 'token-annotations.json'), 'utf8')); +const writeAnn = (dir, a) => fs.writeFileSync(path.join(dir, 'docs', 'token-annotations.json'), JSON.stringify(a, null, 2)); +const readCss = (dir, rel) => fs.readFileSync(path.join(dir, rel), 'utf8'); +const writeCss = (dir, rel, s) => fs.writeFileSync(path.join(dir, rel), s); + +describe('check-dead-knobs (D1)', () => { + test('passes clean, fails when a wiring-claim annotation loses its consumer', () => { + const dir = cloneRepo(); + assert.equal(runGate('check-dead-knobs.js', dir).status, 0); + + // --sf-print-page-margin claims "Maps to @page margin"; strip its only + // consumer from print.css so the claim is now false. + const css = readCss(dir, 'core/print.css') + .replace('var(--sf-print-page-margin, 2cm)', '2cm'); + writeCss(dir, 'core/print.css', css); + + const r = runGate('check-dead-knobs.js', dir); + assert.equal(r.status, 1, `expected failure:\n${r.stdout}${r.stderr}`); + assert.match(r.stderr, /--sf-print-page-margin/); + }); +}); + +describe('check-annotations (D2)', () => { + test('passes clean, fails on a ~ms figure that disagrees with the value', () => { + const dir = cloneRepo(); + assert.equal(runGate('check-annotations.js', dir).status, 0); + + const ann = readAnn(dir); + ann.tokens['--sf-duration-instant'] = 'Near-instant duration (~50ms).'; + writeAnn(dir, ann); + + const r = runGate('check-annotations.js', dir); + assert.equal(r.status, 1, `expected failure:\n${r.stdout}${r.stderr}`); + assert.match(r.stderr, /--sf-duration-instant/); + }); +}); + +describe('check-mirrors (D4)', () => { + test('passes clean, fails when a :root mirror drifts from its @property initial-value', () => { + const dir = cloneRepo(); + assert.equal(runGate('check-mirrors.js', dir).status, 0); + + // Edit only the :root mirror of a source-light colour, not its @property. + const css = readCss(dir, 'core/tokens.css') + .replace('--sf-color-primary-source-light: oklch(0.47 0.27 264);', + '--sf-color-primary-source-light: oklch(0.50 0.27 264);'); + writeCss(dir, 'core/tokens.css', css); + + const r = runGate('check-mirrors.js', dir); + assert.equal(r.status, 1, `expected failure:\n${r.stdout}${r.stderr}`); + assert.match(r.stderr, /--sf-color-primary-source-light/); + }); +}); + +describe('check-hook-tokens (D5)', () => { + test('passes clean, fails when a new undeclared fallback hook is not listed', () => { + const dir = cloneRepo(); + assert.equal(runGate('check-hook-tokens.js', dir).status, 0); + + // Introduce a brand-new fallback-only token nobody catalogued. + const css = readCss(dir, 'optional/forms.css') + .replace('border-radius: var(--sf-field-radius, var(--sf-radius-m));', + 'border-radius: var(--sf-ghost-hook, var(--sf-radius-m));'); + writeCss(dir, 'optional/forms.css', css); + + const r = runGate('check-hook-tokens.js', dir); + assert.equal(r.status, 1, `expected failure:\n${r.stdout}${r.stderr}`); + assert.match(r.stderr, /--sf-ghost-hook/); + }); +}); + +describe('check-bundle-defs (D6)', () => { + test('passes clean, fails when a bundle consumes a token it never defines', () => { + const dir = cloneRepo(); + assert.equal(runGate('check-bundle-defs.js', dir).status, 0); + + // Add a bare (no-fallback) consumption of an undefined token to a file that + // ships in every bundle. + const css = readCss(dir, 'core/base.css') + '\n.sf-ghost { color: var(--sf-nowhere-defined); }\n'; + writeCss(dir, 'core/base.css', css); + + const r = runGate('check-bundle-defs.js', dir); + assert.equal(r.status, 1, `expected failure:\n${r.stdout}${r.stderr}`); + assert.match(r.stderr, /--sf-nowhere-defined/); + }); +}); + +after(() => { + for (const d of tmpDirs) { + try { fs.rmSync(d, { recursive: true, force: true }); } catch { /* ignore */ } + } +}); diff --git a/tests/print.spec.js b/tests/print.spec.js index cc74a163..daaf28a0 100644 --- a/tests/print.spec.js +++ b/tests/print.spec.js @@ -58,6 +58,37 @@ test.describe('Print styles', () => { expect(pca).toBe('exact'); }); + // A1 (#582): --sf-print-page-size / -margin must actually reach the @page box. + // page.pdf() is Chromium-only, so this geometry check runs there; the var() + // fallbacks keep the default render identical on every engine. + test('@page size reads --sf-print-page-size', async ({ page, browserName }) => { + test.skip(browserName !== 'chromium', 'page.pdf() is Chromium-only'); + + // MediaBox is emitted in the PDF in points; extract the first page box. + const boxOf = (buf) => { + const s = buf.toString('latin1'); + const m = s.match(/\/MediaBox\s*\[\s*([\d.]+)\s+([\d.]+)\s+([\d.]+)\s+([\d.]+)\s*\]/); + return m ? { w: +m[3] - +m[1], h: +m[4] - +m[2] } : null; + }; + + // Default: token unset → a4 fallback (≈595×842pt). + await page.setContent('

x

'); + await page.addStyleTag({ path: BUNDLE }); + const def = boxOf(await page.pdf({ preferCSSPageSize: true })); + expect(def).not.toBeNull(); + expect(def.w).toBeCloseTo(595, -1); + expect(def.h).toBeCloseTo(842, -1); + + // Override the token on :root → the @page size follows (letter ≈612×792pt), + // proving var() is substituted inside the @page descriptor. + await page.setContent('

x

'); + await page.addStyleTag({ path: BUNDLE }); + const over = boxOf(await page.pdf({ preferCSSPageSize: true })); + expect(over).not.toBeNull(); + expect(over.w).toBeCloseTo(612, -1); + expect(over.h).toBeCloseTo(792, -1); + }); + test('headings get static pt sizes, not the fluid vw-based scale', async ({ page }) => { await page.setContent(`

Title

Subtitle
`); await page.addStyleTag({ path: BUNDLE }); From 0dca7907f724392b2d1fe62ce42d5280661b1c24 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 9 Jul 2026 11:40:43 +0000 Subject: [PATCH 2/5] feat(components)!: remap .sf-btn min-height ladder onto the --sf-size-* rungs (#582 E1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The button size scale now maps 1:1 onto the --sf-size-* rungs (--xs→xs, --s→s, m→m, --l→l, --xl→xl) instead of the one-rung offset that pinned the default button to the 44px --sf-touch-target floor. Every non-xs/s button becomes shorter; the default drops from 44px (--sf-size-l, via --sf-touch-target) to 40px (--sf-size-m). BREAKING CHANGE: default/.sf-btn--l/.sf-btn--xl min-block-size shrink by one rung. The default control still clears the WCAG 2.2 AA 24px target but no longer meets the 44px AAA target by default — restore it globally with `:root { --sf-btn-min-height: var(--sf-touch-target) }` or per-button with `.sf-btn--l`. See docs/migration.md (0.7.8 → 0.8.0). - optional/components.css: base min-height--size → --sf-size-m; --l → --sf-size-l; --xl → --sf-size-xl; rewrite the ladder comment (the offset is gone). - docs/migration.md: add the 0.7.8 → 0.8.0 breaking entry with a before/after table and the opt-back-in recipe. - docs/components.md, docs/llm-guide.md: update the --sf-btn-min-height default. - tests/button.spec.js: assert the 1:1 min-height↔--sf-size-* mapping (both themes). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_012i9DDskbZqmuMeDwQMjPjF --- docs/components.md | 2 +- docs/llm-guide.md | 2 +- docs/migration.md | 25 +++++++++++++++++++++++++ optional/components.css | 24 ++++++++++++------------ tests/button.spec.js | 37 +++++++++++++++++++++++++++++++++++++ 5 files changed, 76 insertions(+), 14 deletions(-) diff --git a/docs/components.md b/docs/components.md index 8391ebd2..329004e2 100644 --- a/docs/components.md +++ b/docs/components.md @@ -100,7 +100,7 @@ stay orthogonal to the colour families. | `--sf-btn-gap` | `var(--sf-space-2xs)` | Gap between icon + label | | `--sf-btn-font-size` | `initial` → `var(--sf-text-m)` at size `m` | Label size | | `--sf-btn-font-weight` | `var(--sf-font-weight-interactive)` | Label weight | -| `--sf-btn-min-height` | `initial` → `var(--sf-touch-target)` at size `m` | Minimum target height (WCAG 2.2) | +| `--sf-btn-min-height` | `initial` → `var(--sf-size-m)` at size `m` | Minimum target height (40px, WCAG 2.2 AA; set to `var(--sf-touch-target)` for the 44px AAA floor) | | `--sf-btn-border-width` | `var(--sf-border-width-1)` | Border thickness | The four size-varying knobs (`--sf-btn-padding-block`, `--sf-btn-padding-inline`, diff --git a/docs/llm-guide.md b/docs/llm-guide.md index 234bc628..c4005ed2 100644 --- a/docs/llm-guide.md +++ b/docs/llm-guide.md @@ -1108,7 +1108,7 @@ Component tokens from `optional/tokens.components.css`: --sf-btn-gap: var(--sf-space-2xs) --sf-btn-font-size: var(--sf-text-m) --sf-btn-font-weight: var(--sf-font-weight-interactive) ---sf-btn-min-height: var(--sf-touch-target) +--sf-btn-min-height: var(--sf-size-m) /* size m default; set to var(--sf-touch-target) for the 44px AAA floor */ --sf-btn-border-width: var(--sf-border-width-1) /* Cards — .sf-card (live since 0.7.0) */ diff --git a/docs/migration.md b/docs/migration.md index 132b85c1..cc0d3eef 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -3,6 +3,31 @@ Mapping concepts from popular CSS frameworks to SLASHED, plus intra-project upgrade notes. +## SLASHED 0.7.8 → 0.8.0 + +### `.sf-btn` min-height ladder remapped onto the `--sf-size-*` rungs (breaking) + +The button size scale now maps 1:1 onto the `--sf-size-*` scale rungs. +Previously the ladder was offset by one rung so the default button cleared the +`--sf-touch-target` (44px) floor, which pinned the default to `--sf-size-l` and +pushed `--l`/`--xl` a rung higher. Every non-`--xs`/`--s` button therefore +becomes shorter: + +| Button | Before (≤ 0.7.8) `min-block-size` | After (0.8.0) | +|---|---|---| +| `.sf-btn` (default / m) | `--sf-touch-target` = `--sf-size-l` (44px) | `--sf-size-m` (40px) | +| `.sf-btn--l` | `--sf-size-xl` (56px) | `--sf-size-l` (44px) | +| `.sf-btn--xl` | `calc(--sf-size-xl + --sf-space-s)` (~64px) | `--sf-size-xl` (56px) | +| `.sf-btn--xs` / `--s` | `--sf-size-xs` / `--sf-size-s` | unchanged | + +The default control is still above the WCAG 2.2 AA 24px target, but no longer +meets the 44px AAA target by default. To restore the previous heights: + +- **Globally**: `:root { --sf-btn-min-height: var(--sf-touch-target); }` pins + every button back to the 44px floor. +- **Per button**: use `.sf-btn--l` where you previously relied on the default + clearing 44px. + ## SLASHED 0.7.6 → 0.7.7 ### `.sf-btn` axes reworked (breaking) diff --git a/optional/components.css b/optional/components.css index 9d9e6d46..4666a473 100644 --- a/optional/components.css +++ b/optional/components.css @@ -65,7 +65,7 @@ --sf-btn-font-size--size: var(--sf-text-m); --sf-btn-padding-block--size: var(--sf-space-xs); --sf-btn-padding-inline--size: var(--sf-space-m); - --sf-btn-min-height--size: var(--sf-touch-target); + --sf-btn-min-height--size: var(--sf-size-m); display: inline-flex; align-items: center; @@ -300,15 +300,15 @@ /* --- Sizes (m is the default; retune the rule-local *-size tier so a :root override of the public knob still wins over the size) --- - Note on the min-height ladder: the variant names (--xs/--s/--l/--xl) do - NOT map 1:1 onto the --sf-size-* scale rungs. The default (m) button - resolves min-height through --sf-touch-target (= --sf-size-l, 2.75rem) so - the baseline control always clears the touch-target floor, which pushes - the whole ladder up by one rung: --l reads --sf-size-xl and --xl adds - --sf-space-s on top of it. This offset is deliberate — a button needs a - larger hit area than the bare type-scale rung of the same name — so - --sf-size-m is intentionally not consumed here. Remapping onto the literal - rungs would resize every existing button (a visual regression); don't. */ + The min-height ladder maps 1:1 onto the --sf-size-* scale rungs: --xs→xs, + --s→s, default(m)→m, --l→l, --xl→xl. (Before 0.8.0 the ladder was offset + by one rung so the default button cleared the 44px --sf-touch-target + floor; that pinned m to --sf-size-l and pushed --l/--xl up. The offset was + dropped so the size names read predictably — see docs/migration.md.) The + default control is now --sf-size-m (40px), which still clears the WCAG 2.2 + AA 24px target; consumers who want the 44px AAA target back can set + `--sf-btn-min-height: var(--sf-touch-target)` (globally) or add + `.sf-btn--l`. */ .sf-btn--xs { --sf-btn-padding-block--size: 0.125rem; --sf-btn-padding-inline--size: var(--sf-space-xs); @@ -325,13 +325,13 @@ --sf-btn-padding-block--size: var(--sf-space-s); --sf-btn-padding-inline--size: var(--sf-space-l); --sf-btn-font-size--size: var(--sf-text-l); - --sf-btn-min-height--size: var(--sf-size-xl); + --sf-btn-min-height--size: var(--sf-size-l); } .sf-btn--xl { --sf-btn-padding-block--size: var(--sf-space-m); --sf-btn-padding-inline--size: var(--sf-space-xl); --sf-btn-font-size--size: var(--sf-text-xl); - --sf-btn-min-height--size: calc(var(--sf-size-xl) + var(--sf-space-s)); + --sf-btn-min-height--size: var(--sf-size-xl); } /* --- States --- */ diff --git a/tests/button.spec.js b/tests/button.spec.js index 7f0a618f..e59e9c6f 100644 --- a/tests/button.spec.js +++ b/tests/button.spec.js @@ -255,6 +255,43 @@ for (const theme of ['light', 'dark']) { expect(l).toBeLessThan(xl); }); + // #582 E1: the min-height ladder maps 1:1 onto the --sf-size-* rungs. + // min-block-size is the floor, so each size's computed min-block-size must + // equal the matching --sf-size-* token (default m → size-m, 40px). + test('min-height ladder maps 1:1 onto --sf-size-* rungs', async ({ page }) => { + await mount( + page, + ` + + + + `, + ); + const pairs = await page.evaluate(() => { + // Resolve each --sf-size-* token to px via a probe element so the + // comparison is unit-agnostic (computed min-block-size is px, the token + // is rem). + const probe = document.createElement('div'); + document.body.appendChild(probe); + const rungPx = (token) => { + probe.style.minBlockSize = `var(${token})`; + return parseFloat(getComputedStyle(probe).getPropertyValue('min-block-size')); + }; + const btnPx = (id) => + parseFloat(getComputedStyle(document.getElementById(id)).getPropertyValue('min-block-size')); + return [ + [btnPx('xs'), rungPx('--sf-size-xs')], + [btnPx('s'), rungPx('--sf-size-s')], + [btnPx('m'), rungPx('--sf-size-m')], + [btnPx('l'), rungPx('--sf-size-l')], + [btnPx('xl'), rungPx('--sf-size-xl')], + ]; + }); + for (const [got, want] of pairs) { + expect(got).toBeCloseTo(want, 1); + } + }); + test('--block stretches to the container width', async ({ page }) => { await mount( page, From f89a2e033cbc37516b5b2d3d99671a0086cef1dd Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 9 Jul 2026 11:44:12 +0000 Subject: [PATCH 3/5] docs(tokens): --sf-size-m is now consumed by the default .sf-btn (#582 E1 follow-up) After the ladder remap the default button reads --sf-size-m, so the annotation's "not currently consumed by any shipped rule" is stale. Point it at the real consumer instead and regenerate the derived indexes. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_012i9DDskbZqmuMeDwQMjPjF --- configurator/src/data/api-index.generated.json | 4 ++-- dist/css-custom-data.json | 2 +- docs/api-index.json | 2 +- docs/api-index.md | 2 +- docs/token-annotations.json | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/configurator/src/data/api-index.generated.json b/configurator/src/data/api-index.generated.json index 71337e06..094d7dab 100644 --- a/configurator/src/data/api-index.generated.json +++ b/configurator/src/data/api-index.generated.json @@ -2,7 +2,7 @@ "_sync": { "generatedBy": "configurator/scripts/sync-api.mjs", "source": "docs/api-index.json", - "tokensHash": "1f1aad252020", + "tokensHash": "a69d67db119d", "bundles": [ "full", "optimal" @@ -12815,7 +12815,7 @@ "category": "Core tokens", "group": "UI sizes", "description": "Fixed sizes for interactive components (buttons, inputs, chips) at S / M / L.", - "note": "~40px UI component height (scale rung `m`); not currently consumed by any shipped rule.", + "note": "~40px UI component height (scale rung `m`). The default `.sf-btn` min-height.", "value": "2.5rem", "aliasOf": null, "registered": false, diff --git a/dist/css-custom-data.json b/dist/css-custom-data.json index dd900661..51fdff9d 100644 --- a/dist/css-custom-data.json +++ b/dist/css-custom-data.json @@ -3048,7 +3048,7 @@ }, { "name": "--sf-size-m", - "description": "~40px UI component height (scale rung `m`); not currently consumed by any shipped rule.\n\nDefault: `2.5rem`", + "description": "~40px UI component height (scale rung `m`). The default `.sf-btn` min-height.\n\nDefault: `2.5rem`", "syntax": "*" }, { diff --git a/docs/api-index.json b/docs/api-index.json index 5892063f..6f01025b 100644 --- a/docs/api-index.json +++ b/docs/api-index.json @@ -15933,7 +15933,7 @@ "category": "Core tokens", "area": "core", "group": "UI sizes", - "description": "~40px UI component height (scale rung `m`); not currently consumed by any shipped rule.", + "description": "~40px UI component height (scale rung `m`). The default `.sf-btn` min-height.", "value": "2.5rem", "aliasOf": null, "registered": false, diff --git a/docs/api-index.md b/docs/api-index.md index 61afb492..12dead2f 100644 --- a/docs/api-index.md +++ b/docs/api-index.md @@ -564,7 +564,7 @@ and a short description. The machine-readable companion (with all columns) is | `--sf-shadow-xl` | PUBLIC | consumption | shadow | `0 2px 8px 0 oklch(from var(--sf-shadow-color) l c h / clamp(0, calc(var(--sf-shadow-strength) * 0.5), 0.7)), 0 12px 36px 0 oklch(from var(--sf-shadow-color) l c h / clamp(0, calc(var(--sf-shadow-strength) * 3.5), 0.7)), 0 24px 72px 0 oklch(from var(--sf-shadow-color) l c h / clamp(0, calc(var(--sf-shadow-strength) * 2.5), 0.7))` | Extra-large shadow for modals, dialogs, and high-elevation sheets. | | `--sf-shadow-xs` | PUBLIC | consumption | shadow | `0 1px 2px 0 oklch(from var(--sf-shadow-color) l c h / clamp(0, calc(var(--sf-shadow-strength) * 0.5), 0.7))` | Extra-small shadow for barely-elevated elements (focused inputs, small chips). | | `--sf-size-l` | PUBLIC | knob | size | `2.75rem` | Large UI component height (~44px). Touch-friendly interactive elements. | -| `--sf-size-m` | PUBLIC | knob | size | `2.5rem` | ~40px UI component height (scale rung `m`); not currently consumed by any shipped rule. | +| `--sf-size-m` | PUBLIC | knob | size | `2.5rem` | ~40px UI component height (scale rung `m`). The default `.sf-btn` min-height. | | `--sf-size-s` | PUBLIC | knob | size | `2rem` | Small UI component height (~32px). Compact buttons and input variants. | | `--sf-size-xl` | PUBLIC | knob | size | `3.5rem` | Extra-large interactive component height (~56px). For oversized or prominently touch-friendly controls. | | `--sf-size-xs` | PUBLIC | knob | size | `1.5rem` | Extra-small interactive component height (~24px). For very compact inline elements and micro-controls. | diff --git a/docs/token-annotations.json b/docs/token-annotations.json index cd31493c..25c160a4 100644 --- a/docs/token-annotations.json +++ b/docs/token-annotations.json @@ -321,7 +321,7 @@ "--sf-icon-xl": "Extra-large icon size (~32px). Feature icons.", "--sf-icon-2xl": "2× large icon size (~40px+). Hero or onboarding icons.", "--sf-size-s": "Small UI component height (~32px). Compact buttons and input variants.", - "--sf-size-m": "~40px UI component height (scale rung `m`); not currently consumed by any shipped rule.", + "--sf-size-m": "~40px UI component height (scale rung `m`). The default `.sf-btn` min-height.", "--sf-size-l": "Large UI component height (~44px). Touch-friendly interactive elements.", "--sf-container-narrow": "Narrow content max-width — ideal for article body and blog posts (~65ch).", "--sf-container-prose": "Prose max-width — optimised for readability (~75ch).", From aa11767ae442fd3d5ef09e92dc5fef93d92a9b30 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 9 Jul 2026 12:01:11 +0000 Subject: [PATCH 4/5] fix(tokens)!: decouple --sf-touch-target from --sf-size-l; regularise the size scale (#582) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An accessibility constraint must own its value, not borrow it from a configurable design scale. --sf-touch-target was `var(--sf-size-l)`, so retuning the (fully configurable) --sf-size-* scale could silently drag the WCAG 2.5.5 floor below spec. It now holds a fixed 2.75rem (44px) literal, independent of the scale. Freed from carrying the 44px anchor, --sf-size-l returns to its natural rung: the UI size ladder is now a clean +8px geometric scale (24 · 32 · 40 · 48 · 56) instead of 24 · 32 · 40 · 44 · 56. Audited the whole token set for the same anti-pattern (a spec/physical constraint aliased to a configurable scale rung); --sf-touch-target was the only instance — focus-ring dims, header/sticky heights, icon sizes and measure max-widths all already own literal values. BREAKING CHANGE: --sf-size-l is now 3rem (48px), was 2.75rem (44px); the WCAG 44px guarantee now lives only on --sf-touch-target (read that, not --sf-size-l, for a target-size floor). .sf-btn--l follows --sf-size-l to 48px. The a11y min-target helper is unchanged (still 44px). See docs/migration.md. - core/tokens.css: --sf-size-l 2.75rem→3rem; --sf-touch-target literal 2.75rem; comments on the geometric scale and the independent a11y anchor. - tests/a11y-patterns.spec.js: new test proving --sf-touch-target survives a --sf-size-* override. - docs/migration.md, llm-guide.md, token-annotations.json + regenerated indexes. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_012i9DDskbZqmuMeDwQMjPjF --- .../src/data/api-index.generated.json | 14 +++++----- core/tokens.css | 16 +++++++++--- demos/full-api-demo-with-overrides.html | 2 +- demos/full-api-demo.html | 2 +- demos/ultimate-override.css | 7 +++-- dist/css-custom-data.json | 4 +-- docs/api-index.json | 16 ++++++------ docs/api-index.md | 4 +-- docs/llm-guide.md | 8 +++--- docs/migration.md | 26 ++++++++++++++++--- docs/token-annotations.json | 4 +-- docs/token-index.json | 10 +++---- docs/token-index.md | 8 +++--- docs/tokens.md | 4 +-- optional/components.css | 7 ++--- tests/a11y-patterns.spec.js | 19 ++++++++++++++ 16 files changed, 101 insertions(+), 50 deletions(-) diff --git a/configurator/src/data/api-index.generated.json b/configurator/src/data/api-index.generated.json index 094d7dab..f4d1b05a 100644 --- a/configurator/src/data/api-index.generated.json +++ b/configurator/src/data/api-index.generated.json @@ -2,7 +2,7 @@ "_sync": { "generatedBy": "configurator/scripts/sync-api.mjs", "source": "docs/api-index.json", - "tokensHash": "a69d67db119d", + "tokensHash": "edc94fa13e23", "bundles": [ "full", "optimal" @@ -12794,8 +12794,8 @@ "category": "Core tokens", "group": "UI sizes", "description": "Fixed sizes for interactive components (buttons, inputs, chips) at S / M / L.", - "note": "Large UI component height (~44px). Touch-friendly interactive elements.", - "value": "2.75rem", + "note": "Large UI component height (~48px). Touch-friendly interactive elements.", + "value": "3rem", "aliasOf": null, "registered": false, "syntax": null, @@ -14889,14 +14889,14 @@ { "name": "--sf-touch-target", "tier": "PUBLIC", - "role": "consumption", + "role": "knob", "namespace": "touch", "category": "Core tokens", "group": "Layout & a11y", "description": "Focus ring, header height, sticky offset, touch-target, text-contrast knobs, and device safe-area insets. These govern keyboard accessibility, scroll behaviour, and adaptive contrast globally.", - "note": "Minimum touch-target size for interactive elements per WCAG 2.5.5.", - "value": "var(--sf-size-l)", - "aliasOf": "--sf-size-l", + "note": "Minimum touch-target size for interactive elements per WCAG 2.5.5. Fixed 44px anchor, independent of the --sf-size-* scale.", + "value": "2.75rem", + "aliasOf": null, "registered": false, "syntax": null, "fallbackOnly": false, diff --git a/core/tokens.css b/core/tokens.css index 2fdfa50e..3d0a332d 100644 --- a/core/tokens.css +++ b/core/tokens.css @@ -1176,12 +1176,16 @@ --sf-space-4xl: calc(clamp(calc(var(--sf-space-base-min) * pow(var(--sf-space-ratio-min), 5) * 1rem), calc((var(--sf-space-base-max) * pow(var(--sf-space-ratio-max), 5) - var(--sf-space-base-min) * pow(var(--sf-space-ratio-min), 5)) / (var(--sf-fluid-max-vw) - var(--sf-fluid-min-vw)) * (var(--sf-fluid-width) - var(--sf-fluid-min-vw) * 1rem) + var(--sf-space-base-min) * pow(var(--sf-space-ratio-min), 5) * 1rem), calc(var(--sf-space-base-max) * pow(var(--sf-space-ratio-max), 5) * 1rem)) * var(--sf-space-scale)); /* ---------------------------------------------------------- - UI sizes + UI sizes — a purely geometric rung ladder (+0.5rem / 8px per + step: 24 · 32 · 40 · 48 · 56 px). These are free design knobs: + nothing external constrains them, so overriding them just + rescales component heights. Accessibility floors live on their + own tokens (--sf-touch-target), never on a rung — see below. ---------------------------------------------------------- */ --sf-size-xs: 1.5rem; --sf-size-s: 2rem; --sf-size-m: 2.5rem; - --sf-size-l: 2.75rem; + --sf-size-l: 3rem; --sf-size-xl: 3.5rem; /* ---------------------------------------------------------- @@ -1437,11 +1441,15 @@ calc((var(--sf-sticky-offset-desktop) - var(--sf-sticky-offset-mobile)) / ((var(--sf-fluid-max-vw) - var(--sf-fluid-min-vw)) * 1rem) * (100vw - var(--sf-fluid-min-vw) * 1rem) + var(--sf-sticky-offset-mobile)), var(--sf-sticky-offset-desktop)); /* Focus ring dimensions and minimum touch-target size. Governs keyboard - navigation affordance and WCAG 2.5.5 pointer-target requirements. */ + navigation affordance and WCAG 2.5.5 pointer-target requirements. + --sf-touch-target owns the 44px WCAG floor as a literal — it is NOT an + alias of a --sf-size-* rung, so retuning the size scale can never drag + the accessibility minimum below spec. Override it deliberately if you + have your own target policy. */ --sf-focus-ring-width: 2px; --sf-focus-ring-offset: 2px; --sf-focus-ring-style: solid; - --sf-touch-target: var(--sf-size-l); + --sf-touch-target: 2.75rem; /* 44px — WCAG 2.5.5 minimum, independent of the size scale */ /* Global text-contrast knob. Nudges derived reading-text colours toward the extremes (darker in light mode, lighter in dark mode). Positive = more contrast; 0 = no change. Consumed by diff --git a/demos/full-api-demo-with-overrides.html b/demos/full-api-demo-with-overrides.html index 6c60588e..251f76ef 100644 --- a/demos/full-api-demo-with-overrides.html +++ b/demos/full-api-demo-with-overrides.html @@ -122,7 +122,7 @@

SLASHED Full API Demo

-

v0.7.8 · full bundle from jsDelivr CDN · 328 classes · 735 tokens (243 configurable)

+

v0.7.8 · full bundle from jsDelivr CDN · 328 classes · 735 tokens (244 configurable)

ultimate-override.css is ACTIVE. Every value below is recomputed from perturbed knob tokens — toggle it off in the toolbar, or compare against the un-overridden page.
diff --git a/demos/full-api-demo.html b/demos/full-api-demo.html index aab0ccf6..ce0cafdc 100644 --- a/demos/full-api-demo.html +++ b/demos/full-api-demo.html @@ -121,7 +121,7 @@

SLASHED Full API Demo

-

v0.7.8 · full bundle from jsDelivr CDN · 328 classes · 735 tokens (243 configurable)

+

v0.7.8 · full bundle from jsDelivr CDN · 328 classes · 735 tokens (244 configurable)

Baseline render with default tokens. Use the toolbar to switch theme, toggle the ultimate override live, or replay motion. The always-on override variant is full-api-demo-with-overrides.html.
diff --git a/demos/ultimate-override.css b/demos/ultimate-override.css index b5f1afcf..57c07a49 100644 --- a/demos/ultimate-override.css +++ b/demos/ultimate-override.css @@ -5,7 +5,7 @@ Perturbs every CONFIGURABLE (role: "knob") token to a valid, visibly-distinct value so full-api-demo.html can prove each one is wired end to end. - Coverage: 219 of 243 knob tokens overridden. + Coverage: 220 of 244 knob tokens overridden. 24 knobs are intentionally NOT perturbed (structural / meta / sentinel) — listed with reasons at the bottom of this file. ============================================================================ */ @@ -303,7 +303,7 @@ --sf-sidebar-width: 28.8rem; /* was: 18rem */ /* size */ - --sf-size-l: 4.4rem; /* was: 2.75rem */ + --sf-size-l: 4.8rem; /* was: 3rem */ --sf-size-m: 4rem; /* was: 2.5rem */ --sf-size-s: 3.2rem; /* was: 2rem */ --sf-size-xl: 5.6rem; /* was: 3.5rem */ @@ -352,6 +352,9 @@ --sf-text-xl-max-width: 50ch; /* was: none */ --sf-text-xs-max-width: 96ch; /* was: 60ch */ + /* touch */ + --sf-touch-target: 4.4rem; /* was: 2.75rem */ + /* tracking */ --sf-tracking-normal: 0.03em; /* was: 0 */ --sf-tracking-tight: -0.04em; /* was: -0.025em */ diff --git a/dist/css-custom-data.json b/dist/css-custom-data.json index 51fdff9d..64bfbc6c 100644 --- a/dist/css-custom-data.json +++ b/dist/css-custom-data.json @@ -3043,7 +3043,7 @@ }, { "name": "--sf-size-l", - "description": "Large UI component height (~44px). Touch-friendly interactive elements.\n\nDefault: `2.75rem`", + "description": "Large UI component height (~48px). Touch-friendly interactive elements.\n\nDefault: `3rem`", "syntax": "*" }, { @@ -3543,7 +3543,7 @@ }, { "name": "--sf-touch-target", - "description": "Minimum touch-target size for interactive elements per WCAG 2.5.5.\n\nDefault: `var(--sf-size-l)`", + "description": "Minimum touch-target size for interactive elements per WCAG 2.5.5. Fixed 44px anchor, independent of the --sf-size-* scale.\n\nDefault: `2.75rem`", "syntax": "*" }, { diff --git a/docs/api-index.json b/docs/api-index.json index 6f01025b..55f1deb0 100644 --- a/docs/api-index.json +++ b/docs/api-index.json @@ -37,8 +37,8 @@ "PUBLIC-ADVANCED": 55 }, "by_role": { - "consumption": 492, - "knob": 243 + "consumption": 491, + "knob": 244 }, "tokens": 735, "classes": 329, @@ -15907,8 +15907,8 @@ "category": "Core tokens", "area": "core", "group": "UI sizes", - "description": "Large UI component height (~44px). Touch-friendly interactive elements.", - "value": "2.75rem", + "description": "Large UI component height (~48px). Touch-friendly interactive elements.", + "value": "3rem", "aliasOf": null, "registered": false, "animatable": false, @@ -18502,14 +18502,14 @@ "name": "--sf-touch-target", "type": "token", "tier": "PUBLIC", - "role": "consumption", + "role": "knob", "namespace": "touch", "category": "Core tokens", "area": "core", "group": "Layout & a11y", - "description": "Minimum touch-target size for interactive elements per WCAG 2.5.5.", - "value": "var(--sf-size-l)", - "aliasOf": "--sf-size-l", + "description": "Minimum touch-target size for interactive elements per WCAG 2.5.5. Fixed 44px anchor, independent of the --sf-size-* scale.", + "value": "2.75rem", + "aliasOf": null, "registered": false, "animatable": false, "syntax": null, diff --git a/docs/api-index.md b/docs/api-index.md index 12dead2f..dbac8f67 100644 --- a/docs/api-index.md +++ b/docs/api-index.md @@ -563,7 +563,7 @@ and a short description. The machine-readable companion (with all columns) is | `--sf-shadow-strength` | PUBLIC-ADVANCED | knob | shadow | `calc(0.08 + var(--sf-is-dark) * 0.17)` | Base opacity for the entire shadow ramp. Auto-boosted in dark mode via --sf-is-dark. Override with calc() to preserve the adaptation: e.g. calc(0.12 + var(--sf-is-dark) * 0.17). | | `--sf-shadow-xl` | PUBLIC | consumption | shadow | `0 2px 8px 0 oklch(from var(--sf-shadow-color) l c h / clamp(0, calc(var(--sf-shadow-strength) * 0.5), 0.7)), 0 12px 36px 0 oklch(from var(--sf-shadow-color) l c h / clamp(0, calc(var(--sf-shadow-strength) * 3.5), 0.7)), 0 24px 72px 0 oklch(from var(--sf-shadow-color) l c h / clamp(0, calc(var(--sf-shadow-strength) * 2.5), 0.7))` | Extra-large shadow for modals, dialogs, and high-elevation sheets. | | `--sf-shadow-xs` | PUBLIC | consumption | shadow | `0 1px 2px 0 oklch(from var(--sf-shadow-color) l c h / clamp(0, calc(var(--sf-shadow-strength) * 0.5), 0.7))` | Extra-small shadow for barely-elevated elements (focused inputs, small chips). | -| `--sf-size-l` | PUBLIC | knob | size | `2.75rem` | Large UI component height (~44px). Touch-friendly interactive elements. | +| `--sf-size-l` | PUBLIC | knob | size | `3rem` | Large UI component height (~48px). Touch-friendly interactive elements. | | `--sf-size-m` | PUBLIC | knob | size | `2.5rem` | ~40px UI component height (scale rung `m`). The default `.sf-btn` min-height. | | `--sf-size-s` | PUBLIC | knob | size | `2rem` | Small UI component height (~32px). Compact buttons and input variants. | | `--sf-size-xl` | PUBLIC | knob | size | `3.5rem` | Extra-large interactive component height (~56px). For oversized or prominently touch-friendly controls. | @@ -651,7 +651,7 @@ and a short description. The machine-readable companion (with all columns) is | `--sf-text-xs-line-height` | PUBLIC | consumption | text | `calc(var(--sf-leading-relaxed) - 1 * var(--sf-leading-taper))` | Line height for extra-small text. | | `--sf-text-xs-max-width` | PUBLIC | knob | text | `60ch` | Optimal line length for extra-small text. | | `--sf-theme-transition-duration` | PUBLIC | consumption | theme | `calc(300ms * var(--sf-motion-scale))` | Duration of the .sf-theme-transition animated color-scheme crossfade. Scales with --sf-motion-scale so reduced-motion preferences are respected. Default 300ms. | -| `--sf-touch-target` | PUBLIC | consumption | touch | `var(--sf-size-l)` | Minimum touch-target size for interactive elements per WCAG 2.5.5. | +| `--sf-touch-target` | PUBLIC | knob | touch | `2.75rem` | Minimum touch-target size for interactive elements per WCAG 2.5.5. Fixed 44px anchor, independent of the --sf-size-* scale. | | `--sf-tracking-normal` | PUBLIC | knob | tracking | `0` | Normal letter-spacing (0). Default for body text. | | `--sf-tracking-tight` | PUBLIC | knob | tracking | `-0.025em` | Tight letter-spacing (slightly negative). Suitable for large headings. | | `--sf-tracking-wide` | PUBLIC | knob | tracking | `0.025em` | Wide letter-spacing. Use for small-caps labels. | diff --git a/docs/llm-guide.md b/docs/llm-guide.md index c4005ed2..9d36fc1c 100644 --- a/docs/llm-guide.md +++ b/docs/llm-guide.md @@ -678,11 +678,13 @@ For headers, `.sf-header--xs … --xl` (`core/layout.css`) set **block** padding --sf-icon-m: 1.5em --sf-icon-l: 2em --sf-icon-xl: 3em --sf-icon-2xl: 4em -/* UI sizes — rem-based, fixed (buttons, inputs, touch targets) */ +/* UI sizes — rem-based geometric ladder, +8px per step (buttons, inputs). + Purely a design scale: no external constraint pins any rung. The WCAG + touch-target floor lives on its own token (--sf-touch-target), not here. */ --sf-size-xs: 1.5rem /* 24px */ --sf-size-s: 2rem /* 32px */ --sf-size-m: 2.5rem /* 40px */ ---sf-size-l: 2.75rem /* 44px — WCAG 2.5.5 touch target */ +--sf-size-l: 3rem /* 48px */ --sf-size-xl: 3.5rem /* 56px */ ``` @@ -1064,7 +1066,7 @@ Ready-made `animation` values — keyframe + duration + easing + fill-mode. ```css --sf-caret-color: var(--sf-color-action) ---sf-touch-target: var(--sf-size-l) /* 44px — WCAG 2.5.5 */ +--sf-touch-target: 2.75rem /* 44px — WCAG 2.5.5, fixed independent of --sf-size-* */ --sf-opacity-disabled: 0.45 --sf-state-pending-opacity: 0.7 /* async operations */ --sf-opacity-muted: 0.5 diff --git a/docs/migration.md b/docs/migration.md index cc0d3eef..9db41c2b 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -5,18 +5,36 @@ upgrade notes. ## SLASHED 0.7.8 → 0.8.0 +### `--sf-touch-target` decoupled from `--sf-size-l`; size scale regularised (breaking) + +`--sf-touch-target` used to be `var(--sf-size-l)`, which coupled the WCAG 2.5.5 +accessibility floor to a freely-configurable design-scale rung — retuning the +size scale could silently drag the touch target below spec. It now owns its +value as a fixed literal, and `--sf-size-l` is freed to complete a clean +geometric ladder (`+8px` per step): + +| Token | Before (≤ 0.7.8) | After (0.8.0) | +|---|---|---| +| `--sf-touch-target` | `var(--sf-size-l)` (44px, tracked the scale) | `2.75rem` (44px, fixed WCAG anchor) | +| `--sf-size-l` | `2.75rem` (44px) | `3rem` (48px) | +| `--sf-size-*` ladder | 24 · 32 · 40 · 44 · 56 | 24 · 32 · 40 · **48** · 56 | + +If you relied on `--sf-size-l` being 44px (e.g. read it directly for a target +size), read `--sf-touch-target` instead — that's the token that carries the +44px guarantee now. The a11y min-target helper is unchanged (still 44px). + ### `.sf-btn` min-height ladder remapped onto the `--sf-size-*` rungs (breaking) The button size scale now maps 1:1 onto the `--sf-size-*` scale rungs. Previously the ladder was offset by one rung so the default button cleared the `--sf-touch-target` (44px) floor, which pinned the default to `--sf-size-l` and pushed `--l`/`--xl` a rung higher. Every non-`--xs`/`--s` button therefore -becomes shorter: +changes height: | Button | Before (≤ 0.7.8) `min-block-size` | After (0.8.0) | |---|---|---| | `.sf-btn` (default / m) | `--sf-touch-target` = `--sf-size-l` (44px) | `--sf-size-m` (40px) | -| `.sf-btn--l` | `--sf-size-xl` (56px) | `--sf-size-l` (44px) | +| `.sf-btn--l` | `--sf-size-xl` (56px) | `--sf-size-l` (48px) | | `.sf-btn--xl` | `calc(--sf-size-xl + --sf-space-s)` (~64px) | `--sf-size-xl` (56px) | | `.sf-btn--xs` / `--s` | `--sf-size-xs` / `--sf-size-s` | unchanged | @@ -24,9 +42,9 @@ The default control is still above the WCAG 2.2 AA 24px target, but no longer meets the 44px AAA target by default. To restore the previous heights: - **Globally**: `:root { --sf-btn-min-height: var(--sf-touch-target); }` pins - every button back to the 44px floor. + every button to the exact 44px floor. - **Per button**: use `.sf-btn--l` where you previously relied on the default - clearing 44px. + clearing 44px (now 48px). ## SLASHED 0.7.6 → 0.7.7 diff --git a/docs/token-annotations.json b/docs/token-annotations.json index 25c160a4..35f44901 100644 --- a/docs/token-annotations.json +++ b/docs/token-annotations.json @@ -212,7 +212,7 @@ "--sf-focus-ring-width": "Stroke width of the focus ring outline.", "--sf-focus-ring-offset": "Gap between the element edge and the focus ring.", "--sf-focus-ring-style": "Border style of the focus ring (solid, dashed, dotted).", - "--sf-touch-target": "Minimum touch-target size for interactive elements per WCAG 2.5.5.", + "--sf-touch-target": "Minimum touch-target size for interactive elements per WCAG 2.5.5. Fixed 44px anchor, independent of the --sf-size-* scale.", "--sf-header-height": "Total height of the sticky page header. Used by --sf-sticky-offset to prevent content from hiding under it.", "--sf-header-height-desktop": "Header height at desktop breakpoints.", "--sf-header-height-mobile": "Header height at mobile breakpoints.", @@ -322,7 +322,7 @@ "--sf-icon-2xl": "2× large icon size (~40px+). Hero or onboarding icons.", "--sf-size-s": "Small UI component height (~32px). Compact buttons and input variants.", "--sf-size-m": "~40px UI component height (scale rung `m`). The default `.sf-btn` min-height.", - "--sf-size-l": "Large UI component height (~44px). Touch-friendly interactive elements.", + "--sf-size-l": "Large UI component height (~48px). Touch-friendly interactive elements.", "--sf-container-narrow": "Narrow content max-width — ideal for article body and blog posts (~65ch).", "--sf-container-prose": "Prose max-width — optimised for readability (~75ch).", "--sf-container-default": "Default content container max-width (~1200px).", diff --git a/docs/token-index.json b/docs/token-index.json index 589be873..fb703d73 100644 --- a/docs/token-index.json +++ b/docs/token-index.json @@ -15,8 +15,8 @@ "INTERNAL": 1 }, "by_role": { - "knob": 243, - "consumption": 492 + "knob": 244, + "consumption": 491 } } }, @@ -4899,7 +4899,7 @@ "files": [ "core/tokens.css" ], - "value": "2.75rem" + "value": "3rem" }, "--sf-size-m": { "tier": "PUBLIC", @@ -5695,11 +5695,11 @@ }, "--sf-touch-target": { "tier": "PUBLIC", - "role": "consumption", + "role": "knob", "files": [ "core/tokens.css" ], - "value": "var(--sf-size-l)" + "value": "2.75rem" }, "--sf-tracking-normal": { "tier": "PUBLIC", diff --git a/docs/token-index.md b/docs/token-index.md index e8d400f7..272d3345 100644 --- a/docs/token-index.md +++ b/docs/token-index.md @@ -22,8 +22,8 @@ declared value (a value that references `var(--sf-…)` is a derived output): | Role | Count | Meaning | |---|---|---| -| knob | 243 | Input you **set** to configure the system (a literal primitive: length, number, colour literal, keyword, font stack, easing curve …). | -| consumption | 492 | Ready-to-use output you **read**; derived from other tokens via `var(--sf-…)` (incl. `light-dark()`/`oklch(from …)`/`color-mix()`). | +| knob | 244 | Input you **set** to configure the system (a literal primitive: length, number, colour literal, keyword, font stack, easing curve …). | +| consumption | 491 | Ready-to-use output you **read**; derived from other tokens via `var(--sf-…)` (incl. `light-dark()`/`oklch(from …)`/`color-mix()`). | ## INTERNAL tokens @@ -700,7 +700,7 @@ declared value (a value that references `var(--sf-…)` is a derived output): | `--sf-sidebar-gap` | PUBLIC | consumption | Layout | `var(--sf-gap)` | | `--sf-sidebar-min-width` | PUBLIC | knob | Layout | `50%` | | `--sf-sidebar-width` | PUBLIC | knob | Layout | `18rem` | -| `--sf-size-l` | PUBLIC | knob | Core | `2.75rem` | +| `--sf-size-l` | PUBLIC | knob | Core | `3rem` | | `--sf-size-m` | PUBLIC | knob | Core | `2.5rem` | | `--sf-size-s` | PUBLIC | knob | Core | `2rem` | | `--sf-size-xl` | PUBLIC | knob | Core | `3.5rem` | @@ -800,7 +800,7 @@ declared value (a value that references `var(--sf-…)` is a derived output): | `--sf-text-xs-line-height` | PUBLIC | consumption | Core | `calc(var(--sf-leading-relaxed) - 1 * var(--sf-leading-taper))` | | `--sf-text-xs-max-width` | PUBLIC | knob | Core | `60ch` | | `--sf-theme-transition-duration` | PUBLIC | consumption | Core | `calc(300ms * var(--sf-motion-scale))` | -| `--sf-touch-target` | PUBLIC | consumption | Core | `var(--sf-size-l)` | +| `--sf-touch-target` | PUBLIC | knob | Core | `2.75rem` | | `--sf-tracking-normal` | PUBLIC | knob | Core | `0` | | `--sf-tracking-tight` | PUBLIC | knob | Core | `-0.025em` | | `--sf-tracking-wide` | PUBLIC | knob | Core | `0.025em` | diff --git a/docs/tokens.md b/docs/tokens.md index 744ee309..69e5a2dd 100644 --- a/docs/tokens.md +++ b/docs/tokens.md @@ -525,7 +525,7 @@ rebrand workflow. | `--sf-shadow-strength` | `calc(0.08 + var(--sf-is-dark) * 0.17)` | | `--sf-shadow-xl` | `0 2px 8px 0 oklch(from var(--sf-shadow-color) l c h / clamp(0, calc(var(--sf-shadow-strength) * 0.5), 0.7)), 0 12px 36px 0 oklch(from var(--sf-shadow-color) l c h / clamp(0, calc(var(--sf-shadow-strength) * 3.5), 0.7)), 0 24px 72px 0 oklch(from var(--sf-shadow-color) l c h / clamp(0, calc(var(--sf-shadow-strength) * 2.5), 0.7))` | | `--sf-shadow-xs` | `0 1px 2px 0 oklch(from var(--sf-shadow-color) l c h / clamp(0, calc(var(--sf-shadow-strength) * 0.5), 0.7))` | -| `--sf-size-l` | `2.75rem` | +| `--sf-size-l` | `3rem` | | `--sf-size-m` | `2.5rem` | | `--sf-size-s` | `2rem` | | `--sf-size-xl` | `3.5rem` | @@ -613,7 +613,7 @@ rebrand workflow. | `--sf-text-xs-line-height` | `calc(var(--sf-leading-relaxed) - 1 * var(--sf-leading-taper))` | | `--sf-text-xs-max-width` | `60ch` | | `--sf-theme-transition-duration` | `calc(300ms * var(--sf-motion-scale))` | -| `--sf-touch-target` | `var(--sf-size-l)` | +| `--sf-touch-target` | `2.75rem` | | `--sf-tracking-normal` | `0` | | `--sf-tracking-tight` | `-0.025em` | | `--sf-tracking-wide` | `0.025em` | diff --git a/optional/components.css b/optional/components.css index 4666a473..84420b1d 100644 --- a/optional/components.css +++ b/optional/components.css @@ -306,9 +306,10 @@ floor; that pinned m to --sf-size-l and pushed --l/--xl up. The offset was dropped so the size names read predictably — see docs/migration.md.) The default control is now --sf-size-m (40px), which still clears the WCAG 2.2 - AA 24px target; consumers who want the 44px AAA target back can set - `--sf-btn-min-height: var(--sf-touch-target)` (globally) or add - `.sf-btn--l`. */ + AA 24px target; consumers who want the 44px AAA touch target back can set + `--sf-btn-min-height: var(--sf-touch-target)` globally (exactly 44px, an + accessibility anchor independent of the size scale) or reach for + `.sf-btn--l` (--sf-size-l, 48px). */ .sf-btn--xs { --sf-btn-padding-block--size: 0.125rem; --sf-btn-padding-inline--size: var(--sf-space-xs); diff --git a/tests/a11y-patterns.spec.js b/tests/a11y-patterns.spec.js index 2c30d363..a4cf6586 100644 --- a/tests/a11y-patterns.spec.js +++ b/tests/a11y-patterns.spec.js @@ -198,6 +198,25 @@ test.describe('a11y: touch target token', () => { }); expect(px).toBeGreaterThanOrEqual(44); }); + + // #582: the WCAG floor must NOT track the configurable --sf-size-* scale. + // Shrinking a size rung must not drag the touch target below spec. + test('--sf-touch-target is independent of the --sf-size-* scale', async ({ page }) => { + await setup(page, ``); + const px = await page.evaluate(() => { + const el = document.createElement('div'); + // Retune the size scale hard: if the touch target were an alias of a rung + // it would collapse with it. + el.style.setProperty('--sf-size-l', '10px'); + el.style.setProperty('--sf-size-m', '8px'); + el.style.width = 'var(--sf-touch-target)'; + document.body.appendChild(el); + const v = parseFloat(getComputedStyle(el).width); + el.remove(); + return v; + }); + expect(px).toBeGreaterThanOrEqual(44); + }); }); // ── Focus ring (:focus-visible) ───────────────────────────────── From d6bd9635d61ad1caebe551c9737e4c88c79964b2 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 9 Jul 2026 13:42:54 +0000 Subject: [PATCH 5/5] fix(audit): address PR #584 review comments - check-annotations: anchor resolveMs so compound calc() expressions resolve to null instead of yielding the first ms literal, while still resolving the framework's scaled calc(Nms * var(--sf-motion-scale)) form - check-dead-knobs: skip negated wiring disclaimers ("not wired to any rule") so a truthful annotation can't trip the gate - migration: document the breaking --sf-field-block default change from var(--sf-space-l) to var(--sf-space-xs) Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_0114fqDEQ6xsTpmowzLiQWVp --- docs/migration.md | 18 ++++++++++++++++++ scripts/check-annotations.js | 13 +++++++++++-- scripts/check-dead-knobs.js | 4 ++++ 3 files changed, 33 insertions(+), 2 deletions(-) diff --git a/docs/migration.md b/docs/migration.md index 9db41c2b..0b1767a1 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -46,6 +46,24 @@ meets the 44px AAA target by default. To restore the previous heights: - **Per button**: use `.sf-btn--l` where you previously relied on the default clearing 44px (now 48px). +### `--sf-field-block` default reduced from `--sf-space-l` to `--sf-space-xs` (breaking) + +The global form-field block-padding token now defaults to `var(--sf-space-xs)` +instead of `var(--sf-space-l)`, so it matches the block padding the shipped +field default actually applies (see `optional/forms.css`). Previously the token +advertised a much larger value than the fields used, so anyone reading +`--sf-field-block` to align custom controls got spacing that didn't match the +built-in fields. + +| Token | Before (≤ 0.7.8) | After (0.8.0) | +|---|---|---| +| `--sf-field-block` | `var(--sf-space-l)` | `var(--sf-space-xs)` | + +If you overrode field block padding by *reading* `--sf-field-block` (e.g. +`padding-block: var(--sf-field-block)` on a custom control), your control now +tracks the tighter, correct default. To keep the old roomier spacing, pin it +explicitly: `:root { --sf-field-block: var(--sf-space-l); }`. + ## SLASHED 0.7.6 → 0.7.7 ### `.sf-btn` axes reworked (breaking) diff --git a/scripts/check-annotations.js b/scripts/check-annotations.js index a11b880e..79c6a84c 100644 --- a/scripts/check-annotations.js +++ b/scripts/check-annotations.js @@ -78,10 +78,19 @@ function baseValue(name, seen = new Set()) { return v; } -/** Milliseconds if the base value carries a concrete ms literal, else null. */ +/** + * Milliseconds if the base value is a single concrete ms literal, else null. + * Accepts a bare `Nms` or the framework's scaled form `calc(Nms * var(--sf-motion-scale))` + * (the annotation quotes the unscaled base). Anchored end-to-end so compound + * expressions such as `calc(100ms + 200ms)` resolve to null rather than silently + * yielding the first literal — matching how resolvePx rejects non-literal lengths. + */ function resolveMs(name) { const v = baseValue(name); - const m = v && v.match(/(-?[\d.]+)\s*ms/); + if (v == null) return null; + let m = v.match(/^(-?[\d.]+)\s*ms$/); + if (m) return Number(m[1]); + m = v.match(/^calc\(\s*(-?[\d.]+)\s*ms\s*\*\s*var\(\s*--sf-motion-scale\s*\)\s*\)$/); return m ? Number(m[1]) : null; } diff --git a/scripts/check-dead-knobs.js b/scripts/check-dead-knobs.js index 39a9f328..9946f2b8 100644 --- a/scripts/check-dead-knobs.js +++ b/scripts/check-dead-knobs.js @@ -35,6 +35,9 @@ const ANNOTATIONS = path.join(ROOT, 'docs', 'token-annotations.json'); // intentionally excluded so a truthful "this isn't wired" note can't trip the // gate. const WIRING_CLAIM = /\bmaps to\b|\bapplied (?:to|in|on)\b|\bwired to\b|\bhooks? into\b/i; +// Negated forms of the wiring phrases above. A truthful disclaimer such as +// "not wired to any rule" must never be read as a wiring promise. +const NEGATED_CLAIM = /\bnot\s+(?:maps to|applied (?:to|in|on)|wired to|hooks? into)\b/i; const cssFiles = []; for (const dir of ['core', 'optional']) { @@ -69,6 +72,7 @@ for (const [name, note] of Object.entries(tokenNotes)) { if (typeof note !== 'string') continue; if (!name.startsWith('--sf-')) continue; if (!WIRING_CLAIM.test(note)) continue; // only annotations that PROMISE wiring + if (NEGATED_CLAIM.test(note)) continue; // …but not a negated "not wired to" disclaimer if (!declared.has(name)) continue; // undeclared hooks are handled elsewhere // @property-registered tokens are "used" by their registration; but a wiring // claim still requires a real consumer, so don't exempt them here.