Skip to content
Merged
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
## [0.7.19] - 2026-07-16

### Bug Fixes
- **tokens:** `--sf-color-info-subtle` and `--sf-color-danger-subtle` were `0.10` alpha while `--sf-color-success-subtle` / `--sf-color-warning-subtle` were `0.12`, so a `danger`/`info` alert-box background rendered slightly fainter than a `success`/`warning` one at the same content. Unified all four status `-subtle` washes to `0.12` (the `-muted` `0.3` and `-tint` `0.05` rungs were already consistent).
- **tokens:** the numeric brand ramps (`--sf-color-{primary,secondary,tertiary,action,neutral}-50…950`) no longer fold into a "U". The previous model generated each step with `color-mix()` toward the theme's `--sf-color-surface` (tints) / `--sf-color-text` (shades) — so when a chosen source colour fell outside the band between those two anchors (e.g. a brand darker than the text colour, or a project that lightened `--sf-color-neutral`), the shade half inverted and climbed back up: steps 600–950 came out *lighter* than 500, and 50 ≈ 950. The ramp is now generated in OKLCH by pulling the family colour's own lightness a fixed fraction toward an **absolute** target — `--sf-palette-tint-l` (tints) / `--sf-palette-shade-l` (shades) — clamped with `max()`/`min()` so a tint can never end darker than the base nor a shade lighter. The ladder is therefore monotonic light→dark for **any** source colour and any anchor configuration. Chroma is tapered toward the extremes to avoid oversaturation. For default-palette colours (which already sat inside the safe band) the output is visually unchanged; only out-of-band / moved-anchor cases are corrected. `base` is unchanged (it already used absolute lightness steps). New parametric test `tests/ramp-monotonic.spec.js` sweeps the lightness axis for all five families and asserts the ramp never folds and no step is transparent.
- **configurator:** the Colors panel's "Palette ramp" section now edits the two lightness anchors (`--sf-palette-tint-l` / `--sf-palette-shade-l`) with two `0–1` sliders instead of the ten removed `--sf-palette-mix-*` percentage sliders; the Softer/Default/Punchy presets and the live palette preview were rewired to the absolute-anchor model so the preview matches the framework and stays monotonic.
- **layout:** `@container` breakpoints for `.sf-grid-cols-*`, `.sf-bento` and `.sf-alternate` now use `rem` instead of `em`. Inside a container query an `em` resolves against the query container's own font-size, which under the default fluid type scale is inherited from `body` (a `clamp()` over `100vw`, ~16–20px) — so the same fixed-width container rendered a different column count depending on the *viewport*: an 800px container showed `.sf-grid-cols-3` as 3 columns at a 500px viewport but 1 column at 1200px+. `rem` resolves against the fixed root (`html` stays 16px; fluid type is applied to `body`), restoring the documented "responds to its own container width, not the viewport" contract. Thresholds are unchanged numerically (30rem = 480px, 48rem = 768px) and still honour user zoom / root-font-size. Also corrects `docs/architecture.md`, which wrongly listed `.sf-grid` / `.sf-sidebar` as `@container` primitives (both are intrinsic and carry no container query).

### Breaking Changes
- **tokens:** removed the `--sf-palette-mix-50…950` ramp-shape knobs (color-mix percentages of the old model) and replaced them with two lightness-anchor knobs — `--sf-palette-tint-l` (default `0.97`) and `--sf-palette-shade-l` (default `0.1`) — which set how light the lightest / how dark the darkest generated palette step may reach. Per-step pull fraction and chroma taper are now baked into each family. If you overrode `--sf-palette-mix-*` to reshape the ramp, retune via the two new anchors instead (or override individual `--sf-color-{family}-{step}` resolved tokens).

## [0.7.18] - 2026-07-16

### Features
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

<p align="center">
A cascade-layer CSS framework you never compile.<br>
754 design tokens, automatic dark mode, fluid type &amp; spacing — no build step, no Node, no JavaScript.
746 design tokens, automatic dark mode, fluid type &amp; spacing — no build step, no Node, no JavaScript.
</p>

<p align="center">
Expand Down Expand Up @@ -67,7 +67,7 @@ Every letter of SLASHED is a design commitment, and each one is checkable agains

## What's inside

- **754 design tokens** (`--sf-*`) covering color, typography, spacing, layout, borders, shadows, motion, and z-index — catalogued in a machine-readable [API index](docs/api-index.json).
- **746 design tokens** (`--sf-*`) covering color, typography, spacing, layout, borders, shadows, motion, and z-index — catalogued in a machine-readable [API index](docs/api-index.json).
- **Layout primitives** — `.sf-container`, `.sf-stack`, `.sf-grid`, `.sf-cluster`, `.sf-sidebar`, `.sf-switcher`, `.sf-cover`, `.sf-center`, `.sf-frame`, `.sf-reel`, `.sf-bento`, and more ([docs/layout.md](docs/layout.md)).
- **Macros** — recipes like `.sf-prose`, `.sf-flow`, `.sf-truncate`, `.sf-aspect`, `.sf-scroll-shadow`, `.sf-surface` ([docs/macros.md](docs/macros.md)).
- **State classes** — `.sf-is-*` classes mapped to ARIA semantics ([docs/states.md](docs/states.md)).
Expand Down
2 changes: 1 addition & 1 deletion badges/badge-optimal.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"schemaVersion": 1,
"label": "optimal",
"message": "19.5 kB gzip",
"message": "19.6 kB gzip",
"color": "brightgreen",
"namedLogo": "css3"
}
147 changes: 66 additions & 81 deletions configurator/src/components/panels/ColorsPanel.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -109,41 +109,42 @@
{ name: "--sf-color-danger-source-dark", label: "Danger", side: "dark", default: "oklch(0.73 0.198 12)", colorKey: "danger" },
];

const MIX_STEPS = [
{ name: "--sf-palette-mix-50", label: "50", step: "tint", default: 4 },
{ name: "--sf-palette-mix-100", label: "100", step: "tint", default: 8 },
{ name: "--sf-palette-mix-200", label: "200", step: "tint", default: 20 },
{ name: "--sf-palette-mix-300", label: "300", step: "tint", default: 40 },
{ name: "--sf-palette-mix-400", label: "400", step: "tint", default: 65 },
{ name: "--sf-palette-mix-600", label: "600", step: "shade", default: 82 },
{ name: "--sf-palette-mix-700", label: "700", step: "shade", default: 62 },
{ name: "--sf-palette-mix-800", label: "800", step: "shade", default: 38 },
{ name: "--sf-palette-mix-900", label: "900", step: "shade", default: 18 },
{ name: "--sf-palette-mix-950", label: "950", step: "shade", default: 8 },
// Baked per-step ramp curve — mirrors core/tokens.css: how far each step
// pulls its lightness toward the absolute anchor, plus a chroma taper.
// Preview-only (the shape is fixed in the framework); the two anchors below
// are the tunable public knobs.
const RAMP_CURVE = [
{ label: "50", step: "tint", frac: 0.95, taper: 0.20 },
{ label: "100", step: "tint", frac: 0.88, taper: 0.30 },
{ label: "200", step: "tint", frac: 0.72, taper: 0.50 },
{ label: "300", step: "tint", frac: 0.52, taper: 0.72 },
{ label: "400", step: "tint", frac: 0.30, taper: 0.88 },
{ label: "600", step: "shade", frac: 0.16, taper: 0.96 },
{ label: "700", step: "shade", frac: 0.36, taper: 0.88 },
{ label: "800", step: "shade", frac: 0.58, taper: 0.72 },
{ label: "900", step: "shade", frac: 0.78, taper: 0.52 },
{ label: "950", step: "shade", frac: 0.90, taper: 0.38 },
] as const;

// The two PUBLIC-ADVANCED ramp anchors — absolute OKLCH lightness the tint
// (50–400) / shade (600–950) halves reach toward. Unitless 0–1.
const ANCHOR_KNOBS = [
{ name: "--sf-palette-tint-l", label: "Lightest step (L)", default: 0.97 },
{ name: "--sf-palette-shade-l", label: "Darkest step (L)", default: 0.1 },
];

const CURVE_PRESETS = [
{
label: "Softer", desc: "Low contrast palette",
patch: {
"--sf-palette-mix-50": "3%", "--sf-palette-mix-100": "6%", "--sf-palette-mix-200": "14%",
"--sf-palette-mix-300": "30%", "--sf-palette-mix-400": "55%",
"--sf-palette-mix-600": "72%", "--sf-palette-mix-700": "52%",
"--sf-palette-mix-800": "28%", "--sf-palette-mix-900": "12%", "--sf-palette-mix-950": "5%",
},
patch: { "--sf-palette-tint-l": "0.93", "--sf-palette-shade-l": "0.2" },
},
{
label: "Default", desc: "Balanced contrast",
patch: Object.fromEntries(MIX_STEPS.map((s) => [s.name, null])) as Record<string, null>,
patch: Object.fromEntries(ANCHOR_KNOBS.map((k) => [k.name, null])) as Record<string, null>,
},
{
label: "Punchy", desc: "High contrast palette",
patch: {
"--sf-palette-mix-50": "6%", "--sf-palette-mix-100": "12%", "--sf-palette-mix-200": "28%",
"--sf-palette-mix-300": "52%", "--sf-palette-mix-400": "78%",
"--sf-palette-mix-600": "90%", "--sf-palette-mix-700": "72%",
"--sf-palette-mix-800": "50%", "--sf-palette-mix-900": "26%", "--sf-palette-mix-950": "12%",
},
patch: { "--sf-palette-tint-l": "0.98", "--sf-palette-shade-l": "0.05" },
},
];

Expand Down Expand Up @@ -325,10 +326,11 @@
)
));

function getMixValue(s: typeof MIX_STEPS[0]): number {
const raw = overrides[s.name];
if (!raw) return s.default;
return parseFloat(raw);
function getAnchor(name: string, def: number): number {
const raw = overrides[name];
if (raw === undefined) return def;
const n = parseFloat(raw);
return Number.isFinite(n) ? n : def;
}

// Build brand pairs: group by colorKey then render light/dark
Expand Down Expand Up @@ -382,17 +384,22 @@
return stringifyOklch(Math.min(1.0, Math.max(l + 0.22, 0.88)), c * 0.8, h);
}

// Build a concrete color-mix() expression and resolve it via the regular preview probe.
// Bypasses the themed probe entirely — produces correct dark tints/shades from literal values.
function computePaletteSwatch(sourceColor: string, step: string, surface: string, text: string): string {
// Preview one brand palette step with the framework's absolute-anchor model
// (core/tokens.css): pull the source lightness a fixed fraction toward
// --sf-palette-tint-l (tints) / --sf-palette-shade-l (shades), clamped with
// max()/min() so it can't fold, with a chroma taper. Independent of
// surface/text — the ramp no longer mixes toward them.
function computePaletteSwatch(sourceColor: string, step: string): string {
void previewVersion.value;
if (step === "500") return resolveColor(sourceColor) || sourceColor;
const isTint = parseInt(step) < 500;
const mixStepDef = MIX_STEPS.find((s) => s.label === step);
if (!mixStepDef) return sourceColor;
const pct = getMixValue(mixStepDef);
const endpoint = isTint ? surface : text;
const expr = `color-mix(in oklab, ${sourceColor} ${pct}%, ${endpoint})`;
const def = RAMP_CURVE.find((s) => s.label === step);
if (!def) return sourceColor;
const isTint = def.step === "tint";
const target = isTint
? getAnchor("--sf-palette-tint-l", 0.97)
: getAnchor("--sf-palette-shade-l", 0.1);
const clamp = isTint ? "max" : "min";
const expr = `oklch(from ${sourceColor} ${clamp}(l, calc(l + (${target} - l) * ${def.frac})) calc(c * ${def.taper}) h)`;
return resolveColor(expr) || expr;
}

Expand All @@ -409,10 +416,10 @@

// Route each color to the correct palette system: base uses the fixed-L ramp,
// every other key uses the color-mix tint/shade curve toward surface/text.
function paletteSwatch(colorKey: string, sourceColor: string, step: string, surface: string, text: string): string {
function paletteSwatch(colorKey: string, sourceColor: string, step: string, _surface: string, _text: string): string {
return colorKey === "base"
? computeBasePaletteSwatch(sourceColor, step)
: computePaletteSwatch(sourceColor, step, surface, text);
: computePaletteSwatch(sourceColor, step);
}

function handleLightChange(light: ColorSource, dark: ColorSource | undefined, newVal: string) {
Expand Down Expand Up @@ -840,7 +847,7 @@
aria-expanded={showShadeCurve}
class="w-full flex items-center justify-between cursor-pointer"
>
<div class="text-[10px] font-bold text-slate-500 uppercase tracking-widest">Shade / tint curve</div>
<div class="text-[10px] font-bold text-slate-500 uppercase tracking-widest">Palette ramp</div>
<span class="text-[10px] text-slate-500">{showShadeCurve ? "▲" : "▼"}</span>
</button>
{#if showShadeCurve}
Expand All @@ -850,12 +857,10 @@
? (BRAND_SOURCES.find(s => s.name === "--sf-color-primary-source-dark")?.default ?? deriveDarkFromLight(_curvePrimLightSrc, "primary"))
: deriveDarkFromLight(_curvePrimLightSrc, "primary"))}
{@const _curvePrimSrc = curvePreviewSide === "light" ? _curvePrimLightSrc : _curvePrimDarkSrc}
{@const _curveSurface = curvePreviewSide === "light" ? getLightSurface() : getDarkSurface()}
{@const _curveText = curvePreviewSide === "light" ? getLightText() : getDarkText()}
{@const _miniSurface = curvePreviewSide === "light" ? getLightSurface() : getDarkSurface()}
{@const _miniText = curvePreviewSide === "light" ? getLightText() : getDarkText()}
<p class="text-[10px] text-slate-400 dark:text-slate-600 leading-relaxed">
Controls how much color is mixed into each palette step. Step 500 is always the source color.
Set how light the lightest step and how dark the darkest step reach; every
step in between is derived automatically and stays light→dark for any
colour. Step 500 is always the source colour.
</p>

<div class="grid grid-cols-3 gap-1.5">
Expand All @@ -874,35 +879,18 @@
{/each}
</div>

<!-- Tints -->
<!-- Lightness anchors -->
<div>
<div class="text-[10px] font-semibold text-slate-600 dark:text-slate-400 mb-2">Tints (50–400)mixed toward surface</div>
<div class="text-[10px] font-semibold text-slate-600 dark:text-slate-400 mb-2">Ramp anchorsabsolute OKLCH lightness</div>
<div class="space-y-2">
{#each MIX_STEPS.filter((s) => s.step === "tint") as s (s.name)}
{#each ANCHOR_KNOBS as k (k.name)}
<SliderRow
label={s.label}
value={getMixValue(s)}
min={0} max={100} step={1} unit="%"
overridden={s.name in overrides}
onChange={(v) => onSet(s.name, `${v}%`)}
onReset={() => onReset(s.name)}
/>
{/each}
</div>
</div>

<!-- Shades -->
<div>
<div class="text-[10px] font-semibold text-slate-600 dark:text-slate-400 mb-2">Shades (600–950) — mixed toward text</div>
<div class="space-y-2">
{#each MIX_STEPS.filter((s) => s.step === "shade") as s (s.name)}
<SliderRow
label={s.label}
value={getMixValue(s)}
min={0} max={100} step={1} unit="%"
overridden={s.name in overrides}
onChange={(v) => onSet(s.name, `${v}%`)}
onReset={() => onReset(s.name)}
label={k.label}
value={getAnchor(k.name, k.default)}
min={0} max={1} step={0.01} unit=""
overridden={k.name in overrides}
onChange={(v) => onSet(k.name, `${v}`)}
onReset={() => onReset(k.name)}
/>
{/each}
</div>
Expand All @@ -925,22 +913,19 @@
</div>
</div>
<div class="flex items-end gap-0.5 h-20">
{#each MIX_STEPS as s (s.name)}
{@const val = getMixValue(s)}
{@const step = s.label}
{@const swatch = computePaletteSwatch(_curvePrimSrc, step, _curveSurface, _curveText)}
{#each SWATCH_STEPS as step (step)}
{@const swatch = computePaletteSwatch(_curvePrimSrc, step)}
<div class="flex-1 flex flex-col items-center gap-0.5 h-full justify-end">
<span class="text-[7px] font-mono text-slate-400 dark:text-slate-600">{Math.round(val)}</span>
<div
class="group relative w-full rounded-sm border-x border-t border-black/10 dark:border-white/10"
style={`background:${swatch}; height: ${Math.max((val / 100) * 56, 6)}px`}
title={`primary-${step} (${curvePreviewSide}) · mix ${Math.round(val)}%`}
class="group relative w-full h-full rounded-sm border-x border-t border-black/10 dark:border-white/10"
style={`background:${swatch}`}
title={`primary-${step} (${curvePreviewSide})`}
>{@render swatchTip(`primary-${step}`)}</div>
<span class="text-[7px] font-mono text-slate-400 dark:text-slate-600">{step}</span>
</div>
{/each}
</div>
<p class="text-[8px] text-slate-400 dark:text-slate-600 mt-2">Bar height = mix amount · fill = the resolved primary palette swatch at each step</p>
<p class="text-[8px] text-slate-400 dark:text-slate-600 mt-2">Live primary palette, 50 → 950 — always monotonic light→dark</p>
</div>

<!-- Mini palettes: all brand colors × all steps, reactive to light/dark toggle -->
Expand Down Expand Up @@ -969,7 +954,7 @@
<span class="text-[7px] font-mono text-slate-400 dark:text-slate-600 w-12 shrink-0 truncate">{colorKey}</span>
<div class="flex gap-0.5 flex-1">
{#each SWATCH_STEPS as step (step)}
{@const swatch = computePaletteSwatch(miniSrc, step, _miniSurface, _miniText)}
{@const swatch = computePaletteSwatch(miniSrc, step)}
<div
class="group relative flex-1 h-4 rounded-sm border border-black/10 dark:border-white/10"
style={`background:${swatch}`}
Expand Down
Loading