Feature: Template preview harness - #116
Merged
Merged
Conversation
…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>
✅ Deploy Preview for coasys-we ready!
To edit notification comments on pull requests, go to your Netlify project configuration. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
feat: a preview host, and fixtures that outlive it
Branch:
feat/template-preview-harness→dev· 15 commits · phases 0–3 landed for the two templates with reference screenshotsSummary
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.screenshotsandTheme.screenshotshave had a relation for andnothing 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.tsxis twenty-five lines:<PlatformProvider seed platform backend><App/></PlatformProvider>.BackendConnector.ports()returns aBackendPortsbundle(
backendPorts.ts) —agentSession,lifecycle,schemas,profiles,ephemeral,dataBindings, withinterop,runtimeandtranscriptionoptional and feature-detected.createInMemoryBackendPortsalready implements every required member(
lifecycle.ts:277).executorFreeBoot.test.tsxalready runsthe real session, dataset, profile and space stores over that bundle, and
backendPorts.tsnotes 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
BackendConnectorreturning in-memory ports, plus a headlessPlatformAdapter, 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-shelland 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 mirrorswe-web's; the differences are
backend(in-memory ports rather thanad4mConnector),platform(a headless adapter), and its own
we-preview.seed.jsonselecting the showcase templates withmodules 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-inmemoryand thefixture 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.createInMemoryProfileDirectoryholds a privateMapandpublish()writes only the selfprofile, so
get(someOtherDid)returns a blank record and there is no way to seed one(
lifecycle.ts:223). Every template beingmatched 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 aslifecycle.seedShared. Presence needsthe equivalent, for the same reason:
presenceStore.onlineHereover 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 (
CollectionBlockwithkind,children, authors, timestamps) andapplied through the same
Model.createcalls on either backend. That portability is notspeculative:
@we/modelsclasses are compiled from one manifest into row-backed classes byentities.tsand into triple-backed ones by theAD4M 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; writesa PNG. Two analysis helpers run in-page on a canvas rather than as dependencies, because the box has
no ImageMagick and no
sharpand the browser is already an image library: sample a targetscreenshot'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-themestargetswe-toggle(the element iswe-switch),we-tab-item(it iswe-tab),we-menu-group-item(we-menu-group),we-modal::part(modal)and::part(header)(neither part exists),we-input::part(input-wrapper)(that partis on select), and
we-button[circle](the prop issquare). Retro andcyberpunk are substantially broken today and it is invisible because CSS fails
silently.
Write the guard first:
3-primitivesalready emitscustom-elements.json, so atest asserting that every
we-*tag and every::part()used in2-themes/src/**/*.cssactually resolves catches all twelve and stops itrecurring. 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(sixdifferent hardcoded black opacities across modals, drawers and gates),
shadowColor(nine primitives hardcodergba(0,0,0,…), which is invisible on adark 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:9anddrawer.ts:11→surfaceRaised. In dark mode today every modal and popover paints darker thanthe 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'soverride 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:schemasinto CI (audit P3-2, one line). This branch editssix templates and adds a fixture package;
tsccannot see an unknown componenttype, a misspelled prop, a
$routesoutlet with noroutesarray, or an orphan$local. The validator can, and already exists.4.
ShellRouteStoremissingparams/setParam(audit P2-4, ~5 lines). A liveregression 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
typecheckscript for the new app (audit P3-1).apps/we-previewshouldship 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 typecheckedat 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.
scopewas declared unsupported by the in-memory adapter, androwsFordropped it into...rest. The shared engine has always executed drill-downs; only the lowering could not expressone. A scoped query therefore answered a different question — every message in the space rather
than one channel's.
CollectionBlock.childrenisuntyped by design, so containment did not work at all, and
addChildrennever wrote the linkeither. Registering them fixed
scope; a second pass fixedinclude, whererelatedRowsreaddata.tables['']— an untyped relation means of any type, not no targets.Space.url === dataset.sharedIdand falls back touuidonly for a personal one. A space settinguuidalone renders under the default template while looking entirely correct.buildRoutesmounts template routes at therouter root, so the Discord template owns
/channel/:channelId;navigateToSpacebuilds/space/<id>/<view>, which only the default template's own/space/:spaceIdroute satisfies. Noshowcase template can be deep-linked. Recorded, not fixed —
PreviewBootstrapstates bothhalves instead (select dataset, then navigate).
createBlocksencodes UTF-8;decodeEditorStatereads it back with a bare
atob. Any post containing an accent or an em-dash renders mojibake inthe 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
author had to write
var(--we-role-surface-sunken)by hand, so templates used scale positionsinstead. That cannot express a relationship that inverts: our rail was
neutral-100over aneutral-50page, which is lighter-on-darker in dark mode where every real client's rails aredarker in both.
bg="surface-sunken"now resolves.$eachgave a row no view of its neighbours, so grouping was unreachable — collapsingconsecutive messages from one person, which is most of a chat log's density.
$indexand$prevnow come with every row.
#rootandTemplateLayout's content areahardcoded
neutral-50; the second is the element carryingdata-we-theme, so a space theme wasrestyling everything except its own backing.
Found and worked around, worth fixing properly
subtractor, the gap frompage to rail always equals the gap from page to raised surface; the reference's gaps are 3.5 and
6. The
channelstheme pins three surfaces as roles to get an uneven ramp. Roles are the rightescape hatch, but a theme wanting its own rhythm between surfaces has to leave the parametric
system to get it.
channelRailshowed every channel twice once acategory 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 falseby both adapters. Worked around by showing the flat list only while no category exists, which
leaves a loose channel in a categorised space invisible.
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;
hoverPropsstyles an element on its own:hoverand there is no way to say "when my ancestor ishovered".
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.
Resolved. Both run: theTemplateStoreandThemeStoreover in-memory ports are unproven.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
stylesescape hatch is tempting, log a gap in the theme vocabulary instead. Themes hereare parametric — a hue, a saturation, and a
(lightness − subtractor) × multiplierramp(
presets.ts) — so a real platform's palette,which is not a linear ramp off one hue, will not be reachable in full. The
rolesoverride existsbecause 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:
createInMemoryBackendPortsomits theoptional
runtimeport, and the headless platform will omitAccountHost, 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.
inmemory://URIs. Useful rather than limiting — thoseflows become screenshottable.
@we/app-shellhard-depends on module-call/globe/graph/notes plus three.js andgsap. The seed drops them from the deployment but not from the dependency graph.
Follow-ups this makes cheap
Template.screenshots/Theme.screenshots— a relation that exists today and is populated onlyby manual file upload in
publishToMarketplace(
TemplateStore.tsx:1121),which is to say never.
already on Vitest 4 throughout) turns these into
toHaveScreenshottests, so restyling one themecannot silently break another template. Deliberately not adopted now: a test runner is ceremony
during an iteration loop.
we-previewothers can use. The stated ambition — point an AI at a screenshot or a Figmaexport 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
Pixel diffing earns its place later, for self-regression only.
Test plan
twelve;
darkhad a fourteenth instance of the same::part(modal)bug); roles phase 0–1landed — modals and drawers read
surface-raised, no hardcoded black remains in theprimitives outside the vendored leaflet sheet;
validate:schemasin CI;ShellRouteStorefixed with five tests.
pnpm typecheck— clean, including the new app, which unlike we-web can actually betypechecked.
pnpm lintandpnpm lint:css— clean.pnpm validate:schemas— 27 schemas, no issues.pnpm test— whole workspace green. New: 5ShellRouteStore, 6 in-memory seeding + scope,2 engine untyped-relation, 9 fixture, 2 theme-selector, 2 role-token.
pnpm --filter @we/app-preview shoot.channelsandtimelinethemes, and both templates matched against the references.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
--targetsamples both palettes to hex and writes a side-by-side composite. There is deliberatelyno similarity score — see the script's header for why one would measure the wrong thing here.