diff --git a/bundle.config.json b/bundle.config.json index 0585cea7..caadc653 100644 --- a/bundle.config.json +++ b/bundle.config.json @@ -4,6 +4,7 @@ "output": "dist/slashed.essential.css", "files": [ "core/layers.css", + "core/tokens.color-fallbacks.css", "core/tokens.css", "core/tokens.layout.css", "core/tokens.macros.css", @@ -23,6 +24,7 @@ "flat": true, "files": [ "core/layers.css", + "core/tokens.color-fallbacks.css", "core/tokens.css", "core/tokens.layout.css", "core/tokens.macros.css", @@ -41,6 +43,7 @@ "output": "dist/slashed.optimal.css", "files": [ "core/layers.css", + "core/tokens.color-fallbacks.css", "core/tokens.css", "core/tokens.layout.css", "core/tokens.macros.css", @@ -64,6 +67,7 @@ "flat": true, "files": [ "core/layers.css", + "core/tokens.color-fallbacks.css", "core/tokens.css", "core/tokens.layout.css", "core/tokens.macros.css", @@ -86,6 +90,7 @@ "output": "dist/slashed.optimal-components.css", "files": [ "core/layers.css", + "core/tokens.color-fallbacks.css", "core/tokens.css", "core/tokens.layout.css", "core/tokens.macros.css", @@ -111,6 +116,7 @@ "flat": true, "files": [ "core/layers.css", + "core/tokens.color-fallbacks.css", "core/tokens.css", "core/tokens.layout.css", "core/tokens.macros.css", @@ -135,6 +141,7 @@ "output": "dist/slashed.optimal-utilities.css", "files": [ "core/layers.css", + "core/tokens.color-fallbacks.css", "core/tokens.css", "core/tokens.layout.css", "core/tokens.macros.css", @@ -159,6 +166,7 @@ "flat": true, "files": [ "core/layers.css", + "core/tokens.color-fallbacks.css", "core/tokens.css", "core/tokens.layout.css", "core/tokens.macros.css", @@ -182,6 +190,7 @@ "output": "dist/slashed.full.css", "files": [ "core/layers.css", + "core/tokens.color-fallbacks.css", "core/tokens.css", "core/tokens.layout.css", "core/tokens.macros.css", @@ -208,6 +217,7 @@ "flat": true, "files": [ "core/layers.css", + "core/tokens.color-fallbacks.css", "core/tokens.css", "core/tokens.layout.css", "core/tokens.macros.css", diff --git a/core/tokens.color-fallbacks.css b/core/tokens.color-fallbacks.css new file mode 100644 index 00000000..d03cc933 --- /dev/null +++ b/core/tokens.color-fallbacks.css @@ -0,0 +1,86 @@ +/* SLASHED — core/tokens.color-fallbacks.css (@layer slashed.tokens) + HSL-based fallbacks for browsers without light-dark() / oklch(from…) support. + Light mode only — dark mode is handled by modern CSS exclusively. + Customise by overriding the --sf-*-h / --sf-*-s / --sf-*-l channel variables; + the entire derived token tree recomputes automatically, no build step required. */ + +@layer slashed.tokens { + :root { + /* ── Source channels — override to apply brand colours ──────────────── + Values are approximate HSL equivalents of the OKLCH source tokens. */ + + --sf-primary-h: 226; --sf-primary-s: 99%; --sf-primary-l: 47%; + --sf-secondary-h: 221; --sf-secondary-s: 45%; --sf-secondary-l: 12%; + --sf-tertiary-h: 268; --sf-tertiary-s: 84%; --sf-tertiary-l: 39%; + --sf-action-h: 200; --sf-action-s: 100%; --sf-action-l: 32%; + --sf-neutral-h: 217; --sf-neutral-s: 11%; --sf-neutral-l: 42%; + --sf-base-h: 211; --sf-base-s: 27%; --sf-base-l: 95%; + --sf-success-h: 134; --sf-success-s: 100%; --sf-success-l: 24%; + --sf-warning-h: 42; --sf-warning-s: 100%; --sf-warning-l: 45%; + --sf-error-h: 352; --sf-error-s: 93%; --sf-error-l: 38%; + --sf-info-h: 200; --sf-info-s: 100%; --sf-info-l: 30%; + --sf-danger-h: 340; --sf-danger-s: 100%; --sf-danger-l: 35%; + + /* ── Brand resolved ───────────────────────────────────────────────────── */ + + --sf-color-primary: hsl(var(--sf-primary-h) var(--sf-primary-s) var(--sf-primary-l)); + --sf-color-secondary: hsl(var(--sf-secondary-h) var(--sf-secondary-s) var(--sf-secondary-l)); + --sf-color-tertiary: hsl(var(--sf-tertiary-h) var(--sf-tertiary-s) var(--sf-tertiary-l)); + --sf-color-action: hsl(var(--sf-action-h) var(--sf-action-s) var(--sf-action-l)); + --sf-color-neutral: hsl(var(--sf-neutral-h) var(--sf-neutral-s) var(--sf-neutral-l)); + --sf-color-base: hsl(var(--sf-base-h) var(--sf-base-s) var(--sf-base-l)); + + /* ── Status resolved ──────────────────────────────────────────────────── */ + + --sf-color-success: hsl(var(--sf-success-h) var(--sf-success-s) var(--sf-success-l)); + --sf-color-warning: hsl(var(--sf-warning-h) var(--sf-warning-s) var(--sf-warning-l)); + --sf-color-error: hsl(var(--sf-error-h) var(--sf-error-s) var(--sf-error-l)); + --sf-color-info: hsl(var(--sf-info-h) var(--sf-info-s) var(--sf-info-l)); + --sf-color-danger: hsl(var(--sf-danger-h) var(--sf-danger-s) var(--sf-danger-l)); + + /* ── Status strong (darker variant) ──────────────────────────────────── */ + + --sf-color-success-strong: hsl(var(--sf-success-h) var(--sf-success-s) calc(var(--sf-success-l) - 8%)); + --sf-color-warning-strong: hsl(var(--sf-warning-h) var(--sf-warning-s) calc(var(--sf-warning-l) - 15%)); + --sf-color-error-strong: hsl(var(--sf-error-h) var(--sf-error-s) calc(var(--sf-error-l) - 6%)); + --sf-color-info-strong: hsl(var(--sf-info-h) var(--sf-info-s) calc(var(--sf-info-l) - 8%)); + --sf-color-danger-strong: hsl(var(--sf-danger-h) var(--sf-danger-s) calc(var(--sf-danger-l) - 8%)); + + /* ── Surfaces (base-derived) ──────────────────────────────────────────── */ + + --sf-color-bg: hsl(var(--sf-base-h) var(--sf-base-s) calc(var(--sf-base-l) + 2%)); + --sf-color-inset: hsl(var(--sf-base-h) var(--sf-base-s) calc(var(--sf-base-l) - 3%)); + --sf-color-raised: hsl(var(--sf-base-h) var(--sf-base-s) calc(var(--sf-base-l) + 4%)); + + /* ── Text (neutral-derived) ───────────────────────────────────────────── */ + + --sf-color-text: hsl(var(--sf-neutral-h) 15% 12%); + --sf-color-text--secondary: hsl(var(--sf-neutral-h) 10% 35%); + --sf-color-heading: hsl(var(--sf-neutral-h) 20% 8%); + + /* ── Borders (neutral-derived) ────────────────────────────────────────── */ + + --sf-color-border: hsl(var(--sf-neutral-h) 8% 85%); + --sf-color-border--subtle: hsl(var(--sf-neutral-h) 5% 91%); + --sf-color-border--strong: hsl(var(--sf-neutral-h) 14% 70%); + + /* ── Links (action-derived) ───────────────────────────────────────────── */ + + --sf-color-link: hsl(var(--sf-action-h) var(--sf-action-s) calc(var(--sf-action-l) - 5%)); + + /* ── Selection ────────────────────────────────────────────────────────── */ + + --sf-color-selection-bg: hsl(var(--sf-action-h) var(--sf-action-s) var(--sf-action-l) / 0.28); + + /* ── Shadows (neutral-derived) ────────────────────────────────────────── */ + + --sf-shadow-xs: 0 1px 2px 0 + hsl(var(--sf-neutral-h) 20% 10% / 0.06); + --sf-shadow-s: 0 1px 3px 0 hsl(var(--sf-neutral-h) 20% 10% / 0.10), + 0 1px 2px -1px hsl(var(--sf-neutral-h) 20% 10% / 0.10); + --sf-shadow-m: 0 4px 6px -1px hsl(var(--sf-neutral-h) 20% 10% / 0.10), + 0 2px 4px -2px hsl(var(--sf-neutral-h) 20% 10% / 0.06); + --sf-shadow-l: 0 10px 15px -3px hsl(var(--sf-neutral-h) 20% 10% / 0.10), + 0 4px 6px -4px hsl(var(--sf-neutral-h) 20% 10% / 0.06); + } +} diff --git a/core/tokens.css b/core/tokens.css index f283f7a5..44e9eeba 100644 --- a/core/tokens.css +++ b/core/tokens.css @@ -166,6 +166,23 @@ @property --sf-color-info-light { syntax: ""; inherits: true; initial-value: oklch(0.48 0.18 235); } @property --sf-color-danger-light { syntax: ""; inherits: true; initial-value: oklch(0.48 0.22 12); } + /* Plain :root mirrors — character-for-character identical to each @property + initial-value above. Engines lacking @property support read the token value + from here. Do NOT edit these independently from the initial-value strings. */ + :root { + --sf-color-primary-light: oklch(0.47 0.27 264); + --sf-color-secondary-light: oklch(0.22 0.04 264); + --sf-color-tertiary-light: oklch(0.42 0.22 295); + --sf-color-action-light: oklch(0.50 0.22 235); + --sf-color-neutral-light: oklch(0.52 0.025 260); + --sf-color-base-light: oklch(0.96 0.006 250); + --sf-color-success-light: oklch(0.50 0.16 145); + --sf-color-warning-light: oklch(0.75 0.17 80); + --sf-color-error-light: oklch(0.50 0.20 25); + --sf-color-info-light: oklch(0.48 0.18 235); + --sf-color-danger-light: oklch(0.48 0.22 12); + } + /* Mode flag — drives formula direction for non-color dark overrides. Set by themes.css via [data-theme="dark"] and the prefers-color-scheme media query. Do not set directly. */ @@ -205,159 +222,29 @@ source tokens above to customise; or override these directly with your own light-dark() expression. ---------------------------------------------------------- */ - /* Dark auto-derivation formula (brand + status): - clamp(0.65, 0.95 - l*0.5, 0.88) lightens dark-mode value relative to the light source. - Surface inverts: clamp(0.16, 1.18 - l, 0.24) — near-white flips to near-dark. - Override any --sf-color-X-dark to take full per-mode control. */ - --sf-color-primary: light-dark(var(--sf-color-primary-light), var(--sf-color-primary-dark, oklch(from var(--sf-color-primary-light) clamp(0.65, calc(0.95 - l * 0.5), 0.88) calc(c * 0.9) h))); - --sf-color-secondary: light-dark(var(--sf-color-secondary-light), var(--sf-color-secondary-dark, oklch(from var(--sf-color-secondary-light) clamp(0.65, calc(0.95 - l * 0.5), 0.88) calc(c * 0.9) h))); - --sf-color-tertiary: light-dark(var(--sf-color-tertiary-light), var(--sf-color-tertiary-dark, oklch(from var(--sf-color-tertiary-light) clamp(0.65, calc(0.95 - l * 0.5), 0.88) calc(c * 0.9) h))); - --sf-color-action: light-dark(var(--sf-color-action-light), var(--sf-color-action-dark, oklch(from var(--sf-color-action-light) clamp(0.65, calc(0.95 - l * 0.5), 0.88) calc(c * 0.9) h))); - --sf-color-neutral: light-dark(var(--sf-color-neutral-light), var(--sf-color-neutral-dark, oklch(from var(--sf-color-neutral-light) clamp(0.65, calc(0.95 - l * 0.5), 0.88) calc(c * 0.9) h))); - --sf-color-base: light-dark(var(--sf-color-base-light), var(--sf-color-base-dark, oklch(from var(--sf-color-base-light) clamp(0.16, calc(1.18 - l), 0.24) calc(c * 0.5) h))); - - --sf-color-success: light-dark(var(--sf-color-success-light), var(--sf-color-success-dark, oklch(from var(--sf-color-success-light) clamp(0.65, calc(0.95 - l * 0.5), 0.88) calc(c * 0.9) h))); - --sf-color-warning: light-dark(var(--sf-color-warning-light), var(--sf-color-warning-dark, oklch(from var(--sf-color-warning-light) clamp(0.65, calc(0.95 - l * 0.5), 0.88) calc(c * 0.9) h))); - --sf-color-error: light-dark(var(--sf-color-error-light), var(--sf-color-error-dark, oklch(from var(--sf-color-error-light) clamp(0.65, calc(0.95 - l * 0.5), 0.88) calc(c * 0.9) h))); - --sf-color-info: light-dark(var(--sf-color-info-light), var(--sf-color-info-dark, oklch(from var(--sf-color-info-light) clamp(0.65, calc(0.95 - l * 0.5), 0.88) calc(c * 0.9) h))); - --sf-color-danger: light-dark(var(--sf-color-danger-light), var(--sf-color-danger-dark, oklch(from var(--sf-color-danger-light) clamp(0.65, calc(0.95 - l * 0.5), 0.88) calc(c * 0.9) h))); - - /* ---------------------------------------------------------- - Surfaces — derived from --sf-color-base (auto-adapts) - ---------------------------------------------------------- */ + /* Surface alias (plain var — no IACVT, stays ungated) */ --sf-color-surface: var(--sf-color-base); - --sf-color-bg: oklch(from var(--sf-color-base) calc(l + 0.02) c h); - --sf-color-inset: oklch(from var(--sf-color-base) calc(l - 0.02) c h); - --sf-color-raised: oklch(from var(--sf-color-base) calc(l + 0.04) c h); - --sf-color-overlay: oklch(from var(--sf-color-base) l c h / 0.9); - --sf-color-inverse: oklch(from var(--sf-color-base) calc(1 - l) c h); /* ---------------------------------------------------------- - Text — light and dark require opposite formula directions, - so each side of light-dark() references its source token. + Text — plain var aliases (ungated) ---------------------------------------------------------- */ - --sf-color-text: light-dark( - oklch(from var(--sf-color-neutral-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--secondary: light-dark( - oklch(from var(--sf-color-neutral-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-text--muted: var(--sf-color-neutral); - --sf-color-text--placeholder: light-dark( - oklch(from var(--sf-color-neutral-light) clamp(0.45, calc(l + 0.15), 0.75) c h), - oklch(from var(--sf-color-neutral) clamp(0.35, calc(l - 0.1), 0.65) c h) - ); - --sf-color-text--disabled: light-dark( - oklch(from var(--sf-color-neutral-light) clamp(0.55, calc(l + 0.25), 0.82) c h), - oklch(from var(--sf-color-neutral) clamp(0.25, calc(l - 0.2), 0.55) c h) - ); - --sf-color-text--inverse: light-dark( - oklch(from var(--sf-color-neutral-light) clamp(0.85, calc(l + 0.4), 0.98) c h), - oklch(from var(--sf-color-neutral) clamp(0.05, calc(l - 0.4), 0.35) c h) - ); - --sf-color-heading: light-dark( - oklch(from var(--sf-color-neutral-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) - ); - /* Inline-code text colour. Auto-contrasts with --sf-color-code-bg using - the same luminance formula as --sf-color-text--on-*. This prevents - invisible text when code appears inside inverted-colour containers - (e.g. sf-imposter with a dark background where inherited colour is - near-white). Override with a tint value if desired. */ - --sf-color-code-text: oklch(from var(--sf-color-code-bg) clamp(0.1, sign(var(--sf-contrast-threshold) - l) * 999, 0.95) 0 0); /* ---------------------------------------------------------- - Text-on-color — sign(var(--sf-contrast-threshold) - l) * 999 is mode-agnostic: - auto-selects light or dark text based on background luminance. - Uses resolved tokens — works for any user-supplied color. - - CONTRAST NOTE: Guarantees ≥ 3:1 (WCAG AA Large Text / UI). - Colors in the mid-luminance range (L ≈ 0.52–0.67) may not - reach 4.5:1 (AA Normal Text) regardless of threshold chosen — - this is a mathematical limitation of any binary black/white - decision. The SLASHED configurator will warn users when their - brand colors fall in this range. For body text on colored - backgrounds, override --sf-color-text--on-* manually. + Text-on-color — plain var aliases (ungated) ---------------------------------------------------------- */ - --sf-color-text--on-primary: oklch(from var(--sf-color-primary) clamp(0.1, sign(var(--sf-contrast-threshold) - l) * 999, 0.95) 0 0); - --sf-color-text--on-secondary: oklch(from var(--sf-color-secondary) clamp(0.1, sign(var(--sf-contrast-threshold) - l) * 999, 0.95) 0 0); - --sf-color-text--on-tertiary: oklch(from var(--sf-color-tertiary) clamp(0.1, sign(var(--sf-contrast-threshold) - l) * 999, 0.95) 0 0); - --sf-color-text--on-action: oklch(from var(--sf-color-action) clamp(0.1, sign(var(--sf-contrast-threshold) - l) * 999, 0.95) 0 0); - --sf-color-text--on-neutral: oklch(from var(--sf-color-neutral) clamp(0.1, sign(var(--sf-contrast-threshold) - l) * 999, 0.95) 0 0); - --sf-color-text--on-base: var(--sf-color-text); - --sf-color-text--on-surface: var(--sf-color-text--on-base); /* compat alias → base */ - --sf-color-text--on-inverse: var(--sf-color-text--inverse); - --sf-color-text--on-success: oklch(from var(--sf-color-success) clamp(0.1, sign(var(--sf-contrast-threshold) - l) * 999, 0.95) 0 0); - --sf-color-text--on-warning: oklch(from var(--sf-color-warning) clamp(0.1, sign(var(--sf-contrast-threshold) - l) * 999, 0.95) 0 0); - --sf-color-text--on-error: oklch(from var(--sf-color-error) clamp(0.1, sign(var(--sf-contrast-threshold) - l) * 999, 0.95) 0 0); - --sf-color-text--on-info: oklch(from var(--sf-color-info) clamp(0.1, sign(var(--sf-contrast-threshold) - l) * 999, 0.95) 0 0); - --sf-color-text--on-danger: oklch(from var(--sf-color-danger) clamp(0.1, sign(var(--sf-contrast-threshold) - l) * 999, 0.95) 0 0); + --sf-color-text--on-base: var(--sf-color-text); + --sf-color-text--on-surface: var(--sf-color-text--on-base); /* compat alias → base */ + --sf-color-text--on-inverse: var(--sf-color-text--inverse); /* ---------------------------------------------------------- - Borders — direction-dependent, reference source tokens. - Light: borders are lighter than neutral (l + offset). - Dark: borders are darker than neutral (l - offset). + Borders — plain var aliases (ungated) ---------------------------------------------------------- */ - --sf-color-border: light-dark( - oklch(from var(--sf-color-neutral-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-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) - ); - --sf-color-border--strong: light-dark( - oklch(from var(--sf-color-neutral-light) clamp(0.55, calc(l + 0.1), 0.85) 0.02 h), - oklch(from var(--sf-color-neutral) clamp(0.38, calc(l - 0.1), 0.65) 0.02 h) - ); - --sf-color-border--focus: var(--sf-color-action); - --sf-color-border--disabled: oklch(from var(--sf-color-border--subtle) l 0 h / 0.5); - --sf-color-border--translucent: oklch(from var(--sf-color-neutral) l c h / 0.15); + --sf-color-border--focus: var(--sf-color-action); /* ---------------------------------------------------------- - Links — derived from --sf-color-action (auto-adapts) + Links — plain var aliases and geometry (ungated) ---------------------------------------------------------- */ - /* Links keep the action hue but CLAMP lightness toward a contrast-safe - band — a ceiling in light mode, a floor in dark mode — so link text - clears WCAG AA (4.5:1) on the page background for the default palette - AND the large majority of brand overrides (a fixed offset alone only - fixed the default). Lightness dominates OKLCH contrast but doesn't - fully determine it: very high-chroma hues (saturated yellow/green) can - still fall short even at a safe lightness — those remain the consumer's - responsibility (see docs/theming.md → "Link contrast"). - - NOTE: the consumer-facing :link rule in core/base.css uses - `a:link` (not bare `a`) so its specificity matches WebKit's UA - stylesheet `a:link { color: -webkit-link }`. Without that - pseudo-class the framework's `--sf-color-link` value is - silently ignored on unvisited anchors in WebKit and - `-webkit-link` (≈ #00728f in dark mode) paints instead — - regardless of how this token is computed. PR #73 and several - follow-up attempts mistakenly chased the formula. */ - --sf-color-link: light-dark( - oklch(from var(--sf-color-action) clamp(0, min(l - 0.07, 0.48), 1) c h), - oklch(from var(--sf-color-action) clamp(0.68, l, 1) c h) - ); - --sf-color-link--hover: light-dark( - oklch(from var(--sf-color-action) clamp(0, min(l - 0.15, 0.40), 1) c h), - oklch(from var(--sf-color-action) clamp(0, max(l + 0.10, 0.68), 1) c h) - ); - --sf-color-link--active: light-dark( - oklch(from var(--sf-color-action) clamp(0, min(l - 0.21, 0.34), 1) c h), - oklch(from var(--sf-color-action) clamp(0, max(l + 0.15, 0.74), 1) c h) - ); - /* Visited link — 60° hue shift from action gives clearly distinct - chroma without colliding with default tertiary (h ≈ 310). Same - lightness clamp as the base link. If your brand puts action and - tertiary close in hue, override directly: - :root { --sf-color-link--visited: #6b46c1; } */ - --sf-color-link--visited: light-dark( - oklch(from var(--sf-color-action) clamp(0, min(l - 0.07, 0.48), 1) c calc(h + 60)), - oklch(from var(--sf-color-action) clamp(0.68, l, 1) c calc(h + 60)) - ); - --sf-color-link--underline: oklch(from var(--sf-color-action) l c h / 0.3); --sf-color-link--disabled: var(--sf-color-text--disabled); /* Underline geometry — consumed by a:link in core/base.css and by the .sf-link--* macros. `auto` defers to the font's own metrics; @@ -366,24 +253,15 @@ --sf-link-underline-thickness: auto; /* ---------------------------------------------------------- - Interactive states — derived from --sf-color-neutral (auto-adapts) + Interactive states — plain var aliases (ungated) ---------------------------------------------------------- */ - --sf-color-bg--hover: oklch(from var(--sf-color-neutral) l c h / 0.08); - --sf-color-bg--active: oklch(from var(--sf-color-neutral) l c h / 0.12); - --sf-color-bg--selected: oklch(from var(--sf-color-action) l c h / 0.1); - --sf-color-bg--focus: oklch(from var(--sf-color-action) l c h / 0.06); --sf-color-bg--disabled: var(--sf-color-inset); --sf-color-code-bg: var(--sf-color-inset); /* ---------------------------------------------------------- - Selection & backdrop + Selection & backdrop — static values (ungated) ---------------------------------------------------------- */ - --sf-color-selection-bg: light-dark( - oklch(from var(--sf-color-action-light) l c h / 0.28), - oklch(from var(--sf-color-action-light) clamp(0.62, calc(0.93 - l * 0.4), 0.78) c h / 0.55) - ); --sf-color-selection-text: inherit; - --sf-color-mark-bg: oklch(from var(--sf-color-warning) l c h / 0.25); --sf-color-mark-text: inherit; --sf-color-dim: oklch(0 0 0 / 0.5); --sf-color-white: oklch(100% 0 0); @@ -395,67 +273,11 @@ --sf-scrollbar-thumb: var(--sf-color-neutral); --sf-scrollbar-track: transparent; - /* ---------------------------------------------------------- - Status triplets — bg/border use same formula both modes; - text is direction-dependent so uses source tokens. - error = form validation, input errors - danger = destructive actions (delete, remove) - ---------------------------------------------------------- */ - --sf-color-success-subtle: oklch(from var(--sf-color-success) l c h / 0.12); - --sf-color-success-strong: light-dark( - oklch(from var(--sf-color-success-light) calc(l - 0.15) c h), - oklch(from var(--sf-color-success) clamp(0.70, calc(l + 0.15), 1) c h) - ); - --sf-color-success-muted: oklch(from var(--sf-color-success) l c h / 0.3); - - --sf-color-warning-subtle: oklch(from var(--sf-color-warning) l c h / 0.12); - --sf-color-warning-strong: light-dark( - oklch(from var(--sf-color-warning-light) calc(l - 0.25) c h), - oklch(from var(--sf-color-warning) clamp(0.70, calc(l + 0.05), 1) c h) - ); - --sf-color-warning-muted: oklch(from var(--sf-color-warning) l c h / 0.3); - - --sf-color-error-subtle: oklch(from var(--sf-color-error) l c h / 0.1); - --sf-color-error-strong: light-dark( - oklch(from var(--sf-color-error-light) calc(l - 0.1) c h), - oklch(from var(--sf-color-error) clamp(0.70, calc(l + 0.15), 1) c h) - ); - --sf-color-error-muted: oklch(from var(--sf-color-error) l c h / 0.3); - - --sf-color-info-subtle: oklch(from var(--sf-color-info) l c h / 0.1); - --sf-color-info-strong: light-dark( - oklch(from var(--sf-color-info-light) calc(l - 0.1) c h), - oklch(from var(--sf-color-info) clamp(0.70, calc(l + 0.15), 1) c h) - ); - --sf-color-info-muted: oklch(from var(--sf-color-info) l c h / 0.3); - - --sf-color-danger-subtle: oklch(from var(--sf-color-danger) l c h / 0.1); - --sf-color-danger-strong: light-dark( - oklch(from var(--sf-color-danger-light) calc(l - 0.1) c h), - oklch(from var(--sf-color-danger) clamp(0.70, calc(l + 0.15), 1) c h) - ); - --sf-color-danger-muted: oklch(from var(--sf-color-danger) l c h / 0.3); - - /* ---------------------------------------------------------- - Gradients — derived from brand colors (auto-adapt) - ---------------------------------------------------------- */ - --sf-gradient-primary: linear-gradient(in oklch 135deg, var(--sf-color-primary), oklch(from var(--sf-color-primary) calc(l - 0.08) c h)); - --sf-gradient-secondary: linear-gradient(in oklch 135deg, var(--sf-color-secondary), oklch(from var(--sf-color-secondary) calc(l - 0.08) c h)); - --sf-gradient-tertiary: linear-gradient(in oklch 135deg, var(--sf-color-tertiary), oklch(from var(--sf-color-tertiary) calc(l - 0.08) c h)); - --sf-gradient-brand: linear-gradient(in oklch 135deg, var(--sf-color-primary), oklch(from var(--sf-color-primary) l c calc(h + 30))); - --sf-gradient-surface: linear-gradient(in oklab 180deg, var(--sf-color-surface), var(--sf-color-bg)); - - --sf-gradient-fade--r: linear-gradient(in oklch to right, transparent, var(--sf-color-bg)); - --sf-gradient-fade--l: linear-gradient(in oklch to left, transparent, var(--sf-color-bg)); - --sf-gradient-fade--t: linear-gradient(in oklch to top, transparent, var(--sf-color-bg)); - --sf-gradient-fade--b: linear-gradient(in oklch to bottom, transparent, var(--sf-color-bg)); - /* ---------------------------------------------------------- Shadow — strength scales up in dark mode via --sf-is-dark ---------------------------------------------------------- */ --sf-shadow-strength: calc(0.08 + var(--sf-is-dark) * 0.17); --sf-shadow-glow-color: var(--sf-color-primary); - --sf-shadow-glow: 0 0 15px 2px oklch(from var(--sf-shadow-glow-color) l c h / clamp(0, calc(var(--sf-shadow-strength) * 2), 0.7)); --sf-opacity-disabled: 0.45; --sf-state-pending-opacity: 0.7; @@ -468,6 +290,265 @@ --sf-caret-color: var(--sf-color-action); } + /* ── @supports gate: light-dark() ── */ + @supports (color: light-dark(white, black)) { + :root { + + /* ---------------------------------------------------------- + Resolved color tokens — auto-switch via light-dark(). + Dark auto-derivation formula (brand + status): + clamp(0.65, 0.95 - l*0.5, 0.88) lightens dark-mode value relative to the light source. + Surface inverts: clamp(0.16, 1.18 - l, 0.24) — near-white flips to near-dark. + Override any --sf-color-X-dark to take full per-mode control. */ + --sf-color-primary: light-dark(var(--sf-color-primary-light), var(--sf-color-primary-dark, oklch(from var(--sf-color-primary-light) clamp(0.65, calc(0.95 - l * 0.5), 0.88) calc(c * 0.9) h))); + --sf-color-secondary: light-dark(var(--sf-color-secondary-light), var(--sf-color-secondary-dark, oklch(from var(--sf-color-secondary-light) clamp(0.65, calc(0.95 - l * 0.5), 0.88) calc(c * 0.9) h))); + --sf-color-tertiary: light-dark(var(--sf-color-tertiary-light), var(--sf-color-tertiary-dark, oklch(from var(--sf-color-tertiary-light) clamp(0.65, calc(0.95 - l * 0.5), 0.88) calc(c * 0.9) h))); + --sf-color-action: light-dark(var(--sf-color-action-light), var(--sf-color-action-dark, oklch(from var(--sf-color-action-light) clamp(0.65, calc(0.95 - l * 0.5), 0.88) calc(c * 0.9) h))); + --sf-color-neutral: light-dark(var(--sf-color-neutral-light), var(--sf-color-neutral-dark, oklch(from var(--sf-color-neutral-light) clamp(0.65, calc(0.95 - l * 0.5), 0.88) calc(c * 0.9) h))); + --sf-color-base: light-dark(var(--sf-color-base-light), var(--sf-color-base-dark, oklch(from var(--sf-color-base-light) clamp(0.16, calc(1.18 - l), 0.24) calc(c * 0.5) h))); + + --sf-color-success: light-dark(var(--sf-color-success-light), var(--sf-color-success-dark, oklch(from var(--sf-color-success-light) clamp(0.65, calc(0.95 - l * 0.5), 0.88) calc(c * 0.9) h))); + --sf-color-warning: light-dark(var(--sf-color-warning-light), var(--sf-color-warning-dark, oklch(from var(--sf-color-warning-light) clamp(0.65, calc(0.95 - l * 0.5), 0.88) calc(c * 0.9) h))); + --sf-color-error: light-dark(var(--sf-color-error-light), var(--sf-color-error-dark, oklch(from var(--sf-color-error-light) clamp(0.65, calc(0.95 - l * 0.5), 0.88) calc(c * 0.9) h))); + --sf-color-info: light-dark(var(--sf-color-info-light), var(--sf-color-info-dark, oklch(from var(--sf-color-info-light) clamp(0.65, calc(0.95 - l * 0.5), 0.88) calc(c * 0.9) h))); + --sf-color-danger: light-dark(var(--sf-color-danger-light), var(--sf-color-danger-dark, oklch(from var(--sf-color-danger-light) clamp(0.65, calc(0.95 - l * 0.5), 0.88) calc(c * 0.9) h))); + + /* ---------------------------------------------------------- + Text — light and dark require opposite formula directions, + so each side of light-dark() references its source token. + ---------------------------------------------------------- */ + --sf-color-text: light-dark( + oklch(from var(--sf-color-neutral-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--secondary: light-dark( + oklch(from var(--sf-color-neutral-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-text--placeholder: light-dark( + oklch(from var(--sf-color-neutral-light) clamp(0.45, calc(l + 0.15), 0.75) c h), + oklch(from var(--sf-color-neutral) clamp(0.35, calc(l - 0.1), 0.65) c h) + ); + --sf-color-text--disabled: light-dark( + oklch(from var(--sf-color-neutral-light) clamp(0.55, calc(l + 0.25), 0.82) c h), + oklch(from var(--sf-color-neutral) clamp(0.25, calc(l - 0.2), 0.55) c h) + ); + --sf-color-text--inverse: light-dark( + oklch(from var(--sf-color-neutral-light) clamp(0.85, calc(l + 0.4), 0.98) c h), + oklch(from var(--sf-color-neutral) clamp(0.05, calc(l - 0.4), 0.35) c h) + ); + --sf-color-heading: light-dark( + oklch(from var(--sf-color-neutral-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) + ); + + /* ---------------------------------------------------------- + Borders — direction-dependent, reference source tokens. + Light: borders are lighter than neutral (l + offset). + Dark: borders are darker than neutral (l - offset). + ---------------------------------------------------------- */ + --sf-color-border: light-dark( + oklch(from var(--sf-color-neutral-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-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) + ); + --sf-color-border--strong: light-dark( + oklch(from var(--sf-color-neutral-light) clamp(0.55, calc(l + 0.1), 0.85) 0.02 h), + oklch(from var(--sf-color-neutral) clamp(0.38, calc(l - 0.1), 0.65) 0.02 h) + ); + + /* ---------------------------------------------------------- + Links — derived from --sf-color-action (auto-adapts) + ---------------------------------------------------------- */ + /* Links keep the action hue but CLAMP lightness toward a contrast-safe + band — a ceiling in light mode, a floor in dark mode — so link text + clears WCAG AA (4.5:1) on the page background for the default palette + AND the large majority of brand overrides (a fixed offset alone only + fixed the default). Lightness dominates OKLCH contrast but doesn't + fully determine it: very high-chroma hues (saturated yellow/green) can + still fall short even at a safe lightness — those remain the consumer's + responsibility (see docs/theming.md → "Link contrast"). + + NOTE: the consumer-facing :link rule in core/base.css uses + `a:link` (not bare `a`) so its specificity matches WebKit's UA + stylesheet `a:link { color: -webkit-link }`. Without that + pseudo-class the framework's `--sf-color-link` value is + silently ignored on unvisited anchors in WebKit and + `-webkit-link` (≈ #00728f in dark mode) paints instead — + regardless of how this token is computed. PR #73 and several + follow-up attempts mistakenly chased the formula. */ + --sf-color-link: light-dark( + oklch(from var(--sf-color-action) clamp(0, min(l - 0.07, 0.48), 1) c h), + oklch(from var(--sf-color-action) clamp(0.68, l, 1) c h) + ); + --sf-color-link--hover: light-dark( + oklch(from var(--sf-color-action) clamp(0, min(l - 0.15, 0.40), 1) c h), + oklch(from var(--sf-color-action) clamp(0, max(l + 0.10, 0.68), 1) c h) + ); + --sf-color-link--active: light-dark( + oklch(from var(--sf-color-action) clamp(0, min(l - 0.21, 0.34), 1) c h), + oklch(from var(--sf-color-action) clamp(0, max(l + 0.15, 0.74), 1) c h) + ); + /* Visited link — 60° hue shift from action gives clearly distinct + chroma without colliding with default tertiary (h ≈ 310). Same + lightness clamp as the base link. If your brand puts action and + tertiary close in hue, override directly: + :root { --sf-color-link--visited: #6b46c1; } */ + --sf-color-link--visited: light-dark( + oklch(from var(--sf-color-action) clamp(0, min(l - 0.07, 0.48), 1) c calc(h + 60)), + oklch(from var(--sf-color-action) clamp(0.68, l, 1) c calc(h + 60)) + ); + + /* ---------------------------------------------------------- + Selection + ---------------------------------------------------------- */ + --sf-color-selection-bg: light-dark( + oklch(from var(--sf-color-action-light) l c h / 0.28), + oklch(from var(--sf-color-action-light) clamp(0.62, calc(0.93 - l * 0.4), 0.78) c h / 0.55) + ); + + /* ---------------------------------------------------------- + Status triplets — strong (direction-dependent, uses light-dark) + error = form validation, input errors + danger = destructive actions (delete, remove) + ---------------------------------------------------------- */ + --sf-color-success-strong: light-dark( + oklch(from var(--sf-color-success-light) calc(l - 0.15) c h), + oklch(from var(--sf-color-success) clamp(0.70, calc(l + 0.15), 1) c h) + ); + --sf-color-warning-strong: light-dark( + oklch(from var(--sf-color-warning-light) calc(l - 0.25) c h), + oklch(from var(--sf-color-warning) clamp(0.70, calc(l + 0.05), 1) c h) + ); + --sf-color-error-strong: light-dark( + oklch(from var(--sf-color-error-light) calc(l - 0.1) c h), + oklch(from var(--sf-color-error) clamp(0.70, calc(l + 0.15), 1) c h) + ); + --sf-color-info-strong: light-dark( + oklch(from var(--sf-color-info-light) calc(l - 0.1) c h), + oklch(from var(--sf-color-info) clamp(0.70, calc(l + 0.15), 1) c h) + ); + --sf-color-danger-strong: light-dark( + oklch(from var(--sf-color-danger-light) calc(l - 0.1) c h), + oklch(from var(--sf-color-danger) clamp(0.70, calc(l + 0.15), 1) c h) + ); + + } + } + /* ── @supports gate: oklch(from …) relative color + color-mix() ── */ + @supports (color: oklch(from red l c h)) { + :root { + + /* ---------------------------------------------------------- + Surfaces — derived from --sf-color-base (auto-adapts) + ---------------------------------------------------------- */ + --sf-color-bg: oklch(from var(--sf-color-base) calc(l + 0.02) c h); + --sf-color-inset: oklch(from var(--sf-color-base) calc(l - 0.02) c h); + --sf-color-raised: oklch(from var(--sf-color-base) calc(l + 0.04) c h); + --sf-color-overlay: oklch(from var(--sf-color-base) l c h / 0.9); + --sf-color-inverse: oklch(from var(--sf-color-base) calc(1 - l) c h); + + /* ---------------------------------------------------------- + Code text — auto-contrasts with --sf-color-code-bg. + ---------------------------------------------------------- */ + /* Inline-code text colour. Auto-contrasts with --sf-color-code-bg using + the same luminance formula as --sf-color-text--on-*. This prevents + invisible text when code appears inside inverted-colour containers + (e.g. sf-imposter with a dark background where inherited colour is + near-white). Override with a tint value if desired. */ + --sf-color-code-text: oklch(from var(--sf-color-code-bg) clamp(0.1, sign(var(--sf-contrast-threshold) - l) * 999, 0.95) 0 0); + + /* ---------------------------------------------------------- + Text-on-color — sign(var(--sf-contrast-threshold) - l) * 999 is mode-agnostic: + auto-selects light or dark text based on background luminance. + Uses resolved tokens — works for any user-supplied color. + + CONTRAST NOTE: Guarantees ≥ 3:1 (WCAG AA Large Text / UI). + Colors in the mid-luminance range (L ≈ 0.52–0.67) may not + reach 4.5:1 (AA Normal Text) regardless of threshold chosen — + this is a mathematical limitation of any binary black/white + decision. The SLASHED configurator will warn users when their + brand colors fall in this range. For body text on colored + backgrounds, override --sf-color-text--on-* manually. + ---------------------------------------------------------- */ + --sf-color-text--on-primary: oklch(from var(--sf-color-primary) clamp(0.1, sign(var(--sf-contrast-threshold) - l) * 999, 0.95) 0 0); + --sf-color-text--on-secondary: oklch(from var(--sf-color-secondary) clamp(0.1, sign(var(--sf-contrast-threshold) - l) * 999, 0.95) 0 0); + --sf-color-text--on-tertiary: oklch(from var(--sf-color-tertiary) clamp(0.1, sign(var(--sf-contrast-threshold) - l) * 999, 0.95) 0 0); + --sf-color-text--on-action: oklch(from var(--sf-color-action) clamp(0.1, sign(var(--sf-contrast-threshold) - l) * 999, 0.95) 0 0); + --sf-color-text--on-neutral: oklch(from var(--sf-color-neutral) clamp(0.1, sign(var(--sf-contrast-threshold) - l) * 999, 0.95) 0 0); + --sf-color-text--on-success: oklch(from var(--sf-color-success) clamp(0.1, sign(var(--sf-contrast-threshold) - l) * 999, 0.95) 0 0); + --sf-color-text--on-warning: oklch(from var(--sf-color-warning) clamp(0.1, sign(var(--sf-contrast-threshold) - l) * 999, 0.95) 0 0); + --sf-color-text--on-error: oklch(from var(--sf-color-error) clamp(0.1, sign(var(--sf-contrast-threshold) - l) * 999, 0.95) 0 0); + --sf-color-text--on-info: oklch(from var(--sf-color-info) clamp(0.1, sign(var(--sf-contrast-threshold) - l) * 999, 0.95) 0 0); + --sf-color-text--on-danger: oklch(from var(--sf-color-danger) clamp(0.1, sign(var(--sf-contrast-threshold) - l) * 999, 0.95) 0 0); + + /* ---------------------------------------------------------- + Border alpha tokens + ---------------------------------------------------------- */ + --sf-color-border--disabled: oklch(from var(--sf-color-border--subtle) l 0 h / 0.5); + --sf-color-border--translucent: oklch(from var(--sf-color-neutral) l c h / 0.15); + + /* ---------------------------------------------------------- + Link alpha token + ---------------------------------------------------------- */ + --sf-color-link--underline: oklch(from var(--sf-color-action) l c h / 0.3); + + /* ---------------------------------------------------------- + Interactive states — derived from --sf-color-neutral (auto-adapts) + ---------------------------------------------------------- */ + --sf-color-bg--hover: oklch(from var(--sf-color-neutral) l c h / 0.08); + --sf-color-bg--active: oklch(from var(--sf-color-neutral) l c h / 0.12); + --sf-color-bg--selected: oklch(from var(--sf-color-action) l c h / 0.1); + --sf-color-bg--focus: oklch(from var(--sf-color-action) l c h / 0.06); + + /* ---------------------------------------------------------- + Selection & mark + ---------------------------------------------------------- */ + --sf-color-mark-bg: oklch(from var(--sf-color-warning) l c h / 0.25); + + /* ---------------------------------------------------------- + Status triplets — subtle/muted (alpha variants) + bg/border use same formula both modes + ---------------------------------------------------------- */ + --sf-color-success-subtle: oklch(from var(--sf-color-success) l c h / 0.12); + --sf-color-success-muted: oklch(from var(--sf-color-success) l c h / 0.3); + + --sf-color-warning-subtle: oklch(from var(--sf-color-warning) l c h / 0.12); + --sf-color-warning-muted: oklch(from var(--sf-color-warning) l c h / 0.3); + + --sf-color-error-subtle: oklch(from var(--sf-color-error) l c h / 0.1); + --sf-color-error-muted: oklch(from var(--sf-color-error) l c h / 0.3); + + --sf-color-info-subtle: oklch(from var(--sf-color-info) l c h / 0.1); + --sf-color-info-muted: oklch(from var(--sf-color-info) l c h / 0.3); + + --sf-color-danger-subtle: oklch(from var(--sf-color-danger) l c h / 0.1); + --sf-color-danger-muted: oklch(from var(--sf-color-danger) l c h / 0.3); + + /* ---------------------------------------------------------- + Gradients — derived from brand colors (auto-adapt) + ---------------------------------------------------------- */ + --sf-gradient-primary: linear-gradient(in oklch 135deg, var(--sf-color-primary), oklch(from var(--sf-color-primary) calc(l - 0.08) c h)); + --sf-gradient-secondary: linear-gradient(in oklch 135deg, var(--sf-color-secondary), oklch(from var(--sf-color-secondary) calc(l - 0.08) c h)); + --sf-gradient-tertiary: linear-gradient(in oklch 135deg, var(--sf-color-tertiary), oklch(from var(--sf-color-tertiary) calc(l - 0.08) c h)); + --sf-gradient-brand: linear-gradient(in oklch 135deg, var(--sf-color-primary), oklch(from var(--sf-color-primary) l c calc(h + 30))); + --sf-gradient-surface: linear-gradient(in oklab 180deg, var(--sf-color-surface), var(--sf-color-bg)); + + --sf-gradient-fade--r: linear-gradient(in oklch to right, transparent, var(--sf-color-bg)); + --sf-gradient-fade--l: linear-gradient(in oklch to left, transparent, var(--sf-color-bg)); + --sf-gradient-fade--t: linear-gradient(in oklch to top, transparent, var(--sf-color-bg)); + --sf-gradient-fade--b: linear-gradient(in oklch to bottom, transparent, var(--sf-color-bg)); + + /* ---------------------------------------------------------- + Shadow glow + ---------------------------------------------------------- */ + --sf-shadow-glow: 0 0 15px 2px oklch(from var(--sf-shadow-glow-color) l c h / clamp(0, calc(var(--sf-shadow-strength) * 2), 0.7)); + + } + } /* ============================================================ SEMANTIC COLOUR ALIASES — shade, interactive + transparency @@ -490,7 +571,9 @@ computed color, but overriding a step then propagates to its alias. ============================================================ */ - :root { + /* ── @supports gate: shade ramps + alpha aliases ── */ + @supports (color: oklch(from red l c h)) { + :root { /* ─── primary ──────────────────────────────────────────── */ @@ -636,6 +719,8 @@ --sf-color-danger-active: var(--sf-color-danger-xdark); --sf-color-danger-ghost: oklch(from var(--sf-color-danger) l c h / 0.05); + + } } /* ============================================================ @@ -886,43 +971,7 @@ Shadows — BEM consumer API Use in your own classes: .card { box-shadow: var(--sf-shadow-m); } ---------------------------------------------------------- */ - /* Shadow tint — near-black derived from the neutral; the tint simply - inherits the neutral's own chroma/hue, so a colourless neutral yields - colourless shadows. Forced dark in both modes; --sf-shadow-strength - handles dark-mode intensity. Override to a brand colour for explicitly - tinted shadows — keep it dark or you get bright slabs: - oklch(from var(--sf-color-primary) 0.15 c h). */ - --sf-shadow-color: oklch(from var(--sf-color-neutral) 0.15 c h); - - --sf-shadow-none: none; - --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-shadow-s: 0 1px 2px 0 oklch(from var(--sf-shadow-color) l c h / clamp(0, calc(var(--sf-shadow-strength) * 0.5), 0.7)), - 0 2px 6px 0 oklch(from var(--sf-shadow-color) l c h / clamp(0, var(--sf-shadow-strength), 0.7)); - --sf-shadow-m: 0 1px 3px 0 oklch(from var(--sf-shadow-color) l c h / clamp(0, calc(var(--sf-shadow-strength) * 0.5), 0.7)), - 0 4px 12px 0 oklch(from var(--sf-shadow-color) l c h / clamp(0, calc(var(--sf-shadow-strength) * 2), 0.7)); - --sf-shadow-l: 0 2px 4px 0 oklch(from var(--sf-shadow-color) l c h / clamp(0, calc(var(--sf-shadow-strength) * 0.5), 0.7)), - 0 8px 24px 0 oklch(from var(--sf-shadow-color) l c h / clamp(0, calc(var(--sf-shadow-strength) * 3), 0.7)), - 0 16px 48px 0 oklch(from var(--sf-shadow-color) l c h / clamp(0, calc(var(--sf-shadow-strength) * 2), 0.7)); - --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-2xl: 0 4px 12px 0 oklch(from var(--sf-shadow-color) l c h / clamp(0, calc(var(--sf-shadow-strength) * 0.6), 0.7)), - 0 20px 60px 0 oklch(from var(--sf-shadow-color) l c h / clamp(0, calc(var(--sf-shadow-strength) * 4), 0.7)), - 0 40px 100px -8px oklch(from var(--sf-shadow-color) l c h / clamp(0, calc(var(--sf-shadow-strength) * 5), 0.7)); - --sf-shadow-inner: inset 0 2px 4px 0 oklch(from var(--sf-shadow-color) l c h / clamp(0, calc(var(--sf-shadow-strength) * 2), 0.7)); - /* ---------------------------------------------------------- - Text shadows — share --sf-shadow-color / --sf-shadow-strength - ---------------------------------------------------------- */ - --sf-text-shadow-none: none; - --sf-text-shadow-s: 0 1px 2px oklch(from var(--sf-shadow-color) l c h / clamp(0, calc(var(--sf-shadow-strength) * 1.5), 0.7)); - --sf-text-shadow-m: 0 2px 4px oklch(from var(--sf-shadow-color) l c h / clamp(0, calc(var(--sf-shadow-strength) * 2), 0.7)); - --sf-text-shadow-l: 0 4px 8px oklch(from var(--sf-shadow-color) l c h / clamp(0, calc(var(--sf-shadow-strength) * 2.5), 0.7)); - - /* Drop shadows — for filter: drop-shadow() (respects alpha edges) */ - --sf-drop-shadow-s: 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))); - --sf-drop-shadow-m: drop-shadow(0 4px 6px oklch(from var(--sf-shadow-color) l c h / clamp(0, calc(var(--sf-shadow-strength) * 2), 0.7))); - --sf-drop-shadow-l: drop-shadow(0 8px 16px oklch(from var(--sf-shadow-color) l c h / clamp(0, calc(var(--sf-shadow-strength) * 2.5), 0.7))); /* ---------------------------------------------------------- Blur & opacity @@ -1088,6 +1137,51 @@ } + /* ── @supports gate: shadow tokens ── */ + @supports (color: oklch(from red l c h)) { + :root { + + /* Shadow tint — near-black derived from the neutral; the tint simply + inherits the neutral's own chroma/hue, so a colourless neutral yields + colourless shadows. Forced dark in both modes; --sf-shadow-strength + handles dark-mode intensity. Override to a brand colour for explicitly + tinted shadows — keep it dark or you get bright slabs: + oklch(from var(--sf-color-primary) 0.15 c h). */ + --sf-shadow-color: oklch(from var(--sf-color-neutral) 0.15 c h); + + --sf-shadow-none: none; + --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-shadow-s: 0 1px 2px 0 oklch(from var(--sf-shadow-color) l c h / clamp(0, calc(var(--sf-shadow-strength) * 0.5), 0.7)), + 0 2px 6px 0 oklch(from var(--sf-shadow-color) l c h / clamp(0, var(--sf-shadow-strength), 0.7)); + --sf-shadow-m: 0 1px 3px 0 oklch(from var(--sf-shadow-color) l c h / clamp(0, calc(var(--sf-shadow-strength) * 0.5), 0.7)), + 0 4px 12px 0 oklch(from var(--sf-shadow-color) l c h / clamp(0, calc(var(--sf-shadow-strength) * 2), 0.7)); + --sf-shadow-l: 0 2px 4px 0 oklch(from var(--sf-shadow-color) l c h / clamp(0, calc(var(--sf-shadow-strength) * 0.5), 0.7)), + 0 8px 24px 0 oklch(from var(--sf-shadow-color) l c h / clamp(0, calc(var(--sf-shadow-strength) * 3), 0.7)), + 0 16px 48px 0 oklch(from var(--sf-shadow-color) l c h / clamp(0, calc(var(--sf-shadow-strength) * 2), 0.7)); + --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-2xl: 0 4px 12px 0 oklch(from var(--sf-shadow-color) l c h / clamp(0, calc(var(--sf-shadow-strength) * 0.6), 0.7)), + 0 20px 60px 0 oklch(from var(--sf-shadow-color) l c h / clamp(0, calc(var(--sf-shadow-strength) * 4), 0.7)), + 0 40px 100px -8px oklch(from var(--sf-shadow-color) l c h / clamp(0, calc(var(--sf-shadow-strength) * 5), 0.7)); + --sf-shadow-inner: inset 0 2px 4px 0 oklch(from var(--sf-shadow-color) l c h / clamp(0, calc(var(--sf-shadow-strength) * 2), 0.7)); + + /* ---------------------------------------------------------- + Text shadows — share --sf-shadow-color / --sf-shadow-strength + ---------------------------------------------------------- */ + --sf-text-shadow-none: none; + --sf-text-shadow-s: 0 1px 2px oklch(from var(--sf-shadow-color) l c h / clamp(0, calc(var(--sf-shadow-strength) * 1.5), 0.7)); + --sf-text-shadow-m: 0 2px 4px oklch(from var(--sf-shadow-color) l c h / clamp(0, calc(var(--sf-shadow-strength) * 2), 0.7)); + --sf-text-shadow-l: 0 4px 8px oklch(from var(--sf-shadow-color) l c h / clamp(0, calc(var(--sf-shadow-strength) * 2.5), 0.7)); + + /* Drop shadows — for filter: drop-shadow() (respects alpha edges) */ + --sf-drop-shadow-s: 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))); + --sf-drop-shadow-m: drop-shadow(0 4px 6px oklch(from var(--sf-shadow-color) l c h / clamp(0, calc(var(--sf-shadow-strength) * 2), 0.7))); + --sf-drop-shadow-l: drop-shadow(0 8px 16px oklch(from var(--sf-shadow-color) l c h / clamp(0, calc(var(--sf-shadow-strength) * 2.5), 0.7))); + + } + } + /* ============================================================ TYPOGRAPHY ALIASES ============================================================ */ diff --git a/docs/tokens.md b/docs/tokens.md index 2e85a99b..501d8a95 100644 --- a/docs/tokens.md +++ b/docs/tokens.md @@ -75,7 +75,7 @@ rebrand workflow. | `--sf-color-action-darker` | `color-mix(in oklab, var(--sf-color-action) 82%, var(--sf-color-text))` | | `--sf-color-action-ghost` | `oklch(from var(--sf-color-action) l c h / 0.05)` | | `--sf-color-action-hover` | `var(--sf-color-action-darker)` | -| `--sf-color-action-light` | `oklch(0.50 0.22 235) *(registered)*` | +| `--sf-color-action-light` | `oklch(0.50 0.22 235)` | | `--sf-color-action-lighter` | `color-mix(in oklab, var(--sf-color-action) 65%, var(--sf-color-surface))` | | `--sf-color-action-muted` | `oklch(from var(--sf-color-action) l c h / 0.30)` | | `--sf-color-action-subtle` | `oklch(from var(--sf-color-action) l c h / 0.10)` | @@ -88,7 +88,7 @@ rebrand workflow. | `--sf-color-base-darker` | `color-mix(in oklab, var(--sf-color-base) 82%, var(--sf-color-text))` | | `--sf-color-base-ghost` | `oklch(from var(--sf-color-base) l c h / 0.05)` | | `--sf-color-base-hover` | `var(--sf-color-base-darker)` | -| `--sf-color-base-light` | `oklch(0.96 0.006 250) *(registered)*` | +| `--sf-color-base-light` | `oklch(0.96 0.006 250)` | | `--sf-color-base-lighter` | `color-mix(in oklab, var(--sf-color-text) 65%, var(--sf-color-base))` | | `--sf-color-base-muted` | `oklch(from var(--sf-color-base) l c h / 0.30)` | | `--sf-color-base-subtle` | `oklch(from var(--sf-color-base) l c h / 0.10)` | @@ -116,7 +116,7 @@ rebrand workflow. | `--sf-color-danger-darker` | `color-mix(in oklab, var(--sf-color-danger) 82%, var(--sf-color-text))` | | `--sf-color-danger-ghost` | `oklch(from var(--sf-color-danger) l c h / 0.05)` | | `--sf-color-danger-hover` | `var(--sf-color-danger-darker)` | -| `--sf-color-danger-light` | `oklch(0.48 0.22 12) *(registered)*` | +| `--sf-color-danger-light` | `oklch(0.48 0.22 12)` | | `--sf-color-danger-lighter` | `color-mix(in oklab, var(--sf-color-danger) 65%, var(--sf-color-surface))` | | `--sf-color-danger-muted` | `oklch(from var(--sf-color-danger) l c h / 0.3)` | | `--sf-color-danger-strong` | `light-dark( oklch(from var(--sf-color-danger-light) calc(l - 0.1) c h), oklch(from var(--sf-color-danger) clamp(0.70, calc(l + 0.15), 1) c h) )` | @@ -131,7 +131,7 @@ rebrand workflow. | `--sf-color-error-darker` | `color-mix(in oklab, var(--sf-color-error) 82%, var(--sf-color-text))` | | `--sf-color-error-ghost` | `oklch(from var(--sf-color-error) l c h / 0.05)` | | `--sf-color-error-hover` | `var(--sf-color-error-darker)` | -| `--sf-color-error-light` | `oklch(0.50 0.20 25) *(registered)*` | +| `--sf-color-error-light` | `oklch(0.50 0.20 25)` | | `--sf-color-error-lighter` | `color-mix(in oklab, var(--sf-color-error) 65%, var(--sf-color-surface))` | | `--sf-color-error-muted` | `oklch(from var(--sf-color-error) l c h / 0.3)` | | `--sf-color-error-strong` | `light-dark( oklch(from var(--sf-color-error-light) calc(l - 0.1) c h), oklch(from var(--sf-color-error) clamp(0.70, calc(l + 0.15), 1) c h) )` | @@ -146,7 +146,7 @@ rebrand workflow. | `--sf-color-info-darker` | `color-mix(in oklab, var(--sf-color-info) 82%, var(--sf-color-text))` | | `--sf-color-info-ghost` | `oklch(from var(--sf-color-info) l c h / 0.05)` | | `--sf-color-info-hover` | `var(--sf-color-info-darker)` | -| `--sf-color-info-light` | `oklch(0.48 0.18 235) *(registered)*` | +| `--sf-color-info-light` | `oklch(0.48 0.18 235)` | | `--sf-color-info-lighter` | `color-mix(in oklab, var(--sf-color-info) 65%, var(--sf-color-surface))` | | `--sf-color-info-muted` | `oklch(from var(--sf-color-info) l c h / 0.3)` | | `--sf-color-info-strong` | `light-dark( oklch(from var(--sf-color-info-light) calc(l - 0.1) c h), oklch(from var(--sf-color-info) clamp(0.70, calc(l + 0.15), 1) c h) )` | @@ -170,7 +170,7 @@ rebrand workflow. | `--sf-color-neutral-darker` | `color-mix(in oklab, var(--sf-color-neutral) 82%, var(--sf-color-text))` | | `--sf-color-neutral-ghost` | `oklch(from var(--sf-color-neutral) l c h / 0.05)` | | `--sf-color-neutral-hover` | `var(--sf-color-neutral-darker)` | -| `--sf-color-neutral-light` | `oklch(0.52 0.025 260) *(registered)*` | +| `--sf-color-neutral-light` | `oklch(0.52 0.025 260)` | | `--sf-color-neutral-lighter` | `color-mix(in oklab, var(--sf-color-neutral) 65%, var(--sf-color-surface))` | | `--sf-color-neutral-muted` | `oklch(from var(--sf-color-neutral) l c h / 0.30)` | | `--sf-color-neutral-subtle` | `oklch(from var(--sf-color-neutral) l c h / 0.10)` | @@ -184,7 +184,7 @@ rebrand workflow. | `--sf-color-primary-darker` | `color-mix(in oklab, var(--sf-color-primary) 82%, var(--sf-color-text))` | | `--sf-color-primary-ghost` | `oklch(from var(--sf-color-primary) l c h / 0.05)` | | `--sf-color-primary-hover` | `var(--sf-color-primary-darker)` | -| `--sf-color-primary-light` | `oklch(0.47 0.27 264) *(registered)*` | +| `--sf-color-primary-light` | `oklch(0.47 0.27 264)` | | `--sf-color-primary-lighter` | `color-mix(in oklab, var(--sf-color-primary) 65%, var(--sf-color-surface))` | | `--sf-color-primary-muted` | `oklch(from var(--sf-color-primary) l c h / 0.30)` | | `--sf-color-primary-subtle` | `oklch(from var(--sf-color-primary) l c h / 0.10)` | @@ -199,7 +199,7 @@ rebrand workflow. | `--sf-color-secondary-darker` | `color-mix(in oklab, var(--sf-color-secondary) 82%, var(--sf-color-text))` | | `--sf-color-secondary-ghost` | `oklch(from var(--sf-color-secondary) l c h / 0.05)` | | `--sf-color-secondary-hover` | `var(--sf-color-secondary-darker)` | -| `--sf-color-secondary-light` | `oklch(0.22 0.04 264) *(registered)*` | +| `--sf-color-secondary-light` | `oklch(0.22 0.04 264)` | | `--sf-color-secondary-lighter` | `color-mix(in oklab, var(--sf-color-secondary) 65%, var(--sf-color-surface))` | | `--sf-color-secondary-muted` | `oklch(from var(--sf-color-secondary) l c h / 0.30)` | | `--sf-color-secondary-subtle` | `oklch(from var(--sf-color-secondary) l c h / 0.10)` | @@ -214,7 +214,7 @@ rebrand workflow. | `--sf-color-success-darker` | `color-mix(in oklab, var(--sf-color-success) 82%, var(--sf-color-text))` | | `--sf-color-success-ghost` | `oklch(from var(--sf-color-success) l c h / 0.05)` | | `--sf-color-success-hover` | `var(--sf-color-success-darker)` | -| `--sf-color-success-light` | `oklch(0.50 0.16 145) *(registered)*` | +| `--sf-color-success-light` | `oklch(0.50 0.16 145)` | | `--sf-color-success-lighter` | `color-mix(in oklab, var(--sf-color-success) 65%, var(--sf-color-surface))` | | `--sf-color-success-muted` | `oklch(from var(--sf-color-success) l c h / 0.3)` | | `--sf-color-success-strong` | `light-dark( oklch(from var(--sf-color-success-light) calc(l - 0.15) c h), oklch(from var(--sf-color-success) clamp(0.70, calc(l + 0.15), 1) c h) )` | @@ -229,7 +229,7 @@ rebrand workflow. | `--sf-color-tertiary-darker` | `color-mix(in oklab, var(--sf-color-tertiary) 82%, var(--sf-color-text))` | | `--sf-color-tertiary-ghost` | `oklch(from var(--sf-color-tertiary) l c h / 0.05)` | | `--sf-color-tertiary-hover` | `var(--sf-color-tertiary-darker)` | -| `--sf-color-tertiary-light` | `oklch(0.42 0.22 295) *(registered)*` | +| `--sf-color-tertiary-light` | `oklch(0.42 0.22 295)` | | `--sf-color-tertiary-lighter` | `color-mix(in oklab, var(--sf-color-tertiary) 65%, var(--sf-color-surface))` | | `--sf-color-tertiary-muted` | `oklch(from var(--sf-color-tertiary) l c h / 0.30)` | | `--sf-color-tertiary-subtle` | `oklch(from var(--sf-color-tertiary) l c h / 0.10)` | @@ -261,7 +261,7 @@ rebrand workflow. | `--sf-color-warning-darker` | `color-mix(in oklab, var(--sf-color-warning) 82%, var(--sf-color-text))` | | `--sf-color-warning-ghost` | `oklch(from var(--sf-color-warning) l c h / 0.05)` | | `--sf-color-warning-hover` | `var(--sf-color-warning-darker)` | -| `--sf-color-warning-light` | `oklch(0.75 0.17 80) *(registered)*` | +| `--sf-color-warning-light` | `oklch(0.75 0.17 80)` | | `--sf-color-warning-lighter` | `color-mix(in oklab, var(--sf-color-warning) 65%, var(--sf-color-surface))` | | `--sf-color-warning-muted` | `oklch(from var(--sf-color-warning) l c h / 0.3)` | | `--sf-color-warning-strong` | `light-dark( oklch(from var(--sf-color-warning-light) calc(l - 0.25) c h), oklch(from var(--sf-color-warning) clamp(0.70, calc(l + 0.05), 1) c h) )` | diff --git a/optional/legacy.css b/optional/legacy.css index 07cc90d8..241c9778 100644 --- a/optional/legacy.css +++ b/optional/legacy.css @@ -5,15 +5,27 @@ declared before slashed.overrides in layers.css, so user overrides always win. + COLOR FALLBACKS — boundary note + ───────────────────────────────── + Color fallbacks live in @layer slashed.tokens and @layer slashed.themes + (core/tokens.color-fallbacks.css + core/tokens.css). Precomputed sRGB + defaults are declared ungated; modern light-dark()/oklch(from…)/color-mix() + expressions are gated in @supports blocks so they override the sRGB + defaults on capable engines only. + + This file (slashed.legacy) handles only non-color property smoothing: + dvh, :focus-visible, scrollbar-gutter. It does NOT provide color + fallbacks of any kind. + IMPORTANT — what this file is NOT ───────────────────────────────── The framework's real support floor is Chrome 123+, Safari 17.5+, Firefox 128+ (April–July 2024). That floor is set by the colour system: light-dark(), @property with inherits, and the oklch() - relative-color form. None of those have a CSS-only fallback, so - on any older engine the framework's colours collapse to initial - regardless of what this file does. This file does NOT extend - the floor below those versions. + relative-color form. The Tier-1 color fallbacks in tokens.css and + tokens.color-fallbacks.css bridge the gap for engines that support + @layer but not all three features (e.g. Chrome 99–122, Safari 15.4–17.4, + Firefox 97–127). This file does NOT extend the floor below @layer support. What this file IS ───────────────── @@ -25,8 +37,6 @@ below is inert via @supports not (...). Scope: covers gaps in reset.css, base.css, accessibility.css. - oklch()/color-mix()/@property live in tokens.css and have no - fallback path here. ─────────────────────────────────────────────────── */ @layer slashed.legacy { diff --git a/package-lock.json b/package-lock.json index cd28ca20..20053f11 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,8 @@ "@commitlint/config-conventional": "^21.0.1", "@playwright/test": "^1.60.0", "@release-it/conventional-changelog": "^11.0.0", + "culori": "^4.0.2", + "fast-check": "^4.8.0", "lightningcss": "^1.32.0", "release-it": "^20.0.1", "stylelint": "^17.11.1", @@ -1873,6 +1875,16 @@ "node": ">=4" } }, + "node_modules/culori": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/culori/-/culori-4.0.2.tgz", + "integrity": "sha512-1+BhOB8ahCn4O0cep0Sh2l9KCOfOdY+BXJnKMHFFzDEouSr/el18QwXEMRlOj9UY5nCeA8UN3a/82rUWRBeyBw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, "node_modules/data-uri-to-buffer": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-7.0.0.tgz", @@ -2136,6 +2148,29 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-check": { + "version": "4.8.0", + "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.8.0.tgz", + "integrity": "sha512-GOJ158CUMnN6cSahsv4+ExARvIDuzzinFjkp0E9WtiBa5zcVeLozVkWaE4IzFcc+Y48Wp1EDlUZsXRyAztQcSg==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT", + "dependencies": { + "pure-rand": "^8.0.0" + }, + "engines": { + "node": ">=12.17.0" + } + }, "node_modules/fast-content-type-parse": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/fast-content-type-parse/-/fast-content-type-parse-3.0.0.tgz", @@ -3950,6 +3985,23 @@ "dev": true, "license": "MIT" }, + "node_modules/pure-rand": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-8.4.0.tgz", + "integrity": "sha512-IoM8YF/jY0hiugFo/wOWqfmarlE6J0wc6fDK1PhftMk7MGhVZl88sZimmqBBFomLOCSmcCCpsfj7wXASCpvK9A==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, "node_modules/qified": { "version": "0.10.1", "resolved": "https://registry.npmjs.org/qified/-/qified-0.10.1.tgz", diff --git a/package.json b/package.json index a00504d9..e25f935e 100644 --- a/package.json +++ b/package.json @@ -44,7 +44,7 @@ "version-sync": "node scripts/version-sync.js", "lint:css": "stylelint \"**/*.css\"", "lint:css:fix": "stylelint \"**/*.css\" --fix", - "pretest": "npm run build && node --test tests/element-types.test.js tests/class-hints.test.js tests/color-model.test.js", + "pretest": "npm run build && node --test tests/element-types.test.js tests/class-hints.test.js tests/color-model.test.js tests/tier1-p1-srgb-validity.test.js tests/tier1-p2-coverage.test.js tests/tier1-p3-ordering.test.js tests/tier1-p7-oldengine.test.js tests/tier1-p10-contrast.test.js tests/tier1-p11-overrides.test.js", "test": "playwright test", "test:install": "playwright install --with-deps chromium firefox webkit", "release": "release-it", @@ -57,6 +57,8 @@ "@commitlint/config-conventional": "^21.0.1", "@playwright/test": "^1.60.0", "@release-it/conventional-changelog": "^11.0.0", + "culori": "^4.0.2", + "fast-check": "^4.8.0", "lightningcss": "^1.32.0", "release-it": "^20.0.1", "stylelint": "^17.11.1", diff --git a/tests/baseline-modern.json b/tests/baseline-modern.json new file mode 100644 index 00000000..b6591af3 --- /dev/null +++ b/tests/baseline-modern.json @@ -0,0 +1,68 @@ +{ + "dark": { + "--sf-color-primary": "oklch(from oklch(0.47 0.27 264) clamp(0.65, calc(0.95 - l * 0.5), 0.88) calc(c * 0.9) h)", + "--sf-color-secondary": "oklch(from oklch(0.22 0.04 264) clamp(0.65, calc(0.95 - l * 0.5), 0.88) calc(c * 0.9) h)", + "--sf-color-tertiary": "oklch(from oklch(0.42 0.22 295) clamp(0.65, calc(0.95 - l * 0.5), 0.88) calc(c * 0.9) h)", + "--sf-color-action": "oklch(from oklch(0.5 0.22 235) clamp(0.65, calc(0.95 - l * 0.5), 0.88) calc(c * 0.9) h)", + "--sf-color-neutral": "oklch(from oklch(0.52 0.025 260) clamp(0.65, calc(0.95 - l * 0.5), 0.88) calc(c * 0.9) h)", + "--sf-color-base": "oklch(from oklch(0.96 0.006 250) clamp(0.16, calc(1.18 - l), 0.24) calc(c * 0.5) h)", + "--sf-color-success": "oklch(from oklch(0.5 0.16 145) clamp(0.65, calc(0.95 - l * 0.5), 0.88) calc(c * 0.9) h)", + "--sf-color-warning": "oklch(from oklch(0.75 0.17 80) clamp(0.65, calc(0.95 - l * 0.5), 0.88) calc(c * 0.9) h)", + "--sf-color-error": "oklch(from oklch(0.5 0.2 25) clamp(0.65, calc(0.95 - l * 0.5), 0.88) calc(c * 0.9) h)", + "--sf-color-info": "oklch(from oklch(0.48 0.18 235) clamp(0.65, calc(0.95 - l * 0.5), 0.88) calc(c * 0.9) h)", + "--sf-color-danger": "oklch(from oklch(0.48 0.22 12) clamp(0.65, calc(0.95 - l * 0.5), 0.88) calc(c * 0.9) h)", + "--sf-color-bg": "oklch(from oklch(from oklch(0.96 0.006 250) clamp(0.16, calc(1.18 - l), 0.24) calc(c * 0.5) h) calc(l + 0.02) c h)", + "--sf-color-inset": "oklch(from oklch(from oklch(0.96 0.006 250) clamp(0.16, calc(1.18 - l), 0.24) calc(c * 0.5) h) calc(l - 0.02) c h)", + "--sf-color-raised": "oklch(from oklch(from oklch(0.96 0.006 250) clamp(0.16, calc(1.18 - l), 0.24) calc(c * 0.5) h) calc(l + 0.04) c h)", + "--sf-color-text": "oklch(from oklch(from oklch(0.52 0.025 260) clamp(0.65, calc(0.95 - l * 0.5), 0.88) calc(c * 0.9) h) clamp(0.70, calc(l + 0.25 + 0), 1) c h)", + "--sf-color-text--secondary": "oklch(from oklch(from oklch(0.52 0.025 260) clamp(0.65, calc(0.95 - l * 0.5), 0.88) calc(c * 0.9) h) clamp(0.55, calc(l + 0.1 + 0), 0.90) c h)", + "--sf-color-heading": "oklch(from oklch(from oklch(0.52 0.025 260) clamp(0.65, calc(0.95 - l * 0.5), 0.88) calc(c * 0.9) h) clamp(0.70, calc(l + 0.25 + 0), 1) c h)", + "--sf-color-border": "oklch(from oklch(from oklch(0.52 0.025 260) clamp(0.65, calc(0.95 - l * 0.5), 0.88) calc(c * 0.9) h) clamp(0.25, calc(l - 0.3), 0.55) 0.005 h)", + "--sf-color-border--subtle": "oklch(from oklch(from oklch(0.52 0.025 260) clamp(0.65, calc(0.95 - l * 0.5), 0.88) calc(c * 0.9) h) clamp(0.20, calc(l - 0.38), 0.45) 0.005 h)", + "--sf-color-border--strong": "oklch(from oklch(from oklch(0.52 0.025 260) clamp(0.65, calc(0.95 - l * 0.5), 0.88) calc(c * 0.9) h) clamp(0.38, calc(l - 0.1), 0.65) 0.02 h)", + "--sf-color-link": "oklch(from oklch(from oklch(0.5 0.22 235) clamp(0.65, calc(0.95 - l * 0.5), 0.88) calc(c * 0.9) h) clamp(0.68, l, 1) c h)", + "--sf-color-selection-bg": "oklch(from oklch(0.5 0.22 235) clamp(0.62, calc(0.93 - l * 0.4), 0.78) c h / 0.55)", + "--sf-color-success-strong": "oklch(from oklch(from oklch(0.5 0.16 145) clamp(0.65, calc(0.95 - l * 0.5), 0.88) calc(c * 0.9) h) clamp(0.70, calc(l + 0.15), 1) c h)", + "--sf-color-warning-strong": "oklch(from oklch(from oklch(0.75 0.17 80) clamp(0.65, calc(0.95 - l * 0.5), 0.88) calc(c * 0.9) h) clamp(0.70, calc(l + 0.05), 1) c h)", + "--sf-color-error-strong": "oklch(from oklch(from oklch(0.5 0.2 25) clamp(0.65, calc(0.95 - l * 0.5), 0.88) calc(c * 0.9) h) clamp(0.70, calc(l + 0.15), 1) c h)", + "--sf-color-info-strong": "oklch(from oklch(from oklch(0.48 0.18 235) clamp(0.65, calc(0.95 - l * 0.5), 0.88) calc(c * 0.9) h) clamp(0.70, calc(l + 0.15), 1) c h)", + "--sf-color-danger-strong": "oklch(from oklch(from oklch(0.48 0.22 12) clamp(0.65, calc(0.95 - l * 0.5), 0.88) calc(c * 0.9) h) clamp(0.70, calc(l + 0.15), 1) c h)", + "--sf-shadow-xs": "0 1px 2px 0 oklch(from oklch(from oklch(from oklch(0.52 0.025 260) clamp(0.65, calc(0.95 - l * 0.5), 0.88) calc(c * 0.9) h) 0.15 c h) l c h / clamp(0, calc(calc(0.08 + 1 * 0.17) * 0.5), 0.7))", + "--sf-shadow-s": "0 1px 2px 0 oklch(from oklch(from oklch(from oklch(0.52 0.025 260) clamp(0.65, calc(0.95 - l * 0.5), 0.88) calc(c * 0.9) h) 0.15 c h) l c h / clamp(0, calc(calc(0.08 + 1 * 0.17) * 0.5), 0.7)),\n 0 2px 6px 0 oklch(from oklch(from oklch(from oklch(0.52 0.025 260) clamp(0.65, calc(0.95 - l * 0.5), 0.88) calc(c * 0.9) h) 0.15 c h) l c h / clamp(0, calc(0.08 + 1 * 0.17), 0.7))", + "--sf-shadow-m": "0 1px 3px 0 oklch(from oklch(from oklch(from oklch(0.52 0.025 260) clamp(0.65, calc(0.95 - l * 0.5), 0.88) calc(c * 0.9) h) 0.15 c h) l c h / clamp(0, calc(calc(0.08 + 1 * 0.17) * 0.5), 0.7)),\n 0 4px 12px 0 oklch(from oklch(from oklch(from oklch(0.52 0.025 260) clamp(0.65, calc(0.95 - l * 0.5), 0.88) calc(c * 0.9) h) 0.15 c h) l c h / clamp(0, calc(calc(0.08 + 1 * 0.17) * 2), 0.7))", + "--sf-shadow-l": "0 2px 4px 0 oklch(from oklch(from oklch(from oklch(0.52 0.025 260) clamp(0.65, calc(0.95 - l * 0.5), 0.88) calc(c * 0.9) h) 0.15 c h) l c h / clamp(0, calc(calc(0.08 + 1 * 0.17) * 0.5), 0.7)),\n 0 8px 24px 0 oklch(from oklch(from oklch(from oklch(0.52 0.025 260) clamp(0.65, calc(0.95 - l * 0.5), 0.88) calc(c * 0.9) h) 0.15 c h) l c h / clamp(0, calc(calc(0.08 + 1 * 0.17) * 3), 0.7)),\n 0 16px 48px 0 oklch(from oklch(from oklch(from oklch(0.52 0.025 260) clamp(0.65, calc(0.95 - l * 0.5), 0.88) calc(c * 0.9) h) 0.15 c h) l c h / clamp(0, calc(calc(0.08 + 1 * 0.17) * 2), 0.7))" + }, + "light": { + "--sf-color-primary": "light-dark(oklch(0.47 0.27 264), oklch(from oklch(0.47 0.27 264) clamp(0.65, calc(0.95 - l * 0.5), 0.88) calc(c * 0.9) h))", + "--sf-color-secondary": "light-dark(oklch(0.22 0.04 264), oklch(from oklch(0.22 0.04 264) clamp(0.65, calc(0.95 - l * 0.5), 0.88) calc(c * 0.9) h))", + "--sf-color-tertiary": "light-dark(oklch(0.42 0.22 295), oklch(from oklch(0.42 0.22 295) clamp(0.65, calc(0.95 - l * 0.5), 0.88) calc(c * 0.9) h))", + "--sf-color-action": "light-dark(oklch(0.5 0.22 235), oklch(from oklch(0.5 0.22 235) clamp(0.65, calc(0.95 - l * 0.5), 0.88) calc(c * 0.9) h))", + "--sf-color-neutral": "light-dark(oklch(0.52 0.025 260), oklch(from oklch(0.52 0.025 260) clamp(0.65, calc(0.95 - l * 0.5), 0.88) calc(c * 0.9) h))", + "--sf-color-base": "light-dark(oklch(0.96 0.006 250), oklch(from oklch(0.96 0.006 250) clamp(0.16, calc(1.18 - l), 0.24) calc(c * 0.5) h))", + "--sf-color-success": "light-dark(oklch(0.5 0.16 145), oklch(from oklch(0.5 0.16 145) clamp(0.65, calc(0.95 - l * 0.5), 0.88) calc(c * 0.9) h))", + "--sf-color-warning": "light-dark(oklch(0.75 0.17 80), oklch(from oklch(0.75 0.17 80) clamp(0.65, calc(0.95 - l * 0.5), 0.88) calc(c * 0.9) h))", + "--sf-color-error": "light-dark(oklch(0.5 0.2 25), oklch(from oklch(0.5 0.2 25) clamp(0.65, calc(0.95 - l * 0.5), 0.88) calc(c * 0.9) h))", + "--sf-color-info": "light-dark(oklch(0.48 0.18 235), oklch(from oklch(0.48 0.18 235) clamp(0.65, calc(0.95 - l * 0.5), 0.88) calc(c * 0.9) h))", + "--sf-color-danger": "light-dark(oklch(0.48 0.22 12), oklch(from oklch(0.48 0.22 12) clamp(0.65, calc(0.95 - l * 0.5), 0.88) calc(c * 0.9) h))", + "--sf-color-bg": "oklch(from light-dark(oklch(0.96 0.006 250), oklch(from oklch(0.96 0.006 250) clamp(0.16, calc(1.18 - l), 0.24) calc(c * 0.5) h)) calc(l + 0.02) c h)", + "--sf-color-inset": "oklch(from light-dark(oklch(0.96 0.006 250), oklch(from oklch(0.96 0.006 250) clamp(0.16, calc(1.18 - l), 0.24) calc(c * 0.5) h)) calc(l - 0.02) c h)", + "--sf-color-raised": "oklch(from light-dark(oklch(0.96 0.006 250), oklch(from oklch(0.96 0.006 250) clamp(0.16, calc(1.18 - l), 0.24) calc(c * 0.5) h)) calc(l + 0.04) c h)", + "--sf-color-text": "light-dark(\n oklch(from oklch(0.52 0.025 260) clamp(0.05, calc(l - 0.4 - 0), 0.35) c h),\n oklch(from light-dark(oklch(0.52 0.025 260), oklch(from oklch(0.52 0.025 260) clamp(0.65, calc(0.95 - l * 0.5), 0.88) calc(c * 0.9) h)) clamp(0.70, calc(l + 0.25 + 0), 1) c h)\n )", + "--sf-color-text--secondary": "light-dark(\n oklch(from oklch(0.52 0.025 260) clamp(0.15, calc(l - 0.25 - 0), 0.45) c h),\n oklch(from light-dark(oklch(0.52 0.025 260), oklch(from oklch(0.52 0.025 260) clamp(0.65, calc(0.95 - l * 0.5), 0.88) calc(c * 0.9) h)) clamp(0.55, calc(l + 0.1 + 0), 0.90) c h)\n )", + "--sf-color-heading": "light-dark(\n oklch(from oklch(0.52 0.025 260) clamp(0.05, calc(l - 0.4 - 0), 0.35) c h),\n oklch(from light-dark(oklch(0.52 0.025 260), oklch(from oklch(0.52 0.025 260) clamp(0.65, calc(0.95 - l * 0.5), 0.88) calc(c * 0.9) h)) clamp(0.70, calc(l + 0.25 + 0), 1) c h)\n )", + "--sf-color-border": "light-dark(\n oklch(from oklch(0.52 0.025 260) clamp(0.70, calc(l + 0.35), 0.95) 0.005 h),\n oklch(from light-dark(oklch(0.52 0.025 260), oklch(from oklch(0.52 0.025 260) clamp(0.65, calc(0.95 - l * 0.5), 0.88) calc(c * 0.9) h)) clamp(0.25, calc(l - 0.3), 0.55) 0.005 h)\n )", + "--sf-color-border--subtle": "light-dark(\n oklch(from oklch(0.52 0.025 260) clamp(0.75, calc(l + 0.4), 0.97) 0.005 h),\n oklch(from light-dark(oklch(0.52 0.025 260), oklch(from oklch(0.52 0.025 260) clamp(0.65, calc(0.95 - l * 0.5), 0.88) calc(c * 0.9) h)) clamp(0.20, calc(l - 0.38), 0.45) 0.005 h)\n )", + "--sf-color-border--strong": "light-dark(\n oklch(from oklch(0.52 0.025 260) clamp(0.55, calc(l + 0.1), 0.85) 0.02 h),\n oklch(from light-dark(oklch(0.52 0.025 260), oklch(from oklch(0.52 0.025 260) clamp(0.65, calc(0.95 - l * 0.5), 0.88) calc(c * 0.9) h)) clamp(0.38, calc(l - 0.1), 0.65) 0.02 h)\n )", + "--sf-color-link": "light-dark(\n oklch(from light-dark(oklch(0.5 0.22 235), oklch(from oklch(0.5 0.22 235) clamp(0.65, calc(0.95 - l * 0.5), 0.88) calc(c * 0.9) h)) clamp(0, min(l - 0.07, 0.48), 1) c h),\n oklch(from light-dark(oklch(0.5 0.22 235), oklch(from oklch(0.5 0.22 235) clamp(0.65, calc(0.95 - l * 0.5), 0.88) calc(c * 0.9) h)) clamp(0.68, l, 1) c h)\n )", + "--sf-color-selection-bg": "light-dark(\n oklch(from oklch(0.5 0.22 235) l c h / 0.28),\n oklch(from oklch(0.5 0.22 235) clamp(0.62, calc(0.93 - l * 0.4), 0.78) c h / 0.55)\n )", + "--sf-color-success-strong": "light-dark(\n oklch(from oklch(0.5 0.16 145) calc(l - 0.15) c h),\n oklch(from light-dark(oklch(0.5 0.16 145), oklch(from oklch(0.5 0.16 145) clamp(0.65, calc(0.95 - l * 0.5), 0.88) calc(c * 0.9) h)) clamp(0.70, calc(l + 0.15), 1) c h)\n )", + "--sf-color-warning-strong": "light-dark(\n oklch(from oklch(0.75 0.17 80) calc(l - 0.25) c h),\n oklch(from light-dark(oklch(0.75 0.17 80), oklch(from oklch(0.75 0.17 80) clamp(0.65, calc(0.95 - l * 0.5), 0.88) calc(c * 0.9) h)) clamp(0.70, calc(l + 0.05), 1) c h)\n )", + "--sf-color-error-strong": "light-dark(\n oklch(from oklch(0.5 0.2 25) calc(l - 0.1) c h),\n oklch(from light-dark(oklch(0.5 0.2 25), oklch(from oklch(0.5 0.2 25) clamp(0.65, calc(0.95 - l * 0.5), 0.88) calc(c * 0.9) h)) clamp(0.70, calc(l + 0.15), 1) c h)\n )", + "--sf-color-info-strong": "light-dark(\n oklch(from oklch(0.48 0.18 235) calc(l - 0.1) c h),\n oklch(from light-dark(oklch(0.48 0.18 235), oklch(from oklch(0.48 0.18 235) clamp(0.65, calc(0.95 - l * 0.5), 0.88) calc(c * 0.9) h)) clamp(0.70, calc(l + 0.15), 1) c h)\n )", + "--sf-color-danger-strong": "light-dark(\n oklch(from oklch(0.48 0.22 12) calc(l - 0.1) c h),\n oklch(from light-dark(oklch(0.48 0.22 12), oklch(from oklch(0.48 0.22 12) clamp(0.65, calc(0.95 - l * 0.5), 0.88) calc(c * 0.9) h)) clamp(0.70, calc(l + 0.15), 1) c h)\n )", + "--sf-shadow-xs": "0 1px 2px 0 oklch(from oklch(from light-dark(oklch(0.52 0.025 260), oklch(from oklch(0.52 0.025 260) clamp(0.65, calc(0.95 - l * 0.5), 0.88) calc(c * 0.9) h)) 0.15 c h) l c h / clamp(0, calc(calc(0.08 + 0 * 0.17) * 0.5), 0.7))", + "--sf-shadow-s": "0 1px 2px 0 oklch(from oklch(from light-dark(oklch(0.52 0.025 260), oklch(from oklch(0.52 0.025 260) clamp(0.65, calc(0.95 - l * 0.5), 0.88) calc(c * 0.9) h)) 0.15 c h) l c h / clamp(0, calc(calc(0.08 + 0 * 0.17) * 0.5), 0.7)),\n 0 2px 6px 0 oklch(from oklch(from light-dark(oklch(0.52 0.025 260), oklch(from oklch(0.52 0.025 260) clamp(0.65, calc(0.95 - l * 0.5), 0.88) calc(c * 0.9) h)) 0.15 c h) l c h / clamp(0, calc(0.08 + 0 * 0.17), 0.7))", + "--sf-shadow-m": "0 1px 3px 0 oklch(from oklch(from light-dark(oklch(0.52 0.025 260), oklch(from oklch(0.52 0.025 260) clamp(0.65, calc(0.95 - l * 0.5), 0.88) calc(c * 0.9) h)) 0.15 c h) l c h / clamp(0, calc(calc(0.08 + 0 * 0.17) * 0.5), 0.7)),\n 0 4px 12px 0 oklch(from oklch(from light-dark(oklch(0.52 0.025 260), oklch(from oklch(0.52 0.025 260) clamp(0.65, calc(0.95 - l * 0.5), 0.88) calc(c * 0.9) h)) 0.15 c h) l c h / clamp(0, calc(calc(0.08 + 0 * 0.17) * 2), 0.7))", + "--sf-shadow-l": "0 2px 4px 0 oklch(from oklch(from light-dark(oklch(0.52 0.025 260), oklch(from oklch(0.52 0.025 260) clamp(0.65, calc(0.95 - l * 0.5), 0.88) calc(c * 0.9) h)) 0.15 c h) l c h / clamp(0, calc(calc(0.08 + 0 * 0.17) * 0.5), 0.7)),\n 0 8px 24px 0 oklch(from oklch(from light-dark(oklch(0.52 0.025 260), oklch(from oklch(0.52 0.025 260) clamp(0.65, calc(0.95 - l * 0.5), 0.88) calc(c * 0.9) h)) 0.15 c h) l c h / clamp(0, calc(calc(0.08 + 0 * 0.17) * 3), 0.7)),\n 0 16px 48px 0 oklch(from oklch(from light-dark(oklch(0.52 0.025 260), oklch(from oklch(0.52 0.025 260) clamp(0.65, calc(0.95 - l * 0.5), 0.88) calc(c * 0.9) h)) 0.15 c h) l c h / clamp(0, calc(calc(0.08 + 0 * 0.17) * 2), 0.7))" + } +} diff --git a/tests/bundle-size.spec.js b/tests/bundle-size.spec.js index a858cf23..0636b395 100644 --- a/tests/bundle-size.spec.js +++ b/tests/bundle-size.spec.js @@ -10,10 +10,12 @@ import zlib from 'node:zlib'; const DIST = path.resolve(import.meta.dirname, '..', 'dist'); // gzip kB budgets per minified bundle. +// Updated after adding core/tokens.color-fallbacks.css (Tier-1 sRGB fallbacks), +// which adds ~1.5–2kB gzip to each bundle containing color tokens. const BUDGETS = { - 'slashed.essential.min.css': 15, - 'slashed.optimal.min.css': 18, - 'slashed.full.min.css': 20, + 'slashed.essential.min.css': 18, + 'slashed.optimal.min.css': 22, + 'slashed.full.min.css': 22, }; for (const [file, budgetKb] of Object.entries(BUDGETS)) { diff --git a/tests/tier1-p1-srgb-validity.test.js b/tests/tier1-p1-srgb-validity.test.js new file mode 100644 index 00000000..6ac9a455 --- /dev/null +++ b/tests/tier1-p1-srgb-validity.test.js @@ -0,0 +1,130 @@ +/** + * Feature: tier-1-color-fallback + * Property 1: Every Tier-1 unguarded default is a valid CSS color expression + * that resolves to sRGB — hex, rgb(), or hsl() with var() channels. + * None use light-dark(), oklch(from…), or color-mix(). + * + * Run: node --test tests/tier1-p1-srgb-validity.test.js + */ +import { test, describe } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import fc from 'fast-check'; + +const ROOT = path.resolve(import.meta.dirname, '..'); +const DIST = path.join(ROOT, 'dist/slashed.full.css'); +const FALLBACKS = path.join(ROOT, 'core/tokens.color-fallbacks.css'); + +// ── helpers ────────────────────────────────────────────────────────────────── + +const HEX_RE = /^#[0-9a-f]{3}(?:[0-9a-f]{3})?$/i; +const RGB_RE = /^rgb\(\s*\d+\s+\d+\s+\d+\s*(?:\/\s*[\d.]+\s*)?\)$/; + +// hsl() can contain var() references which have nested parens — check by prefix/suffix +function isHSL(v) { return v.startsWith('hsl(') && v.endsWith(')'); } + +// Valid sRGB-resolving color expressions in the fallbacks file. +// hsl() with var() channels is valid on every browser from Chrome 49+ (2016). +// oklch() is intentionally NOT accepted here — the fallback contract is sRGB-only +// (HSL/hex/rgb); an oklch() value would mean a modern expression leaked unguarded. +function isValidSRGB(value) { + const v = value.trim(); + return HEX_RE.test(v) || RGB_RE.test(v) || isHSL(v) || + v === 'none' || v === 'transparent' || v === 'inherit' || v === 'auto'; +} + +// ── extract unguarded declarations from color-fallbacks.css ────────────────── + +function extractFallbackDecls(css) { + const stripped = css.replace(/\/\*[\s\S]*?\*\//g, ''); + const decls = []; + for (const m of stripped.matchAll(/(--sf-[\w-]+)\s*:\s*([^;]+);/g)) { + decls.push({ name: m[1], value: m[2].replace(/\s+/g, ' ').trim() }); + } + return decls; +} + +// ── tests ──────────────────────────────────────────────────────────────────── + +describe('P1: Tier-1 unguarded defaults are valid sRGB', () => { + let decls; + + test('dist/slashed.full.css exists', () => { + assert.ok(fs.existsSync(DIST), 'dist/slashed.full.css not found — run npm run build first'); + }); + + test('core/tokens.color-fallbacks.css exists', () => { + assert.ok(fs.existsSync(FALLBACKS), 'core/tokens.color-fallbacks.css not found'); + const css = fs.readFileSync(FALLBACKS, 'utf8'); + decls = extractFallbackDecls(css); + assert.ok(decls.length > 0, 'No declarations found in fallbacks file'); + }); + + test('all color custom properties have valid sRGB-resolving values', () => { + // Channel variables (--sf-primary-h etc.) are numbers, not colors — skip them. + const colorDecls = decls.filter(d => + (d.name.includes('-color-') || d.name.startsWith('--sf-shadow-color')) && + !d.name.endsWith('-h') && !d.name.endsWith('-s') && !d.name.endsWith('-l') + ); + const violations = colorDecls.filter(d => !isValidSRGB(d.value)); + if (violations.length > 0) { + const msg = violations.map(v => ` ${v.name}: ${v.value}`).join('\n'); + assert.fail(`Non-sRGB values found in fallbacks:\n${msg}`); + } + }); + + test('no light-dark() in unguarded section', () => { + const violations = decls.filter(d => d.value.includes('light-dark')); + if (violations.length > 0) { + assert.fail(`light-dark() in unguarded fallbacks: ${violations.map(d => d.name).join(', ')}`); + } + }); + + test('no oklch(from…) relative color in unguarded section', () => { + const violations = decls.filter(d => d.value.includes('oklch(from')); + if (violations.length > 0) { + assert.fail(`oklch(from…) in unguarded fallbacks: ${violations.map(d => d.name).join(', ')}`); + } + }); + + test('no color-mix() in unguarded section', () => { + const violations = decls.filter(d => d.value.includes('color-mix(')); + if (violations.length > 0) { + assert.fail(`color-mix() in unguarded fallbacks: ${violations.map(d => d.name).join(', ')}`); + } + }); + + // fast-check: every color declaration uses an allowed expression type + test('fast-check: all color declarations use valid sRGB-resolving expressions (100 iterations)', () => { + const colorDecls = decls.filter(d => + (d.name.includes('-color-') || d.name.startsWith('--sf-shadow-color')) && + !d.name.endsWith('-h') && !d.name.endsWith('-s') && !d.name.endsWith('-l') + ); + if (colorDecls.length === 0) return; + + fc.assert( + fc.property( + fc.integer({ min: 0, max: colorDecls.length - 1 }), + (idx) => isValidSRGB(colorDecls[idx].value) + ), + { numRuns: 100 } + ); + }); + + test('fast-check: all hsl() declarations contain no modern-only features (100 iterations)', () => { + const hslDecls = decls.filter(d => d.value.trim().startsWith('hsl(')); + if (hslDecls.length === 0) return; + + fc.assert( + fc.property( + fc.integer({ min: 0, max: hslDecls.length - 1 }), + (idx) => { + const v = hslDecls[idx].value; + return !v.includes('light-dark') && !v.includes('oklch(from') && !v.includes('color-mix('); + } + ), + { numRuns: 100 } + ); + }); +}); diff --git a/tests/tier1-p10-contrast.test.js b/tests/tier1-p10-contrast.test.js new file mode 100644 index 00000000..31ca3885 --- /dev/null +++ b/tests/tier1-p10-contrast.test.js @@ -0,0 +1,94 @@ +/** + * Feature: tier-1-color-fallback + * Property 10: WCAG 2.1 AA contrast (4.5:1 body, 3:1 large/UI) for critical + * fg/bg pairs using the HSL channel defaults from color-fallbacks.css. + * + * Run: node --test tests/tier1-p10-contrast.test.js + */ +import { test, describe } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import fc from 'fast-check'; +import { parse, wcagContrast } from 'culori'; + +const ROOT = path.resolve(import.meta.dirname, '..'); + +// Read --sf-X-h/s/l channel defaults from the fallbacks file +function readChannels() { + const css = fs.readFileSync(path.join(ROOT, 'core/tokens.color-fallbacks.css'), 'utf8'); + const stripped = css.replace(/\/\*[\s\S]*?\*\//g, ''); + const ch = {}; + for (const m of stripped.matchAll(/--(sf-[\w]+-[hsl])\s*:\s*([\d.]+%?)\s*;/g)) { + ch[m[1]] = m[2]; + } + return ch; +} + +// Build resolved HSL color strings for default token values. +// calc() is evaluated in JS so culori can parse the result. +function buildColors(ch) { + const num = (v) => parseFloat(v); + const baseL = num(ch['sf-base-l']); + const actionL = num(ch['sf-action-l']); + return { + text: `hsl(${ch['sf-neutral-h']} 15% 12%)`, + 'text--secondary': `hsl(${ch['sf-neutral-h']} 10% 35%)`, + bg: `hsl(${ch['sf-base-h']} ${ch['sf-base-s']} ${baseL + 2}%)`, + link: `hsl(${ch['sf-action-h']} ${ch['sf-action-s']} ${actionL - 5}%)`, + primary: `hsl(${ch['sf-primary-h']} ${ch['sf-primary-s']} ${ch['sf-primary-l']})`, + }; +} + +function contrast(a, b) { + const c1 = parse(a); + const c2 = parse(b); + if (!c1 || !c2) return 0; + return wcagContrast(c1, c2); +} + +describe('P10: Fallback contrast pairs meet WCAG 2.1 AA (light mode defaults)', () => { + let colors; + + test('setup: read channel defaults', () => { + const ch = readChannels(); + colors = buildColors(ch); + }); + + test('body text on bg ≥ 4.5:1', () => { + const c = contrast(colors.text, colors.bg); + assert.ok(c >= 4.5, `text on bg: ${c.toFixed(2)}:1 (need 4.5:1)`); + }); + + test('secondary text on bg ≥ 4.5:1', () => { + const c = contrast(colors['text--secondary'], colors.bg); + assert.ok(c >= 4.5, `text--secondary on bg: ${c.toFixed(2)}:1 (need 4.5:1)`); + }); + + test('link on bg ≥ 4.5:1', () => { + const c = contrast(colors.link, colors.bg); + assert.ok(c >= 4.5, `link on bg: ${c.toFixed(2)}:1 (need 4.5:1)`); + }); + + test('primary on bg ≥ 3:1 (UI/large text)', () => { + const c = contrast(colors.primary, colors.bg); + assert.ok(c >= 3.0, `primary on bg: ${c.toFixed(2)}:1 (need 3:1)`); + }); + + // fast-check: dark text on light bg always meets 4.5:1 + test('fast-check: dark text on light bg always meets 4.5:1 (100 iterations)', () => { + fc.assert( + fc.property( + fc.integer({ min: 2, max: 25 }), // text lightness 2–25% + fc.integer({ min: 80, max: 99 }), // bg lightness 80–99% + (tl, bgl) => { + const fg = parse(`hsl(217 15% ${tl}%)`); + const bg = parse(`hsl(211 10% ${bgl}%)`); + if (!fg || !bg) return true; + return wcagContrast(fg, bg) >= 4.5; + } + ), + { numRuns: 100 } + ); + }); +}); diff --git a/tests/tier1-p11-overrides.test.js b/tests/tier1-p11-overrides.test.js new file mode 100644 index 00000000..5add0ddd --- /dev/null +++ b/tests/tier1-p11-overrides.test.js @@ -0,0 +1,160 @@ +/** + * Feature: tier-1-color-fallback + * Property 11: Overriding --sf-*-h/s/l channel variables propagates to all + * derived tokens on every browser (no build step required). + * + * Unlike a JS re-implementation of the formulas (which can stay green even if + * the real CSS regresses), this suite parses the actual derivation expressions + * out of core/tokens.color-fallbacks.css and resolves THOSE against overridden + * channel values — so a change to the shipped formulas changes what's tested. + * + * Run: node --test tests/tier1-p11-overrides.test.js + */ +import { test, describe } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import fc from 'fast-check'; +import { parse } from 'culori'; + +const ROOT = path.resolve(import.meta.dirname, '..'); +const CSS = fs.readFileSync(path.join(ROOT, 'core/tokens.color-fallbacks.css'), 'utf8'); + +// Read --sf--h/s/l channel defaults (percent sign stripped — values are plain numbers). +function readChannels(css) { + const ch = {}; + for (const m of css.matchAll(/--(sf-[\w]+-[hsl])\s*:\s*([\d.]+)%?\s*;/g)) { + ch[m[1]] = parseFloat(m[2]); + } + return ch; +} + +// Extract `--sf-color-X: hsl(var(--sf--h) var(--sf--s) +// [ var(--sf--l) | calc(var(--sf--l) ± N%) ] [/ alpha])` +// — i.e. every Tier-1 color token whose value derives from a single family's channels. +function extractDerivations(css) { + const re = /(--sf-color-[\w-]+)\s*:\s*hsl\(\s*var\(--sf-([\w]+)-h\)\s+var\(--sf-\2-s\)\s+(?:var\(--sf-\2-l\)|calc\(\s*var\(--sf-\2-l\)\s*([+-])\s*([\d.]+)%\s*\))\s*(?:\/\s*([\d.]+)\s*)?\)/g; + const decls = []; + let m; + while ((m = re.exec(css)) !== null) { + const [, token, family, sign, delta, alpha] = m; + decls.push({ + token, + family, + lDelta: delta ? (sign === '-' ? -parseFloat(delta) : parseFloat(delta)) : 0, + alpha: alpha !== undefined ? parseFloat(alpha) : null, + }); + } + return decls; +} + +// Resolve a parsed derivation against a given channel set — mirrors what the +// browser's cascade does when var()/calc() resolve against :root custom properties. +function resolve(decl, channels) { + const h = channels[`sf-${decl.family}-h`]; + const s = channels[`sf-${decl.family}-s`]; + const l = channels[`sf-${decl.family}-l`] + decl.lDelta; + return decl.alpha != null ? `hsl(${h} ${s}% ${l}% / ${decl.alpha})` : `hsl(${h} ${s}% ${l}%)`; +} + +const baseChannels = readChannels(CSS); +const derivations = extractDerivations(CSS); + +describe('P11: HSL channel override propagation (real CSS contract)', () => { + + test('setup: fallback file declares derived color tokens parseable from source channels', () => { + assert.ok(Object.keys(baseChannels).length >= 30, 'Expected --sf-*-h/s/l channel triples for all families'); + assert.ok(derivations.length >= 8, 'Expected multiple --sf-color-* tokens deriving from --sf-*-h/s/l channels'); + }); + + test('overriding a family hue changes its resolved color but not its lightness/saturation', () => { + const primary = derivations.find(d => d.token === '--sf-color-primary'); + assert.ok(primary, '--sf-color-primary must derive directly from --sf-primary-h/s/l'); + + const overridden = { ...baseChannels, 'sf-primary-h': (baseChannels['sf-primary-h'] + 130) % 360 }; + const before = parse(resolve(primary, baseChannels)); + const after = parse(resolve(primary, overridden)); + assert.ok(before && after, 'resolved colors must be parseable'); + assert.notEqual(before.h, after.h, 'Hue override must change resolved hue'); + assert.equal(before.s, after.s, 'Saturation must stay the same when only hue changes'); + assert.equal(before.l, after.l, 'Lightness must stay the same when only hue changes'); + }); + + test('overriding --sf-base-l propagates to every base-derived surface token by the same delta', () => { + const surfaceTokens = derivations.filter(d => d.family === 'base' && d.token !== '--sf-color-base'); + assert.ok(surfaceTokens.length >= 2, 'Expected surface tokens (bg/inset/raised) to derive from --sf-base-l'); + + const DELTA = 10; + const overridden = { ...baseChannels, 'sf-base-l': baseChannels['sf-base-l'] + DELTA }; + + for (const decl of surfaceTokens) { + const before = parse(resolve(decl, baseChannels)); + const after = parse(resolve(decl, overridden)); + assert.ok(before && after, `${decl.token} must resolve to a parseable color`); + // culori normalizes HSL lightness to [0,1]; DELTA is in percentage points. + assert.ok( + Math.abs((after.l - before.l) - DELTA / 100) < 1e-9, + `${decl.token} lightness must shift by exactly the --sf-base-l delta (${DELTA}%), got ${((after.l - before.l) * 100).toFixed(3)}%` + ); + } + }); + + test('overriding --sf-action-l propagates to --sf-color-link with its formula offset preserved', () => { + const link = derivations.find(d => d.token === '--sf-color-link'); + assert.ok(link, '--sf-color-link must derive from --sf-action-h/s/l'); + + const DELTA = 8; + const overridden = { ...baseChannels, 'sf-action-l': baseChannels['sf-action-l'] + DELTA }; + const before = parse(resolve(link, baseChannels)); + const after = parse(resolve(link, overridden)); + assert.ok(before && after, 'link must resolve to a parseable color'); + // culori normalizes HSL lightness to [0,1]; DELTA is in percentage points. + assert.ok( + Math.abs((after.l - before.l) - DELTA / 100) < 1e-9, + `link lightness must track --sf-action-l 1:1 (offset preserved), got Δ=${((after.l - before.l) * 100).toFixed(3)}%` + ); + }); + + // fast-check: overriding any family's hue changes the resolved hue of every + // Tier-1 token that derives from that family (the propagation guarantee). + test('fast-check: overriding a family hue changes every token derived from it (100 iterations)', () => { + const families = [...new Set(derivations.map(d => d.family))]; + fc.assert( + fc.property( + fc.integer({ min: 0, max: families.length - 1 }), + fc.integer({ min: 1, max: 359 }), + (famIdx, hueShift) => { + const family = families[famIdx]; + const familyDecls = derivations.filter(d => d.family === family); + const overridden = { ...baseChannels, [`sf-${family}-h`]: (baseChannels[`sf-${family}-h`] + hueShift) % 360 }; + + return familyDecls.every(decl => { + const before = parse(resolve(decl, baseChannels)); + const after = parse(resolve(decl, overridden)); + return !!before && !!after && before.h !== after.h; + }); + } + ), + { numRuns: 100 } + ); + }); + + // fast-check: every parsed derivation resolves to a valid, parseable color + // both at its committed defaults and under random channel overrides. + test('fast-check: parsed derivations resolve to valid colors under random overrides (100 iterations)', () => { + fc.assert( + fc.property( + fc.integer({ min: 0, max: derivations.length - 1 }), + fc.integer({ min: 0, max: 359 }), + fc.integer({ min: 0, max: 100 }), + fc.integer({ min: 0, max: 100 }), + (idx, h, s, l) => { + const decl = derivations[idx]; + const overridden = { ...baseChannels, [`sf-${decl.family}-h`]: h, [`sf-${decl.family}-s`]: s, [`sf-${decl.family}-l`]: l }; + return !!parse(resolve(decl, overridden)); + } + ), + { numRuns: 100 } + ); + }); +}); diff --git a/tests/tier1-p2-coverage.test.js b/tests/tier1-p2-coverage.test.js new file mode 100644 index 00000000..8e1b2837 --- /dev/null +++ b/tests/tier1-p2-coverage.test.js @@ -0,0 +1,104 @@ +/** + * Feature: tier-1-color-fallback + * Property 2: For any Tier-1 token, exactly one unguarded default exists; + * no omission, no duplication. + * + * Run: node --test tests/tier1-p2-coverage.test.js + */ +import { test, describe } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import fc from 'fast-check'; + +const ROOT = path.resolve(import.meta.dirname, '..'); +const FALLBACKS = path.join(ROOT, 'core/tokens.color-fallbacks.css'); + +const TIER1_TOKENS = [ + '--sf-color-primary', '--sf-color-secondary', '--sf-color-tertiary', + '--sf-color-action', '--sf-color-neutral', '--sf-color-base', + '--sf-color-success', '--sf-color-warning', '--sf-color-error', + '--sf-color-info', '--sf-color-danger', + '--sf-color-bg', '--sf-color-inset', '--sf-color-raised', + '--sf-color-text', '--sf-color-text--secondary', '--sf-color-heading', + '--sf-color-border', '--sf-color-border--subtle', '--sf-color-border--strong', + '--sf-color-link', '--sf-color-selection-bg', + '--sf-color-success-strong', '--sf-color-warning-strong', + '--sf-color-error-strong', '--sf-color-info-strong', '--sf-color-danger-strong', + '--sf-shadow-xs', '--sf-shadow-s', '--sf-shadow-m', '--sf-shadow-l', +]; + +describe('P2: Coverage and uniqueness of Tier-1 fallback declarations', () => { + let css; + let ungatedDecls; + + test('setup: load fallbacks file', () => { + css = fs.readFileSync(FALLBACKS, 'utf8'); + // Only count declarations in the @layer slashed.tokens block (light-mode defaults) + // The themes block intentionally re-declares tokens per selector + const tokensEnd = css.indexOf('@layer slashed.themes'); + const lightCSS = css.slice(0, tokensEnd > 0 ? tokensEnd : css.length); + const stripped = lightCSS.replace(/\/\*[\s\S]*?\*\//g, ''); + const counts = new Map(); + for (const m of stripped.matchAll(/(--sf-[\w-]+)\s*:/g)) { + const name = m[1]; + counts.set(name, (counts.get(name) ?? 0) + 1); + } + ungatedDecls = counts; + }); + + test('all required Tier-1 tokens are present', () => { + const missing = TIER1_TOKENS.filter(t => !ungatedDecls.has(t)); + if (missing.length > 0) { + assert.fail('Missing Tier-1 tokens: ' + missing.join(', ')); + } + }); + + test('no Tier-1 token declared more than once in light-mode block', () => { + const dupes = TIER1_TOKENS.filter(t => (ungatedDecls.get(t) ?? 0) > 1); + if (dupes.length > 0) { + assert.fail('Duplicate fallback declarations in tokens block: ' + dupes.join(', ')); + } + }); + + test('tokens.css has no ungated light-dark() declarations', () => { + const tokensCss = fs.readFileSync(path.join(ROOT, 'core/tokens.css'), 'utf8'); + const stripped = tokensCss.replace(/\/\*[\s\S]*?\*\//g, ''); + // Find light-dark() not inside @supports + let inSupports = 0; + for (const line of stripped.split('\n')) { + if (line.includes('@supports')) inSupports++; + if (line.trim() === '}') { if (inSupports > 0) inSupports--; } + if (line.includes('light-dark(') && inSupports === 0) { + assert.fail(`Ungated light-dark() found in tokens.css: ${line.trim()}`); + } + } + }); + + test('tokens.css has no ungated oklch(from…) declarations', () => { + const tokensCss = fs.readFileSync(path.join(ROOT, 'core/tokens.css'), 'utf8'); + const stripped = tokensCss.replace(/\/\*[\s\S]*?\*\//g, ''); + let inSupports = 0; + for (const line of stripped.split('\n')) { + if (line.includes('@supports')) inSupports++; + if (line.trim() === '}') { if (inSupports > 0) inSupports--; } + if ((line.includes('oklch(from') || line.includes('color-mix(')) && inSupports === 0) { + assert.fail(`Ungated oklch(from)/color-mix() found in tokens.css: ${line.trim()}`); + } + } + }); + + // fast-check: any token from a known-covered set is present in the fallbacks + test('fast-check: random sample of Tier-1 tokens has a fallback (100 iterations)', () => { + fc.assert( + fc.property( + fc.integer({ min: 0, max: TIER1_TOKENS.length - 1 }), + (idx) => { + const token = TIER1_TOKENS[idx]; + return (ungatedDecls.get(token) ?? 0) >= 1; + } + ), + { numRuns: 100 } + ); + }); +}); diff --git a/tests/tier1-p3-ordering.test.js b/tests/tier1-p3-ordering.test.js new file mode 100644 index 00000000..da630884 --- /dev/null +++ b/tests/tier1-p3-ordering.test.js @@ -0,0 +1,98 @@ +/** + * Feature: tier-1-color-fallback + * Property 3: sRGB default source-index strictly less than gated modern + * declaration's source-index, per token, in dist/slashed.full.css. + * + * Run: node --test tests/tier1-p3-ordering.test.js + */ +import { test, describe } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import fc from 'fast-check'; + +const ROOT = path.resolve(import.meta.dirname, '..'); +const DIST = path.join(ROOT, 'dist/slashed.full.css'); + +const TOKENS_TO_CHECK = [ + '--sf-color-primary', + '--sf-color-secondary', + '--sf-color-action', + '--sf-color-neutral', + '--sf-color-base', + '--sf-color-success', + '--sf-color-warning', + '--sf-color-error', + '--sf-color-text', + '--sf-color-border', + '--sf-color-link', +]; + +function escapeRegExp(s) { + return s.replace(/[.*+?^${}()|[\]\\-]/g, '\\$&'); +} + +function findDeclarations(css, tokenName) { + const results = []; + const re = new RegExp(escapeRegExp(tokenName) + '\\s*:', 'g'); + let m; + while ((m = re.exec(css)) !== null) { + results.push({ index: m.index }); + } + return results; +} + +describe('P3: sRGB fallback declared before modern gated expression', () => { + let full; + + test('dist/slashed.full.css exists', () => { + assert.ok(fs.existsSync(DIST), 'dist/slashed.full.css missing'); + full = fs.readFileSync(DIST, 'utf8'); + }); + + // The fallbacks CSS comes before tokens.css in the bundle, so all + // declarations from the fallbacks file have smaller source indices. + test('tokens.color-fallbacks.css content appears before tokens.css content', () => { + const fallbacksMarker = 'HSL-based fallbacks for browsers without light-dark()'; + const tokensMarker = 'SLASHED — core/tokens.css'; + const fIdx = full.indexOf(fallbacksMarker); + const tIdx = full.indexOf(tokensMarker); + assert.ok(fIdx >= 0, 'fallbacks header not found in bundle'); + assert.ok(tIdx >= 0, 'tokens.css header not found in bundle'); + assert.ok(fIdx < tIdx, 'fallbacks content should appear before tokens.css in bundle'); + }); + + // For each checked token, the sRGB declaration (from fallbacks) must appear + // at a smaller source position than any @supports-gated declaration + for (const token of TOKENS_TO_CHECK) { + test(`${token}: sRGB default precedes gated modern expression`, () => { + const all = findDeclarations(full, token); + if (all.length < 2) return; // token may only appear once (ungated, no modern) + + // First occurrence is the sRGB fallback from tokens.color-fallbacks.css + // All @supports-gated occurrences come later + const first = all[0].index; + const rest = all.slice(1).map(d => d.index); + for (const laterIdx of rest) { + assert.ok(first < laterIdx, `${token}: sRGB fallback not first (${first} vs ${laterIdx})`); + } + }); + } + + // fast-check: random token from known set respects ordering invariant + test('fast-check: ordering invariant holds for sampled tokens (100 iterations)', () => { + fc.assert( + fc.property( + fc.integer({ min: 0, max: TOKENS_TO_CHECK.length - 1 }), + (idx) => { + const token = TOKENS_TO_CHECK[idx]; + const all = findDeclarations(full, token); + if (all.length < 2) return true; + const first = all[0].index; + return all.slice(1).every(d => first < d.index); + } + ), + { numRuns: 100 } + ); + }); +}); diff --git a/tests/tier1-p7-oldengine.test.js b/tests/tier1-p7-oldengine.test.js new file mode 100644 index 00000000..07799407 --- /dev/null +++ b/tests/tier1-p7-oldengine.test.js @@ -0,0 +1,171 @@ +/** + * Feature: tier-1-color-fallback + * Property 7: On Old_Engine (no @supports match), ungated sRGB defaults are + * the only surviving declarations and never contain light-dark() + * or oklch(from …). + * + * Simulates an Old_Engine by extracting only non-@supports declarations from + * the full bundle and asserting they satisfy the sRGB invariant. + * + * Run: node --test tests/tier1-p7-oldengine.test.js + */ +import { test, describe } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import fc from 'fast-check'; + +const ROOT = path.resolve(import.meta.dirname, '..'); +const DIST = path.join(ROOT, 'dist/slashed.full.css'); + +/** + * Simulate Old_Engine by stripping all @supports blocks from CSS. + * Returns only the declarations that survive (ungated ones). + */ +function stripSupports(css) { + // Remove @supports { ... } blocks (handles nesting) + let result = ''; + let depth = 0; + let inSupports = false; + let supportsDepth = 0; + const lines = css.split('\n'); + for (const line of lines) { + const trimmed = line.trim(); + if (trimmed.startsWith('@supports')) { + inSupports = true; + supportsDepth = depth; + } + if (!inSupports) { + result += line + '\n'; + } + // Count braces + for (const ch of line) { + if (ch === '{') depth++; + else if (ch === '}') { + depth--; + if (inSupports && depth <= supportsDepth) { + inSupports = false; + } + } + } + } + return result; +} + +function extractCustomDecls(css) { + const stripped = css.replace(/\/\*[\s\S]*?\*\//g, ''); + const decls = new Map(); + for (const m of stripped.matchAll(/(--sf-[\w-]+)\s*:\s*([^;]+);/g)) { + decls.set(m[1], m[2].replace(/\s+/g, ' ').trim()); + } + return decls; +} + +const TESTED_TOKENS = [ + '--sf-color-primary', + '--sf-color-secondary', + '--sf-color-action', + '--sf-color-neutral', + '--sf-color-base', + '--sf-color-text', + '--sf-color-border', + '--sf-color-success', + '--sf-color-warning', + '--sf-color-error', +]; + +const HEX_RE = /^#[0-9a-f]{3}(?:[0-9a-f]{3})?$/i; +const RGB_RE = /^rgb\(\s*\d+\s+\d+\s+\d+\s*(?:\/\s*[\d.]+\s*)?\)$/; + +// hsl() can contain var() references (nested parens) — check by prefix/suffix +function isHSL(v) { return v.startsWith('hsl(') && v.endsWith(')'); } + +function isValidSRGBValue(v) { + return HEX_RE.test(v) || RGB_RE.test(v) || isHSL(v) || + /^none$/.test(v) || /^transparent$/.test(v) || /^inherit$/.test(v) || + /^oklch\(\s*[\d.]+%?\s+[\d.]+\s+[\d.]+\s*\)$/.test(v); // absolute oklch is OK +} + +describe('P7: Old-engine cascade simulation', () => { + let oldEngineDecls; + + test('setup: load and strip @supports blocks', () => { + assert.ok(fs.existsSync(DIST), 'dist/slashed.full.css missing'); + const full = fs.readFileSync(DIST, 'utf8'); + const stripped = stripSupports(full); + oldEngineDecls = extractCustomDecls(stripped); + assert.ok(oldEngineDecls.size > 0, 'No declarations found in old-engine simulation'); + }); + + test('no light-dark() survives on old engine', () => { + const violations = [...oldEngineDecls.entries()] + .filter(([, v]) => v.includes('light-dark')); + if (violations.length > 0) { + assert.fail('light-dark() survived on Old_Engine: ' + + violations.map(([k]) => k).join(', ')); + } + }); + + test('no oklch(from…) survives on old engine', () => { + const violations = [...oldEngineDecls.entries()] + .filter(([, v]) => v.includes('oklch(from')); + if (violations.length > 0) { + assert.fail('oklch(from…) survived on Old_Engine: ' + + violations.map(([k]) => k).join(', ')); + } + }); + + test('no color-mix() in core Tier-1 tokens survives on old engine', () => { + // Scope to core semantic tokens (not numeric palette scale from optional/tokens.palette.css) + const CORE_TOKEN_PATTERN = /^--sf-color-(?!.+-\d{2,3}$|.+-a\d+$)/; + const violations = [...oldEngineDecls.entries()] + .filter(([k, v]) => CORE_TOKEN_PATTERN.test(k) && v.includes('color-mix(')); + if (violations.length > 0) { + assert.fail('color-mix() survived on Old_Engine in core tokens: ' + + violations.map(([k]) => k).join(', ')); + } + }); + + test('all core color tokens have sRGB values on old engine', () => { + for (const token of TESTED_TOKENS) { + const val = oldEngineDecls.get(token); + assert.ok(val, `${token} has no value on Old_Engine`); + assert.ok(isValidSRGBValue(val), `${token} value "${val}" is not valid sRGB`); + } + }); + + // fast-check: any sampled core token has a valid sRGB value on old engine + test('fast-check: sampled tokens have sRGB values on old engine (100 iterations)', () => { + fc.assert( + fc.property( + fc.integer({ min: 0, max: TESTED_TOKENS.length - 1 }), + (idx) => { + const token = TESTED_TOKENS[idx]; + const val = oldEngineDecls.get(token); + return !!val && isValidSRGBValue(val) && + !val.includes('light-dark') && !val.includes('oklch(from'); + } + ), + { numRuns: 100 } + ); + }); + + // Simulate dark mode on old engine: [data-theme="dark"] block should also have sRGB + test('dark-mode [data-theme="dark"] values are sRGB on old engine', () => { + const full = fs.readFileSync(DIST, 'utf8'); + const stripped = stripSupports(full); + // Extract just the [data-theme="dark"] block + const darkStart = stripped.indexOf('[data-theme="dark"]'); + if (darkStart < 0) return; // may not be in old-engine content + + const darkBlock = stripped.slice(darkStart, stripped.indexOf('}', darkStart) + 1); + const decls = extractCustomDecls(darkBlock); + for (const [name, val] of decls) { + if (!name.startsWith('--sf-color-')) continue; + assert.ok( + isValidSRGBValue(val), + `Dark-mode ${name} value "${val}" is not valid sRGB` + ); + } + }); +}); diff --git a/tests/tier1-p8-modern.spec.js b/tests/tier1-p8-modern.spec.js new file mode 100644 index 00000000..a43e702c --- /dev/null +++ b/tests/tier1-p8-modern.spec.js @@ -0,0 +1,121 @@ +// @ts-check +/** + * Feature: tier-1-color-fallback + * Property 8: On a Modern_Engine (Chromium) the computed values of all Tier-1 + * tokens match the committed baseline captured from the same engine. + * This proves that adding core/tokens.color-fallbacks.css before + * core/tokens.css did NOT change any resolved value on modern browsers. + * + * Regenerate baseline: BASELINE_CAPTURE=1 npx playwright test tests/tier1-p8-modern.spec.js --project=chromium + * Run: npx playwright test tests/tier1-p8-modern.spec.js --project=chromium + */ +import { test, expect } from '@playwright/test'; +import path from 'node:path'; +import fs from 'node:fs'; +import { pathToFileURL } from 'node:url'; + +const FIXTURE = pathToFileURL(path.join(import.meta.dirname, 'fixture.html')).href; +const BASELINE = path.join(import.meta.dirname, 'baseline-modern.json'); + +const TIER1_TOKENS = [ + '--sf-color-primary', '--sf-color-secondary', '--sf-color-tertiary', + '--sf-color-action', '--sf-color-neutral', '--sf-color-base', + '--sf-color-success', '--sf-color-warning', '--sf-color-error', + '--sf-color-info', '--sf-color-danger', + '--sf-color-bg', '--sf-color-inset', '--sf-color-raised', + '--sf-color-text', '--sf-color-text--secondary', '--sf-color-heading', + '--sf-color-border', '--sf-color-border--subtle', '--sf-color-border--strong', + '--sf-color-link', '--sf-color-selection-bg', + '--sf-color-success-strong', '--sf-color-warning-strong', + '--sf-color-error-strong', '--sf-color-info-strong', '--sf-color-danger-strong', + '--sf-shadow-xs', '--sf-shadow-s', '--sf-shadow-m', '--sf-shadow-l', +]; + +// Only run on Chromium (the reference Modern_Engine). +// Firefox/WebKit may resolve oklch differently and are excluded here. +test.skip(({ browserName }) => browserName !== 'chromium', 'P8 is Chromium-only'); + +async function captureValues(page, theme) { + await page.goto(FIXTURE); + if (theme === 'dark') { + await page.evaluate(() => document.documentElement.setAttribute('data-theme', 'dark')); + } else { + await page.evaluate(() => document.documentElement.removeAttribute('data-theme')); + } + await page.evaluate(() => document.fonts.ready); + + return page.evaluate((tokens) => { + const style = getComputedStyle(document.documentElement); + const result = {}; + for (const t of tokens) { + result[t] = style.getPropertyValue(t).trim(); + } + return result; + }, TIER1_TOKENS); +} + +test.describe('P8: Modern-engine computed values match baseline', () => { + test('light mode — Tier-1 tokens match baseline', async ({ page }) => { + const captureMode = process.env.BASELINE_CAPTURE === '1'; + const values = await captureValues(page, 'light'); + + if (captureMode) { + const existing = fs.existsSync(BASELINE) + ? JSON.parse(fs.readFileSync(BASELINE, 'utf8')) + : {}; + existing.light = values; + fs.writeFileSync(BASELINE, JSON.stringify(existing, null, 2) + '\n', 'utf8'); + console.log('Baseline (light) written to', BASELINE); + return; + } + + if (!fs.existsSync(BASELINE)) { + throw new Error( + 'tests/baseline-modern.json not found. ' + + 'Generate it with: BASELINE_CAPTURE=1 npx playwright test tests/tier1-p8-modern.spec.js --project=chromium' + ); + } + + const baseline = JSON.parse(fs.readFileSync(BASELINE, 'utf8')); + expect(baseline.light, 'Baseline missing light section').toBeTruthy(); + + for (const token of TIER1_TOKENS) { + const actual = values[token]; + const expected = baseline.light[token]; + expect(expected, `Baseline missing token ${token} (light) — regenerate with BASELINE_CAPTURE=1`).toBeTruthy(); + expect(actual, `${token} (light)`).toBe(expected); + } + }); + + test('dark mode — Tier-1 tokens match baseline', async ({ page }) => { + const captureMode = process.env.BASELINE_CAPTURE === '1'; + const values = await captureValues(page, 'dark'); + + if (captureMode) { + const existing = fs.existsSync(BASELINE) + ? JSON.parse(fs.readFileSync(BASELINE, 'utf8')) + : {}; + existing.dark = values; + fs.writeFileSync(BASELINE, JSON.stringify(existing, null, 2) + '\n', 'utf8'); + console.log('Baseline (dark) written to', BASELINE); + return; + } + + if (!fs.existsSync(BASELINE)) { + throw new Error( + 'tests/baseline-modern.json not found. ' + + 'Generate it with: BASELINE_CAPTURE=1 npx playwright test tests/tier1-p8-modern.spec.js --project=chromium' + ); + } + + const baseline = JSON.parse(fs.readFileSync(BASELINE, 'utf8')); + expect(baseline.dark, 'Baseline missing dark section').toBeTruthy(); + + for (const token of TIER1_TOKENS) { + const actual = values[token]; + const expected = baseline.dark[token]; + expect(expected, `Baseline missing token ${token} (dark) — regenerate with BASELINE_CAPTURE=1`).toBeTruthy(); + expect(actual, `${token} (dark)`).toBe(expected); + } + }); +});