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 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..715167b9 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/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)) { 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(() => {