Feature: Template kit - #112
Merged
Merged
Conversation
…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>
✅ Deploy Preview for coasys-we ready!
To edit notification comments on pull requests, go to your Netlify project configuration. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
feat: a template fragment kit, and the monorepo made to follow its rules
Branch:
feat/template-kit→dev· 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 atauthoring 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) andwe/(reads WE's stores or$agent). The tier split is the package's honest dependency declaration: a fragment namingspaceStore.membersfails silently on a host without that store, andpackage.jsoncan't say so.Includes
field, which landed as a fragment rather than a schema operator because which eventcarries 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$mapselect — every generated face identical);templateRow/themeRowwere drifting twins; themarketplace 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-shelldependency is gone.
Validator (
@we/schema-shared) — four new lint classes for schemas that render, take theclick, and silently do nothing: bare/literal
$-strings in$mapselects;$map's itemsexpression (previously read from a key the grammar doesn't have, so never checked); writes to
read-only
$queriesnames (with shadowing handled);$toggleLocal/$callLocal(previouslyunchecked entirely). 10 tests, each reproducing a bug that shipped in WE's own templates.
@we/ai-context— apatternsfragment: the kit's shapes as copyable JSON in the in-app AI'sprompt, 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/Comboboxkept: unused but genuinely code-worthy.@we/module-graph— itsfragments.ts(which independently converged on the kit's APIshapes before the conventions existed) aligned with them:
knowledgeMaptakes 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.
Docs —
docs/architecture/template-fragments.md(the expansion model, fragment vs componentvs 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 theeditor and AI, phased).
Deliberate non-changes
Decisions, not gaps — recorded so review doesn't re-litigate them:
field— custom controls (pickers,EditableImage) ormergewrites into object state; converting would mean options for a control table thefragment doesn't own.
would cost the fragment 2–3 options to serve one call site; the extraction threshold in the
kit's CONVENTIONS exists for exactly this.
peopleTooltipstays inwe/though it names no store — grouped with its consumers.Calendar/Comboboxkept despite zero consumers — behaviour-worthy vocabulary; deadness aloneisn't the crime, arrangement-in-code is.
Known follow-ups
requiresagainstgetScopeAtNode) — phase 2 of theexplorer/palette plan (
docs/internal/plans/prs/).docs/architecture/template-fragments.md.Test plan
reproducing the shipped bug classes, and 52 new kit tests
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, notconvention. Plus pinned contracts: the
$item-literal regression,field's per-controlevents,
confirmModalclearing its flag from all three exits,cardList's hoist keywe-validate-schemas: all 22 schemas green (the new lints run over every template in therepo)
components
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