fold-ng

August 25, 2026 · View on GitHub

npm version npm downloads install size types included license status: pre-1.0 live demo Sponsor

fold-ng is an accessible, dark-first Angular 22 UI component library and design system — signals-first, standalone, zoneless, and SSR-ready. It ships a two-tier design-token model (themeable to the bone) plus WCAG-minded components: buttons, forms, overlays/dialogs, navigation, data tables, toasts and more. No NgModule, no zone.js, no runtime CSS-in-JS — just standalone components styled against CSS variables.

Every component, driven by a live playground — the fastest way to see what fold-ng looks and behaves like.

Production-quality, pre-1.0 (0.x). Every component is tested, the package builds AOT green, and it's dogfooded as the design system of a real application. It stays 0.x only because the API isn't frozen until 1.0.0 — not because it's unstable. Pin your version; 0.x minor bumps may still refine the API. See CHANGELOG.md for the road to 1.0.

Install

npm install fold-ng

Angular 22 (@angular/core, @angular/common, @angular/forms, @angular/platform-browser) is a peer dependency.

Bundle size. Components are standalone and the package is side-effect-free except its CSS (sideEffects: ["**/*.css"]), so you only ship what you import — not the whole library. (No Bundlephobia badge: it can't measure an Angular partial-Ivy package, which needs the Angular linker to become final JS — the install-size badge above is the honest figure.)

Quickstart

Import the tokens once, then use any standalone component directly:

// styles.css
@import "fold-ng/tokens.css";
import { Component } from "@angular/core";
import { FoldButtonComponent, FoldCardComponent } from "fold-ng";

@Component({
  standalone: true,
  imports: [FoldButtonComponent, FoldCardComponent],
  template: `
    <fold-card>
      <button foldButton emphasis="solid" (click)="save()">Save</button>
      <a foldButton emphasis="outline" intent="neutral" routerLink="/back">
        Cancel
      </a>
    </fold-card>
  `,
})
export class DemoComponent {
  save() {}
}

Everything is dark by default; opt into light with data-theme="lumen". See docs/TODO.md for the roadmap and the full component list below.

Consuming the tokens

Import the CSS once at your app's style entry point:

@import "fold-ng/tokens.css";

Everything renders umbra (the dark theme) by default. To switch a subtree (or the whole app), set data-theme on an ancestor — usually <html>:

<html data-theme="lumen"></html>

Five themes ship: umbra (the dark base, no attribute), lumen (light), bubbly (festive lavender, violet brand, rounded), navi (dark chrome, light page) and titan (brushed titanium — a light brushed-steel ground with a frameless top and floating rails, bright cards, a heat-anodized copper-orange brand, solid steel borders, iPhone-soft corners). The extras exist to prove the point — each is the umbra or lumen block with its primitive families re-pointed. Adding a sixth is a new [data-theme] block in semantic.css plus the primitives it names.

bubbly and navi also change their corners, and navi its depth — radius and elevation are the two scales a theme may re-declare. The line is not that one scale is special: a theme may change what a surface looks like, never where it sits. Corner softness is a brand axis (friendly vs institutional); depth is calibrated against a ground, and a shadow tuned for near-black ink is a grey smear on a light page. Neither moves a box by one pixel. Type, space and motion stay theme-invariant — those re-flow or re-time a page. The contract test enforces that split.

navi is the interesting one: mixing chrome and page means one theme needs the text/border/surface roles to differ per region, which a single set of roles cannot express. It gets there by re-declaring those roles on [data-surface="chrome"] — a contract any element opts into with the foldSurface directive (the app-shell stamps it on its rails + header) — so the theme never names a component's internals. Variables only. If mixed chrome ever stops being a demo, the catalogue should grow real *-on-chrome roles.

Auto-inverting surfaces

The same [data-surface] seam powers an accent surface — a region filled with the brand accent whose entire content sub-tree flips to a compatible on-accent palette with no per-component code. Drop a fold-card in a grid and set surface="accent" (or stamp foldSurface="accent" on any element):

<fold-card surface="accent">
  <h3>Studio plan</h3>
  <p>Everything in Pro, plus shared workspaces.</p>
  <fold-badge content="Popular" variant="accent" />
  <fold-link href="/plans">Compare plans</fold-link>
  <button foldButton>Choose</button>
</fold-card>

Every nested thing — the text ramp, the hairline border, a raised band, the badge, the link, the button, even a filled icon tile — reads correctly on the accent. Not because the card special-cases them, but because the region re-points the semantic roles they already resolve against.

How it works (and why it survives a solid button)

An inverting surface has to do something a chrome surface never does: swap the brand pair. On the accent, --fold-color-primary should become the light ink (so a link or a filled tile's ground reads), and --fold-color-on-primary should become the accent itself (so the label on that tile reads). Written naively that's a CSS custom-property cycle (a: var(b); b: var(a) → both invalid).

The trick: capture on the surface, invert on the descendants. The surface element records the accent and its ink into two private vars while the tokens still hold their normal values, and the inverted role-set is applied to descendants from those captures — so a role can reference the pre-inversion value it is replacing, no cycle:

[data-surface="accent"] {
  --_accent-ink: var(--fold-color-on-primary); /* captured here… */
  --_accent-fill: var(--fold-color-primary);
}
[data-surface="accent"] * {
  --fold-color-text: var(--_accent-ink); /* …consumed here */
  --fold-color-primary: var(--_accent-ink); /* fill → light ink */
  --fold-color-on-primary: var(--_accent-fill); /* on-fill → the accent */
  /* …surfaces/borders as color-mix of the captured pair… */
}

Every value is a color-mix of the captured pair, so it is derived, not authored — one definition holds on all five themes, and the surface/band steps stay a lighter shade of the accent (gradation kept in-hue).

Overriding per theme

None of this is a cage. Writing your own CSS in a card is always free — raw values don't reference the roles, so a surface never touches them. An override isn't a hack: it re-anchors one relationship (text ↔ ground ↔ accent) inside a controlled frame, and coherence follows because the ratios are preserved. Speak in roles and gain the adaptation, or paint a pixel and own it — both coexist.

The derived defaults are good, not sacred. A theme that wants a different on-accent ramp (a light accent might want darker text, say) overrides any role by nesting its own theme selector under the surface — the same seam chrome uses:

[data-theme="titan"] [data-surface="accent"] * {
  --fold-color-text: var(--fold-ref-steel-900); /* dark ink on a light accent */
}

The one honest limit: an accent surface is a single ground, so surface stays one axis — there is no accent × sunken. That is a feature (a card in a grid has one job: stand out), not a gap.

Then style against the semantic tokens — never a raw colour:

.header {
  background: var(--fold-color-bg-header);
}
.cta {
  background: var(--fold-color-primary);
  color: var(--fold-color-on-primary);
}
.cta:hover {
  background: var(--fold-color-primary-strong);
}

From TypeScript you get the same tokens, typed:

import { foldColorVar } from "fold-ng";

el.style.background = foldColorVar("bg-page"); // "var(--fold-color-bg-page)"
foldColorVar("bg-pag"); // ✗ compile error — misspelt token

The two-tier model

Tokens come in two layers. This separation is the whole point — it is what lets another project re-theme by swapping the palette, and what keeps the app from hard-coding colours.

TierFilePrefixRole
1 · Primitivesprimitives.css--fold-ref-*The raw palette. The only place a literal hex is allowed. Theme-invariant.
2 · Semanticsemantic.css--fold-color-*Role tokens (bg-header, primary…). Point at primitives. Flip per theme.

Components consume tier 2 only. A component never names --fold-ref-teal-500; it names --fold-color-primary. Re-theming means re-pointing the semantic layer, never touching a component.

The naming convention

--fold-<tier>-<category>-<role>[-<variant>]
        │       │          │        └─ strong · primary · secondary · tertiary …
        │       │          └────────── page · header · rail · primary …
        │       └───────────────────── color · (space, radius, text … to come)
        └───────────────────────────── ref (primitive) · color (semantic)

Namespaced with --fold- so the package never collides with an app's own tokens.

The bg- rule. A surface fill role carries a bg- marker (bg-page, bg-rail-primary); a foreground/brand role does not (primary, on-primary). So bg- reads as "this paints a background."

The contract test

src/tokens/__tests__/tokens.contract.spec.ts is the lock. It fails the build if:

  • any theme block falls out of parity with the catalogue (the dark :root base and every [data-theme] override are checked);
  • a semantic token points at a primitive that doesn't exist (dangling var);
  • a semantic token hard-codes a hex instead of referencing a primitive;
  • a primitive is declared but never used;
  • the CSS drifts from the typed catalogue in tokens.catalog.ts.

Add a token → add it to tokens.catalog.ts and every theme block, or the test goes red. That is how a theme stays complete.

pnpm --filter fold-ng test

Current token set

Deliberately small — we grow it together, one confirmed role at a time.

Semantic tokenRole
--fold-color-bg-pagePage background
--fold-color-bg-headerTop header bar
--fold-color-bg-rail-primaryRail 1 — app menu
--fold-color-bg-rail-secondaryRail 2 — workspace menu
--fold-color-bg-rail-tertiaryRail 3 — tertiary nav
--fold-color-primaryPrimary / accent (brand teal)
--fold-color-primary-strongPrimary hover / active
--fold-color-on-primaryText / icon on a primary fill

(plus the status families, neutral surfaces, the two card tints surface-card / surface-sunken, glass, and the radius / text / icon-size / space / motion / blur scales — see tokens.catalog.ts for the full, typed set.)

Components

All standalone, signals-first, styled against the semantic tokens. Import from the package root.

Find one by what you need

The reference table below is keyed by component name — but you usually know your intent, not the name (that's how an uppercase mini-title gets hand-rolled instead of reaching for fold-element-title). Start here; before hand-rolling a label, field, badge, card or overlay, scan this table — fold almost certainly ships it.

I need to…Reach for
a small uppercase label / eyebrow over a groupfold-element-title (variant="eyebrow" · bar · title)
a section with a title + description + actionsfold-page-section (semantic <section> + aria-labelledby)
show read-only label/value pairs (a recap)fold-field-list / fold-field (dl/dt/dd)
a text fieldfold-input — number → fold-number-input · multiline note → fold-textarea
a date / time fieldfold-date (date·datetime-local·month·week) · fold-time
a dropdownfold-select (native) — custom rows → fold-listbox · multi → fold-multiselect
an on/off field · a password fieldfold-checkbox · fold-password-field
a range / debounced searchfold-slider / fold-range-slider · fold-search
a button · icon-only action · icon on/offfold-button (<button>/<a>) · fold-button-icon · fold-toggle-icon · text link → fold-link
an inline "are you sure?" guardfold-inline-confirm (no modal — simple · type-to-confirm · secret)
a "delete X" danger block (type-to-confirm)fold-danger-zone (alert frame + blast-radius text + retype-to-arm)
a status / count pill · status→colourfold-badge · fold-status-badge
a tinted message / alert rowfold-callout (inset for in-flow)
a "what is this?" help bubblefold-info (the i → popover; also the info input on every labelled field)
a transient toastfold-toast + FoldToastService
a card · titled info card · page splashfold-card · fold-context-card · fold-hero-section (bordered → fold-hero-card)
in-page tabs · a routed nav bar · a segmented controlfold-tabs · fold-view-nav · fold-view-toggle / fold-choice-row
a breadcrumb trail (routerLink or href)fold-breadcrumb ([items], last = current page)
a "← Back" link (route, href, or history)fold-back-link (routerLink / href / history-back button)
a table + paginationfold-data-table · fold-paginator
an avatar · a clusterfold-avatar (+ …Detail) · fold-avatar-list
an empty / loading statefold-empty-state · fold-loading / fold-spinner
a side panel · anchored popover · actions menufold-panel-host · fold-popover · fold-dropdown
a collapsible sectionfold-disclosure (a modal dialog is roadmap — use a modal fold-panel-host or fold-inline-confirm)
an icon · a calendar · a timeline / stepperfold-icon · fold-calendar-month/-week/-day/-list/-agenda/-timegrid · fold-timeline
a nav rail · app skeleton · a detail page with railsfold-menu · fold-app-shell · fold-aside-layout (page scaffold → fold-page-layout)
drag-drop file uploadfold-file-dropzone
ComponentSelectorWhat it is
FoldAppShellComponentfold-app-shellResponsive app skeleton (rails + header + content + self-collapsing footer slots; headerLayout/footerLayout inset·full, footerBehavior pinned·scroll; mobileNav drawer·none — [(mobileNavOpen)] off-canvas drawer for the primary rail on mobile, or none to compose an fold-nav-launcher; scroll scroll·stage — the shell owns the content scroll by default so pages flow ([foldScrollRegion] opts a nested area back into its own scroll); built-in skip-link to a focusable <main>. Regions float per-surface via foldElevated, not a shell flag).
FoldMenuComponent (+ Item / Section / Separator)fold-menuCollapsible nav rail — coloured sections, tint="follow", depth level, collapse-toggle placement. Items are a[fold-menu-item].
FoldNavLauncherComponent (+ FoldNavTileComponent)fold-nav-launcherFull-screen mobile nav launcher — a centred tile grid over a blurred scrim (scrim / Escape / close dismissal, focus-trap, scroll-lock). columns="auto" scales tiles to the count. Pairs with fold-app-shell mobileNav="none". Tiles are a[fold-nav-tile]variant="surface"·filled.
FoldPageLayoutComponentfold-page-layoutPage scaffold — gutter + header + body rhythm; fills its container (width is a content concern). Tokens --fold-page-gutter / --fold-page-gap; sections can bleed edge-to-edge.
FoldPageSectionComponentfold-page-sectionSemantic <section> grouping — eyebrow title (names the region via aria-labelledby) + description + actions; stack / bleed helpers. Not a box — compose a fold-card inside for that.
FoldHeroSectionComponentfold-hero-sectionFull-bleed page splash — the borderless intro band at the top of a page (carries the <h1>). Direct child of fold-page-layout: cancels the gutter + top pad to sit flush, brand-tinted wash + hairline. align center·start, wash, [heroBackdrop] decorative lane. (For a bordered header card, see fold-hero-card.)
FoldAsideLayoutComponentfold-aside-layoutDetail-page grid — a centred column flanked by up to two sticky rails ([asideLeft] / [asideRight]), collapsing to one column on its own container width (:has()-driven, container queries). Labelled rails become complementary landmarks; every track is a CSS var.
FoldNavLayoutComponentfold-nav-layoutPlaces a bar ([tabNav] — a fold-view-nav or a fold-tabs) with its content — placement="top" or a side rail that folds back on top (hysteretic, on its own width) below foldAt. exportAs="foldNavLayout" exposes stacked() so the projected bar follows in one binding.
FoldCardComponentfold-cardRaised content surface (surface = card/sunken/accent, hairline border, consistent radius). accent is an auto-inverting accent-filled card: the whole content sub-tree re-points to a compatible on-accent palette (text, borders, band gradation, nested buttons/links/icon-tiles) — every value a color-mix of the accent, so it holds on all themes (see auto-inverting surfaces). Optional projected [cardHeader]/[cardFooter] bands with per-band chrome (separators/raisedBands = none/header/footer/both); the body padding never shifts when a band toggles. interactive makes the whole card an accessible button (role/tabindex, focus ring, Enter/Space/click → (activated)).
FoldContextCardComponentfold-context-cardTitled info card: icon header + body + optional footer action.
FoldHeroCardComponentfold-hero-cardProminent header card — bordered surface × accent overlay + optional accent bar. (For a full-bleed page splash, see fold-hero-section.)
FoldElementTitleComponentfold-element-titleUppercase section/card mini-title (eyebrow · bar variants).
FoldFieldListComponent / …Fieldfold-field-listRead-only dl/dt/dd recap — label/value pairs ([empty] placeholder). The display half of a record; fold-input is the edit half.
FoldInputComponentfold-inputText-input control (value: string) — Signal Forms ([formField]) or standalone [(value)]; size × align × variant, label / required / hint. The edit half of a record (fold-field reads).
FoldNumberInputComponentfold-number-inputNumeric sibling of fold-input (value: number | null, empty ⇒ null); owns min / max / step + label / required / hint. Split so each control keeps its true type.
FoldTextareaComponentfold-textareaMultiline sibling of fold-input (value: string). No resize handle — fixed rows height, wraps + scrolls overflow. Shares the box + label/required/hint/error chrome. Signal Forms or [(value)].
FoldSelectComponentfold-selectNative <select> wrapper (options projected as <option>); shares fold-input's box chrome. Signal Forms (FormValueControl<string>) or [(value)]. For custom rows use fold-listbox.
FoldDateComponentfold-dateNative calendar-date wrapper (type = date · datetime-local · month · week) — keeps the OS picker, hands back a typed [(value)] string (YYYY-MM-DD). min/max/step pass through. Time-of-day → fold-time.
FoldTimeComponentfold-timeNative time-of-day wrapper (<input type="time">) — typed [(value)] string (HH:mm), min/max/step. The sibling of fold-date.
FoldCheckboxComponentfold-checkboxBoolean control — a native <input type="checkbox"> (keyboard, indeterminate, forms) restyled to tokens. Signal Forms ([formField], a FormCheckboxControl) or standalone [(checked)]; indeterminate, label/ariaLabel, hint/errors, size.
FoldFieldsetComponentfold-fieldsetNamed group of controls — a real <fieldset>/<legend> with the browser's margin/padding/3D border undone and the rhythm on tokens. disabled disables every control inside (the element's unique power); hint wired through aria-describedby (hintPosition under/inline); legend (empty renders none) or ariaLabel; optional marks the whole group (not each member); legendVariant (eyebrow/heading — the same two registers as a page-section's title), direction (vertical/horizontal), appearance (plain/border); --fold-fieldset-gap.
FoldPasswordFieldComponentfold-password-fieldPassword input + a live requirements checklist (a dot/tick per rule). Rules injected via FoldPasswordRule ({ label, test } — regex/zod/anything); revealable eye (a fold-input capability); marker dot/check; [rules] slot to redesign the list; validChange; Signal Forms.
FoldViewToggleComponentfold-view-toggleSegmented single-select (Cards/Table, density, chart-mode…). Generic options ({ value, icon?, label?, ariaLabel?, disabled? }) + [(value)]; a real role="radiogroup" — roving tabindex, arrow keys, Home/End, disabled-skip; size, iconOnly, activeStyle (raised / accent).
FoldSearchComponentfold-searchDebounced search box — an fold-input that emits searchChange once typing settles (delayMs), trimmed + de-duplicated.
FoldListboxComponent / FoldOptionComponentfold-listbox / fold-optionStyleable single-select — the richer sibling of fold-select (native <select>) for options that need custom rows (icon, second line, status). On fold-popover; role="listbox" + aria-activedescendant, full keyboard (↑/↓, Home/End, type-ahead, Enter). Signal Forms (FormValueControl<string>, [formField]/[(value)]); shares fold-input's box chrome.
FoldMultiselectComponentfold-multiselectMulti-select sibling of fold-listbox (same popover + fold-option rows). Value is a set (readonly string[]); activating a row toggles it and the panel stays open. Separate component — not a multiple flag — so the Signal-Forms value type stays honest. role="listbox" + aria-multiselectable; the trigger summarises the picks.
FoldOptgroupComponentfold-optgroupLabelled group of <fold-option>s inside a fold-listbox / fold-multiselect — the styleable <optgroup>. Presentational: role="group" + aria-labelledby header (no role="option", so keyboard nav skips it); the owner discovers grouped options in document order, so roving crosses groups seamlessly.
FoldSliderComponentfold-sliderSingle-value range slider — a styled native <input type="range"> (design-system track/fill/thumb). Signal Forms ([formField], a FormValueControl<number>) or [(value)]; real <label for>, aria-valuetext, hint/errors, focus ring.
FoldRangeSliderComponentfold-range-sliderDual-thumb range slider selecting a { min, max } window (shares the slider track/thumb). Two-way [(value)]; a labelled role="group", per-thumb i18n aria (minLabel/maxLabel) + formatted aria-valuetext, disabled.
FoldFileDropzoneComponentfold-file-dropzoneFile-picker dropzone — drag-over visuals, keyboard activation, hidden <input type=file> plumbing; emits the picked File[] (presentational — never uploads).
FoldLinkComponentfold-linkInline text link / link-button (icons, accent · muted).
FoldButtonComponentbutton[foldButton] · a[foldButton]Action button — applied to a real <button> or <a> (link that looks like a button, gets href/routerLink); orthogonal emphasis (solid·soft·outline) × intent$ (\text{primary}·\text{neutral}·\text{warning}·\text{danger}) \times 3 \text{sizes} \times \text{shape}/$block; icon/iconTrailing shorthand (auto-sized) or project content; loading (spinner + aria-busy). Use native (click).
FoldButtonIconComponentfold-button-iconIcon-only momentary button — shape × size × tone; a one-shot action (no pressed state). For a text button use fold-button; for on/off use fold-toggle-icon.
FoldToggleIconComponentfold-toggle-iconIcon-only toggle — the same surface as fold-button-icon, plus [(active)] + aria-pressed (true/false) and a pressed state. Emits toggled.
FoldInlineConfirmComponentfold-inline-confirmIn-place “are you sure?” guard — the projected trigger swaps to a confirm/cancel row (no modal). Simple (confirmed emits ""), type-to-confirm ([match]), or secret (password, masked, emits the value). confirmIcon + a chosen cancelIcon; Escape cancels; message announced via aria-describedby; focus in-then-back; controlled [(open)] + keepOpenOnConfirm for async pending; i18n via provideFoldInlineConfirmLabels.
FoldDangerZoneComponentfold-danger-zoneFramed destructive-action block for “delete X” settings. appearance="filled" (alert-tinted) or "section" (a danger section: alert border + normal-background body for ordinary content). The confirm reveals on click — an actionLabel button opens an in-place fold-inline-confirm (plain “are you sure?”, or type-to-confirm when confirmPhrase is set); (confirmed) emits the typed text. Omit actionLabel for a section with no action. role="group" + aria-labelledby.
FoldDataTableComponentfold-data-tableControlled roster table — sortable sticky header, tone rows, controlled selection (checkbox column), roving-keyboard nav, mobileLayout (scroll / auto-cards / custom foldRowCard), an optional foldToolbar bar, sticky-first, density.
FoldPaginatorComponentfold-paginatorServer-side paginator (size selector + range + page nav).
FoldTimelineComponentfold-timelineConnected rail of nodes (dot + optional date + label) — vertical navigable history or horizontal step progress; nodes optionally clickable.
FoldBadgeComponentfold-badgeStatus / count pill (accent/info/warning/alert/success).
FoldStatusBadgeComponentfold-status-badgeStatus→colour badge (maps a domain status key to a tone).
FoldChoiceRowComponentfold-choice-rowSegmented / chip selector.
FoldViewNavComponentfold-view-navNavigation bar styled as tabs. Items carry a link (routerLink → a real <a>: cmd-click, deep-links, active state auto), an href, or nothing (a button); aria-current="page" on the active one. direction="auto" follows a wrapping fold-nav-layout; collapsed for an icon rail. For in-page panel switching use fold-tabs instead.
FoldBreadcrumbComponentfold-breadcrumbHierarchical link trail. Data-driven [items] where each crumb links by routerLink or href (works without the router; RouterLink only instantiates on a routerLink crumb). Last item is the current page (aria-current="page"), not a link. navigation landmark, decorative chevron separators. Needs @angular/router only when a crumb uses routerLink (optional peer).
FoldBackLinkComponentfold-back-linkThe “← Back” affordance for a detail page. Three modes by input: routerLink (in-app), href (external / non-router), or neither → a history-back <button> (Location.back()). Router-coupled but degradable — the history mode needs no router. label + leading icon (default chevron-left).
FoldTabsComponent + FoldTabPanelComponentfold-tabs + fold-tab-panelThe in-page ARIA Tabs widget: role="tablist" + roving arrow-key keyboard, aria-selected/aria-orientation, each tab wired to its fold-tab-panel (aria-controlsaria-labelledby). Panels take the bar by ref ([tabs]="t") so they coordinate across fold-nav-layout slots.
FoldIconComponentfold-iconSVG icon (~135 built-in glyphs across 7 categories incl. commerce + FoldIconRegistry).
FoldSpinnerComponentfold-spinnerIndeterminate loading arc (currentColor, icon-sized, reduced-motion aware). Decorative by default; labelrole="status". Powers loading on the buttons.
FoldAvatarComponent / …Detailfold-avatarInitials/image avatar (square, muted, status ring) + identity cell.
FoldAvatarListComponentfold-avatar-listOverlapping avatar cluster (per-face variant, limit + a +N overflow chip).
FoldToastComponent / …Containerfold-toastFrosted snackbar (variant glyph + dismiss) + queue host (+ FoldToastService).
FoldLoadingStateComponentfold-loadingLoading placeholder — fold-spinner + message, in a role="status" region; size input; stretches to fill.
FoldEmptyStateComponentfold-empty-stateEmpty-state block (icon + title + message + optional action).
FoldCalloutComponentfold-calloutTinted message row — status colour + icon + message + optional trailing actions; inset (bordered, in-flow) appearance.
FoldDisclosureComponentfold-disclosureOne summary toggling one collapsible panel — the accordion primitive (open-state is the consumer's to bind); keeps content mounted, unlike native <details>.
FoldPanelHostComponentfold-panel-hostSide-panel / overlay host (+ FoldPanelHostService / FoldPanelRef / FoldPanelToggle). Modal: accessible name, inert background barrier, top-most focus trap, scroll-lock. Localise the close label once via provideFoldPanelLabels({ close }).
FoldPanelHeaderComponentfold-panel-headerStandard panel header (title/eyebrow, self-closing). Names its dialog (aria-labelledby) and reads the app-wide close label.
FoldPanelFooterComponentfold-panel-footerPanel/dialog action bar pairing with fold-panel-header: tokenised top border + padding + align="end" | "between" | "start". Projects the buttons; sits flex: none so it stays pinned to the panel bottom while the body scrolls (no position: sticky).
FoldPopoverComponentfold-popoverAnchored floating layer — projected content in the native top layer (escapes overflow/z-index), positioned by a dependency-free flip → size → shift engine (computePlacement): a tall panel gets a max-height and scrolls inside the viewport. [(open)]; autoUpdate (ResizeObserver); optional arrow; native CSS enter/exit (@starting-style + allow-discrete); outside-click + Escape dismissal, focus-return, auto-wired aria-haspopup/expanded/controls.
FoldDropdownComponentfold-dropdownActions menu on fold-popoverrole="menu" with <fold-dropdown-item>s, ↑/↓ roving, Home/End, type-ahead; opens onto the first enabled item, closes returning focus to the trigger. Give the trigger foldPopoverTrigger="menu".
FoldInfoComponentfold-infoThe small i that answers "what is this?" — a quiet round trigger revealing a sentence or two in a fold-popover (native top layer, so it escapes overflow: hidden and every z-index). A click, not a hover: hover-only help is unreachable by touch. text · label (accessible name) · placement. It is what the info input on every labelled field renders, and it stands alone where there is no field — a card corner, a table header.

| FoldCalendarMonthComponent | fold-calendar-month | Month grid where events span the days they cover: a date-axis role="grid" with one roving tab stop, and over it a lane-packed layer of bands (one per week crossed, with open edges). +N chips sit on the crowded day, not at the end of the row. | | FoldCalendarWeekComponent | fold-calendar-week | Seven day columns of stacked chips — nothing spans, so nothing is clipped and every chip is a real button in the tab order. Container-queried: labels and icons drop out before they truncate. | | FoldCalendarDayComponent | fold-calendar-day | One day in full, with room for the subline — where a dayClick drill-down lands. Empty state takes a projected action (button[empty]). | | FoldCalendarListComponent | fold-calendar-list | The flat reading of the same feed, in date order, each row leading with the span Intl formats. Nothing is ever hidden behind a lane budget. | | FoldCalendarTimegridComponent | fold-calendar-timegrid | Hour columns for a week or a day, with the all-day strip on top. Timed events are placed on the clock and share their width only with what they actually collide with (exclusive boundaries, per-cluster widening); a span crossing midnight becomes one block per day. Times are wall-clock HH:mm, never instants; now is an input, not a clock. | | FoldCalendarAgendaComponent | fold-calendar-agenda | "What's next" rail grouped by day, with a to-handle slice (the warning/alert tones — the same scale the chips paint with) and a live badge. Collapses to a spine; mode/collapsed are models, so the app owns persistence. | | FoldCalendarToolbarComponent | fold-calendar-toolbar | Today / prev / next / period title / view switch. Owns no data — date and view are two-way, and the step matches the reading. views takes { value, label } for an app's own view. | | FoldCalendarSourceFilterComponent | fold-calendar-source-filter | Chips switching each feed of a merged calendar on and off. Owns the selection only; the caller runs the pure foldFilterBySource(). |

Two tiers, on purpose. The components are the first; under them, the same geometry is exported as pure functions — foldBuildMonthGrid (week rows with their events already packed into lanes), foldBuildWeek / foldBuildDay, foldBuildAgenda, plus foldShiftDate / foldRangeForView / foldViewTitle for paging and naming a period, and foldCalendarNextFocus for the arrow keys. Lay a calendar out without rendering it with these components, and the hard part — packing spans into lanes, clipping them at week boundaries, keeping an open edge on the side that continues — is already solved and already tested. The filters (foldEventsOnDay, foldEventsInRange, foldFilterBySource) are the tier every page uses whichever rendering it picks.

Measured, and kept measured. Laying out a month (mean of 20 runs, pnpm bench:calendar, committed with a budget so it cannot regress quietly):

events in the window502001 0005 00020 000
ms per layout0.140.410.995.022

Calendar dates are plain YYYY-MM-DD strings, never Date. That is also exactly what Temporal.PlainDate.toString() returns, so the family is Temporal-native without depending on it: foldFromTemporal() accepts a PlainDate, PlainDateTime or ZonedDateTime (structurally typed, so it compiles on a runtime that has none of them), and going the other way needs no helper — Temporal.PlainDate.from(foldDate) already takes one of ours. See foldToday / foldFromNativeDate / foldAddDays and the reasoning in /calendar-dates. The family plots on the Gregorian calendar and has no resource (staff × day) view. Whole-day spans are the month, week, day, list and agenda views; the time grid adds the hours, with time as wall-clock HH:mm rather than an instant — the app converts at its own boundary, and no zone can move a meeting after that.

It is a pure display, by decision and not by omission: it knows nothing about what an event means or which layer produced it, so it never originates one — no drag-to-create, no range-select. Tone is the caller's to compute, which is what lets the same component paint a leave request and an account sitting open between registration and activation, its band crossing into warning then alert on a threshold the app owns. Everything drawn around an event is a projectable template: foldCalendarEvent, foldCalendarDay, foldCalendarHeading, foldCalendarTitle, foldCalendarOverflow.

Directives worth knowing: foldSurface (page·chrome — the seam a mixed theme re-colours across), foldElevated (raise any bg-owning element into an inset, rounded, shadowed card — the per-surface "floating" mechanism, driven by --fold-surface-inset/-radius/-shadow), foldStickyColumn (turn an <aside> into a sticky side column — sticky="top·center·bottom" + stickyOffset, the rest tunable via --fold-sticky-column-*; un-stick with --fold-sticky-column-position: static at the page's stacking breakpoint), foldScrollRegion (turn any element into a bounded, coordinated scroll box — overflow + min-*: 0 + overscroll-behavior + the house scrollbar, on a block·inline·both axis; the one opt-in of the shell scroll system, it registers with the shell so an overlay freezes it while a modal is open), and foldRepeatPress (press-and-hold auto-repeat for a stepper button — fires once on press then on a tunable cadence while held, and stops the instant foldRepeatPressDisabled goes true mid-hold).

Form-field ids

Browsers warn — "A form field element should have an id or name attribute" — about any <input> / <select> / <textarea> that carries neither (autofill + a11y can't identify it). Two pieces keep that silent, SSR-safely:

  • FoldIdService — a unique-id generator. A per-injector counter (one per app, fresh per SSR request), so ids are deterministic in render order and match server ↔ client. Use it when a component owns a labelled control and needs the id for <label for>: readonly id = inject(FoldIdService).next('fold-input'). (crypto.randomUUID() differs server vs client and trips hydration — don't.)
  • FoldFieldIdDirective — auto-applies (by selector) to any native control missing both id and name, and assigns one. Add it to a component's imports once and every loose native control in its template is fixed — no per-element edit. It skips anything already identified (static or bound id/ name), so it never fights a control you've labelled yourself.

Icons

fold-icon ships a built-in set of single-colour SVGs (114 today, across ui, nav, music, status, people, brands), inlined so the package is self-contained — no .svg loader config leaks to a consumer. Icons use currentColor, so they inherit color and are sized with size (xs…xl, which map to the --fold-icon-size-* scale, or a pixel number):

<fold-icon name="search" />
<fold-icon name="heart" [size]="18" />
<fold-icon name="edit" title="Edit track" />
<!-- name autocompletes the built-ins (FoldBuiltinIconName) -->

A consumer adds its own icons — the package's set stays the shared core; the app extends it through the root FoldIconRegistry. Register once at bootstrap (idiomatic, like provideRouter):

// app.config.ts
providers: [provideFoldIcons({ "my-logo": "<svg …>…</svg>" })];

Or at runtime — the icon recolours/resolves reactively:

inject(FoldIconRegistry).register("my-logo", svgMarkup);
inject(FoldIconRegistry).registerMany({ … });

A custom entry with a built-in key overrides it. name is typed FoldBuiltinIconName | (string & {}), so built-ins autocomplete while any registered custom string is still accepted (an unknown name renders nothing and console.warns in dev).

Rendering — shared sprite. Each unique icon is added once to a hidden document-level SVG sprite as a <symbol>; every <fold-icon> renders a lightweight <svg><use href="#…"/></svg> that references it. So the DOM holds one copy of an icon's paths no matter how many times it renders — a data-table with 500 rows × 3 icons is 1500 tiny <use> elements over 3 symbols, not 1500 copies of the markup. It is lazy (only rendered icons enter the sprite) and SSR-safe (the sprite is serialised into the server HTML, so <use> resolves on first paint; the client adopts it rather than building a second). color and fill: currentColor inherit across the <use> into the symbol, so an icon still takes its colour from the host — and outlined icons keep their source fill="none" / stroke="currentColor".

Trust contract. Icon markup is injected into the sprite unsanitised (the sprite insertAdjacentHTMLs the authored <svg> so it survives intact), so every registered value must be a static, authored <svg> string, never derived from user input. The registry enforces a backstop (not a sanitiser) on all three doors (provideFoldIcons, register, registerMany): a non-<svg> root or a <script> / inline on*= handler throws at registration. Passing user-controlled markup would be a stored-XSS sink — keep icon strings static.

Auto-colour

fold-avatar (and any entity that needs a stable, recognisable colour) draws from one app-wide palette via FoldPaletteRegistry — a root singleton. Reading from a single source is the point: the same seed (name / id) is the same colour everywhere, so people/entities stay recognisable across screens.

Consumers never touch the palette arrays or the hash — they call colorFor:

private readonly palette = inject(FoldPaletteRegistry);
readonly color = computed(() => this.palette.colorFor(this.seed())); // reactive

Choose the palette once, at bootstrap (like provideRouter):

// app.config.ts — a built-in name, or your own colour list
providers: [provideFoldPalette("vivid")]; // 'vivid' (default) | 'extended' | 'pastel'
providers: [provideFoldPalette(MY_BRAND_COLOURS)]; // readonly string[]

Or switch it live — every avatar recolours in the same frame:

inject(FoldPaletteRegistry).use("pastel");
inject(FoldPaletteRegistry).use(MY_BRAND_COLOURS);

Palettes are categorical data, not semantic tokens (qualitative hues to tell entities apart, theme-invariant), so they live in TS (FOLD_AUTO_PALETTES), consumed by a hash — not in the token CSS. Add a curated palette by adding one entry to FOLD_AUTO_PALETTES; it becomes a typed FoldAutoPaletteName everywhere.