Design-system guides

How to read, compose, and extend the canon.

The fleet's UI is built from five atomic-design tiers — atoms, molecules, organisms, templates, pages. Each tier has a job; each composition rule has a reason. Read these guides before adding a new primitive or before reaching for a hex literal — the canon is what keeps every gyrum surface visually, structurally, and ergonomically coherent.

The five tiers

Brad Frost's atomic-design taxonomy, adapted to gyrum's Svelte 5 source stack (ADR-182). Each tier is a directory in gyrum-ui/packages/svelte/src/ and a row in canon-manifest.yaml. The boundary between tiers is load-bearing — a misclassified primitive drifts toward coupling, and coupling makes the design system fragile. The legacy Lit primitive package @gyrum-labs/components is in maintenance — its existing entries still drive canon's chrome today (gy-hero, gy-list-row, gy-badge, gy-empty-state via app.html's importmap), but no new primitives author there.

L1 Atom

A single-purpose primitive — gy-button, gy-badge, gy-dot, gy-section-h. Renders one visual idea. Holds no application data. Composes only via slot and prop. Has a stylelint-enforced token surface (no hex, no pixel font-sizes).

Examples: 11 atoms in the catalogue.

L2 Molecule

A few atoms combined for one job — gy-kpi-card, gy-list-row, gy-hero. Still data-shape-agnostic; the consumer pours strings + tone values in via slots. Composes atoms; never composes another molecule (that's an organism).

Examples: 11 molecules in the catalogue.

L3 Organism

A self-contained section of UI that may handle ONE named data shape — gy-pulse-grid, gy-runtime-band. Composes molecules + atoms. The boundary: if a primitive handles two unrelated data shapes, it's a page-level composition, not an organism. Application organisms (epic-card, activity-panel, ticket-panel, version-pill, recent-failures-panel, bugs-summary-card) live in ai-frontend's src/lib/components/canon/ today, pending promotion (warp#2278 Phase 4).

Examples: 2 organisms canonical · 6 organisms pending promotion.

L4 Template

A page-level layout with locked geometry and named slots — gy-overview, gy-rail. Owns max-width, gutter, vertical gap; consumers cannot override from inside slots. The single dial consumers may turn is a width variant (narrow / default / wide). Without templates, every page DIYs the macro composition and pages drift visually.

Examples: 2 templates canonical.

L5 Page

A fully-realised composition with sample data — the reference shape for each surface (home-page, project-overview-page, services-list-page, services-new-page). Anchors a template with realised content so the convention is observable. Tier scaffold lands under warp#2512; first concrete primitives under warp#2521 / warp#2526.

Status: scaffold in flight.

Composition rules

Each tier composes the tier below (and same-tier when needed), never above. The rule is one-directional because composition upward — a button consuming a card consuming a template — creates a cycle that breaks both reasoning and bundling.

TierMay composeMust NOT compose
AtomSlots, props, css-tokensAny other component
MoleculeAtoms, atoms-via-slotMolecules, organisms, templates, pages
OrganismMolecules, atomsTemplates, pages, other organisms (if they're not a clear sub-shape)
TemplateOrganisms, molecules, atoms (rare)Pages
PageTemplates (one), organisms via slotsOther pages

The canon-tier eslint plugin (warp#2281 Phase 1.5) enforces this structurally — a PR that breaks the rule fails the gate. Read ADR-117 (module guidelines) for the cross-fleet shape and ADR-113 (principles tier) for why composition direction matters.

Naming conventions

Names are part of the contract. The Svelte source file, package path, and (where present) legacy custom-element tag all derive mechanically from the kebab-case name; no exceptions.

SurfaceShapeExample (button)
Svelte source filepackages/svelte/src/Gy<Pascal>.sveltepackages/svelte/src/GyButton.svelte
Package import@gyrum-labs/svelte/Gy<Pascal>.svelte@gyrum-labs/svelte/GyButton.svelte
Storybook title<Tier>s/<Pascal>Atoms/Button
Legacy Lit tag (maintenance only — ADR-182)gy-<kebab>gy-button
Legacy Lit source (maintenance only — ADR-182)packages/components/src/<tier>s/<kebab>/<kebab>.tspackages/components/src/atoms/button/button.ts
Legacy Lit package import (maintenance only — ADR-182)@gyrum-labs/components/<kebab>/<kebab>.js@gyrum-labs/components/button/button.js

The aggregator that builds data/stories-manifest.json derives all of the above from the kebab name + tier; deviating from the convention silently breaks the manifest. The legacy-tagged rows describe the maintained Lit substrate that canon's chrome still imports — they are not the path for new primitives.

Token usage

Every value that conveys meaning — colour, font-size, spacing, radius — comes from a token. Hex literals and pixel font-sizes are stylelint-rejected in canon components (warp#854 / warp#2281). The token families are the dial; the dial is the contract.

Colour

var(--accent)
var(--bad)
var(--ok)
var(--warn)
var(--text)
var(--text-muted)
var(--bg)
var(--bg-subtle)
var(--border)

Don't reach for a hex value because a token "doesn't quite match". Either the design intent maps to a token (use it) or the token system has a gap (open an ADR; don't bypass).

Type ramp

var(--text-xs)    /* metadata, hints   */
var(--text-sm)    /* body small, meta  */
var(--text-base) /* default body      */
var(--text-md)    /* emphasis         */
var(--text-lg)    /* sub-headings     */
var(--h1-size)    /* page title       */
var(--h2-size)    /* section heading  */

Never use font-size: 13px — the canon ramp covers it. If a design calls for a non-canon size, the design is wrong or the ramp needs a new step.

Spacing

var(--space-2)  /* 4px   */
var(--space-3)  /* 8px   */
var(--space-4)  /* 12px  */
var(--space-6)  /* 16px  */
var(--space-8)  /* 24px  */
var(--space-10) /* 32px  */
var(--space-12) /* 48px  */

The space scale is geometric, not linear. Use the named step; don't compute "space-7" between space-6 and space-8.

Accessibility

Canon primitives ship a11y-correct by default; consumers cannot downgrade the contract from outside.

  • Keyboard: every interactive element is focusable via Tab; focus rings use var(--accent) at 2px outline.
  • Contrast: token pairs (text-on-bg, text-on-accent, bad-on-bg, etc.) are pre-validated for WCAG AA. If your component composes from canon tokens, you do not need to re-check contrast.
  • Icon-only buttons: always carry aria-label. The canon gy-button warns at the console if a button has no slotted text + no aria-label.
  • Live regions: dynamic status displays (deploy progress, run health) use aria-live="polite" by default; aria-live="assertive" is reserved for outage / error states.
  • Reduced motion: any animation longer than ~250ms must check prefers-reduced-motion: reduce and shorten or skip.

Migration: inline → canon → gyrum-ui

Application code that re-implements a primitive is one of the most common sources of design drift. The promotion path takes a primitive from a local app component to a canonical Svelte source under gyrum-ui in three named phases (per ADR-182).

  1. Phase 1 — inline (current)

    A primitive lives in ai-frontend/src/lib/components/<Name>.svelte. Couples to app-specific data types; usable only inside ai-frontend. The visual + structural shape is settled enough to be extracted.

  2. Phase 2 — canon-tier extraction

    Move to src/lib/components/canon/<Name>.svelte in ai-frontend. Decouple from app state — accept data via typed props. Add a Storybook story + a smoke spec asserting warp#974 console-cleanness. The canon path is the consumer-visible contract.

    Recipe: see warp#2501 (epic-card), warp#2503 (activity-panel), warp#2511 (version-pill) — three recent canonisations with the full extraction + story + spec shape.

  3. Phase 3 — promotion to gyrum-ui (warp#2278 Phase 4 / ADR-182)

    Port to Svelte source at gyrum-ui/packages/svelte/src/Gy<Name>.svelte. Consumers import via @gyrum-labs/svelte/Gy<Name>.svelte (subpath exports — no barrel). Add a manifest entry; the canon site picks it up on the next scripts/sync-components.sh run. The legacy Lit path under packages/components/ is in maintenance and is not the target for new lifts (ADR-181 superseded by ADR-182 on 2026-05-14).

    The application-library page tracks which canon-tier primitives are still in Phase 2 (the "pending promotion" tier).

Contributing a new primitive

  1. Open an ADR in dark-factory/docs/decisions/ if the primitive introduces a new convention (token family, composition pattern, accessibility shape).
  2. Author the Svelte 5 source under gyrum-ui/packages/svelte/src/Gy<Pascal>.svelte per ADR-182. (The legacy gyrum-ui/packages/components/ Lit package is in maintenance — do not add new entries there.)
  3. Write a Storybook story covering the prop space + at least one empty/error variant.
  4. Add the subpath export to gyrum-ui/packages/svelte/package.json and the named export to packages/svelte/src/index.ts.
  5. Append a row to gyrum-ui/canon-manifest.yaml with tier, name, and source path fields.
  6. Run npm run build:all + npm run test:all locally; ship through gyrum-review-pr + gyrum-complete-pr.
  7. Open a follow-up PR against this repo (gyrum-labs/canon) that runs scripts/sync-components.sh and commits the regenerated data/stories-manifest.json.

Machine-consumable API

Canon exposes a stable plain-HTTP surface for fleet agents (gyrum-brief, canon-extension-gate, the persona reviewers) to fetch a rule's frontmatter fields without cloning the canon repo. Per ADR-185 these URLs are the triple-derive contract's machine surface — treat them as a stable interface.

Endpoint shape

GET https://canon.gyrum.ai/api/rule/<slug>/<field>

Slug: any principle or guideline doc-slug whose markdown source carries the triple-derive frontmatter (warp#3717).

Field (kebab-case in the URL; mapped to snake_case keys in the source frontmatter):

  • prompt-snippet — the 3-7 line agent-facing avoid-this text gyrum-brief injects into dispatch briefs.
  • gate-failure-template — the 6-field gate-output template with {file} + {line} placeholders.
  • triggers — the YAML block declaring when the rule applies (file globs, ticket types, context imports). Returned as plaintext YAML so the consumer can parse it back with any standard YAML library.

Response contract

  • 200 — body is the raw field value as plaintext; Content-Type: text/plain; charset=utf-8; Cache-Control: public, max-age=300 (5 minutes).
  • 404 — unknown slug, unknown field, or doc lacks the requested field. The body is canon's standard 404 page; consumers distinguish by status code.
  • CORS: Access-Control-Allow-Origin: *. No auth, no secrets — the surface is public-readable.

Example

$ curl https://canon.gyrum.ai/api/rule/canon-extension-discipline/prompt-snippet
Consumer-tier <style> blocks importing a canon primitive must satisfy
ONE of three paths per declaration:
1. Token-only: <=3 properties, all values are var(--*), no layout /
   sizing / positioning / transform properties.
…

Source: src/routes/api/rule/[slug]/[field]/+server.ts. Adding a new field requires extending src/lib/rule-frontmatter.ts + the entries() hook in the route file (one entry per field) so adapter-static prerenders the new combinations.