Design-System Elements: Create, Validate, Sync
Design-System Elements (Materialise, Drift, Sync)
Designer Agent Kit — documentation written for AI agents, readable by humans.
A design-system element is a component whose template you flatten (copy) into your page and then own
the copy of. You edit the copy freely; jay-stack validate tells you where your copy has drifted from the
source; jay-stack sync re-flattens from the current source while keeping the edits you marked. This is
Jay's answer to "reuse a shared component but tweak this one instance" — with no runtime cost and no hidden
composition machinery.
As a designer you both create and use design systems — this is your job, not only a plugin author's. A design-system element can be one you author from scratch, one you copy from a plugin into your project and adapt, or one you link straight from a plugin. This guide covers both sides: creating a design-system element (authoring the template + contract) and using one (linking, flattening, editing, syncing).
Replaces the old
<override>tag. If you have seen<override ref="…">in older pages, that mechanism is gone — its job is now done by editing the flattened copy and marking the edited facet (see Marking what you own).
The three component models — only one is a design-system element
| Model | How it is imported | Template? | Validate / sync? |
|---|---|---|---|
| Keyed headless | <script … key="cart">, used via {cart.field} |
No | No |
| Nested (no template) | <jay:card> with an inline body you wrote by hand |
No | No |
| Design-system element | <jay:card> whose import carries template="…" |
Yes | Yes |
Only a <jay:X> region whose application/jay-headless import carries a template= attribute is a
design-system element. That template= is the provenance marker: it records where the flattened body
came from, so validate can diff it and sync can re-flatten it. Nothing else — a keyed import, a nested
region you hand-authored, a plain instance — is ever drift-checked or synced.
Prefer design-system elements
Reuse UI through design-system elements, not by hand-authoring the same markup in each page. Three
preferences — jay-stack validate nudges you toward each (warnings, never blocking, each suppressible):
- Create and consume templates as design-system elements. When a component renders UI, ship a
.jay-htmltemplate for it, and link it from the page withtemplate=+jay-stack syncrather than hand-writing the<jay:X>body. (Rules:COMPONENT-NO-TEMPLATEon the authoring side,REGION-NOT-LINKEDon the consuming side.) - When one instance needs extensive restyling or structural change, make another template for the same
component — a second design-system variant — instead of piling style/class/structure overrides onto one
flattened copy. (Rule:
REGION-OVERRIDE-NON-CONTENT.) - Hand-author an inline region only for a genuinely one-off usage — a region used once, where no shared template exists or would help.
Two templates vs. a conditional — different axes
These solve different problems; do not substitute one for the other:
- Two templates = two design-system elements. Different designs chosen at composition time (a
compact card vs. a feature card). You pick which template a region links to. This is the axis
REGION-OVERRIDE-NON-CONTENTpoints you to when an instance's look or structure diverges. - A conditional (
if/ variant) = one element, runtime change. Same design, a branch driven by runtime state (logged-in vs. not). Use conditionals for state, not to fork a design.
A restyle or net-new layout DOM on one instance is a second design — reach for a second template, not a conditional.
What counts as drift worth a new variant
REGION-OVERRIDE-NON-CONTENT fires only on non-content changes to a linked region:
- Content (fine — this is what flattening is for): editing text, image
src/alt, and enriching text with inline content markup (<strong>,<span>,<img>, a<ul>/<li>list, a<table>, headings…). - Non-content (make a new variant): changing
classor inline/scoped CSS, or adding net-new layout DOM (<div>/<section>wrappers, custom components) — a different look or structure.
Creating a design-system element
Authoring a design-system element is just authoring a component: a .jay-contract (its data shape,
props, refs) and a .jay-html template (its markup + optional <style>). You can start it three ways:
- From scratch — write the contract and template yourself for a reusable piece of your UI (a card, a hero, a callout).
- From a plugin, copied in — copy a plugin's shipped
.jay-html(and its.jay-contract) into your ownsrc/components/…, then adapt it. Point pages at your copy; it is now project-owned, so you edit the source directly — nooverridemarks and no upstreamsync. - From an existing project component — add a
.jay-htmltemplate to a component that only had a contract, so pages can flatten it.
1. Put it under src/components/
A project-owned element lives beside your pages:
src/components/card/
card.jay-contract # data shape, props, refs
card.jay-html # the template pages flatten from
(A plugin's element lives in its package; you link it by a template= path into node_modules/…. Same
mechanics — only the path differs.)
2. Write the contract
The contract is the component's API — the props an instance is configured with and the tags the template binds. Keep it to what varies per use. See the Contract Authoring Guide.
# card.jay-contract
name: Card
props:
- { name: heading, type: string, required: true }
- { name: body, type: string, required: true }
tags:
- { tag: heading, type: data, dataType: string }
- { tag: body, type: data, dataType: string }
A pure presentational element can have an (almost) empty contract. If the element binds no data and
needs no code — static markup and styles only — the contract still has to exist (it names the element and is
what the page's contract= import resolves), but it needs nothing but a name:
# hero-banner.jay-contract — a pure design element, no data, no code
name: HeroBanner
tags: [] # no data to bind
3. Write the template
An ordinary .jay-html: a <head> declaring its contract, a <body> of markup binding {…} against the
component's own tags, and an optional <style> of plain component CSS.
<!-- card.jay-html -->
<html>
<head>
<script type="application/jay-data" contract="./card.jay-contract"></script>
</head>
<body>
<div class="ds-card">
<h3 class="ds-card__heading">{heading}</h3>
<p class="ds-card__body" jay-content>{body}</p>
</div>
</body>
<style>
.ds-card {
border: 1px solid #e2e8f0;
padding: 20px;
}
.ds-card__heading {
font-size: 1.15rem;
}
</style>
</html>
Author plain CSS here, not
@scope. In the template you write ordinary class rules (.ds-card { … }). When a page flattens the element,synccopies these rules into the page verbatim and wraps them in a canonical@scope (.<ref>)block for you — that scoped wrapper is a page-side concern (see The shape of a region's CSS); you don't write it in the template.
4. Choose the right axis of variation
Decide how each instance is allowed to differ, and encode it in the element — this is the core design skill:
| The instance varies by… | Encode it as… | Example |
|---|---|---|
| data (same design, different values) | a contract prop/tag, bound {…} |
{heading}, {price} |
| content (per-page text/media the consumer fills) | a content slot — jay-content (optional) |
a body paragraph, a hero <img> |
| runtime state (same design, a branch) | a conditional (if / variant) |
logged-in vs. not |
| design / structure (a different look) | a separate template (a second element) | compact card vs. feature card |
Prefer props for anything that is really just data. Use jay-content to pre-open the content a page is meant
to fill (optional — it spares the consumer an override; see Content
slots). Fork a second template, not a conditional, when
the look or structure differs — see Two templates vs. a conditional.
5. Compose other elements inside it
A template may nest other design-system elements (<jay:button>). Declare their contract import in the
template <head>; when a page flattens this element, the nested regions flatten transitively (the page
must also carry a template= import for each nested element so it resolves). See
jay-html-components.md.
Once the element exists, pages consume it exactly as below.
Using a design-system element (link + flatten)
1. Declare the import with template=
<head>
<script
type="application/jay-headless"
contract="./components/card/card.jay-contract"
template="./components/card/card.jay-html"
></script>
</head>
contract=— the component's contract (data shape, refs, props), as for any headless import.template=— the source.jay-htmlto flatten from. Resolved relative to the page directory.
A template= that does not resolve is a hard error, not a warning — fix the path or remove the
attribute.
2. Place the region and flatten it
Write the tag, then let sync fill its body from the source template:
<body>
<jay:card ref="promo" heading="{title}"></jay:card>
</body>
jay-stack sync
After sync the region carries a flattened copy of the source body, plus the component's CSS copied into
the page's <style>, @scope-wrapped by the region's ref:
<jay:card ref="promo" heading="{title}">
<div class="card">
<h3 class="card-heading">{heading}</h3>
<p class="card-body">Default body</p>
</div>
</jay:card>
@scope (.promo) {
.card {
border: 1px solid #ccc;
padding: 16px;
}
.card-heading {
color: black;
}
}
The flattened body is the template body verbatim — no synthetic class on the root — and the CSS is the
component's own plain rules wrapped in one @scope (.<ref>) block. Write region CSS in this shape
yourself; the rules are below in The shape of a region's CSS.
A bare
<jay:X>with no flattened body is a hard error at build time — flatten it withjay-stack sync(first-fill is just the no-edits case of sync; there is no separateaddcommand).
3. Edit the copy freely
The flattened body is yours. Rewrite text, change classes, add/remove children, edit the scoped CSS. Bind
{…} expressions against the component's own contract — inside <jay:card>, {heading} is the card's
heading prop, not the page's. What you may change without prompting a "make a new variant" nudge is the
content vs. non-content distinction above.
4. Two variants of one component on a page — as=
A <jay:X> region tag is derived from the imported contract's name, so importing one contract twice would
make both imports back the same <jay:card> tag — an ambiguous collision (REGION-TAG-COLLISION, a
hard error). To flatten two design variants of one
contract side by side (e.g. a default card and a feature card — same data shape, different design), give
the second import its own region tag with as=:
<head>
<!-- default card → <jay:card> -->
<script
type="application/jay-headless"
contract="./components/card/card.jay-contract"
template="./components/card/card.jay-html"
></script>
<!-- feature card (same contract, different template) → <jay:feature-card> -->
<script
type="application/jay-headless"
contract="./components/card/card.jay-contract"
template="./components/card/card.feature.jay-html"
as="feature-card"
></script>
</head>
<body>
<jay:card ref="starter" heading="Starter"></jay:card>
<jay:feature-card ref="pro" heading="Pro"></jay:feature-card>
</body>
as=must be a kebab-case region tag ([a-z][a-z0-9-]*), e.g.as="feature-card"→<jay:feature-card>.- Both imports share the same contract, so both regions bind the same props/refs — only the design
(template + CSS) differs. Each region flattens and drifts independently from its own
template=. as=composes at every nesting level: a template may alias the nested regions it uses (two<jay:button>s as<jay:main-cta>/<jay:secondary-cta>), as long as the page carries a matching aliased import for each.- Write a second variant only when the difference is non-content (class / CSS / structure). A difference that is just runtime state (same design, a branch) is a conditional inside one element, not a second import.
The shape of a region's CSS
A region's CSS is the component's own CSS, copied into the page <style> and wrapped in one @scope block.
sync produces this form and validate enforces it — but when you hand-write or edit a region's CSS,
write it this way directly:
1. Wrap the component's rules in @scope (.<ref>)
All of a region's rules live inside one @scope block keyed by the region's ref:
@scope (.promo) {
/* …the card's rules, as plain class selectors… */
}
This isolates the component's styles to that region — rules inside never leak out, and page rules outside never bleed in.
2. Write plain class selectors — the ref is not a class on your markup
A jay ref is not emitted to the DOM, so @scope (.promo) would have nothing to match. The compiler
therefore synthesizes a <div class="promo" style="display: contents"> scope-anchor wrapper around the
region body at build time (DL#206). It is layout-transparent (display:contents generates no box) and you
never write it, see it in source, or style it — it exists only so .promo has a real element to root at.
Because the wrapper is the scope root, your real roots are ordinary descendants of it. So every rule — including the component's own root element — is a plain class selector, exactly as authored in the template. Keep the template's classes on your markup unchanged; add no ref class:
@scope (.promo) {
.card {
border: 1px solid #ccc;
} /* ✅ the region root — a plain class selector, matched as a descendant of the wrapper */
.card-heading {
color: black;
} /* ✅ descendant — also a plain class selector */
}
No
:scoperewrite. In the old model the root rule was rewritten to:scopeand the ref was stamped onto the root as a class. DL#206 removed both: page region CSS is now byte-for-byte the template's plain rules, just wrapped in@scope (.<ref>).:scopewould now refer to the invisible wrapper — don't use it.
3. Instances of the same component coalesce into one block
When a page holds several regions flattened from the same template, they share one @scope block with a
selector list — never one duplicated block per ref:
@scope (.cardStarter, .cardPro) {
.card {
border: 1px solid #ccc;
}
.card-heading {
color: black;
}
}
Regions from different templates stay in separate blocks. If you add a second instance by hand, fold its ref into the existing block's selector list rather than copying the block.
4. A CSS-shipping region must have a ref (error)
The @scope (.<ref>) anchor is derived from the region's ref=. A region whose template= ships CSS but
whose <jay:X> tag has no ref has nothing to scope to — the materialiser cannot wrap the CSS and
jay-stack sync silently drops it. validate catches this as a hard error (REGION-CSS-NO-REF, DL#209):
<jay:card> flattens template="./components/card/card.jay-html" which ships CSS, but the region has no ref=
— so its CSS cannot be scoped and `jay-stack sync` will silently drop it.
→ Add a ref= to the <jay:card> so sync can scope its CSS as an @scope (.<ref>) block and preserve it.
Fix it by adding a ref — there is no suppression, because the alternative (keeping the CSS unscoped) would
leak the region's styles across the whole page.
Drift: what jay-stack validate tells you
Because the copy has a known source (its template=), jay-stack validate diffs the two and reports each
place your copy differs — at facet granularity (one attribute, one style declaration, a subtree, one
CSS rule or declaration), not "this node changed":
<jay:card> region differs from source template "./components/card/card.jay-html":
<h3> children changed ("{heading}" → "On sale now").
To keep the page's version, mark the node override="children";
to discard it and re-flatten from source, run `jay-stack sync`.
Every drift is a warning, and the page still builds. A warning is a decision point, not a failure:
- Keep your edit → mark the facet (below). It will no longer be reported, and
syncwill preserve it. - Discard your edit → run
jay-stack syncto re-flatten that facet from source.
An unedited copy validates clean. A template= that cannot be read, and a template that (transitively)
includes itself, are hard errors.
Marking what you own (facets)
To keep an edit, mark the exact facet you own. Everything you do not mark still reconciles with source, so you get the source's future fixes for free on the parts you did not touch.
Markup facets — the override attribute
| You own… | Mark it | Effect |
|---|---|---|
| one attribute | override="class" |
your class is kept; other attributes reconcile |
| one inline-style declaration | override="style.color" |
your color is kept; other style props reconcile |
| this element's children/subtree | override="children" |
your subtree is kept; the element's attrs reconcile |
| several facets at once | override="class children" |
space- or comma-separated list; each facet is kept |
| the whole node | override (bare) or * |
the entire node is kept verbatim |
override is a facet list: list the facets you own, separated by spaces or commas (override="class, style.color"). A bare override (or override="*") is the shorthand for "the whole node is mine." Anything not listed still reconciles with source.
<jay:card ref="promo" heading="{item.title}">
<div class="card featured" override="class">
<!-- class is page-owned; the rest of this div still reconciles -->
<h3 override="children">Half price this week</h3>
<p class="card-body">Default body</p>
</div>
</jay:card>
- Removing a child: delete it and mark the parent
override="children"— otherwisesyncrestores it. - Attribute / style tweak: edit the value and mark that facet (
override="href",override="style.color"), leaving siblings to reconcile.
CSS facets — the jay:override pragma
A comment has no element to hang an attribute on, so scoped CSS uses a pragma placed immediately before (or inside) the rule:
@scope (.promo) {
/* jay:override: color */
.card-heading {
color: red; /* kept across sync */
font-weight: bold; /* still reconciles with source */
}
/* jay:override */
.card-badge {
/* the whole rule is page-owned */
}
}
/* jay:override: <prop> */— the page owns that one declaration./* jay:override */— the page owns the whole rule.
Content slots — jay-content (an optional helper)
override lives on the page and is per-instance. jay-content is its mirror on the template side —
and because you author templates too, it is a tool you reach for when
creating an element, not something only plugin authors use. Put it on a template node to pre-declare a
content slot: "this part is meant to be filled per page." Edits to a marked facet are then expected by
design — validate never reports them as drift, and sync keeps the consumer's version — without any
page-side override.
<!-- in a template you author or own: -->
<p jay-content>{body}</p>
<img jay-content="src alt" src="placeholder.png" alt="" />
<!-- in each flattened page, just edit — no override needed: -->
<p>Everything your team needs, in one place.</p>
<img src="/hero.png" alt="Our team at work" />
jay-content is optional, not required. It is purely a convenience that removes the need to mark
override on the slot. Unlike slot/children mechanisms in other frameworks, you do not have to annotate
every variable spot — an unmarked content edit still works; it just shows up as drift until you either mark
it (override on the page, or jay-content on the template) or sync it away. Reach for jay-content when
a node is meant to vary across pages and you'd rather not repeat an override on every instance.
It is a template-side marker — it lives on the source template and is stripped from the flattened page
(so it is stated once, and resilient to template edits). It uses the same facet-list grammar as
override (children, style.<prop>, attribute names, * — see
Marking what you own), with one difference: a bare jay-content means
children (the common text slot), whereas a bare override means the whole node.
Upgrading: jay-stack sync
When the source template changes (a component upgrade, a design fix), re-flatten:
jay-stack sync # every design-system region in the project
jay-stack sync src/pages/home.jay-html # one page
jay-stack sync --all # explicit "all pages"
Sync is re-flatten, not merge: it overwrites everything from the current source except the facets
you marked override, which it preserves verbatim. There is no merge base, no hash, no conflict prompt —
your region is by definition equal to its source except at your marked facets, so sync is deterministic
overwrite-with-holes. It can never silently combine two edited versions into wrong markup. A synced region
validates clean.
CSS reconciles the same way as markup — drift is overwritten, marked facets are kept:
- A region block equal to the canonical form (or missing) is (re-)emitted as the canonical
@scope (.<ref>)block, coalesced with its same-template siblings. - A block that diverges but carries a
/* jay:override */pragma is preserved verbatim — your pragma is never rewritten. - A block that diverges without any pragma is unmarked CSS drift:
syncoverwrites it back to the canonical form, exactly as it re-flattens unmarked markup. (This is what letssyncmigrate a page to a new canonical form — e.g. an older:scope { … }/ ref-on-root block is rewritten to the DL#206 form: plain class rules wrapped in@scope (.<ref>).)
So after one sync a page's CSS is in canonical form, and a validate-clean page is one sync leaves
unchanged. To keep a hand-edit through sync, mark it with a jay:override pragma.
Suppressing the preference warnings
Each "prefer design-system elements" warning is suppressible for the legitimate case:
| Rule | Suppress |
|---|---|
REGION-NOT-LINKED |
jay-validations="REGION-NOT-LINKED" on the application/jay-headless import (per region type), or list the contract under allow-inline-region (below) |
REGION-OVERRIDE-NON-CONTENT |
jay-validations="REGION-OVERRIDE-NON-CONTENT" on the import |
COMPONENT-NO-TEMPLATE |
allow-no-template: [Contract] (a data-only component has no .jay-html to host an attribute) |
NO-DESIGN-SYSTEM |
allow-no-design-system: true |
Per-region-type suppression lives on the import; put multiple rules in one attribute:
<script
type="application/jay-headless"
contract="./components/card/card.jay-contract"
jay-validations="REGION-NOT-LINKED REGION-OVERRIDE-NON-CONTENT"
></script>
Project-wide / list suppression lives in the page's validations script:
<script type="application/jay-validations">
jay-stack:
allow-inline-region: [Card, HeroBanner] # contract names — allow hand-authored regions
allow-no-template: [MetricsProvider] # data-only components with no UI
allow-no-design-system: true # project intentionally shares no design system
</script>
Why copy instead of reference?
Flattening removes the composition "seam": the region compiles through the ordinary headless-instance path,
so there is no runtime crossing machinery and no per-component ref-scope rules. The cost is that page
files grow and a copy duplicates its source — which is exactly what validate (drift) and sync
(re-flatten) exist to manage. Design edits stay in the page; the source stays reusable.
About this document
This page is part of the Jay Stack Agent Kit — documentation generated from the framework source and written primarily for AI agents. The language and structure are optimized for machine consumption — expect precise, specification-style prose rather than narrative documentation. Learn more about the Agent Kit →