Feature: Transcript and extraction improvements - #195
Merged
Conversation
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>
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>
✅ Deploy Preview for coasys-we ready!
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>
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.
ExtractionPassgainstrigger,promptandresponse, and both kindsof 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
shareExtractionDetailnow governs whether thelive 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.extractedis a second, non-owning relation writtenbeside 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),busyWhenon a modulelauncher,
notifyon the module contract, aformatJsonhost function,emptyStateacceptingeither a label or a message, and a tooltip audit that made
we-tooltipthe only spelling.Known follow-ups
fetches every child and filters client-side. Upstream, and not needed for this branch.
shareExtractionDetailmeans less than it says now — see above.provenance link is what avoids that, so nothing should go back to per-entity queries here.
includecrashes on this relation,and the schema reference did not say a polymorphic include cannot be class-filtered.
Test plan
pnpm buildclean, tree not dirty afterwards (CI's drift gate).pnpm testgreen.validate:schemas,role-audit,surface-auditclean.@we/entities generate:typesand@we/backend-ad4m generate:classes.suggestions, read the log and open a prompt, pick a finished call back up.
start button could crowd the centred switcher.