Skip to content

Feature: Collection kinds - #115

Merged
jhweir merged 11 commits into
devfrom
feat/collection-kinds
Aug 12, 2026
Merged

Feature: Collection kinds#115
jhweir merged 11 commits into
devfrom
feat/collection-kinds

Conversation

@jhweir

@jhweir jhweir commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator

Collection kinds, showcase templates, and seed-selected distribution

Summary

Six showcase templates — a Discord-shaped Channels space, a
Timeline / Photos / Videos triptych over one set of posts, a kanban Boards template and an
Events template — built to demonstrate the range of the template system. No new content model
was added for any of them.
Every container is a CollectionBlock carrying a free-text kind and
a mode, so a template dropped onto a space that has been collecting posts for a year works
retroactively: no SDNA change, no module install, no store registration, nothing to migrate.

Getting there needed a small amount of substrate — composer anchoring, a reconcile guard, mention
edges, per-agent read/mute state, a set of kit fragments — plus the distribution mechanism that lets
a deployment choose which templates it ships. The templates then acted as the first real load on all
of it and surfaced several pre-existing holes, which are fixed here too.

This is stage 0 of notes/we/August-2026/content-models-plan.md: the bet that composition over
the existing block vocabulary covers real applications before the constraint machinery exists. The
plan doc (notes/we/August-2026/collection-kinds-plan.md) carries the full reasoning, the scoping
decisions and what was deliberately deferred.

Changes

Collection modes — kind stays a free label

packages/block-system/shared/src/modes.ts, packages/models/src/blocks/CollectionBlock.ts

CollectionBlock.kind is a free string describing what a collection is for, invented by whichever
template needs it and registered nowhere. The new mode field carries the one fact a consumer must
know: who owns the children. document means one agent authored the whole artifact; feed means
many agents append independently; collaborative is declared and unimplemented so a third regime
never has to migrate out of a two-value assumption.

A kind→mode registry was built first and deleted. It is wrong peer-to-peer: the answer would
depend on which modules the reading client had installed, so of two agents in one channel the one
missing the module is unprotected. Written on the record, the fact travels with the data and a
client that has never heard of the label still knows what it may not do.

The reconcile guard

packages/block-system/shared/src/serialization.ts

reconcileBlocks assumes the incoming tree is the whole truth — true of a re-saved artifact, false
of a container many agents append to, where its orphan pass deletes everything the editing agent's
tree omits. It now refuses any mode that is not document, checked before the transaction opens so
a refusal touches nothing. An absent mode passes: every post predating the field has none.

Composer anchoring, and mentions

packages/block-system/shared/src/serialization.ts, packages/models/src/WeNode.ts,
packages/app-shell/.../SpaceStore.tsx

createBlocks takes { kind, mode, anchor }, the anchor being a raw { id, predicate } applied to
the root only. One composer path now serves a post (unanchored), a channel message
(we://children) and a reply (we://comment) — the latter an edge that had existed on WeNode
since the beginning with no consumers. createPost grows matching options and remains the only
store change of its kind.

WeNode.mentions plus extraction from mention inline nodes, so "posts mentioning me" is a graph
query rather than a substring scan of textContent — handles are mutable and not unique, so a text
scan matches the wrong agent and misses renamed ones. Reconciled by diff on edit, the one relation
here where read-modify-write is correct: the author owns the text, so there is no second writer.

Six dead properties removed from CollectionBlock

display, columns, gap, direction, format and indent looked like a collection's layout and
were not. BlockRenderer renders purely from the editorState blob, so the authored layout has
always lived there and these were a redundant projection nothing read. Two had never held a value at
all: extractBlockData copies serialized-node properties by name, and the Lexical node calls
them layout and columnCount. No migration — orphan links for the three that were written stay
inert.

What remains is two groups and a rule: container (children, kind, mode, title,
description, textContent), true of every collection; and document (editorState, type),
true only of a composition. Nothing new joins the second, and kind-specific configuration waits for
content-model shapes.

Per-agent private state

packages/models/src/entities/ReadMarker.ts, MutedAgent.ts

Both tier-1 infra records in the root dataset — the SpacePreference rule. Written into a space
they would sync one agent's read positions, and their mute list, to every member of that community;
the second is worse than a leak, since it would tell the muted person.

ReadMarker is keyed per node rather than a per-space map, so two tabs switching channels cannot
lose each other's write. lastReadAt is ISO-8601 UTC because the comparison happens as a string and
that is the format whose lexicographic order is chronological.

Framing worth keeping past this PR: a notification in a local-first system is a standing query
whose new results I have not seen
— (query, marker, delta). This is the marker. An unread dot and
"3 unread mentions" are the same shape with different queries, so the notification engine later
generalises this rather than being invented beside it.

MutedAgent is safety rather than social graph, which is why it does not wait for follows or
cross-space feeds — it is a filter over whatever a feed already returns. Landing it now is
deliberate: a feed either carries the filter from its first fragment or none of them do.

Kit fragments

packages/templates/kit/src/lists/, overlays/composerModal.ts

collectionFeed, commentThread, channelRail, kanbanBoard, mediaGrid, loadMore, and
composerModal. These are where "just works" lives: they carry the scope drill-down shape (which
is the only form available, since children is an untyped relation and include cannot be used),
the loaded gate, the empty-state copy, and the mute filter — which is unconditional and applied in
the query
, since filtering rendered rows would make a page of twenty show fifteen when five are
muted.

composerModal replaced two hand-written copies of the composer save handshake, one in the default
template and one in the showcase. Its saveAction takes '$arg' as a placeholder the caller
positions, because the serialized tree is not always last — updatePost(id, json) takes it second,
createPost(json, options) first.

Supporting store actions: moveChild (a kanban card between columns as a relink of two
we://children edges; add before remove, so a failure between the two round trips leaves the card
in two columns rather than none) and setAttending (an RSVP that writes only this agent's own entry
— the add-only rule that keeps participants conflict-free).

Distribution — seed-selected, build-time bundled

we-seed.json, packages/app-shell/scripts/, templateRegistry.ts

templateRegistry was a hardcoded object with one live entry and a commented-out second one, which
is what happens when the only way to add a template is to impose it on every deployment. The seed
could already select modules; templates — VISION's highest-volume contribution type — were the
missing half.

Now: sources in-repo (typechecked and walked by the schema validator, so a renamed prop breaks the
build), WeSeedFile.templates selecting them, and generate-templates writing the registry from
that list at build time, so an unselected template leaves the import graph rather than being
filtered at boot. Runtime filtering would hide a template while still shipping every byte of it.

publish-templates derives a marketplace bundle from the validated source. It writes JSON and stops
there rather than talking to a backend: publishing is an authored act by a specific agent into a
specific space, and a script that could overwrite a shared space's templates from CI is a bad idea
however careful it is.

Templates can suggest a theme

packages/app-shell/src/shared/themeResolution.ts, TemplateMeta.themeId,
AgentSettings.useTemplateTheme

A template names a theme in meta.themeId and is seen in it. Resolved, never written — the
suggestion is a rung in the theme chain, so switching template changes the look without overwriting
anyone's stored choice, switching back restores by recomputation, and the opt-out is one boolean in
the resolver rather than writes to unwind.

Precedence turns on whether the template on screen is the space's default, not on which
preference field was written. Two review corrections got it there, and both are worth knowing:

  1. The first ordering put the space's theme above the suggestion unconditionally — wrong in the case
    that matters most, since a space's default theme was chosen alongside its default template, so
    any space that had set one would never show a template's theme at all.
  2. The second keyed the pairing on SpacePreference.templateId, missing that the switcher writes
    AgentSettings.currentTemplateId instead. On the path people actually use it reported "the space
    chose this" and the suggestion never applied.

The rule lives in resolveSpaceTheme, a pure function with 16 tests, because it is the feature and
everything around it is plumbing.

Pre-existing bugs the templates surfaced

Each was found by the templates being the first real load on the machinery:

  • Token-valued limit was unvalidatable. Typed and validated as number while the renderer
    deep-resolves query params, so limit: { $local: 'pageSize' } worked at runtime and failed
    validation — making every paginated list unvalidatable.
  • $plural was missing from the validator's token union. Documented, resolvable, and rejected
    in any schema that used one. It survived because the only fragment emitting one had no validated
    caller.
  • The schema layer had no arithmetic at all, so "show 20 more" was inexpressible and a paginated
    list could not advance past its first page. Added $setLocal: { by } — a form of $setLocal
    rather than a general $add, since the need is bumping a counter and general arithmetic in
    schemas invites computing layout values.
  • Local scope leaked across route boundaries in the validator. buildRoutes renders each route
    through its own RenderSchema call, so a route subtree inherits no context — but the validator
    carried the parent scope across and therefore approved reads that resolve to nothing, which is
    the one direction a validator must not fail in.
  • Tokens in children arrays were not checked at all. A token has no type, so walking it as a
    node dropped it into the grouping-node branch; every store path, action name and $local
    reference inside a token in a children array went unexamined.
  • Lifecycle actions created computations outside a reactive root. onSuccess/onError/
    onFinally run from a promise callback and handler arrays from the click, both outside any owner,
    so passing createMemo produced computations that are never disposed. Now noMemo. This also
    fixed a quieter bug: a memoized argument arrives as an accessor, and $action's relative-path
    branch tests typeof resolvedArgs[0] === 'string' before deepUnwrap runs, so onSuccess
    navigations with a $concat argument skipped relative-path resolution entirely.
  • BlockComposer with onSave and no onReady is now a validation error. The composer is
    pull-based, so that pairing is a handler nothing can call — and since onReady is optional, the
    composer falls back to rendering a save button of its own, leaving two buttons of which only the
    unexpected one works.
  • The dispatcher and the zod union are now pinned against each other. Two hand-maintained lists
    of the same thing, with nothing comparing them; the first two items above are both instances. A
    test extracts each side and fails in either direction.

Known follow-ups

Recorded in the plan doc's §6, deferred by decision rather than overlooked:

  • Typed childreninclude over children (a channel list with counts and previews in one
    query). Blocked on the self-reference cycle.
  • Manual ordering — waits on the AD4M CRDT ordering strategy. A position scalar written now
    would be a shape that design supersedes. GraphView's manual layout is complete and unused,
    waiting on the same thing.
  • Virtualization and scroll anchoringloadMore refetches each page's predecessors, which is
    fine for hundreds and wrong for tens of thousands. Cursor paging (Page.after exists in the IR)
    plus windowing is the real fix.
  • Cross-space feeds — "home" is this space's posts, because queries do not fan out yet.
  • Large mediaVideoBlock is url + provider; video uploads need chunked, streamed storage.
  • Roles and private channels — a collection is not a permission boundary, and enforcement needs
    the validation layer beneath WE. The Channels template says so where a lock icon would lie.
  • Multi-author documents — a wiki page is many agents and one artifact, which neither
    document nor feed can hold. The mode enum is open so a third regime needs no migration.
  • ChatSession / ChatMessage — the one standing violation of container-not-content. Blocked
    on the semanticRole vocabulary decision, and its first real customer.
  • Legacy kind: 'post' backfill — lets the Default template's reads move off type: 'root'.
  • A token directly in a children array is still not scope-checked by name — the fix above
    covers store/action/$local references inside it, but the array position itself has no separate
    rule.

Test plan

Automated, all run against this branch:

  • pnpm build — 30 packages including the electron, tauri and web targets
  • pnpm lint, pnpm lint:css — clean
  • pnpm -r typecheck — clean
  • pnpm -r test — 1,725 passing. New: 38 in block-shared (reconcile guard including that a refusal
    leaves children intact, anchor placement, mode defaulting, mention extraction/dedup/reconcile),
    22 in schema-shared semantic validation (route-boundary scope, children tokens, the composer
    handshake), 5 for $setLocal: { by }, 2 for lifecycle dispatch, 4 for operator parity, 16 for
    theme resolution
  • pnpm --filter @we/schema-shared validate — 27 schemas, no issues
  • Regenerating the core manifest, the template registry and the ai-context outputs leaves the tree
    clean

Negative tests, run by injecting the bug and confirming the right failure:

  • Reintroducing the route-boundary scope bug produces three errors pointing at exactly the runtime
    warnings it caused
  • Dropping $plural from the zod union, and adding a zod-only token, each fail the parity test with
    the appropriate message

Manual, in the browser:

  • Creating a channel, posting a message into it, and the unread dot
  • Switching template moves the theme (Boards → light, Events → retro, triptych stays dark)

Not verified, and the reason this wants a careful first run: the SDNA shape changed — six
properties dropped from CollectionBlock, mode added, mentions added to WeNode, and two new
root models. Reads go through declared properties, so an installed shape carrying extra ones should
be inert, but that needs a pre-existing space to confirm and cannot be checked without a running
executor.

jhweir and others added 11 commits August 12, 2026 18:13
Substrate for templates that organise content into containers — channels,
boards, playlists — without any new content model. Three changes that fit
together, plus a cleanup the investigation turned up.

**Modes, not a kind ontology.** `CollectionBlock.kind` stays a free label
saying what a collection is *for*, invented by whichever template needs it
and registered nowhere. The new `mode` field carries the one fact a consumer
must know: who owns the children. `document` means one agent authored the
whole artifact; `feed` means many agents append independently;
`collaborative` is declared and unimplemented so a third regime never has to
migrate out of a two-value assumption.

A kind→mode registry was built first and thrown away. It is wrong in a
peer-to-peer system: the answer would depend on which modules the *reading*
client installed, so of two agents in one channel the one missing the module
is unprotected. Written on the record, the fact travels with the data.

**The reconcile guard.** `reconcileBlocks` assumes the incoming tree is the
whole truth — true of a re-saved artifact, false of a container many agents
append to, where its orphan pass deletes everything the editing agent's tree
omits. It now refuses any mode that is not `document`, checked before the
transaction opens so a refusal touches nothing. An absent mode passes: every
post predating the field has none.

**Anchoring.** `createBlocks` takes `{ kind, mode, anchor }`; the anchor is a
raw `{ id, predicate }` applied to the root only. One composer path now
serves a post (unanchored), a channel message (`we://children`) and a reply
(`we://comment`) — `we://comment` being an edge that has existed on WeNode
since the beginning with no consumers. `createPost` grows the matching
options, and stays the only store change.

**Mentions.** `WeNode.mentions` + extraction from `mention` inline nodes, so
"posts mentioning me" is a graph query rather than a substring scan of
textContent — handles are mutable and not unique, so a text scan matches the
wrong agent and misses renamed ones. Reconciled by diff on edit, which is
the one relation here where read-modify-write is correct: the author owns
the text, so there is no second writer to race.

**Cleanup: six dead properties off CollectionBlock.** `display`, `columns`,
`gap`, `direction`, `format` and `indent` looked like a collection's layout
and were not. BlockRenderer renders purely from the editorState blob, so the
authored layout has always lived there and these were a redundant projection
nothing read. Two had never held a value at all: extractBlockData copies
serialized-node properties by *name*, and the Lexical node calls them
`layout` and `columnCount`. No migration — orphan links for the three that
were written stay inert, and nothing reads them.

What remains is two groups and a rule: container fields (children, kind,
mode, title, description, textContent) true of every collection, and
document fields (editorState, type) true only of a composition. Nothing new
joins the second, and kind-specific config waits for content-model shapes.

Tests: 38 in block-shared covering the guard (including that a refusal
leaves children intact), anchor placement, mode defaulting, and mention
extraction/dedup/reconciliation. app-shell 260 and transcribe 33 still green.

Plan: notes/we/August-2026/collection-kinds-plan.md, stages A–C and E.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The per-agent-private-state gap, scoped to its two forcing cases. Both are
tier-1 infra records in the root dataset — the `SpacePreference` rule, and
for the same reason: written into a space they would sync one agent's read
positions, and their mute list, to every member of that community. The
second is worse than a leak, since it would tell the muted person.

**ReadMarker** { nodeId, spaceUuid, lastReadAt }. One record per node read,
keyed by node rather than a per-space map, so two tabs switching channels
cannot lose each other's write — a map is one property rewritten every time,
which is the read-modify-write race `WeNode.participants` documents.

`lastReadAt` is ISO-8601 UTC because the comparison happens as a string
(`createdAt gt marker` pushes down as a scalar compare), and that is the
format where lexicographic order is chronological. A local-offset variant
sorts wrongly and does it quietly.

Framing worth keeping past this stage: a notification in a local-first
system is a standing query whose new results I have not seen — (query,
marker, delta). This is the marker. An unread dot and "3 unread mentions"
are the same shape with different queries, so the notification engine later
generalises this rather than being invented beside it.

**MutedAgent** { did, description }. Safety rather than social graph, which
is why it does not wait for follows or cross-space feeds — it needs neither,
being a filter over whatever a feed already returns. Landing it now is
deliberate: a feed either carries the filter from its first fragment or none
of them do. Global rather than per-space, because the case a per-space list
would fail is exactly the common one — someone unpleasant in one community
turning up in another. One record per mute, so mute and unmute are an
independent create and delete.

Not moderation and not enforcement: this hides content on one screen. An
AD4M neighbourhood is writable by every member, and community-level removal
needs governance plus the validation layer under it.

Store: `markRead` (silent on failure — a lost marker is a stale dot, not
worth interrupting what the user opened the channel to do), `setAgentMuted`
(reports failure — it was asked for deliberately, and believing you have
muted someone when you have not is worse than knowing it failed), plus
`readMarkers` keyed by node id for indexing inside `$each`, and `mutedDids`
for the feed filter.

Known limit, deferred not overlooked: the root dataset is this device's, so
read positions do not follow the agent across machines. Promoting them is
the AgentSettings decision, and a marker that syncs needs conflict rules —
"latest wins" is only obviously right until someone marks a channel unread
on purpose.

Plan: notes/we/August-2026/collection-kinds-plan.md stage H.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The arrangement layer over the collection substrate. Six fragments, plus the
one operator gap and one store action they turned out to need.

**collectionFeed** — the shape every container surface is: the collections of
some kind, inside a parent or loose in the space, with something to say when
there are none. Carries three things a hand-written `$each` keeps getting
wrong: the `scope` drill-down form (`children` is untyped, so `include` is
unavailable and this is the only shape that works), the loaded gate, and the
mute filter.

The mute filter is unconditional and in the *query*, not over the rendered
rows. Filtering after the fact makes a page of twenty show fifteen when five
are muted, because the limit was already applied — a hole in the page rather
than a shorter list.

**commentThread** — replies over `we://comment`, recursive. Bounded at
authoring time because a schema is a finite tree; past the limit it shows a
count, since a thread that simply stops looks finished to someone whose reply
is below the cut.

**channelRail** — channels, optionally grouped by category, with unread dots
from the read markers. Marking read is wired to the *navigation*, not the
feed's mount: a feed that marks on mount also marks on every re-render the
router does, clearing dots nobody saw.

Fixed a bug while writing it, which also corrected the store: the dot needs
this row's marker, and `{ $store: 'spaceStore.readMarkers.<context ref>' }`
cannot express that — `$store` resolves a *static* dot path, so the ref is
taken literally and resolves to nothing, reading as "never opened" and
leaving every channel permanently dotted. `readMarkers` is now an array read
with `$find`, and the docstring claiming a schema could index a keyed map is
gone; it could not.

**kanbanBoard** — board → columns → cards, all containment. Records the
decision that **containment expresses status**: `TaskBlock.status` and a
board's columns say the same thing, this picks columns, and a template using
both will get the disagreement two sources of truth always produce.

**mediaGrid** — the same posts a feed renders as a timeline, drawn as a grid,
which is the triptych demo in one fragment. Cover images come from a
`$firstImage` single-item projection, so one query yields posts and images
without a fetch per tile.

**loadMore** — paging by raising a `$local` limit. Honest about the cost: each
page refetches everything before it, which is fine for hundreds and wrong for
tens of thousands. Cursor paging (`Page.after` already exists in the IR) plus
windowing is the real fix.

**`$setLocal: { by: n }`** — the schema layer had *no arithmetic at all*, so
"show 20 more" was inexpressible and a paginated list could not advance past
its first page. Added as a form of `$setLocal` rather than a general `$add`
operator: the need is bumping a counter, and general arithmetic in schemas
invites computing layout values, which is what tokens and DS props are for.
Non-numeric current value counts as 0 — NaN would reach a query limit and
empty the list with nothing to point at.

**`spaceStore.moveChild`** — a card between columns as a relink of two
`we://children` edges. Add before remove deliberately: both are separate
round trips, so a failure between them leaves the card in two columns rather
than none — visible and fixable, where the other order loses it somewhere no
view lists.

Plan: notes/we/August-2026/collection-kinds-plan.md stages D, F, G.

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

Six applications over one universal container, plus the distribution
mechanism that lets a deployment choose which of them it ships.

## The templates

Discord-shaped channels, a Timeline/Photos/Videos triptych, kanban boards,
and events. **No new content model for any of them.** Every container is a
`CollectionBlock` with a `kind` label the showcase package invented and a
`mode` saying who owns its children, so a template dropped on a space that
has been collecting posts for a year works retroactively — no SDNA change,
no module install, no migration.

The triptych is the argument: Timeline, Photos and Videos read *the same
records*. Switching between them turns a feed into a photo grid into a video
library over one space, which is data/interface separation demonstrated
rather than asserted.

Kanban is in the set deliberately — five of six render conversation, and
without one that renders work the showcase accidentally argues that this is
a social-media engine. Events earns its place by needing nothing new:
`participants`, `EventBlock`, `Calendar` and `LocationBlock` were all built
and undemonstrated.

Each is honest about a limit rather than faking past it: no private channels
(a neighbourhood is writable by every member, so a collection is not a
boundary — the empty state says so where a lock icon would lie), no video
uploads, no cross-space feed, no manual ordering.

## Distribution

`templateRegistry` was a hardcoded object with one live entry and a
commented-out second one — which is what happens when the only way to add a
template is to impose it on every deployment. The seed could already select
`modules`; templates, the highest-volume contribution type in the system,
were the missing half.

Now: sources in-repo (typechecked, walked by the schema validator, so a
renamed prop breaks the build), `WeSeedFile.templates` selecting them, and
`generate-templates` writing the registry from that list at **build time**,
so an unselected template leaves the import graph rather than being filtered
at boot. Runtime filtering would hide a template while still shipping it.

`publish-templates` derives a marketplace bundle from the validated source.
It writes JSON and stops there rather than talking to a backend: publishing
is an authored act by a specific agent into a specific space, and a script
that could overwrite a shared space's templates from CI is a bad idea
however careful it is.

## Three bugs the templates surfaced

**`limit` rejected tokens.** Typed and validated as `number`, while the
renderer deep-resolves query params — so `limit: { $local: 'pageSize' }`
worked at runtime and failed validation, making every paginated list
unvalidatable. `scope.anchorId` already carried the allowance.

**`$plural` was missing from the validator's token union.** Documented,
resolvable, and rejected in any schema that used one. It survived because
the only fragment emitting one had no validated caller — this union is a
second hand-maintained list of the dispatcher's operators, and it drifts
exactly this way.

**`$setLocal: { by }` needed its zod branch** to match the resolver added
with the kit fragments.

Also: `spaceStore.setAttending` for RSVPs, writing only this agent's own
entry — the add-only rule from `WeNode.participants`, which is what keeps a
roster conflict-free without coordination.

Verified: `pnpm lint`, `lint:css`, `-r typecheck`, `-r test` and all 27
schemas validate clean.

Plan: notes/we/August-2026/collection-kinds-plan.md stages I and J.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The generator emitted unformatted output, so `lint --fix` rewrote the
committed file and the next regeneration then differed from it — CI's
generated-file diff check would fail on a file nobody had edited.

Formats before writing, following `@we/ai-context`'s generator. The repo's
other convention is to put generated sources under `src/generated/**` and
lint-ignore them; formatting is the better half of that trade for a file
this small, since it stays readable in review.

Verified idempotent: regenerating the manifest, the template registry and
the ai-context outputs now leaves a clean tree.

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

Dead leftovers. The kind registry was replaced by mode-on-record, which
removed `ModuleDefinition.kinds` from the contract — but the `kinds: [...]`
blocks in notes and transcribe stayed, so both modules declared a field that
no longer exists. Their collections already write `mode: 'feed'` directly,
which is the whole mechanism now; nothing was lost with the declarations.

Missed because neither package has a `typecheck` script, so their types are
only checked by tsup's dts step during `pnpm build` — and the earlier
verification ran lint, per-package typecheck, tests and schema validation
but not a full build. `pnpm build` now passes across all 30 packages
including the three app targets, and is worth running before calling this
kind of change green.

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

Two bugs from actually using the Discord template, plus the validator gap
that let the second one ship.

**Posting a message failed** with `Cannot read properties of null (reading
'type')` from inside `persistNode`. `BlockComposer.onSave` does not fire when
the user types or when the modal closes — it fires when somebody calls the
composer's own `save()`, which it hands out once through `onReady`. The
showcase composer skipped that handshake and read a `draft` local nothing
ever wrote, so every post was `createPost(null)`.

Now wired the way the default template's composer already does it: `onReady`
stores `save` in a function-typed local, the button calls it with
`$callLocal`, and the action runs inside `onSave` with the serialized tree as
`$arg`. Worth noting how invisible the broken version was — it typechecked,
validated, rendered, and failed only on submit, several frames from the
cause.

**Signal controls silently never appeared**, with only a
`field "signalTypes" not declared` console line. `buildRoutes` renders each
route through its own `RenderSchema` call, so a route subtree inherits *no*
context: `$queries` and `$localState` on the template root are invisible
below a `$routes` outlet. Four templates hoisted `signalTypes` onto the root;
Timeline's feed genuinely needed it, and the other three declarations were
unread. Declarations now sit in the routes that use them.

**The validator agreed with the mistake**, which is why it shipped: it
carried the parent scope across the route boundary, so it approved reads that
resolve to nothing — failing in the one direction a validator must not. It
now resets scope at each route, using an *empty* set rather than `null`,
because those mean different things here: `null` is "unknown", which is how a
standalone fragment is judged so that reading a local its eventual parent
declares is not an error, while below a route nothing is unknown. Confirmed
against the regression: reintroducing the Timeline bug now produces three
errors pointing exactly at the runtime warnings, and all 27 existing schemas
still validate clean, so the stricter rule finds no false positives.

Four tests cover it, with the reads in `props` because that is where the real
ones were — and because a token sitting directly in a `children` array is not
walked at all, a separate gap noted but not addressed here.

Verified: `pnpm build`, `lint`, `lint:css`, `-r typecheck`, `-r test`, 27
schemas.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The composer handshake was right but undiscoverable, and two checks that
should have caught the mistake were themselves broken. Fixing the ergonomics
without fixing the checks would just move the next instance somewhere else.

**One composerModal, in the kit.** There were two hand-written copies of the
save handshake — the default template's `postComposerModal` and the
showcase's — which is the drifting-twins pattern the kit's CONVENTIONS name
as an extraction trigger, well past the three-use threshold. Earlier I argued
it belonged in the showcase because it "encodes these templates' decisions";
that was wrong. The thing making it showcase-specific was hardcoding
`createPost`, which is exactly the parameter to lift out.

The action is now a `saveAction` with `'$arg'` marking where the serialized
tree goes — a placeholder the caller positions rather than an argument
appended for them, because the tree is not always last: `updatePost(id, json)`
takes it second, `createPost(json, options)` first. An implicit append would
silently serve one and corrupt the other.

The fragment carries its own `$if`, so it mounts only while open — which is
what resets the composer between uses. Both call sites dropped their wrapper.

**The validator refuses `onSave` without `onReady`.** The composer is
pull-based, so that pairing is a handler nothing will ever call — and since
`onReady` is optional, the composer falls back to rendering a floppy-disk
button of its own, leaving two buttons of which only the unexpected one
works. A hardcoded component rule, because the constraint is a relationship
between two optional props and no manifest can express it.

**Tokens in `children` arrays are checked at last.** `children` legitimately
accepts tokens — a `$plural` count-noun label is written that way — but a
token has no `type`, so walking it as a node dropped it into the
grouping-node branch, which looks for routes and children and finds neither.
Every store path, action name and `$local` reference inside a token in a
children array went unexamined: move the same expression from a prop into
children and the validator stopped having an opinion. Found while writing the
route-boundary tests, when the first attempt passed against known-buggy code.

**Lifecycle actions resolve with `noMemo`.** `onSuccess`/`onError`/`onFinally`
run from a promise callback, and event-handler arrays run from the click —
both outside any reactive owner, so passing Solid's `createMemo` created
computations outside a root that are never disposed. A lifecycle array is
evaluated once, at a moment that has already happened; there is nothing for a
memo to be for.

That also fixes a quieter bug: a memoized argument arrives as an accessor,
and `$action`'s relative-path branch tests `typeof resolvedArgs[0] ===
'string'` before `deepUnwrap` runs — so
`onSuccess: [{ $action: 'routeStore.navigate', args: [{ $concat: … }] }]`
skipped relative-path resolution entirely. Absolute paths survived; relative
ones did not.

**Documented** in `ai-context/src/fragments/patterns.ts`, so the handshake
reaches CLAUDE.md as a house pattern rather than being discovered from a
stack trace — including what the wrong spelling looks like and why it fails
so far from its cause.

14 new tests. Verified: `pnpm build`, `lint`, `lint:css`, `-r typecheck`,
`-r test` (1,400+), 27 schemas, and no drift from regenerating.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A showcase template that renders in the wrong palette undersells itself, so a
template can now name a theme in `meta.themeId` and be seen in it.

**Resolved, never written.** The suggestion is a rung in the theme chain, not
a value applied on switch. That is the whole design: switching template
changes the look without overwriting anyone's stored choice, switching back
restores what was there by recomputation, and the opt-out is one boolean in
the resolver rather than a setting that has to unwind writes. Applying on
switch would have meant A→B→A silently keeping B's theme, and a user who had
deliberately picked one getting it replaced.

**Precedence turns on who chose the template, not on layer** — the correction
that came out of review, and the case the first ordering got wrong. A space's
default theme was chosen *alongside its default template*; they are a pair.
Ranking the space's theme above the suggestion unconditionally meant any
space that had set one would never show a template's theme at all: overriding
to Channels left you in the palette somebody picked for the Cards layout, so
the feature did nothing in precisely the spaces that had configured
themselves. Now:

- an explicit pin always wins — the only way to say "this theme here,
  whatever else changes", and therefore also what stops template switching
  moving the theme in that space
- `AGENT_DEFAULT` is explicit too, so a template cannot reinterpret it
- the space chose the template → space theme, then suggestion, then global
- the agent chose it → suggestion first, since the space's theme is a
  leftover from an interface they just replaced; the space's theme still
  backs it up, so a community's look survives a template with no opinion

No new state — `SpacePreference.templateId` already records which happened.

The rule lives in `resolveSpaceTheme`, a pure function, because it is the
feature and everything around it is plumbing. 14 tests, including the
reversibility property and the case the first design broke.

`meta.themeId` rather than only `Template.themeId` (which existed, was
written by `saveTemplate`, and was read by nothing): built-in templates have
no model record, and in `meta` the suggestion travels with a fork, a publish
and a `?template=` link.

A suggestion naming a theme the agent lacks is reported once and ignored,
matching how `?theme=` in a share link degrades. `useTemplateTheme` defaults
on — read as `!== false` so an agent who has never decided gets the useful
behaviour rather than the absent one — with a switch in Settings beside the
theme-scope one.

Themes: the Timeline/Photos/Videos triptych deliberately share `dark`, since
those three exist to show one space rendered three ways and the theme must
not be a second variable moving at the same time. Channels takes `dark`,
Boards `light`, Events `retro`.

Verified: `pnpm build`, `lint`, `lint:css`, `-r typecheck`, `-r test`, 27
schemas, no drift from regenerating.

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

Applying a template did not change the theme. Two paths change the template
and they write different places: `spaceStore.setSpaceTemplateOverride` writes
`SpacePreference.templateId`, while the template *switcher* calls
`templateStore.switchTemplate`, which writes `AgentSettings.currentTemplateId`
and never touches the preference.

The resolver keyed on the preference, so on the switcher path — the one people
actually use — it reported "the space chose this template" and computed the
suggestion from the space's *default* template rather than the one just
applied. With the Default template suggesting nothing, that resolved to empty
and the theme stayed put.

Now both the suggestion and the pairing come from
`templateStore.currentTemplate`, which is what is on screen however it got
there. `templateIsSpaceDefault` replaces the raw override string in
`ThemeResolutionInput`, and it is a better statement of the rule anyway: the
question was never "which field was written", it was "is the template I am
looking at the one this space's theme was chosen alongside".

`currentTemplate` is a `createStore` proxy, so reading `.id` and
`.meta.themeId` inside the theme effect tracks them and a switch re-runs it.
It is a single global, so `templateThemeFor` returns nothing for any space
that is not on screen — right rather than merely safe, since the only caller
passing another uuid is `setSpaceThemeOverride`, which is writing a pin that
outranks the suggestion regardless. For that case the pairing falls back to
the preference field, which is the best available answer off-screen.

Two tests added for the switcher path specifically; 16 in total.

Verified: `pnpm build`, `lint`, `lint:css`, `-r typecheck`, `-r test`, 27
schemas. Theme ids checked against `themeRegistry` — light/dark/retro all
resolve, so none of the six suggestions will silently fall through.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The prop dispatcher and `zPropToken` are two hand-maintained lists of the
same thing, and nothing compared them. They drift in one direction: an
operator the dispatcher resolves but the union rejects means a schema that
renders correctly fails to validate — or gets written around a validation
error that was never real.

Both had already happened. `$plural` was documented, resolvable and absent
from the union, so any count-noun label was an error; the only fragment
emitting one had no validated caller, so it went unnoticed for months.
Token-valued `limit` was the same shape — the renderer deep-resolves query
params, but the schema said `number`, making every paginated list
unvalidatable. This branch hit both.

Reads the source deliberately: the drift is textual, and neither side
exposes its set at runtime — the dispatcher is an if-chain and the union is
a zod type. Comparing behaviour instead would need a valid instance of every
token, which is a third hand-maintained list and the same problem again.

Checks both directions, plus that `ZOD_ONLY` (currently just `$query`, which
the *renderer* resolves rather than `resolveProp`) cannot rot into a stale
excuse, plus that the extraction found a plausible set — the real failure
mode of a source-reading test is a regex matching nothing and passing for
the wrong reason.

Which it duly did on the first attempt: stripping comments turned out to be
load-bearing rather than tidy, because the doc comment above `zPluralToken`
contains `{ $plural: … }` as an example, so deleting the token from the
union still left `$plural` in the extracted set. Verified by injecting both
drifts and watching the right test fail with the right message.

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

netlify Bot commented Aug 12, 2026

Copy link
Copy Markdown

Deploy Preview for coasys-we ready!

Name Link
🔨 Latest commit cff4e02
🔍 Latest deploy log https://app.netlify.com/projects/coasys-we/deploys/6a7ccd715ae4c40008b20dad
😎 Deploy Preview https://deploy-preview-115--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 50a97a3 into dev Aug 12, 2026
4 of 5 checks passed
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