← Back to home

Design-system packs — author's guide

Status: contract documented; Mantine and shadcn are the reference implementations.

A design-system pack is the unit of pluggable UI in Loom's frontend generators — pluggable across React, Vue, Svelte, Angular, and Phoenix HEEx. When a user writes design: <name> in a .ddd source's deployable webApp { ... } block, the React generator loads that pack and renders every UI surface (pages, forms, tables, cells) through it. Built-in packs are mantine, shadcn, mui, chakra (React), vuetify, shadcnVue (Vue), shadcnSvelte, flowbite (Svelte), angularMaterial, primeng, spartanNg (Angular), and coreComponents, daisyui (HEEx); custom packs are any directory the user points the slot at.

This document is the contract. If you're writing a third design pack — for Material UI, Chakra, your in-house system, whatever — this is what you must satisfy.

1. Repository layout

The generator sees three categories of template directories at the repo root:

designs/{mantine,shadcn,...}/    # one directory per design pack
vite/                            # pack-agnostic Vite scaffold (shared)
api/                             # pack-agnostic API integration (shared)
docker/                          # pack-agnostic deployment artifacts (shared)

A pack is a single directory under designs/ (or anywhere on disk for user packs) containing one pack.json manifest plus one .hbs file per logical name in the manifest's emits map.

The three sibling directories (vite/, api/, docker/) supply templates the pack inherits for free — see §6.

2. The pack.json manifest

{
  "name": "mantine",
  "version": "v9",
  "format": "tsx",
  "stack": "v3",
  "emits": { "<logical-name>": "<file-name.hbs>", ... },
  "imports": { "<logical-name>": [{ "from": "...", "named": [...] }], ... },
  "shellFiles": { "<logical-name>": "<output-path>" },
  "shellGlobs": { "<glob>": "<output-pattern>" },
  "helpers": { "lucide": { "IconPlus": "Plus", ... } }
}

A pack manifest declares what to emit (emits, shellFiles, shellGlobs), what to import in the emitted code (imports, helpers), and which cross-cutting stack to ride (stack). The stack carries React / router / Zod / Vite versions; see § 2a Stacks and how a pack picks one below.

A custom pack is checked before anything is generated. For design: "./my-pack" (resolved against the directory of the .ddd file that declares the deployable, not the working directory), ddd parse and ddd generate load the pack and refuse a malformed one with a single loom.design-pack-invalid error naming the defects: no pack.json (or not valid JSON), no emits map, an emits entry whose .hbs is missing, an unknown format or stack, a missing required template (§ 3), a chrome message that is empty, carries a markup-significant character (<, >, ", a backslash, a backtick, ${) or a stray brace, a template naming an undeclared chrome role, a HEEx template passing ICU hole values to chrome, a shellFiles key not in emits, a template that is not valid Handlebars, or a {{> partial}} nothing provides. A pack whose format is not the framework's is loom.design-pack-format-mismatch. The editor (and the playground) cannot read the directory, so there the only signal is the loom.design-pack-custom-unchecked warning.

format

Optional, defaults to "tsx". Discriminates the output language the pack's templates produce — "tsx" for React/Mantine/shadcn-style packs (Handlebars over .hbs files yielding TSX); "heex" for Phoenix LiveView packs (Handlebars over .heex.hbs files yielding HEEx, e.g. the built-in coreComponents and daisyui packs); "svelte" for Svelte 5 / SvelteKit packs (Handlebars over .hbs files yielding Svelte markup, e.g. the built-in shadcnSvelte and flowbite packs — these declare the sv1 stack and own the full TSX-style required surface incl. forms and field inputs, plus a svelte-config shell template). "vue" for Vue 3 packs (Handlebars yielding Vue SFC template markup, e.g. the built-in vuetify and shadcnVue packs).

Vue-pack authoring note — the mustache collision: Handlebars and Vue both use {{ … }}. Generation-time VM substitutions stay plain Handlebars ({{label}}, {{{onClick}}}); a literal Vue runtime interpolation must be written \{{ … }} (Handlebars emits the literal mustache). JS-splicing attributes use single quotes (@click='{{{onClick}}}') because rendered JS carries double-quoted string literals. Vue packs additionally own the op-dialog template (the operation-modal wrapper the page shell renders) — see REQUIRED_PRIMITIVES.vue.

"angular" is the format for standalone Angular packs (Handlebars over .hbs files yielding Angular component markup, e.g. the built-in angularMaterial pack — it declares the ng1 stack and emits an angular-json shell instead of the Vite world's vite-config). Angular's forms render inline through the walker (the renderCreateForm / renderModal seams emit typed Reactive Forms directly, not pack templates), so an angular pack owns the display / layout / input surface only — it ships no form-of / field-input-* / form-* / op-dialog / modal templates (see REQUIRED_PRIMITIVES.angular, the one frontend required-set that subtracts the form family). Like Vue, JS-splicing event bindings use single quotes ((click)='{{{onClick}}}').

The Handlebars compiler is content-agnostic, so format does not change template compilation. It DOES gate which repo-root shared template directories the loader pulls in:

Format Shared dirs read
tsx (default) vite/, api/, docker/
heex phoenix/ (empty today — coreComponents/daisyui ship their shell files directly)
svelte sveltekit/ (api client + logger + root layout + the SvelteKit dockerfile)
vue vue/, api/, docker/ (the api/ fetch-client layer is framework-neutral TS)

A pack's filename convention should match its format (*.hbs for tsx, *.heex.hbs for heex), but the loader keys off the manifest's emits paths verbatim — this is convention, not enforcement.

name, version

Display strings. The generator does not use name for resolution (directory name + DSL slot value drive that); it surfaces in error messages.

emits — required

Map of logical template name → filename, relative to the pack directory. Every logical name the generator needs MUST appear here. The loader throws on missing entries; it does not silently fall back. See §3 for the full list of required logical names.

imports — optional

Per-primitive map declaring what the generated code must import to use that primitive. Used by the body-walker: when the page metamodel references a stack, the walker calls primitive-stack and looks up this map to know which package the named exports come from.

"imports": {
  "primitive-button": [{ "from": "@/components/ui/button", "named": ["Button"] }],
  "primitive-card":   [{ "from": "@/components/ui/card",   "named": ["Card", "CardContent", "CardHeader", "CardTitle"] }]
}

Mantine maps almost every primitive to @mantine/core; shadcn maps them to local @/components/ui/* paths (because shadcn ships components as source — see shellGlobs).

shellFiles — optional

Pack-specific files emitted as part of the project shell, declared by logical name → output path. Used for one-off files like tailwind.config.ts or postcss.config.js that a pack needs but aren't a regular template emission.

"shellFiles": {
  "tailwind-config": "tailwind.config.ts",
  "postcss-config":  "postcss.config.js",
  "globals-css":     "src/globals.css",
  "lib-utils":       "src/lib/utils.ts"
}

shellGlobs — optional

Glob-pattern → output-pattern map for emitting multiple files of the same kind. shadcn uses this to copy its 14 components-ui-*.hbs templates to src/components/ui/<name>.tsx:

"shellGlobs": {
  "components-ui-*": "src/components/ui/{1}.tsx"
}

The {1} substitution is the capture from the * in the input glob. Mantine doesn't use this because its components come from an npm package, not source files.

chrome — optional

Role → English for every user-visible string this pack bakes into its own templates — an array field's Remove, an op dialog's This operation has no parameters., a bool cell's Yes/No, an empty picker's Select….

"chrome": {
  "removeItem": "Remove",
  "addItem": "Add {item}",
  "boolTrue": "Yes",
  "boolFalse": "No"
}

Why a declaration and not a scrape of the .hbs. Loom's user-visible-string extraction pass (M-T1.11) walks the IR, so it sees strings an author wrote in .ddd and nothing else — a word that exists only in a template is invisible to it, and therefore untranslatable. Scraping the templates instead would have to tell Remove apart from VariantProps, an @doc string and a CSS class, and a pack author would have no way to opt a string out. It would also produce no role, and the role is what makes two occurrences of Close in one pack distinguishable to a translator.

Each entry becomes one catalog key:

pack.<family>.<role>.<hash>

<family> is name, not name@version: two versions of one family that spell Remove identically share a key, and a translator translates it once. <hash> is a content hash of the message (D-I18N-KEY), so two versions that do diverge get separate keys automatically, and rephrasing a string re-keys it — ddd i18n sync then sees a delete-old + add-new rather than silently keeping a translation of the old wording. The pack. namespace cannot collide with the authored-string keys (page.* / component.* / menu.*) or with the toolchain's own curated chrome.* table.

Swapping a pack does not throw the translations away. Because <family> is part of the key, moving a UI from shadcn@v4 to mui@v7 re-keys every chrome string the two packs spell identically — pack.shadcn.removeItem.<h> becomes pack.mui.removeItem.<h>, with the same <h>. ddd i18n sync follows that rename and carries the existing translation onto the new key (merge case 5, src/i18n/merge.ts), reporting each one as carried and naming both keys so a reviewer can re-check the wording against the new pack's UI:

$ ddd i18n sync app.ddd
  de: +0 new, 41 kept, 6 carried, -8 dropped
      carried: pack.mui.removeItem.k9d3x1
            <- pack.shadcn.removeItem.k9d3x1 (design-pack family swap; same role, same message)

The carry is deliberately narrow: same <role>, same <hash>, the old key gone from the fresh extraction, the new key untranslated, the lock (locales/.loom/source.lock.json) proving the English is byte-identical, and the donor a real translation rather than a TODO:/conflicted value. Anything ambiguous — two dropped families offering one wording, or two new families wanting one — carries nothing and falls back to a TODO:. A role the new pack spells differently has a different hash and still re-keys, which is the whole point of hashing the message.

Templates spell a declared string through four helpers, which are bound into every pack.render(...) (including partials):

Helper Position i18n OFF i18n ON (tsx)
{{{chrome "removeItem"}}} markup text Remove {t("pack.…", "Remove")}
{{{chrome "addItem" item=elementLabel}}} markup text, ICU holes Add Line {t("pack.…", "Add {item}", { item: "Line" })}
{{{chromeAttr "aria-label" "paginationLandmark"}}} a whole attribute aria-label="Pagination" aria-label={t("pack.…", "Pagination")}
{{{chromeValue "operationSucceeded" operation=humanOp}}} a target-language value "Ship succeeded" t("pack.…", "{operation} succeeded", { operation: "Ship" })
{{{chromeImport "../i18n"}}} the runtime import (nothing) import { t } from "../i18n";

chromeAttr takes the attribute NAME because a bound attribute changes shape per framework — Vue grows a : prefix, Angular brackets ([attr.aria-label]), HEEx an expression body. Hole values are compile-time strings from the template's own data, which is what lets the OFF path splice them in verbatim.

chromeImport is for WHOLE-FILE templates only (format-helpers, a shadcn components/ui/*), and the specifier is yours to give: only the pack knows where its file lands relative to the generated i18n module. A FRAGMENT template (a form field, an op dialog) must not use it — its output lands inside a page whose import block the walker owns, and that path is wired automatically. HEEx packs pass the web module instead ({{{chromeImport webModule}}}), which emits use Gettext, backend: ….

i18n OFF is byte-identical by construction. The binding starts off and each helper returns exactly the bytes the template used to spell inline; a frontend switches it on only for a UI that is already translatable by its authored strings. Pack chrome therefore never turns the translation runtime on for an app that has nothing else to translate.

Two gates apply, both in test/generator/pack-declared-chrome.test.ts: every declared role must be rendered by some template in the same pack (no dead catalog entries), and no pack template may leave user-visible English in a markup text node or a user-facing attribute — that scan is what stops the next hardcoded word from shipping untranslatable.

helpers — optional

Pack-specific lookup tables consulted by helpers in templates. Today the only registered helper is lucide, used by the shadcn pack to translate Tabler-style icon names from the DSL (IconPlus) to Lucide's names (Plus).

stack — required for tsx packs

Identifier of the stack the pack rides — a coherent React + router + Zod + Vite + TypeScript dep bundle shipped under stacks/<id>/ at the repo root. This separates what UI library you use (the pack) from what underlying runtime you build against (the stack). See § 2a.

2a. Stacks and how a pack picks one

Multiple pack families (mantine / shadcn / mui / chakra) all run on React. Rather than each pack restating its React / router / Zod / Vite versions independently — which used to drift and cause upgrade pain — the project ships a small set of stacks under stacks/<id>/ and every pack declares which one it rides.

Stack React Router Zod TypeScript Vite Used by
v3 19.2 react-router@^7 4 6 8 the React packs: chakra@v3, mantine@v9, mui@v7, shadcn@v4
sv1 (Svelte 5 / SvelteKit) SvelteKit routing 4 6 8 the svelte packs (shadcnSvelte, flowbite)
vue1 (Vue 3) vue-router 4 6 8 the vue packs (vuetify, shadcnVue)
ng1 (Angular 22) @angular/router 4 6 (Angular build) the angular pack (angularMaterial)

Each stack ships:

stacks/<id>/
├── stack.json                  # id + description only (a human label; not read by codegen)
├── stack-package-deps.hbs      # dep names + ranges injected into the pack's package.json
└── stack-package-devdeps.hbs   # devDep names + ranges injected into the pack's package.json

stack.json is a two-field label (id, description). The real dependency pins live in the *.hbs fragments — the only files the loader reads. The playground bundler derives its React-runtime policy from the emitted package.json (the React major), not from any field in stack.json.

How stack flows through the pipeline

  1. Pack manifest declares "stack": "v3".
  2. The pack loader (src/generator/_packs/) pulls the two *.hbs snippets from stacks/v3/ into the shared-partials map.
  3. When the bundler renders the pack's package-json.hbs, it injects the stack's deps/devDeps into the emitted package.json — the pack's own package-json.hbs only lists pack-specific deps (e.g. @mantine/core, not react).
  4. Router imports the generator emits itself (page bodies, and the {{routerPackage}} shell variable) use react-router, the Router 7 package every React stack ships (ROUTER_PACKAGE in stack-runtime.ts).

Authoring rule

A pack version's package-json.hbs MUST NOT restate framework deps already covered by its stack (react, react-dom, react-router*, zod, typescript, vite). Restating them creates drift between the package.json the bundler emits and the stack's intent. The validation step (§ 8) flags overlap.

The partial-newline rule (or package.json comes out mangled)

Handlebars strips the newline after a partial call that is standalone — alone on its line. So whether a fragment ends with a trailing newline is not cosmetic; it has to match how its callers spell the call, and 7 of 13 packs shipped

    "typescript": "6.0.3",
    "vite": "8.3.5"  }
}

because the two disagreed. The rule, which test/generator/_packs/package-json-shape.test.ts enforces by re-serializing every pack's emitted package.json and diffing:

  "devDependencies": {
    "@types/node": "22.20.5",
{{> stack-package-devdeps}}  }

A new stack copies both conventions; the gate tells you within a second if it did not.

3. Required emits

The generator dispatches the following 80+ logical names. Every pack must emit a file for each. Logical names are conceptual contracts; the .hbs file behind each can render whatever the design system needs to fulfill the contract.

Project shell (3)

Name Purpose
theme TS module exporting the design system's theme tokens. May be a stub if the pack handles theming elsewhere (e.g. shadcn projects tokens into globals.css).
main App entrypoint. Mounts React, sets up the router, wraps <App> in pack-specific providers (e.g. <MantineProvider> or <Toaster>).
package-json package.json for the generated app. Declares pack-specific dependencies.
tsconfig tsconfig.json. shadcn adds path mappings for @/*.
vite-config vite.config.ts. shadcn adds Vite resolver alias for @/*.
format-helpers Per-pack runtime helpers (IdValue, DateTimeValue, BoolValue, NumberValue, EmptyValue, KeyValueRow, and the money helper — see the money-display contract below). Output written to src/lib/format.tsx (src/lib/format.ts on Vue/Svelte/Angular).
app-shell App-shell layout: navbar/sidebar/main outlet. Under auth: ui (the authUi view-model flag) it also renders the Sign out control (data-testid="sign-out", label via {{{chrome "signOut"}}}) in its header/toolbar — the pack-agnostic AuthGate renders none, so it never floats over page content. Pinned for every pack by test/generator/_frontend/sign-out-in-shell.test.ts.
home Home/dashboard page.

The money-display contract (a pack does not own it)

Loom money has no currency dimension and rides the wire as the RS-12 fixed-scale decimal string ("12.3456", scale 4). A pack must therefore not invent a symbol or a fraction-digit count of its own: money display is one toolchain-wide contract, not fifteen pack opinions.

The algorithm lives in src/generator/_frontend/money-format.ts as the MONEY_TEXT_SOURCE constant, spliced into every pack's format-helpers emit through the {{{moneySource}}} variable the React / Vue / Svelte / Angular emitters always pass. A pack's own helper (MoneyValue on React, formatMoney elsewhere) keeps its markup wrapper and delegates to moneyText(value, currency?, decimals?):

Both knobs are pre-existing, user-declared Money(…) arguments; there is no pack-manifest knob. A new pack that hand-rolls its own money formatter fails test/generator/_packs/money-display-cross-pack.test.ts, which bans "USD", style: "currency" and Number( on the money path across every pack at once.

Page templates (4)

Name Purpose
page-list Aggregate list page: header + create button + table + empty/error/loading states.
page-detail Aggregate detail page: header + breadcrumbs + identity block + parts/operations.
page-new Aggregate creation page: react-hook-form scaffold.
operation-modal Modal/dialog rendering for aggregate operations.

Workflow + view templates (4)

Name Purpose
workflow-form One workflow's form page.
workflow-index All-workflows index page.
views-index All-views index page.
view-table One view's tabular page.

Field-input templates (10)

One per primitive type the DSL can declare as an input on a creation or workflow form.

Name DSL type
field-input-string string
field-input-int int
field-input-decimal decimal
field-input-bool bool
field-input-datetime datetime
field-input-id-select id (from a known aggregate, dropdown)
field-input-id-text id (raw text input fallback)
field-input-enum-select enum
field-input-valueobject value object (recursive)
field-input-array array of any of the above

Field-row templates (8)

Field-rows render an aggregate's fields on the detail page. Each wraps <KeyValueRow> from format-helpers.

Name DSL kind
field-row-string string
field-row-id id
field-row-id-link id with aggregate link
field-row-datetime datetime
field-row-bool bool
field-row-number int / decimal
field-row-enum enum
field-row-valueobject value object (recursive)

Cell templates (8)

Cells render an aggregate's columns on the list page and view tables. Each wraps the pack's <TableCell>-equivalent.

Name DSL kind
cell-string string
cell-id id
cell-id-link id with link
cell-row-id-link row-level id link
cell-datetime datetime
cell-bool bool
cell-number int / decimal
cell-enum enum

Aggregate piece templates (2)

Name Purpose
part-table Table rendering aggregate "parts" (collection-of-X on the detail page).
op-button A single operation-trigger button on the detail page.

Realtime templates (1, tsx only)

Name Purpose
realtime-toast One toast statement rendered into RealtimeHandlers.tsx per on <channel>.<Event> { toast(…) } handler (channels.md Part I). Receives {{{message}}} (a TS expression). Declare the toast lib under imports["realtime-toast"]. Packs whose toast is hook-based may ship an optional realtime-toast-setup line rendered inside the component body (chakra v2's const toast = useToast();).

Walker primitives (22)

The body-walker dispatches every page-metamodel node through these primitives. The walker calls pack.render("primitive-X", vm) and expects the pack's template to produce design-system-appropriate JSX.

Primitive Use
primitive-heading Section heading
primitive-text Body text
primitive-divider Visual separator
primitive-stack Vertical flex container
primitive-group Horizontal flex container
primitive-toolbar Horizontal toolbar (group variant)
primitive-grid Grid container
primitive-container Width-constrained container
primitive-empty Empty-state message
primitive-button Action button
primitive-card Card with title + content
primitive-stat Stat block (label + value)
primitive-badge Status pill / chip
primitive-field Labeled text input
primitive-toggle Labeled switch / checkbox
primitive-number-field Labeled number input
primitive-password-field Labeled password input
primitive-loader Loading spinner
primitive-anchor Link / <a>
primitive-image <img> wrapper
primitive-avatar User-avatar component
primitive-tabs Tabs container with header + content slots

4. Inherited shared sources

A pack does NOT need to emit these — the generator pulls them from the repo-root sibling directories:

Source dir Logical name → output path
vite/ index-html → index.html
tsconfig-node → tsconfig.node.json
api/ api-client → src/api/client.ts
api-config → src/api/config.ts
docker/ dockerfile → Dockerfile
dockerignore → .dockerignore

A pack CAN override any of these by emitting a template with the same logical name — the loader registers shared partials first, then pack templates, so pack entries win on collision. Don't override unless you have a real reason (e.g. a pack that needs to be deployed as a Cloudflare Worker instead of a Docker image could replace dockerfile with a wrangler-config of its own).

5. Template language

Templates are Handlebars 4 (handlebars npm package). The generator registers these helpers globally:

Some view-models additionally carry a context function — called like a helper, but supplied per render by the walker because its output is framework-shaped. primitive-anchor has one:

The app-shell's nav entries carry the same split for their LABELS, because a label authored in a menu { … } block is a translatable catalog string:

Both are triple-stache values. {{label}} still exists (the raw string) but rendering it directly re-opens the dead-catalog-key bug: the key is extracted, the app shows English at every locale.

Every template registered via emits is ALSO registered as a Handlebars partial under its logical name. This lets higher-level templates compose primitives:

<form>
  {{> primitive-button label="Save"}}
  {{> primitive-button label="Cancel"}}
</form>

The partial dispatch resolves to whichever pack is currently loaded — the same shared template produces Mantine output under one pack and shadcn output under another.

strict: true is on. Templates throw at render time if a referenced field is missing from the view-model. This is intentional: it forces the pack and the preparer to stay in sync; missing data surfaces immediately during tests instead of leaking blanks into output.

6. Distribution model: npm vs source-copy

The two reference packs illustrate the two distribution models a pack can use:

npm-package model (Mantine)

Components come from an npm dependency. Each primitive's imports entry points to @mantine/core (or similar); package-json declares those packages; the generated app npm installs them at boot.

Pros: no source files to ship; component updates flow via package updates; clean.

Cons: bound to the package's design decisions; pack can't customize a single component without forking the package.

Source-copy model (shadcn)

Components are TS files vendored into the generated project. Each component-ui template (components-ui-button.hbs, components-ui-table.hbs, …) renders the source of one component; shellGlobs emits each one to src/components/ui/<name>.tsx; imports for each primitive points at the local path (@/components/ui/<name>).

Pros: full ownership of every component; user can edit them after generation.

Cons: more templates to maintain; component "updates" mean editing your pack templates.

A custom pack can use either model or a mix. The contract is the same: every required logical name renders something that satisfies the preparer's view-model.

7. Helpers (special-purpose maps)

The helpers field carries pack-specific lookup tables consulted by generator-side code, not by templates directly.

lucide

shadcn's icon library is lucide-react. The DSL uses Tabler-style icon names (IconPlus, IconTrash, …). When the React generator emits an icon reference, it looks up helpers.lucide[<DSL name>] and substitutes the Lucide equivalent if present:

"helpers": {
  "lucide": {
    "IconPlus": "Plus",
    "IconTrash": "Trash2",
    "IconCheck": "Check"
  }
}

Mantine doesn't need this — @tabler/icons-react exports the DSL names directly.

8. Validating your pack

After authoring, run the following:

Generate and tsc-compile

node bin/cli.js generate system <your.ddd> -o /tmp/test-out
cd /tmp/test-out/web_app
npm install
npx tsc --noEmit

Generation must succeed; tsc must report zero errors. If templates reference an undeclared field, tsc will catch the resulting type error.

Vite build

npm run build

This catches issues that survive tsc --noEmit but break bundling: missing CSS imports, asset resolution, Tailwind config errors.

The LOOM_REACT_BUILD=1 gate

The repo ships test/e2e/generated-react-build.test.ts which does the above for the example systems × the built-in React packs (mantine, shadcn, mui, chakra). Add your custom pack to its cases for ongoing coverage:

LOOM_REACT_BUILD=1 npx vitest run test/e2e/generated-react-build.test.ts

Side-by-side visual check

The playground ships three storybook examples (storybook-mantine.ddd, storybook-shadcn.ddd, storybook-components.ddd). Generate yours against the storybook examples and visually compare against Mantine and shadcn output for the same DDL.

8a. The spacing and chrome contract

Loom's promise is "same model, same page, different skin". Structurally that always held; visually it did not. Rendering ONE .ddd (the four storybook examples differ by a single design: line) through the packs measured, in the DOM:

concern what the packs actually did
root page Stack gap 0 (mui) / 8 (chakra) / 12 (vuetify) / 16 px (mantine, shadcn, flowbite)
Group gap 4 / 8 / 12 / 16 px — mui and chakra both wrote gap={1}, but MUI's spacing unit is 8px and Chakra's token 1 is 4px
form submit-row offset mt="sm" 12px / sx={{mt:1}} 8px / mt={1} 4px / pt-2 8px-as-padding
Card inner padding 16 / 16+24 / 24 / 24 px
<main> padding 16 / 24 / 24 / 16→24 px
wide table at 390px 7 of 8 packs scrolled the DOCUMENT sideways; two wrapped the table in a scroll container

Every one of those is a pack that never stated its intent and inherited whatever its library defaulted to. So the contract below is not only a set of numbers — it is the rule that a pack states its spacing explicitly. An unstated gap is what let four packs drift, and a library upgrade would move it again without a diff.

The contract lives twice: as prose here, and as data in src/generator/_packs/spacing-contract.ts. test/generator/_packs/pack-spacing-contract.test.ts is the gate — it reads every pack's governed templates, resolves each pack's own spelling back to pixels, and fails outside SPACING_TOLERANCE_PX.

The scale

token px what it separates
xs 4 hairline offsets inside one control
sm 8 ADJACENT CONTROLS — buttons in a row, a label from its value
md 16 BLOCKS — the things stacked down a page, a surface's inner padding
lg 24 the page from its chrome (the <main> inset on a wide screen)
xl 32 reserved; no primitive uses it yet

Two rhythms, one page: md between blocks, sm between adjacent controls. Anything else is a deviation, and the gate names it.

The governed concerns

concern contract
stack.gap md — the page's vertical rhythm
group.gap sm — adjacent controls
toolbar.gap md, cross-axis centred, main-axis space-between
paper.padding md
card.padding md — body AND header, when the pack splits them
keyValueRow.gap sm, with the label a fixed 140px column and the value adjacent to it — never space-between, which strands the value 1000px away on a wide screen
formSubmitRow.marginTop md (a margin, not padding)
formSubmitRow.gap sm
main.padding md below the lg breakpoint, lg at and above it
main.contained <main> sets min-width: 0
table.scrollContainer every table sits in a full-width overflow-x: auto container
container.size Container { size: … } reaches the rendered max-width
breadcrumbs.semantics a named nav with a separator between crumbs
navSection.label 12px / 600 / uppercase / 0.05em tracking / muted
chart.integerAxis an integral series draws integer ticks

Two of these are structural rather than numeric, and both were found the same way — by measuring a phone-width viewport:

How each pack spells the scale

The gate resolves these dialects back to pixels; a new pack declares which one it speaks.

dialect packs gap of md padding of md unit
mantine mantine v9 gap="md" p="md" / padding="md" named scale (md = 16)
mui mui v7 gap={2} sx={{ p: 2 }} 8px
chakra chakra v2/v3 gap={4} p={4} 4px
tailwind shadcn v3/v4, shadcnVue, shadcnSvelte, flowbite gap-4 p-4 4px
vuetify vuetify v3 ga-4 pa-4 4px
css angularMaterial, primeng, spartanNg gap: 16px in the pack's loom-* rules padding: 16px literal

The 8-vs-4 unit split is exactly what produced the gap={1} divergence: copying a literal between two of these dialects silently halves or doubles it. Copy the TOKEN, never the number.

A pack whose scale is NAMED rather than numeric takes its nearest step, and the tolerance band is what admits it: Mantine has no 8px entry (xs is 10, sm is 12), so Group and KeyValueRow — both sm = 8px in the contract — spell gap="xs" and resolve to 10px, two inside the band. Reaching for gap={8} instead would hit the number exactly and take the pack off its own scale, which is the trade the band exists to refuse.

The typography half — documented, NOT gated

One heading level: 2 measured 14px on chakra, 24px on shadcn, 26px on mantine and 60px on mui. The intended ladder is h1 30 / h2 24 / h3 20 / h4 16 / h5 14 / h6 12, with a wider tolerance band than the spacing one (±4px) so a pack may keep its own rem steps — Mantine's 1.625rem h2 = 26px would pass, while 14px and 60px would not.

No gate measures this today. scripts/measure-pack-spacing.mjs reads SPACING_CONTRACT only; the ladder and its band lived in spacing-contract.ts as two exported constants that nothing ever read, and were deleted with the rest of the never-referenced export surface in Wave CR1 packet g (audit row P1-4). The numbers stay here because they are the measured motivation; re-declaring them in code belongs in the same PR as the probe that checks them, not before it.

Adding a primitive, or a pack

Measuring the real DOM

The template-level gate proves the pack SAYS 16px. What it cannot prove is that the library then RENDERS 16px. scripts/measure-pack-spacing.mjs closes that gap: point it at running generated apps and it measures the computed styles of the same page across packs and asserts the same contract module. It is opt-in (LOOM_PACK_SPACING_DOM=1 npm run test:pack-spacing-dom) because it needs a browser and a booted stack per pack — see testing.md.

9. Architectural rules

These rules constrain pack authorship and the shared-template layer both. See audits/pack-equivalence-audit.md for the empirical evidence behind them.

  1. Low-level design-system-dependent templates stay per-pack and dead-simple, even with some duplication. Two packs both rendering <Badge>{{label}}</Badge> are not duplicating — the <Badge> resolves to different imports in each pack, so the shape-match is coincidental.

  2. Templates land in a shared directory (vite/, api/, docker/) only when they have ZERO design-system content. Pure project scaffold (Dockerfile, index.html, fetch wrapper) is shareable; anything that references a design-system component is not.

  3. "Small differences" like <Table.Td> vs <TableCell> are design-system identity, not duplication. Don't try to unify them.

  4. One pack, one distribution model. But the contract is the same. The generator doesn't care whether your components come from npm or from vendored source; it cares whether each logical name renders correctly.

10. How a user picks your pack

In a .ddd file:

deployable webApp {
  platform: react
  design: ./path/to/your-pack    // relative to the .ddd file
  port: 3001
}

Or by absolute path:

design: /opt/loom-packs/your-pack

Or by built-in name — bareword resolves to the family's latest version, family@version pins explicitly:

design: mantine                  // → mantine@v9 (current default)
design: "mantine@v9"             // pinned to v9
design: shadcn                   // → shadcn@v4
design: "mui@v7"
design: chakra                   // → chakra@v3
design: coreComponents           // default for phoenixLiveView platform (daisyui also available)

The shipped families: mantine (v9), shadcn (v4), mui (v7), chakra (v3), the HEEx packs coreComponents (v3) and daisyui (v1), the vue packs vuetify (v3 — tracks Vuetify 3) and shadcnVue (v1 — the shadcn-vue flavour: reka-ui + Tailwind 4, source-copy distribution), the svelte packs shadcnSvelte and flowbite, and the angular pack angularMaterial. The current bareword defaults live in BUILTIN_PACK_LATEST in src/util/builtin-formats.ts.

The React 18 (stack v1) packs mantine@v7, chakra@v2, mui@v5 and shadcn@v3 were retired; pinning one is the validation error loom.design-pack-retired, which names the successor (RETIRED_PACKS in the same file).

The two self-hosting frontends take no pack. Feliz and Flutter render procedurally rather than through .hbs templates, so neither reads a pack family from design::

The loader (src/generator/_packs/loader-fs.ts:resolvePackDir) resolves identifiers in this order:

  1. Built-in family — any name matching a registered family (with optional @version) resolves under <repo>/designs/<family>/<version>/.
  2. Absolute path — design: "/opt/loom-packs/my-pack" is used verbatim.
  3. Relative path — anything else (e.g. design: "./my-pack", design: "../shared/my-pack") is anchored against the directory the .ddd source file lives in.

Custom packs follow the same pack.json + emits contract as built-in ones — see § 2. Stack selection ("stack": "vN") is required for tsx custom packs; the loader still resolves the named stack from the repo's stacks/ directory. Custom packs whose format is heex go through the same path but bind against designs/coreComponents/'s emit set (the baseline HEEx pack).

HEEx packs own a different surface than the JSX packs. LiveView has one component convention (CoreComponents), so the HEEx walker emits design-neutral markup + <.button>/<.table>/<.input>-style component calls inline and never dispatches call-site primitive-* templates. A HEEx pack instead owns the SHELL, rendered by the elixir generator via pack.render:

Logical name Output VM
core-components lib/<app>_web/components/core_components.ex { webModule }
main lib/<app>_web/components/layouts/root.html.heex { appName }
app-shell lib/<app>_web/components/layouts/app.html.heex { hasSidebar, webModule, currentUser, skipToContent, primaryNavAria }
sidebar (+ sidebar-entry partial) lib/<app>_web/components/sidebar.ex { webModule, appName, authEnabled, sections }
theme priv/static/assets/theme.css the shared ThemeVM
assets-css assets/css/app.css {}
assets-js assets/js/app.js {}
tailwind-config assets/tailwind.config.js { appName }
package-json assets/package.json { appName }

Because a page renders through those component calls, core-components is a CONTRACT, not just a file: the walker emits <.button>, <.table>, <.input>, <.modal>, <.badge>, <.empty>, <.pager>, <.header>, <.error>, <.label>, <.simple_form>, <.flash_group> and <.card>, so a HEEx pack must define every one of them (with the attrs the walker passes — e.g. <.button variant=…>, <.card title=… variant=… shadow=…>). A missing component or an undeclared attr is a mix compile --warnings-as-errors failure in the generated app, not a silent style regression.

Where the line falls between walker and pack: the walker owns design-NEUTRAL geometry — Stack/Group/Grid/Container/Toolbar emit Tailwind flex/grid utilities inline, since daisyUI (and any other LiveView design system) adds a COMPONENT vocabulary, not a layout one, and both packs build Tailwind from the same content globs. The pack owns anything a design system actually decides — the card surface's border/elevation/padding/title typography, a button's rank — which is why Card/Paper render through <.card> rather than a hardcoded class string.

The assets files build into priv/static/assets/app.{css,js} via the generated assets/package.json (tailwind + esbuild) — the Dockerfile's assets-build stage runs it for images, mix assets.build for host dev. The tailwind config is where a pack's plugin lands (daisyui does exactly that), which is what makes its component-class vocabulary actually render. The required set is REQUIRED_PRIMITIVES.heex = {core: [], shell: HEEX_SHELL} in src/generator/_packs/required-primitives.ts; divergence between the two built-in packs is gated by test/generator/elixir/heex-design-pack.test.ts.

11. Worked example — minimal pack skeleton

A truly minimal pack that just delegates everything to plain HTML elements (no design system):

my-pack/
  pack.json
  theme.hbs                       # empty export
  main.hbs                        # plain React boot
  app-shell.hbs                   # <div>{children}</div>
  home.hbs
  ...
  primitive-button.hbs            # <button>{{label}}</button>
  primitive-card.hbs              # <div className="card">...
  ...
{
  "name": "minimal",
  "version": "0.0.1",
  "emits": { "theme": "theme.hbs", "main": "main.hbs", ... }
}

No imports map needed — every primitive uses plain HTML elements, no external components to import. No shellFiles / shellGlobs / helpers.

This would still produce a working app: ugly, but type-correct, and the contract is satisfied.

12. Adding a new pack version — recipe

When a pack family ships a new major (e.g. Mantine 8 → 9), fork the directory rather than mutating the existing one. The recipe is mechanical; the upstream library's own migration guide is doing the real thinking.

Step 1 — Audit before forking

Read the upstream migration guide. Then grep the existing pack's templates for every deprecated / renamed API. No match = no template change needed in that area; the migration is package-json-only.

# Search for prop renames documented in the upstream migration guide.
grep -nE 'color=|isOpen=|gutter=|in=|spacing=' designs/<family>/<vOld>/*.hbs

# Search for components that were renamed or removed.
grep -nE '<Divider|<Modal|<Drawer\b' designs/<family>/<vOld>/*.hbs

# Search for hooks whose signature changed.
grep -nE 'useToast|useDisclosure|useFullscreen' designs/<family>/<vOld>/*.hbs

# Search for upstream package imports that may have moved subpaths.
grep -nE "from \"@<family>/" designs/<family>/<vOld>/*.hbs

Step 2 — Fork the directory

cp -r designs/<family>/<vOld> designs/<family>/<vNew>
# Bump the manifest's `version` field to match the directory.
sed -i 's/"version": "<vOld>"/"version": "<vNew>"/' \
  designs/<family>/<vNew>/pack.json

The loader cross-checks pack.json's version against the parent directory name and throws on mismatch. The cross-check catches copy-paste forks that leave the manifest stale.

Step 3 — Pick a stack

The cross-cutting framework deps (React, react-router, zod, Vite, TS) live in stacks/<id>/, not in each pack. Decide which stack the new pack version targets (see § 2a):

Then designs/<family>/<vNew>/package-json.hbs carries only the pack-specific deps, with the framework deps pulled in via the stack partials:

{
  ...
  "dependencies": {
{{> stack-package-deps}},
    "@<family>/core": "^<newMajor>.0.0",
    ... pack-specific deps only ...
  },
  "devDependencies": {
{{> stack-package-devdeps}}
  }
}

If a pack needs an extra devDep the stack doesn't supply (shadcn's @types/node, say), append it after the partial.

Step 4 — Apply template changes

For each row in your Step-1 worklist, edit the templates. Mechanical piece (boolean prop renames, component renames) are amenable to sed -i or the upstream codemod (chakra and mui ship them). Compound-component restructuring (Chakra v3) is hand-work.

Always emit named-import createRoot in main.hbs:

// CORRECT — works under both React 18 and 19:
import { createRoot } from "react-dom/client";
createRoot(document.getElementById("root")!).render(<App />);

// WRONG under React 19 — type-checks but explodes at runtime:
import ReactDOM from "react-dom/client";
ReactDOM.createRoot(document.getElementById("root")!).render(<App />);

Step 5 — Register the new version

Add the qualified name to src/util/builtin-formats.ts:

export const BUILTIN_PACK_FORMATS = {
  // ... existing entries ...
  "<family>@<vNew>": "tsx",  // or "heex" for HEEx (Phoenix LiveView) packs
} as const satisfies Record<string, "tsx" | "heex">;

Don't flip BUILTIN_PACK_LATEST in the same PR — that's a separate "promote" PR paired with refreshing the byte-equivalence fixture under test/fixtures/baseline-output/.

Step 6 — Add the version to the test matrix

test/e2e/generated-react-build.test.ts:

const PACKS: readonly PackSpec[] = [
  // ... existing entries ...
  { family: "<family>", version: "<vNew>" },
];

.github/workflows/generated-react-build.yml:

pack: ["mantine@v9", "shadcn@v4", ..., "<family>@<vNew>"]

Step 7 — Add a pinned storybook example to the playground

So the in-browser dropdown can demo old + new side-by-side:

cp web/src/examples/storybook-<family>.ddd \
   web/src/examples/storybook-<family>-<vNew>.ddd
# Rewrite the `design:` slot to the pinned form.
sed -i 's/design: <family>/design: "<family>@<vNew>"/' \
  web/src/examples/storybook-<family>-<vNew>.ddd

Register it in web/src/examples/index.ts.

Step 8 — Verify

# Unit suite — should still pass clean.
npm test

# The new shard must pass both tsc --noEmit AND vite build.
LOOM_REACT_BUILD_CASE="web/src/examples/sales-system.ddd:<family>@<vNew>" \
  npx vitest run test/e2e/generated-react-build.test.ts

# Sanity-check at least one other shard didn't regress.
LOOM_REACT_BUILD_CASE="web/src/examples/sales-system.ddd:<family>@<vOld>" \
  npx vitest run test/e2e/generated-react-build.test.ts

# Playground build clean.
cd web && npm run build

# Playground e2e — at least the editor + workspace-persistence + the
# new pinned-storybook spec.
PLAYWRIGHT_BROWSERS_PATH=/opt/pw-browsers npx playwright test \
  e2e/editor.spec.ts \
  e2e/workspace-persistence.spec.ts \
  e2e/<family>-versions-pinned.spec.ts

The vite build step inside the shard is non-negotiable — it catches class-shape mismatches tsc --noEmit lets through.

Step 9 — Promote to default (follow-up PR)

In a separate PR after the new version has soaked:

  1. Flip BUILTIN_PACK_LATEST.<family> from <vOld> to <vNew> in src/util/builtin-formats.ts.

  2. Regenerate the byte-equivalence baseline:

    node scripts/capture-baseline-fixture.mjs
    
  3. Update test/playground/loader-vfs.test.ts's bareword expectation:

    expect(resolvePackDir("<family>")).toBe("/designs/<family>/<vNew>");
    

Anti-patterns to avoid

anti-pattern why
Skipping vite build in CI to "save time" tsc lets type-correct-but-runtime-broken code through
Skipping the runtime preview e2e for a new stack only the runtime gate catches regressions that type-check + bundle
Re-stating React / router / zod / Vite / TS deps in package-json.hbs the stack abstraction exists precisely to prevent this drift
Flipping BUILTIN_PACK_LATEST in the same PR as the new pack byte-equivalence fixture goes stale; two unrelated changes in one diff
Keeping forwardRef wrappers / <Context.Provider> "for symmetry with v7" new pack versions are clean breaks — no compat shims inside one pack
Adopting react-router-dom (v6 name) in new packs targeting an RR-7 stack v7 renamed to react-router
Letting manifest.version (or manifest.stack) drift from reality loader throws on version/dir mismatch; a wrong stack resolves the wrong framework deps
Externalising React for a React-19 stack the duplicate-ReactSharedInternals bug — React-19 stacks inline React on purpose
Promoting a new CSS-pipeline major (shadcn / Tailwind) with only the BUILTIN_PACK_LATEST flip the playground preview picks the Tailwind runtime from the bundled CSS (tailwindFlavor in web/src/preview/iframe-html.ts: @tailwind → the v3 Play CDN, @import "tailwindcss" → @tailwindcss/browser), so a new CSS major needs that in-browser path, proven by a deployed preview-runtime spec, before promotion; JS-runtime-styled packs (Mantine / Chakra / MUI) don't
Moving the DEFAULT pack's stack without grepping the tests walker suites that generate through the default pack hardcode framework literals (e.g. from "react-router-dom"); update them in the same PR
Depending on a package with a loose transitive peerDependency range esm.sh resolves it to the latest major in the playground (a chakra icons package pulled React-incompatible v3 despite a v2 pin); avoid such packages or pin tighter

13. References