Skip to content

Feature: Panel layouts transcript surface - #166

Merged
jhweir merged 12 commits into
devfrom
feat/panel-layouts-transcript-surface
Aug 27, 2026
Merged

Feature: Panel layouts transcript surface#166
jhweir merged 12 commits into
devfrom
feat/panel-layouts-transcript-surface

Conversation

@jhweir

@jhweir jhweir commented Aug 27, 2026

Copy link
Copy Markdown
Collaborator

Panel layouts and the transcript surface

Branch: feat/panel-layouts-transcript-surface off dev.

Summary

Two efforts that meet in the same space UI. A template can now say what panels its interface has
and where each one starts
, floating panels can share an edge instead of covering each other, and
the transcript panel shows the transcript instead of a session-local buffer of the reader's own
lines.

The thread through all of it is that arrangement is data and the host owns the geometry. A template
declares in named positions — a snap, a DockSize, a grow ratio — and never in pixels, because only
the host can see the viewport. The declaration is a suggestion: the placement chain is now what the
reader last dragged, then what the interface asked for, then the module's own opening bid, resolved
live and never written. That is the same rung meta.themeId already occupies for themes, and it is
what makes switching template or section non-destructive.

The transcript half turned out to need almost nothing built. The shared record already held every
agent's utterances with authors and timestamps, and already outlived the session —
spaceStore.exportCallTranscript has been reading exactly that to write a text file with real names
in it. The panel was the only thing not looking at it.

Changes

The transcript surface

  • we-scroll-area gains pin="end" (design-system/3-primitives). Follows the end of the
    content as it grows, only while the reader is already there. The conditional half is the whole
    feature: pinning unconditionally yanks somebody out of what they scrolled up to re-read, every
    time a line lands. Two observers, because content grows for two reasons and only one is a
    mutation. No event yet — a consumer wanting "N new" can have one when something renders that
    affordance.
  • The transcript panel reads the record (module-system/transcribe). It rendered
    modules.transcribe.recent, a session-local buffer of the last twenty blocks this agent wrote —
    own-only, capped and gone on reload by construction. All three complaints people raised were that
    one cause. It now drills into the collection the way CallsList already does for a finished
    meeting, oldest first, with $agent putting a real name and face on each line. recent is removed
    rather than kept as an echo: pending already shows the words not yet flushed, so an echo of the
    flushed ones would double-render the reader's own lines.
  • The empty state gates on there being no record, which fixes a smaller bug in passing —
    re-opening the panel on an already-transcribed call offered to start recording as though nothing
    had been said.
  • docs/architecture/transcripts.md writes the shape down. Five things read it now and none can
    import the constants, because templates → modules and modules → modules are both sideways
    edges. Deliberately not a shared kind registry: a free-text discriminator on a generic container is
    the symptom, and the answer is a declared content type.
  • transcriptFeed is exported and registered in the module's schemas, so a template can place
    the utterances without the capture chrome around them.

Shell geometry

  • Floating panels can share an edge (dockGeometry.ts). occupiedFor returns early for anything
    that does not displace, so two floats overlapped outright, and the eight snaps are points rather
    than a column. Note the axis, because an earlier reading of this got it wrong and the plan has been
    corrected: a strip (displacing panels) stacks perpendicular to its edge, growing inward; a
    column (floating panels) divides along it. Both are wanted; this adds the second.
  • The division is flexbox, computed rather than delegated. Base size plus grow. Proportions were
    the obvious alternative and are wrong: three panels at 2/1/1 that lose the middle one leave the
    survivors at 2/1 of the whole, so the top one grows for a reason nobody can see. Real CSS flex is
    unavailable — a flex container means reparenting panels out of fixed positioning, which remounts
    their subtrees and would drop a call's live video.
  • Below NARROW_VIEWPORT_PX a column collapses to full-bleed sheets. Two 350px cards over
    content on a 400px viewport leave nothing of any of the three.
  • columnSlots draws the seams so a float can be dragged into a column rather than only
    appended to it. insertDock takes the mode; activeInsert carries it so a strip seat and a column
    seat on the same edge cannot be confused.
  • Non-module chrome can reserve its band. moduleChrome summed chromeReserve off module stores
    and nothing else, so a shell template pinning a nav strip reserved nothing and a panel snapped to
    that corner opened underneath it. meta.chromeReserve plus hostChromeReserves.

Template-facing panels

  • meta.panels — one list, two kinds of entry. module places a panel a feature module already
    contributes and opens it; node supplies the content itself. Splitting them would have meant two
    declarations that could disagree about the same edge.
  • The three-rung placement chain, with placementFromDeclaration turning names into pixels and
    refusing to displace from a corner.
  • Template-authored panels become real docks whose open flag the shell owns, since there is no
    module to own it — the arrangement shellDocks.ts uses for space settings, one level more
    dynamic. Entries are diffed rather than rebuilt: registration announces, the geometry memo re-runs
    on every announcement, and a template being edited re-declares on every keystroke.
  • Declarations travel on their own change channel, or the effect that turns a declaration into
    dock entries would re-run itself forever.
  • Panels follow the section you are in. meta.panels is read off the view on screen as well as
    the shell, with the shell winning on a collision — a section is portable and renders inside
    interfaces it knows nothing about, so what it says about the screen is a suggestion.
  • Opening a module's panel goes through the module, using the two keys the launcher already
    declares: read activeWhen, fire action only if false. Load-bearing rather than fastidious —
    transcribe's action is togglePanel, so firing it blindly would close the thing the template asked
    for.
  • Provenance. A panel a layout opened belongs to the layout and is withdrawn when the layout
    stops naming it; a panel somebody opened themselves survives navigation. Without it a per-view
    layout either accumulates every panel you walked past or closes one holding live state — leaving
    the graph view would stop a recording.
  • layoutPinned / resetDockToLayout. The chain was one-way: a drag beat the declaration for
    good. Reset forgets the stored placement rather than writing the declared one, so the panel goes
    on following the layout afterwards. Offered first on the position menu, disabled rather than
    hidden.
  • The panels context fragment. The capability does not exist until it reaches
    @we/ai-context — the globe is the case on record. Leads with the panel-versus-flow test, which is
    the load-bearing part: without it a generated dashboard comes out as eight draggable windows
    because somebody said the word "panel".

Deviations from the plan

  • $reverse was not added. The architecture plan's rules now forbid new value operators and name
    this proposal as the first entry on the deferred list. Rather than smuggling it in as a query or
    $each construct, the transcript renders ascending and unbounded, pinned to the end. A single
    call's transcript is bounded by the length of the call, so the window the plan worried about is not
    load-bearing yet; it waits for the expression layer's function library.
  • Only transcriptFeed was split out, not captureControls. Nothing places the capture chrome
    separately, and a fragment with no second caller is an extraction waiting to be got wrong.
    extractionStatus was already its own file for an unrelated reason.
  • Auto-reveal titlebars are not done. Collapsing the header and revealing it on hovering the
    panel
    needs a descendant selector, which inline styles cannot express — so it needs a real
    stylesheet or a component, not a prop. Doing it through hoverProps on the titlebar itself would
    mean hovering a thin strip, which is a worse feature than none. Left for a follow-up that can pick
    the mechanism deliberately.
  • PR D (the interpretation panel) is not started, and has been re-sequenced rather than merely
    deferred — see "What comes next". Moving extraction observability to a host dock means rewriting
    ExtractionStatus.schema.ts (671 lines) against interpretationStore rather than
    modules.transcribe.*, and deciding the split between observability (host) and "extract this"
    (with the content).

What ships unexercised

No template in the repo declares meta.panels yet. The mechanism is complete and tested, and
the first real consumer is the workshop space below.

That is the one genuinely uncomfortable thing about merging this as it stands, and it is worth
naming rather than papering over: it is the shape of failure the architecture plan records about the
globe — a good protocol nobody could reach. What makes it acceptable here is that the half which
actually made the globe unauthorable is in this PR: the panels context fragment ships alongside
the capability, so it is documented before it is used rather than after.

Everything else is exercisable by hand today, and was: opening two panels and dragging both to one
edge is the column path, with no declaration involved.

What comes next

PR 2 — the workshop space. The interpretation panel and the first real consumer of meta.panels
belong in the same change, because they are the same screen: a graph of a call's extracted records
in the centre, the transcript down the left, the extraction readout beneath it, the call on the
right, and a floating strip switching between that and a task view. The left-hand extraction panel
is PR D, which is why it is a dependency of the workshop rather than a follow-up beside it.

One constraint to settle before that is designed: per-view panel layouts resolve against
spaceStore.spaceViews() matched to route segments, so the workshop shell has to be a
$views-based shell
(like the default template's Header/Sidebar layouts). A showcase template
carries its own route table and no $views marker, so one built that way would get the shell's
panels and no per-view variation — degrading quietly, which is worse than breaking.

Known follow-ups

  • Transcribe dropping its duplicate runningPasses / settledPasses / hasActivity, and its
    chrome reserve shrinking when the readout leaves the fixed chrome column — both fall out of the
    interpretation panel in PR 2.
  • Auto-reveal titlebars, with a mechanism chosen for descendant styling.
  • Narrow-viewport column collapse should be reconciled with mobile-plan.md's container-query idiom
    before it is relied on.
  • installFromMarketplace / installToSpace have still not been checked against role: 'view'.

Test plan

Run and passing:

  • @we/primitives — 138 tests, including five new ones for pin="end": follows when at the end,
    holds when scrolled up, treats a few pixels short as the bottom, re-arms on return, and does
    nothing without the prop.
  • @we/app-shell — 721 tests. New coverage for column layout (stacking, even division, grow, the
    add/remove property, per-member widths, axis flip, the minimum, narrow collapse), column
    membership, column seams, declared placement, the declaration registry, and the reset-to-layout
    menu item.
  • @we/module-transcribe — 80 tests, unmoved by the panel and fragment changes.
  • @we/schema-shared — 615 tests; validate clean across 32 schemas; role-audit clean.
  • @we/ai-context — 14 tests; context regenerated and committed.
  • Typecheck clean across every touched package.

Verified by hand in the Electron app:

  • Panels behave as expected — the transcript reading the record, and floating panels sharing an edge.

Not verified, and honestly so:

  • A template declaring meta.panels, and the layout changing between sections. Nothing declares
    any yet; PR 2 is the first exercise of that path.
  • The multi-agent transcript, which needs two real agents in one call. The single-agent path is
    what has been seen working; that every participant's lines appear is inference from the record's
    shape and from exportCallTranscript already reading it that way.
  • The narrow-viewport column collapse, which wants reconciling with mobile-plan.md first.

jhweir and others added 12 commits August 27, 2026 17:41
…further up

we-scroll-area gains pin="end". Every log in the app wants the same behaviour and
none of them should implement it twice: a transcript, a chat, an activity feed.

The conditional half is the whole feature. Pinning unconditionally yanks somebody
out of what they scrolled up to re-read, every time a line lands — which in a live
transcript is constantly. So the element remembers whether the reader was at the
end before the content changed, and only follows if they were.

Two observers, because content grows for two reasons and only one is a mutation:
rows arriving are a childList change on the light DOM, and the host being resized
changes what "the end" means without touching the content. Neither catches an image
loading inside an existing row, which is worth knowing rather than worth a third
observer — a log is rows of text.

No event yet. A consumer wanting "N new" while scrolled up can have one when
something actually renders that affordance; an event nobody listens to is API that
has to be kept working for nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Everyone who used this asked for the same two things — see what other people
said, and still have it there after the call restarts. Both were one cause, and
neither needed anything built.

The shared record already holds every agent's utterances, each carrying its
author and the moment it was said, and it already outlives the session:
spaceStore.exportCallTranscript has been reading exactly that to write a text
file with real names in it. The panel was the only thing not looking at it. It
rendered `recent` instead — a session-local buffer of the last twenty blocks
*this* agent wrote, which is own-only, capped and gone on reload by
construction.

So it drills into the collection the same way the calls list already does for a
finished meeting, oldest first, with $agent putting a real name and face on each
line. Attribution needs no diarization: each agent transcribes only their own
microphone, so the block's author is the speaker.

`recent` is removed rather than kept as a local echo. There is already a finer
one — `pending` shows the words not yet flushed — so an echo of the flushed
blocks would only double-render the reader's own lines against the query.

The empty state now gates on there being no record rather than on an empty
buffer, which fixes a smaller bug in passing: re-opening the panel on a call
that had already been transcribed offered to start recording as though nothing
had ever been said.

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

A call's transcript is a CollectionBlock with kind 'call', mode 'feed', its
utterances as children and its roster as participants — and none of that is
enforced anywhere. `kind` is a free-text string with no registry, nothing ties
it to `mode`, and nothing says children are utterances in time order.

That was fine while transcribe was the only reader. It is not any more: the
panel now drills into the collection, CallsList renders finished meetings,
Header creates one, the graph module styles call nodes, and
exportCallTranscript writes a text file from it. Five readers, and none of them
can import the constants — templates → modules and modules → modules are both
sideways edges, so each spells 'call' itself.

So the page is the contract, and it says so: change the shape and every row in
its table changes with it. Deliberately not a shared kind registry — a
free-text discriminator on a generic container is the symptom, and the answer
is a declared content type rather than a lookup table of magic strings.

Also records the two things a reader gets wrong: `include` crashes on
CollectionBlock.children because it is untyped, and mode: 'feed' is what stops
reconcileBlocks deleting other agents' utterances.

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

transcriptFeed is the utterances and nothing else — no capture controls, no
status notes — exported and registered in the module's `schemas`, which exists
precisely so a template can place a named fragment.

It is the half of the panel that means something on its own: a template putting
a transcript beside a graph wants the lines, not the chrome around them. That
is also what the panel-layout work needs — a template-authored panel has to be
able to hold this directly.

Only the feed. The plan called for splitting captureControls out to match, and
that is speculative: nothing places it separately, and a fragment with no
second caller is an extraction waiting to be got wrong. extractionStatus was
already its own file for an unrelated reason — it is contributed to the call
bar rather than composed into this panel.

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

Two floats snapped to the same edge overlapped outright — occupiedFor returns
early for anything that does not displace, and the eight snaps are points
rather than a column, so top-left and bottom-left put two seeded cards in two
corners with a hole between them. A left-hand column sharing the height was not
expressible at all.

Note the axis, because an earlier reading of this got it wrong: insertionSlots
sorts a left-edge strip by x and draws full-height vertical lines, so
*displacing* panels stack perpendicular to their edge, growing inward. That is
right for what it does. A column divides *along* the edge instead. Both
arrangements are wanted; this adds the second and leaves the first alone.

The division is flexbox, computed rather than delegated: each member has a base
(its own h on a side edge, w on a top or bottom one) and a grow, spare room
goes out by grow ratio, and a shortfall shrinks bases proportionally with a
floor at MIN_FLOAT_PX. Proportions were the obvious alternative and are wrong —
three panels at 2/1/1 that lose the middle one leave the survivors at 2/1 of
the whole, so the top one grows for a reason nobody can see. Base-plus-grow
gives a closed panel's room to its neighbours and moves nothing else, which is
what people expect and what the test asserts.

Real CSS flex is not available for this: making a column a flex container means
reparenting panels out of fixed positioning, which remounts their subtrees and
would drop a call's live video.

Below NARROW_VIEWPORT_PX every member takes the whole region as a full-bleed
sheet. Two 350px cards over content on a 400px viewport leave nothing of any of
the three, so the arrangement changes its mind rather than shrinking — the same
threshold that switches displacing off, for the same reason.

Membership is implied by the snap, not declared, exactly as a strip's is.
Corners are excluded: a corner is a place for one card and has no edge to
divide.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
columnSlots is insertionSlots' sibling, and the axis is the whole difference:
a strip's lines run across the region because its members stack inward from
their edge, a column's run along it because its members share the edge. Same
convention either way — a drop that can only report an edge can only ever
append, so the seams become targets and the drop reports an index.

insertDock now takes the mode. A strip is the panels displacing an edge, a
column the ones floating on it; the renumbering is identical and the only
differences are which set the dropped panel joins and whether landing there
means taking room. activeInsert carries the mode so the two cannot be confused
when both offer a seat on the same edge.

An empty edge gets no column seam. A column is started by snapping to the edge,
which the eight targets already offer — a seam with nothing to sit beside would
be describing a boundary that is not there.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
moduleChrome summed chromeReserve off every module store and nothing else, so a
shell template that pins a nav strip reserved nothing and a panel snapped to
that corner opened underneath it. A template has the same problem the call bar
has and no store to publish from, because a template is data.

So it declares the box in meta.chromeReserve, TemplateLayout registers it into
hostChromeReserves — the sibling of hostDockStores, keyed and announcing for
the same reasons — and the shell folds it into the same sum. Withdrawn on
unmount and replaced rather than accumulated on re-register, so a shell that
stops declaring a bar stops reserving the band.

The shape is restated in TemplateMeta rather than imported from
@we/module-shared: schema types are the layer modules point at, so naming a
module type there would be an edge running the wrong way.

This is also the reverse of what the interpretation panel needs later — when
the extraction readout leaves the fixed chrome column for a dock of its own it
must stop contributing here, or panels keep clearing a band that is gone.

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

meta.panels is one list answering one question — what panels does this
interface have — with two kinds of entry, because splitting them would have
meant two declarations that could disagree about the same edge. `module` places
a panel some module already contributes; `node` supplies the content itself.

A declaration is a suggestion, and the placement chain now has three rungs:
what the user last dragged the panel to, then what the interface asked for,
then the module's own opening bid. The middle rung is resolved live and never
written, so switching template or view is non-destructive, switching back
restores what was there, and an author improving a layout is not overruled
forever by one stray drag. The same shape meta.themeId already follows.

Everything the declaration carries is a name — a snap, a DockSize, a grow
ratio. placementFromDeclaration turns those into pixels against a viewport the
template cannot see, exactly as a module's `md` becomes 440, and it refuses to
displace from a corner because a rectangular layout cannot flow around one.

A `node` panel becomes a real dock whose open flag the shell owns, since there
is no module to own it — the arrangement shellDocks.ts already uses for space
settings, one level more dynamic. Entries are diffed rather than cleared and
rebuilt: registration announces, the geometry memo re-runs on every
announcement, and a template being edited re-declares on every keystroke.

Declarations travel on their own change channel rather than dockRegistry's, or
the effect that turns a declaration into dock entries would re-run itself
forever: register, announce, re-read, register.

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

A layout is about a section rather than a whole interface — a graph wants a
transcript beside it and an inbox does not, and both are sections of one
template. So meta.panels is read off the view on screen as well as the shell,
with the shell having the last word: a section is portable and renders inside
interfaces it knows nothing about, so what it says about the screen is a
suggestion and the interface that owns the screen overrules it.

The active section is derived, not stored. The shell's routes are built from
the same resolved sections, so the segment in the URL is the section on screen.

Declaring a module's panel now opens it, through the two keys the launcher
already declares: read activeWhen, fire action only if it is false. That is
load-bearing rather than fastidious — transcribe's action is togglePanel, so
firing it blindly at an open panel would close the thing the template asked
for. A module with no activeWhen cannot be asked, so it is opened once and
never toggled back; an unanswerable question is better left alone than guessed.

And provenance, which is why this keeps a set. A panel a layout opened belongs
to the layout and is withdrawn when the layout stops naming it; a panel
somebody opened themselves is theirs and survives navigation. Without the
distinction a per-view layout either accumulates every panel you have walked
past, or closes one holding live state — leaving the graph view would stop a
recording.

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

The capability does not exist until it is documented here. The globe is the
case on record in this repo: a good layer protocol, no catalogue of layer names
in the generated context, and the conclusion written down is that an LLM cannot
author a globe template. meta.panels would have gone the same way.

So the fragment covers the declaration — both kinds of entry, every field, and
that sizes are named because only the host can see the viewport — plus the two
arrangements an edge can hold, and chromeReserve.

It leads with the panel-versus-flow test, which is the load-bearing part. A
dashboard where nothing overlaps, nothing is dragged and nothing persists
position is a Grid of cards; built from panels instead it is busier, harder to
read and worse on a phone. Without the test in front of it, a generated
dashboard comes out as eight draggable windows because somebody said the word
"panel".

Placed after routing, since a section's layout is read against the routes it
renders at, and before the rules, which are prohibitions rather than guidance.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The three-rung chain was one-way. A drag beat the interface's declaration for
good, with nothing to return to it — so an author improving a layout would be
overruled forever by one stray drag somebody made once, and the "resolved live,
never written" property bought nothing the reader could feel.

layoutPinned says a panel has been moved away from something there is to go
back to; resetDockToLayout forgets the stored placement. Deleting rather than
writing the declared one is the point: the panel goes on following the layout
afterwards, including when the template changes it.

Offered first on the position menu and disabled rather than hidden — the same
choice displaceButton makes, for the same reason. A control that vanishes when
you move a panel is one you stop looking for, and the disabled state carries
the rule. It sits before the eight rather than among them because it undoes a
position rather than choosing one.

Same pairing as spaceThemePinned and clearSpaceThemePin, which is where this
shape came from.

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

CI's build-drift check caught the generated context being stale: layoutPinned
and resetDockToLayout were added after the last generate-context run, so a full
build regenerated CLAUDE.md and friends and the tree came back dirty.

Regenerating alone would have satisfied the check and shipped both members as
`unknown`, which is exactly what the architecture plan rules out — a named
capability with no semantics looks complete, so an LLM either avoids it or
guesses. Described in fragments/stores.ts instead, then regenerated.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@jhweir
jhweir merged commit c62b0b8 into dev Aug 27, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant