Skip to main content

Design System and Foundations

The design tokens and SCSS utilities in libs/stretched-components/src/foundations/ — the single source of truth for every visual value on the platform (web app, Storybook, and the Mobile App shell alike).

See also: Component Library Guide for how components consume these tokens.

The mandatory rules

These are enforced by libs/stretched-components/src/components/CLAUDE.md (library) and apps/stretched/src/app/CLAUDE.md (app pages) — they apply to all CSS, everywhere:

  1. Use a token for every value a token covers. No raw hex/rgb/named colors, no raw px/rem spacing, no raw ms durations or inline cubic-bezier, no raw z-index integers, no raw box-shadows. Untokenized CSS is a theming bug by definition.
  2. Light and dark mode must work natively. That only happens through the semantic color tokens, which remap automatically under <html data-theme="dark"> and OS prefers-color-scheme. A hardcoded #6b7280 or white is a dark-mode bug.
  3. Typed props, never raw strings. Icon props are EIcon, color props are EColor (widen with a union type if a component genuinely needs more — EColor | CalendarCategoryColor — but EColor stays in the union). Never a bare unicode glyph or hex string.
  4. Prefer display: grid over display: flex. grid-auto-flow: column for horizontal rows, grid-template-columns for structure, place-items/align-items for alignment. Flex only when grid genuinely cannot express it (e.g. inline text flow).
  5. No token fits? That's a conversation, not an exception. Either propose a new foundation token or ask the user — and document any approved deviation as a block comment in the SCSS. When editing an older stylesheet, fix untokenized values you find and verify both themes.
  6. Reusable values go in foundations, not components. A new spacing step, duration, or color role belongs in the foundation file (ask first — it's a global change). Tokens describe intent (--color-primary), never instances (--button-background).

Foundation files

All files live in libs/stretched-components/src/foundations/; _index.scss forwards everything. Several have co-located .md docs (e.g. colors.md, mixins.md).

FileProvides
colors.scss / colors.tsGray + accent palettes, semantic tokens (--color-surface*, --color-text-*, --color-border*, status + status-surface/text, accent -surface tints), full dark-theme remap; colors.ts exports the typed EColor constant of var(--color-*) references
icons.tsEIcon — typed Material Icons ligature names, rendered via <span class="material-icons">{{ icon }}</span>
spacing.scss / spacing.ts4px-base scale --space-0--space-32 (incl. px, half steps); spacing.ts exports SPACING_TOKENS + SpacingTokenSchema (Zod) for builder-editable spacing props
typography.scss--text-xs--text-6xl sizes, --font-regular/medium/semibold/bold weights, --leading-* line-heights, --tracking-* letter-spacing
fonts.scss--font-sans / --font-mono / --font-serif system stacks
shadows.scss--shadow-none/xs/sm/md/lg/xl/2xl/inner elevations; dark mode swaps to white-ring + deep-blur shadows automatically
borders.scss--radius-sm/md/lg/xl/full and --border-width-xs/sm/md/lg
motion.scss--duration-fast/normal/slow/entrance/cinematic/spin/beat and --ease-default/in/out/in-out — always pair a duration with an ease
layout.scss--breakpoint-medium/expanded/large/extra-large (M3 window-size-class lower bounds), app-shell dims (--sidebar-width-*, --header-height), --content-max-width, content column caps --content-width-xs…xl, and auto-grid item minimums --item-min-xs…lg (use these, never raw rem widths)
variables.scssFocus ring (--focus-ring-*), icon sizes (--icon-xs--icon-xl), component sizes (--size-xs--size-xl; --size-md = 44px minimum touch target), --opacity-* semantics, --z-* layer scale
mixins.scssheading($level), truncate, line-clamp($lines), focus-ring, transition($props), visually-hidden, surface($level), the breakpoint gates gt-compact/gt-medium/gt-expanded/gt-large (+ gt()/lt(), § Mobile first below), and the responsive layout patterns auto-grid / split / layout-container / section-stack / page-header (§ Responsive layout below) — use these before writing your own CSS
form-input.scssBordered-box form-input mixins (input-field, form-input, input-overlay, input-clear-btn, input-dropdown, input-option, input-message) themed via --sc-form-* tokens
inline.scssinline-input mixin — the underline style for madlib/story-teller inputs, themed via --sc-inline-* tokens
magic.scssMagic attention accents — border beam / shimmer / glow (Magic-UI-inspired, dependency-free, honors prefers-reduced-motion); apply via the scMagic directive (components/magic), color via --sc-magic-color. ONE per view. Demo: Storybook Foundations/Magic Effects

Colors: palette vs. semantic

colors.scss has two layers:

  • Palette (--color-gray-50950, --color-blue, --color-green, … + their -surface 12% tints) — the "paint cans". Use a raw accent only when the intent is this specific hue.
  • Semantic tokens — what components should use: --color-primary(-hover/-active), --color-surface(-secondary/-tertiary/-elevated), --color-text-primary/secondary/tertiary/on-primary/link, --color-border(-light), status aliases --color-success/warning/error/info plus --color-*-surface / --color-*-text pairs for alerts, badges, and toasts.

Dark mode remaps both layers under [data-theme='dark'] and @media (prefers-color-scheme: dark) (opt out per-page with data-theme='light') — so a component written purely against semantic tokens is theme-correct with zero extra work.

EColor in TypeScript

EColor values are var(--color-*) strings, so a value assigned in TS (e.g. to an SVG fill) still remaps in dark mode. Use it for every color prop — see the typed-prop rules in Component Library Guide.

Icons: EIcon

icons.ts exports EIcon, a const object of Material Icons ligature names grouped by purpose (status/feedback, navigation, actions, user/auth, common UI, …). Render with the Material Icons font:

<span class="material-icons">{{ icon() }}</span>

New icons are added to EIcon in icons.ts — never as inline strings in a component. Size icons with the --icon-* tokens.

Responsive layout — the named patterns

Collapsing layouts are codified as three mixins in foundations/mixins.scss. Reach for these before hand-rolling grid-template-columns + media queries — they are the house vocabulary, and the GridLayout component's minItemWidth mode uses the same mixin internally so the dynamic path and hand-written CSS can't drift.

MixinWhat it doesWhen
auto-grid($min-item-width, $gap)Natural flow: as many equal ≥$min-item-width columns as fit; collapses 4→3→2→1 from available space alone. No media queries.Any grid of equivalent cards/tiles. This is the default.
split($template, $collapse-below, $gap)Asymmetric panes (2fr 1fr…) that stack below $collapse-below of container width (@container; falls back to viewport if none registered).Chart + sidebar, media + copy — anything unequal.
layout-containercontainer-type: inline-size — marks the wrapper whose width split() measures.On the section/wrapper around a split() grid.
section-stack($compact, $medium, $large)Page-level section rhythm: grid row-gap stepped per window class (defaults 32px Compact / 48px Medium+Expanded / 96px Large+). A phone gap looks cramped on tablets; a desktop gap looks hollow — this encodes the middle step once.On a page's top-level section stack (usually :host). Never bake page rhythm into a component.
page-headerTHE page header (added 2026-07-29): top-left h1 (3xl/bold) + tagline (p, lg/secondary/60ch), one internal rhythm — apply to the page's <header> and write plain <h1> + <p> inside. One pattern, no per-page deviations.Every signed-in/app page header. Centered marketing heroes (pricing, landing, legal) are the sanctioned exception.

Guidance:

  • $min-item-width is the one design decision — the narrowest width at which an item is still readable. Pick it honestly; too small yields cramped two-column phones (that's what the old per-page @media overrides were patching).
  • Pick split() thresholds from the container's width, not the viewport's. A max-width: 56rem section never reaches a 64rem threshold.
  • Viewport branching remains right for exactly two things: synchronized collapse ("these five sections stack together") and reordering/reflow beyond column count — and it goes through the breakpoint gates below, never a raw @media query. One-off coordination stays in the page, per the escalation rule.
  • Reference usage: apps/stretched landing page

In authored story content (dynamic configs) the same split exists as two components: gridLayout + minItemWidth (= auto-grid, uniform collections) and areaLayout — per-gate grid-template-areas strings (base / gtCompact / gtMedium / gtExpanded / gtLarge, mobile-first inheritance) with children carrying an area name. areaLayout is THE mechanism for per-screen-size arrangement in story data; a slot absent from the active gate's template is hidden, not auto-placed. See components/layouts/area/README.md. (landing-explore.component.scss); collapse behavior at multiple widths at once: the SqueezeFrames story under Layouts/Grid Layout.

Mobile first & the breakpoint gates

Stretched is mobile first: the base styles of every stylesheet — outside any gate — ARE the Compact experience. Compact spans 320–599px — 320 is the design floor (the smallest supported iPhone width; below 320 is unsupported), so verify base styles down to 320, not just 390. Wider window size classes opt INTO enhancements through the named gates in foundations/mixins.scss; a gate adds for bigger rooms, it never "fixes mobile". On Compact, prefer full-screen takeovers over squeezed-down desktop chrome (the AppShell sidebar menu is the canonical example), and keep touch targets ≥ --size-md (44px) at the base tier — width can't tell touch from pointer (a landscape phone is Expanded).

Breakpoints are the Material 3 window size classes (1dp = 1 CSS px). The gates are the only sanctioned way to branch on viewport width — no hand-written @media queries in app or component SCSS:

ClassRangeDevicesGate that opens itToken
Compact< 600pxphone in portrait— (the base styles)
Medium600–839tablet / foldable in portraitgt-compact--breakpoint-medium
Expanded840–1199phone/tablet landscape, small desktopgt-medium--breakpoint-expanded
Large1200–1599desktopgt-expanded--breakpoint-large
Extra-large1600+desktop, ultra-wide monitorsgt-large--breakpoint-extra-large

gt($class) / lt($class) sit underneath for the rare case a named gate can't express — lt('compact') ("Compact only") powers the AppShell full-screen menu takeover; every lt() use carries a comment. The interactive philosophy page (with a live resize demo) is Storybook → Foundations/Mobile First.

Breakpoints caveat

CSS custom properties can't be used inside @media queries — which is why the gates' px map lives in mixins.scss as SCSS values, kept in sync with --breakpoint-* in layout.scss. The --breakpoint-* custom properties exist for runtime JS access; container queries remain the tool for component-relative collapse.

Consuming foundations

App global stylesheet:

@use '@stretched/stretched-components/src/foundations/index';

Component SCSS (mixins only — token custom properties are global):

@use '@stretched/stretched-components/src/foundations/mixins' as mix;

.my-component {
@include mix.heading(3);
@include mix.transition(background-color);
}

Consuming apps need "stylePreprocessorOptions": { "includePaths": ["libs/stretched-components/src/foundations"] } in their build config (see libs/stretched-components/README.md § Build config).