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, `
`), + specimen(name, `
`), ), ); const onColor = grid( @@ -59,7 +58,7 @@ export function color(): string { ...["border", "border--subtle", "border--strong"].map((b) => specimen( `--sf-color-${b}`, - `
`, + `
`, ), ), ); @@ -299,15 +298,24 @@ export function layout(): string { ), ); const bentoDemo = specimen( - "sf-bento", - `
${["a", "b", "c", "d", "e"].map((t) => `
${t}
`).join("")}
`, + "sf-bento — sf-bento-featured / -wide / -tall span modifiers", + `
${[ + ["sf-bento-featured", "featured (2×2)"], + ["", "b"], + ["", "c"], + ["sf-bento-wide", "wide (2×1)"], + ["sf-bento-tall", "tall (1×2)"], + ["", "f"], + ] + .map(([mods, t]) => `
${esc(t)}
`) + .join("")}
`, ); const frameDemo = grid( 9, ...(["video", "square", "portrait"] as const).map((r) => specimen( `sf-frame--${r}`, - `
`, + `
`, ), ), ); @@ -429,27 +437,237 @@ export function components(): string { } // ── Macros & utilities ───────────────────────────────────────────────────── +// Every one of the 58 shipped macro classes is applied here for real — this +// gallery is a coverage floor (tests/preview-coverage.test.ts), not just a +// reference; a bare class-name chip doesn't satisfy that spirit, so each +// macro gets an actual specimen instead of only appearing in a text list. +const LOREM_MACRO = + "SLASHED ships a token-driven CSS framework with no build step and no JavaScript required to render a page."; + export function macros(): string { + // Surfaces — the generic form (any colour via --sf-surface-color) plus + // all 10 precomputed named variants. + const surfaceNamed = ["primary", "secondary", "tertiary", "action", "neutral", "inverse", "success", "warning", "info", "danger"]; const surfaces = grid( 12, - ...["primary", "secondary", "tertiary", "action", "neutral", "success", "warning", "info"].map((s) => + specimen( + "sf-surface (custom --sf-surface-color)", + `
Aa
`, + ), + ...surfaceNamed.map((s) => specimen( `sf-surface--${s}`, `
Aa
`, ), ), ); - const macroClasses = classesOfKind("macro").filter((c) => c.selector.startsWith(".sf-")); - const list = well( - `
${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 #11,024.50
Invoice #82398.00
Invoice #91312,760.75
`); + + // Links — external marker + underline affordances, as real anchors. + const links = well(`
+ sf-link-external + sf-link--subtle (hover me) + sf-link--reverse (hover me) +
`); + + // 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( + `
${Array.from( + { length: 8 }, + (_, i) => `
Row ${i + 1}
`, + ).join("")}
`, + ); + const scrollSnap = well( + `
${["a", "b", "c"] + .map( + (l, i) => + `
${tag(`section ${l}`)}
`, + ) + .join("")}
`, + ); + 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) => + `
+
${esc(label)}
+
`; + 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 ──────────────────────────────────────────────────