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
- Pack manifest declares
"stack": "v3". - The pack loader (
src/generator/_packs/) pulls the two*.hbssnippets fromstacks/v3/into the shared-partials map. - When the bundler renders the pack's
package-json.hbs, it injects the stack's deps/devDeps into the emittedpackage.json— the pack's ownpackage-json.hbsonly lists pack-specific deps (e.g.@mantine/core, notreact). - Router imports the generator emits itself (page bodies, and the
{{routerPackage}}shell variable) usereact-router, the Router 7 package every React stack ships (ROUTER_PACKAGEinstack-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:
stack-package-deps.hbsends without a trailing newline — every pack appends its own dependencies right after it ({{> stack-package-deps}},), so the call is never standalone.stack-package-devdeps.hbsends with one — most packs call it standalone, and a pack with extra devDependencies puts them BEFORE the partial and lets the partial close the block:
"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?):
- default = verbatim, locale-neutral — the value's own digits, with no
Number()coercion, no locale grouping, no currency, no re-scaling, so the stored 4th decimal is visible on screen; decimals: nre-scales the digit string to exactly n fraction digits, half away from zero (the backends' and Postgres' rounding family), never through a float;currency: "EUR"prefixes the code the page source passed, verbatim (EUR 12.3456) — never a symbol the toolchain guessed.
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:
expr <value>— emits the value as a raw expression (no HTML escaping). Use for JS expressions that should render as-is, e.g.value={{expr valueExpr}}.json <value>— JSON-stringifies the value. Use for inline literals:defaultRadius={{json radius}}.
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:
navAttr "<attr>"— the whole link attribute (leading space included) for theAnchor(to:)destination. The PACK picks the attribute name (toon a ReactRouterLink,hrefon a plain<a>,routerLinkon Angular); the walker picks the spelling, so a literal path rendersto="/orders"while a computed one (to: "/greet/" + who) renders the framework's bound form (to={…}/:to="…"/[routerLink]="…"). Splice it with a triple stache and NO=:{{#if hasTo}}<a class="loom-anchor"{{{navAttr "href"}}}>{{{label}}}</a>{{else}}…{{/if}}Spelling the attribute by hand (
href={{{to}}}) drops every computed destination on the floor —tois the bare expression, kept only for the two procedural packs (Feliz, Flutter) whose output is not HTML.
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:
labelText— the label in text position, already escaped (i18n off) or bound as{t(key, default)}/{{ t(…) }}(i18n on).labelAttr "<attr>"— the same value as a whole attribute, for a pack that passes the label as a prop ({{{labelAttr "primary"}}}on MUI's<ListItemText>,{{{labelAttr "label"}}}on Mantine's<NavLink>).
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.
- Standard Handlebars:
{{#each}},{{#if}},{{#unless}},{{> partial-name}}, etc.
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:
main.contained+table.scrollContainerare one bug, fixed in two places. A<main>that is a flex child hasmin-width: auto, so a wide table inside it widens the flex item rather than overflowing it, and the DOCUMENT scrolls sideways. Wrapping the table alone does not help while<main>can still grow; settingmin-width: 0alone does not help while the table has no scroll container. Both, and a phone gets a table that scrolls under a page that does not.- The scroll container may be the pack's own element (
<div class="loom-table-scroll">on the Angular packs, the wrapper mantine now emits) or the library's (<TableContainer>on mui,<Table.ScrollArea>on chakra, the.v-table__wrappervuetify's<v-table>renders, theoverflow-autodiv inside shadcn's ownTable). The gate accepts either — what it refuses is a bare<table>.
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
- A new layout primitive that carries spacing gets a row in
SPACING_CONTRACT, a value in every pack, and a probe in the gate — in the same PR. A primitive whose spacing is unstated is a primitive that will diverge. - A new pack declares its dialect in the gate's pack table. If it
speaks a dialect no other pack speaks, add the resolver there; the
gate is the only place that knows how to turn
ga-4into 16.
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.
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.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."Small differences" like
<Table.Td>vs<TableCell>are design-system identity, not duplication. Don't try to unify them.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::
- Feliz repurposes the slot for a daisyUI theme (
design: "dracula"), validated againstDAISYUI_THEMES— an unknown theme isloom.design-theme-unknown. - Flutter has no
design:axis at all. It emits Material 3 widgets (src/generator/flutter/pack.ts) and lowering drops the value, so the generated project is identical withdesign: mantine, withdesign: shadcn, and with nodesign:line. Writing one warns (loom.design-pack-ignored).
The loader (src/generator/_packs/loader-fs.ts:resolvePackDir)
resolves identifiers in this order:
- Built-in family — any name matching a registered family
(with optional
@version) resolves under<repo>/designs/<family>/<version>/. - Absolute path —
design: "/opt/loom-packs/my-pack"is used verbatim. - Relative path — anything else (e.g.
design: "./my-pack",design: "../shared/my-pack") is anchored against the directory the.dddsource 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):
- A React pack version (React 19 + RR 7 + zod 4) declares
"stack": "v3". - If the framework baseline you need doesn't exist yet, create a new stack first (see § 2a — Adding a new stack).
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:
Flip
BUILTIN_PACK_LATEST.<family>from<vOld>to<vNew>insrc/util/builtin-formats.ts.Regenerate the byte-equivalence baseline:
node scripts/capture-baseline-fixture.mjsUpdate
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
audits/pack-equivalence-audit.md— what's shared vs per-pack, with evidenceplans/per-pack-migration.md— per-library historical migration notes (mantine→v9, mui→v7, chakra→v3, shadcn→v4)src/generator/_packs/loader.ts— pure compile coresrc/generator/_packs/loader-fs.ts— Node FS adapterweb/src/build/loader-vfs.ts— playground VFS adaptersrc/util/builtin-formats.ts— built-in pack format map + bareword defaultsstacks/<id>/— stack definitions (v3,sv1,vue1,ng1)designs/<family>/<version>/— reference implementationstest/e2e/generated-react-build.test.ts— the static-validation gatetest/platform/pack-manifest.test.ts— manifest-shape contract teststest/generator/_packs/template-shared-layer.test.ts— shared-source contract tests