Skip to content

Chore: Audit fixes - #114

Merged
jhweir merged 42 commits into
devfrom
chore/audit-fixes
Aug 12, 2026
Merged

Chore: Audit fixes#114
jhweir merged 42 commits into
devfrom
chore/audit-fixes

Conversation

@jhweir

@jhweir jhweir commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator

Audit remediation: gates, correctness, coverage, conventions

Summary

A full-repo audit (AUDIT_2026-08-11.md) found the codebase in good shape on
discipline — near-zero TODO debt, documented catch blocks, a well-tested
schema-system — with the rot concentrated in three places: CI that gated on
nothing but the build
, a test gap shaped exactly like the riskiest code,
and documentation two refactors behind reality. This branch works through
every tier of that audit, then through the bugs that surfaced while testing the
result.

Three things are worth a reviewer's attention up front. CI now actually
gates
— lint, stylelint, typecheck, generated-file freshness and the full test
suite all block a merge, where previously only pnpm build could fail. Several
user-facing bugs were already broken on dev
and are fixed here (the sign-in
screen had no Login button; the signal-type modal's mode picker was empty; the
create-space modal was unclosable from two routes). One regression was
self-inflicted and caught in manual testing
— trimming "unconsumed" editor
exports broke the template toolbar, because the app shell consumes them via
dynamic import, which a static-import grep cannot see; the fix carries a comment
warning the next person.

The branch also closes the routing conventions discussion: view state now lives
in the URL, so a link reproduces what the sender sees — including an optional
template/theme suggestion — under a written four-tier convention.

Changes

CI and tooling gates

  • .github/workflows/build.yaml — Lint and Test carried
    continue-on-error: true, justified by a backlog that no longer existed, so
    regressions landed silently on dev. Both now block, joined by stylelint, a
    typecheck step, and a generated-files diff (a PR that changes ai-context
    inputs without committing regenerated output now fails instead of drifting).
    Lockfile drift is caught with --frozen-lockfile before the AD4M override
    rewrite; the dependency cache stores the pnpm store rather than a
    one-level-deep node_modules glob that missed most of a 2–4-level workspace;
    a concurrency group stops duplicate pushes each running a full AD4M source
    build. WE steps run the pinned pnpm 10 developers use.
  • eslint.config.js — 1,293 of the repo's 1,295 lint errors came from one
    machine-generated file that was never ignored; **/src/generated/** is now
    ignored in both eslint and prettier, and the two real errors are fixed. The
    prefer-ds-props rule pointed at packages/app-framework, renamed to
    app-shell long ago, so the two packages with the most hand-written DS usage
    were never linted by it.
  • Stylelint was fully configured but ran only in editors; it now has a
    script and a CI step (zero findings).
  • @we/cli — the build script's A && B || C shape ran the fallback and
    reported success when the real build failed.

Broken public surface (@we/primitives)

Four defects invisible from inside the monorepo because nothing here used the
broken paths: combobox.ts was a stale duplicate registering a second
we-select (hence the duplicate registry entry); the per-element build wrote to
a directory that didn't exist, so import '@we/primitives/button' typechecked
and failed at runtime; the package-local ESLint config scoped eslint-plugin-lit
to that same phantom path; and two packages declared a peer of
@we/primitives ^1.0.0 against a package versioned 0.1.0. The dist layout now
mirrors src/primitives.

Correctness bugs

Fixed on dev before this branch (found while testing):

  • The sign-in screen had no Login button. A template-kit refactor collapsed
    the unlock row to a bare field() fragment, dropping the button, the
    Enter-to-submit handler and the error wiring — sessionStore.login had zero
    callers, so unlocking was impossible.
  • The signal-type modal's mode list was empty — the same refactor rewrote
    the select without carrying its options across.
  • The create-space modal was unclosable from the cards and globe routes:
    they embedded route-local copies gated on a local flag, while every close path
    inside the modal sets the shell flag. The copies are gone (which also
    removed the last template-default → template-shell imports, so that
    backwards dependency is now genuinely gone).
  • Spaces list 500s — an unresolved $store reference in a where-clause
    serialized to an empty condition the executor rightly refuses;
    pruneUnresolvedWhere now drops conditions whose operands haven't resolved
    (keeping null, which is a value).
  • Posts never loaded on reload — the stores bag exposes data bindings
    reactively, but query sites read $getModel once at setup, so a template
    mounted before the backend connected was stranded until a route change. All
    five query sites now read bindings inside their effect and self-heal.
  • Location picker closed mid-drag when a map pan released outside it — a
    click's target is the common ancestor of mousedown and mouseup, so dismissal
    now keys off pointerdown.
  • we-code blocks were white in dark mode — scale tokens invert per theme;
    the block now pins lightness and stays parametric on hue/saturation.
  • Introduced by this branch and fixed: the template/theme toolbar disappeared
    when unconsumed editor exports were trimmed (the app shell consumes four of
    them by dynamic import).

Type safety and test coverage

The suite runs 1,660 tests, up from a baseline where six packages had no
test script at all.

  • SerializedBlockNode was any — every function in the 841-line block
    serialization module flowed through it, while a different interface of the
    same name lived in block-solid. It is now a real type the editor-side
    interface extends, and the pipeline has its first tests, including
    reconcileBlocks' id-claiming, duplicate detection and orphan deletion (where
    a regression silently deletes user content), run against a fake model layer.
  • The DS-prop invariantDesignSystemProps (85 props) and
    designSystemKeys (five hand-maintained arrays) agreed only by luck; a
    drifted prop typechecks everywhere and is silently dropped by every layout
    component. Now a test.
  • New coverage for the graph engine (spatial index, camera, pointer behaviours
    including the claim/broadcast rule, expanders), the token CSS generators
    (snapshot), @we/models' registry precedence rules, and the editor's pure
    helpers — extracted so tests import the real code, replacing a test that
    re-implemented the function it tested and could never fail.

Backend contract

createInMemoryBackendPorts stubbed its data plane, so the boot suite that
backendPorts.ts calls "the conformance test" could not exercise a single query
or mutation — and the package held a second, unrelated in-memory backend that
could. The bundle now carries the same binding surface as the AD4M adapter
(shared query adapter, mutations, $identities, a real ephemeral bus), with a
conformance suite pinning the surface key-by-key and a CRUD round-trip.

Theming

Completes the layering work started on feat/graph-engine-improvements:
themeToStyle/applyThemeVars and the ThemeOverrides vocabulary move to
@we/themes (schema-shared re-exports), ThemeParameters becomes a deprecated
alias of the one vocabulary, and semantic role tokens (--we-role-*) arrive
with parametric defaults so every existing and user-authored theme keeps
working. The dark preset makes the first designed use: raised surfaces lighten
rather than casting shadows — a relationship a uniform lightness inversion
cannot express.

Routing and view state (new convention)

docs/architecture/routing-and-view-state.md names four tiers and the question
that picks between them — "if I sent this URL to someone, should they see the
effect?"

  • View state → the URL. routeStore gains reactive params and
    setParam(name, value, { push? }); $localState fields opt in with
    syncParam. Reads/writes stay $local/$setLocal; defaults drop their param
    so URLs stay clean; precedence is URL > persisted > initial. The renderer
    reaches the router through a $routeParams host binding, so hosts without one
    degrade to plain local state. Params survive leaving and returning a
    keep-alive route (they didn't, and a reload then believed the bare URL).
  • Preferences → persist (device) and explicitly not the URL: a shared
    link must not impose display density on its recipient.
  • Links can carry ?template= / ?theme= — applied silently when the
    recipient has them (clicking is the consent; idempotent), degraded with a
    one-time warning toast naming what's missing when they don't.
  • Loading is a real state. Query accessors expose <name>Loaded, and
    cardList holds a skeleton until it flips — the empty state now only ever
    asserts "loaded and empty", never "hasn't answered yet".

Deduplication and cleanup

Column/Row/Grid/Card collapse onto one scaffold (which is also what made
disabledProps — accepted, documented, silently dropped — actually work); the
desktop apps' verbatim-duplicated platform plumbing collapses into
createDesktopPlatform + createLocalAd4mConnector; the account registry's two
implementations (504-line JS, 1,052-line Rust) now parse shared fixtures in
both suites
, where before nothing proved a file written by one host round-trips
through the other. Removed after verifying zero references: netlify.toml and
its orphaned build script, three empty playground stubs, an incompletely deleted
module directory, 776 KB of unreachable images, the dead --we-depth-* token
category, and seven editor exports with no consumer.

Dependencies

@coasys/ad4m was declared 0.11.0 in eight manifests against a root override
installing 0.13.0-test-9; all now declare the pinned version. vitest collapses
from three majors to one (two packages ran 1.6.1 inside the same pnpm test),
TypeScript to one range, and internal @we/* deps use workspace:* everywhere.
The Apollo stack, router and plugin-opener were removed from the desktop apps
after verifying they are unimported.

Documentation

The app-shell README documented useAdamStore and a PlatformAdapter whose
methods don't exist; codebase-map.md — the "start here" doc — taught
adamStore vocabulary two refactors gone and never mentioned the graph system
or template kit; OPERATORS.md used adamStore in six examples; the seed file
had three mutually inconsistent descriptions. All corrected, with the seed
now sourced from types/seed.ts. New READMEs for backend-system,
module-system, templates, ai-context, models and the two contract
packages the repo's own rules required; app-shell finally has a
CONVENTIONS.md. Guides describing packages that no longer exist are archived
with banners naming their replacements. A PR template with a docs-sync
checklist
addresses the root cause: the stale docs rotted because nothing
forced the update.

Known follow-ups

Deliberately out of scope, in rough priority order:

  1. App-shell store test suites (SpaceStore 1.8k LOC, TemplateStore,
    ThemeStore, EditorStore — every write path) and backend-ad4m's write
    paths
    (sdnaModels, lifecycleAdapter, agentHelpers). Each is a
    multi-day effort; the completed in-memory ports now make them writable
    without an executor, which was the blocker.
  2. Marvin's executor is out of date — the OR/AND/NOT where-clause
    combinators landed in ad4m on 2026-07-02, so any remote node built before
    that 500s on the spaces query. Local was rebuilt during this work; marvin
    needs cargo build --release --bin ad4m-executor. Worth considering a
    capability handshake so the adapter can compute OR client-side against an old
    node rather than failing.
  3. Component adoption of the new role tokens — the vocabulary and the dark
    preset's first use are in; migrating components from scale positions to roles
    is the follow-on that makes designed dark themes possible.
  4. "Copy link to this view" — the reading half of ?template=/?theme= is
    done; a share affordance that writes them is a small UI addition.
  5. ~26 MB of skybox JPEGs committed under src/ (2k and 4k sets) — LFS or
    CDN changes contributor setup, so it deserves its own decision.
  6. 99 already-merged branches are deletion candidates; main hasn't moved
    since 2025-11-29.
  7. Preferences at agent scopepersist is device-local; a preference that
    should follow the agent across devices belongs in AgentSettings.

One decision for you: AUDIT_2026-08-11.md is currently tracked and carries
a per-item status section. Keep it as a record, or drop it now that its findings
are either fixed or listed above?

Test plan

Verified on this branch:

  • pnpm lint — clean (was 1,295 errors)
  • pnpm lint:css — clean (never ran outside editors before)
  • pnpm typecheck — clean
  • pnpm build — succeeds, and leaves the working tree clean (the seed-config
    generators' missing trailing newlines used to dirty three tauri files on
    every build)
  • pnpm test1,660 passing, no failures
  • pnpm validate:seed and pnpm validate:schemas (22 schemas) — clean
  • cargo test for the tauri account registry, including the new shared
    fixtures both hosts parse
  • Manual testing in we-electron and we-web against a remote node, which is
    where the sign-in, toolbar, create-space modal, signal-type, location
    picker, dark-mode code block, spaces-query, posts-reload and sort-persistence
    issues were each found and then re-verified after fixing

Not verified: the tauri desktop build end-to-end, and any behaviour requiring a
second peer (sync, neighbourhood join).

jhweir and others added 30 commits August 12, 2026 00:53
…errors

1,293 of the repo's 1,295 lint errors were prettier complaints inside
packages/models/src/generated/coreManifest.ts — machine-generated output
that was never added to the lint/prettier ignore lists (the sibling
generated dir in design-system already was). Ignore **/src/generated/**
in both, matching what .gitignore already does for design-system.

The two remaining real errors: an unused `call` binding in the
transcribe store, and a prettier line-wrap in models' entity exports.

`pnpm lint` now exits 0. (AUDIT P0-1)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… output

The Lint and Test steps carried continue-on-error justified by a backlog
that no longer exists — lint is clean after the generated-file ignore and
the test suite is fully green — so the only step that could fail a build
was Build itself. Both gates are now blocking, and two new ones join them:

- Typecheck (after Build, so dist types exist), via a new root
  `typecheck` script; coverage grows package by package.
- A generated-files diff check, so a PR that changes ai-context inputs
  without committing the regenerated CLAUDE.md et al. fails instead of
  drifting silently.

Install hygiene: the lockfile is validated with --frozen-lockfile while
package.json is still pristine (the AD4M file: override rewrite is
deliberate drift, so the later install stays unfrozen); the dependency
cache now stores the pnpm store instead of a one-level node_modules glob
that missed the 2–4-level-deep workspace and was keyed on a lockfile the
workflow then mutated. WE steps run under the pinned pnpm 10.18.3 that
developers use; only AD4M's own install stays on pnpm 9 (its overrides
format is rejected by pnpm 10), invoked via npx without touching the
global binary. A concurrency group cancels superseded runs instead of
duplicating the full AD4M source build. (AUDIT P0-2, parts of P3-5/P3-6)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…d path

The rule's file glob still named packages/app-framework — renamed to
app-shell long ago — so the two packages with the most hand-written DS
component usage were never linted by it. Widened to app-shell + editor
(alongside the existing block-system + schema-system).

No new violations surface: the remaining style={{}} usages in those
packages sit on plain divs (three.js mounts, router internals), which
the rule deliberately exempts because raw elements have no DS props.
(AUDIT P0-3)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Four defects in @we/primitives' published shape, none visible from
inside the monorepo because nothing here used the broken paths:

- combobox.ts was a dead, stale duplicate of select.ts registering the
  same we-select tag — the reason we-select appeared twice in the
  generated component registry. Deleted.
- tsup built src/components/**, a directory that does not exist (the
  sources live in src/primitives/), so the package.json `./*` exports
  pointing at dist/components/*.js typechecked and failed at runtime.
  The build now emits one dist/components/<name>.js per primitive,
  matching the dist/types/*/components layout the declaration generator
  already produces — `import '@we/primitives/button'` works.
- The package-local ESLint config scoped eslint-plugin-lit to the same
  phantom components/ path (and imported a root config from a path that
  doesn't exist); it now covers src/primitives/** and resolves the real
  root config. Primitives pass the lit recommended rules clean.
- 4-components and 5-widgets declared a peer of @we/primitives ^1.0.0
  against a package versioned 0.1.0 — a range no release could satisfy.

Also in 5-widgets: @we/design-utils is a runtime import and is now a
real dependency, externalized in tsup so the bundle resolves the live
package instead of freezing a copy; d3-force and four unimported @we/*
devDeps are dropped (zero references in src). (AUDIT P0-4, part of P3-1)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- The dark theme's we-text[variant='heading-md'] rule was the only
  selector in all five themes missing its [data-we-theme] scope, so
  loading the @we/themes aggregate recolored heading-md text in every
  theme. Scoped to dark.
- Deleting a space theme left it in every picker until the space was
  reloaded: the template's delete button bypasses the store
  (model.delete) and nothing re-pulled the list, and the store's own
  deleteTheme only pruned installedThemes. ThemeStore now exposes
  refreshSpaceThemes (mirroring templateStore.refreshSpaceTemplates),
  ThemesList wires it as the delete's onSuccess — the exact wiring its
  drifted twin TemplatesList already had — and deleteTheme prunes
  spaceThemes too.

(The third bug in this audit item, disabledProps being silently
dropped, is fixed with the layout-component factory in the next
commit.) (AUDIT P0-5)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ire disabledProps

Column and Row were byte-identical but for one string; Grid and Card
repeated the same ~25-line splitProps → filterProps → mergeProps →
buildLayoutStyles → useStateProps scaffold, four copies of the same
hasStateProps closure included. createLayoutComponent now carries the
shared shape, parameterized by defaults, component-own prop keys, flex
direction, and an optional final style transform (Grid's template
computation, Card's surface-opacity color-mix). The four components stay
declared as functions so the ai-context extractor keeps finding them.

Riding on the single scaffold: disabledProps — accepted, documented,
and silently dropped until now — actually applies. useStateProps emits
--we-ds-disabled-* vars and the DS interop stylesheet applies them under
[data-we-interactive][aria-disabled='true'], declared last so a disabled
element's styles win over hover/active. Layout elements have no native
:disabled, so the consumer marks disabled the accessible way
(aria-disabled) and styling follows. (AUDIT P0-5 third bug, P3-2)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… tokens

Completes the theming layering work begun on feat/graph-engine-improvements
(AUDIT P0-6). Three moves:

1. themeToStyle/applyThemeVars move from @we/schema-shared to @we/themes,
   beside the presets — they map theme parameters onto design-system CSS
   custom properties, which is design-system knowledge; schema-shared was
   a consumer holding the owner's tools because schema nodes carry a
   `theme`. schema-shared re-exports both unchanged, so no import breaks.
   The applyThemeVars removal-bookkeeping tests move with it.

2. One vocabulary, one declaration: ThemeOverrides (the full vocabulary)
   now lives in @we/themes; ThemeParameters — the deliberately-narrow
   duplicate the presets used — is a deprecated alias of it.

3. Semantic role tokens, the substantive gap: colour tokens are scale
   positions, not roles, so "raised surface" was only ever a convention
   that survived dark mode because everything inverted uniformly. The
   tokens CSS now emits --we-role-* variables (page, surface,
   surfaceRaised, surfaceSunken, text, textMuted, textFaint, textInverse,
   border, borderStrong, accent, accentText), each defaulting to a
   parametric expression over the scale — every existing and
   user-authored theme keeps working untouched. A theme pins individual
   roles via ThemeOverrides.roles; the dark preset (and the dark CSS
   file, for CSS-only consumers) makes the first designed use of it:
   raised surfaces get lighter in dark instead of casting shadows.
   Component adoption of the role vars is deliberate follow-on work.

Also: the tokens aggregate object gains `role` and the previously
omitted `zIndex` (audit design-system finding 19).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ree helpers

Four helpers existed as private copies that had already diverged:

- deepUnwrap lived in both the Solid renderer (with a recursion depth
  guard) and the action resolver (without one — unbounded recursion on a
  cyclic value, on the path every user interaction dispatches through).
  The copies also disagreed on Object.create(null) objects: the action
  copy passed them through untouched, leaking reactive accessors into
  store methods. The unified version keeps the guard and recurses into
  null-prototype objects, and lives beside REACTIVE_ACCESSOR in
  propResolvers/reactive.ts.
- isSchemaChild had two variants in the same package that disagreed on
  whether an array counts as a node (the indexer's accepted arrays), so
  index traversal and scope traversal saw different trees.
- isPropsSchemaNode was copy-pasted four times (indexer, scope, and the
  editor's InspectorPanel + EditorOverlay); replaceNodeInTree — ~50
  lines of recursive tree rewriting — twice in the editor, one copy
  labelled "local copy".

All now live in @we/schema-shared (treeUtils.ts), exported through the
package index; the editor imports them like it already imports
findNodeById/mergeNode. New tests pin the previously-divergent
behaviours: the array rejection, the null-prototype recursion, the
cyclic-value guard, and replaceNodeInTree's traversal of children/
routes/slots/props edges. (AUDIT P1-3)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ntract

SerializedBlockNode — the type every function in the 841-line block
serialization module flows through — was `any`, and a second, unrelated
interface of the same name in @we/block-solid described the editor-side
shape. The shared type is now real ({ type, version?, children?, plus a
typed index signature for per-block properties), and block-solid's
interface extends it, adding only the `version` Lexical requires. The
handful of editor call sites that relied on `any`'s silence (null from
decodeEditorState, Lexical's stricter root type) now say what they mean.

The pipeline's contract gets its first tests, run against a fake model
layer implementing exactly the surface serialization.ts touches — no
executor needed:

- extractInlineText / extractTextContent (whitespace collapse, leaf
  field extraction, childEditorState recursion, the 5,000-char cap with
  word-boundary truncation) and extractBlockData, now exported.
- createBlocks: tree creation, child linking, id stamping for later
  reconciliation, the editorState blob + textContent write, kind
  stamping, and list-metadata passthrough onto listitems.
- reconcileBlocks — the 267 lines where a regression silently deletes
  user content: claimed ids update in place, unclaimed nodes are
  created, orphans are deleted, duplicate ids (copy/paste) get fresh
  instances, foreign ids are not claimed, and each parent's children
  relation is overwritten with the final ordered list.

Also removed: blocksToLexicalJSON and its blockToLexical/groupListItems
machinery (~150 lines) — a lossy Lexical-reconstruction fallback with
zero consumers anywhere in the repo. Recoverable from git if the
fallback is ever wired up. (AUDIT P1-1, part of P3-4)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…nd the token CSS

The headline test asserts DesignSystemProps (85 props in @we/design-types)
and designSystemKeys (five hand-maintained arrays in @we/design-utils)
agree exactly, in both directions. They agreed today only by luck: a prop
added to the interface but not the arrays typechecks everywhere and is
then silently dropped by every Column/Row/Grid/Card. That whole bug class
is now a test failure.

Around it:
- design-utils: tests for tokenVar (token vs raw-CSS discrimination,
  calc(), keywords, the bare-'0' rule), zIndexVar, parseBorder,
  mergeProps (the shorthand-precedence rule the variant/size merge chain
  is built on: an explicit p beats a default's px), mapFlexAxes (ax/ay
  swap between row and column), filterProps, and buildLayoutStyles'
  structural baseline — including bg emitting the background-color
  longhand, never the shorthand.
- 1-tokens: the CSS generators are now pure exported string builders
  (writing moved to the generateCSS edge), snapshot-tested — every
  --we-* variable the system reads shows up as a snapshot diff instead
  of a silently unstyled component. A regression test pins that the two
  scrollbar values now flow from the token source: the generator used to
  filter them out and hardcode replacements, so editing component.ts did
  nothing (AUDIT P1-6, scrollbar item).
- Every design-system package now has a test script, so pnpm -r test
  visits all of them instead of silently skipping six of seven.

(AUDIT P1-4 design-system, part of P1-6)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…behaviours, expanders

48 new tests over the modules the audit flagged as the highest-value
untested logic in graph-system:

- SpatialIndex: radius vs box hit-testing (a card's corners are outside
  its radius), nearest-centre ordering for overlapping nodes, the
  cell-size-scales-with-largest-node rule that keeps the 3×3 sweep
  valid, marquee rectangles, rebuild semantics.
- Viewport: world↔screen round-trip, the stationary-point property of
  zoomAt (the whole feel of a zoomable canvas), zoom clamping including
  the at-max no-op, fit's centre/actual-size cap and its no-size guard,
  visibleBounds padding.
- Behaviours: pan claims only the background; drag preserves the grab
  offset (dragging vs teleporting), releases the pin on drop unless
  pin:true, emits the node's final position, drops the drag when no
  button is held, and refuses while locked; select distinguishes click
  from drag-that-ended-on-a-node and shift-toggles; dispatchPointer
  broadcasts gesture-ending phases — pinning the click-latches-a-node
  bug the code comment documents.
- Collection expander: the drill-down scope for the untyped children
  relation, one-way containment, undeclared child types skipped, a
  failing child query warns instead of throwing.
- Property expander: label property not repeated, literal-node
  convergence across instances (the reason values are nodes at all),
  hideEmpty, and the valueNodes:false leaf form. (AUDIT P1-4 graph)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…al code

The editor's only test re-implemented toRelative because the original
closed over a component-scoped ref — a test that could never fail when
the component changed. src/helpers.ts now holds the editor's pure logic,
imported by the components and by tests alike:

- overlay geometry: nearestToken (token snapping), computeSizeDelta
  (resize maths incl. the corner-handle average and anchor-side sign
  flip), the handle predicates and cursors, and toRelativeRect — the
  subtraction that cancels the viewport out of the overlay's coordinate
  contract, now tested for the scroll-stability property directly.
- the focus-ring shorthand parser/serializer (parseRing/composeRing),
  round-trip tested including the theme-accent recognition and the
  raw-color passthrough.
- condition-operand logic from ValueRefPicker + ConditionEditor:
  operandLabel, refPath, refToOperand, operandValueType, and the
  operandComplete/exprComplete validity rules (unary vs binary vs
  group).

16 tests replace the 3 that tested a copy; the copy-based geometry test
is deleted. ValueRefPicker re-exports operandLabel/operandValueType so
existing consumers are untouched. (AUDIT P1-4 editor)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
modelRegistry's own doc comments describe two silent failure modes; both
are now tests. The globalThis keying that makes split-bundle registries
impossible ("everything registers successfully, every lookup fails") is
asserted through the Symbol.for key directly, and getModelForPerspective
is pinned on all three rules: native classes win over synthesised ones
(decorator metadata), the fallback reads `uuid` and never `id` (a
PerspectiveProxy carries an unrelated subscription id that must not
win), and a miss returns undefined rather than throwing.

The pure utils get their first tests too: signal normalisation (incl.
the zero-width-range veto convention), all four aggregate modes, and the
file-storage decoders — whose silently-empty error returns are now a
pinned decision instead of an accident. (AUDIT P1-4 models)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…onformance tests

createInMemoryBackendPorts stubbed its data plane — ephemeral reported
the capability absent and dataBindings returned two of the contract's
bindings — so the boot suite that backendPorts.ts says "doubles as the
conformance test" could not exercise a single query or mutation. The
package even contained a second, unrelated in-memory backend
(createInMemoryBackend) that did implement queries, with its own private
copy of the query adapter.

The bundle now carries the same binding surface the AD4M adapter
provides: $getModel/$getModelForPerspective over the compiled row-backed
entities, the shared inMemoryQueryAdapter (extracted to queryAdapter.ts
so the two backends share one definition), model mutations
(create/update/delete through the entity classes), $identities wired to
the host profile cache, and $ephemeral/ephemeral backed by the shared
InMemoryBus with the agent id read lazily, mirroring the AD4M port.

A new conformance suite pins the binding surface key by key (so a
missing binding is a named failure, not a silently boot-only backend)
and runs create → query → update → delete plus the ephemeral
no-self-delivery rule through the bundle end to end. Given backend
independence is a first-class goal, this seam now has a second, complete
implementation of the contract rather than a hypothesis. (AUDIT P1-5)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two ways the token pipeline could silently ignore its own source, on top
of the scrollbar fix that landed with the generator tests:

- @we/design-utils inlined a frozen copy of @we/tokens' font scale: the
  package declared tokens as a devDependency, so tsup bundled the token
  tables into dist. Adding a lineHeight token to @we/tokens then did
  nothing until design-utils happened to rebuild — and when it did, the
  new token fell through to raw-CSS passthrough and emitted an invalid
  declaration. tokens is now a real dependency, externalized in both
  build entries, so the live package resolves at runtime.
- The generated tokens CSS hardcoded three Google Fonts @import URLs —
  a third-party network request on every load of a local-first,
  offline-capable app. The webfont fetches now live in an opt-in
  dist/css/fonts.css; the main entry makes no network requests. The app
  shell imports '@we/tokens/css/fonts' alongside the tokens, so the
  shipped app looks exactly as before; playgrounds and embeds get
  offline-safe tokens by default. (AUDIT P1-6)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Alignment (AUDIT P1-2): every workspace manifest that declared
@coasys/ad4m 0.11.0 — a two-minor-version fiction against the root
override that actually installs 0.13.0-test-9 — now declares the pinned
version. The override in the root package.json remains the single point
to move both when the pin changes.

Unification (AUDIT P3-1): vitest collapses from three majors to one —
module-call and module-transcribe ran vitest 1.6.1 while the rest of the
workspace ran 4.x, i.e. two test runners with different expect/mocking
semantics inside one `pnpm test`; both suites pass unchanged on 4.
typescript lands on ^5.7.2 everywhere (was ^5.3.3 across graph-system
and most modules, ^5.9.3 in playgrounds), plus @types/node ^24.10.0,
tsup ^8.5.1, vite ^6.0.7, vite-plugin-solid ^2.11.11, zod ^4.1.12,
tsx ^4.21.0, rimraf ^6.1.0. Internal @we/* deps use workspace:*
everywhere outside peerDependencies, so none can resolve from the
registry.

Removed, verified unimported: the Apollo stack (@apollo/client, graphql,
graphql-ws) plus @solidjs/router in both desktop apps — apolloClient.ts
is a historical name, it only ever used Ad4mClient — and
@tauri-apps/plugin-opener in tauri; the three @we/graph-* deps the
graph-explorer playground declared and never imported; zod and
@we/design-types in app-shell. @types/leaflet stays and now does its
job: location-picker's map/marker fields were `any` with the types
package sitting unused. Full workspace test suite and typecheck pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ting

Three deduplications, one per drift hazard the audit named (P3-2):

- Desktop platform plumbing: Electron and Tauri each carried a verbatim
  apolloClient.ts (named for a client library neither had used in
  years), a structurally identical platform adapter, and the same
  connector choreography. The shape now lives once —
  createDesktopPlatform(transport) in @we/app-shell/shared and
  connectToLocalExecutor/createLocalAd4mConnector in @we/backend-ad4m —
  and each app supplies only what is genuinely its own: the IPC bridge
  or the Rust command surface. Six files become two factories plus two
  thin transports; the typed SerializedBlockNode also surfaced two
  latent unknown-parameter casts in SpaceStore's post actions, now
  explicit boundary casts.

- The account registry's shared on-disk format: two implementations
  (504-line JS, 1052-line Rust) both document that a spelling only one
  host reads wipes every name and avatar on the next write — and no
  test proved a file written by one round-trips through the other. Both
  suites now parse the same fixtures (registry.shared.json in the
  agreed camelCase dialect, registry.legacy-tauri.json in the old
  snake_case), electron via vitest and tauri via cargo test; both pass.

- TemplatesList/ThemesList: two ~160-line schema files identical after
  token substitution — the pair whose drift produced the stale-theme
  bug fixed earlier. They are now one kit fragment, installedList(),
  parameterized by the axes that genuinely differ (entity, key field,
  apply action, default field, refresh action), following the
  marketplaceList precedent. All 22 schemas validate; kit tests pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…, DEV-only test schemas, dead names retired

- EditorStore's four byte-identical debounced panel-width persisters
  collapse into one createPersistedWidth helper (same signal-immediately
  / write-debounced semantics, per panel key).
- The schema test harness (~3k LOC of test schemas plus its test store)
  no longer registers in production: the shell template entry is gated
  on import.meta.env.DEV, so an app build drops the registration and
  tree-shakes the schemas. It remains a dev tool exactly where it was
  in dev builds.
- The removed stores' names stop haunting readers: the editor's ~70
  local adamStore/aiStore bindings over the host port are renamed to
  what they alias (identity/session; one shadowed <For> item became
  chat), and EditorStore's leftover "AiStore" comment now names
  EditorStore. (AUDIT P3-3)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Everything here was verified unreferenced before deletion (AUDIT P3-4):

- netlify.toml and scripts/build-with-ad4m-link.sh: the deploy config
  could not have worked (pnpm build alone cannot succeed without the
  ad4m link step it never invoked, no Node pin), and the script was
  orphaned from every configured pipeline while CI reimplements the
  same clone-and-link inline.
- apps/playgrounds/{react,vue,svelte}: one-line README stubs
  advertising frameworks with no implementation; not even matched by
  the workspace glob.
- packages/module-system/assistant/: an empty directory holding only
  node_modules — the incompletely deleted remains of a removed module.
- The unreachable CTAv2 image set (776 KB pinned off behind
  IMAGE_SETS.v1 — statically imported, so bundled but unrenderable),
  the unimported CTAv1/ForCommunities.jpg, and the unreferenced
  about/hero.md. The about page keeps its one live set.
- The --we-depth-* token category: six generated variables with zero
  consumers anywhere (the shadow scale is what components actually
  use). effect.ts, its generator, and the DepthToken type are gone.
- 3-primitives/docs/notes.md: a stale scratchpad documenting a dist
  layout the build has not produced in months.
- @we/editor's seven component exports with no consumer anywhere — the
  real surface is mountTemplateEditor + EditorHost, and that is now
  what the index says.
- RerenderLog registers in the component registry only in dev builds:
  logging on every mount is its purpose, which is exactly why a
  production template should not be able to reach it.

Deferred deliberately: the ~26 MB of committed skybox JPEGs (2k + 4k
sets) — moving them to LFS or a CDN changes contributor setup and
deserves its own decision. Full test suite and lint pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… logging

Console noise (AUDIT P3-5): SpaceStore's test() scratch action — two
raw debug logs behind a store method named test — is deleted outright;
TemplateStore's every-init template dump and CollectionInput's two
click-path logs go with it. EditorStore's AI tool-loop diagnostics
(nine logs including two full JSON.stringify template dumps per tool
call) now run only in dev builds.

Silent failures get a voice:
- The 15 unconditional console.warns in propResolvers/local.ts —
  template-authoring mistakes like $setLocal on an undeclared field —
  now also reach the host's $onError surface (the toast), through a
  sink the renderer installs from stores.$onError. One error channel
  per package instead of two conventions.
- backend-ad4m's avatar resolution kept its best-effort catch but logs
  the failure with the url: a broken avatar was indistinguishable from
  an absent one, in the one catch in the package with no written
  justification.
- ai-context's drift detector now fails the build (exitCode 1) when
  fragments/stores.ts documents a member that no longer exists — that
  direction of drift licenses $store paths into nothing and was only
  ever a scrolling warning. The undocumented direction stays
  informational. Regenerated context outputs ride along (the dead
  effect.depth tokens and the duplicate we-select entry drop out;
  themeStore.refreshSpaceThemes joins the store surface).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ipeline, honest cli build

- .gitignore covers the latent gaps: coverage/, *.tsbuildinfo, .turbo/,
  .vite/, .DS_Store, .idea/, and *.log generally (CI already expected
  .turbo to exist; storybook.log was the only log ignored).
- `pnpm clean` no longer deletes pnpm-lock.yaml — clean meant
  "dependency float on next install". The actual disk hogs it never
  touched (69 GB of tauri target/, dist-electron/) get `clean:deep`.
- Stylelint finally runs somewhere besides editors: `pnpm lint:css`
  gates the authored CSS (packages/apps src — the naive glob drowned in
  vendored minified CSS) and CI runs it beside ESLint. Zero findings
  today.
- @we/cli's build script no longer masks its own failures: the
  `A && B || C` shape ran the fallback and reported success when B
  failed; it is now an explicit if/else. bootstrap defers to the tsup
  config instead of duplicating the entry list on the command line, and
  the commented-out log line is gone.
- pnpm-workspace.yaml names the globe family explicitly instead of a
  `module-system/*/*` wildcard that matched 38 directories to find 4
  packages. (AUDIT P3-6)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The audit's docs tier, first half (P2-1..P2-5, P2-7 moves):

- packages/app-shell/README.md — rewritten. It showed useAdamStore and a
  PlatformAdapter with methods that don't exist, named consumers by the
  wrong package names, and missed five real directories. It now documents
  the actual two ports a host supplies (PlatformAdapter with the desktop
  factory, BackendConnector with the backend-ad4m connector factory), the
  real store roster, the directory map, and the three exports.
- docs/architecture/codebase-map.md — the "start here" doc taught
  adamStore vocabulary two refactors gone. Now: sessionStore/datasetStore/
  spaceStore ownership, dataset-first terminology, the backend contract
  seam, and new sections for the graph system and the template kit. The
  same phantom "elements → pages" ladder is fixed here, in the ai-context
  architecture fragment that generates the always-loaded orientation, in
  README.md, and in VISION.md.
- OPERATORS.md — the six adamStore examples (uncompilable vocabulary in
  the canonical operator reference) now use the real stores and $me.
- Seed system: one description instead of three. The guide is rewritten
  from types/seed.ts (documenting modules/features/globalSpaceUrl/
  marketplaceUrl, which were entirely missing) and points at the type as
  source of truth; the seed README's drifted parallel schema is replaced
  with a pointer; seed-examples gain the modules/features fields the real
  we-seed.json carries.
- README.md — three package links 404'd (packages/utils is deleted, two
  frameworks/ paths moved) and six of twelve systems were invisible. Now
  a full system table plus the real command set.
- VISION.md — targeted, not rewritten: the "How It's Built" ladder names
  real layers, and the template-kit fragment rung and graph engine —
  the mechanisms the forking story leans on — are in it, linking
  template-fragments.md.
- developer-setup.md — Node 24 (.nvmrc), coasys clone URLs, the real
  package tree, the missing commands.
- Guides: EMBEDDING.md moves to docs/guides/embedding-external-apps.md
  (the mechanism is alive in appBridge.ts — the stale AdamStore heading
  now names it; all seven inbound links updated). The cesium pair and
  launcher-ui-customization describe packages/registries that no longer
  exist — archived to internal/old with banners naming what replaced
  them; the globe layers README sheds its old @we/cesium-layers name.
  WEB_SCRAPING_STRATEGY.md (an unimplemented plan in apps/) moves to
  internal/plans with a status banner. The four unbannered internal/old
  docs get their banners; the docs index reflects all of it and finally
  links template-fragments.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ules named

Second half of the docs tier (AUDIT P2-6, P2-7):

New, following graph-system's README as the model (what the system is, a
package table with dependency direction, the decisions worth knowing):
- packages/backend-system/README.md — the contract, its two adapters,
  the conformance seam, and where @coasys/* is allowed to appear.
- packages/module-system/README.md — the module contract in one
  paragraph, the bundled modules, the deployment/space/agent layering.
- packages/templates/README.md — kit/shell/default and why fragments
  exist (the drifting-twins story).
- packages/ai-context/README.md — the extractors × fragments
  architecture behind 540 KB of tracked generated output that nothing
  previously explained, and the "when you change X" table.
- packages/models/README.md — the two-entry registry and the manifest.
- block-system/shared and graph-system/protocol READMEs — the two
  contract packages violating package-conventions.md's own rule 6
  (a shared contract package states what belongs and what doesn't).
- packages/app-shell/CONVENTIONS.md — the biggest package finally has
  its rules written down: layering, the load-bearing store nesting
  order, stores-as-schema-API, DEV-gating, logging, and the in-memory
  test contract.

Corrected, where the existing text actively misled:
- design-system root README (packages don't publish independently, no
  monorepo Storybook, no CONTRIBUTING/LICENSE files, "elements/pages").
- 4-components README — rewritten; it documented per-component
  .module.scss files that never existed, a deleted PostCard import,
  and wrong directories.
- 3-primitives README (src/primitives not src/components, `pnpm start`
  not storybook, the per-component subpath imports the build now
  actually produces) and its CONVENTIONS' dead plan link.
- 5-widgets README — was a single heading line.
- 1-tokens README (neutral not ui, real radius var, honest runtime
  claim) and CONVENTIONS (the four token files missing from its table,
  including the shadow scale components actually use and the new
  roles). 2-themes README/CONVENTIONS gain the light theme and the JS
  entry.
- block-system README's broken conventions link; performance.md pins
  its measurement point; examples.md notes its knowledge-map sketch
  shipped as the graph engine.

Plus .github/pull_request_template.md with the docs-sync checklist —
the freshness contract the audit asked for: the stale docs rotted
because nothing forced the update at PR time.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The architecture fragment fix (elements/pages → primitives, no pages
layer) propagates into the three generated references. Full gate suite
green at this point: lint, stylelint, typecheck, build, and 1,300+
tests across the workspace.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…perpetual dirty diff

Both desktop generate-seed-config scripts wrote their JSON outputs via
JSON.stringify with no trailing newline, while the committed files end
with one (prettier normalized them at some point). Every pnpm build
therefore rewrote the three tauri generated files byte-different — a
permanent phantom diff after any clean rebuild. All nine JSON writes
across the two scripts now append the newline; rerunning both
generators reproduces the committed files exactly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…exports, authoring-scoped warnings

Three user-visible breaks, two mine and one inherited:

1. The boot screen's Login button (predates this branch): the
   template-kit refactor (e62db1d) collapsed the whole unlock row to a
   bare field() fragment — dropping the Login button, the
   Enter-to-submit handler, the "Incorrect password" error wiring, and
   the clearPasswordError chain. sessionStore.login was left with zero
   callers: unlock was impossible. The full row is restored, with a
   comment on why it is deliberately not the field fragment (the
   fragment renders a lone control; this row is input + button + shared
   error, the shape an OS sign-in uses).

2. The template/theme toolbar: trimming @we/editor's "unconsumed"
   component exports missed that the app shell consumes four of them
   via dynamic import — lazy(() => import('@we/editor').then(m =>
   m.DesignToolbar)) and friends resolve undefined and render nothing,
   silently. DesignToolbar, RightPanelContainer, TemplateCard and
   EditorOverlay are exported again, with a warning comment that these
   consumers are invisible to a static-import grep.

3. The "$local: postRows" toast on app load: routing local-state
   authoring warnings to $onError unconditionally toasted diagnostics
   from *stored* templates at people merely opening a space. The sink
   now follows the authoring session — EditorStore installs a warning
   toast while an editing surface is open and removes it after; viewers
   get the console copy only, as before.

Verified: schemas validate, editor typechecks, app-shell + schema-solid
suites green, lint clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Block-mode we-code defaulted to bg neutral-900 / color neutral-100 —
scale positions, which the parametric lightness ramp inverts per theme.
The intended near-black terminal read in light mode therefore rendered
as a white slab with dark text in dark mode (visible on the runtime
settings Languages section).

A code block is a terminal: dark in every theme. The block background
and text now pin their lightness and keep only hue/saturation
parametric — the same move the dark theme's tooltip inversion makes —
so every theme tints the block with its own hue while the terminal look
holds. Inline code keeps its scale tokens, which adapt correctly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…op warning, keep the fields

Reloading straight into a data route logged a cluster of warnings from
one cause: the first frame renders before the backend's data bindings
land in the stores bag, so $queries found no $getModel, warned, and —
the real damage — dropped its declared fields from $local entirely.
Every downstream read then warned "$local: field not declared", blaming
the template for a query that simply hadn't started yet. The wired
render replaces the tree a beat later, which is why everything worked
despite the noise.

Absence of $getModel is also a legitimate permanent state: the
RendererDataBindings contract makes every data binding optional so a
presentation-only host can omit them. Warning per query in such a host
was wrong on its own terms.

All three sites ($queries, $each's $query, $single) now degrade
quietly — empty results, no item — and $queries registers its declared
fields as empty reads either way, so $local resolution stays coherent
through the unwired frame.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…lved

Opening the spaces list toasted "RPC error 500: Failed to parse model
query: data did not match any variant of untagged enum WhereCondition".
The spaces query filters `url: { not: { $store:
'datasetStore.currentDatasetCid' } }` — and on a reload into the route,
the cid hasn't loaded for the first frame, so the operand resolves to
undefined, `{ not: undefined }` serializes to the empty condition `{}`,
and the executor's parser rightly refuses it. The same failure awaits
any where-clause reference that is briefly (or, on a data-less host,
permanently) unresolved.

An unresolved input means "don't filter on this yet", not "send a
filter with a hole in it": pruneUnresolvedWhere (new in schema-shared,
tested) strips undefined-operand conditions — including inside OR/AND
branches and NOT clauses, dropping combinators that empty out — while
keeping null operands, which are values. Both renderer resolution sites
($each/$queries via createQuerySignal, and $single) prune after token
resolution; the effect re-runs and applies the real filter the moment
the value exists.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
jhweir and others added 12 commits August 12, 2026 14:59
…dist/primitives

The per-element build landed in dist/components/*.js while the sources
live in src/primitives/ — a leftover from repairing the broken subpath
exports, where the JS output was aligned to the directory name the
type-declaration generator happened to hardcode. Now the whole chain
says primitives: the tsup entries, the generated framework declarations
(dist/types/*/primitives/), and every package.json subpath export. The
public specifier (`import '@we/primitives/button'`) is unchanged; the
dist layout is internal to the package.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…es side-effect import

The ./styles export resolves to a CSS file with no type declarations,
and the bare specifier doesn't end in .css — so neither vite/client's
ambient nor the playground's *.css declaration matches it, exactly like
the documented @we/tokens/css case beside it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ases outside

The picker dismissed on any document `click` outside itself. A click's
target is the common ancestor of its mousedown and mouseup targets, so
panning the map and letting go outside the picker synthesized an
"outside click" and closed the map mid-gesture. Dismissal now keys off
`pointerdown` — where the press *starts* is what inside/outside means
to the user — so a drag that begins on the map keeps the picker open
wherever it ends, and a genuine outside press still closes it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…opies are gone

The create-space modal was mounted twice: once in shell chrome
(createSpaceModalMount, slot 'core:createSpace', gated on
shellStore.createSpaceOpen — "registered as chrome so it exists once,
wherever it is opened from"), and again inside CardsRoute and
GlobeRoute, gated on a route-local createSpaceModalOpen flag. Every
close path inside the modal (X, backdrop, Cancel, post-create
onSuccess) sets the *shell* flag — so the route-local copies, opened by
their local flag, could never be closed.

The local mounts and flags are deleted; the routes' open buttons now
set the shell flag and get the chrome copy, which closes correctly from
every path. Deleting them also removes the last two
template-default → template-shell imports, so the back-dependency the
audit flagged as "still present contrary to the PR claim" is now
actually gone — the package.json dependency goes with it. All 22
schemas validate; template-default typechecks and builds.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…or dropped

The same template-kit refactor that collapsed the boot screen's login
row (e62db1d) also rewrote the signal-type modal's mode select as
field({ control: 'select' }) — without carrying the options array over.
we-select fell back to its empty default: a mode picker with nothing to
pick, on the control that decides whether a signal is a toggle, vote,
rating or slider. The four options ride through the fragment's props
now, as the fragment intends.

Swept the rest of that refactor's hunks for further losses: the only
other removals are a cosmetic input bg and opt-in touch-on-blur, both
consistent with the fragment's documented philosophy. With this and the
login row, that refactor's casualties are accounted for.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The users list gets the spaces card's shape: a full-width cover image
when the profile has one (same 120px cover treatment), a lg avatar with
shadow — now carrying `hash` so an unfetched profile still gets a
stable generated face — a heading-sm name with the @handle beneath, the
bio in body text, and a location stat chip.

Two deliberate divergences from a literal copy: the location chip gates
on `city` rather than the location object (a lat/lng-only location has
nothing readable to show and rendered ", "), and there is no created/
joined date chip — a member's profile summary (AgentProfileSummary)
carries no such field, unlike a Space model, so the card only promises
what the data can deliver.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ing is a real state; view settings persist

Three related list-experience fixes:

1. Reloading on a query-backed route left it empty until a route change.
   The stores bag exposes the data bindings as getters over a memo of
   the backend ports — reactive, but every query site read $getModel
   once at component setup, so a template mounted before the backend
   connected was permanently stranded. All five sites ($queries, $each,
   $single, prop-level $query, and $map+$query hoisting) now read the
   bindings inside the query effect: the subscription starts by itself
   the moment the bindings land. The queryToken test that pinned the
   old warn-and-bail contract now pins the self-healing one.

2. Loading is no longer collapsed into empty. Each query accessor
   carries a `loaded` signal — false until the first result set or
   error, then true for good (re-runs keep old rows until new ones
   reconcile, no flashing). $queries exposes it as `<name>Loaded`; the
   kit's new skeletonList fragment renders pulsing card-shaped blocks,
   and cardList holds it until the query answers — the empty state now
   only ever asserts "loaded and empty". The semantic validator knows
   the derived name.

3. $localState fields accept persist: '<key>' — device-local
   (localStorage) persistence with an explicit, namespaced key; the
   stored value wins over `initial` on mount and $resetLocal clears it.
   The cards route persists contentType, sortField, sortDirection and
   displayMode, so a reload lands where you left it; search text stays
   deliberately ephemeral (a remembered filter silently hiding content
   reads as missing data, not a preference).

Docs updated in the ai-context fragments and regenerated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…, implemented

The routing conventions discussion, landed as code + a decision doc
(docs/architecture/routing-and-view-state.md): every piece of UI state
has one home, chosen by "if I sent this URL to someone, should they see
the effect?"

- Tier 1, location → the path (existing routes/segments machinery).
- Tier 2, view state → URL query params. routeStore gains a reactive
  `params` record and `setParam(name, value, { push? })` (history
  written directly — the path doesn't change, so the route tree must
  not re-resolve; popstate covered). $localState fields opt in with
  syncParam: '<param>' (object form adds push: true for changes that
  deserve a Back entry). Reads/writes stay $local/$setLocal; a field
  back at its declared initial removes its param, keeping URLs clean.
  Precedence on mount: URL > persisted > initial. The renderer reaches
  the router through a $routeParams host binding, so any host can wire
  its own and hosts without one degrade to plain local state.
- Tier 3, preferences → persist (device) or AgentSettings, and NOT the
  URL: a shared link must not impose display density on its recipient.
- Tier 4, ephemeral → plain local state.

The cards route adopts the tiers: content type (?type=, push), sort
(?sort=&dir=), search (?q=) share and reload with the URL; displayMode
stays a device preference. The previous commit's persist keys for the
URL-tier fields are superseded — device storage made the same URL show
different content per machine, the opposite of shareable.

Links can also suggest a look: ?template=<id> and ?theme=<id> are
honored by the shell — applied silently when the recipient has them
(clicking the link is the consent; idempotent, so re-sharing is safe),
and degraded with a one-time warning toast naming what's missing when
they don't, falling back to the recipient's own. Matching re-resolves
as templates/themes stream in, so space templates arriving after boot
still apply.

Five new renderer tests pin the contract (URL-wins precedence, push
semantics, clean-URL removal, degradation without the binding); the
generated reference documents both tiers and the suggestion params.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The reported repro: set sort to likes, visit another route, return —
the list still shows likes (keep-alive routes hold their live state
across navigation) but the URL is bare, because the router strips the
query string on the way out. The screen and the address disagree, and
a reload believes the address: sort snaps back to the default. Reload
without navigating first and ?sort=likes is still in the URL, so it
sticks — exactly the asymmetry observed.

routeStore now keeps an in-memory, per-session map of each path's last
query string — updated on every setParam and on every arrival the
router reports — and navigate() to a bare path restores that path's
remembered search. An explicit `?` in the target always wins, and a
field returning to its default clears both the param and the memory,
so returning lands bare. Reloads keep starting from the URL itself,
which this keeps truthful.

Five routeStore tests pin the contract (write/remove, push-vs-replace,
restore-on-return, cleared-memory, shared-link arrival); the behavior
is recorded in docs/architecture/routing-and-view-state.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…mes, not just handles

Searching the member list only matched handles while the spaces list
beside it matched name OR description — not an oversight in the
template but a gap in the grammar: $filter's where deliberately lacked
the logical combinators $query's has, so a client-side filtered list
could only ever search one field.

matchesWhere now handles OR/AND/NOT with $query's exact semantics —
branches are full where-clauses, siblings stay implicitly ANDed,
combinators nest, malformed shapes match nothing rather than
everything. Six tests pin the grammar; the generated reference drops
its "$query-only" caveat and shows the two-field search shape for both.

The member list searches name OR handle: `name` is the assembled
display name, so first and last are covered by one branch, and the
handle branch keeps @handle searches working for members with real
names. Bio is left out deliberately — a people search matching a stray
word deep in a bio surfaces people who don't look like matches; it's
one more OR branch if that call changes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The previous commit put a description field on the typed store entry —
StateMemberMeta has no such field, and the regenerated contextData
broke schema-shared's dts build (caught by template-default's
typecheck). The typed entry carries shape only; the prose for params
and setParam moves to the parallel docs section where every other store
member's prose lives. Regenerated; builds and typechecks green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A space with coordinates but no reverse-geocoded city rendered
"Location: , " — the chip gates on the location object while the value
it prints is city + country. Gated on city, matching the member card.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@netlify

netlify Bot commented Aug 12, 2026

Copy link
Copy Markdown

Deploy Preview for coasys-we ready!

Name Link
🔨 Latest commit c4eda1f
🔍 Latest deploy log https://app.netlify.com/projects/coasys-we/deploys/6a7c9269166d49000894b8a4
😎 Deploy Preview https://deploy-preview-114--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 3fd4b83 into dev Aug 12, 2026
4 of 5 checks passed
@jhweir jhweir mentioned this pull request Aug 12, 2026
9 tasks
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