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:
- Use a token for every value a token covers. No raw hex/rgb/named colors, no raw
px/remspacing, no rawmsdurations or inlinecubic-bezier, no raw z-index integers, no raw box-shadows. Untokenized CSS is a theming bug by definition. - Light and dark mode must work natively. That only happens through the semantic color tokens, which remap automatically under
<html data-theme="dark">and OSprefers-color-scheme. A hardcoded#6b7280orwhiteis a dark-mode bug. - Typed props, never raw strings. Icon props are
EIcon, color props areEColor(widen with a union type if a component genuinely needs more —EColor | CalendarCategoryColor— butEColorstays in the union). Never a bare unicode glyph or hex string. - Prefer
display: gridoverdisplay: flex.grid-auto-flow: columnfor horizontal rows,grid-template-columnsfor structure,place-items/align-itemsfor alignment. Flex only when grid genuinely cannot express it (e.g. inline text flow). - 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.
- 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).
| File | Provides |
|---|---|
colors.scss / colors.ts | Gray + 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.ts | EIcon — typed Material Icons ligature names, rendered via <span class="material-icons">{{ icon }}</span> |
spacing.scss / spacing.ts | 4px-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.scss | Focus 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.scss | heading($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.scss | Bordered-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.scss | inline-input mixin — the underline style for madlib/story-teller inputs, themed via --sc-inline-* tokens |
magic.scss | Magic 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-50…950,--color-blue,--color-green, … + their-surface12% 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/infoplus--color-*-surface/--color-*-textpairs 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.
| Mixin | What it does | When |
|---|---|---|
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-container | container-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-header | THE 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-widthis 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@mediaoverrides were patching).- Pick
split()thresholds from the container's width, not the viewport's. Amax-width: 56remsection never reaches a64remthreshold. - 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
@mediaquery. One-off coordination stays in the page, per the escalation rule. - Reference usage:
apps/stretchedlanding 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:
| Class | Range | Devices | Gate that opens it | Token |
|---|---|---|---|---|
| Compact | < 600px | phone in portrait | — (the base styles) | — |
| Medium | 600–839 | tablet / foldable in portrait | gt-compact | --breakpoint-medium |
| Expanded | 840–1199 | phone/tablet landscape, small desktop | gt-medium | --breakpoint-expanded |
| Large | 1200–1599 | desktop | gt-expanded | --breakpoint-large |
| Extra-large | 1600+ | desktop, ultra-wide monitors | gt-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).