Skip to content

Fix: UI tweaks - #125

Merged
jhweir merged 12 commits into
devfrom
fix/ui-tweaks
Aug 20, 2026
Merged

Fix: UI tweaks#125
jhweir merged 12 commits into
devfrom
fix/ui-tweaks

Conversation

@jhweir

@jhweir jhweir commented Aug 20, 2026

Copy link
Copy Markdown
Collaborator

One panel system, a portable kit, and the chrome around them

Summary

The app had two panel systems at the same screen edge. The editor positioned its rails by
subtracting --we-dock-right; the shell positioned module docks by subtracting
--we-editor-right; with both open they dodged each other into an overlap with a gap beside it,
and every previous fix added another term to one of the two calculations. This branch collapses
them into one: any panel, anywhere, moved the same way — drag by a titlebar, snap to one of eight
targets, resize from any edge or corner, reorder within a strip, remembered per device. The
editor's panels are docks now, which is what actually removes the class of bug rather than the
instance of it.

Underneath that sits a model change worth stating on its own. Position and displacement used to be
a single enum, so "put it on the right" also meant "span that edge and take a column of the
window" — three people in a 320px call cost a full-height strip. They are separate questions now,
meeting in one rule: a panel that displaces spans its edge; a panel that floats does not.

The rest of the branch is the chrome that surrounds it, and one packaging change that unblocked
part of it. @we/schema-kit splits the portable schema fragments out from under templates/, so
a feature module can use them without the sideways modules → templates dependency the rules
forbid — the call bar had been hand-copying a fragment because of that edge. Alongside: the call
stage now opens when a call starts and keeps its audio when the pictures go away, the control bar
reads as the app's chrome rather than its own, a theme you pick is finally persisted where you
picked it, and four smaller fixes in the design system and the template kit.

Two older bugs surfaced on the way and are fixed here because the panel work could not be finished
around them: the styles escape hatch never reached any primitive, and the icon collector never
saw icons passed as data — which was most of the shell's chrome, all fetching from a CDN and blank
offline.

Changes

One panel system

  • packages/app-shell/src/shared/dockGeometry.ts — the arrangement, as pure functions: snap
    resolution, strip ordering, slot measurement, fit-to-content, maximise. Pure because it is the
    part with real edge cases and no way to see them in a render; it carries the bulk of the 63
    geometry tests. Several of those tests exist because a first fix was wrong — ties in the strip
    order made panels dodge each other, slots measured in a region excluding their own strip
    collapsed to one line, and a resize stamped a docked panel's full height over the card it
    returns to.
  • packages/app-shell/src/shared/registries/dockRegistry.ts — placement and displacement as
    independent axes. Displacing is offered on the four edge-centre snaps only: a rectangular layout
    cannot flow around a box in a corner, so insetting there carves out a column and leaves most of
    it empty.
  • packages/app-shell/src/shared/registries/editorDocks.ts (new) — registers the editor's
    panels as ordinary docks, which is the actual fix for the overlap. Deleted with it:
    RightPanelContainer, computeEditorRightOffset, editorOccupiedWidth, --we-editor-right,
    three rail-width constants, and four width signals with their own localStorage keys.
  • packages/app-shell/src/frameworks/solid/stores/ShellStore.tsx — drag, snap, resize, move
    and reorder state for every panel, replacing the two arbiters.
  • packages/editor/src/components/EditingBar.tsx, panelLayout.ts, mount.tsx — the
    32px icon rails are gone; resizing is the frame's job on every edge now, and the panel toggles
    moved to the editing bar, where a control that opens a panel is visible before its panel exists.
  • packages/design-system/3-primitives/src/primitives/move-handle.ts (new) — the two-axis
    sibling of we-resize-handle, reporting pointer position so a panel can be restored under the
    cursor. resize-handle.ts gains line, so a floating panel's edges show a cursor rather
    than a coloured bar while keeping their keyboard focus ring.
  • packages/module-system/call/src/index.ts, store.ts — the six-placement menu is gone.
    The module now says only "a card, floating, when it opens" and the shell answers everything else.

Two bugs the panel work uncovered, both older than it

  • packages/design-system/3-primitives/src/shared/design-system-mixin.tsstyles never
    reached any primitive. applyInlineStyles was called on a prop getInstanceProps had already
    filtered out, because styles is added to designSystemKeys outside the layer sets: the escape
    hatch was reinstated at one end of the pipe and still cut at the other.
    --we-resize-handle-thickness and --we-resize-handle-line had been inert since they were
    documented. Covered by inline-styles.test.ts (new).
  • packages/design-system/3-primitives/scripts/collect-icons.ts — the collector only ever saw
    icons named as elements, missing every one that arrives as data: a module's icon, a
    launcher's, a menu item's, anything passed through a helper. That was most of the shell's chrome,
    the whole call bar included — all CDN fetches, blank offline. 95 → 189 bundled, 0 missing.

@we/schema-kit — the portable tier becomes a package

  • packages/schema-system/kit/ (new, 18 files moved in) — fragments that name no store.
    @we/template-kit keeps what reads WE's own stores and re-exports the rest, so every template
    import is unchanged. A module depends on the portable half at compile time only — fragments
    run during its build and dist holds the expanded data — hence a devDependency, never a peer.
    The dependency rule is upheld rather than excepted: both families point inward at the kit.
  • packages/templates/kit/src/kit.test.ts — the first pass got the boundary wrong and nothing
    failed. channelRail, collectionFeed, commentThread and mediaGrid all filter on
    spaceStore.mutedDids and all four went into the package whose entire claim is that it names
    none, because the tier test walks expansions and none of those four had a fixture. The test now
    also reads @we/schema-kit's source with comments stripped and fails on a $store: or
    '$agent'. Comments have to go first: half those files discuss $store in prose, and a check
    that cannot tell an explanation from a dependency gets answered by rewording rather than by
    moving the fragment.
  • peopleTooltip moved the other way — filed under we/ but naming no store; its first
    callers passed one as items, which is the caller's dependency. That misfiling is what sent the
    call module to a hand-copy in the first place.

The call bar and stage

  • packages/module-system/call/src/index.ts — the stage never opened. visible started false
    and join never set it, so pressing the call button produced a control bar and nothing else.
    join opens it now; placement already answers where separately.
  • Audio separated from video. A remote stream was only ever attached to a participant's
    <video>, and that element is conditional in three independent places — each defensible where it
    lives. So a peer turning their camera off was inaudible, and putting the video away silenced the
    call. we-audio gains the stream property we-video already had, and the module contributes
    an audioSink at the control bar's slot anchor: one element per remote participant, mounted for
    as long as you are in a call, drawing nothing. Chrome rather than a dock, because a slot
    contribution outlives every state the stage can be in — including not existing.
  • packages/design-system/3-primitives/src/primitives/audio.tsmuted moves from ?muted
    to .muted, the attribute-versus-property trap we-video already documents. It had never muted
    anything.
  • b3624de8 — the bar reads as the app's chrome. Controls one size up and square (an
    icon-only button was keeping the 16px of side padding it holds for a label); corners from
    --we-theme-control-radius so a theme set to Sharp no longer draws a fully rounded box around
    square buttons; neutral-50 with a border rather than neutral-0 with an lg shadow, which is
    what the app uses for a sheet with content on it, not a cluster of buttons. The transcribe
    module's contributed button follows all of it — a contributed control is only "one set of
    controls" while it is the same shape as the set.

Theme selection persists where you make it

  • packages/app-shell/src/frameworks/solid/stores/SpaceStore.tsx — picking a theme in the rail
    wrote nothing; setCurrentTheme set a signal and stopped, so the choice survived until something
    recomputed the space theme. What made it visible: agentSettings is deliberately
    { equals: false }, the theme resolution read it raw through themeStore.defaultThemeId, and so
    every agent-settings write — a scope toggle, a module switch, a template change — re-ran the
    resolution and pushed its answer over the agent's choice. The resolution now flows through a memo
    with an explicit equals, so the effect fires on the resolved value rather than on whoever
    notified. That fixes the class: a hand-maintained dependency list would drift the first time the
    precedence grows an input.
  • spaceStore.applyTheme persists the pick where it was made — pinned to the space on screen,
    or the agent's global default when there is no space. The pin lands in SpacePreference.themeId,
    the slot the precedence rule already documents as "this theme here, whatever else changes", so
    nothing new was invented and the resolution became total.
  • packages/templates/shell/src/DesignControls.schema.ts — a pin is sticky and otherwise
    invisible, so the surface that creates one can undo it: a filled push-pin on the pinned row,
    shown only when the pin actually diverges from what the space would otherwise show. Filled
    because the strip is otherwise verbs, and a hollow pin there reads as an offer to pin rather than
    a report that something is pinned. Choosing a theme also no longer closes the picker — a theme
    repaints the whole window including the popover, so the click is self-evidently landed, and
    themes are the one list here people work through by comparison.

Layering, and four smaller fixes

  • packages/design-system/1-tokens/src/z-index.tschrome (250) joins the ladder between
    sticky and modal. Docking the call stage on the left looked like the panel had been placed on
    top of the sidebar: the geometry was right, the stacking was not. Collapsed, the two never
    overlap; then the pointer arrives, the rail expands 80px → 240px, and those 160px open behind
    the video. The right edge keeps this rule by sliding the module rail inwards, but the left edge
    cannot move — being what the layout is measured from — so there it has to be said as a layer.
  • CollapsedContent.solid.tsx — opening the box swapped one cap for a larger one, and
    overflow: hidden meant everything past 5000px was clipped rather than scrollable. A call
    transcript ran out at ~90 utterances with the remaining 200 simply gone; every card body had the
    same hole, since cardShell wraps all of them. Animates towards the content's own measured
    height now — a real transition target and an honest cap — fed by the ResizeObserver that already
    drove the fade threshold, so content arriving late widens the cap instead of being cut off by a
    stale measurement.
  • CallsList.ts — drops the transcript's own fold. cardShell already collapses the whole
    card with the same primitive, so a collapsed card showed two nested disclosures wrapping the same
    text, which reads as a mistake rather than as two choices.
  • packages/module-system/transcribe/src/store.ts — transcription needed a raised voice at
    ordinary mic distance. Three thresholds decide that and only two were being set:
    minUtteranceRms sat at the worklet default of 0.04, measured as the mean across an utterance
    carrying 500ms of pre-roll and up to 500ms of trailing silence — so it was the real floor, and
    lowering onset alone would have moved the gate without opening it. Onset 0.04 → 0.025, silence
    0.025 → 0.015, minUtteranceRms 0.04 → 0.02 and now stated explicitly. Kept above zero: it is
    what stops Whisper inventing "you" on a near-silent segment.
  • packages/templates/kit/src/layout/rail.ts — hovering the row you are already on flashed it
    from primary back to near-white, reading as a deselection at the moment the pointer says
    otherwise. railItem's ghost button carries hover/active colours of its own and a state beats a
    resting colour, so the mark survived everywhere except under the pointer.
  • packages/design-system/3-primitives/src/primitives/modal.ts — the composer modal came out
    as narrow as its longest line of text with a horizontal scrollbar under a one-line editor. The
    scroll region takes align-items from base, and composerModal is the only modal that centres,
    so the scroller shrink-wrapped to its content. It stretches regardless now; align-items still
    passes the centring down to the children it was meant for.

Known follow-ups

  • AgentSettings.currentThemeId is now dead weight. It exists in the manifest and the AD4M
    entity with predicate we://current_theme and nothing writes it — the theme pin lives in
    SpacePreference.themeId, where the precedence rule can see it. Deliberately not removed here:
    dropping a predicate touches the model layer and deserves its own change.
  • Theme pin write chattiness. Clicking through five themes to compare writes five
    SpacePreference updates, each followed by a findAll refresh. Small model, root dataset, so
    probably fine — worth watching in real use before adding debounce machinery speculatively.
  • The template picker still closes on select, deliberately: a template switch replaces the page
    and often the route with it, so staying open would leave the surface hovering over a page that
    changed underneath. Worth revisiting if the asymmetry with the theme picker reads oddly in use.
  • 72 files on dev fail prettier --check (README, seed-examples/, and others). Pre-existing
    and untouched here — every file this branch changes is clean.

Test plan

Run at branch HEAD (4efbcd7e), not merely claimed by the individual commits:

  • pnpm typecheck — every package and all four app targets, clean
  • pnpm test2,359 passing across 28 packages, 0 failures
  • pnpm validate:schemas — 28 schemas, no issues
  • pnpm lint — clean (needed one prettier autofix on CollapsedContent, committed as
    4efbcd7e; it would otherwise have failed CI)
  • prettier --check on every file this branch touches — clean
  • pnpm build — every package plus @we/app-web, exit 0 (pre-existing chunk-size warnings only)
  • New coverage added by the branch: 63 dock-geometry tests, 6 call-stage tests (four structural,
    because the failure was structural — every piece rendered exactly as written, in the wrong
    dependency), 2 chrome-layering tests against tokens and schema rather than a render, and the
    inline-styles regression test
  • Manual: theme picking and the scope toggle confirmed working in the running app

Not verified here — worth a pass before merge:

  • Manual exercise of the panel system end to end: drag, all eight snaps, displace on the four
    edge-centres, resize from corners, reorder within a strip, fit, maximise, and position
    restored across a restart
  • Manual check of the call stage on a real multi-peer call: audio surviving a camera off, and
    surviving the stage being closed
  • Icon bundling offline (the collector change claims 95 → 189, 0 missing)

jhweir and others added 12 commits August 20, 2026 16:29
The composer modal came out as narrow as its longest line of text, with a
horizontal scrollbar under a one-line editor.

[part='content'] — the scroller interposed yesterday so the close button stops
riding away with the content — takes align-items: inherit from base but had
nothing giving it a cross-axis size. base is a column flex container, so its
children's width is decided by its align-items, which is the `ax` prop: every
other modal leaves it at the default 'stretch' and the scroller spans the box,
while composerModal is the only one that centres, so the scroller shrink-wrapped
to its content. The composer's width: '100%' then measured 100% of that, and its
own overflow: auto turned the remainder into the horizontal bar.

Nothing was wrong with `ax: 'center'`: base's children used to be the slotted
nodes themselves, and a percentage width against base is definite whatever the
alignment. The scroll region is structure rather than one of those nodes, so it
now stretches regardless, and align-items still passes the centring down to the
children it was meant for.

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

Hovering the row you are already on flashed it from primary back to near-white,
which reads as a deselection at the exact moment the pointer says otherwise.

`railItem` builds a ghost button and marks the active row by colour, but ghost
carries hover and active states of its own — `color: 'neutral-900'` — and a state
wins over the resting colour. So the mark survived everywhere except under the
pointer.

Both state objects are now conditional on the same `active` the resting colours
use: one step along the hue (primary-600 → primary-700, brighter in a dark theme
and deeper in a light one) with the background going neutral-100 → neutral-200,
which it had to, since the row already rests at ghost's hover background and the
highlight would otherwise have been the only thing moving. The inactive arm
restates ghost's own values because a caller's state object replaces the
variant's rather than merging with it — leaving it out drops the hover background
from every unselected row.

The colour still snaps while the background takes 50ms, per ANIMATABLE_STATE_PROPS
in the primitives' shared helpers, and deliberately: it is one step within a hue
rather than the flash this removes.

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

Two faults with one shared consequence: a call that appeared not to have
started, and one that went quiet whenever the pictures did.

**The stage never opened.** `visible` starts false and `join` never set it, so
pressing the call button produced a control bar and nothing else — `dockEdge` is
null while the stage is closed and the host renders no dock for a null edge. The
only routes to a visible stage were the expand toggle and the placement menu,
both of which read as ways to change something already on screen. `join` now
opens it; `placement` already answers where separately, with `float`, so showing
it costs the space behind it nothing.

**The audio hung off the pictures.** A participant's `<video>` was the only thing
a remote stream was ever attached to, and that element is conditional in three
independent places, each defensible where it lives: a tile renders one only while
that peer has a picture, the stage renders tiles only while it is open, and
`dockFrame` unmounts a dock with no edge so a stage nobody watches stops decoding
video. So a peer turning their camera off was inaudible, and putting the video
away — a valid thing to want, and now reachable far more often — silenced the
call.

Sound and picture are separated instead. `we-audio` gains the `stream` property
`we-video` already had, and the module contributes an `audioSink` at the same
slot anchor as the control bar: one element per remote participant, mounted for
as long as you are in a call, drawing nothing and taking no room. Chrome rather
than a dock, because a slot contribution outlives every state the stage can be in
— including not existing. Tiles are muted throughout now, since an unmuted one
beside the sink is the same voice from two decoders.

`we-audio.muted` also moves from `?muted` to `.muted` — the attribute-versus-
property trap `we-video` documents, where the content attribute seeds
`defaultMuted` at creation and lit-html strips bound attributes from the clone.
It had never muted anything.

Six tests, four of them structural, because the failure was structural: every
piece rendered exactly as written, in the wrong dependency. They pin that the
sink exists, that its only condition is `active`, that self is excluded, and that
the tiles stay silent.

Verified: 73 call tests, 98 primitives, 582 schema-shared, 28 schemas, lint,
typecheck. Regenerated the CEM and ai-context for the new property.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Docking the call stage on the left looked like the panel had been placed on top
of the sidebar. The geometry was right and the stacking was not.

`SIDEBAR_PX` reserves the left edge, so a left dock resolves to `left: 80px` —
beside the collapsed sidebar, exactly as `computeLeftOffset` says it should be.
Collapsed, the two never overlap and nothing showed. Then the pointer arrives,
the rail expands from 80px to 240px, and those extra 160px open *behind* the
video: the sidebar was `zIndex: 10` and every dock is placed on `sticky`. It
overlays the template the same way and always has; it simply lost to a neighbour
that outranked it. DOM order could not have saved it either — the sidebar
registers at `dock-left` and module docks at `dock-right`, so a tie goes to the
panel.

**`chrome` (250) joins the ladder**, between `sticky` and `modal`: chrome that is
always there, above panels that come and go. `sticky` holds both sticky page
furniture and a module's docked panel, so a rail needing to stay above one had
nothing to ask for. The rule is not new — the right edge keeps it by sliding the
module rail inwards out of a panel's way — but the left edge cannot move, being
what the layout is measured from, so there it has to be said as a layer.

The chrome rail stays on `sticky`. It is later in DOM order than any dock, so it
already wins where it matters, and moving it would change nothing visible today.

Two tests, against the tokens and the schema rather than a render, because both
halves are static decisions and there is no runtime moment where the damage
shows: collapsed, the boxes do not overlap.

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

Six changes to one strip of buttons, each of which was the bar disagreeing with
something the app had already decided.

**One size up, and square.** The controls are `md` now — you reach for them
mid-sentence while looking at somebody else, and the bar is still a pill you can
ignore. `square` with them: an icon-only button was keeping the 16px of side
padding it holds for a label, so a 24px glyph sat in a 56px box and three toggles
read as three slabs. Icons follow on their own, a sized primitive publishing
`--we-context-icon-size` for whatever is slotted into it. The divider grew to
match, staying two thirds of a control's height rather than becoming half of one.

**Corners follow the theme.** The bar was a flat `pill`, so a theme set to Sharp
drew a fully rounded box around square buttons — the one shape in the app that
ignored the shape presets. It reads `--we-theme-control-radius` now, which is the
right one of the three: a surface is a sheet with content on it, and this is a
cluster of buttons drawn tight around them. Falling back to `400`, `we-button`'s
own default, so the bar and the buttons in it agree on every preset. Not the
concentric `inner + padding`, which is only right while the padding is: a theme
set to Sharp would then draw 8px corners around square buttons — this bug again,
smaller — and it collides with Rounded, which sets controls to that figure
outright.

**Surface, not sheet.** `neutral-0` with an `lg` shadow is what the app uses for
something with content on it — a dropdown, a modal, the panel the editor's share
button opens. A cluster of buttons is `neutral-50` with a border: the editor's
undo/redo, mode and save/close bars, and the module rail. The shadow stays at the
rail's weight, because unlike the editor's bars this floats over whatever a space
is showing rather than over a dimmed editing surface.

**Padding is `200` on both axes**, and the transcribe module's contributed button
follows all of it — size, square, radius — since a contributed control is only
"one set of controls" while it is the same shape as the set.

Its icon is `text-aa` rather than a record dot, which said nothing about what
comes out and, beside a microphone button already meaning "capture", read as a
second redder mute. The `weight: 'fill'` it toggled while listening went with it:
only the `regular` weight of any icon is bundled, so that was a CDN fetch firing
at the moment recording started — offline, the icon vanished as you pressed it.
Red carries the live state on its own.

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

The call bar wanted the roster tooltip every avatar stack in the app already has,
and could not have it. That is the whole story: the kit lived under `templates/`,
`modules → templates` is the sideways edge the dependency rules forbid, so the
module hand-copied `peopleTooltip`. Duplication caused by packaging rather than
by design, and invisible — nothing flags a copied fragment, so the two drift and
the copy is the one that misses the fix.

**The tier is a package now.** `@we/schema-kit` (schema-system/kit) holds what
names no store; `@we/template-kit` keeps what reads WE's own stores and
re-exports the rest, so every template import is unchanged. A module depends on
the portable half at *compile time only* — fragments run during its build and
what ships in `dist` is the expanded data, so there is no runtime coupling and no
version for host and module to agree on. Hence a devDependency, never a peer.

The rule is upheld rather than excepted: both families now point inward at the
kit instead of one of them pointing sideways.

**The first pass got the boundary wrong and nothing failed.** `channelRail`,
`collectionFeed`, `commentThread` and `mediaGrid` all filter on
`spaceStore.mutedDids`, and all four went into the package whose entire claim is
that it names none — because the tier test walks *expansions*, so it only covers
fragments a fixture exists for, and none of those four had one. `kit.test.ts` now
also reads `@we/schema-kit`'s source, comments stripped, and fails on a `$store:`
or `'$agent'`. Comments have to go first: half those files discuss `$store` in
prose, and a check that cannot tell an explanation from a dependency is answered
by rewording instead of by moving the fragment. Verified it fails on a planted
store and passes without one.

`peopleTooltip` moved the other way — filed under `we/`, but it names no store:
its first callers passed one as `items`, which is the caller's dependency. That
misfiling is what sent the module to a copy.

**The bar itself** gained a tooltip on every control, naming the move rather than
the state ("Mute" while unmuted), and the roster on hover — everyone, not the
three faces the stack drew, since the ones it hid are exactly the ones there is
no other way to find out about. The transcribe button's `title` attribute became
a `we-tooltip`: same words, but the browser's own tooltip has its own delay,
typeface and position, so the one contributed button was the one that felt like a
different program.

Also updates the `--we-z-chrome` token snapshot, missed in 69b2543 because that
change was verified against app-shell's tests rather than the tokens package.

Verified: `pnpm -r typecheck`, `pnpm -r test` (2,300+ across 28 packages), 28
schemas, lint, format, and the call module rebuilt — 0 references to
`@we/schema-kit` in its `dist`, which is the compile-time-only claim holding.

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

Six placements, four editor rails and two arbiters became one arrangement: any
panel, anywhere, moved the same way.

**The model.** Position and displacement were one enum, so "put it on the right"
also meant "span that edge and take a column of the window" — three people in a
320px call cost a full-height strip. They are separate questions now, meeting in
one rule: *a panel that displaces spans its edge; a panel that floats does not.*
Displacing is offered on the four edge-centre snaps only, because a rectangular
layout cannot flow around a box in a corner — insetting there carves out a column
and leaves most of it empty.

Every panel gets a titlebar with a grip, eight snap targets, resize from any edge
or corner, fit-to-content, maximise, and a position remembered per device. The
call module's six-placement menu is gone; it now says only "a card, floating,
when it opens" and the shell answers everything else.

**The editor's panels are docks too**, which is what actually fixes the overlap
they had with module panels. They were a second panel system at the same edge —
the editor offset itself by `--we-dock-right`, the shell offset docks by
`--we-editor-right`, and with both open they dodged each other into an overlap
with a gap beside it. Every previous fix added a term to one of the two
calculations. Deleted: `RightPanelContainer`, `computeEditorRightOffset`,
`editorOccupiedWidth`, `--we-editor-right`, three rail-width constants, four
width signals with their own localStorage keys, and the editor's own positioning.
The 32px icon rails went with them: resizing is the frame's job on every edge now,
and the toggles moved to the editing bar, where a control that opens a panel is
visible before its panel exists.

**Reordering.** A strip is an ordered list and a drop reports an index — the
convention VS Code, Photoshop and IntelliJ share, drawn as a line between panels.
Without it the stack order was the registry's, so a panel dragged out returned to
the slot it left however far along the edge it was dropped.

**Two bugs this uncovered, both older than the work:**

- `styles` never reached any primitive. `applyInlineStyles` was called on a prop
  `getInstanceProps` had already filtered out, because `styles` is added to
  `designSystemKeys` outside the layer sets — so the escape hatch was reinstated
  at one end of the pipe and still cut at the other. `--we-resize-handle-thickness`
  and `--we-resize-handle-line` have been inert since they were documented.
- `collect-icons` only ever saw icons named *as elements*, missing every one that
  arrives as data — a module's `icon`, a launcher's, a menu item's, anything
  passed through a helper. That was most of the shell's chrome, the whole call bar
  included: all CDN fetches, blank offline. 95 → 189 bundled, 0 missing.

Also: `we-move-handle` (the two-axis sibling of `we-resize-handle`, reporting
pointer position for restore-under-cursor), `DropdownMenu.itemSize` (`size` only
ever reached the trigger), and `we-resize-handle`'s `line` prop, so a floating
panel's edges show a cursor rather than a coloured bar while keeping their
keyboard focus ring.

The geometry is pure and carries 63 tests. Several of them exist because a fix was
wrong first: ties in the strip order made panels dodge each other, slots measured
in a region that excluded their own strip collapsed to one line, and a resize
stamped a docked panel's full height over the card it returns to.

Verified: `pnpm -r typecheck`, `pnpm -r test`, 28 schemas, lint, format, and the
shell builds.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Transcription needed a raised voice at ordinary mic distance. Three
thresholds decide that, and only two of them were being set.

`minUtteranceRms` was never in the store's override, so it sat at the
worklet default of 0.04 — numerically the same as the onset threshold,
but measured as the mean across a whole utterance that carries 500ms of
pre-roll and up to 500ms of trailing silence. That made it the real
floor: quiet speech could pass onset, open an utterance, and still be
dropped in emit() on the way out. Lowering onset alone would have moved
the gate without opening it.

- speechOnsetThreshold 0.04 -> 0.025
- silenceThreshold     0.025 -> 0.015
- minUtteranceRms      0.04 (unset) -> 0.02, now stated explicitly

Kept above zero rather than removed: it is what stops Whisper inventing
"you" on a near-silent segment.

The meter's threshold marker derives from speechOnsetThreshold, so it
follows the change; only its stale percentage comment needed updating.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The utterances sat behind a CollapsedContent, but cardShell already collapses
the whole card with that same primitive — so a card in collapsed mode showed
two nested expand/collapse boxes wrapping the same text, which reads as a
mistake rather than as two choices. The card-level fold covers the header and
the findings too, so it is the one worth keeping.

Removes the now-unused transcriptOpen local state.

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

Opening the box did not uncap it — it swapped one cap for a larger one, and
`overflow: hidden` meant everything past 5000px was clipped rather than
scrollable, so it could not be reached at all. A call transcript ran out at
roughly ninety utterances with the remaining two hundred simply gone; any card
body over 5000px had the same hole, since cardShell wraps every one of them.

The flat number was there because `max-height: none` cannot be transitioned to.
Animate towards the content's own measured height instead: a real target for the
transition, and an honest cap. The ResizeObserver that already fed the fade
threshold now feeds the height too, so content arriving late — an utterance
mid-call, a slow image — widens the cap as it lands instead of being cut off by
a stale measurement.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Picking a theme in the rail wrote nothing. `setCurrentTheme` set a signal
and stopped there, so the choice survived exactly until something
recomputed the space theme — and `AgentSettings.currentThemeId` sat in the
model unwritten while its twin `currentTemplateId` was persisted by the
template picker sitting beside it.

What made it visible was the theme scope switch. `agentSettings` is
deliberately `{ equals: false }` (updateAgentSettings mutates in place, so
identity cannot be the test), and `spaceStore`'s theme resolution read it
raw through `themeStore.defaultThemeId`. Every agent-settings write — a
scope toggle, a module switch, a template change — therefore re-ran the
resolution and pushed its answer over whatever the agent had chosen,
landing them back on the space's default.

Two halves:

- The resolution now flows through a memo with an explicit `equals`, so
  the effect fires on the resolved *value* rather than on whoever happened
  to notify. That fixes the class rather than the instance: a hand-
  maintained dependency list would drift the first time the precedence
  grows an input. `defaultThemeId` is memoised too, so the `equals: false`
  signal stops leaking "settings changed" to readers of one field.

- `spaceStore.applyTheme` persists the pick where it was made: pinned to
  the space on screen, or the agent's global default when there is no
  space. The rail is contextual chrome, so "here" is the reading of a
  click in it that does not surprise; rewriting the global default from
  inside a space would make per-space themes reachable only from Settings.
  The pin lands in `SpacePreference.themeId`, the slot the precedence rule
  already documents as "this theme here, whatever else changes", so
  nothing new was invented and the resolution became total.

A pin is sticky and otherwise invisible — the space's default and the
template's suggestion both stop reaching you, with nothing saying why — so
the surface that creates one can now undo it: a filled push-pin on the
pinned row, shown only when the pin actually diverges from what the space
would otherwise show, releasing it on click. Filled because the strip is
otherwise verbs, and a hollow pin there would read as an offer to pin
rather than a report that something is pinned.

Choosing a theme no longer closes the picker. The kit's advice to close on
select guards against a change hidden behind the surface that made it; a
theme repaints the whole window, this popover included, so the click is
self-evidently landed — and themes are the one list here people work
through by comparison. Edit, fork and New still close, each opening
another surface. The guidance in `pickerPopover` is updated to say which
is which rather than leave an unexplained exception.

The Cards route's theme list gets the same action — same act, same bug.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Formatting only — the ternary needed wrapping parens. `pnpm lint` runs
prettier as an eslint rule with --max-warnings 0, so this failed the repo
lint gate.

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

netlify Bot commented Aug 20, 2026

Copy link
Copy Markdown

Deploy Preview for coasys-we ready!

Name Link
🔨 Latest commit 4efbcd7
🔍 Latest deploy log https://app.netlify.com/projects/coasys-we/deploys/6a878595c62e740008bf6b67
😎 Deploy Preview https://deploy-preview-125--coasys-we.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@jhweir
jhweir merged commit 0468364 into dev Aug 20, 2026
4 of 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