Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 16 additions & 0 deletions core/macros.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
25 changes: 14 additions & 11 deletions core/themes.css
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
50 changes: 50 additions & 0 deletions optional/components.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
5 changes: 4 additions & 1 deletion tests/bundle-size.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand Down
51 changes: 51 additions & 0 deletions tests/surface-generic.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand Down