Skip to content

Feature: Template preview harness - #116

Merged
jhweir merged 14 commits into
devfrom
feat/template-preview-harness
Aug 12, 2026
Merged

Feature: Template preview harness#116
jhweir merged 14 commits into
devfrom
feat/template-preview-harness

Conversation

@jhweir

@jhweir jhweir commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator

feat: a preview host, and fixtures that outlive it

Branch: feat/template-preview-harnessdev · 15 commits · phases 0–3 landed for the two templates with reference screenshots

Summary

The six showcase templates are shaped like the platforms they reference but don't yet look like
them, and closing that gap is visual work: proportion, density, palette, type scale. Doing it by
hand means a screenshot-and-describe round trip per adjustment, which caps out at four or five
iterations per template before it stops being worth anyone's afternoon.

This closes the loop instead. A fourth host runs the real WE application over the in-memory
backend — no executor, no agent, no neighbourhood — and Playwright drives it headlessly against
the installed Chrome, so a render can be produced, looked at, adjusted and re-rendered without a
human in the middle. The target is a screenshot the author picked; the output is a template and a
theme that match it.

The second half is the part that outlives the exercise. Rendering a template needs data in it —
an empty Discord clone screenshots as an empty state and says nothing about density — and the
fixtures that supply it are useful in at least two more places: seeding a personal space with
sample content so an author can build a template against something, and generating the marketplace
preview images that Template.screenshots and Theme.screenshots have had a relation for and
nothing to fill with. So the fixture format is designed for three consumers from the start, and
only one of them is built here.

Why a host and not a harness

The first sketch of this was a small harness with stubbed stores. That was wrong, and worth
recording because it is the obvious wrong answer: stubbed stores drift from the real ones, so
every screenshot would be of a fiction, and templates would get matched against behaviour the
application does not have. A fidelity tool built on approximations of the thing it measures is
worse than no tool.

The right seam already exists, one layer lower. Stores in this codebase are already
backend-agnostic — they consume ports:

  • apps/we-web/src/index.tsx is twenty-five lines:
    <PlatformProvider seed platform backend><App/></PlatformProvider>.
  • BackendConnector.ports() returns a BackendPorts bundle
    (backendPorts.ts) — agentSession,
    lifecycle, schemas, profiles, ephemeral, dataBindings, with interop, runtime and
    transcription optional and feature-detected.
  • createInMemoryBackendPorts already implements every required member
    (lifecycle.ts:277).
  • executorFreeBoot.test.tsx already runs
    the real session, dataset, profile and space stores over that bundle, and backendPorts.ts
    notes that its own surface was derived from what that suite had to mock — "the boot suite doubles
    as the conformance test".

So no new abstraction over stores is needed, and none should be invented. The preview host is a
BackendConnector returning in-memory ports, plus a headless PlatformAdapter, plus its own seed.

It follows that this shows the whole application, not a template in isolation. The shell is
itself templates — Sidebar, Settings, Profile, BootScreen, TemplateEditor, ModuleRail,
the marketplace and spaces surfaces all live in @we/template-shell
and are painted by the same renderer over the same stores. <App/> in the preview host is WE,
with a different connector behind it.

What ships

apps/we-preview (new app) — a fourth host beside we-web/we-electron/we-tauri. Entry mirrors
we-web's; the differences are backend (in-memory ports rather than ad4mConnector), platform
(a headless adapter), and its own we-preview.seed.json selecting the showcase templates with
modules off. A separate app directory rather than a flag on we-web because apps are deployments
here — that is what the seed file expresses — and because it keeps @we/backend-inmemory and the
fixture sets out of the production web bundle, which a runtime flag would drag in unless fought.

@we/backend-inmemory — seedable peer profiles. The one concrete gap found while planning.
createInMemoryProfileDirectory holds a private Map and publish() writes only the self
profile, so get(someOtherDid) returns a blank record and there is no way to seed one
(lifecycle.ts:223). Every template being
matched is a multi-author surface — a feed of identical blank avatars is not a feed — so this is
load-bearing, not cosmetic. Fix is small and has precedent: an optional seed argument surfaced
through InMemoryBackendPortsOptions, in the same spirit as lifecycle.seedShared. Presence needs
the equivalent, for the same reason: presenceStore.onlineHere over a one-agent bus shows only you,
and the Discord template puts that roster in its channel header.

packages/templates/fixtures (new package) — declarative sample content per template,
expressed in model terms (CollectionBlock with kind, children, authors, timestamps) and
applied through the same Model.create calls on either backend. That portability is not
speculative: @we/models classes are compiled from one manifest into row-backed classes by
entities.ts and into triple-backed ones by the
AD4M adapter, and the difference is invisible to a caller. Fake agents (DID, name, avatar) and fake
presence are part of the format rather than bolted on.

The shoot script — Playwright with channel: 'chrome' against the already-installed
/usr/bin/google-chrome, so no browser download. Takes template, theme, route and viewport; writes
a PNG. Two analysis helpers run in-page on a canvas rather than as dependencies, because the box has
no ImageMagick and no sharp and the browser is already an image library: sample a target
screenshot's palette to real hex values, and composite target-beside-render into one image (which is
materially easier to judge than two files).

Before Phase 0 — the audit slice this branch depends on

This branch measures visual fidelity, so it needs a design system that renders
correctly before it can measure anything. Otherwise every screenshot is of a bug,
and the gap list this is meant to produce is just the audit's, rediscovered
slowly and by eye. From PR_AUDIT_2026-08-12_FOLLOWUP.md:

Required — this branch measures against these

1. Fix the 12 dead theme selectors (audit P2-3, ~½ day including the guard).

2-themes targets we-toggle (the element is we-switch), we-tab-item (it is
we-tab), we-menu-group-item (we-menu-group), we-modal::part(modal) and
::part(header) (neither part exists), we-input::part(input-wrapper) (that part
is on select), and we-button[circle] (the prop is square). Retro and
cyberpunk are substantially broken today
and it is invisible because CSS fails
silently.

Write the guard first: 3-primitives already emits custom-elements.json, so a
test asserting that every we-* tag and every ::part() used in
2-themes/src/**/*.css actually resolves catches all twelve and stops it
recurring. Two of the five themes cannot be matched against anything until this
lands.

2. Role tokens, phases 0–1 (audit P4-1, ~1.5 days).

Phase 0 — add the roles that are missing and blocking: overlay/scrim (six
different hardcoded black opacities across modals, drawers and gates),
shadowColor (nine primitives hardcode rgba(0,0,0,…), which is invisible on a
dark surface — the reason dark themes need elevation-by-lightness), focus,
{danger,success,warning}Surface, surfaceHover/surfaceActive.

Phase 1 — migrate the nine overlay/floating primitives onto them.

Start with the two lines that pay immediately: modal.ts:9 and drawer.ts:11
surfaceRaised. In dark mode today every modal and popover paints darker than
the page it floats over
, with a black shadow invisible against near-black. The
--we-role-* vocabulary shipped in #114 with zero consumers, so the dark preset's
override is currently dead code.

Without this, the fidelity work keeps hitting the theme ceiling this PR already
predicts under "Fidelity target" — and logs gaps that are already written down.

Cheap while you are in there — protective of this branch specifically

3. pnpm validate:schemas into CI (audit P3-2, one line). This branch edits
six templates and adds a fixture package; tsc cannot see an unknown component
type, a misspelled prop, a $routes outlet with no routes array, or an orphan
$local. The validator can, and already exists.

4. ShellRouteStore missing params/setParam (audit P2-4, ~5 lines). A live
regression from #114: shell overlays have no query-param support. This host boots
the real shell and renders its overlays, so it is inside the blast radius.

5. A typecheck script for the new app (audit P3-1). apps/we-preview should
ship with one rather than joining the 38 packages that lack it — especially since
we-web, the app it mirrors, is one of two that cannot currently be typechecked
at all (its Vite path aliases are absent from its tsconfig). The full workspace
rollout is ~1 day with 28 packages already clean; at minimum do not add a 39th.

Deliberately not prerequisites

The trust boundary (audit P0), the device-lifecycle cluster (P1), and the
raw-theme-CSS decision (P0-4). None of them changes what a template looks like.
P0-4 does constrain a later theming step — demand-driven scoped-theme injection
— but that is not on this branch's path.


Phases

Phase 0 — prove the risky part. (After the prerequisites above.) Boot app-shell over in-memory ports in a real browser, render
one showcase template with hardcoded rows, screenshot it. Everything else is downstream. Built as
the real host rather than a throwaway spike, since the spike is ninety percent of the host anyway.

Phase 1 — fixture format, and a set per template. Includes the profile/presence seeding above.

Phase 2 — the shoot script and the two analysis helpers.

Phase 3 — the matching work, against author-supplied screenshots.

Phase 4 — deliberately out of scope here. In-app "fill this space with sample content", using
the same fixtures against a real perspective. Blocked on an open question (below), and there is no
reason for it to hold up phases 1–3.

What the host found

Everything below was found by looking at one rendered frame, which is the argument for the host in
a sentence. All are fixed on this branch except the last two, which are recorded rather than
repaired.

  • scope was declared unsupported by the in-memory adapter, and rowsFor dropped it into
    ...rest. The shared engine has always executed drill-downs; only the lowering could not express
    one. A scoped query therefore answered a different question — every message in the space rather
    than one channel's.
  • Untyped relations were omitted from the engine's relation map. CollectionBlock.children is
    untyped by design, so containment did not work at all, and addChildren never wrote the link
    either. Registering them fixed scope; a second pass fixed include, where relatedRows read
    data.tables[''] — an untyped relation means of any type, not no targets.
  • A shared space was invisible to the template resolver, which matches on
    Space.url === dataset.sharedId and falls back to uuid only for a personal one. A space setting
    uuid alone renders under the default template while looking entirely correct.
  • No URL can express "this space, that route". buildRoutes mounts template routes at the
    router root, so the Discord template owns /channel/:channelId; navigateToSpace builds
    /space/<id>/<view>, which only the default template's own /space/:spaceId route satisfies. No
    showcase template can be deep-linked. Recorded, not fixedPreviewBootstrap states both
    halves instead (select dataset, then navigate).
  • Editor state does not survive non-ASCII. createBlocks encodes UTF-8; decodeEditorState
    reads it back with a bare atob. Any post containing an accent or an em-dash renders mojibake in
    the running app. Recorded as a test, not fixed — it bit this branch's own Kanban fixture.

Two more are visible in the shots and belong to the matching work rather than here: the channel
rail lists every channel twice (once flat, once under its category), and Kanban column headers all
count 0.

The matching, and what it cost the system to do it

Two references (Discord, X), measured rather than eyeballed, and matched. What the exercise was
for was the list below: every item is something the template or theme system could not express,
found by trying to say it.

Fixed here, because both templates needed them

  • A template could not name a role. The vocabulary existed and the primitives read it, but an
    author had to write var(--we-role-surface-sunken) by hand, so templates used scale positions
    instead. That cannot express a relationship that inverts: our rail was neutral-100 over a
    neutral-50 page, which is lighter-on-darker in dark mode where every real client's rails are
    darker in both. bg="surface-sunken" now resolves.
  • $each gave a row no view of its neighbours, so grouping was unreachable — collapsing
    consecutive messages from one person, which is most of a chat log's density. $index and $prev
    now come with every row.
  • Two surfaces ignored the theme entirely. #root and TemplateLayout's content area
    hardcoded neutral-50; the second is the element carrying data-we-theme, so a space theme was
    restyling everything except its own backing.

Found and worked around, worth fixing properly

  • The lightness ramp is uniform and real palettes are not. With one subtractor, the gap from
    page to rail always equals the gap from page to raised surface; the reference's gaps are 3.5 and
    6. The channels theme pins three surfaces as roles to get an uneven ramp. Roles are the right
    escape hatch, but a theme wanting its own rhythm between surfaces has to leave the parametric
    system to get it.
  • "Containers with no parent" is not a query. channelRail showed every channel twice once a
    category existed, because the ungrouped list has no scope and returns all of them. The correct
    query needs a filter on the absence of an incoming relation — relationFilters, declared false
    by both adapters. Worked around by showing the flat list only while no category exists, which
    leaves a loose channel in a categorised space invisible.
  • There is no group-hover. Reaction controls for every defined type on every row roughly doubled
    a one-line message's height, so only signals somebody gave are shown now — and a first reaction
    can no longer be given from the feed. The usual answer is revealing controls on row hover;
    hoverProps styles an element on its own :hover and there is no way to say "when my ancestor is
    hovered".
  • Grouping cannot break on time. Real clients end a group after a few minutes' silence. That
    needs date arithmetic the schema language does not have, so grouping is author-only and
    over-groups a conversation that paused.

Deliberate differences, not gaps

Discord's server rail and member list, and X's trends column, are chrome WE's shell provides in a
different shape or not at all. The templates match the parts that are theirs. A band of shell
sidebar also stays on the agent's theme rather than the space's, so a full-window screenshot has a
strip of chrome in a different palette — correct for the app, visible in a photograph.

Open questions

Authorship in a real perspective. In the preview host, fake DIDs can be minted and their
profiles seeded. In a real AD4M perspective the author is the signing agent, so fixture content is
authored by whoever ran it and every row shows one face — which is exactly what the templates being
matched are least able to survive. Either the in-app version accepts single-author sample data and
says so, or it needs a display-layer concession. Unresolved, and the reason Phase 4 is sequenced
last rather than folded into Phase 1.

TemplateStore and ThemeStore over in-memory ports are unproven. Resolved. Both run: the
dark theme applies, the shell surfaces paint, and the template switcher resolves bundled templates.

Fidelity target. Proceeding on idiomatic lookalike: build from DS props and theme tokens, and
where a styles escape hatch is tempting, log a gap in the theme vocabulary instead. Themes here
are parametric — a hue, a saturation, and a (lightness − subtractor) × multiplier ramp
(presets.ts) — so a real platform's palette,
which is not a linear ramp off one hue, will not be reachable in full. The roles override exists
because that ceiling was already hit once. Expect the gap list to be the more valuable artifact.

Known limits of the preview host

Worth writing down so they are not rediscovered as bugs:

  • Backend-specific settings surfaces render degraded. createInMemoryBackendPorts omits the
    optional runtime port, and the headless platform will omit AccountHost, so RuntimeSettings,
    LanguageSettings, HostSettings and AccountSettings show their capability-gated empty states.
    Irrelevant to template matching; relevant if the host is ever used for shell design work.
  • Join and publish are simulated against inmemory:// URIs. Useful rather than limiting — those
    flows become screenshottable.
  • Module weight. @we/app-shell hard-depends on module-call/globe/graph/notes plus three.js and
    gsap. The seed drops them from the deployment but not from the dependency graph.

Follow-ups this makes cheap

  • In-app capture. A button in the running app that writes the current view to
    Template.screenshots / Theme.screenshots — a relation that exists today and is populated only
    by manual file upload in publishToMarketplace
    (TemplateStore.tsx:1121),
    which is to say never.
  • Visual regression. Once matched, the shoot script wrapped in Vitest browser mode (the repo is
    already on Vitest 4 throughout) turns these into toHaveScreenshot tests, so restyling one theme
    cannot silently break another template. Deliberately not adopted now: a test runner is ceremony
    during an iteration loop.
  • A we-preview others can use. The stated ambition — point an AI at a screenshot or a Figma
    export and get a WE template — is this host plus a fixture set. Worth keeping in view while naming
    things, not worth building for yet.

Non-goals

  • No live network access. Reference material is author-supplied screenshots; nothing browses.
  • No new abstraction over stores. The port contract is the seam, and it already exists.
  • No pixel-diff scoring against target screenshots — different content and text make it noise.
    Pixel diffing earns its place later, for self-regression only.

Test plan

  • Prerequisites: theme-selector guard green and thirteen dead rules fixed (the audit counted
    twelve; dark had a fourteenth instance of the same ::part(modal) bug); roles phase 0–1
    landed — modals and drawers read surface-raised, no hardcoded black remains in the
    primitives outside the vendored leaflet sheet; validate:schemas in CI; ShellRouteStore
    fixed with five tests.
  • Phase 0: preview host boots in headless Chrome with no executor; the shell paints.
  • pnpm typecheck — clean, including the new app, which unlike we-web can actually be
    typechecked.
  • pnpm lint and pnpm lint:css — clean.
  • pnpm validate:schemas — 27 schemas, no issues.
  • pnpm test — whole workspace green. New: 5 ShellRouteStore, 6 in-memory seeding + scope,
    2 engine untyped-relation, 9 fixture, 2 theme-selector, 2 role-token.
  • Each of the six templates renders populated (not an empty state) at 1440px:
    pnpm --filter @we/app-preview shoot.
  • Phase 3: channels and timeline themes, and both templates matched against the references.
  • Manual, and the one that matters: James confirms the real app renders what the preview
    host showed, for at least one template. The host is worth nothing if that does not hold, and
    no automated check on this branch can establish it.

Running it

pnpm --filter @we/app-preview dev                    # http://localhost:3100
pnpm --filter @we/app-preview build && pnpm --filter @we/app-preview serve   # :4173

pnpm --filter @we/app-preview shoot                                    # all six
pnpm --filter @we/app-preview shoot -- --fixture discord --width 1280
pnpm --filter @we/app-preview shoot -- --fixture discord --clip 'we-scroll-area'
pnpm --filter @we/app-preview shoot -- --fixture discord --target ~/ref/discord.png

--target samples both palettes to hex and writes a side-by-side composite. There is deliberately
no similarity score — see the script's header for why one would measure the wrong thing here.

jhweir and others added 14 commits August 12, 2026 21:02
…guard

CSS fails silently, so a theme rule naming an element that does not exist is
invisible: no warning, no build error, nothing to attribute the result to. Retro
and cyberpunk had accumulated thirteen such rules between them, which is most of
what those two themes thought they were doing.

Three failure modes, all found in the wild:

- Wrong tag — `we-toggle` (it is `we-switch`), `we-tab-item` (`we-tab`),
  `we-menu-group-item` (`we-menu-group`).
- Wrong part — `we-modal::part(modal)` (it is `base`, in retro *and* dark),
  `we-input::part(input-wrapper)` (that part is on `we-select`; input's is `base`).
- Wrong attribute — `we-button[circle]` (the prop is `square`).

The guard comes first and is the durable half: tags and attributes are checked
against the generated `custom-elements.json`, and parts against the `part="x"` the
primitives render plus the `[part='x']` their own styles target, since either can
introduce one. It also asserts it found a non-trivial vocabulary, so a moved path
or a renamed decorator fails loudly rather than passing by matching nothing.

Retro's `we-modal::part(header)` is deleted rather than repaired. It wanted a Win95
title bar, and the modal renders backdrop / base / close-button-wrapper / slot and
nothing else — there is no header to attach to. Removing a rule that never matched
changes no pixels; restoring the intent is a modal API change, not a theme fix.

Note that fixing these *changes how retro and cyberpunk render*, for the first
time since the rules were written. That is the point — they cannot be matched
against a reference while most of their styling is inert — but it does mean both
themes now want looking at.

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

`--we-role-*` shipped in #114 with zero consumers, so the dark preset's
`surfaceRaised` override has been inert since it landed: in dark mode every modal,
drawer and popover still painted *darker* than the page it floated over, with a
black shadow invisible against near-black. This makes the vocabulary real.

Phase 0 — nine roles that were missing and blocking:

- `overlay` — one scrim, replacing six hardcoded black alphas that differed by
  accident rather than by decision. Drawer's backdrop moves 0.4 → 0.6 as a result;
  a scrim that varies by which overlay opened it reads as a bug.
- `shadowColor` — opaque, with the consumer supplying alpha via `color-mix`. Kept
  opaque because the seven distinct alphas across the primitives encode genuinely
  different elevations and collapsing them would flatten the hierarchy. What was
  never a decision is the hue.
- `focus` — `--we-ring-color` now resolves to it, so the two cannot drift.
- `surfaceHover` / `surfaceActive`, `accentMuted`,
  `{danger,success,warning}Surface`.

Phase 1 — every overlay/floating surface and every hardcoded shadow colour:

- `modal.ts` and `drawer.ts` default `bg` → `surface-raised`. These are the two
  lines the whole thing was for.
- Both backdrops → `overlay`.
- The five floating panels (select, date-picker, colour-picker, icon-picker,
  location-picker) → `surface-raised` + `border` on the panel itself.
- Sixteen `rgba(0,0,0,α)` shadows across eleven files → the role, each keeping its
  own alpha exactly. No hardcoded black remains in the primitives outside the
  vendored leaflet stylesheet.

Deliberately untouched: the ~463 non-floating `neutral-*` background call sites
(the audit's phases 2–5), and the places where a scale position is genuinely
correct — input hover tints, the badge/tag/alert variant maps, the skeleton
gradient.

The role test is now derived from the token object rather than a hand-written
list, and asserts no role hardcodes a colour. A role added and forgotten was
exactly how `overlay` and `shadowColor` ended up inlined in nine primitives.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`ShellRouteStore` declares `const store: RouteStore = {…}` and simply omitted
`params` and `setParam` when the routing work added them, so every
`{ $store: 'routeStore.params.x' }` inside a shell surface — profile, settings,
the marketplace — read `undefined`, and `$localState` `syncParam` fields there had
nowhere to sync to. A regression since #114.

It compiled because app-shell has no typecheck script (audit P3-1): the annotation
is on a `const` whose excess/missing members TypeScript would have caught the
moment anything ran `tsc` over the package. That rollout stays out of this branch,
so the guard here is the test instead.

The implementation cannot mirror the main store's. That one reaches for
`history.replaceState` so a param-only change does not re-resolve the route tree,
and reads back from `window.location` — neither of which a `MemoryRouter` has, and
having its own location is the entire reason the shell store exists. So params are
read from the router's location and written by navigating it with `replace`, which
keeps the overlay out of the browser URL and out of the app's history.

Five tests, including the one that matters most: setting a shell param must leave
`window.location` untouched. A shell param leaking into the browser URL would be a
different bug of exactly the same size.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
12.6k LOC of template data was gated by `tsc` alone, which cannot see an unknown
component type, a misspelled prop, a `$routes` outlet with no `routes` array, or
an orphan `$local` — all of which typecheck cleanly and then render nothing. The
validator has always existed and been thorough; it was reachable only from the AI
editor and the CLI, so nothing ran it on the way in.

Placed after Build so workspace dists exist, and before Typecheck so a schema
failure reports as itself rather than as a downstream type error. Its roots
already include `templates/showcase/`, which is where this branch's edits land.

Currently green: 27 schemas, no issues.

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

Boots WE in a headless browser with no executor, no agent setup, no
neighbourhood and no network, in a couple of seconds. That is what makes a
render → screenshot → adjust loop possible at all; the alternative is a
screenshot-and-describe round trip per adjustment.

It is the *whole* application, not a template preview. The shell is itself
templates — Sidebar, Settings, Profile, BootScreen, TemplateEditor, ModuleRail,
marketplace, spaces — so all of it renders and can be clicked around. The only
difference from we-web is which BackendConnector PlatformProvider receives.

That is the property worth having. The first sketch of this was a harness with
stubbed stores, which would have made every screenshot a fiction: templates
matched against behaviour the app does not have. The seam already existed one
layer down, and `createInMemoryBackendPorts` already implemented every required
member of it.

Resolves the plan's main open question: TemplateStore and ThemeStore *do* run
over the in-memory ports. The executor-free boot suite mocks both — because they
pull in the template and theme registries, not because they cannot run — so this
was unproven until something mounted them. The dark theme applies and the shell
surfaces paint.

Deliberately no `we-preview.seed.json`. `templates` is not read at runtime:
`generate-templates` compiles the *root* seed's list into a single generated
registry for the whole monorepo, so a second seed naming a different set would
declare templates this build cannot import. The entry spreads the root seed and
overrides only what differs — modules off (Cesium and media devices do not
survive a headless screenshot, and a spinning globe makes every render differ
from the last), apps off, no ad4m block.

Ships with a typecheck script rather than joining the 38 packages without one,
and unlike we-web it can actually run: its tsconfig carries the `@shared` /
`@solid` path aliases that exist only in Vite, plus the `.glb` ambient
declaration the shell's 3D cube needs. Clean at zero errors.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This backend runs one agent, so everything that renders *people* degenerates to a
column of one: a feed with a single author, a member list of yourself, a presence
roster of yourself. All three render correctly and show nothing about whether a
design holds up — which makes them useless as the subject of a screenshot, and
every template being matched is a multi-author surface.

Two seams, because they are genuinely different things.

**Profiles** are a read the directory serves, and `publish` deliberately writes
only `ctx.selfId()`'s record — as the real directory does — so a peer's profile
had no way to exist at all. `get` on an unseeded DID still returns a blank rather
than throwing: a profile that has not arrived yet is the normal case in a real
directory too.

**Presence** is a message, so seeded peers beat on the same channel and in the
same shape a real heartbeat uses, and nothing about `PresenceStore` is
special-cased. `SeededPeer` is `PresenceState` minus the two fields the beat owns,
so `focus` is stated rather than inferred — which matters, because `online`
filters on `focus.datasetUri` and `onlineHere` further filters on `focus.path`,
and a peer with no focus is present in the abstract and visible nowhere.

They have to keep beating. Presence is self-healing by design — `derivePeers` ages
every state out on a TTL — so a single announcement would show a roster that
empties itself, and a screenshot would then depend on when it was taken. The
interval is 1s rather than the app's 5s: that interval is tuned for the cost of a
real broadcast, and there is no network here, so what matters instead is how long
after load the roster takes to fill. A subscriber always attaches after the scope
it subscribes through exists, so the first beat cannot be synchronous.

Two things the tests found rather than confirmed:

- The beat registry was keyed on the dataset key alone. `keyFor` counts from zero
  per bus, so the first dataset of *every* bundle is `ds-0` — two independent
  backends shared an entry and the second silently never beat. Now keyed by bus
  (weakly) and dataset.
- The bus's `dispose` unsubscribes by *agent*, not by scope, so two scopes sharing
  `selfId` cannot be told apart by delivery. The refcount test asserts on the timer
  instead, which is the thing that actually matters: a leaked interval keeps a
  vitest run alive forever.

Both options are absent by default — nothing changes for existing callers.

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

The preview host now paints a Discord-shaped channel with real content in it:
distinct authors and avatars, fixed timestamps, multi-paragraph and one-word
messages side by side, signal counts, and a rail with categories. That is the
whole point — an empty template screenshots as an empty state and says nothing
about density or rhythm, which is most of what makes a UI recognisable.

`@we/template-fixtures` is the format, expressed in model terms and applied
through `@we/models` rather than as rows, so the same fixture can later seed a
real perspective or generate marketplace previews. Everything it writes is
deterministic — dataset id, space uuid, every node id — because the backend is in
memory and re-mints on every load: a shoot script that had to *discover* the id of
`#general` would need a second load, which would produce different ids again.

Four things had to be fixed to get a single frame, all found by looking at one:

- **`scope` was declared unsupported by the in-memory adapter.** The shared engine
  has always been able to execute a drill-down (`scopeRows`); only the lowering
  could not express one, and `rowsFor` silently dropped `scope` into `...rest`. So
  a scoped query answered a different question — every message in the space rather
  than one channel's. That cost the showcase templates every drill-down they have.
- **Untyped relations were omitted from the engine's relation map**, on the grounds
  that there is nothing to hydrate against. True, and it took `scope` down with it,
  since a drill-down needs only the foreign key. `CollectionBlock.children` is
  untyped by design, so containment did not work at all — and `addChildren` never
  wrote the link either, because it looked for the child in a target table the
  relation does not name.
- **The space was invisible to the template resolver.** It matches a *shared*
  dataset by `Space.url === dataset.sharedId`, falling back to `uuid` only for a
  personal one, so a fixture setting `uuid` alone rendered under the default
  template while looking entirely correct.
- **No URL can express "this space, that route".** `buildRoutes` mounts template
  routes at the router root, so the Discord template owns `/channel/:channelId`
  outright — while `navigateToSpace` builds `/space/<id>/<view>`, a shape only the
  default template's own `/space/:spaceId` route satisfies. `PreviewBootstrap`
  states both halves instead (select dataset, then navigate), which is why the root
  is composed from `StoreProvider` rather than the packaged `<App/>`: stores do not
  exist outside it. The inconsistency itself is real and left where it is.

Also recorded as a test rather than fixed: `createBlocks` encodes editor state as
UTF-8 and `decodeEditorState` reads it back with a bare `atob`, so any post with a
non-ASCII character renders mojibake in the running app.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Closes the loop. `pnpm --filter @we/app-preview shoot -- --fixture discord` boots
the host in headless Chrome, waits for the fixture the page reports having
applied, and writes a PNG at 2× — text at 1× is too soft to judge letterforms or
spacing from, which is most of what a theme is.

`--clip <selector>` photographs one element at magnification. Judging density and
rhythm from a message row that is sixty pixels tall in a 1440px page is guesswork;
cropped, it is just looking.

`--target <png>` does the two things a reference screenshot is genuinely good for:

- **Samples its palette to real hex.** A coarse histogram over both images
  recovers the surface, border, text and accent colours exactly — measurement
  rather than eyeballing, which was the sharpest limit on this whole exercise.
- **Composites target beside render into one image**, scaled to a common height so
  a retina capture does not read as a different design.

Both run in the page on a canvas. The browser is already an image library, and
this machine has neither ImageMagick nor `sharp`; the alternative was a native
dependency to do what a canvas does in twenty lines.

Deliberately **no similarity score**. The obvious loop is "diff against the target,
iterate until it clears a threshold", and that is right for cloning a page, where
the two converge to identical pixels. These templates render arbitrary community
content in a platform's *shape* — different names, different messages, a different
number of rows — so a pixel diff is dominated by content, sits at a large constant,
and barely moves as the layout improves. It cannot drive anything, and optimising
it would push toward matching content, which means nothing. Pixel diffing earns its
place later, against our own previous render, where identity is the goal.

A script rather than a browser MCP server because this is committed: reproducible
by anyone, and wrappable in Vitest browser mode as a visual-regression suite once
the templates are worth freezing. It uses the Chrome already on the machine
(`channel: 'chrome'`), so `playwright-core` downloads nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Twitter, Instagram, YouTube, Kanban and Events. All six templates now render
populated, at every viewport, from `pnpm --filter @we/app-preview shoot`.

Each is shaped to stress what its layout is judged on rather than to show the
feature list: a 3:1 length difference between adjacent posts, a grid with an odd
count so the last row is short, video titles of wildly different lengths beside a
fixed thumbnail, an empty Kanban column beside a full one, events spread across
months. One cast across all six, so comparing two templates compares the
templates rather than the content.

**The engine gap Instagram found.** Registering untyped relations for `scope`
(previous commit) left `include` over them still resolving to nothing, because
`relatedRows` read `data.tables['']`. An untyped relation names no target table,
which is not the same as having no targets — it means *of any type*, which is
exactly what a collection holding text, images and embeds is. So every
cover-image projection resolved to null, and a media grid, which drops posts with
no image rather than showing blank tiles, rendered as nothing at all. It now looks
across every table, with two tests.

Images are offline gradient data URIs. Real photographs would be better subjects,
but a fixture that fetches them fails without a network and rots when a URL does,
and committing megabytes of JPEG to make six templates render is a bad trade.
They are varied enough in hue and value to judge tile spacing, aspect handling and
overlay contrast; swap in real assets before judging anything *photographic*.

Fixture bodies are ASCII, and the shared cast documents why: the UTF-8/`atob`
mismatch recorded last commit bit this branch's own Kanban card, which rendered
"Scanning â about a third done" until the em-dashes came out. Profile names keep
their accents — those are ordinary model fields, not editor state.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Palette and column structure from a PNG, so matching starts from numbers instead
of adjectives.

A screenshot carries no scale — 5112px wide is a 2556pt window at 2x or a 5112pt
one at 1x, and every measurement means something different depending on which.
`--window` if you know the capture size; `--calibrate <cssPx>` otherwise, given the
known CSS width of the first column, which is the more reliable of the two because
a platform's rail width is published and nobody remembers their window size. On the
Discord reference, calibrating against its 72px server rail lands on 1.97x, which
is 2x plus one pixel of antialiasing at the boundary — the two methods agreeing is
the check that the number means anything.

Columns come from the modal colour of each x down 240 sampled rows. The obvious
approach, one horizontal scan line, is useless: at any given y it crosses server
icons, avatars and embedded images, so the bands it reports are whatever content
sat on that line. A rail is flat for hundreds of rows and an avatar is not.

Known limit: flat surfaces measure exactly (#1a1a1e, #ffffff come back as
themselves), but thin antialiased elements — accent text, 1px dividers — are
averaged with their neighbours by the histogram and come back approximate. Read
those as "about this hue", not as the value to paste into a theme.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`bg="surface-sunken"` now resolves to `var(--we-role-surface-sunken)`, on every
colour prop and inside a border shorthand.

Roles could not be *named* from a template before this. The vocabulary shipped, a
theme could pin one, and the primitives now read them — but a template author had
to spell `var(--we-role-surface-sunken)` by hand, so templates kept reaching for
scale positions instead. That is the missing half of the role work: adoption by
components is worth nothing if the layer above cannot participate.

It matters more than it sounds, because a scale position cannot express a
relationship that *inverts*. The Discord-shaped template paints its rail
`neutral-100` over a `neutral-50` page: darker-on-lighter in light mode, and
lighter-on-darker in dark, because the whole scale flips. Discord's rails are
darker than its page in both. Measured against the reference screenshot, ours is
the wrong way round — and no choice of scale position fixes it, because the two
modes need opposite answers. A role can say "sunken" and let the theme decide what
that means.

No collision: colour tokens are `{hue}-{shade}` over five closed hues, and no role
name starts with one. Scoped to colour props, so a space prop naming `surface`
still resolves to a (nonexistent) space variable rather than silently becoming a
colour — a mistake worth keeping visible.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A row could only ever ask about itself. `$each` handed the item down and nothing
about its neighbours, so any design where a row depends on the one before it was
unreachable — not awkward, unreachable, with no prop or theme able to recover it.

The case that forced it is **grouping**. A chat log that repeats the avatar and
byline on every consecutive message from the same person is a visibly different and
much less dense design from one that collapses them, and that difference is most of
the gap between our channels template and the reference screenshot it is being
matched to. It is one `$if` away once a row can see its predecessor:

    condition: { $eq: ['$message.author', '$prev.author'] }

The first row has no `$prev` at all, so the condition is false there and it keeps
its byline — which is what a feed wants, and the reason absent must not read as
"same as the last item".

Both keys are plain values, matching how the item itself is passed: a context ref
resolves by path lookup and a function under `prev` would break it. The consequence
is documented rather than hidden — `$prev` is captured when a row renders, so a
*reorder* that leaves a row's own identity unchanged under a keyed `<For>` can
leave it stale. Appends and prepends, which is every feed here, are unaffected.

Documented in the ai-context fragment, so the in-app AI gets it too.

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

Matched against a measured reference rather than by eye. The `channels` preset and
the template changes are one commit because neither is worth anything alone: the
theme fixes the colour, the template fixes the structure, and the reference is
wrong about both until both land.

**The theme.** Near-neutral surfaces (about 5% saturation on a blue hue) where the
stock dark preset runs 20% on whatever the primary hue is — which is why our render
came out visibly purple beside a near-grey reference. `neutralHue` and
`neutralSaturation` fix that outright, and the accent is one more number.

What parameters *cannot* fix is the ramp. The scale steps evenly, so with any single
`subtractor` the gap from page to rail equals the gap from page to raised surface;
the reference's gaps are 3.5 and 6. Three surfaces are pinned as roles instead —
which is what roles are for, and the first theme to need them for it. Pins stay
parametric in hue and saturation so changing `neutralHue` still moves everything
together; only the lightnesses are fixed, because the lightnesses are the design.

**Grouping.** Consecutive messages from one person now drop their avatar and byline
and hang under the line above. This is most of the density difference, and it is
what `$prev` was added for.

**Elevation the right way round.** The rail was `neutral-100` over a `neutral-50`
page — darker-on-lighter in light mode and *lighter-on-darker* in dark, because the
scale inverts. It reads `surface-sunken` now, and every other colour in the template
names a role too.

**Two bugs the reference exposed.**

`channelRail` rendered every channel twice once a category existed: the ungrouped
list above the groups queries channels with no scope, so it returned all of them.
The honest fix is a query for "channels with no parent category", which cannot be
written — it needs a filter on the absence of an incoming relation, and
`relationFilters` is declared false by both adapters. It now shows only while no
category exists, which covers the case the original comment was guarding.

A reaction control per defined type on every row — "0" beside a heart, "0" beside a
compass, down the whole channel — roughly doubled a one-line message's height.
Only signals somebody actually gave are shown now. The cost is named rather than
hidden: a first reaction can no longer be given from the feed, because revealing
controls on row hover is not expressible — `hoverProps` is self-hover only and
there is no way to say "when my ancestor is hovered".

The preview host also asks for the space theme to cover the whole window. Right for
the app to default the other way; wrong for a host whose output is a photograph of
one template, where shell chrome in the agent's theme puts a second design in frame
and the sampled palette then averages two themes.

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

The other reference, and the opposite problem to the chat log: almost the whole
design is one flat white with hairline rules, no card, no elevation, no shadow
anywhere in the column. `shadowIntensity: 'flat'` earns its place — every surface
primitive still wants to cast something, and a timeline that shadows its rows stops
reading as a single sheet.

**Stacked byline.** The avatar now sits beside the *whole* post — name, text and
actions in one column to its right — rather than only beside the name, with the
text starting again at the far left. One hanging edge down the feed instead of two,
and it is most of what makes a timeline read as a column of utterances rather than
a stack of cards.

**"0 replies" is gone**, for the reason the zero-count reactions went: a column of
zeroes under every post in a quiet feed asserts nothing and is the row's whole
height.

**Two surfaces that a theme could not reach**, both found by pinning `page` white
and watching a grey frame survive it:

- `#root` (and `html`, `body`) hardcoded `neutral-50`, so a theme could paint every
  surface it owned and still leave a border of the old colour wherever the app did
  not fill the viewport.
- `TemplateLayout`'s content area did the same — and it is the element carrying
  `data-we-theme`, so a space theme was restyling everything except its own backing.

Both now read `--we-role-page`, which defaults to exactly `neutral-50`; nothing
moves for a theme that pins nothing. `color` was deliberately left on
`neutral-1000` rather than adopting `--we-role-text`, which defaults to
`neutral-900`: taking it would shift body text in every existing theme as a side
effect of a background fix, and that belongs to the role migration proper where it
can be seen and decided on.

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 684104d
🔍 Latest deploy log https://app.netlify.com/projects/coasys-we/deploys/6a7ced36a3eb220007d2f277
😎 Deploy Preview https://deploy-preview-116--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 41c0b3d 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