From 56fa2e607621739664397e29ca49f20c3418ba5d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Jul 2026 11:45:27 +0000 Subject: [PATCH 1/4] fix(themes): auto-flip heading/body/card foreground on coloured surfaces (#496) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit :root-only alias-overrides (--sf-heading-color: var(--sf-color-heading), --sf-body-color, --sf-card-border-color) seal their value at the declaring element because var() resolves on :root. Any heading/body/card that reads the alias therefore read the frozen page value and ignored the semantic token re-declared by the .sf-surface--* auto-contrast block and the [data-theme] section blocks — leaving headings dark on a dark surface and card title/body/border computed for the wrong background. Complete the #496 pattern: - core/macros.css: re-declare --sf-heading-color / --sf-body-color in the .sf-surface auto-contrast cascade so direct headings/body re-resolve against the surface's adapted tokens. Add an internal, inherited --sf-surface-active flag signalling "inside an auto-contrast surface". - core/themes.css: re-declare --sf-heading-color / --sf-body-color alongside the existing --sf-card-bg / --sf-card-border-color re-declarations in the shared [data-theme] block (covers both schemes). - optional/components.css: a .sf-card keeps the page surface as its own (non-adapting) background, so the surface's flipped foreground must not leak into it. Gated on --sf-surface-active via a style query (not a `.sf-surface .sf-card` descendant selector, which would misclassify the surface classes in the generated class reference), restore the surface-appropriate tokens on the card from the ambient neutral and set `color` for inherited body text. A consumer's own --sf-color-heading override on a non-surface ancestor is still inherited (flag unset there). Purely additive token re-declarations — no default-mode values change. Adds regression tests: a direct heading and a card (title/body) inside .sf-surface--secondary must clear 3:1 contrast against their own background; both fail without the fix. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01ScK3jJFKQ7aWasg25dc7VS --- core/macros.css | 16 +++++++++++ core/themes.css | 25 +++++++++-------- optional/components.css | 50 ++++++++++++++++++++++++++++++++++ tests/surface-generic.spec.js | 51 +++++++++++++++++++++++++++++++++++ 4 files changed, 131 insertions(+), 11 deletions(-) diff --git a/core/macros.css b/core/macros.css index 68b0aeaa..5f4452fa 100644 --- a/core/macros.css +++ b/core/macros.css @@ -286,6 +286,22 @@ --sf-caret-color: var(--sf-surface-contrast); --sf-shadow-color: oklch(from var(--sf-surface-color) var(--sf-shadow-lightness) c h); + + /* :root-only alias-overrides seal their value at the DECLARING element + (var() resolves on :root), so a heading/body that reads the alias + reads the frozen page value and ignores this surface's re-declared + semantic token above — leaving headings dark on a dark surface. + Re-declare the aliases here so they re-resolve against THIS surface's + adapted --sf-color-heading / --sf-color-text. Same class of bug as the + [data-theme] block in themes.css (#496), extended to heading/body. */ + --sf-heading-color: var(--sf-color-heading); + --sf-body-color: var(--sf-color-text); + + /* INTERNAL flag (inherits): signals "inside an auto-contrast surface" so a + nested .sf-card can restore surface-appropriate foreground for its own + (non-adapting) background via a style query — see .sf-card in + optional/components.css (#496). Not a public hook; read, don't set. */ + --sf-surface-active: 1; } } .sf-scrim { diff --git a/core/themes.css b/core/themes.css index a80c30ad..23b15eb8 100644 --- a/core/themes.css +++ b/core/themes.css @@ -19,19 +19,22 @@ background-color: var(--sf-color-bg); color: var(--sf-color-text); - /* Component-layer tokens that merely alias a themed core token (no - light/dark branching of their own, e.g. --sf-card-bg: var(--sf-color- - surface)) are declared once in optional/tokens.components.css's plain - :root block. Without a redeclaration here, that var() substitutes - using :root's OWN --sf-color-surface at computed-value time and the - result inherits down frozen — a nested data-theme section further in - never gets a chance to re-substitute it, breaking the "data-theme - works on any element" guarantee (docs/theming.md) for .sf-card - specifically. Re-declaring the alias here, at the same non-root - scope, forces a fresh substitution against this section's own - (possibly overridden) --sf-color-surface/-border. */ + /* Component-layer and :root-only alias tokens that merely alias a themed + core token (no light/dark branching of their own, e.g. --sf-card-bg: + var(--sf-color-surface)) are declared once in :root (tokens.css / + optional/tokens.components.css). Without a redeclaration here, that + var() substitutes using :root's OWN --sf-color-surface/-heading/-text + at computed-value time and the result inherits down frozen — a nested + data-theme section further in never gets a chance to re-substitute it, + breaking the "data-theme works on any element" guarantee + (docs/theming.md) for .sf-card and for heading/body colour (#496). + Re-declaring the aliases here, at the same non-root scope, forces a + fresh substitution against this section's own (possibly overridden) + --sf-color-surface / -border / -heading / -text. */ --sf-card-bg: var(--sf-color-surface); --sf-card-border-color: var(--sf-color-border); + --sf-heading-color: var(--sf-color-heading); + --sf-body-color: var(--sf-color-text); } /* LumLocker: locks 4 brand colors to a fixed OKLCH L — --sf-lumlocker in diff --git a/optional/components.css b/optional/components.css index 4d1dd7e7..42f4f258 100644 --- a/optional/components.css +++ b/optional/components.css @@ -416,6 +416,56 @@ box-shadow: var(--sf-card-shadow, var(--sf-shadow-s)); } + /* Card-on-coloured-surface reset (#496). + A .sf-card keeps the page/theme surface for its OWN background + (--sf-card-bg = --sf-color-surface, which a coloured .sf-surface--* does + NOT override — the card stays a light "elevation" island above the colour). + But the surface auto-contrast cascade (core/macros.css) re-points + --sf-color-text / -heading / -border to contrast with the SURFACE colour, + and those inherit into the card, computing its title/body/border for the + wrong background (e.g. light-on-light). Re-derive the surface-appropriate + tokens on the card from the ambient neutral so its content stays legible + against its own background, and set `color` so inherited body text is + re-established too. + + Activation is gated on the surface's --sf-surface-active flag via a style + query — NOT a `.sf-surface .sf-card` descendant selector — so (a) ordinary + cards and [data-theme] cards (handled in core/themes.css) are untouched, + (b) a consumer's own --sf-color-heading override on a non-surface ancestor + is still inherited (the flag is unset there), and (c) the surface class + names don't leak into this component file's generated class reference. + SL-001: the light-dark() derivations below mirror core/tokens.css's + "Resolved color tokens" block — keep them in sync if the formulas change. */ + @supports (color: oklch(from red l c h)) { + @container style(--sf-surface-active: 1) { + .sf-card { + + --sf-color-heading: light-dark( + oklch(from var(--sf-color-neutral-source-light) clamp(0.05, calc(l - 0.4 - var(--sf-contrast-bias)), 0.35) c h), + oklch(from var(--sf-color-neutral) clamp(0.70, calc(l + 0.25 + var(--sf-contrast-bias)), 1) c h)); + --sf-color-text: var(--sf-color-heading); + --sf-color-text--secondary: light-dark( + oklch(from var(--sf-color-neutral-source-light) clamp(0.15, calc(l - 0.25 - var(--sf-contrast-bias)), 0.45) c h), + oklch(from var(--sf-color-neutral) clamp(0.55, calc(l + 0.1 + var(--sf-contrast-bias)), 0.90) c h)); + --sf-color-border: light-dark( + oklch(from var(--sf-color-neutral-source-light) clamp(0.70, calc(l + 0.35), 0.95) 0.005 h), + oklch(from var(--sf-color-neutral) clamp(0.25, calc(l - 0.3), 0.55) 0.005 h)); + --sf-color-border--subtle: light-dark( + oklch(from var(--sf-color-neutral-source-light) clamp(0.75, calc(l + 0.4), 0.97) 0.005 h), + oklch(from var(--sf-color-neutral) clamp(0.20, calc(l - 0.38), 0.45) 0.005 h)); + + /* Re-seal the :root aliases against the card's restored tokens. */ + --sf-heading-color: var(--sf-color-heading); + --sf-body-color: var(--sf-color-text); + --sf-card-border-color: var(--sf-color-border); + + /* Body text inherits the `color` property (not a token) from the surface, + so re-establish it from the card's restored --sf-color-text. */ + color: var(--sf-body-color, var(--sf-color-text)); + } + } + } + .sf-card__header, .sf-card__body, .sf-card__footer { diff --git a/tests/surface-generic.spec.js b/tests/surface-generic.spec.js index 8b4c307d..a5583649 100644 --- a/tests/surface-generic.spec.js +++ b/tests/surface-generic.spec.js @@ -82,6 +82,57 @@ test.describe('Generic surface (.sf-surface)', () => { expect(borderOnSurface).not.toBe(borderOnRoot); }); + // Named variant .sf-surface--secondary is dark in BOTH light and dark mode. + // Regression guard for #496 (alias footgun): a heading reads --sf-heading-color + // and a card reads --sf-card-bg / --sf-color-heading; :root-only aliases used + // to seal the page value and ignore the surface's auto-contrast re-mapping, + // leaving the heading dark-on-dark and the card's title/body light-on-light. + // Self-contained WCAG luminance-contrast probe (shared canvas technique). + function probeSurfaceHeadingCard() { + const cv = document.createElement('canvas'); cv.width = cv.height = 1; + const ctx = cv.getContext('2d', { willReadFrequently: true }); + const toLum = (color) => { + ctx.clearRect(0, 0, 1, 1); ctx.fillStyle = color; ctx.fillRect(0, 0, 1, 1); + const [r, g, b] = ctx.getImageData(0, 0, 1, 1).data; + const lin = v => { v /= 255; return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4; }; + return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b); + }; + const ratio = (a, b) => { const x = toLum(a), y = toLum(b); return (Math.max(x, y) + 0.05) / (Math.min(x, y) + 0.05); }; + + const section = document.createElement('section'); + section.className = 'sf-surface--secondary'; + const h2 = document.createElement('h2'); h2.textContent = 'heading'; + const card = document.createElement('div'); card.className = 'sf-card'; + const title = document.createElement('h4'); title.className = 'sf-card__title'; title.textContent = 'title'; + const body = document.createElement('p'); body.textContent = 'body'; + card.append(title, body); + section.append(h2, card); + document.body.appendChild(section); + + const surfBg = getComputedStyle(section).backgroundColor; + const cardBg = getComputedStyle(card).backgroundColor; + const out = { + headingVsSurface: ratio(getComputedStyle(h2).color, surfBg), + titleVsCard: ratio(getComputedStyle(title).color, cardBg), + bodyVsCard: ratio(getComputedStyle(body).color, cardBg), + }; + section.remove(); + return out; + } + + test('direct heading auto-flips on a coloured surface (#496)', async ({ page }) => { + await page.goto(FIXTURE); + const { headingVsSurface } = await page.evaluate(probeSurfaceHeadingCard); + expect(headingVsSurface).toBeGreaterThanOrEqual(3); + }); + + test('a .sf-card on a coloured surface keeps its title/body legible against its own bg (#496)', async ({ page }) => { + await page.goto(FIXTURE); + const { titleVsCard, bodyVsCard } = await page.evaluate(probeSurfaceHeadingCard); + expect(titleVsCard).toBeGreaterThanOrEqual(3); + expect(bodyVsCard).toBeGreaterThanOrEqual(3); + }); + test('default surface color is the base surface', async ({ page }) => { await page.goto(FIXTURE); const { bg, baseBg } = await page.evaluate(() => { From 4206fdafe0ce607bb3edcffdf0e7d884200665db Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Jul 2026 12:00:14 +0000 Subject: [PATCH 2/4] docs(changelog): note coloured-surface heading/body/card foreground fix (#496) Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01ScK3jJFKQ7aWasg25dc7VS --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 734aee19..cbccaf2b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ### Features - **configurator:** colour fields now accept any CSS colour format — paste a hex, `rgb()`, `hsl()`, a named colour, `lab()`/`lch()`, `color()`, etc. and it is converted automatically into the token's canonical space (OKLCH, or OKLAB where that is the field's default). Conversion uses the browser's own colour engine, so it matches exactly what gets painted; `var()` references and already-canonical values pass through untouched. +### Bug Fixes +- **themes:** headings, body text and cards now auto-flip their foreground on coloured surfaces (`.sf-surface--*`) and in `[data-theme]` sections (#496). The `:root`-only alias overrides (`--sf-heading-color`, `--sf-body-color`, `--sf-card-border-color`) previously sealed the page value at `:root` — so a heading stayed dark on a dark surface, and a `.sf-card` on a coloured surface rendered its title/body/border for the wrong background. A `.sf-card` keeps the page surface as its own (non-adapting) background and now restores surface-appropriate, legible foreground for it; a consumer's own `--sf-color-heading` override on a non-surface ancestor is still inherited. + ## [0.7.24] - 2026-07-19 ### Features From e4f547ad0712cc8eafe34f3ecde170a3ece35369 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Jul 2026 12:17:32 +0000 Subject: [PATCH 3/4] =?UTF-8?q?test(bundle-size):=20bump=20full.min.css=20?= =?UTF-8?q?budget=2022.1=20=E2=86=92=2022.2=20for=20#496=20card=20reset?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The coloured-surface .sf-card foreground re-derivation duplicates the light-dark() heading/text/border formulas, tipping the already-edge full bundle a few bytes over the 22.1kB gzip guardrail (22.123kB). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01ScK3jJFKQ7aWasg25dc7VS --- tests/bundle-size.spec.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/bundle-size.spec.js b/tests/bundle-size.spec.js index b7377cb5..eb36f1e4 100644 --- a/tests/bundle-size.spec.js +++ b/tests/bundle-size.spec.js @@ -16,9 +16,12 @@ const DIST = path.resolve(import.meta.dirname, '..', 'dist'); // --row-compact/--row-tall (disambiguates from the child modifier // .sf-bento-tall) — full.min.css was already at the 22kB edge, and the few // extra bytes tipped it over. +// full.min.css bumped 22.1 → 22.2 for the #496 coloured-surface card reset: +// the .sf-card foreground re-derivation duplicates the light-dark() heading/ +// text/border formulas, tipping the already-edge bundle a few bytes over. const BUDGETS = { 'slashed.optimal.min.css': 22, - 'slashed.full.min.css': 22.1, + 'slashed.full.min.css': 22.2, }; for (const [file, budgetKb] of Object.entries(BUDGETS)) { From 8a7880a4fa34ee3f63706b8c4a3847f54a1812e0 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Jul 2026 12:18:38 +0000 Subject: [PATCH 4/4] style(components): fix indentation in the #496 card-reset @container block MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Align the .sf-card rule and its declarations to the file's 2-space nesting (@supports 2 → @container 4 → rule 6 → decls 8), per review feedback. No behavioural change — minified output is byte-identical. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01ScK3jJFKQ7aWasg25dc7VS --- optional/components.css | 50 ++++++++++++++++++++--------------------- 1 file changed, 25 insertions(+), 25 deletions(-) diff --git a/optional/components.css b/optional/components.css index 42f4f258..715167b9 100644 --- a/optional/components.css +++ b/optional/components.css @@ -438,31 +438,31 @@ "Resolved color tokens" block — keep them in sync if the formulas change. */ @supports (color: oklch(from red l c h)) { @container style(--sf-surface-active: 1) { - .sf-card { - - --sf-color-heading: light-dark( - oklch(from var(--sf-color-neutral-source-light) clamp(0.05, calc(l - 0.4 - var(--sf-contrast-bias)), 0.35) c h), - oklch(from var(--sf-color-neutral) clamp(0.70, calc(l + 0.25 + var(--sf-contrast-bias)), 1) c h)); - --sf-color-text: var(--sf-color-heading); - --sf-color-text--secondary: light-dark( - oklch(from var(--sf-color-neutral-source-light) clamp(0.15, calc(l - 0.25 - var(--sf-contrast-bias)), 0.45) c h), - oklch(from var(--sf-color-neutral) clamp(0.55, calc(l + 0.1 + var(--sf-contrast-bias)), 0.90) c h)); - --sf-color-border: light-dark( - oklch(from var(--sf-color-neutral-source-light) clamp(0.70, calc(l + 0.35), 0.95) 0.005 h), - oklch(from var(--sf-color-neutral) clamp(0.25, calc(l - 0.3), 0.55) 0.005 h)); - --sf-color-border--subtle: light-dark( - oklch(from var(--sf-color-neutral-source-light) clamp(0.75, calc(l + 0.4), 0.97) 0.005 h), - oklch(from var(--sf-color-neutral) clamp(0.20, calc(l - 0.38), 0.45) 0.005 h)); - - /* Re-seal the :root aliases against the card's restored tokens. */ - --sf-heading-color: var(--sf-color-heading); - --sf-body-color: var(--sf-color-text); - --sf-card-border-color: var(--sf-color-border); - - /* Body text inherits the `color` property (not a token) from the surface, - so re-establish it from the card's restored --sf-color-text. */ - color: var(--sf-body-color, var(--sf-color-text)); - } + .sf-card { + + --sf-color-heading: light-dark( + oklch(from var(--sf-color-neutral-source-light) clamp(0.05, calc(l - 0.4 - var(--sf-contrast-bias)), 0.35) c h), + oklch(from var(--sf-color-neutral) clamp(0.70, calc(l + 0.25 + var(--sf-contrast-bias)), 1) c h)); + --sf-color-text: var(--sf-color-heading); + --sf-color-text--secondary: light-dark( + oklch(from var(--sf-color-neutral-source-light) clamp(0.15, calc(l - 0.25 - var(--sf-contrast-bias)), 0.45) c h), + oklch(from var(--sf-color-neutral) clamp(0.55, calc(l + 0.1 + var(--sf-contrast-bias)), 0.90) c h)); + --sf-color-border: light-dark( + oklch(from var(--sf-color-neutral-source-light) clamp(0.70, calc(l + 0.35), 0.95) 0.005 h), + oklch(from var(--sf-color-neutral) clamp(0.25, calc(l - 0.3), 0.55) 0.005 h)); + --sf-color-border--subtle: light-dark( + oklch(from var(--sf-color-neutral-source-light) clamp(0.75, calc(l + 0.4), 0.97) 0.005 h), + oklch(from var(--sf-color-neutral) clamp(0.20, calc(l - 0.38), 0.45) 0.005 h)); + + /* Re-seal the :root aliases against the card's restored tokens. */ + --sf-heading-color: var(--sf-color-heading); + --sf-body-color: var(--sf-color-text); + --sf-card-border-color: var(--sf-color-border); + + /* Body text inherits the `color` property (not a token) from the surface, + so re-establish it from the card's restored --sf-color-text. */ + color: var(--sf-body-color, var(--sf-color-text)); + } } }