Skip to content

Feature: Template kit - #112

Merged
jhweir merged 11 commits into
devfrom
feat/template-kit
Aug 11, 2026
Merged

Feature: Template kit#112
jhweir merged 11 commits into
devfrom
feat/template-kit

Conversation

@jhweir

@jhweir jhweir commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator

feat: a template fragment kit, and the monorepo made to follow its rules

Branch: feat/template-kitdev · 11 commits · 113 files · +4,900 / −5,220 (approx)

Summary

WE's own templates had drifted into copies of each other — fourteen shapes each written out
wherever needed, so "the same thing" was only ever true by coincidence. Two prompts were
byte-identical in different packages; four marketplace browsers were two pairs of near-copies
that had already diverged in behaviour; and the copying was hiding real bugs (four avatar stacks
rendering the same generated face for everybody, a delete flow one page had and its twin didn't).

The fix is @we/template-kit: the shared shapes as functions returning nodes. They run at
authoring time and leave nothing behind — what ships is plain JSON, indistinguishable from JSON
written by hand, which keeps templates inspectable in the visual editor, editable by the AI, and
free of any runtime dependency on the package. The same shapes ship as JSON recipes in the
generated AI reference, so browser-authored templates produce the same trees.

The second half of the PR makes the kit's rules the monorepo's rules: they're written down
(package README + CONVENTIONS, design-system CONVENTIONS, an architecture doc), enforced where
possible (four new validator lint classes, with tests), and applied retroactively — nine
layer-4 components that were pure arrangement-in-code, all with zero consumers, are deleted
while pre-1.0 deletion is still free.

Changes

@we/template-kit (new package) — 16 fragments in two tiers: portable
(states/ layout/ lists/ overlays/ input/ — no store names) and we/ (reads WE's stores or
$agent). The tier split is the package's honest dependency declaration: a fragment naming
spaceStore.members fails silently on a host without that store, and package.json can't say so.
Includes field, which landed as a fragment rather than a schema operator because which event
carries a control's value
is design-system knowledge that mustn't leak into the schema resolver.

Both template packages rebuilt on it — ~2,300 template lines removed. Also fixes found by the
consolidation: four Flux participant stacks seeded avatars with a bare '$item' (a literal in
$map select — every generated face identical); templateRow/themeRow were drifting twins; the
marketplace templates page lacked the delete/spinner wiring its themes twin had; About's missing
bottom padding was drift, not a decision. The backwards template-default → template-shell
dependency is gone.

Validator (@we/schema-shared) — four new lint classes for schemas that render, take the
click, and silently do nothing: bare/literal $-strings in $map selects; $map's items
expression (previously read from a key the grammar doesn't have, so never checked); writes to
read-only $queries names (with shadowing handled); $toggleLocal/$callLocal (previously
unchecked entirely). 10 tests, each reproducing a bug that shipped in WE's own templates.

@we/ai-context — a patterns fragment: the kit's shapes as copyable JSON in the in-app AI's
prompt, each with the decision that's easy to omit spelled out beside it. Deleted components are
gone from the AI's vocabulary at the same stroke.

Design system — deleted PostCard, CircleButton, IconLabelButton, List, Table,
Timeline, Accordion, Breadcrumbs, Stepper (arrangement-in-code, zero consumers anywhere,
several unusable from templates due to render-function props; PostCard carried an invalid design
token nothing ever caught because nothing ever rendered it). Also GraphWidget/mockGraphData
(superseded by the graph engine — the follow-up its PR named) and SpaceSidebarWidget (dead).
Calendar/Combobox kept: unused but genuinely code-worthy.

@we/module-graph — its fragments.ts (which independently converged on the kit's API
shapes before the conventions existed) aligned with them: knowledgeMap takes an options object,
the header names these as module-provided fragments (the fourth scope), and the kit's CONVENTIONS
now states module fragments are governed by the same rules.

Docsdocs/architecture/template-fragments.md (the expansion model, fragment vs component
vs operator, drift semantics for the future provenance system, deferred obligations with
triggers); kit README + CONVENTIONS (extraction threshold, options-object API, the const rule);
design-system CONVENTIONS ("does this deserve to be code", one-vocabulary-two-grammars, the
surfaces-vs-tools rule that keeps the editor TSX);
docs/internal/plans/prs/COMPONENT_EXPLORER_AND_FRAGMENT_PALETTE_PLAN.md (node insertion for the
editor and AI, phased).

Deliberate non-changes

Decisions, not gaps — recorded so review doesn't re-litigate them:

  • 28 form fields not converted to field — custom controls (pickers, EditableImage) or
    merge writes into object state; converting would mean options for a control table the
    fragment doesn't own.
  • Three shapes left hand-written (SpaceHeader's presence row, two compact bylines) — each
    would cost the fragment 2–3 options to serve one call site; the extraction threshold in the
    kit's CONVENTIONS exists for exactly this.
  • peopleTooltip stays in we/ though it names no store — grouped with its consumers.
  • Calendar/Combobox kept despite zero consumers — behaviour-worthy vocabulary; deadness alone
    isn't the crime, arrangement-in-code is.
  • The editor stays TSX permanently — the surfaces-vs-tools rule in design-system CONVENTIONS.

Known follow-ups

  • Insert-time scope checking (requires against getScopeAtNode) — phase 2 of the
    explorer/palette plan (docs/internal/plans/prs/).
  • The four deferred constitutions, each with its tripwire — the trigger table at the end of
    docs/architecture/template-fragments.md.

Test plan

  • Workspace typecheck (42 projects) clean
  • Full test suite: 1,440 passing, zero failures — including 10 new validator tests
    reproducing the shipped bug classes, and 52 new kit tests
  • Kit tests assert on expansions, not implementations: every fragment (both branches where
    it forks) validates against the real generated context; and the portable tier is checked by
    walking each expansion for $store/$agent — the tier claim is now enforced by test, not
    convention. Plus pinned contracts: the $item-literal regression, field's per-control
    events, confirmModal clearing its flag from all three exits, cardList's hoist key
  • we-validate-schemas: all 22 schemas green (the new lints run over every template in the
    repo)
  • Workspace build clean; eslint and prettier clean
  • ai-context regenerated; CLAUDE.md/schemaContext contain zero references to deleted
    components
  • Manual browser pass (James): cards route across content types incl. empty states and
    confirm modals; About/Settings restructure incl. Discovery switch and Location editor;
    SpaceHeader members/presence rows; all four marketplace browsers; runtime-settings admin
    sections; boot/create-space forms

jhweir and others added 11 commits August 11, 2026 00:53
…e built from it

WE's own templates had drifted into copies of each other. Not one abstraction that
needed extracting — fourteen shapes each written out wherever it was needed, so that
"the same thing" was only ever true by coincidence. Two prompts (`notConfigured` in
`SpaceGate` and in `MarketplaceGate`) were byte-identical in *different packages*.

`@we/template-kit` is those shapes as functions returning nodes. They run at authoring
time and leave nothing behind: what ships is JSON indistinguishable from JSON written by
hand, which is what keeps a template inspectable in the visual editor, editable by an AI
and free of any runtime dependency on this package. The kit is split into a tier naming
no store and a `we/` tier reading WE's own — the store surface is its real dependency and
`package.json` cannot express it.

Two bugs came out with the duplication, both invisible while each copy was read alone:

- Four Flux participant stacks seeded their avatars with `hash: '$item'`. `$map`'s
  `select` resolves a string only when it starts with `'$item.'`, so a bare one is a
  literal — every generated face in those rows was the same face. `CallsList` had found
  this and wrapped its own in `$concat`; the note never reached the other four. `peopleRow`
  can only get it right.
- `templateRow` and `themeRow` were the same row twice and had begun to diverge. Now one
  generator with two calls.

Also fixed on the way past: `HomeRoute` asked for `orderedSidebarItems.length` where
every other list uses `$count`.

The kit's rule, written in its index: code owns only what data *cannot* express —
behaviour, focus, accessibility, browser APIs, measurement. Everything above that line is
arrangement and stays data, because a prop is a customisation somebody predicted while a
node tree is every customisation, including the ones nobody thought of. So `AvatarStack`
stays a component and the count beside it does not; `we-modal` stays a primitive and the
confirm dialog inside it does not.

Three shapes were deliberately left alone — the presence row, and two compact bylines.
Each would have cost the fragment two or three options to serve one call site. Two
instances of a shape is a coincidence; three is a fragment.

Net: 940 lines out of the default template, and every schema still validates.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…place browsers into one

The shell had the other half of the duplication. `notConfigured` here was byte-identical
to the one in `SpaceGate` — different package, same twelve lines — and the "coming soon"
stubs for blocks and components had drifted into their own gap and their own title size,
which nobody chose.

`marketplaceList` replaces four files that were two pairs of near-copies: templates and
themes on the marketplace's own pages, and the same two again as compact panels inside
space settings. They had already diverged in a way that mattered — the themes page wires
up install, delete and a per-row spinner, and the templates page beside it wired up none
of the three, so publishing a template you could not then remove was the only path
available. Four axes genuinely differ (grid or list, sortable, a second empty line, the
card's own props); everything else was the same list four times.

`settingsSection` moves into the kit as `adminSection`, where the runtime settings pages
and anything else that administers a backend can share it.

340 lines out of the shell.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…nd do nothing

Four checks, each for a failure the validator was silent about and the runtime only
mentions to a console nobody has open. All four have shipped in WE's own templates.

**`$map` select references.** `resolveSelectValue` substitutes a string only when it
starts with `'$item.'`; everything else passes through untouched. So a bare `'$item'`
becomes the five characters `$item` — the same for every row. Four Flux participant
stacks seeded their avatars that way and drew one generated face for everybody, which
reads as a styling quirk rather than a bug and survived every review. Now an error, with
the fix in the message.

**`$map`'s source was never checked at all.** The walker read `mapObj.source`; the grammar
is `items`. A dead `$store` inside a `$map` was invisible — the same class as the
`signalTypesBySlug` breakage that motivated walking query internals.

**Writes to hoisted query results.** `$queries` and `$localState` share one `$local`
namespace so a *reader* need not care which declared a name. A *writer* must:
`$setLocal` against a query warns and no-ops. Tracked separately now, with shadowing
handled — a `$localState` field of the same name on a lower node takes the name back.

**`$toggleLocal` and `$callLocal` were not checked at all**, so a typo in either produced
exactly the failure this validator exists for.

Tests cover all four plus the shadowing case. They read `context.json` from disk the way
the CLI does rather than importing `@we/ai-context`, which is a build tool and would point
the dependency the wrong way.

Also documents the fragment architecture in docs/architecture/template-fragments.md: the
runtime-invariance rule, fragment-vs-component-vs-operator, the ambient-scope contracts
the kit relies on, and the provenance/drift design for when fragments become things a
community can extract and publish.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…why it isn't an operator

Forty-two form fields across the two template packages, each spelling out the same triple:
read `$local`, write `$setLocal` on the control's own event, hand the field's error to the
wrapper. Fourteen of them are now one line each.

It landed as a fragment rather than the `$field` *operator* the architecture doc first
assumed, and the reason is worth keeping: the mapping from a field to **how a control
reports a change** is per-component knowledge — `we-input` emits `onInput` carrying
`$event.detail`, `we-select` emits `onChange`, `Search` calls back with the value itself as
`$arg`. An operator would have to hold a table of design-system conventions inside the
schema resolver, which is exactly what the three seams forbid, or be told the event and the
path at each call site, which saves nothing over writing the two props. A fragment can hold
that table because it lives in the layer that already knows the components. The doc is
corrected, with the reasoning, as a worked example of the boundary.

The fragment covers the plain case only, and refuses the rest by design: the twenty-eight
remaining fields use custom controls (location, icon and colour pickers, `EditableImage`) or
write somewhere other than where they read (`merge` into an object field). Converting those
would mean options for a control table this does not own.

Also drops a redundancy several call sites carried: `error: { $if: { condition: { $error: 'x' },
then: { $error: 'x' } } }`. `$error` is already empty until the field is touched, so the
condition tested the token it was about to render.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…re built from

The kit is TypeScript, so a template authored in the browser cannot import it. What it can
do is produce the same JSON — which is the point of expanding at authoring time: the output
is the shared artifact, not the helper.

So the kit's fragments also ship as recipes in the generated reference: empty states, the
hoisted-query list, gate prompts, confirm dialogs, form fields, bylines, avatar groups, page
shells, section cards, attribute rows. Each is JSON to copy, with the decision that is easy
to omit and hard to notice missing spelled out beside it — why the empty state carries a
delayed fade, why `hash` accompanies `image` rather than standing in for it, why the query
is hoisted rather than left on the `$each`.

Two of the notes are the bugs this branch found, written down where the next author will
meet them: a bare `"$item"` in a `$map` select is a literal, so every generated face comes
out identical; and a `$local` flag must be declared by an ancestor of the button that sets
it, or the control renders, takes the click and does nothing.

Placed after the stores and their patterns — a recipe names both, so it only reads once the
vocabulary underneath it exists — and before the rules, which are the short prohibitions a
reader should meet last.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…own, then enforced

A README stating the package's purpose and the fragments' place in the ecosystem, a
CONVENTIONS.md carrying the authoring rules, and then a sweep applying those rules to the
fragments that predate them. The satisfying part of the audit was that every finding was a
place the rules were broken, not a place they were wrong.

What the rules convicted:

**Two exports with zero call sites.** `statChip` was extracted from the inventory and never
wired in — speculative API, the thing the extraction threshold exists to prevent. It now
serves the five sites it was extracted from (FluxChannels' two counts, SpacesList's three
facts), and its `label` becomes `string`: the value form interpolates it, and a token
through that template literal rendered `[object Object]:`. `gridWrapper`'s only caller was
`cardList` in the same file, so it is no longer exported.

**Options that preserved drift.** `pageShell` grew `pt` to reproduce About's missing bottom
padding — an accident promoted to API. Dropped; About gains its bottom padding. `minHeight`
existed for the cards route's wrapper, which had never been converted; it is now, so the
option has its call site. The four-line conditional-spread dance collapses to `?? ` defaults,
per the spread rule now in CONVENTIONS: `??` for a defaulted value, conditional spread only
where the *key* is optional.

**The const rule.** A fragment's body should read as the tree it emits; a node is named only
when the output references it more than once. `agentByline` inlines its single-use `inner`;
`gatePrompt` collapses to one literal (the `$localState` fork becomes a conditional spread,
the imperative `iconProps` becomes two more); `peopleRow` inlines its single-use `stack`,
`avatars` and `tally` — and its `lookup` helper generalises to take the context ref, which
removes the one profile-join that was written out twice in different scopes.

**Consistency.** `adminSection` was the only fragment with positional arguments (it moved
over verbatim); options object now, six call sites updated. `peopleTooltip`'s options were
typed `unknown` from before the kit's conventions; `cardShell.localState` hand-rolled the
shape `LocalStateField` already owns; `emptyState` pointed at a README that did not exist —
now it does, and the pointer aims at CONVENTIONS.

The conventions file also records the two rules with no code to enforce them: a fragment
merges in the same commit as the call sites it replaces, and when an expansion changes
materially its recipe in `@we/ai-context` changes with it — the recipe and the fragment are
two renderings of one decision.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…in code

Nine layer-4 components — PostCard, CircleButton, IconLabelButton, List, Table, Timeline,
Accordion, Breadcrumbs, Stepper — deleted, with their registry entries, exports and styles.

Every one fails the rule the kit wrote down: code owns only what data cannot express, and
each of these was a fixed arrangement of primitives with no behaviour, no measurement and no
browser API. A usage census across every template, module schema and TSX file found zero
consumers for all nine. That is not a coincidence — it is the rule enforcing itself: a layout
frozen behind props is unusable from templates (which want editable nodes) and unnecessary
from code (which composes primitives directly), so there is nobody left to use it. Half of
them were doubly unreachable, built around render-function props no schema can express.
PostCard shipped with an invalid radius token (`r="md"`) that nothing ever caught because
nothing ever rendered it — dead code rotting on schedule.

Also gone: GraphWidget and mockGraphData (superseded by the graph engine's GraphView — the
follow-up its PR named) and SpaceSidebarWidget (the sidebar schema uses CollapsibleSidebar).
Calendar and Combobox stay: unused today but genuinely code-worthy, and deadness alone is not
the crime.

The rule that decides all this is now written where the next component will be proposed, in
design-system/CONVENTIONS.md: the "does this deserve to be code" test (name the thing it does
that a Column full of primitives cannot), and the one-vocabulary-two-grammars policy —
components are the single-source vocabulary, fragments and JSX are its two arrangement
grammars, neither re-implements the other's layer, and a pattern needed identically on both
sides is either demoted to a component or mounted as a schema island via RenderSchema, never
written twice. Which surfaces are schemas at all follows one line: surfaces a deployment
should be able to replace are schemas; tools are code — the editor stays TSX, deliberately.

Pre-1.0 is when deletion is free; the compatibility constitution will make registry entries
permanent, so the vocabulary the AI is taught shrinks to components templates can actually
drive while that is still allowed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
How node insertion comes to the visual editor: a registry-driven explorer first
(closing the real gap — users cannot add nodes at all), then kit fragments with
param forms, live RenderSchema previews in the current theme, and insert-time
scope checking. One insert pipeline serves the palette and the in-app AI alike,
and is the choke point where marketplace action-disclosure later lands. Global
adjustment of reused fragments stays gated behind the provenance design in
template-fragments.md, which phase 2's manifests are shaped to become.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Deferring the constitutions is the decision; this makes it a recorded one. Four
obligations — fragment provenance, store-contract versioning, the compatibility
constitution, action capabilities — each with the trigger that ends its deferral,
so the contract is written when its moment arrives rather than excavated from
whatever the first hundred templates happened to depend on. Sequencing refreshed
to match what this branch actually landed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…entions

The graph module converged on the kit's API shapes independently — which was the
evidence the shapes were right — but it predates the conventions, so one drift is
corrected before a second module copies it: knowledgeMap takes an options object
rather than a positional entity, per the rule (uniform call sites, room for depth
and layout options without a migration). The fragments' header now names them as
what the architecture doc calls module-provided fragments, the fourth scope, and
the kit's CONVENTIONS states that module fragments are governed by the same rules
— only the address differs.

Bodies were already exemplary: single literals reading as the tree they emit,
each with its why. Nothing else to fix.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Fifty-two tests in three groups, all about output rather than implementation — a
fragment is free to restructure as long as what it emits stays valid, tier-honest,
and keeps the behaviours call sites depend on.

Every fragment's expansion (both branches, where it forks) validates against the
real generated context. cardShell and cardList validate inside a shim declaring
the ambient displayMode their contract documents — which is the validator working
as designed: their own $localState switches scope checking on, and the shim is
the same declaration the palette's insert-with-fix will one day add for real.

The portable tier's claim — no store names outside we/ — was enforced by
convention and checked by nobody; now a walk over each expansion asserts no
$store token and no $agent node the fragment itself introduced. This converts
the one unchecked line in the PR's test plan into a permanent check that covers
every future fragment too.

And the contracts worth pinning individually: dids-mode avatar hashes are a token,
never the literal '$item' (the bug this branch was born from, now a regression
test at the source); field wires the event each control actually emits;
confirmModal clears its flag from all three exits; cardList's branches read the
same hoisted key; agentByline shares one interpolation across both arrangements.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@netlify

netlify Bot commented Aug 11, 2026

Copy link
Copy Markdown

Deploy Preview for coasys-we ready!

Name Link
🔨 Latest commit 65531c7
🔍 Latest deploy log https://app.netlify.com/projects/coasys-we/deploys/6a7b0a5ae5dac6000882c615
😎 Deploy Preview https://deploy-preview-112--coasys-we.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@jhweir
jhweir merged commit 0fbaaf9 into dev Aug 11, 2026
1 check failed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant