name: datastar-components description: Author small interactive UI components from Datastar reactive attributes over a baked DOM, styled with system.css — date pickers, range pickers, month calendars, comboboxes/autocompletes, and multi/single selection grids. Use whenever building or editing such a component with Datastar (data-signals/data-bind/data-text/data-show/data-class/data-attr/data-style/data-on), or when the user mentions Datastar, $-signals, a baked-grid-of-buttons component, an anchored/flipping popover, the lowercase-namespace gotcha, data-ui-* state hooks, or wiring reactive state onto system.css primitives. Pairs with the system-css skill (which covers the styling engine itself). Do NOT use for React/Vue/Svelte components or for server-rendered Datastar SSE backends — this is client-side, build-stepless, signal-driven HTML (the server half — CQRS commands, streams, morph-vs-replace — is the land-and-stream skill). scope: general
Datastar-enhanced components
These components are HTML, not a framework app. There is no build step, no virtual DOM, and no client-side templating. The markup is the component; Datastar attributes make it reactive, and system.css styles it (static/system.css — read the system-css skill). State lives in signals, not in form fields — with the land-and-stream qualifier stated up front: signals hold COMPONENT-EPHEMERAL state (draft, hover, shown month) and act as TRANSPORT for commands; anything a view renders is STORE truth, and a fat-morph re-merging a signal server-authoritatively is correct behavior, not a bug. Every component below is the same small shape — learn it once.
Read the system-css skill for the styling engine (--bg/--fg/--type, primitives, the surface contract). This skill is only about wiring Datastar onto it.
The recipe — every component follows it
- One signal namespace holds all state.
data-signals:ns="{ ... }"on the wrapper. Everything reads/writes$ns.field. - Bake the structurally-constant DOM. Datastar has no client-side loop or template. A month grid always has 42 day cells; a numpad has 12 keys; a combobox list has N options. Emit those nodes at author/build time (a Python f-string loop, a server template — anything that runs before the page ships). The DOM shape is fixed; only its content and state change.
- Bind each baked node reactively —
data-text,data-show,data-class,data-attr,data-style,data-on. - Props down via pure helpers; events up via
data-on. Plain JS functions take signal values as arguments and return a value/boolean (cellIsToday($cal.v, i),cbFilter($cb.q)). They never read globals or mutate — they're props-down.data-on:clickis events-up: it mutates the signal, the bindings recompute. - Layout from system.css primitives in the markup; the rest from the API. See "Don't hand-roll layout" below.
Datastar bindings these components use
| Attribute | Purpose |
|---|---|
data-signals:ns="{...}" |
declare the component's state (JSON; may reference globals like a baked DP_INIT) |
data-bind:field |
two-way bind a control's value to a signal |
data-text="expr" |
element text content |
data-show="expr" |
toggle visibility (inline display) |
data-class:name="expr" |
toggle a class |
data-attr:name="expr" |
toggle/set an attribute (see boolean rule below) |
data-style:--prop="expr" |
drive a CSS custom property → repaints through the engine |
data-on:evt="stmt" |
event handler (click, input, keydown, focus, blur, …); evt is in scope |
Gotchas (all verified by rendering)
- Lowercase the signal namespace. HTML lowercases attribute names, so
data-signals:calSmbecomescalsmand$calSmsilently fails to resolve. Use all-lowercase namespaces:dp,rng,cb,sel,view,cal. data-attr:NAMEwith a boolean toggles attribute presence, not a"true"/"false"string.data-attr:data-ui-selected="cellIso(v,i) === $cal.sel"putsdata-ui-selectedon the selected cell and removes it elsewhere — so[data-ui-selected]matches exactly. Multi-dash names work. For a token attribute (a value, not mere presence) use a ternary:data-attr:aria-current="cellIsToday(v,i) ? 'date' : 'false'"→ matched as[aria-current="date"].data-style:--propis the reactive paint knob — for COMPONENT CHROME only.data-style:--bg="busy(iso)"drives a cell's surface through the color engine; prefer it over toggling many classes. It is NEVER for application data or chart marks — data recolors server-side (the svg-charts backend-toggle rule); this knob paints the throwaway interaction state a component owns.- Avoid
data-computed. It silently no-opped under an earlier bundle; inline the expression in each binding instead. (The project pins Datastar Pro v1.0.2, which does exportcomputed— re-verify by rendering before relying on it.) - No client loops. Bake the constant DOM, then
data-show/data-texteach baked node. Don't try to generate rows on the client. - A suggest lane replies to a FIXED listbox id.
SuggestReply(w, r, "ws", …)patches#ws-list(ComboOptionsViewrendersid+"-list"), so a ComboBox/ComboSelect'sidmust EQUAL its lane's reply id and be unique on the page. gf-367: the portfolio wizard'spfws/pfwbboxes posted to lanes that answeredws/wb— the search never landed an option and nobody saw an error. data-bindtakes the VALUE form here:data-bind="sq", never the key formdata-bind:sq(gf-385: the group-pick input rendereddata-bind:gpqand neither direction synced — the pick wrote the signal and the field kept the typed text; the pinned bundle honours the value form, which every combobox uses).- Load Datastar from
/static/datastar.js(the pinned Pro bundle, v1.0.2) as a<script type="module">— the base layout does this once; never add a second runtime or a CDN copy. (Inlining is only for throwaway offline artifacts, not this app.)
State belongs in data-ui-*, not data-*
Datastar owns the data-* space. Put your styling state hooks under the
framework's existing data-ui-* namespace (it already uses
data-ui-theme/size/motion and .tag[data-ui-state]). This avoids any
collision with Datastar and makes selectors self-documenting:
.cal .ngrid > button[aria-current="date"] { outline: 2px dashed var(--focus); }
.cal .ngrid > button[data-ui-selected] { outline: 2px solid var(--focus); }
.cal .ngrid > button[data-ui-adjacent] { --bg: 0.1; }
<button data-attr:aria-current="cellIsToday($cal.v,i) ? 'date' : 'false'"
data-attr:data-ui-selected="cellIso($cal.v,i) === $cal.sel"
data-attr:data-ui-adjacent="!cellInMonth($cal.v,i)">…</button>
Classes are identity; data-ui-* (or ARIA) is state. A class says what the
element is — a layout primitive or component (.card, .column, .ngrid,
.tag), authored once, never toggled. A data-ui-* attribute says what state
it's in, toggled at runtime by data-attr. Keep them visibly separate:
class="column" (it's a column) carrying data-ui-selected (it's selected).
If the element already carries ARIA state, style off that — don't add a
redundant flag. Selection grids set aria-pressed/aria-checked; a combobox
option sets aria-selected. Style &[aria-pressed="true"] directly so the
accessible state and its look share one source of truth and one binding. Reserve
data-ui-* for state with no ARIA equivalent — a calendar's adjacent
(out-of-month) day, a range's band / preview / endpoints. (A calendar's today
is a real ARIA fact, aria-current="date", so it stays ARIA.) Two house
reservations on top of that rule: data-ui-state="active" is the ENGINE's
painted lens-control state (--bg 1 / --fg −1 — an applied sort/filter/scope
is a condition ARIA has no word for), and data-ui-state="on|off" is the
legend-toggle pair. Never stamp an ARIA state AND a data-ui-* flag for the
same fact — one source of truth, one binding.
data-class(reactively toggling a class) is still fine for genuinely class-shaped things — toggling a layout primitive, or the container-band.medium/.columnreveal. The rule is only that visual state lives indata-ui-*/ARIA, not that every reactive toggle is an attribute.
Where component CSS lives
Wrap each component's rules in @layer components (with a comment banner) and
nest the state inside the structural class, so an element and its states
read as one block:
@layer components {
/* calendar — standalone month surface */
.cal .ngrid > button {
aspect-ratio: 1; display: grid; place-items: center; --bg: 0.3;
& > b { --type: -1; --fg: -0.9; }
&[data-ui-adjacent] { --bg: 0.1; & > b { --fg: -0.6; } }
&[aria-current="date"] { outline: 2px dashed var(--focus); }
&[data-ui-selected] { outline: 2px solid var(--focus); }
}
}
Your components then become genuine peers of .card/.tag in the same layer,
so the cascade stays predictable instead of "app CSS wins by being unlayered."
The framework declares the spine up front (@layer reset, core.color, core.type, core.layout, theme, base, components, utility, media, skin, view-transitions;), so an
appended @layer components {} block joins that layer after the framework's
components — it doesn't become a new top layer. Do not put component rules
in project (or leave them unlayered): they would out-cascade the media
layer and break the revert-layer gates (.small/.medium/.c-*). Reactive
paints are unaffected: data-style:--bg and inline style="--bg:…" are inline
styles, which beat any layer.
Don't hand-roll layout — compose primitives
A well-built component is almost no custom CSS. Push every layout decision into system.css classes in the markup; keep only shape and the type/color API in your stylesheet.
- Layout → primitives in markup:
.card(surface),.column/.row,.spread(space-between),.lcr(start/center/end),.ngridwithstyle="--cols:7"(fixed-column grid),.grid(auto-fit),.menu(anchored popover; note.centeris TEXT centering, a utility — not a box-centering primitive). Never writedisplay/flex/gridin component CSS. - Type & color → the API only:
--type(size),--bg(surfaces),--fg(ink). E.g..cal b { --type:-1; --fg:-0.9 }. - State →
data-ui-*attributes (above) +.tag[data-ui-state="on|off"]. - Shape →
aspect-ratio. This is the one geometric thing you set yourself (square day cells, square keys). Addmin-inline-size:0so 1fr grid tracks can shrink, anddisplay:grid; place-items:centerto center the glyph.
Rule of thumb: a component rarely needs more than ~10 lines of custom CSS. The month calendar is 8–9 lines total (square + number color + the two
data-ui-*outlines + the--bgin/out tints). If you're writing flexbox, you're fighting the system — reach for a primitive.
Container-responsive (no media queries, no JS)
Make one component render differently by width of its own box:
- Put
.containeron a wrapper (it setscontainer-type: inline-size). - Give a child both a gate class and a layout primitive:
class="medium column". The gate class (in the media layer) hides it withdisplay:noneout of band; in band it becomesdisplay: revert-layer, which restores the value from the earlier layer — i.e..column's flex. The primitive is load-bearing: a bare.mediumwith no composed display reverts to defaultblock, not hidden.
Two container gate families, two jobs: .small/.medium/.large are
density tiers (component scale: .small < 8rem = glyph alone, .medium
8rem–20rem = glyph + label, .large ≥ 20rem = full block; "and-up" via
multi-class class="medium large"). .c-mobile/.c-tablet/.c-desktop are
the layout band family (576px / 768px, container-measured).
<button class="column"> <!-- a day cell -->
<b data-text="cellDay(v,i)"></b>
<span class="medium large column">…2 chips…</span> <!-- glyph+label and up -->
<span class="large column">…3 chips…</span> <!-- full block only -->
</button>
No @container rule in your CSS — the framework already gates both families.
Anchored popover (combobox / menu)
Use CSS anchor positioning instead of JS measurement:
anchor-name on the field; on a popover="manual" element set
position-anchor, top: anchor(bottom), width: anchor-size(width), and
position-try-fallbacks: flip-block so it opens downward and flips up near the
viewport bottom. It lives in the top layer (no z-index juggling). Chromium
125+; add an @supports fallback if Firefox/Safari matter.
Two scars to carry (gf-79, gf-46): a CLOSED .menu popover must get
display: none — hiding by opacity alone leaves every hidden menu in the
tab order. And out-of-flow anchor positioning LOSES to morphs: the moment
the server removes or replaces the anchor node, everything anchored to it
jumps or orphans. Anchor to stable, server-owned nodes only; anything that
chains or stacks (toasts) stays IN-FLOW with interpolate-size collapse —
degrade is a snap, never a break. An overlay that traces an element pins
to THE TRACED ELEMENT itself, never its wrapper (hold-confirm's inset:0
mis-scale).
The six components
(This skill ships no references folder — its bundled reference and
examples were lost in an old repack and never restored; the table below
is the surviving summary, and the live in-tree exemplars are the rockets
under static/rocket/ and their benches on Admin → UI lab → Rockets.)
| Component | Signal shape | Patterns it teaches |
|---|---|---|
| Date — single | {v, s} (view month, selected ISO) |
popover calendar, baked 42 cells, aria-current (today) + data-ui-selected |
| Date — range | {v, a, b, h} (anchor, end, hover) |
two endpoints + hover preview, band tint via --bg |
| Calendar | {v, sel} |
standalone month surface, .ngrid+.spread nav, in/out tint, Today/prev/next |
| Combobox | {q, open, hi, label} (query, open, highlighted idx) |
text filter, keyboard nav, anchored flipping popover |
| group-pick (gf-385) | three page signals: typed text · picked name (the TAG) · rename draft | pick-or-make over server rows: focus shows all, typing filters, Enter picks only a highlighted ROW (a typed word is never a value — "No match" + an Add row chosen from the list), the pick is a tag in the field, a pencil renames a row in place (Enter/check saves, Escape/leaving cancels), per-row delete disabled while in use; view.GroupPick(GroupPickSpec); bench UI → Rockets |
| Selection grid — multi | {ids: []} |
array signal, toggle membership, aria-pressed + data-ui-state |
| Selection grid — single | {id} |
single-id signal, radio semantics, aria-checked |
Rocket components (Datastar Pro) — when to reach for them
Everything above is build-stepless HTML: bake the DOM, bind it, inline the
bundle. Rocket is the other mode — Datastar Pro's web-component API
(rocket(tag, {...})). It packages a component as a real custom element with a
typed prop API, instance-scoped state, and a render() that can loop. It is
not a replacement for the recipe above; it is for a specific kind of component.
Use Rocket only when all of these hold (the date picker is the canonical yes):
- There is a public API worth typing — attributes a page author sets
(
mode,value,value-end), decoded via codecs (oneOf,string,date). - The state is genuinely ephemeral and client-only — draft selection, hovered cell, shown month. In a server-owns-state app (land-and-stream), the committed value is server state and flows back in as an attribute; only the throwaway interaction state lives in the component. If the "state" is application data, it does not belong in a client component — render it server-side.
- The internal DOM is repetitive — Rocket's
rendersupports<template data-for="i in [...]">, so the 42 cells are looped, not baked. This is the main ergonomic win over the vanilla recipe. ⚠ The carve-out (gf-44 doctrine): if the server ALREADY renders the markup, Rocket owns interaction ONLY — a behavior wrapper over server truth. A roster needs no rocket at all: it is server markup under the engine's.rostercomponent (gf-98). The app's month calendar is server markup overcal-*; a client-rendered calendar is the standalone-widget case, never the app's. - You reuse it across pages, so a registered element + typed wrapper pays off.
Display-only or server-driven things (card, tag, alert, table, dashboard, a disclosure toggle) are not Rocket candidates — a typed templ/partial wrapper gives the same drop-in reuse without a client runtime. By this test most apps have only a few Rocket components: date/time pickers, combobox, token input.
The shape
import { rocket } from '/static/datastar.js'; // ABSOLUTE path (see gotcha)
// pure date/format helpers on window so rendered expressions can call them
Object.assign(window, { cellDay, cellIso, cellInMonth, cellIsToday, som, monthLabel, fmtPretty });
rocket('event-date-picker', {
mode: 'light', // inherit system.css; shadow DOM walls it off
props: ({ oneOf, string }) => ({
mode: oneOf('single','range').default('single'),
value: string.default(''), // committed start ISO; reflects to attribute
valueEnd: string.default(''),
}),
setup: ({ $$, props, host, action }) => { // destructure `action` — needed below
$$.da = ''; $$.db = ''; $$.hover = ''; // ephemeral draft/UI state
$$.v = props.value ? som(new Date(props.value).getTime(),0) : som(Date.now(),0);
action('commit', () => { // registered action (see gotcha)
host.setAttribute('value', $$.da);
host.dispatchEvent(new CustomEvent('change', { bubbles:true, detail:{ start:$$.da, end:$$.db||$$.da }}));
});
},
render: ({ html }) => html`
<div class="calendar">
<template data-for="i in [0,1,2,/* … */,41]"> <!-- looped, not baked -->
<button data-text="cellDay($$v,i)"
data-attr:data-ui-selected="!!($$da && cellIso($$v,i)===$$da)"
data-on:click="$$clicked=cellIso($$v,i); @pick()"></button>
</template>
<button data-on:click="@commit(); el.closest('[popover]').hidePopover()">Confirm</button>
</div>
`,
});
The stepped dialog (wizard) grammar — three templs, one signal (gf-314/315)
A wizard inside the app is a dialog.modal.glass whose STATIONS ride
one page-local signal: data-signals__ifmissing="{pkw: N}" on the
dialog seeds N from the record at land (the first station whose fact is
open) and keeps it across morphs; wizSignalStrip(sig, stations) is the
house crumb trail with every station clickable and aria-current on
the active one; wizSignalFoot(sig, last) is Back/Next; each body is a
data-show="$sig == i". Every verb inside is a 204 + publish, and the
morph re-lands the tags. Outside the app (no session, a reload between
steps) the wizard is a PAGE of plain forms and 303s with the strip as real
links (/start's six stations, gf-360…366; the page opens NO stream —
Page.NoStream — so nothing can morph a half-filled form) — a dialog
cannot survive the sign-in reload. wizOpener is the header-rack "Set
up" verb. The PORTFOLIO wizard is retired (gf-367); packages
(view/settings.templ, signal pkw) is the surviving exemplar.
Morph armor — protecting client/wizard state from server morphs
A region whose CHILDREN carry in-flight state (a multi-step wizard body, a component-owned DOM island) must survive the page-wide morphs a land-and-stream app fires on every publish. Verified against the bundle source (gf-51):
data-ignore-morph— when BOTH the DOM node and the incoming node carry it, the morph walk returns the node untouched, subtree included. This is the tool for subtree state. (One side only ≠ armor.)- Replace-mode patches bypass the morpher (plain
replaceWith) — which is how the armored region's OWNER updates it: the server command answers withPatchElements(html, WithModeReplace()), and the rendered fragment re-carries the id + the armor attribute so later patches and morphs behave the same. data-preserve-attris NOT armor — it shields the listed attributes (openon a dialog, inlinestyle) while the children still morph. Right tool for keeping a dialog open across a morph; wrong tool for keeping what's inside it.
The worked example: the connector wizard (connWizDialog in
view/settings.templ, its commands in handler/connector.go) — a
stepped dialog whose station is derived from what the org has saved,
each command a 204 + org publish, the page's own fat-morph re-landing
the next station. (The CSV import wizard that first carried this armor
retired with the seam; the lab's mock (system-css/site/lab) keeps its shape.)
Two more morph/fetch traps, verified in the bundle source (gf-52):
-
data-on:submitALWAYS prevents the native submit (bundle:el instanceof HTMLFormElement && event === "submit" && preventDefault(), with or without__prevent). A nativemethod="post"form must carry nodata-on:submit; a Datastar form isdata-on:submit__prevent+@post(url, {contentType: 'form'})+ a 204 (gf-358, the contact dialog lost a day of messages to this). -
File uploads need
enctype="multipart/form-data"ON THE FORM.@post(url, {contentType: 'form'})builds a FormData, but ships it as the body only when the form declares that enctype — otherwise the bundle flattens it toURLSearchParamsand every file input silently drops (server sees "no file"). The attribute is load-bearing even though no native submit ever happens. -
A morphed checkbox freezes after its first flip. The morpher diffs
checkedby comparing the ATTRIBUTE on old-vs-new but writes only the PROPERTY, leaving the old attribute stale — every later morph false-negatives. Fix: key the input'sidby state (e.g.act-7-on/act-7-off) so old and new never pair and the morph replaces the node.
Rocket gotchas (each cost a real debugging cycle here)
-
A rocket host RE-RENDERS its template on every prop change (gf-323, learned on the virtual-scroll bench, retired gf-324): every
data-attr:*landing after first render runsrenderagain, so element references captured inonFirstRenderpoint at detached nodes while Datastar keeps patching the live ones by id. Re-query the nodes at the top of every reset (host.querySelector('[data-viewport]')), and pass STATIC props as plain attributes — only a value that truly changes ridesdata-attr:. The reference virtual-scroll also averaged empty blocks into a running height total; measure only what holds rows, by its own row count. -
A rocket that renders its own subtree must OWN it end to end (gf-323, on prod): the page-wide morph on every publish deletes what the server did not send, so the host needs
data-ignore-morph— and that same flag makes Datastar skip any patch aimed inside the subtree, the rocket's own block replies included. Apply those by hand (parse thedatastar-patch-elementsevent, swap the live element's children by id) instead of dispatching them back to Datastar. Verify under publishes, not on a quiet page: the scratch server showed nothing because nothing published. And any scroller that re-positions content above the viewport setsoverflow-anchor: noneon it, or Chrome's scroll anchoring will move scrollTop after every re-position and the scroller will chase its own tail. -
$$nameis the instance-local signal. Insiderender, write$$da,$$mode; insetup,$$.da = ''. Rocket rewrites$$to a per-instance path, so multiple instances on a page don't collide. Helpers are still plain JS onwindow, called with$$values as args (props-down, unchanged from the recipe). -
Register actions with
action(name, fn)— not$$.fn = …. Defining$$.commit = () => {}does not make@commit()callable; you get "Undefined Rocket action: commit." Destructureactionfrom the setup context and callaction('commit', () => {...}). -
Local actions take NO arguments — pass data via a signal. Calling
@pick(cellIso($$v,i))makes Rocket rewrite the dispatch and recurse into aMaximum call stack size exceeded. The working pattern mirrors no-arg@commit(): set a signal first, then call the bare action which reads it —data-on:click="$$clicked=cellIso($$v,i); @pick()", and inside,const iso = $$.clicked;. -
Boolean-coerce every
data-attrexpression with!!(...). Rocket sets an attribute whenever the expression yields a string;$$da && …returns''(empty string, notfalse) when$$dais empty, sodata-ui-selected=""lands on every cell and presence-based CSS ([data-ui-selected]) matches all of them.data-attr:data-ui-selected="!!($$da && cellIso($$v,i)===$$da)"forces a real boolean, so Rocket omits the attribute when false. (This is the Rocket-specific counterpart to the vanilla presence rule above.) -
mode: 'light'so the component lives in the page's DOM/CSS and inherits system.css tokens. Shadow DOM (open/closed) would wall off the stylesheet; only reach for it if you want encapsulation and theme purely via custom props. -
Commit on light-dismiss. A
[popover]firestoggle; wiredata-on:toggle="evt.newState==='closed' && <valid> && @commit()"so clicking outside locks in the selection (no separate Cancel needed).
Project integration (Go + templ, or any SSR)
- One file per component, served as a static asset:
static/rocket/<name>.js. Its bundle import must be absolute (/static/datastar.js) — a relative./datastar.jsresolves against the subfolder and 404s. - A thin typed wrapper in your view layer (one templ component) gives the
drop-in API and lets the compiler catch attribute typos:
go
templ EventDatePicker(mode, value, valueEnd string) { <event-date-picker mode={ mode } if value != "" { value={ value } } if valueEnd != "" { value-end={ valueEnd } } ></event-date-picker> } - Load it once from the base layout (
<script type="module" src="/static/rocket/<name>.js">). Registration is idempotent and order-independent — custom elements upgrade whenever the definition lands — so per-page placement doesn't matter and HTTP caching makes it a one-time download. - Shipping a new component: the
.jsgoes in static/rocket/, the templ wrapper in view/; runtempl generateand confirm the wrapper compiled — and give it a bench page in the lab (system-css/site/lab/rocket-.html) so regressions are seen in the lab first. - Whole-page morph caveat (land-and-stream): when an SSE update morphs a page
with the component open, the morph diffs against Rocket's managed interior. If
the internals get clobbered mid-interaction, keep the morph off the host's
subtree (
data-ignore-morphon BOTH nodes) and let Rocket own it — and update the armored region only via replace-mode patches (the fat-morph bypass; see land-and-stream §2). - Observers get cleanup, always. A MutationObserver left running under a fat-morphing page re-scans every morphed node — that leak was the bulk of the phantom ~5s morph cost (gf-98). Register observers in setup, disconnect in cleanup, re-observe on re-attach; props are codec'd; define is idempotent.
- Datastar signals only — no custom
data-q/data-sortstamps (gf-66): server state rides real signals or server-rendered attributes the engine already knows (data-ui-*, ARIA), never an invented data-* vocabulary.
THE .active UTILITY (gf-191, per Mike)
A TRANSIENT client highlight (keyboard focus row in a combobox/menu,
a hover-independent "this one" marker) is the .active utility class —
never aria-current, which keeps its REAL meaning (the current page /
step / date in a set: the org switcher's current org, the wizard
strip's current station). Rockets toggle .active for paint and the
proper ARIA attribute for semantics (aria-selected in a listbox).
The engine's paint recipes list .active beside their ARIA selectors.
THE ROCKET STATE CANON (gf-191, per Mike: "there should be canon
solutions for these situations" — paid for three times: the search rocket, toast-card, the combobox)
Every rocket that holds client state under fat-morphs follows these FIVE laws; a new rocket that deviates is wrong until proven otherwise:
- Client-owned state rides an ATTRIBUTE on the host, shielded by
data-preserve-attrin the server markup — never a JS-only flag. (dialogopen,data-ui-statefor a popover,aria-expandedfor a toast's fold,data-ignorefor maps.) An attribute survives inspection, gives CSS a hook, and morphs can be told to keep it; a closure variable dies silently. THE CONVERSE (gf-470): state the SERVER can know is the server's, un-shielded — the toast's own word (unread·read·leaving) is stamped from seen_at and a just-dismissed grace window; the rocket only echoes it a round trip early, and the next morph agrees. A preserved attribute the server also renders is two truths — the server's can never land. - Observers RESTORE and RE-HOME; they never DECIDE. A MutationObserver may re-home derived state (the highlight the morph swapped out from under you, command-palette's resetActive) and re-assert what the preserved attribute already says — it must never originate a state change. An observer that opens things will one day open them on a data-text ripple.
- State changes come from USER INTENT only: focus, clicks, and
isTrustedevents. A signal write dispatches synthetic events (isTrusted: false) — a rocket that treats those as input will act on its own echo. - Rockets never invent data. Every committable choice is a server-rendered element with its own datastar action; Enter/keys just .click() it. If the rocket can fabricate a selection, the server and client can disagree.
- Content is server truth and morphs freely. Armor the CONTAINER
(
data-ignore-morphfor imperative third-party DOM, REPLACE-mode patches for wizard bodies), never the data.
Pre-ship checklist
- Signal namespace is lowercase; one signal object per component.
- DOM is baked (no client loop); every dynamic node is bound.
- Layout is primitives; custom CSS is only
aspect-ratio+--type/--bg/--fgdata-ui-*state.
- Booleans go through
data-attr(presence); tokens/strings via ternary. - Datastar loads once, from
/static/datastar.js(no second runtime). - You rendered it (headless or real browser) and checked geometry + state + no console errors — not just eyeballed the markup.
- (Rocket only) actions are registered with
action(name, fn)and called with no args (data passed via a signal); everydata-attrexpression is!!()-coerced; the bundle import is an absolute path;mode: 'light'.
One bad expression takes the whole page down (gf-175)
datastar throws ValueRequired on an EMPTY expression (e.g.
data-on:submit__prevent="") and the exception ABORTS the page's entire
attribute walk — every data-* binding on the page goes inert: blank
data-text, data-show not hiding, data-attr never applying. The
symptom reads like "datastar didn't load." A demo/no-op handler still
needs a real expression ("0"); when a whole page's bindings die at
once, check the console for ONE plugin error before debugging any
individual binding.
Dialogs on a live page
Every <dialog> on a land-and-stream page carries data-preserve-attr="open"
(Mike, 2026-09-17). A push from the stream morphs the whole page; without the
preserve, the morph resets the open attribute and the dialog closes under
the person mid-form for a reason they cannot see. The contact and vote
dialogs are the exemplars.
Local draft state in a public dialog rides _-prefixed signals (off the
wire) kept by the Pro persist plugin —
data-persist:contact="{include: /^_contact\./}" on the form, the submit
handler clearing them and setting the dialog's own _sent flag
(__ifmissing, so a push cannot reset it) — view/layout.templ, gf-358.