diff --git a/badges/badge-optimal.json b/badges/badge-optimal.json
index 99f96683..46b854cc 100644
--- a/badges/badge-optimal.json
+++ b/badges/badge-optimal.json
@@ -1,7 +1,7 @@
{
"schemaVersion": 1,
"label": "optimal",
- "message": "19.4 kB gzip",
+ "message": "19.5 kB gzip",
"color": "brightgreen",
"namedLogo": "css3"
}
diff --git a/configurator/src/lib/preview/catalog.ts b/configurator/src/lib/preview/catalog.ts
index a48e6f60..939d32bf 100644
--- a/configurator/src/lib/preview/catalog.ts
+++ b/configurator/src/lib/preview/catalog.ts
@@ -32,6 +32,13 @@ export interface ApiClass {
export const TOKENS: ApiToken[] = (apiIndex as { tokens: ApiToken[] }).tokens;
export const CLASSES: ApiClass[] = (classIndex as { classes: ApiClass[] }).classes;
+/** Solid-colour gradient families (--sf-gradient-*, excluding the directional
+ * --sf-gradient-fade--* and --sf-scrim-gradient). Shared between sections.ts
+ * (which renders the swatches) and skin.ts (which generates the
+ * .pv-grad--{family} rules they use) — the two must stay in lockstep or a
+ * swatch silently renders blank. */
+export const GRADS = ["primary", "secondary", "tertiary", "action", "brand", "surface"];
+
export const isPublic = (t: ApiToken) => t.tier === "PUBLIC";
export const isAdvanced = (t: ApiToken) => t.tier === "PUBLIC-ADVANCED";
diff --git a/configurator/src/lib/preview/sections.ts b/configurator/src/lib/preview/sections.ts
index 273aa5bd..47cbda6c 100644
--- a/configurator/src/lib/preview/sections.ts
+++ b/configurator/src/lib/preview/sections.ts
@@ -4,12 +4,11 @@
// Foundations enumerate the real API via ./catalog; components are curated.
import { page, section, specimen, tag, cluster, grid, stack, well, frame, esc } from "./specimen";
-import { familySteps, classesOfKind, tokensMatching, tokensByGroup, classesByKind } from "./catalog";
+import { familySteps, classesOfKind, tokensMatching, tokensByGroup, classesByKind, GRADS } from "./catalog";
const RAMP_STEPS = [50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950];
const RAMP_FAMILIES = ["primary", "secondary", "tertiary", "action", "base", "neutral"];
const STATUS = ["success", "warning", "danger", "info"];
-const GRADS = ["primary", "brand", "tertiary", "surface"];
const rampRow = (family: string) =>
specimen(
@@ -35,7 +34,7 @@ export function color(): string {
const surfaces = grid(
10,
...surfaceTokens.map((name) =>
- specimen(name, `
${macroClasses
- .map((c) => `
${esc(c.selector)}`)
+
+ // Prose, not-prose & flow — real rhythm, not a swatch.
+ const prose = well(`
+
Article heading
+
Paragraphs get automatic vertical rhythm from .sf-prose — no utility classes needed between them.
+
+
+
.sf-not-prose opts this box out of the prose rhythm above.
+
+
…and rhythm resumes after it.
+
`);
+ const flow = well(`
+
One
+
Two — margin-block-start
+
Three — margin-block-start
+
`);
+
+ // Truncation — one line, then 2/3/N-line clamps, all on the same copy.
+ const truncation = grid(
+ 14,
+ specimen("sf-truncate", `
${LOREM_MACRO}
`),
+ specimen("sf-line-clamp-2", `
${LOREM_MACRO}
`),
+ specimen("sf-line-clamp-3", `
${LOREM_MACRO}
`),
+ specimen(
+ "sf-line-clamp-N (--sf-line-clamp:5)",
+ `
${LOREM_MACRO} ${LOREM_MACRO}
`,
+ ),
+ );
+
+ // Equal height — three unevenly-tall children forced level.
+ const equalHeight = well(
+ `
Short
Two
lines
Three
lines
here
`,
+ );
+
+ // Aspect ratio — content-agnostic, distinct from the .sf-frame primitive.
+ const aspect = well(
+ `
${["16 / 9", "1 / 1", "4 / 3"]
+ .map(
+ (r) =>
+ `
${tag(`--sf-aspect:${r}`)}
`,
+ )
.join("")}
`,
);
+
+ // Tabular numbers — a real numeric column so digits visibly align.
+ const tabularNums = well(`
+
+ | Invoice #1 | 1,024.50 |
+ | Invoice #82 | 398.00 |
+ | Invoice #913 | 12,760.75 |
+
+
`);
+
+ // Links — external marker + underline affordances, as real anchors.
+ const links = well(`
`);
+
+ // Drop shadows — filter: drop-shadow() follows alpha shape, so a ring
+ // (mask-cut circle) shows the hole surviving the shadow, unlike box-shadow.
+ const dropShadows = grid(
+ 9,
+ ...(["xs", "s", "m", "l", "xl"] as const).map((s) =>
+ specimen(
+ `sf-drop-shadow-${s}`,
+ `
`,
+ ),
+ ),
+ );
+
+ // Scrolling — shadow mask, snap, and content-visibility, all with real
+ // overflowing content so the effect is actually observable.
+ const scrollShadow = well(
+ `
`,
+ );
+ const scrollSnap = well(
+ `
`,
+ );
+ const contentAuto = well(
+ `
content-visibility:auto — off-screen instances of this section skip layout/paint until scrolled near.
`,
+ );
+
+ // Overflow fade — genuinely overflowing content in the axis each variant
+ // fades: a single-line nowrap row for the horizontal/inline variants, and
+ // a fixed-height column for the vertical/block variants. flex-wrap must
+ // be forced off on the row: .sf-cluster wraps by default, which would
+ // just break it onto multiple lines instead of overflowing it.
+ const fadeRow = () =>
+ `
${Array.from(
+ { length: 10 },
+ (_, i) => `${i + 1}`,
+ ).join("")}
`;
+ const fadeColumn = () =>
+ `
${Array.from(
+ { length: 6 },
+ (_, i) => `${i + 1}`,
+ ).join("")}
`;
+ const overflowFade = grid(
+ 11,
+ ...([
+ ["", fadeRow],
+ ["--right", fadeRow],
+ ["--left", fadeRow],
+ ["--inline", fadeRow],
+ ["--top", fadeColumn],
+ ["--bottom", fadeColumn],
+ ["--block", fadeColumn],
+ ] as const).map(([m, shape]) => specimen(`sf-overflow-fade${m}`, `
${shape()}
`)),
+ );
+
+ // Scrim, surface-bg & text-protect — text over a token-driven gradient
+ // "photo" (no external asset needed).
+ const photo = "var(--sf-gradient-brand, var(--sf-color-primary))";
+ const scrimCard = (mods: string, label: string) =>
+ `
`;
+ const scrims = grid(
+ 12,
+ specimen("sf-scrim sf-scrim--bottom", scrimCard("sf-scrim--bottom", "Legible headline")),
+ specimen("sf-scrim sf-scrim--top", scrimCard("sf-scrim--top", "Legible headline")),
+ specimen("sf-scrim sf-scrim--full", scrimCard("sf-scrim--full", "Legible headline")),
+ );
+ const surfaceBg = well(
+ `
sf-surface-bg — named background preset
`,
+ );
+ const textProtect = `
+
Readable over a photo
+ `;
+
+ // Text gradient.
+ const textGradient = well(`
Gradient headline
`);
+
+ // Corner scoop — the notch reveals whatever sits behind the element, so
+ // the card sits on a contrasting frame.
+ const scoopCard = (mods: string) =>
+ `
`;
+ const cornerScoop = frame(
+ `
${[
+ ["sf-corner-scoop", "default (top-right)"],
+ ["sf-corner-scoop sf-corner-scoop--top-left", "top-left"],
+ ["sf-corner-scoop sf-corner-scoop--bottom-left", "bottom-left"],
+ ["sf-corner-scoop sf-corner-scoop--bottom-right", "bottom-right"],
+ ]
+ .map(([mods, label]) => specimen(label, scoopCard(mods)))
+ .join("")}
`,
+ );
+
+ // Overlap — an avatar pulled down over the card that follows it
+ // (.sf-overlap--down; the plain .sf-overlap pulls UP onto whatever
+ // precedes it instead — not what a leading avatar needs here), plus the
+ // remaining directional variants on their own swatches.
+ const avatar = `
`;
+ const overlapHost = frame(`
+
${avatar}
+
.sf-overlap-host reserves space so in-flow content clears the intruding avatar above.
+
`);
+ const overlapDirections = grid(
+ 10,
+ ...([
+ ["", "sf-overlap (pulls up)"],
+ ["--start", "sf-overlap--start"],
+ ["--end", "sf-overlap--end"],
+ ] as const).map(([m, label]) =>
+ specimen(label, `
`),
+ ),
+ );
+
+ // No-tap-highlight — no visual difference to show (it only suppresses the
+ // mobile tap-highlight overlay); still applied to a real element for
+ // coverage rather than left to a text mention.
+ const noTapHighlight = well(
+ `
sf-no-tap-highlight — suppresses the mobile tap-highlight overlay (no on-screen difference here)`,
+ );
+
return page(
"Macros & utilities",
- "Surface macros and the full set of shipped macro utility classes.",
- section("Surfaces (sf-surface--*)", surfaces),
- section("All macro classes", list, `${macroClasses.length} classes in @layer slashed.macros`),
+ "Every shipped macro (@layer slashed.macros), applied for real — not just named.",
+ section("Surfaces (sf-surface, sf-surface--*)", surfaces),
+ section("Prose, not-prose & flow", well(stack("m", prose, flow))),
+ section("Truncation (sf-truncate, sf-line-clamp-*)", truncation),
+ section("Equal height (sf-equal-height)", equalHeight),
+ section("Aspect ratio (sf-aspect)", aspect),
+ section("Tabular numbers (sf-tabular-nums)", tabularNums),
+ section("Links (sf-link-external, sf-link--subtle, sf-link--reverse)", links),
+ section("Drop shadows (sf-drop-shadow-*)", dropShadows, "Alpha-following filter: drop-shadow() — note the shadow survives the masked hole."),
+ section("Scroll shadow (sf-scroll-shadow)", scrollShadow),
+ section("Scroll snap (sf-scroll-snap)", scrollSnap),
+ section("Content-visibility (sf-content-auto)", contentAuto),
+ section("Overflow fade (sf-overflow-fade + edge/axis modifiers)", overflowFade),
+ section("Scrim over media (sf-scrim, sf-scrim--*, sf-scrim__content)", scrims),
+ section("Named background preset (sf-surface-bg)", surfaceBg),
+ section("Text protect (sf-text-protect)", well(textProtect)),
+ section("Text gradient (sf-text-gradient)", textGradient),
+ section("Corner scoop (sf-corner-scoop + corner modifiers)", cornerScoop),
+ section("Overlap (sf-overlap, sf-overlap-host)", well(stack("m", overlapHost, overlapDirections))),
+ section("No tap highlight (sf-no-tap-highlight)", noTapHighlight),
);
}
diff --git a/configurator/src/lib/preview/skin.ts b/configurator/src/lib/preview/skin.ts
index 1f58e478..f451ce0f 100644
--- a/configurator/src/lib/preview/skin.ts
+++ b/configurator/src/lib/preview/skin.ts
@@ -5,6 +5,8 @@
// references a live --sf-* token, so configurator overrides drive these exactly
// like the framework's own classes — the whole gallery reacts to every change.
+import { GRADS } from "./catalog";
+
const RAMP_STEPS = [50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950];
const RAMP_FAMILIES = ["primary", "secondary", "tertiary", "action", "base", "neutral"];
const STATUS = ["success", "warning", "danger", "info"];
@@ -12,7 +14,6 @@ const RADII = ["xs", "s", "m", "l", "xl", "2xl", "full"];
const SHADOWS = ["xs", "s", "m", "l", "xl", "2xl"];
const SPACES = ["3xs", "2xs", "xs", "s", "m", "l", "xl", "2xl", "3xl", "4xl"];
const TYPE_STEPS = ["display-l", "display-m", "display-s", "2xl", "xl", "l", "m", "s", "xs"];
-const GRADS = ["primary", "brand", "tertiary", "surface"];
export function previewSkinCSS(): string {
const rampClass = (family: string) =>
@@ -45,7 +46,7 @@ export function previewSkinCSS(): string {
/* Status chips + gradients */
.pv-chip{display:block;inline-size:100%;block-size:44px;border-radius:var(--sf-radius-s);}
${STATUS.map((s) => `.pv-chip--${s}{background:var(--sf-color-${s});}`).join("")}
- .pv-grad{block-size:44px;border-radius:var(--sf-radius-m);border:var(--sf-border-width-1) var(--sf-border-style) var(--sf-color-border);}
+ .pv-grad{inline-size:100%;block-size:44px;border-radius:var(--sf-radius-m);border:var(--sf-border-width-1) var(--sf-border-style) var(--sf-color-border);}
${GRADS.map((g) => `.pv-grad--${g}{background:var(--sf-gradient-${g});}`).join("")}
/* Radius + shadow demo boxes */
.pv-demo-box{inline-size:56px;block-size:56px;background:var(--sf-color-primary-100);border:2px solid var(--sf-color-primary-300);}
@@ -62,8 +63,13 @@ export function previewSkinCSS(): string {
/* Generic filled block for layout-primitive demos (stack/cluster/grid gaps) */
.pv-box{background:var(--sf-color-primary-100);border:var(--sf-border-width-1) var(--sf-border-style) var(--sf-color-primary-300);border-radius:var(--sf-radius-s);padding:var(--sf-space-s);color:var(--sf-color-primary-700);font-size:var(--sf-text-s);font-family:var(--sf-font-mono);text-align:center;min-inline-size:3rem;}
.pv-box--tall{min-block-size:4rem;display:flex;align-items:center;justify-content:center;}
- /* Motion demo tile */
- .pv-motion-tile{inline-size:100%;block-size:56px;background:var(--sf-gradient-primary);border-radius:var(--sf-radius-m);}
+ /* Motion demo tile — "exit"/fade-out/scale-down presets end their real,
+ one-shot animation at opacity:0 (animation-fill-mode:both), which is
+ correct for actual exit transitions but would leave the tile blank
+ forever in a static gallery. Force infinite alternate playback here
+ (unlayered, so it beats @layer slashed.motion) purely so every tile
+ stays visible and keeps demonstrating its motion. */
+ .pv-motion-tile{inline-size:100%;block-size:56px;background:var(--sf-gradient-primary);border-radius:var(--sf-radius-m);animation-iteration-count:infinite !important;animation-direction:alternate !important;}
/* Tinted card variants (demo-only; unlayered so they beat .sf-card's layer) */
.pv-card--primary{background:var(--sf-color-primary);color:var(--sf-color-text--on-primary);}
.pv-card--soft{background:var(--sf-color-primary-100);color:var(--sf-color-primary-700);}`;
diff --git a/core/layout.css b/core/layout.css
index 6d6e1f9f..8fff2eeb 100644
--- a/core/layout.css
+++ b/core/layout.css
@@ -43,16 +43,18 @@
}
.sf-divider {
+ inline-size: 100%;
border: none;
border-block-start: var(--sf-divider-width) var(--sf-divider-style) var(--sf-divider-color);
margin-block: var(--sf-divider-gap);
}
.sf-divider--vertical {
- border-block-start: none;
- border-inline-start: var(--sf-divider-width) var(--sf-divider-style) var(--sf-divider-color);
- margin-block: 0;
- margin-inline: var(--sf-divider-gap);
- align-self: stretch;
+ inline-size: auto;
+ border-block-start: none;
+ border-inline-start: var(--sf-divider-width) var(--sf-divider-style) var(--sf-divider-color);
+ margin-block: 0;
+ margin-inline: var(--sf-divider-gap);
+ align-self: stretch;
}
.sf-divider--soft { --sf-divider-color: var(--sf-color-border--subtle); }
diff --git a/tests/layout.spec.js b/tests/layout.spec.js
index b8f67d83..a6d220f3 100644
--- a/tests/layout.spec.js
+++ b/tests/layout.spec.js
@@ -534,6 +534,24 @@ test.describe('layout: .sf-divider', () => {
expect(cs.inline).toBeGreaterThan(0);
expect(cs.block).toBe(0);
});
+
+ test('spans its container width in normal (block) flow', async ({ page }) => {
+ await setup(page, `
`);
+ const w = await page.locator('#t').evaluate(el => el.getBoundingClientRect().width);
+ expect(w).toBeGreaterThan(300);
+ });
+
+ test('--vertical stays a narrow rule inside a flex row, not stretched to the row width', async ({ page }) => {
+ await setup(page, `
+
+ Left
+
+ Right
+
+ `);
+ const w = await page.locator('#t').evaluate(el => el.getBoundingClientRect().width);
+ expect(w).toBeLessThan(20);
+ });
});
// ── .sf-divide ──────────────────────────────────────────────────