Skip to content

Feature: Transcript and extraction improvements - #195

Merged
jhweir merged 88 commits into
devfrom
feat/transcript-extraction-graph
Sep 11, 2026
Merged

Feature: Transcript and extraction improvements#195
jhweir merged 88 commits into
devfrom
feat/transcript-extraction-graph

Conversation

@jhweir

@jhweir jhweir commented Sep 11, 2026

Copy link
Copy Markdown
Collaborator

Transcripts and extraction: writing into a call, and reading what came out

Summary

This branch takes the transcribe module from "a panel that shows lines and a button that finds
things" to two surfaces somebody can actually work in: a transcript you can write into and correct,
and an extraction panel that says what it looked for, what it found, and what it asked the model.

Three things drove most of it. A person needs to be able to add to and mend a transcript, not only
read one. A pass needs to leave a durable record, because a feed that starts empty on every reload
cannot answer "what has been read here". And an extracted record needs to be distinguishable from
everything else in a call — which turned out to be a missing relation rather than a query problem.

Changes

Writing into a transcript. A composer under the feed writes into whichever call is on screen,
not only the live one; typed lines are marked as typed and can be edited afterwards. The transcript
panel keeps one control per question — Transcribe for your own microphone, and the level meter stays
up (disabled) when it is off, so the panel does not change height as you toggle.

A pass is written down. ExtractionPass gains trigger, prompt and response, and both kinds
of pass now write one: a one-shot from the run that returned it, a watched one from whoever is
listening when it settles. So the log holds every reading of a call, by every member, with the
exchange behind a disclosure — and it survives a reload, which the live feed never did. Note the
cost: the exchange replicates to every member, so shareExtractionDetail now governs whether the
live readout offers it rather than whether it is shared at all. Gating the write on that setting is
the way back if it matters.

Provenance, and what it unlocked. Extracted records were indistinguishable from transcript lines:
both hang off we://children. CollectionBlock.extracted is a second, non-owning relation written
beside containment — "a model proposed this from reading this call". Containment stays, so the call's
board still gathers the work. With it, one polymorphic include returns exactly what the passes wrote,
in one round trip, ordered by date and countable. Without it that question is unaskable: a query
names one entity, an untyped include carries no class constraint, and a call's children are mostly
transcript.

Suggestions and results read as one thing. A staged suggestion and a settled record are drawn by
the same card from the same builders, differing in the edge colour and whether there is an answer to
give. Cards are built from the model's own declaration, so a shape a community defined this morning
is titled, iconed and detailed with nothing written for it.

Calls have a corner. The top-left region is fixed now rather than appearing with a call: it names
the call, offers the way back into it, and offers a new one when there is none. The way back moved
there from the transcript panel's header, as a part the call module publishes, so any interface that
draws a call's name gets it with one line.

Design system and host. helpTip (an info glyph beside a panel name), busyWhen on a module
launcher, notify on the module contract, a formatJson host function, emptyState accepting
either a label or a message, and a tooltip audit that made we-tooltip the only spelling.

Known follow-ups

  • Class constraints on a polymorphic include would help the graph's collection expander, which
    fetches every child and filters client-side. Upstream, and not needed for this branch.
  • shareExtractionDetail means less than it says now — see above.
  • Extracted results cannot interleave by date across models if ever read per-model again; the
    provenance link is what avoids that, so nothing should go back to per-entity queries here.
  • Two docs were stale and are fixed: the transcripts doc claimed include crashes on this relation,
    and the schema reference did not say a polymorphic include cannot be class-filtered.

Test plan

  • pnpm build clean, tree not dirty afterwards (CI's drift gate).
  • Full pnpm test green.
  • validate:schemas, role-audit, surface-audit clean.
  • Entity regeneration: @we/entities generate:types and @we/backend-ad4m generate:classes.
  • Manual: start a call, speak, correct a line, extract by hand and by watch, keep and discard
    suggestions, read the log and open a prompt, pick a finished call back up.
  • Not verified: the panel corner at narrow viewport widths, where a long call title plus the
    start button could crowd the centred switcher.

jhweir and others added 30 commits September 7, 2026 17:14
coasys/ad4m#927 merged, completing the eight-PR model-layer series: relation
correctness, a getter relation that can name its target class, the where-clause
compiler, relation quantifiers, subjectClassesOf, polymorphic include, and CRDT
ordering in two halves. Five of the eight are Rust, so the executor matters as
much as the npm package here.

`@coasys/ad4m` moves to 0.13.0-test-model-layer, hand-published from dev at
54a3fd956 under npm's `dev` tag so `latest` keeps pointing at a build everyone
else is already on. `@coasys/ad4m-connect` deliberately stays where it is: it
carries no dependency on core, so republishing it would have said something
untrue about what changed.

VERIFIED_AGAINST_AD4M moves in the same commit, which is the whole point of it
existing — the pin moving alone is the silent case it was written to catch. Its
docstring now names the SHA as well as the version, because a hand-published
version corresponds to no git tag, and because the executor binary is never
published at all: the Rust half is pinned by that SHA and by nothing else.

Nothing yet takes up any of the new affordances. The capability profile still
declares relationFilters: false and still carries both sort degradations; those
are claims about the executor and are checked against a running one, not against
a changelog, so they move later in the branch on evidence rather than here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The extraction panel's review list showed each staged suggestion as one run-on
line of `field: value` pairs, in a column that could not scroll, and offered two
buttons. Every part of that is fixed here, and one of them turned out to be a
backend bug rather than a layout one.

A proposal's values arrive as `[predicate, value]` pairs with no class attached,
so the adapter had to read a predicate back to a property name with nothing to
index by. That mapping is one-to-many — predicates are shared across models on
purpose, which is what lets `?node we://title ?t` span every kind of block — so
it kept whichever name registered first. `Relationship` is third in SPACE_MODELS,
so `we://title` resolved to `label` for every proposal in the app: a task's title
arrived under a relationship's field name. The visible symptom was somewhere else
entirely — the review list leads with `title`, which then matched nothing, so
cards led with their description instead.

`subjectClassesOf` (in the ad4m build this branch already pins) answers which
model a base belongs to, so the name tables are built per model and indexed by
the class the base actually is. It has to skip `InterpretationOverlay`, which the
machinery instantiates over the same base URI and which ranks by required triples
rather than by anything anybody chose.

With the model known, the card can say what it is — icon and name from
`recordStore.displays` — and draw a title, a state and a description instead of a
string. `DisplayField` now carries `options`, the values a field is allowed to
hold where the declaration closes the set. `kindFor` deliberately flattens those
to `text`, which is right for drawing one and loses the only thing that lets a
caller do better: a badge cannot tell a state from a free sentence, and an edit
control cannot offer the choices, so it offers a text box and somebody types
"pending" into a field whose model knows three words.

The section scrolls now. It could not before because a Column cannot shrink below
its content unless told it may, so flexbox took the whole deficit out of the
results list underneath — which collapsed to nothing while the review list ran off
the bottom. `minHeight: 0` is what makes it shrinkable; the scroll area is what
the shrinking then does. The cards are a grid rather than a column, so widening
the panel reflows them instead of stretching each one into a strip.

Editing accepts first and writes second, which is not an implementation detail:
accepting makes the model's staged value the real one and deletes the overlay, so
a write made first is one the accept then silently overwrites. Ordering it this
way also lands after the overlay is gone, which is what stops a later pass
overwriting what was typed — deleting the overlay is the lock. The draft lives in
the store because the fields come from the model and there is no set of names a
schema could declare, which is the same reason `recordStore` holds its own.

That needed a space-scoped `updateEntity`. `linkEntity` deferred the question and
asked for an answer on concurrent writers; the answer is that there is none to
give, because this is the same last-write-wins-per-field write `record.update`
already performs and that is reachable from any template. It is not a widening of
what can happen to a record, only of where it can be reached from. It stays wrong
for lists, which is why `linkEntity` is add-one and remains so.

Pending records are also excluded from the results list below. A staged `create`
is a fully written record — the engine writes real values whenever no human owns
them and keeps the overlay only as provenance — so every suggestion appeared
twice: once as a decision, and again among the settled results, indistinguishable
from something already agreed to.

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

Reopening a call after restarting the app showed its extracted records on the
board and nothing in the review list, with the cards drawn as settled. It read as
though everything had been accepted while nobody was looking. Nothing had been:
the list was empty because nothing had ever asked for it.

`proposals` was one flat list filled from two places, and both are events during a
session. The activity-feed effect reloads when a pass settles — but the feed is a
live subscription that starts empty, so passes from before the restart are not in
it. `useCollection` reloads when the transcriber adopts a record — but it only
adopts one when this agent is about to write into it, which a reader who is not
recording never triggers. `refreshProposals` was exposed for exactly this case and
wired to nothing.

The same flatness hid a second fault. The list belonged to whichever conversation
last filled it, which is the live one, so a panel opened on a past call listed
what the current call had staged — the mismatch `extractionFor` was keyed to fix,
still present in the one member that had not been.

So proposals are keyed per call and fetched on first read of a key. Keyed because
the surface asking is about whichever call is on screen and an expression cannot
pass an argument to a store member; fetched on read because only the reader knows
which call it is asking about — a past call named in the address is not something
the store has any other way to learn. `$agent` demand-fetches a profile from a DID
for the same reason. The panel and the workshop board both name their own call
now, and a read in a render loop is one fetch rather than one per frame.

`proposals` stays as the live call's, which is what it always meant to be. Nothing
here reads it, but it is the spelling an already-installed template would be
using, and pointing it at the live call makes such a template correct rather than
merely unscoped.

A failed fetch now leaves the previous list alone instead of emptying it. Clearing
would take a list somebody is part-way through reviewing off the screen because an
unrelated read timed out.

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

Two faults from keying suggestions per call, both reported from use.

Outside a call the key was empty, and an empty key asked the backend for
everything staged in the dataset. The port offers that and it is the honest
answer for a surface genuinely about a dataset — but this panel is about a
conversation, so with no call on screen it listed every suggestion the space had
ever accumulated, undifferentiated, with no way to tell which came from where. It
is also the hazard `loadProposals` was narrowed to avoid in the first place: a
proposal from another call is one the reader cannot act on from where they are,
because accepting it commits a record parented to a conversation they are not in.

So an empty key now means no call rather than every call — nothing fetched,
nothing listed — and the panel gates the section on there being a call at all.
The gate is a node rather than a clause because the count *reads* the list and a
read is what fetches it; one combined expression would have asked the backend the
exact question the gate exists to prevent. Reviewing across calls may be worth
building, but it wants its own grouping and its own sense of where a decision
lands, and it is not this panel with the scope taken off.

The second was mine. A board's `pending` marker was keyed along with everything
else, and the two are not the same question. "Which decisions am I being asked
for" is about a conversation. "Has anybody agreed to this record" is about the
record, and is true or false wherever it is drawn. Keyed, switching calls flashed:
the outgoing call's cards stay on the board for the moment its replacement is
queried, and against the incoming call's list — empty, nothing having fetched it
yet — every one of them rendered as settled. They were never settled; they were
being asked the wrong question.

`pendingIds` answers the record's question from the union of every call known
about, and only ever loses an entry to a real accept or reject, so nothing can
un-mark a card that is still waiting. The review list stays keyed.

A card arriving on a board before its call's proposals have loaded can still be
briefly unmarked. That is one fetch rather than a re-query and a fetch, and
closing it needs a "not yet known" state the marker cannot express today.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A review card was rendering whatever the pass proposed, in whatever order the value
map happened to be built. That put `occurrence` — a dedup key whose own note calls
it "not a display value" — on every event card, printed a raw `2026-09-13T00:00`,
announced the model as `EVENTBLOCK`, and captioned every field with its property
name. The card reads from the declaration now, and the pieces it needed are added
where they were missing rather than guessed at in the panel.

`RecordDisplay` could not see relations at all: `fields` was built from
`properties`, so a declared edge to another record was absent from the very thing
whose job is to describe how a model is shown — and absent silently, rendering
nothing rather than rendering wrongly. They are fields now, carrying what they
point at, which is also where their icon comes from: a place shows a pin because
`LocationBlock` says its icon is a pin, and a community's own model shows whatever
icon that community chose. That is the general form of the answer a name heuristic
only ever approximates.

`EventBlock.location` becomes a relation to `LocationBlock`. `we://location` meant
a literal here and a record on `Space` — one predicate, two shapes, which is the
version of predicate sharing that cannot work — and a place is a thing rather than
a word: as a string it could not be mapped, could not be the same Bristol as the
one on another event, and carried nothing an address or a country could hang off.

`LocationBlock`'s coordinates stop being required. They were `required` with a
`default: 0`, which guaranteed a number was present and never that it meant
anything: `minCount` is metadata the executor surfaces rather than a constraint it
enforces, and the default fires at construction, so a location created without
coordinates silently claimed Null Island. There is no guarantee to lose here, only
a lie to stop telling — and a model hearing "we're going to Bristol" has a name and
no coordinates. Made to supply them it would, confidently and wrongly, onto a
globe. `name` becomes the identity so a place mentioned three times in a call is
one record rather than three.

`success` joins the button variants, for the confirming half of a yes/no pair and
only that — a form's Save stays `primary`. The case is `danger`'s own, from the
other side: a status fill deserves a role so a theme can say something about it
beyond its hue, and `accent` is the brand colour a theme may legitimately set
anywhere, including next to `danger`. Nothing new was needed underneath. Keep and
Discard carry the board's own `check` and `x`, which are not decoration: a
green/red pair is the classic one to fail on, so the glyph carries the meaning for
anyone who cannot separate the colours.

## The composer's location field no longer reaches the record

`extractBlockData` copies declared *properties*, and there is no relation support
anywhere in the block persist path. So a location typed into the block composer's
event input still shows in the composer, where it lives in the block's content, and
no longer lands on the `EventBlock` record — which means it drops out of queries,
and the workshop's events list shows no place for a hand-composed event.

Written down rather than discovered. Closing it properly means minting the target
by its identity and linking it from the persist path — which would make
`LocationBlock.name`'s new identity earn its keep on both sides — or replacing that
text input with a picker over real places. Either is a change to every block save
and is left as its own decision rather than taken quietly here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sixty-three commits, including a kanban-boards series that reached the same
extraction proposals this branch rebuilds. Nineteen files overlapped and two
conflicted; the rest of the work here was checking that what merged cleanly also
agreed, which in one place it did not.

`WorkshopTemplate`'s events query conflicted with both sides right. Dev scoped it
to the call the workshop is about, so it stopped quietly widening to the whole
space; this branch added `include: { location: true }`, because location is a
relation now and an unhydrated one arrives as a URI the row prints as nothing.
Either alone loses something, so it takes both.

`schemaContext.ts` is generated, so it took dev's and was regenerated from the
fragments rather than hand-merged.

## What merged cleanly and still disagreed

`taskBoard`'s `PENDING` read `modules.transcribe.proposals` — the flat list this
branch replaced, for the two reasons it replaced it: nothing fills it until a pass
settles or the transcriber adopts a record, so a board marked nothing after a
restart, and what it does hold is the *live* call's, so a board showing a past one
marked the wrong suggestions. It reads `pendingIds` now, which asks the question a
card marker means: whether anybody has agreed to this record, which is true
wherever it is drawn.

Its card summary needs the rows rather than the ids, so `pendingProposals` joins
it — the same union, in the shape a lookup can read.

Both sides were internally consistent, which is why the merge was silent about it.

## Two smaller repairs

`generate:classes` lifts JSDoc from properties and relations and has nowhere to put
one attached to an entity-level key, so the docblock on `EventBlock.display` failed
the round-trip test as missing from the generated class. It is a block comment now,
with a note saying why.

Dev's `45ac1529` found the same hazard this branch's edit flow answers — Keep
copies every staged value onto the record, so an older suggestion overwrites a
decision somebody just made. The two answers compose rather than collide: a board
move settles the staged property before Keep, and an edit here is written after the
accept. The board's callers reach `acceptProposal` with no draft open, so the
branch this branch added is inert for them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two things somebody wants while a call is running: to type something into the
record — a name the recogniser will not catch, a decision nobody said aloud — and
to fix a line it misheard. Both put text in the timeline a microphone writes into,
which is the point: a remark typed during a meeting belongs at the moment it was
typed, among what was being said then. A comment kept in its own entity could only
ever sit beside the conversation, because a transcript is one list ordered by
`createdAt` and a schema cannot merge two queries into one.

So both are `TextBlock`s among the utterances, and the question is what the record
then claims. Unmarked, a typed note and a mended line are indistinguishable from
speech in three places at once: the panel draws them with a byline, the export
writes them as spoken lines, and an extraction pass hands them to a model under a
prompt that says a transcript is what was said out loud. WE already refuses that
kind of quiet overstatement — the coverage readout exists so a transcript of two
people out of five cannot pass as a transcript of the meeting.

`TextBlock.source` answers it in one field: `spoken` where a recogniser heard it,
`typed` where a person wrote it, `corrected` where somebody has since fixed what
was heard. `corrected` only ever replaces `spoken` — mending your own writing is
not a correction of a transcript — and empty stays empty, so every block the
composer has ever written, and every one written before this existed, is left
alone rather than retroactively claiming to be speech.

Not `style`, which looks open in the manifest and is not: `serialization.ts`
narrows it to `h1|h2|h3|blockquote|normal`, so any other word is silently dropped
on a composer round trip. One predicate meaning two unrelated things is what
`we://location` had to be unpicked from this week.

Correcting is offered on every line rather than only your own. A recogniser
mishears names, jargon and anybody with a cold, and whoever notices is usually not
the speaker; a shared record every member can write to makes that possible already,
so refusing it in the UI would only mean the transcript stays wrong. What is not
acceptable is the correction being invisible, which is what the mark is for.

The row editor is per-row `$localState` rather than store state. The proposal
editor needed a store draft because *which fields exist* comes from the model;
here the field is `text` and nothing else, which is the case locals are for.

`TranscriptTurn` carries `source` now, optional and ignorable, so the two consumers
that assert somebody's words can be accurate about it. The export marks a line
`(typed)` or `(corrected)`: a text file has no badges, and it is the artefact most
likely to be quoted back or filed long after anybody remembers which was which.
Extraction still receives typed turns unchanged — the turns can now say so, but
changing what the executor is told is its own decision.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Four things a transcript panel got wrong, and the one that was worth fixing
in the design system rather than here.

The composer now sits under whichever transcript is on screen, not only under
the one being recorded. Watching a call back is when somebody notices what is
worth writing down; during it they are busy talking. Nothing about a typed
line pretends to have been said at the time — `source` is what stops it, and
it carries its own `createdAt` — so the reason it was gated on the live call
does not survive contact with the field that already exists to answer it.
`addComment` takes the collection as its first argument accordingly, and names
the call's dataset only when the target IS the call in progress: a live call in
one space outlives a reader who walks to another, so naming it unconditionally
sent a note about a past call here to wherever the live one was running.

The composer is also now the panel's chrome rather than the last row of the
feed. Inside the scroll region it read well on a full transcript and badly on
every other one — with two lines said, a box halfway up an empty panel that
moves every time anybody speaks. A place to write is a fixture of the surface.

`we-timestamp` and `we-badge` no longer give up room or break, in the
primitive. A flex item's automatic minimum size is its content, which makes a
run of text the sibling that yields when a row runs short — and "4 minutes ago"
has no useful narrower form, so shrinking it folded the phrase onto two lines.
Every consumer patched that by hand; two of those patches are deleted here and
the rest of the repo gets the fix without knowing. `relativeStyle` abbreviates
where room is genuinely short, and the neutral badge's fill moved to
`control-surface` so it is visible against an utterance rather than invisible.

`we-textarea` gains `autoGrow`, `maxRows` and `submitOnEnter`. The first is
what makes a one-row box line up with the input and button beside it: it takes
the control height for its size instead of whatever `rows` x line-height comes
to, which also fixes a per-size floor that had every size sitting at md.
`submitOnEnter` is a prop rather than a schema idiom because a schema can read
a key event and has nothing that calls `preventDefault` — the schema version
would send the message AND insert the newline. `on:submit` is the escape hatch
for a consumer that wants its own rule. The AI panel's hand-rolled Enter
handler and guessed `maxHeight` are deleted in favour of both.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Switching from a space on a self-routing template to a space on a
section-hosting one left the URL and the sidebar naming the new space while
every query read the old one's records. Reopening the self-routing template
before it healed jumped the sidebar back, because that path navigates to
whichever dataset the store holds — which was still the old one.

Three things had to line up, and each was reasonable alone.

`navigateToSpace` switches the dataset first and navigates second, so between
the two the stores describe B while the URL still says A. The section guard in
`TemplateProvider` woke inside that window: the template had just been replaced
with B's default, and the one before it routed itself, so `hasViewsMarker` went
from false to true on a URL holding a self-routing template's own path — a
section no space has. It then did exactly what it is written to do, to the
wrong space, and rewrote the URL to `/space/A/<B's first section>`. That is a
route change naming A, so the effect that keeps the dataset in step with the
address switched back to A; it started later than the switch to B and so landed
last, and nothing afterwards disagreed with it.

The guard is the invariant stated plainly: correct an address only when it is
an address about the space you are reading from. `datasetAddressedBy` says
whether a `/space/<segment>` URL names a dataset — both forms resolve, since a
local id has to keep working as an alias — and the three sites that spelled
that comparison out now share it.

And `switchDataset` no longer publishes a result the reader has moved on from.
A switch is several round trips long, so two of them overlap routinely and used
to finish in whatever order the network chose. Tracking what was last *asked
for* makes the latest ask win however long it takes to arrive, which is the
class of bug rather than this instance of it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`relativeStyle` was added to `we-timestamp` and then named by nothing, so every
row went on reading "3 hours ago" — in the panel narrow enough that the length
of that phrase was the reason the row wrapped in the first place. A prop no call
site sets is a prop that does nothing.

The transcript row and the extraction history now ask for `short`: "4 min. ago"
rather than "4 minutes ago". Set at the call sites rather than defaulted in the
primitive, because how wordy a time should be is a density decision belonging to
the surface — the same stamp under a post in a full-width feed reads better
spelled out.

Pinned by a test, since silently keeping the default is exactly how this got
missed once already.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Relative time answers "how fresh is this?", which is a question about a feed of
unrelated items. A transcript is not one: every row came out of the same
conversation, so on a call recorded last Tuesday all two hundred rows read
"6 days ago" — the same string on every line, carrying no information and taking
the width that made the row wrap in the first place. What a reader wants from a
line of a finished meeting is where in it the line was, and that is the clock.

So: the clock time on a past transcript, relative while the call is live, where
the numbers differ row to row and move on their own.

One node rather than an `$if` on the two. `relative` short-circuits inside the
primitive, so `timeStyle` simply goes unread while it is true; a branch would
unmount and rebuild every row the moment a call ended.

The live side is `narrow`, not Intl's `short`. In English `short` does not
abbreviate "days" at all, so it leaves the widest string exactly as long as
`long` did, and spells the rest with full stops ("3 hr. ago"). Narrow is the
coherent abbreviation and needs no deviation from CLDR to get there. It is not
conditional on the panel's width: a transcript stamp is a coordinate, something
skimmed past to find a moment rather than read, and a coordinate wants to be
terse at any width. That is a fact about the row, not about the room it has.

The extraction history keeps relative time, because it *is* the feed of
unrelated items the form is for — passes run minutes or days apart and "how
recent is this one" is the whole question. It moves to `narrow` for density
alone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two lowercase word-badges on a row already carrying a name, a time and an edit
button was too much furniture for metadata. Both marks get lighter, and they get
lighter in different ways, because they are not the same kind of claim.

`typed` becomes an icon with a tooltip. It is a provenance nicety rather than a
warning, so putting the sentence behind a hover costs nothing. `keyboard` and
not a pencil: the pencil is the edit button two elements along the same row, and
one glyph cannot mean both "written rather than spoken" and "change these
words".

`corrected` becomes "(edited)" in muted text. This one is a claim about whether
the line is still a verbatim quote, and `editUtterance`'s own note is why it
cannot be hover-only — a mended line reading as something somebody said is a
claim nobody checked, and a tooltip is invisible on a touchscreen and to anybody
not poking at rows. The words carry the message and the tooltip adds *when*,
which is worth having and worth nothing as the only channel. "(edited)" rather
than an asterisk or a badge: lighter than what it replaces, needs no legend, and
every reader already holds the convention from chat.

The time comes from `updatedAt`, which arrives on every fetch beside `author`
and `createdAt` with no field to declare. It is slotted into the tooltip as a
`we-timestamp` rather than interpolated into the `title` string, because a
schema cannot format a date and the value is epoch milliseconds. What it cannot
say is *who*: `author` stays the original speaker, and anybody may mend a line.

`spoken` stays silent, and deliberately gets no sparkle. A transcript is speech
almost all the way down, so marking it would put an icon on every row to restate
the panel's own title — and a mark that appears everywhere is one people stop
reading. Machine-heard is the assumption here, which is why that icon earns its
place on an extraction card and not on this row.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`title` is a global HTML attribute, so a component declaring one shares a name
with a browser feature. Both of these reflected it to the host, which meant the
browser drew its own native tooltip as well: two bubbles for one phrase, ours
immediately and the browser's a second later, unstyled. On we-menu-group it
repeated, in a bubble, the heading already painted on screen.

Dropping the reflection alone would not have been worth trusting. An attribute
set directly — which hand-written JSX does for a custom element — brings the
native tooltip straight back, and nothing about that failure is visible from the
call site: the styled bubble still appears, so nothing looks broken until the
browser's arrives. That is how this survived to be reported three times. The
only fix that cannot recur is to stop using the name.

So `we-tooltip.title` is now `content` — which the existing named slot already
called itself, so the prop and the slot agree — and `we-menu-group.title` is
`heading`, which is what it paints. No alias: there are no outside consumers
yet, and a deprecated spelling nobody is forced off is an inconsistency with a
longer life than the bug.

All 58 call sites move with it, 32 in TSX and 26 in schemas. Two app-shell tests
asserted on the prop by name and move too.

A dev-only warning covers the one route left. The rename stops this element
producing a native tooltip; it cannot stop somebody asking for one by hand, so
a `title` attribute here now says what is wrong and what to write instead,
rather than silently showing two. Same shape as `warnAboutSmil` in we-html, and
for the same reason — the failure was never the behaviour, it was the silence.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The rename stopped this element producing a browser tooltip. It could not stop
one being asked for by hand, and 28 nodes were: `title` on a we-button, a badge,
a Card, a clickable Column, a drag handle. Those drew the browser's own bubble —
unstyled, a second late, truncated, absent on a touchscreen — beside a hundred
that drew ours, so which tooltip you got depended on which part of the app you
were in.

All of them now wrap in `we-tooltip`. Where the control has no visible text the
hint is *also* written as `label`, which we-button maps to `aria-label`: `title`
had been quietly doing double duty as the accessible name on every icon-only
button, so swapping it for a tooltip alone would have left them anonymous to a
screen reader — a regression nothing would have reported. Three layout-bearing
cases needed more care than a wrap: the sidebar header's box moves out to the
wrapper, since a wrapper around a flex child becomes the flex child.

`tooltip-audit` is what stops it coming back. The validator will not — `title`
is a legitimate global attribute, allowlisted beside `id` and `role`, so a
schema naming it is *valid*; the mistake is a design decision, which is the same
reason `role-audit` is a script. It walks the composed tree and reports `title`
on any node whose type declares no `title` prop, that being exactly when the
value falls through to the attribute. Asking the component registry rather than
keeping a list is what tells a tooltip from content: TaskDisplay's title is the
task's, we-iframe's names the embedded document, and neither reaches the host.
A hand-kept exemption list would have gone stale on the next component.

Also: the transcript's edit pencil now stays out of the way until the row is
pointed at. A pencil on two hundred rows is furniture on the ordinary case,
which is reading. Faded rather than unmounted, so the row does not change width
as the pointer crosses it — and `focusProps` brings it back on `:focus-visible`,
without which tabbing would move focus onto something invisible. The row owns
the hover state because an affordance that appears only once you are on it
cannot be found, and props cannot say "when my parent is hovered".

One test asserted its way to a node by fixed depth and broke on the new wrapper;
it now finds the button by what it is.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
## The textarea never grew, and scrolled a single line

Both symptoms were one wrong reading. The shared reset sets line-height to the
theme's value falling back to "normal", and "normal" is not a number:
getComputedStyle reports the string, parseFloat returns NaN, and `|| 0` turned
that into zero. So the cap was zero — the box was sized to nothing and floored
by min-height at one row, which is why it looked right and refused to grow —
and the overflow test was always true, which is the scrollbar over one line.

The primitive now declares a line-height it can measure, falling back to the
token rather than to "normal" and still yielding to a theme that has an opinion;
and the arithmetic defends itself besides, since a computed value is not a
promise. The cap also counts the padding now: scrollHeight includes it and
`line * maxRows` does not, so the box used to stop growing a fraction of a row
early and scroll while it still had room.

The tests stand a browser up rather than trusting jsdom, which performs no
layout — every measurement is zero there, so the faulty arithmetic and the fixed
one agree and a test written against it pins nothing. That is what the first
version of these did. They now supply real padding, a real scroll height and a
line-height of "normal", and both symptoms reproduce against the old maths.

## The mending row

One row that grows, not two that sit half empty under a line that is usually one
line long — the same behaviour as the composer at the foot of the panel, and
Enter saves there too.

Its font is pinned because `size` presets carry type as well as padding: a
compact control also shrank the words, so mending a line made it visibly smaller
than the line beside it. Moving to `md` would have fixed the type by making the
box bigger; the two are separable, so they are separated.

Save goes from success to primary — green is the palette for an outcome, and
this is a plain submit that has not done anything yet. The Keep/Discard pair on
a proposal card keeps success and danger, because there the colour *is* the
decision being offered. Cancel goes from ghost to secondary, since ghost has no
edge until it is hovered and read as a word left beside a button.

## The typed mark

`text-aa` rather than `keyboard`: a keyboard is a grid of small keys, and at
12px they merge into a smudge that reads as "some icon". Two letterforms survive
the size. `translate` means language and `key-return` means submit — both the
wrong claim rather than an unclear one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…the clock

## The 37px box beside a 32px button

The textarea's vertical padding was a fixed 8px whatever its size, and that
number is md's: a 40px control less a 24px line box, halved. Every other size
inherited md's padding and overshot. At sm a 21px line inside 16px of padding is
37px in a row built for 32, so the box stood proud of the send button and
`min-height` could do nothing about it — 37px of content is 37px tall.

`square` on the button would not have helped: it sets width *and* height to the
control height, so the button would have stayed 32 and merely become 32 wide.

The padding is now derived — half of what is left of the control height once the
line box is taken out — so one row is one control tall at every size, which is
what `--we-textarea-control-height` was already promising and only half
delivering. The line box is 1em times the line-height ratio, which sidesteps the
`lh` unit and its browser support; `max()` guards a theme whose type is taller
than its controls, where no padding is the honest answer rather than negative
padding. It is the innermost fallback, so every override still wins.

we-input never had this because a single-line field sets an explicit height and
centres its text — its padding is decorative. A textarea's height is its
content, so the padding is load bearing and has to follow the size.

Doing that split a two-variable line in the CSS and exposed a violation the
theme-reach guard had been unable to see: it reads one match per line, and
`--we-theme-input-padding` had been sharing a line with the textarea's own
override slot, which is exempt. The reading is legitimate — `inputPadding`'s
docblock names textarea as a member of that group, and says why — so the cascade
table now says so too, exactly as button and badge do.

## "Aa" against "PM"

In en-US the clock ends AM or PM, and the typed mark is two letterforms in the
same faint grey right beside it; together they read as one word. The marks move
up to the name and the clock goes last.

Separation rather than a badge or a brighter icon, because neither addresses
what is wrong: the collision is one of shape, and a more legible "Aa" is still a
word next to a word. It is also locale-specific — en-GB renders 14:32 and never
collided — so a fix aimed at the glyph would have been treating one locale's
symptom. The mark is about the line, the clock about the moment; they were
neighbours by accident.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
## Horizontal padding follows the size too

12px at every size, which is md's number again — the same fault the vertical
half had, one axis along. At sm that is a third more of the box given to padding
than its type warrants, and the text sat visibly inset when mending a line.

SIZE_DEFAULTS has always declared the px per size and has never been able to say
it here, because `nativePadding` suppresses the generic declaration. So the sizes
now publish it as a variable the rule reads, which is the same shape
`--we-textarea-control-height` already uses for the vertical.

## The typed mark is a chip

Moving it off the clock was the first attempt and did not work: in en-US the time
ends AM or PM, and the trouble is that two letterforms in the same faint grey
read as one word with whatever sits beside them, not that they were adjacent to
that particular thing. What separates them is a *ground* rather than a gap — as a
chip the mark stops being loose text on the row and becomes a thing sitting on
it. So the clock goes back where it read better, last before the marks.

The badge's neutral fill is already `control-surface` for exactly this, a step
away from the row's `surface-sunken` in either polarity — and this same
transcript row is the case that argued it, in BADGE_APPEARANCE_DEFAULTS.

`(edited)` stays words on the row. It is a claim about whether the line is a
verbatim quote, and a chip is the shape of an aside.

## The speaker

Avatar `xxs` to `xs`, name from `footnote` to `label`. A transcript is a list of
people talking, so who is speaking is the second thing on a row after the words;
at the bottom of the scale a face is a coloured dot and a column of them stops
being scannable. `label` brings a medium weight with it, which is wanted — the
name is what the eye lands on when skimming for who said something.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
## "(edited)" breaking mid-word

Not a fault of that text so much as of the row holding it. A flex item's
automatic minimum size is its content, so a row that names no contract takes the
deficit out of whichever sibling *can* shrink — and the design system defaults
typography to `overflow-wrap: anywhere`, which drops a word's min-content width
to a single glyph. "(edited)" could therefore shrink to nearly nothing and broke
between letters.

The name is the one thing on the row with a sensible narrower form, and it
already asks to be truncated — which needs `minWidth: 0` to happen at all, since
without it the item is never asked to be narrower than its text. So the name
takes the shrinking and the marks refuse it. `flexShrink` goes on the tooltip
wrappers rather than the marks, because the wrapper is the flex item: a badge
refusing to shrink says nothing about the box around it. "(edited)" gets
`nowrap` besides, being one atomic phrase with no narrower form.

That is the same rule the timestamp and badge got in the primitives, applied one
level up where the primitives cannot reach.

## Three smaller ones

The typed mark's glyph goes to 14px, between the badge's own xxs and xs. The
badge's height is fixed per size, so a larger glyph fills it rather than
stretching it.

Save loses its tick — the word alone is clearer, and the `gap` that only existed
to space the two goes with it.

The composer reads at 16px like the rest of the panel. Same trap the mending
field had: the size presets carry type as well as padding, so a compact control
was also a small-worded one, sitting under utterances at 16px and writing what
would become one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A tooltip decorates a child; it is not a region of its own. While its host was
`inline-flex` it took part in its parent's layout anyway, and every consequence
had to be paid for at the call site: it became the flex item its child should
have been, so a `we-badge` declaring `flex-shrink: 0` was protected and the
wrapper around it was not, and a tight row crushed the badge regardless. Grid
tracks, stretch alignment and gap arithmetic all had the same wrapper in the way.

`we-draggable` already had this right, and states the general rule — the wrapper
must not exist as a box, because a real element in between takes the track and
leaves the child laid out against the wrapper instead of the grid. The two
primitives that decorate are now both `display: contents`; the two that *are* a
region, `we-sortable` and `we-drop-zone`, keep their boxes, which is the actual
distinction.

Every previous fault here was fixed by tuning the box rather than removing it.
The host was `inline-block` and wrapped content sat too high, so it became
`inline-flex`; the rail's buttons had no definite width to be 100% of, so the
wrapper was given a width. Both are symptoms of the same cause and both go.

Three things follow from having no box:

- `[part='trigger']` has to be boxless too, or the box has only moved down a
  level and the trigger span becomes the flex item instead.
- Positioning anchors to the slotted child. A boxless element has no rectangle —
  `getBoundingClientRect` collapses to zero at the origin, which would put every
  bubble in the top-left corner. `we-draggable` reaches for the assigned element
  for the same reason one concept along, where it is focus rather than geometry
  that the missing box takes away.
- Hover moves to `mouseover`/`mouseout`. Enter and leave do not bubble, and
  whether a `display: contents` element is on the ancestor chain they walk is a
  corner of the spec no consumer should have to bet on. The containment check is
  the usual cost, and stops a crossing between the trigger's own children
  flickering the bubble.

The one new silent failure is that a geometry prop written on the host now does
nothing, which is exactly the shape this codebase keeps hunting — it validates,
it renders, and it is inert. `warnAboutBoxlessLayoutProps` says so in
development, for `we-draggable` as well, which has carried the hazard unreported
since it was written.

Three workarounds go with it: the sidebar header's box moves back to the Column,
the rail's width goes, and the two `flexShrink`s on the transcript's marks are
unnecessary now the badge and the text are the flex items themselves.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every tooltip appeared in the top-left corner of the screen. Making the wrapper
boxless moved the positioning anchor to the slotted child, and the slotted child
is not what `assignedElements` hands back: the schema renderer wraps every node
in a `display: contents` div, so what came back was a wrapper — boxless for
exactly the same reason the tooltip now is, with a zero rect at the origin,
which is precisely where the bubbles went.

`we-sortable._resolveItem` sees through the same wrappers and its docblock says
why; it resolves by `data-we-id`, which a tooltip's trigger has no reason to
carry. So this descends instead: through the light DOM until something takes
part in layout, then into the shadow root, which is where a boxless custom
element keeps its own drawing — `we-icon` is `display: contents` and renders an
svg inside, so a tooltip on a bare icon anchors on that svg.

The tests could not have caught this and now can. They mounted the child
directly, which is not how the app mounts anything, so the suite stayed green
while every tooltip in the app was in the corner. They now mount through a
`display: contents` wrapper the way the renderer does, at one level and at two,
and both fail against the naive version.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
## The row was right-aligning itself

My doing, two commits ago. Giving the name `flex: 1 1 auto` to let it absorb the
row's shrinking also let it *grow*, so it took every spare pixel and pushed the
clock, the marks and the pencil to the far edge.

The grow half was never wanted. The name only needed permission to give up
space, which is `0 1 auto` — shrink, do not grow — with `minWidth: 0`, without
which a flex item is never asked to be narrower than its own text. The row packs
left again and still cannot crush its marks.

## A transcript is not a stack of tiles

Each utterance had a fill, a radius and padding of its own. A hundred of those
is a wall of boxes, and the padding pushed the words much further apart than
telling one line from the next requires — which the gap between rows was already
doing on its own.

It also let the panel fight its own ground: a docked panel can float over the
content on a translucent surface, and a column of opaque sunken rectangles
cancels that, leaving the glass visible through the gaps and nowhere else.

The typed mark survives losing the fill behind it because the badge's neutral is
`control-surface`, chosen as a step away from *both* `surface` and
`surface-sunken` — the note there said "the row's surface-sunken", which was
true when it was written and is no longer the reason it works.

## Two tooltips

"Typed into the transcript, not spoken" to "Typed, not spoken" — the panel is
the transcript, so the middle of that sentence was restating where the reader
already is. "Fix these words" to "Edit text", which claims less: the button
opens an editor, and whether what is there is wrong is the reader's call.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The gap is the only thing separating one line from the next now that a row
carries no fill or padding, so it goes a step up the scale to do that job alone.

## The flash

Leaving the editor makes a row shorter — a field and two buttons become one line
of text — so it reflows out from under a pointer that was resting on Cancel. The
read view then mounts with `pointerOnRow` still true, the pencil fades in over
200ms, and by the time it has the pointer is outside the shrunken row, so it
fades straight back out. Long enough each way to read as a glitch rather than as
anything deliberate.

Both exits now say the pointer has left. That is a small lie when it has not,
and it corrects itself on the next mouse move — where the flash corrects itself
by being wrong twice.

The test for it matches the handler each clear belongs to rather than counting
them. A count passes on two of three, because the row's own mouseleave writes
the same token: the first version of this test was green with the fix removed
from Cancel, which is the exit that was reported.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`sm` is the compact size — 32px, right for a control tucked into a dense row of
something else, wrong for the one thing on the panel a person is meant to type
into. Both halves move to md, since a field and the button beside it have to
agree about how tall the row is.

The `fontSize: '300'` pinned here goes with it. That override existed only to
undo `sm`'s smaller type; md's own preset already reads at 300. Worth noting as
the shape of the trap rather than as a tidy-up — the size presets carry padding
*and* type, so choosing a compact control silently chooses small words, and the
fix is either to pin the type back or to stop asking for the compact control.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Your two pixels, and they were mine. `we-input` and `we-button` put their height
on `[part='base']`, where `box-sizing: border-box` counts the border as part of
it. This put its floor on the inner textarea instead — so the *text* was one
control tall and the base drew its 1px border around that, making the whole
control two pixels over at every size.

The earlier "one row is one control tall" fix was therefore half a fix that
looked like a whole one: it made the inner box exactly right and left the thing
a reader actually sees exactly wrong, which is why the row still did not line up
with the button beside it.

So the floor moves to the base, and the derived padding now aims at the control
height *less* the border, twice — or the text fills the whole floor and the
border pushes the control past it, which is the same two pixels from the other
direction. CSS cannot read a width back out of a border shorthand, so
`--we-textarea-border-width` names it once, where a theme changing the border
can set it alongside.

The send button gains `square`, so an icon-only button is a square at the row's
height rather than a rounded rectangle with an icon adrift in it.

Two notes on the tests. The one pinning the floor asserted it on the inner
element — it was pinning the bug — and now checks both parts, so putting it back
fails. And it strips comments before asserting: these blocks argue about the very
declarations being tested, so a plain substring match found the word `min-height`
in prose and passed regardless of the CSS. That is the same reason
`themeReach.test.ts` reads declarations rather than source.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
"Add a note to the transcript…" to "Type a message…". A placeholder is read at a
glance, and the panel it sits in is already headed "Transcript" — so naming the
destination was the box explaining where it was.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The same thing was a "comment" in the store, a "note" in the docblocks and the
tests, and a "message" in the placeholder a reader actually sees. So
`addComment` becomes `addMessage`, `local.comment` becomes `local.message`, and
the prose follows.

"Comment" was the worst of the three and worth losing on its own account: it
suggests commentary *on* something, and the whole argument in that method's
docblock is that this is not that — it is a `TextBlock` among the utterances, a
line of the transcript indistinguishable in kind from a spoken one. The name
implied a relationship the data deliberately does not have.

Cheap now and not later: the module has no outside consumers, and
`modules.transcribe.addComment` is public API the moment one exists — the same
argument that settled `we-tooltip`'s `content`.

`(typed)` and `(corrected)` in the exported transcript stay as they are. Those
name the *source* of a line rather than the thing itself, which is a different
question and one the marks in the panel ask the same way.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Layouts, "Remember this arrangement…" and "Fork as a new template" are all built
and none has been exercised. Three unproven entries at the top of the one menu
people use to change template is a busy picker charging every reader for a
feature nobody has tried yet.

All three live in `layoutsSection`, so one term in its condition holds all three.

`sessionStore.devTools` rather than deleting the call: the section goes on being
type-checked, validated and walked by the audits, and it stays reachable for the
testing that is the actual blocker — it is on in a development build, and
Settings → Developer turns it off to see the picker as a reader will. A
commented-out call would rot instead.

Take the `devTools` term out of the condition to ship it; nothing else in this
file is conditional on it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The `devTools` gate hid it from everyone except the person who wanted it hidden:
that switch is on in a development build, so the section stayed on screen and
only vanished where it had never appeared — the default template declares no
panels, so its layout condition was already false. Working as built, and useless.

The mistake was answering the wrong question. A condition asks "should this show
*now*", and every honest answer to that is something a reader can reach. What is
true here is "this is not finished", which nothing at runtime should be able to
change — so it is a flag at the composition level and the section is simply not
in the tree.

Not deleted, because the blocker is testing and a deleted section cannot be
tested. `layoutsSection` stays referenced from the guarded spread, so TypeScript
keeps it compiling and a rename cannot leave it behind. The trade is that the
schema audits walk the composed tree and no longer see it — acceptable while
nothing it paints is on screen to be wrong, and flipping `LAYOUTS_READY` puts it
back under all of them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The canvas route sat visibly darker than the tasks and events routes beside it.
`GraphView` defaults its background to `neutral-0` where the template around it
paints `page` — one step apart on the neutral scale, and a scale position
follows the theme's polarity, so `neutral-0` is the *darkest* end in a dark theme
where `page` is not.

Every consumer takes that default: no call site in the repo passes `bg` at all.
So one word aligns every graph in the app with whatever the route it fills is
painted with, and none of them needed changing.

A step also cannot follow what a theme *decides* a page is, and the contrast
corrections at apply time skip it entirely. `role-audit` exists to catch exactly
this and structurally cannot: it walks composed schema trees, and this is a
component's own style. The resolver already understood roles — only the fallback
never used one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Naming already worked and the workshop simply never showed it: the call record is
a CollectionBlock, `title` and `description` are its fields, and the Cards view
has had a full editor for both. This surfaces the same fields in the template
where calls are actually worked in.

## The calls panel

A row showed only a timestamp, so a list of meetings could be told apart only by
date — which is reading by elimination. The title goes above the time, falling
back to the plain "Call" a card starts as. That fallback is load-bearing rather
than cosmetic: neither edit form puts a `required` rule on the title, so clearing
a name has to leave something behind.

## The pill

Its own, beside the switcher rather than inside it. That bar is content-sized and
centred, so a title in it would move Canvas, Tasks and Events sideways every time
somebody renamed a call or opened one with a longer name — nav you cannot build
muscle memory for is worse than nav you have to look at. Two pills of the same
family, each sized by its own contents. Left rather than centred for the same
reason: this one grows, and a centred box that grows moves at both ends.

`participants` is the roster, so the faces are everyone who was *in* the call
whether or not they said anything. Not live presence, which is empty for every
call being read back — and not the set of people who spoke, which would drop
exactly the attendee a reader is most likely to have forgotten. The relation is
untyped, so it comes back as DIDs unhydrated, which is the shape `peopleRow`
takes; it does the profile join and the hover roster already.

The record query carries `when`, because an unresolved operand is pruned rather
than sent and pruning widens: a `where` that lost its id would ask for every
CollectionBlock in the space and hand back the first, which is another call's
name shown with confidence.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
jhweir and others added 26 commits September 10, 2026 18:19
A pass somebody pressed for is written by the run that returned it — it
has the turns, the target list and the outcome in hand when it resolves.
A watched pass has no such moment: it runs inside the executor and is only
ever reported, so the record has to be written by whoever is listening to
the report. That is InterpretationStore, and it is the only place
subscribed to it.

The rule for which rows become records is `watchPassRecord`, beside the
other rule about this feed that was worth deciding away from a store.
Each of its four refusals removes a row that should not exist: a one-shot
its own run already wrote, a peer's row that would make the log a history
of who was watching, a pass with no collection to hang off, and one still
running.

It reads the merged row rather than the event that settled the pass. The
prompt arrives on `llmRequestSent` and the ids on `processed`, several
steps apart, so reading the settling event alone stored every watched
pass with an empty exchange — which is the one thing this was added to
keep.

`recordWatchPass` resolves the call's target list host-side, since what a
call looks for is three layers of state and the executor is told class
URIs rather than model names. It is classified WIRING: a template writing
extraction history would be forging somebody else's run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The durable history listed a pass's outcome, its runner and its time, and
stopped there — the exchange was in the live bar, which is thrown away on
every reload. So the log could say a call had been read eight times and
nothing about why any of the readings decided what it did, which is the
question people actually have while this is being built.

Each row now carries a glyph for what started it, and opens onto the same
two panes the live bar shows, with the same defaults: prompt closed,
response open. `codePane` takes the row key as a parameter rather than
hardcoding the live bar's processor id, so the two lists read the
exchange through one component — the point of storing both kinds of pass
is that they stop being told apart by which surface is on screen.

The disclosure is offered only where there is something under it. A
record written before the fields existed carries neither half, and an
absent property is absent rather than defaulted, so those stay plain
lines instead of a press answered with an empty box.

Also widens the harness type a store test asserts against, which had been
failing the package's typecheck.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`emptyState` required a `label` and also accepted a `message` that
replaces the sentence the label builds, so a caller with a better
sentence had to supply a label that was never read — and one that left it
off failed the typecheck in a package that does not run one on every
change. WorkshopTemplate had been sitting in that state.

The options are now a union: one of `label` or `message`, still allowing
both, since several callers pass a label deliberately as a note about
what the list is even where the sentence says something better. What is
refused is neither, which used to render "This space doesn't have any
undefined."

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Picks up ExtractionPass's three new fields.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Four changes to how the two transcribe panels are laid out, and one bug
they turned up.

Extract now moves to the top right of the extraction panel, where the
transcript panel keeps Transcribe and Continue. It is the one press this
panel offers, so two docked panels side by side now have their actions in
the same corner rather than one headed and one buried in a body. It needs
`local.spoken` to know whether anybody has spoken, and a `$queries` entry
is only readable inside the node declaring it — so that query is hoisted
to the panel root.

The auto-extract button goes under the chips instead of beside Extract
now. The pairing was misleading: the two look alike and answer different
questions, and "extract as the call goes" is a fact about the list of
chips it now sits beneath.

The transcript panel's two header buttons wrapped their words in a
`footnote`, which is a step below the size a `sm` button sets for its own
label — so they were smaller than every other button in the module,
including the Extract now they now sit opposite. The wrapper is gone; the
button already knows what size its text is.

The readings gain a Logs heading, in the treatment every other region
here wears, and a line saying there are none yet. The collapsed count was
a bare line between the chips and the results belonging to neither, and a
call nobody had read showed nothing at all — so the log had no presence
until it had content.

The bug: the "More <kind>" button in the Extracted list was a sibling of
the node declaring `shown` and `found` rather than a child of it, so both
names were out of scope. `count(undefined) >= undefined` is false, so it
has never rendered; had it, its `$setLocal` would have warned and
no-opped. The validator says nothing about local reads until something in
the tree declares a local — an empty scope is knowable, an absent one is
not — and this panel declared none above that row until the hoist above
gave the walk a scope to check against.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Auto extract goes to the header and Extract now goes under the chips,
reversing where the last commit put them.

The reason the swap reads better is what the panel one over does with the
same corner. Transcribe and Continue are both modes — a state a press
changes and which then persists — and "Auto extract: on" is that shape.
Extract now is an action, and it acts on the list of chips, so it sits at
the end of that list rather than a panel away from it.

The `spoken` query goes back onto the well with it. It was hoisted to the
panel root one commit ago only because its one reader had moved into the
header, and a hoisted query is readable only inside the node that
declares it. Back down it is also skipped on a node with no model, which
the root could not do — everything in that well is already behind that
gate.

The two tests that assert the arrangement now key on the actions rather
than the button labels: the panel's help text names both buttons, sits in
the header, and so matched either way round.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The phone button on a call card chained two actions: continue the call,
then `modules.transcribe.resume` to tell the transcriber which record to
adopt without waiting for presence. That second method no longer exists.
It went when the call module started marking a continued call's activity
`continued`, which the transcriber acts on itself — and an action naming
a missing module method resolves to nothing, so the call has been a
silent no-op under a comment saying it was load-bearing.

The two tests guarding it are turned around rather than deleted. They
existed because `resume` was the most dangerous of the three ways into a
call: it re-pointed the live transcript at whatever record it was given
and announced the claim, and peers adopt an announced record over their
own, so a stray click on an old card moved everybody's transcript into an
old meeting. The click-time branch that kept it unreachable mid-call is
no longer needed, but the hazard would come back with the action — so
they now assert it stays absent.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The Continue button lived in the transcript panel's header, and being
there was a category error that read as an asymmetry: two panels sit side
by side about the same call, and only one of them offered the way into
it. A panel's header control is for the thing that panel is about, which
is why Transcribe belongs beside "Transcript"; picking a call back up is
about the call.

It is now `call.continueCallButton`, published by the module whose action
it is, and placed at the start of the Workshop shell's call pill, before
the name. Leading rather than trailing because it is the one thing on
that pill that is an offer — the pencil and the roster describe the
meeting, this changes what you are doing.

Three consequences worth stating. The offer now survives both panels
being closed, which the panel copy could not: closing the transcript took
the only visible way back with it and left the module rail, which nobody
finds. Any other interface that draws a call's name gets the gate, the
wording and the refusal-while-running with one `$part` line, and renders
nothing where the call module is off. And it is icon-only, because the
pill sits in a fixed band already holding a title, an edit button, a
divider and up to four avatars — so the accessible name is stated rather
than inherited, from the same expression as the tooltip.

The part deliberately declares no `subject`. Substitution is whole-token,
and two of its three expressions mention the record inside a longer
sentence, so a subject would rewrite the action and leave the wording and
the guard talking about the address. Half a rewritten sentence is worse
than none, and the button has one honest meaning: pick up the call you
are looking at.

The panel's three rules about the button move to the call module's suite
with it. The panel keeps one test, asserting the button has not grown
back, and one that the empty-transcript placeholder still says in words
what can be done — which matters more now that the offer is not directly
above it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
It was a small control with a 16px glyph, which put it a step below
whatever it is placed next to. The Workshop pill's edit button is a
default-height control carrying an explicit 18px pencil, so two buttons
of the same kind sat at two sizes and the offer was the quieter of them.

The box now matches, which also matters where a pill reserves a band
measured from a control at that height. The glyph goes to 20px: past the
pencil, because this is the offer rather than one of a set of
adjustments, and short of the 24px a default control gives a nested icon,
which is for a glyph that is the whole button and would shout beside a
heading. The call card's phone button sizes past its own row for the same
reason.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Four changes, all from testing the pill with a second control in it.

Both glyphs go back to filling their buttons. Neither is written as
`24px`: a default-height `we-button` sets `--we-context-icon-size` to
`--we-size-sm`, which is 24px, so removing the override is both the
documented way to ask for it and the way it stays right if the scale
moves. The pencil was pinned at 18px earlier in this branch, reported
from the running app, on the reasoning that a full glyph is heavy beside
a heading it belongs to; the call button was then pinned at 20px to sit
between the two. With two controls in the pill that reasoning inverts —
the 40px box is what the eye aims at, and a glyph that does not fill it
reads as an afterthought rather than as a quiet control.

The pill's extra inset moves from the leading edge to the trailing one.
It was `pl`, from when a title led and a heading wants room from the edge
it starts at. A button leads now and carries its own padding, so the
inset was stacked on padding already there; the roster that ends the pill
has none of its own, which is where the room was wanted.

The vertical rule between the name and the faces goes. It separated two
things nobody was going to confuse, and in a box this small it was a
third kind of mark competing with the gap already doing the job.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both spent most of a call's life as a heading over nothing, which is the
usual state rather than the exceptional one: a call nobody has read yet
has no readings and no results, and the panel said so twice.

The readings are gated on their own count, and the query answering it
moves up to the panel body. A section that unmounts itself cannot own the
query that decides whether it should — it would stop asking and never
come back — so the query lives one level above and the section is a plain
`$if` over the result, which renders no node at all when empty. The
placeholder added a commit ago goes with it: a sentence apologising for
an empty list earns its place when the list is usually full, and this one
starts empty every time.

The results could not be gated the same way, and that is worth writing
down. Each kind is its own subscription, and a schema cannot sum a list
of queries whose length it does not know, so nothing above the groups can
ask whether any of them found anything. The single "Extracted" heading
has therefore become one heading per kind, inside the group, where the
question is answerable — and it is named from the model, so a shape a
community defined this morning is titled with nothing written for it. A
call with nothing extracted now shows no heading at all.

The cost of that is real and accepted: the headings went into the scroll
region with their rows, so a long list scrolls its own titles away. The
alternative was a permanent heading that is wrong more often than it is
useful.

Sections also sit further apart. At `300` a band sat the same distance
from its neighbour as a heading sat from the rows it names, so five
sections read as one long column; `400` between them against `200` inside
them is what attaches a heading to what it names. The kinds inside the
scroll region get the same figure, since they are now sections too.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
It disappeared whenever a call was running, which made it the only thing
on the pill that came and went — and it went at the moment the pill has
most to say.

Two causes. The gate carried `!active`, so being in any call removed it.
And it read the call out of `?call=` alone, which is how somebody opens a
meeting that has *finished*: a surface showing a live call usually has no
such parameter, so the control blanked for the whole of every call. It
now uses the same fallback every other surface about a call uses.

So the button is always there while there is a call to be about, and its
four meanings are carried by colour, label and whether it can be pressed.
The call you are in is red — the fill role, and the same record test the
calls list uses to mark its own live row, so the two cannot disagree —
and the press goes to the call. A call running elsewhere disables it with
a reason rather than removing it: continuing this one would tear that one
down and re-point every peer's transcript at this record, which is the
call store's rule and what `goToCall` refuses in the same words. With
nothing running it picks the call up, or joins it where somebody else is
already there.

The press branches in a handler array rather than in the node, so it
reads the store at the click. That is the point of a button that survives
a call starting and ending underneath it; choosing at render time would
bake in whichever state the pill first drew in. The calls list branches
its own phone button the same way.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The readings appeared above the "Logs" heading and again below it. The
one above came from the live feed's collapsed "N extractions processed"
fold; the one below from the stored `ExtractionPass` records.

I caused it. Before the two kinds of pass were unified, the live feed
carried the watched ones and the durable log carried the pressed ones, so
between them each pass was listed once. Making both complete made them
duplicates of each other, and nothing on screen said which list was
which.

The durable one wins on every count: it survives a reload, it is scoped
to the call by containment rather than to whatever this session happened
to observe, and it carries the outcome, the trigger and the stored
exchange. So the live feed drops its settled half and keeps the one thing
the log cannot give — a pass while it is still running, whoever started
it, with its phase and its elapsed clock.

Its gate moves from `hasActivity` to `runningCount` for the same reason.
`hasActivity` deliberately counts settled rows so the readout did not
vanish the instant a pass finished and take its result with it; with the
result now written down a few hundred pixels below, holding it here is
what produced the second list.

The footnote about prompts staying on each person's machine goes with
those rows. It explained why a peer's *finished* pass would not open, and
`detailWithheld` is computed over settled rows alone, so it could only
have appeared with nothing on screen to explain. It had also stopped
being true: a pass now writes its exchange into the graph, so that
setting governs whether this readout offers it rather than whether it is
shared at all — which remains the thing to revisit on the setting itself.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two things reported from the running app.

The prompt and response panes in the log came out as a one-line trough
with a horizontal scrollbar. The record holds both verbatim, which is
right for a record and unreadable as a pane: each is a single unbroken
line, and indenting is the difference between a document and a string.
The live feed already does this in its store, because a store can call
JSON.stringify; the durable rows come from a query, where there is no
store in the way, and `codePane`'s own option says it wants "the
already-indented text".

So the host lends `formatJson({ text })`, which is where the routing
table sends computation the closed expression library lacks. Two
alternatives were worse: storing the indentation would put whitespace
into a field that already replicates to every member of a space, and
teaching CodeEditor to silently reformat its own content would be a
hidden behaviour the next caller has to discover. Text it cannot parse
comes back unchanged, deliberately — a model that answered in prose or
fenced its JSON is exactly the failure somebody opens the pane to
diagnose.

And a pass now reports "3 things found" or "Nothing found". A record is
what the graph stores; a thing is what the reader asked to be found, and
the list above these rows is already headed "Things to extract". "No
records" also read as a failure to write rather than as a pass that
looked and found nothing, which is an ordinary outcome and the one that
line reports most often.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every suggestion was a `we-alert`, which always draws a status glyph. It
has no way to be told not to, and that is deliberate: its own note points
at WCAG 1.4.1, which asks that colour never be the only thing carrying a
status, and a glyph per card is how a lone alert earns that.

A grid of them is the case the rule was not written for. Eight cards with
eight identical warning triangles say one thing eight times and crowd the
titles they sit beside.

So the redundancy moves up to the heading, where there is one of it. The
icon now sits beside the count, and nothing is left relying on colour
alone: "Awaiting your call" is a text label, and the mark and number are
next to it. The cards become a Column carrying what `we-alert`'s `accent`
appearance drew — a surface, ordinary text, and a three-pixel rule in the
status at full strength rather than in its tint — so a card here and an
alert elsewhere still look like the same family.

The count goes solid. A soft badge is a tint, and a tint of the warning
hue against a panel was dark enough that the number inside it stopped
being the thing you noticed; solid puts it on the status at full
strength, which is what a count somebody is meant to act on should look
like.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The results were grouped by model with a heading per kind. That grouping
came from the shape of the data rather than from anything a reader
wanted: a record has a type and a query takes one entity name, so a list
of mixed kinds is a list of queries, and the groups were those queries
showing through. Each card already says what kind it is.

So it is one list again, under one "Extracted" heading, and the cards are
the same cards as "Awaiting your call" — same kind row, same headline,
same detail fields — with a success edge instead of a warning one and no
answer to give, since these are already agreed to. No pencil either:
editing a suggestion is part of answering it, and a record on this list
has been written, so changing one is an ordinary record edit belonging
where records are edited.

That is a shared definition rather than a copy. `CardShape` names the one
thing that differs between the two rows — a suggestion carries its values
as a `{ name, value }` list because nothing has been written yet, a
record has ordinary properties — and the kind row, the title block and
the detail rows are built from it. Everything else already came from
`recordStore.displays`, which is the same lookup for both.

Each kind still needs its own node for its subscription and its page
counter, so those nodes are `display: contents` and every card is a
direct item of one grid. What this cannot do is interleave: cards come
out grouped by kind in the order the targets are listed, because a schema
cannot merge a list of queries whose length it does not know, let alone
sort across them.

Also: the type glyph goes from `xs` to `sm`, a step up from the caption
beside it, since it is the fastest way to tell a task card from an event
card. The warning glyph beside the count is gone — the heading says
"Awaiting your call" in words, which is the whole meaning, and a triangle
next to it put an alarm on an ordinary part of using the panel.

And one empty box that was costing a gap: `extractNowControl` renders
nothing outside a live call, but the row wrapping it stayed, so the well
went on reserving a gap for a control that was not there. The button
aligns itself now. The redundant subject gate around the log went with
it, since that query already carries its own.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An extracted task listed Status, Priority, Comments, Signals,
Participants, Calls and Mentions, and the last five had nothing after
them. `WeNode` declares those five relations, so every model in the space
inherits all of them, and a card built from the declaration drew a
caption for each.

The filter was already there and could not see them. A record carries a
to-many relation as a list, an empty list is truthy, so a truthiness test
keeps every one. `count` answers for a list and gives 0 for anything
else, so it cannot stand in for the scalar test either — the `many` flag
on the field is what picks between them.

That test now belongs to the card shape rather than to the card, because
"empty" is a different question for the two rows this panel draws. A
suggestion's values arrive as a list of what the pass proposed, so a
field it did not propose is absent rather than present and empty, and the
plain test is exactly right there. Only the record side needs the branch.

Status and Priority stay: they carry their model's defaults, which is a
value, and a task's state is worth reading on a card about it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An extracted task and a transcript line were indistinguishable: both hang
off `we://children`, because interpretation was handed the containment
predicate and parented its results with it. So "what did this call
produce" could not be asked. A query names one entity, an untyped
`include` carries no class constraint, and a call's children are mostly
transcript — which is why the panel asked "all the tasks, then all the
events" and got a list grouped by model rather than by anything a reader
wanted.

`CollectionBlock.extracted` is that question, made askable. Untyped, so a
shape a community defined this morning is included without being named
anywhere; unordered, because nothing arranges what a pass found and
`createdAt` already says when each record was made.

It is written beside containment, not instead of it. `children` is
ownership and the call's board gathers through it, so a record that
stopped being a child would vanish from the surface it exists to appear
on. These are two claims about one record — it is part of this
collection, and a model proposed it from reading this collection — and
they diverge exactly where it matters, since a task somebody composes
into a call by hand is contained and not extracted. The same split
`arranges` already makes one level over.

The port takes the predicate as an argument rather than knowing it, for
the reason it already takes `parent` as one: a backend that hard-coded it
would be deciding what a relation in somebody else's graph is called.
Omit it and nothing extra is written. Both paths write it — the one-shot
run after it parents, and the watch's listener as each batch lands — and
in both the containment link goes first, since a record that is parented
but unmarked merely loses its provenance where one marked but unparented
is invisible to everything that reaches content by traversal.

Existing extracted records carry no such link and are not backfilled, by
agreement: the data is recreatable.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The results list becomes what it looked like it already was: one list, in
the order the records were made, with a number beside its heading.

All three follow from the provenance link rather than from any change
here. Reading through it means one subscription instead of one per model,
so the rows can be ordered by the backend rather than arriving in
whatever order the models were listed in, and there is a single count to
put in the badge. The section can also hide itself on the right question
now — "is there anything here" rather than "does this call have any
models ticked", which was the nearest thing a node above the groups could
see.

The card keys on `__subjectClass`, the class each row turned out to be,
which is the wire contract for a polymorphic read. It replaces a name the
list was built from, and it is deliberately not `item.type`: that is a
real property on a CollectionBlock, so it means something else on some of
the rows a call can hold.

The per-model paging control goes rather than being fixed. It never
rendered — it sat beside the node declaring its state instead of inside
it — and it existed to bound a query that could not say what it wanted.
Through `children` an unbounded read means the whole transcript; asking
for what a pass wrote is small by construction.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
"New call" was the whole of `goToCall`, and `goToCall` has a branch that
continues the call named *in the address* when nothing is running. That
is how the module rail picks up the meeting you are reading, and it is
the wrong reading of this button: with a call selected in the list below
it, pressing New call reopened the selected one.

Narrowed rather than swapped to `startCall`, because the other two
branches are still wanted. Somebody already in a call is taken back to
it; somebody who is not, in a space where a call is running, joins that
one instead of opening a second beside it. Only the third case starts
anything, and only that case now reaches `startCall`.

Branched in the handler rather than in the node, so one button renders
either way and the conditions read the store at the press rather than at
whichever paint the panel opened on.

The label gains its missing third word while it is here. With a call
running that this agent had not joined, the button said "New call" and
joined it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The pill was the whole of the top-left region, so it appeared when a call
was named and vanished when one was not. That made the corner people had
learned to look at the corner that was sometimes missing — and calls had
no permanent address on screen at all: the module rail's launcher is the
least discoverable control in the app, the calls panel is a section
somebody can close, and this flickered.

The region is fixed now, and the conversation is a place: starting one,
seeing the current one and picking an old one back up all resolve to the
same corner.

Two children rather than one that swaps. A region showing the button *or*
the pill has a hole, and it is a bug we had already fixed once — reading
a finished call is exactly when somebody wants to start a fresh one, and
swapping would mean deselecting first to reach the button. So both are
present, and the single state that quietens the button is being in a
call, where a second is refused anyway and the pill's own control already
says "go to the call".

The pill leads. When there is a call it is the subject and holds the left
edge, so the offer of another follows and shifts as a title grows, which
is the cheaper of the two to move. It also yields when the region runs
short: a title truncated a few characters earlier beats a button pushed
off the edge, which needed `minWidth: '0'` on the pill as well as the
obvious `flexShrink` on the button.

`chromeReserve` is unchanged, confirmed rather than assumed. Its width
describes the centred bar alone, and the height is still one control in a
padded row, since the button beside the pill is the shorter of the two.

The switcher does not move either way: it is centred on the content,
computed from the sidebar and dock insets, so a neighbour that changes
width is nothing to it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Starting a call failed with "invalid type: map, expected a string". The
handler was written the obvious way, with no `args`, and that does not
call a method with none — it forwards the handler's own arguments, so the
click passed a PointerEvent where `startCall` expects an optional anchor
id, and the backend refused the write. `args: []` would not have helped
either; an empty list reads as "no args given" and forwards the event
too.

It only appeared when this button stopped calling `goToCall`, which takes
no parameters and so swallowed the event harmlessly. It now passes the
empty string, which is how `startCall` already spells "no anchor" — a
call about the space rather than about some node in it.

The button also takes its size, because it is placed at two scales. In
the corner it is a default control, matching the pill's own; in the calls
panel header it stays small, since `panelShell` reserves the height of a
small control there and a default one would make that header taller than
every other panel's.

And the corner holds the pill's height whether or not the pill is there.
Without it the region was as tall as whatever it contained, so with no
call the button sat at the top of the band while the switcher beside it
sat centred in the full height — both pinned to the same offset and
looking misaligned. Written as the arithmetic the pill arrives at rather
than as a number, including the offset a theme can add to control
heights.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A new call's first moment is somebody speaking: the unsaved preview
appears and the record still holds nothing, so "Nothing has been said
here yet." sat directly above the words being said. Two claims about one
moment, and one of them false.

It also cost the handover its footing. The placeholder sits above the
preview, so a line saving took the placeholder and the preview away
together while the row arrived, and the column moved twice on the way —
which is the jump-up-then-back-down before the saved message appears.

Gated on the same expression the preview gates itself on, so the two
cannot disagree about whether there is a preview, and wrapped once rather
than added to each branch: there are two of them and one has no condition
of its own, so a term would have had to become a condition and the pair
would have drifted.

Reversible for nothing. A buffer that never saves takes the placeholder's
reason away with it and the sentence comes back.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The start button leaves the corner whenever a call is named there.

It was present alongside the pill for a commit, on my argument that
reading a finished call is exactly when somebody wants to start a fresh
one, so a corner that swapped would make that state need a detour. The
argument was sound and its premise was not: the calls panel keeps its own
start button, and clicking the selected row there deselects it and brings
this one straight back. The detour is a click somebody is already making,
and what it buys is a corner that names the call rather than a button
crowding it.

`!CALL` rather than `!active`, which it subsumes: being in a call sets
the record `CALL` falls back to, so the pill is there and this is not.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
It had a scroller on the suggestions and another on the results, each
`flex: '1'` inside a panel of fixed height. So the two competed for that
height, the chips and the readings between them were pinned in place, and
reading a long list meant a small window moving inside a small window —
while whichever list you were not reading went on taking room.

One scroller over the whole body instead. The header stays put because it
is `panelShell`'s and sits outside it; everything under it moves
together, which is the shape a docked column wants.

The cost is that a long list pushes the rest below the fold, so each of
the two headings gains a caret beside its count. The count is what says
whether folding is worth it. `$if` rather than `$animate`, deliberately:
the point of folding a list here is that it has got large, and keeping a
screenful of cards mounted behind a clipped wrapper would fold away the
only thing it cost. Nothing is lost by unmounting — the suggestions come
from a store, the results from a query declared above, and an open
editor's draft lives in the store rather than in the card.

Which fold is open persists on the device. It says how somebody likes
this panel arranged rather than what they are looking at, so it must not
travel in a shared link.

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

netlify Bot commented Sep 11, 2026

Copy link
Copy Markdown

Deploy Preview for coasys-we ready!

Name Link
🔨 Latest commit da62953
🔍 Latest deploy log https://app.netlify.com/projects/coasys-we/deploys/6aa3ce825768fb0007a38f46
😎 Deploy Preview https://deploy-preview-195--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.

`tsc --noEmit` fails in five places here, from two causes, and neither
shows up in a test run or a build — which is why they reached CI.

`import.meta.env.DEV` in `tooltip.ts` and `boxless.ts` has no type: this
package carries no `vite/client`, and should not. It is the
framework-neutral layer, loaded into hosts that use no bundler at all, so
taking a build tool as a dependency to describe a flag would be paying at
the seam the layer exists to keep clean. `@we/module-call` settled the
same question in `devPeers.ts` with a cast, which is erased at compile
time and emits the same `import.meta.env?.DEV` a bundler replaces. One
`DEV_BUILD` in `boxless.ts`, where this package's dev-only diagnostics
already live and which `tooltip.ts` already imports from, so the reason
is written once rather than beside each read.

The other three are `X as Y` where neither type is the other's
supertype. `tooltip.test.ts`'s `mount` helper did not list `open` in the
element type it returns, so both hover tests re-cast what it handed
back; `open` is a declared reactive property, so the helper says so and
the casts go rather than widening. `textarea.test.ts` is the odd one:
`submit` is a name the DOM already owns, so the listener is typed
`SubmitEvent` where every sibling file's `moved`, `dropped` and `change`
listener gets a plain `Event` and casts to `CustomEvent` cleanly. That
one needs the `unknown` step, with a line saying why it differs from the
spelling used everywhere else in the package.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@jhweir
jhweir merged commit 131cb40 into dev Sep 11, 2026
10 checks passed
HexaField added a commit that referenced this pull request Sep 11, 2026
Dev added options to DisplayField (PR #195). Regenerate the three
context files whose stores reference now includes the field.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
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