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).
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).
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).
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.
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.
Tier
May compose
Must NOT compose
Atom
Slots, props, css-tokens
Any other component
Molecule
Atoms, atoms-via-slot
Molecules, organisms, templates, pages
Organism
Molecules, atoms
Templates, pages, other organisms (if they're not a clear sub-shape)
Template
Organisms, molecules, atoms (rare)
Pages
Page
Templates (one), organisms via slots
Other 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.
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.
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.
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).
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.
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.
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
Open an ADR in dark-factory/docs/decisions/ if the primitive
introduces a new convention (token family, composition pattern, accessibility
shape).
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.)
Write a Storybook story covering the prop space + at least one
empty/error variant.
Add the subpath export to gyrum-ui/packages/svelte/package.json and the named
export to packages/svelte/src/index.ts.
Append a row to gyrum-ui/canon-manifest.yaml with tier, name, and source
path fields.
Run npm run build:all + npm run test:all locally;
ship through gyrum-review-pr + gyrum-complete-pr.
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.