Skip to content

Landing page elevation: real bb surfaces, section rhythm, and a rebuilt demo set - #6

Open
lnittman wants to merge 319 commits into
mainfrom
bb/landing-elevation
Open

Landing page elevation: real bb surfaces, section rhythm, and a rebuilt demo set#6
lnittman wants to merge 319 commits into
mainfrom
bb/landing-elevation

Conversation

@lnittman

Copy link
Copy Markdown
Owner

Elevation pass on the marketing site (apps/web), built on the fork for review before anything is proposed upstream.

The starting point was a page that described bb in prose and illustrated it with generic marketing furniture. The goal was a page that shows the product: every demo is either a real recording of a seeded workspace or a component rebuilt from the app's own tokens, and nothing on the page claims a capability bb does not have.

What changed

Sections and rhythm. Full-bleed alternating white/slate bands with the inner panels retained, no hairline dividers, no eyebrow captions. Header is non-sticky, matching production. The release callout is a pill.

Seven demo components, hand-built from the app. Tasks board, working-tree review, subagents, ask-a-question, plugin build, the multi-agent gang, and background spawns. Each one is a real bb window — the same chrome, rails, row metrics, glyph vocabulary, and state tints the shipped app uses. Several were verified field-by-field against live captures of a seeded instance rather than from memory.

Rendering law. Every demo is fully legible in prerender, with JS off, under prefers-reduced-motion, and in a single screenshot. Motion enhances; it never owns whether content exists. Loops reserve their own space so no section changes height as it plays.

Honesty law. Demo content comes from a seeded storefront workspace, never real user data. Stats (stars, forks, contributors, merged PRs) are fetched at build time by apps/web/scripts/refresh-github-stats.mjs and baked. The merged-PR feed is real merges.

Copy. Rewritten toward concrete mechanism over abstraction, with the em-dash habit removed — it was the page's strongest tell that a model wrote it.

Primitives. Button family reworked (no y-axis lift on hover), a copy control that morphs icon to checkmark with no tooltip, sanctioned type tokens, and theme colors derived from the --canvas/--ink anchors rather than hardcoded literals.

Verification

pnpm exec turbo run typecheck test build --filter=@bb/web — clean, 73 tests passing. Demos verified visually at desktop and mobile widths in a headed browser.

What this PR is for

Review. It is open in the fork so it can be read publicly and critiqued in detail. It is not proposed upstream yet.

AGENT GENERATED: by Claude Fable 5

ymichael and others added 11 commits August 20, 2026 00:26
## What was wrong

Two async transitions could suppress Edit after submitting a new thread.
Provider capability gating waited on the full
execution-options/model-discovery path, and navigation could briefly
lose the provider facts already loaded by the composer. More
importantly, the timeline controller preserved row identity using only
the row ID and source-sequence range. The server projects
`turnRequest.status` from `pending` to `accepted` onto that same message
row without extending its sequence range, so the merge retained the
stale pending object indefinitely. Edit requires an accepted message;
refresh rebuilt the timeline directly from the accepted server row and
made the icon appear.

## What changed

The thread detail view now reads capabilities from the lightweight,
environment-routed provider roster and reuses composer-warmed provider
facts while that roster loads. The timeline merge also includes
turn-request fields in its identity signature, so an accepted server
projection replaces the pending row instead of being discarded as
unchanged. Regression coverage exercises both the post-submit provider
fallback and the pending-to-accepted row transition. There are no wire,
CLI, guide, or protocol changes.

## How you verified

- Added a timeline-merge regression test that fails before the fix by
retaining `pending` and passes after the accepted row replaces it.
- Reproduced the exact flow in the browser: new thread, submit, navigate
to the active thread, and confirmed Edit appears without refresh while
the Stop run control is still present.
- `pnpm exec turbo run test --filter=@bb/client-core --force` (238
tests)
- `pnpm exec turbo run test --filter=@bb/app --force -- --run
src/hooks/queries/system-queries.test.tsx` (17 tests)
- `pnpm exec turbo run typecheck --filter=@bb/client-core
--filter=@bb/app`
- `pnpm exec turbo run lint --filter=@bb/client-core --filter=@bb/app`
(0 errors; existing warnings remain)
- `pnpm exec prettier --check` on the changed source files

Fixes: delayed edit-action visibility (no linked issue).

> AGENT GENERATED: by GPT-5
## What was wrong

[get-bb#2013](get-bb#2013) established the
uniform rule that `input.accepted` means the provider consumed the
input, never that bb queued it, because an acceptance still pending when
a stale terminal arrives lets that terminal claim the input and complete
an empty turn for a message the provider has not answered. Pi and ACP
still emitted acceptance at dispatch.

Pi's exposure is not just theoretical timing. `PiSdkSession.prompt()`
resolves as soon as pi queues a prompt that arrives while a run is still
unwinding, and the bridge reported that resolution as
`pi/prompt/settled` — a `claimIfIdle` turn terminal. So a `turn/start`
pi merely queued produced acceptance plus a terminal in the same tick,
which the assembler turned into a started-and-completed empty turn while
the real answer ran later under an unaccepted turn.

ACP emitted acceptance in the `turn/start` handler before the turn
opened, and for a steer it emitted acceptance at queue time even though
the queued input is dropped whenever the turn fails or the session stops
— reporting input the agent was never given as accepted into the turn.

## What changed

- `PiSdkSession` tracks pending input consumption for both of pi's
queues instead of steering only, and resolves it from pi's preflight
hook (the input entered a run) or from the queue update that delivers a
queued message. Its `prompt()` now returns that consumption signal
alongside the settlement of the run it started, and reports no
settlement for input pi queued into a run it did not start.
- The pi bridge answers `turn/start` and emits `input.accepted` only
once pi read the input.
- The ACP bridge carries the waiting command with the input and emits
`input.accepted` once the `session/prompt` request carrying it goes out,
so the acceptance names the open turn and a dropped steer is never
accepted. Every turn input still leaves with exactly one reply
([get-bb#853](get-bb#853)).
- `HOST_DAEMON_PROTOCOL_VERSION` 140 to 141: older daemons emit the
queue-time semantics and produce those phantom turns.

Two deviations from the issue's proposed fix:

1. The issue states pi's steer path "already waits for actual SDK
acceptance." It does not — `PiSdkSession.steer()` resolved once the SDK
took the message into its queue, the same queue-time violation as
`turn/start`.
2. Both steer paths deliberately keep answering their command at queue
time, because the runtime fails a bridge request that goes unanswered
for 30 seconds (`sendJsonRpcRequest`). Pi delivers steering only between
assistant turns, so a steer sent during a long tool call would time out;
ACP delivers a steer only when the cancelled prompt is reissued. Neither
can manufacture a turn: a steer's acceptance lands in a turn the
assembler already holds open, and the
[get-bb#2013](get-bb#2013) failure mode needs a
*pending* acceptance. Pi keeps reporting a steer its run never read
through the session error path.

There are no CLI, guide, configuration, or user-facing documentation
changes.

## How you verified

- New pi regression: a `turn/start` pi queues behind a live run emits no
turn events until the queue delivers it, then the acceptance lands in
the turn pi opened. Before the change it received `turn/started` +
`turn/input/accepted` + `turn/completed` — the phantom turn.
- New ACP regressions: acceptance is emitted immediately after the turn
opens rather than before it, and a steer dropped by `thread/stop` leaves
the turn with one accepted input instead of two. Both fail before, pass
after.
- New `PiSdkSession` coverage for queued-versus-direct dispatch, and for
a queued follow-up surviving the `agent_end` that continues into it.
- `pnpm exec turbo run typecheck test --filter=@bb/agent-runtime
--filter=bb-plugin-provider-acp --filter=@bb/host-daemon-contract
--force` — 417, 172, and 52 tests passed; typechecks passed.
- `pnpm exec turbo run build typecheck --filter='...[origin/main]'` — 62
tasks passed.
- `git diff --check` — passed.

Fixes get-bb#2014

🤖 Generated with [Claude Code](https://claude.com/claude-code)

> AGENT GENERATED: by Claude Opus 5

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
## What was wrong

Cursor ACP applies its project MCP approval gate to client-supplied
session MCP servers. ACP has no client permission round trip for that
gate, so Cursor rejected the valid `bb-bridge` stdio config before
spawning it. The same config-advertisement path exists before and after
get-bb#1834, and the get-bb#1932 bootstrap fix remains valid; the missing Cursor
approval was the separate root cause.

## What changed

The ACP bridge now installs the exact bb-owned session MCP fingerprint
in the Cursor project approval store before `session/new`,
`session/load`, or `session/fork`. It limits the workaround to
`cursor-agent` plus the `bb-bridge` config, preserves existing
approvals, serializes concurrent updates, and removes approvals that bb
installed when the session ends.

The MCP child also reports `initialize` back to the bridge, giving
host-side diagnostics for both config construction and successful child
startup. No server/host-daemon wire contract changed, so
`HOST_DAEMON_PROTOCOL_VERSION` does not need a bump.

## How you verified

Added fingerprint, approval-file preservation/concurrency,
session-lifecycle, and MCP initialize diagnostic regressions. These
expose the missing approval before the fix and pass afterward.

- `pnpm exec turbo run test --filter=bb-plugin-provider-acp --force` —
175 passed
- `pnpm exec turbo run typecheck --filter=bb-plugin-provider-acp`
- Isolated manual run against Cursor CLI `2026.06.19-20-24-33-653a7fb`,
with approval installed after ACP `initialize` and before `session/new`;
Cursor spawned and initialized the MCP server

Fixes get-bb#2018

> AGENT GENERATED: by GPT-5
## What was wrong

Pi sampled context-window usage only on SDK `agent_end`, which fires
after the entire agent run. A tool-heavy run contains multiple SDK
`turn_end` events—each after an assistant response and its tool
results—so bb's context meter stayed stale throughout the tool loop even
though Pi's underlying context estimate was changing. See get-bb#2023.

## What changed

- Sample and emit Pi context-window usage on every `turn_end`, while
retaining the existing `compaction_end` update.
- Stop sampling again at `agent_end`; that event is still forwarded
normally for completion and checkpoint handling, but the final
`turn_end` already emitted the same context snapshot.
- Add a bridge regression test with an intermediate tool-result turn and
a final response. It asserts that both usage snapshots arrive and that
`agent_end` does not duplicate the final one.
- Bump `HOST_DAEMON_PROTOCOL_VERSION` from 141 to 142 because the
bundled Pi bridge's daemon-to-server event cadence changed and enrolled
daemons need to update.

## How you verified

- `pnpm exec turbo run test --filter=@bb/agent-runtime --force --
src/pi/bridge/__tests__/bridge.test.ts` — 27 passed. The new regression
fails before the fix because only the `agent_end` sample is emitted.
- `pnpm exec turbo run typecheck --filter=@bb/agent-runtime
--filter=@bb/host-daemon-contract --force` — passed.
- `pnpm exec turbo run test --filter=@bb/host-daemon-contract --force --
test/contract.test.ts` — 38 passed.
- The full host-daemon-contract suite was also run locally: 51 tests
passed and its unrelated fixed gzip-byte measurement test differed under
local Node 26.3.1/zlib (`payload-size.test.ts`); the protocol contract
itself passed.

Fixes get-bb#2023

> AGENT GENERATED: by GPT-5
## What was wrong

Long-lived threads repeatedly scanned JSON payloads for todo tool names,
rebuilt the full conversation outline for unrelated command and
reasoning events, and pruned arbitrarily large sets of resolved deltas
in one synchronous SQLite write. Those paths blocked the server event
loop and delayed otherwise small event inserts. Separately, stall
diagnostics attributed awaited RPC wall time as event-loop work, treated
laptop suspension as a runtime stall, and warned on fresh 512-event
bursts before there was evidence that delivery was stuck.

## What changed

- Add a guarded generated tool-name column and partial todo lookup index
through Drizzle migration 0104.
- Key the conversation-outline cache by the latest outline-relevant
event while still returning the current thread sequence.
- Use the materialized parent-tool-call column in remaining event
queries and cap each resolved-delta prune pass at 500 rows.
- Attribute event-loop stalls only to completed synchronous work; keep
awaited routes visible only as current work.
- Reset server and host event-loop samples after likely system
suspension and report the host heartbeat wake as informational.
- Require a depth-512 daemon event queue to remain queued for five
seconds before warning, while retaining the unconditional thirty-second
age warning.

The timeline byte limit and default event budget are intentionally
unchanged. There are no server/host wire changes, so
HOST_DAEMON_PROTOCOL_VERSION is unchanged.

## How you verified

- pnpm exec turbo run test --filter=@bb/db --force: 406 tests passed.
- pnpm exec turbo run test --filter=@bb/host-daemon --force: 586 tests
passed.
- Affected server suites: 91 current tests passed, including outline
caching, event-loop attribution, and timeline-window regression
coverage.
- pnpm exec turbo run test --filter=@bb/config --filter=@bb/domain
--force: 108 config and 137 domain tests passed.
- Affected app tests passed: 43 tests.
- pnpm exec turbo run typecheck for @bb/db, @bb/domain, @bb/config,
@bb/host-daemon, @bb/server, and @bb/app: all passed.
- Reproduced the todo lookup on a copied 41k-event thread: median
11.46ms to 0.04ms. Reproduced a 5k-delta prune: median 10.05ms to 1.22ms
per bounded pass.

The full server suite was also attempted, but existing npm-artifact
packaging tests do not produce a clean signal in this sandbox; all
suites covering changed server paths passed.

Fixes: N/A — log-driven performance investigation.

> AGENT GENERATED: by GPT-5
## What was wrong

Session-open validation required the newer `localApiPort` field before
comparing daemon and server protocol versions. Daemons from before that
field existed therefore received `400 invalid_request: Required` instead
of `protocol_version_mismatch`; because the daemon only invokes its
protocol self-updater for the latter response, an enrolled older daemon
could retry forever while the server reported it offline.

## What changed

- Default a missing `localApiPort` to `null` at the server boundary so
pre-field session payloads reach the protocol-version check.
- Keep the current daemon-side request type explicit by exporting the
schema's parsed output type.
- Add a regression request frozen to the pre-`localApiPort` wire shape,
which protects future required session fields from bypassing the
mismatch response.
- Bump `HOST_DAEMON_PROTOCOL_VERSION` from 142 to 143 for the
wire-boundary behavior change.

## How you verified

The new server regression reproduces the old daemon payload without
`localApiPort` and now receives `protocol_version_mismatch`; before the
fix, the live equivalent received `invalid_request: Required`.

- `pnpm exec turbo run test --filter=@bb/host-daemon-contract --force --
--run test/contract.test.ts`
- `pnpm exec turbo run test --filter=@bb/server --force -- --run
test/internal/internal-session-protocol-version.test.ts`
- `pnpm exec turbo run test --filter=@bb/host-daemon --force -- --run
src/server-client.test.ts src/protocol-self-update.test.ts`
- `pnpm exec turbo run test --filter=@bb/scripts --force -- --run
test/request-dev-restart.test.ts`
- `pnpm exec turbo run typecheck --filter=@bb/host-daemon-contract
--filter=@bb/host-daemon --filter=@bb/server`
- `pnpm exec prettier --check
packages/host-daemon-contract/src/session.ts
packages/host-daemon-contract/src/protocol.ts
packages/host-daemon-contract/test/contract.test.ts
apps/server/test/internal/internal-session-protocol-version.test.ts`
- `git diff --check`

Fixes: N/A (no linked issue).

> AGENT GENERATED: by GPT-5
## What was wrong

The active thread timeline and the full-history conversation outline
shared the same realtime invalidation group, so every events-appended
streaming batch sent another /conversation-outline request. The server
cache hardening now on main from get-bb#2025 avoids rebuilding for
outline-irrelevant events, but the client still performs redundant HTTP
reads at streaming cadence, and assistant text deltas can still
invalidate the full projection. The outline does not need sub-second
route refreshes because the incremental timeline already carries the
live conversation rows.

## What changed

- Split realtime timeline-window invalidation from conversation-outline
invalidation.
- Refresh the full outline at the terminal turn boundary instead of for
every streaming delta; unknown lifecycle notifications still invalidate
it conservatively, and history rewrites retain the existing full
invalidation path.
- Overlay live timeline conversation rows onto the cached full outline
so current user and assistant messages remain fresh while a turn
streams.
- Reconciled the server cache documentation with the outline-aware cache
added by get-bb#2025 and the new client refresh policy.
- Added regressions proving streaming deltas do not refetch the outline,
turn completion does, and live timeline labels replace or extend a
cached outline.

This implements the client-side pacing direction from get-bb#1972 using a turn
boundary plus live-row overlay instead of a timed debounce. It does not
change the API contract or the server/daemon wire format, so
HOST_DAEMON_PROTOCOL_VERSION is unchanged.

## How you verified

The new realtime invalidation test fails before the change because an
assistant delta refetches the active outline query. The TOC merge test
also fails before the change because a loaded outline always wins over
newer timeline rows.

After rebasing onto origin/main at 0b2723a:

- pnpm exec turbo run test --filter=@bb/app --
src/hooks/cache-owners/cache-owner-registry.test.ts
src/hooks/realtime-cache-effects.test.ts
src/components/thread/toc/ThreadTableOfContents.test.tsx — 80 tests
passed
- pnpm exec turbo run typecheck --filter=@bb/app — passed
- git diff --check origin/main...HEAD — passed

Fixes get-bb#1972

> AGENT GENERATED: by GPT-5
The edges kept disappearing because every ring form fails here for a
different reason: an OUTSET shadow is clipped away by the dissolve
mask, and an INSET shadow paints beneath child backgrounds, so the
chrome bars and rows erased it. A border belongs to the element's own
box — children sit inside it, and the mask keeps it — so the frame
finally holds its left and right edges.

Written as longhand border-width/style/color: the shorthand with a
color-mix value was being dropped, computing to border-style: none.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The rest-and-respond rewrite dropped the .in reveal class from board
cards but left the rule that hid every card without it, so the board
rendered three empty columns.
The nested rows carried a 2px rounded border-left stub per child. The app
draws one 1px hairline down the whole child group and sets children apart
by a 24px indent alone, so the demos now do the same.

Also corrected against the shipped sidebar: finished threads end in the
5px unread dot, never a green check; rows are 28px with a 6px radius and
8px lead instead of 36px and 8px; hover and selected carry the app's own
--sidebar-accent and --state-active strengths rather than half of each;
and project labels take the chrome label recipe. Dropped the invented
'reported back to its parent' row, which has no counterpart in the app.

Headings that had drifted onto one-off oklch literals now read from
--ink-strong with the h1.
Both were the weakest demos on the page and failed the same way: a strong
left column beside a thin right one, with a skeleton panel hanging off the
build frame and a chrome-less rail standing in for a window in spawn.

Each is now one window, on the chrome the gang demo already proved.

Build is a thread: you watch the ask land, the agent scaffold the plugin,
register the CLI and write the skill, and then the panel it built appears
in the sidebar nav beside you. That arrival is the section's whole claim,
and it now happens in the sidebar rather than in a floating preview.

Spawn stops floating its causes outside the frame. A shell command, a
Telegram message and a nightly automation are threads, so they arrive as
threads, each carrying the glyph the app gives a background spawn and a
transcript that opens by naming where it came from. Clicking a row opens
it, as it does in the app.

Thread panes are bottom-aligned now, because a thread you are looking at
is scrolled to the bottom. That is both truer and why neither pane
carries a band of dead space under its last line any more.

Copy corrected against the shipped thread: the composer says 'Ask a
follow-up', and the diff chip reads 'Uncommitted · N files' without the
invented 'Working tree' prefix.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 3f5825f8d2

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +1131 to +1133
.side-row-new /* Only the New-thread button shares its row (with the search chip); the
plugin rows are their own lines and must not stretch. */
.side-row-new .side-act {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Fix the selector so the new-thread action can flex

The intervening comment turns this into .side-row-new .side-row-new .side-act, which never matches the JSX because the button is an immediate child of a single .side-row-new. Consequently, the intended flex: 1 is never applied, so the New thread action shrinks to its content and the search affordance is not aligned at the far edge of the sidebar row.

Useful? React with 👍 / 👎.

lnittman and others added 18 commits August 20, 2026 10:17
68 rules and three keyframes for the old two-column build and spawn
compositions, plus the selectors that could only ever match their state
classes. Verified by diffing the rendered page: every static region is
pixel-identical and the page height is unchanged.
## What was wrong

The mobile thread header showed a "Working" subtitle with a spinner
under the title while a thread ran. The timeline already shows a working
indicator, so the header line was noise.

## What changed

- `apps/mobile/src/screens/thread/ThreadDetailHeader.tsx`:
`headerSubtitle` hides working-tone statuses (Working, Provisioning,
Starting, Stopping, Reconnecting). The header keeps "Needs input",
"Error", "Waiting for host", "Archived", and the child / side chat
label. The spinner is gone.
- `apps/mobile/src/screens/thread/thread-detail-header-model.ts`:
removed the unused `spinning` field from `ThreadStatusPill`.

## How you verified

- `pnpm exec turbo run typecheck --filter=@bb/mobile` passes.
- Manual check in the iOS simulator against the mobile e2e backend: an
active thread shows only the title in the header, and the timeline still
shows "Working...".

Fixes #

> AGENT GENERATED: by Claude Opus 5

Co-authored-by: Claude <noreply@anthropic.com>
## What was wrong

The iOS unread divider used the `attention` token (yellow), a centered
label, and two rules. The web app uses `timeline-accent` (blue), a left
label, and one rule. The two apps did not match.

## What changed

`apps/mobile/src/screens/thread/timeline/TimelineList.tsx`: the divider
now uses `text-timeline-accent` / `bg-timeline-accent`, an uppercase
medium-weight "New" label on the left, and one rule on the right. This
matches `UnreadDivider` in
`apps/app/src/components/thread/timeline/ThreadTimelineRows.tsx`.

## How you verified

`pnpm exec turbo run typecheck --filter=@bb/mobile` passes. Visual
change only; no new tests.

> AGENT GENERATED: by Claude Opus 5

Co-authored-by: Claude <noreply@anthropic.com>
…2034)

## What was wrong

The mobile composer's voice bar
(`apps/mobile/src/composer/VoiceBar.tsx`) showed a red dot, a
"Listening…" label, and an elapsed timer. It gave no live audio feedback
and did not look like the web `VoiceRecordingBar`, which draws scrolling
sound-wave bars from the microphone level.

## What changed

- `apps/mobile/src/composer/voice-waveform-model.ts` (new): pure port of
the web `WaveformVisualizer` math. `meteringToAmplitude` converts
expo-audio metering (dBFS) to a bar amplitude with the same noise floor,
gain, and gamma as the web RMS path; plus the scrolling bar buffer and
the SVG path builder.
- `apps/mobile/src/composer/VoiceWaveform.tsx` (new): draws the bars as
one `react-native-svg` path (3px bars, 2px gaps, round caps, newest at
the right, oldest fading on the left via a gradient stroke). Samples
`readLevel()` at ~30 Hz while active, freezes when inactive, shows flat
idle bars under reduce-motion.
- `apps/mobile/src/composer/VoiceBar.tsx`: web layout — round ghost
cancel · waveform · round primary confirm. While transcribing the bars
freeze and breathe (the `animate-shine-icon` stand-in) and the confirm
button shows a spinner.
- `apps/mobile/src/composer/useComposerVoice.ts`: records with
`isMeteringEnabled: true` and exposes `readLevel()`; the elapsed-seconds
ticker is removed.
- `apps/mobile/app/dev/ui.tsx`: a "Voice bar (synthetic levels)" gallery
section so the bar can be exercised without a mic.

No wire changes.

## How you verified

- New `voice-waveform-model.test.ts` (dB mapping floor/clamp/monotonic,
scroll buffer, path geometry). `pnpm exec turbo run test typecheck lint
--filter=@bb/mobile` pass.
- iOS Simulator (iPhone 17 Pro) through the dev client and the UI
gallery: recording scrolls right→left with the left-edge fade; Check →
transcribing freezes and breathes with a spinner; X → recording resumes.
Checked dark and light.

Fixes #

> AGENT GENERATED: by Claude Opus 5

Co-authored-by: Claude <noreply@anthropic.com>
## What was wrong

The mobile app's left drawer (`expo-router/drawer`) repeated the home
screen: home already shows the grouped thread list with the compose
dock. The drawer only added the server switcher, Settings, display
options, and a path to another thread from inside a thread. It cost an
edge gesture and a scrim, and the bb connect edge-swipe press-through
(noted in the Phase 7 integration entry) came from it.

## What changed

- `apps/mobile/app/(drawer)/` and `src/screens/shell/DrawerContent.tsx`
are removed. `app/index.tsx` (home) is the root of the native stack;
`_layout.tsx` anchors on `index`.
- New `src/screens/shell/WorkspaceMenu.tsx`: the home header's left
button is the active server's initials with the realtime dot. It opens a
bottom sheet with the server label and connection state, the server
rows, Add server, Archived threads, Settings, and UI gallery in E2E
mode. It dims with the compose scrim.
- `HomeScreen.tsx` sets the title (server label) and the header-left
button in every ready state. Search and display options stay in the
header's right slot.
- Dead code removed: `SidebarActionsProvider.onBeforeNavigate`, and
`selected` on `SidebarThreadList` / `SidebarThreadRowView` (only the
drawer highlighted the open thread).
- E2E: new `e2e/subflows/open-settings.yaml` (avatar → Settings)
replaces every `drawer-*` step in 10 flows. `phase1-shell` asserts the
sheet contents, `phase3-threads` searches from `home-search`,
`phase5-connect` drops the header-toggle workaround.
- Docs: `apps/mobile/README.md`, a new entry in
`plans/bb-mobile-progress.md`.

No wire changes.

## How you verified

- `pnpm exec turbo run typecheck lint test --filter=@bb/mobile`: green,
817 tests (adds `workspace-initials.test.ts`).
- Maestro on the iPhone 17 Pro simulator (iOS 26.3) against the e2e
harness: `phase1-shell` and `phase3-threads` pass end to end.
Screenshots show the avatar in the header and the workspace sheet.
- `phase7-settings` was not run: its pre-flight
`phase7-settings-reset.js` got a 400 from the harness before the app
launched (unrelated to this change).

> AGENT GENERATED: by Claude Opus 5

Co-authored-by: Claude <noreply@anthropic.com>
The showroom prose still described a task tracker while the demo built a
review queue. The demo changed and the copy did not follow, so the section
argued with itself. Copy now names what the demo actually ships.

Rendering law:
- The hero and nav entered from opacity 0 with a 6px blur, so the page's
  claim did not exist for the first 120ms — in a screenshot, for a crawler,
  or as the LCP element. Both entrances are transform-only now and the
  headline carries no delay.
- Stat numerals were zeroed at hydration and held that way until the reader
  scrolled to them, up to five seconds. A full-page capture could record
  four zeros about a project that has none. The roll is armed on
  intersection instead, so the true value is on screen at every other
  moment.

Mobile:
- The bento's mobile rule set min-height while the desktop's fixed 440px
  height stayed in force, so the intended reduction never happened.
- Three responsive rules set grid-template-columns on the demo roots, which
  are flex containers. The grid is on .gang-body; they were no-ops.

Accessibility:
- The PR marquee paused for the mouse but not the keyboard, and its rows
  are real links. Pauses on focus-within now, with a visible focus ring.
- Two spinners were missing from the reduced-motion block.
- The copy control's checkmark had no announced equivalent.
- Company names were 14px at 55% opacity because the whole item was faded.
  The mark is faded now; the name is not.

Also removed the last two hover lifts, on the release pill and the provider
marks, neither of which is a control.
… layout

The toggle rearranges twelve tasks; it was blinking between two renders of
the same data. Matching view-transition-names on each card and its row let
the browser move them, with the root cross-fade switched off so a local
toggle does not dissolve the page around it. No dependency: where the API
is missing, or motion is not wanted, it is the same instant swap as before.

Also from the compositing and typography passes:

- The hero's Changes pane animated its own width from 0 to 320px, running
  layout on the largest object above the fold every frame and reflowing the
  thread column with it. The box is final from the first frame now and only
  its contents travel. It also joined on a mount timer, so it appeared in
  neither the prerendered HTML nor any capture taken before the timer; it
  is open in the markup and CSS owns the width gate.
- The subscribe field was 14.5px, which makes iOS Safari zoom the whole page
  when it takes focus. 16px, rather than capping maximum-scale and costing
  pinch zoom in every other browser.
- will-change on the PR marquee, the one element running an uninterrupted
  transform loop.
- font-optical-sizing on Inter's opsz axis, font-synthesis: none, a themed
  ::selection, real underline offsets, and user-select off on demo chrome
  so a select-all takes the page's words and not the scenery's.
…-bb#2038)

## What was wrong

The mobile app had no EAS project, no store credentials, and no release
path. \`app.json\` had no \`extra.eas.projectId\`, \`eas.json\` had an
empty submit profile, and the nightly publish workflow built desktop
only. Nothing could reach TestFlight.

## What changed

- \`apps/mobile/app.json\`: link to the EAS project \`@bb-team/bb-app\`
(slug \`bb-app\`, owner \`bb-team\`, \`extra.eas.projectId\`). Set
\`ITSAppUsesNonExemptEncryption: false\` so each TestFlight build skips
the export-compliance question. The dev-client scheme is now
\`exp+bb-app://\` (e2e launch subflow and the incoming-link test
follow).
- \`apps/mobile/eas.json\`: \`submit.production\` with the Apple team,
App Store Connect app id \`6803559210\`, the API key id and issuer id,
and the key path \`./asc-api-key.p8\` (gitignored via \`*.p8\`).
- \`apps/mobile/package.json\`: pin \`eas-cli@22.0.0\` as a
devDependency so local and CI runs share one version (\`pnpm exec eas
…\`).
- \`.github/workflows/publish-bb-app.yml\`: new \`nightly-mobile-ios\`
job, gated like the desktop nightly jobs. On Ubuntu it writes the
numeric base of the nightly version into \`app.json\` (iOS rejects
prerelease strings; the remote EAS build number tells nightlies apart),
writes the \`.p8\` from the \`ASC_API_KEY_P8\` secret, and runs \`eas
build -p ios --profile production --non-interactive --no-wait
--auto-submit\` with \`EXPO_TOKEN\`. EAS builds on its own macOS workers
and uploads to TestFlight.
- \`apps/mobile/README.md\`: the Release section now documents the real
setup, the manual TestFlight path, the nightly job, and the two repo
secrets.

Out of band (not in the diff): EAS holds the iOS distribution
certificate, App Store provisioning profile, and APNs push key; the
\`EXPO_TOKEN\` (robot, Developer role on \`bb-team\`) and
\`ASC_API_KEY_P8\` repo secrets are set.

## How you verified

- \`eas build -p ios --profile production\` from this branch built green
on EAS (build 1246687c, version 0.0.1 build 2); the pnpm
\`expo-modules-jsi\` patch and \`lightningcss\` override applied on the
EAS image.
- \`eas submit -p ios --latest --non-interactive\` with the committed
submit profile uploaded to App Store Connect (submission b718dedb). The
App Store Connect API reports build 2 as \`processingState: VALID\`.
- \`eas whoami\` with the robot token authenticates as \`bb-team\`
(Developer).
- \`pnpm exec turbo run test --filter=@bb/mobile --force\`: 119 files,
813 tests pass.
- \`actionlint\` on the workflow: only the pre-existing Blacksmith
runner-label warnings.

Fixes #

> AGENT GENERATED: by Claude Opus 5

---------

Co-authored-by: Claude <noreply@anthropic.com>
The section claimed 'the panel is live in your sidebar' and then showed a
nav label appearing. That is evidence a string was inserted, not that
working software was built.

The plugin now opens in a third pane, the way the app lays out a thread
with a panel beside it, so the resting frame carries the whole argument at
once: the ask, the three build steps, the report, the nav row it added, and
the queue itself listing the threads waiting on you. The column is in the
grid from the first frame and only its contents arrive, so opening it costs
no layout — the section holds 800px through the entire loop.

Both loops also run briskly and then hold. Build finished at 5.8s of 12 and
gang at 7s of 11.6, so most of each loop — and most captures — caught a
half-built room. They now settle at 4.3s and 4.6s, complete for roughly two
thirds of their cycle.
20px sat on the bento cards, the stat cards and the PR feed while the
declared ladder ran 8/10/14/28 — the page's most common marketing surface
used a value the ladder did not contain. It is --r-tile now.

The token block also says out loud which values are a ladder and which are
not: the 6px rows, 7px cards and 12px frames inside the demos are the app's
own metrics, transcribed so the recreations match what ships. Reading them
as drift and 'fixing' them to the ladder would break the fidelity they are
there for.

Two row-hover transitions were hand-written at 0.14s ease; they are
interaction feedback, so they take --duration-hover.
The new three-column rule beat the shared .gang-body mobile override on
specificity, in every media query, so at 430px the demo crammed a sidebar,
a thread and a panel into 102px, 168px and 120px. They stack now, the way
the app gives one surface the screen at a time, and the panel takes a top
border instead of a left one.

The thread's context row also broke across two lines at that width. The
branch chip steps out below 620px; the model and worktree chips keep their
line.
## What was wrong

The mobile app still shipped the Expo template icon set: the blue
chevron app icon, the Expo adaptive icon layers, the Expo splash icon
and favicon, and the default `#E6F4FE` adaptive icon background and
notification color. The desktop app ships the black \`bb\` glyph on
white, so the two apps did not match on a home screen.

## What changed

- \`apps/mobile/assets/\`: regenerated \`icon.png\` (1024 opaque, white
background), \`android-icon-foreground.png\`,
\`android-icon-background.png\`, \`android-icon-monochrome.png\`,
\`splash-icon.png\`, and \`favicon.png\` from
\`apps/desktop/assets/icon.png\`.
- \`apps/mobile/app.json\`: the Android adaptive icon
\`backgroundColor\` is now \`#FFFFFF\` and the notification accent
\`color\` is \`#000000\`.

No wire changes, no CLI or doc changes.

## How you verified

- Built a Release app with \`expo run:ios --configuration Release
--no-bundler --device <udid>\` and installed it on an iPhone 15 Pro. The
home screen shows the \`bb\` glyph.
- Compared the new \`icon.png\` against the desktop asset by eye at
1024x1024.

Fixes #

> AGENT GENERATED: by Claude Opus 5

Co-authored-by: Claude <noreply@anthropic.com>
…b#2026)

## Human comments

Before this, (1) custom file openers did not work on files opened via
CMD+P, and (2) a custom file opener would render with nearly 0px height:
<img width="666" height="815" alt="Screenshot 2026-08-20 at 1 14 41 AM"
src="https://github.com/user-attachments/assets/54a8b8b5-f903-49ce-a940-e9f8bdac5c16"
/>


-----

## What was wrong

The `fileOpener` plugin slot could not work end to end — three
independent defects, each of which alone made the slot unusable.
**Reachability:** the secondary panel's file search built its tab
through `createTabForFileSearchSelection` and never called
`createFileOpenerTabForRequest`, so a file picked from the
"+"/quick-open screen always got the built-in preview regardless of the
user's Settings → File openers choice; diversion applied only to file
links and `bb thread open`, contradicting the comment on `openTab` that
claims every file-open flow funnels through it. **Sizing:**
`fileTabContentFillsRegion` resolved the active tab's `actionId` against
`threadPanelActions`, but a file-opener tab's actionId is
`file-opener:<id>` (`FILE_OPENER_ACTION_ID_PREFIX`) and never matched,
so opener tabs always landed in the preview's scroll container rather
than the definite-height region; and the file-opener wrapper was
`min-h-0 flex-1` where the action-tab wrapper 90 lines above it is
`h-full min-h-0 flex-1`. Since that region is a block box, `flex-1` was
inert and the wrapper collapsed to content height, so an opener that
sizes itself with `flex-1` rendered at zero height.

Found while building a Monaco-based editor plugin against the slot: the
opener registered, appeared in Settings, was explicitly selected for
`.ts` and `.json`, and still never rendered — and once forced to render,
occupied ~10px.

## What changed

- `apps/app/src/components/secondary-panel/useThreadFileTabs.ts` —
`selectFileSearchResult` now runs the same opener diversion as
`openTab`, falling back to the built-in tab when no opener matches. The
replace-the-new-tab-screen behavior is unchanged.
- `apps/app/src/views/thread-detail/ThreadDetailView.tsx`,
`apps/app/src/views/RootComposeView.tsx` — `fileTabContentFillsRegion`
now also returns true for file-opener tabs, keyed off `fileOpenerOwner`
(already set on exactly these tabs). A plugin opener owns its own layout
and scrolling, so it gets the definite-height region, matching a
`layout: "flush"` action tab.
- `apps/app/src/components/plugin/PluginPanelActions.tsx` — the
file-opener wrapper gains `h-full`, matching the action-tab wrapper.

No wire changes, so `HOST_DAEMON_PROTOCOL_VERSION` is untouched. No CLI,
guide, or doc surfaces are affected: this restores documented behavior
rather than adding any.

Both sizing changes are required. Without the region fix the opener
fills a scroll container and overflows by its `pb-3`; without `h-full`
the wrapper stays content-sized however tall the region is.

## How you verified

Three tests added to `useThreadFileTabs.test.ts`, alongside the existing
`openTab` diversion coverage:

- `diverts a workspace file picked from the file search` — **fails
before, passes after**. Verified by restoring the pre-fix
`useThreadFileTabs.ts` with the new tests in place: 17 pass, this one
fails; with the fix, 18 pass.
- `keeps the built-in preview for an unmatched file search extension`
and `honors a pinned built-in preference from the file search` — pass
both before and after. They are guards, not regression proofs: they pin
the fallbacks so a future change cannot start diverting files the user
asked BB to keep.

```
pnpm exec turbo run test --filter=@bb/app -- useThreadFileTabs   # 18 passed
pnpm exec turbo run typecheck --filter=@bb/app                   # clean
```

The two sizing defects are **not** covered by automated tests. They are
CSS-in-DOM-context failures — the wrapper collapses only because its
ancestor is a block box — and the only cheap unit test available would
assert a Tailwind class string, which is the kind of test AGENTS.md
discourages. Testing them for real would mean extracting the
`fileTabContentFillsRegion` computation out of both views into a helper;
happy to do that if reviewers want it covered.

Verified manually against a dev server on this checkout with a Monaco
`fileOpener` plugin installed. Before: quick-open a `.ts` file →
built-in preview, with the plugin's registration confirmed live in the
console and "Automatic (Monaco)" selected in Settings; pinning `.json`
to Monaco explicitly changed nothing. Walking the DOM from
`[data-testid="plugin-file-opener-tab-content"]` showed the wrapper at
`clientHeight: 38` inside a `display: block` scroll container at 775px.
After: quick-open renders the plugin editor, filling the panel, with
editing, saving, and find working.

Fixes #

> AGENT GENERATED: by Claude Opus 5

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
## What was wrong

The builtin Provider retry plugin was explicitly registered with
`defaultEnabled: false`, so fresh installations shipped automatic
subscription-limit recovery disabled even though the plugin is bundled
and auto-installed.

## What changed

- Enable `provider-retry` when its builtin registration is first
installed while preserving the stored choice for existing installations.
- Add focused coverage for the fresh-install default and for preserving
an existing disabled choice.
- Require the plugin to reach `running` in the packaged-app smoke test.
- Update configuration docs, CLI guides, the bb-cli skill, and the QA
runbook to describe the new default.
- No server/host-daemon wire behavior changed;
`HOST_DAEMON_PROTOCOL_VERSION` is unchanged.

## How you verified

- `pnpm exec turbo run typecheck --filter=@bb/server`
- `pnpm exec turbo run test --filter=@bb/server --
test/services/plugins/builtin-plugins.test.ts -t 'Provider retry
enabled|preserves an installed builtin'` (2 passed)
- Prettier check across all changed files
- The full builtin-plugin test file also passed the 22 unaffected/new
cases; two existing source-watcher cases hit the local sandbox's
`EMFILE: too many open files, watch` limit.

Fixes: no linked issue.

> AGENT GENERATED: by GPT-5
## What was wrong

Production-bundle QA required choosing between the convenient worktree
isolation of `pnpm dev` and the production build/serving behavior of
`pnpm start`. The dev launcher already derived stable checkout-specific
data and ports, but only launched the Vite development server; the
production launcher used the desired optimized, same-origin bundle path
without applying those worktree selectors.

## What changed

- Added `pnpm start:worktree`, which reuses the development dotenv
cascade and checkout-specific data/server/host-daemon selectors before
invoking the existing production-style source launcher.
- Added a typed worktree runtime policy that is reapplied after
persisted `config.json`/`env.json` settings are loaded, locking the
worktree data directory, ports, inherited skills, listener host, absent
Vite port, and disabled telemetry.
- Made `start-bb.mjs` build children lead process groups and forward
SIGINT/SIGTERM with leader-first shutdown and escalation, waiting until
descendant processes are gone.
- Added focused launcher-policy and real process-tree SIGTERM tests.
- Documented the command in the README, configuration/debugging guides,
and platform support list.
- No server/host-daemon wire contract changed, so
`HOST_DAEMON_PROTOCOL_VERSION` is unchanged.

## How you verified

- `pnpm exec turbo run typecheck --filter=@bb/scripts --filter=bb-app`
- `pnpm exec turbo run test --filter=@bb/scripts --filter=bb-app
--force` (`@bb/scripts`: 18 files/97 tests; `bb-app`: 1 file/65 tests)
- `pnpm exec prettier scripts/start-bb.mjs
packages/bb-app/src/launcher.ts packages/bb-app/src/index.ts
packages/bb-app/test/index.test.ts
packages/scripts/src/commands/run-dev.ts
packages/scripts/test/run-dev.test.ts
packages/scripts/test/start-bb.test.mjs docs/configuration.md
docs/platform-support.md --check`
- The process regression sends SIGTERM to a live launcher fixture and
asserts both its build leader and grandchild are gone before exit.
- Started `BB_TELEMETRY=false pnpm start:worktree`, fetched the worktree
server URL, and confirmed it returned hashed `/assets/*.js` production
bundles with no Vite client or source-module entry. Ctrl-C stopped both
listeners.

Fixes get-bb#2044

> AGENT GENERATED: by GPT-5
…2043)

## What was wrong

`POST /threads/:id/queued-messages` (`createQueuedMessageForThread` in
`apps/server/src/routes/threads/actions.ts`) only checked
archived/stopping/deleted. It never applied the gone-environment rule
(`goneThreadEnvironmentDetails`) that the direct send path applies
through `requireThreadCommandEnvironment`. A thread whose managed
worktree was destroyed (archive → grace window → destroy → unarchive)
kept `status: idle`, answered `201` with a queued-message id, and the
message could never drain: the auto-send hit the same `409
thread_environment_unavailable` internally and the 10 s sweep retried
forever. The CLI also labelled the destroyed worktree "Provisioning".

Issue: get-bb#1789. Report: https://get-bb.github.io/reports/issues/1789.html

## What changed

- `apps/server/src/routes/threads/actions.ts`:
`createQueuedMessageForThread` admits the message inside the same `BEGIN
IMMEDIATE` transaction as the insert, on the freshly loaded thread row
(`admitQueuedMessage`): writable check, environment check, and one
provider-thread-id read. A `destroying`/`destroyed` environment returns
the same `409 thread_environment_unavailable` as the send path. A thread
with `environmentId === null` that already has a provider thread id (the
row was pruned after destroy) returns `409` with reason
`never_attached`, again the same as send. A thread that has not run yet
and has no environment still accepts queued messages, because that is
how messages wait for provisioning.
- `packages/db/src/data/queued-thread-messages.ts`:
`createQueuedThreadMessageInTransaction` for caller-owned transactions;
`listIdleThreadsWithQueuedMessages` joins `environments` and skips
`destroying`/`destroyed`, so queued rows that survived archive → destroy
→ unarchive no longer fail the sweep every 10 s.
- `packages/core-ui/src/environment-display.ts`:
`formatEnvironmentDisplay` labels a `destroying` environment
"Destroying" and a `destroyed` one "Destroyed" instead of
"Provisioning". This changes `bb thread show` and app metadata labels.
- No wire shape changed, so `HOST_DAEMON_PROTOCOL_VERSION` is unchanged.
No new routes or CLI flags.

Not done here: the report also suggests a
`runtime.environmentStatus`/`canRun` field on the thread response. That
is an API contract addition and is left as a follow-up.

## How you verified

- `apps/server/test/public/public-thread-queue-gone-environment.test.ts`
(from the report, extended): 3 of 5 tests fail before the route fix
(`expected 201 to be 409`); the sweep test (real `archiveThread` →
`retire.requested` → `destroy.started` → `destroy.completed` → `POST
/unarchive`) fails before the query change; all 5 pass after. One test
is a control that a never-run, environment-less thread still gets `201`.
- `packages/core-ui/test/environment-display.test.ts`: new case for
`destroying`/`destroyed` labels.
- `pnpm exec turbo run test typecheck --filter=@bb/db
--filter=@bb/server --filter=@bb/core-ui --filter=@bb/cli`: typecheck
clean; db 406/406; core-ui 17/17; cli 452/452; server 1798/1799. The one
failure is `test/internal/internal-skill-trees.test.ts` (file mode `420`
vs `436`), a umask difference on this machine, unrelated to this change.

Fixes get-bb#1789

> AGENT GENERATED: by Claude Opus 5

---------

Co-authored-by: Claude <noreply@anthropic.com>
…2042)

## What was wrong

A thread can never be archived once its environment row is gone.
`pruneDestroyedEnvironments` hard-deletes `destroyed` environment rows
after 7 days with no live-thread guard. `threads.environment_id` is `ON
DELETE SET NULL`, so a still-unarchived thread silently loses its
pointer. `POST /threads/:id/archive` and `/archive-all` then call
`requireThreadHostCommandEnvironment`, which throws `409
thread_environment_unavailable` (`never_attached`). The app shows
"Workspace is not available yet." and the thread stays in the sidebar.
Delete was the only way out.

The archive path only uses the environment for
`requestActiveRuntimeThreadStopIfNeeded`, which is a no-op for an idle
thread with no environment. The requirement is not load-bearing.

Report: https://get-bb.github.io/reports/issues/1924.html

## What changed

Server only. No wire shape change, so no `HOST_DAEMON_PROTOCOL_VERSION`
bump.

- `thread-command-environment.ts`: add
`resolveThreadHostCommandEnvironment`. It returns `null` for a `null`
pointer and still throws for a dangling non-null id.
- `thread-archive.ts`:
`ArchiveThreadWithLifecycleEffectsArgs.environment` is nullable. Skip
the runtime stop when `null`. Hidden forks and
`archiveThreadAndChildren` use the resolver; only non-null environments
enter `affectedEnvironmentIds`.
- `routes/threads/actions.ts`: `routes.archive` uses the resolver.
`routes.stop` uses the resolver in place of its inline null branch (same
behavior).

Not changed: the prune sweep still removes rows that live threads point
at. That is a separate behavior decision; this PR makes archive tolerate
the state.

## How I verified

- New test `apps/server/test/threads/archive-pruned-environment.test.ts`
seeds a thread, marks its environment destroyed 8 days ago, runs
`pruneDestroyedEnvironments`, and archives via `/archive` and
`/archive-all`. Both cases fail with 409 before this change and pass
after.
- `pnpm exec turbo run test typecheck --filter=@bb/server`: typecheck
clean; 1795/1796 tests pass. The one failure is
`internal-skill-trees.test.ts` (file mode 420 vs 436, umask) and fails
identically without this change.

Fixes get-bb#1924

> AGENT GENERATED: by Claude Opus 5

---------

Co-authored-by: Claude <noreply@anthropic.com>
## What was wrong

The machine list and detail redesign in
[get-bb#1996](get-bb#1996) compressed status,
project count, permissions, and update state into a dense set of glyphs
and tooltips. That made common metadata slower to scan, turned the add
action into an ambiguous bare plus, and put the detail identity subtitle
inside a card that implied a separate section. Several flex items were
also forced to remain unshrinkable, so long metadata and detail
key/value groups became cramped on phone-width screens. Tracks get-bb#2049.

## What changed

- Replaced decorative machine, project, permission, and update glyphs
with visible metadata text while retaining status dots, provider marks,
and action icons.
- Restored the labeled “Add a machine” button.
- Moved connection state, platform, and pairing age directly below the
machine heading instead of wrapping that subtitle in a card.
- Made section actions and detail key/value rows stack on compact
screens, and allowed long update status text to wrap.
- Added focused regression coverage for visible status/metadata, the
labeled action, the subtitle header structure, and compact layout
classes.
- No wire, daemon protocol, CLI, persistence, or documentation contract
changes.

### Machine list

| Original | After get-bb#1996 | This PR |
| --- | --- | --- |
| ![Original machine
list](https://github.com/user-attachments/assets/77cb6e87-9225-4dfd-94ee-a689e6820a6e)
| ![Machine list after PR
1996](https://github.com/user-attachments/assets/603e6f8d-22c7-4a34-b2ee-b568c8f862ec)
| ![Text-first machine
list](https://github.com/user-attachments/assets/866a07d8-aaf3-4e8e-bc18-5162e6fb2d7e)
|

### Machine detail

| Original | After get-bb#1996 | This PR |
| --- | --- | --- |
| ![Original machine
detail](https://github.com/user-attachments/assets/12a2ba39-3b3c-4bbc-a0d6-05d165c74bf0)
| ![Machine detail after PR
1996](https://github.com/user-attachments/assets/5d41a136-fcdb-4da6-ae26-7bfe7bb5ed43)
| ![Text-first machine
detail](https://github.com/user-attachments/assets/6002a866-5335-4b80-ad88-4103d6b6bf68)
|

### Narrow screens at 320px

| Page | Before | After |
| --- | --- | --- |
| List | ![Narrow machine list
before](https://github.com/user-attachments/assets/9beaabec-ec93-406a-bfd4-5cafc2b54cb4)
| ![Narrow machine list
after](https://github.com/user-attachments/assets/5a307a92-39a3-4825-92a5-beaf86d5fb7f)
|
| Detail | ![Narrow machine detail
before](https://github.com/user-attachments/assets/7128a6a9-ab48-4c3a-9d24-41f7baf86608)
| ![Narrow machine detail
after](https://github.com/user-attachments/assets/7930edd8-a302-4b27-92b9-85013e2afb31)
|

## How you verified

- `pnpm exec turbo run test --filter=@bb/app --
src/components/settings/MachinesSettingsSection.test.tsx
src/views/MachineSettingsView.test.tsx` — 21 tests passed. The new
assertions fail against the previous icon-only and card-based markup.
- `pnpm exec turbo run typecheck --filter=@bb/app` — passed.
- `pnpm exec turbo run lint --filter=@bb/app` — passed.
- Rendered the real Settings story at true 320px, 390px, 768px, and
1440px viewports. Both pages stayed within the viewport at 320px and
390px with no visible horizontal overflow.

Fixes get-bb#2049

> AGENT GENERATED: by GPT-5.6 Codex
lnittman and others added 30 commits August 23, 2026 12:05
The last two functional <select>s -- the extensions category filter and the
review scope -- carried the same exposure as the model picker: the OS paints
its own popup, and appearance:none only ever reached the closed control.

Rather than a third copy of the pattern, extracted DemoPicker: a button with a
chevron over a menu the page draws, dismissing on outside pointerdown and
Escape through the shared useDismiss primitive, with drop="up" for the one
that sits at the bottom of the composer. All three pickers are on it now.

Verified: zero <select> elements on the rendered page; each picker opens,
selects and closes, in its correct direction.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The hero's sidebar toggle set `hidden` on the aside and narrowed the header
from 320px to 48px -- and the rail stayed exactly where it was. Root cause:
`.side { display: flex }` beats the UA's `[hidden] { display: none }`, because
author styles outrank the UA sheet, and 7,000 lines of stylesheet had no
`[hidden]` rule at all. The attribute was inert on every styled element on the
page. Now it isn't.

Verified: the rail collapses to 0, the thread pane reclaims exactly the 320px
it gave up (748 -> 428), and reopening restores both precisely.

The build and gang windows showed a rail with no way to collapse it. Both have
the hero's toggle now, at the left end of the chrome row where the app puts
it, and their grids hand the column back rather than holding a 320px gap.
Verified: build 320 -> 0 (three columns to two), gang 320 -> 0 (two to one),
both restoring.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Below 1099px the window became a horizontal scroll-snap carousel whose panes
were pages, and below 720px it was scaled with `zoom`. Neither is anything bb
does: the app stays inline to its 767px compact boundary
(use-compact-viewport.tsx:10-36) and then uses drawers. Measured cost of the
old behaviour: at 900px the scroller put main/sidebar/diff at x=29/839/1649,
so two thirds of the product sat off-screen; at 390px `zoom: 0.646` rendered a
mandated 32px row at 20.7px and body text near 8.7px.

The panes now drop the way the app drops them -- rail at the compact boundary,
changes pane at two-up -- and below 720px the window keeps its real 980px and
runs off the right edge under a 48px dissolve. A legible slice of the product
beats an illegible whole of it. Deleted useFitMock, --mock-scale and
--mock-visible-width; no fifth breakpoint was added.

Verified: zoom 1, body text 13.5px, step rows 20px, no page-level horizontal
scroll, and zero scroll-snap rules left anywhere in the stylesheet.

Laws 1, 4 and 5.

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

Behaviour was fixed earlier; geometry had never been checked. Measured against
the real prompt box and its shared-ui primitives, then corrected:

- The picker was animating a background over 160ms. The app's pickers carry
  LIST_HOVER_TRANSITION -- `transition-none` -- and do not animate at all
  (option-display.tsx, motion.ts:22). That was motion the product does not
  have, so it is removed rather than retuned.
- Picker geometry to the OPTION_BASE contract: padding 8px -> 4px (px-1),
  gap 7px -> 4px (gap-1), font 13px -> 12px (text-xs), muted -> foreground on
  hover.
- Glyphs inside the picker and the send button to 16px. Every SVG inside a
  shared-ui Button is painted `size-4` by the cva base whatever the call site
  authors, so the landing's 15px and 12px were both off.
- The context row is locked to 24px, 4px below the box, inset 15px/14px with
  8px between chips, and clips rather than wrapping -- it was 26px tall on a
  10px margin with 4px gaps, and wrapped to 47.2px across two lines.
- Chips to h-6 (24px) and text-xs.
- Editor text to 13px/1.7, from 13.5px/1.5.

Verified: zero remaining drift across seventeen measured properties, no
page-level horizontal scroll.

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

The gang section carried a second copy of the hero's provider rail. The claim
directly above it already names all eight providers in prose, and the demo now
shows a different one per thread, so the logos were saying a third time what
the section had already said twice.

Removing it left `.act-lead .providers` and `.band-copy .providers` (and its
narrow-screen override) matching nothing. Each was a standalone block, checked
against the DOM before deleting so no shared declaration was orphaned.

The signup pair takes the install card's 660px measure, so the closer's two
acquisition surfaces line up: input 548.3 + 12 gap + button 99.7 = 660, left
edges flush with the card above.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
SpawnDemo renders its rail rows as <button>, in a demo the selection rule never
named, so it silently kept two bugs the other three had fixed: no visible
selected row, and an unread dot derived from `open.id === c.id` that re-armed
every time you left a thread.

The cause was specificity. `.sub-demo a.sub-row` (0,2,1) outranked
`.sub-row.is-open` (0,2,0), so the state rules had to be rewritten per demo and
per element type -- and a fourth demo using a different element fell straight
through. The scope now sits in :where(), which contributes no specificity, so
the state rules win on their own and apply to every rail row anywhere.

Verified across all four demos: the selected row paints
oklab(0.3211 0 0 / 0.118) in each, including SpawnDemo's button rows.
SpawnDemo's unread state is modelled like SubagentsDemo's -- read once, read
for good.

This is a patch, not the fix. sol is auditing for the shared primitive that
makes the whole class impossible.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Blog had four different left edges on one page -- nav and footer on the 1200
rail, the post index at 924, the post column at 680, and the signup form at
660 aligning with nothing. Changelog disagreed again: its release bodies were
686 and its page head took the full 924.

The 660 was mine, from this morning: I sized the signup to match the closer's
install card, and it leaked onto both content pages where no install card
exists. It is scoped to `.closer-subscribe` now, where the reason for it is.

Added `--prose: 680px` beside `--rail`, and put blog posts, the blog page head,
changelog release bodies and the signup on every content page on it. The two
pages now agree with each other and with the landing's rail rather than each
picking a number.

Verified at 1440: blog index page-head / post / form all x=974 w=680; post
detail article-head / post / form the same; changelog release body and form
both 680; nav and footer 1200 on all three, matching the landing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
My earlier pass invented `--prose: 680px`, which was both the wrong width and a
duplicate: the stylesheet already declares the ladder (`--rail` 1200, `--stage`
1440, `--measure` 640) with a comment saying sections use these and never
bespoke widths. Removed it.

Blog, the post page and the changelog now put their heads, bodies and releases
on `.rail`, so every block on those pages shares the 1200 the landing's demos,
cards and PR feed already use, and the nav and footer they were already using.

The signup is the landing's own SubscribeCard on all three pages instead of a
bare bordered strip -- same container, same 660 form centred inside it. The
card takes an optional description and id so each page keeps its own line and
its #subscribe anchor.

Verified at the same viewport on all three: page head, body, signup card and
footer all report the nav's exact x and width, with the form at 660 inside the
card and no page-level horizontal scroll.

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

The agent stat was a floor presented as a measurement. It came from a full-text
search for "AGENT GENERATED", a tag upstream asks agents to append -- so any
agent PR without the line was uncounted, and "of those, written by agents"
claimed exhaustive detection the method cannot provide.

The per-row chips had a second problem on top of that: the feed filters out
lnittman, who writes most of the human PRs, so 17 of 18 visible rows carried a
chip against a 60% aggregate. A label on 94% of rows distinguishes nothing, and
each row asserted individually what the count asserted better.

Both removed, along with the second GitHub search, the `agent` field on feed
rows and `agentMergedLastMonth` -- otherwise the build kept fetching data
nothing rendered. Stat grid is three columns.

Hero type set to the Paper reference: 64px / wght 510 / -0.022em / 100%, on
Inter Variable, replacing 72px / 640 / -0.045em, which was heavier and tighter
than the mark it aimed at. The lead follows at 15px / 160% / -0.011em.
Verified computed: 64px, "wght" 510, -1.408px, 64px line-height.

Blog's page head is centred on the landing's own treatment, its sub on the
same .section-lead values. Changelog release prose lost its 38em/44em caps:
they were sized for a 686px column and, once the body moved to the rail, they
-- not the content -- were deciding where lines broke. Verified: li now 962
wide, matching its column.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
"Get new posts by email" and its changelog twin were bare text links doing a
call-to-action's job. They keep the anchor -- a real in-page target that works
with JS off, which is how the landing's own GitHubLink is built -- and take the
btn btn-ghost recipe: 39.4px, 6px radius, 14.5px/520, 16px glyph on an 8px gap.
The generic .meta-row link colour is scoped :not(.btn) so it stops fighting the
button's own treatment.

Reverted the centring from the previous commit: the blog head sits flush left
like the changelog's, with h1, lead and button all on the same edge. The lead
keeps the landing's .section-lead type (15px / 160% / -0.011em) -- that part
was cohesion worth having, the centring was not.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The closer room takes the changelog media stage's ground -- the same 22px
radial-dot pitch at the same 12% ink -- so the last room on the page sits on
the surface its demo cards do.

RELEASE_MEDIA already existed as a version-keyed map with one entry. Filled
three more, each drawn only from what that release's own notes claim:

- 0.35.0 Plugins: plugin pages as flat sidebar rows with Automations separate
  from Extensions, which is what those notes describe.
- 0.34.0: the cross-provider Ask User Question card, built from the landing's
  own .askq so it matches the recreation on the homepage.
- 0.0.31 Splits: three panes side by side, the active one carrying the app's
  selected surface. The release says up to eight; three is what the card fits
  and the prose beside it carries the number.

New .split-card and .ask-card styles reuse the existing media-card shadow and
the 48px chrome row. The pane title needed its own box for the ellipsis -- as a
bare flex child it hard-clipped mid-word.

Four of ten releases now carry a visual, all on real surfaces.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Plugins and skills listed with their install state, on the app's own metrics:
a 48px chrome row over 28px rows, verified. The names, the plugin/skill split
and the install affordance all come from that release's own notes -- nothing
depicted that the changelog does not claim.

Five of ten releases carry a visual now. The remaining five are model-catalog
refreshes and fix rollups with no surface worth drawing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The label already says macOS; the mark was restating it. Removed the
hand-authored AppleSolidIcon with it, since that was its only call site.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Four terms in four slate squircles instead of a middot-separated run. The dots
were doing the separating; the tiles do it now, so they go.

`corner-shape: squircle` is a real superellipse where the browser supports it
(Chrome does, verified computed) and degrades to the 10px radius everywhere
else, so no fallback branch is needed. Ground derives from --ink-strong at 5%
over the canvas with the shared --ring edge; 26px tall on a 6px gap, centred
and wrapping.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
sol's steps 1-2 of the seven-step parity refactor, verified here before
landing:

- demo-app-primitives.tsx: DemoThreadScene (monotonic read-through reducer --
  selection can never re-arm an unread dot, by construction), DemoThreadRail
  (32px project rows, 28px thread anchors, 1/2/4px spacing, file-private row
  renderer: openable models become anchors, scenery becomes inert divs),
  chrome/action-row/glyph primitives with a 12|14|16 glyph type.
- One marked canonical CSS block owns every .demo-* selector; state rules are
  single and unscoped.
- Two enforcement suites (10 tests): reducer/status-priority/SSR contracts,
  and the CSS contract -- canonical-block ownership, token-sourced metrics,
  exactly one selected and one hover rule with no demo names or tag-qualified
  arms in either.
- Subagents is migrated. Verified live: project rows 32px, thread anchors
  28px, parent-to-child 2px, one painted selection, aria-current on the open
  row, and A/B/A leaving both threads read.

Gates: build, typecheck 0, 89 tests (was 79).

Of note from the run itself: the delegate wrapper died with its parent shell,
so its fallback lane re-spawned the same brief on pi over the finished work;
it was stopped before writing. The codex session's own result file carried the
report the wrapper lost.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The felt-quality interview picked "roughness" as a twin failure; this is the
measured pass over page chrome. Verified computed before and after; the demo
surfaces were left to the primitive migration.

- .sec-title carried the page's only 700, with the body's 1.6 line-height
  leaking onto a display heading. It joins the display family (64/510 ->
  54/600 -> 32/600) at 1.1.
- The signup title had the same line-height leak: 22px on 1.6 -> 1.15.
- The stat numerals sat alone at 620 between the display 600s and nothing;
  now 600. (.ext-detail's 620 is demo territory and stays for the migration.)
- The hero tiles were 10px, off the 6/8/12/16/20 ladder -> 8px, squircle kept.
- The hero's two large breathing gaps (callout->h1, tiles->providers) were 48
  and 44; both 48 now.

Checked and deliberately NOT changed: the --act *0.6/0.45/0.35 band system,
the stat-card inset top-light, the four-step ink ladder, 480/550 label
weights, and the callout's stronger border on its canvas ground. Zero 700s
remain in chrome.

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

The felt-quality direction is still chrome, living product. The chrome half:

- Nav entrance (hdr-down) and the hero's staggered fade-up are gone -- removed,
  not replaced, per the review that called them marketing-animation language
  and the owner's stillness call. Their reduced-motion escapes go with them.
- The button press scale (0.985) is gone with its --duration-press token; the
  app's buttons are colour-only. Buttons keep 0ms-in/150ms-out background/
  box-shadow/color.
- Provider marks are bare SVGs, not controls; their hover ink shift and
  transition were invented motion. Removed, including their entries in the two
  shared selector lists -- each list kept its other members (checked; this is
  the surgery that once broke the hover law).

And a real bug the measurement kept tripping over: applyThemePreference sets
data-theme-switching to kill every transition for one frame, removing it in a
requestAnimationFrame -- which never fires in an occluded or backgrounded
document. React effects still run there (MessageChannel, not rAF), so the
attribute was set and never cleared, leaving the whole page's transitions dead.
A 120ms timeout now backstops the rAF; whichever fires first wins. This was
also the source of every "all transitions are none" misreading this session.

Verified: attribute clears on load, hero and nav report zero animations,
buttons at color-only 150ms, hover law intact. 89 tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
sol's step 3, verified here before landing. Both demos now render their rails
through DemoThreadScene/DemoThreadRail: 28px thread anchors, 32px project
rows, 1px siblings, 2px parent-to-child, 4px between projects -- each delta
app-cited in the report.

The one behavioral catch: Gang suppressed status glyphs on the selected row
(`open.id === thread.id ? null : status`), so a running thread's spinner
vanished exactly while you looked at it -- the derive-from-selection family
again. The primitive retains status while selected, as ThreadRow does.

Preserved and verified live: Build search filtering with the selected pane
retained outside the filtered nodes, both rail toggles (320 -> 0 -> restore),
Gang's per-thread provider chips (Pi/Codex bind per selection), status
priority, and the nested guide. The migrated-source guard now covers Build and
Gang; Spawn's legacy CSS stays intentionally live for step 4.

Gates: build, typecheck 0, 93 tests (was 89).

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

sol's step 4 -- the step it flagged highest-risk -- interrupted mid-flight by a
task kill and finished anyway; verified here before landing.

The delegate's own reviewer blocked on "three hrefs, one rendered target",
but it reviewed the mid-flight tree: the finished work routes every cause row
through /?spawn=<id>#spawn-<id> with validateSearch on the landing route, so a
copied link or no-JS navigation SSRs with the matching pane target present.
Verified: /?spawn=cli renders id="spawn-cli" server-side.

Verified live: three openable 28px anchors, seven scenery rows that are inert
divs (no href, default cursor, untabbable), the rail toggle, zero legacy
sub-row/gang-row markup left in the demo, and A/B/A leaving read threads
read. Prerender carries every title and final source (checked with python --
grep silently fails on this file's 52k-char lines).

Arrival feeds only attentionRevision now; the local read Set and the row
entrance animation are gone. New spawn-demo-routing module owns the
search-param contract with its own tests.

Gates: build, typecheck 0, 98 tests (was 93).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
sol's step 5, a 55-minute run whose harness record says "failed" only because
a task kill landed during teardown; the inner process reports completed and
its final report was recovered from the session's response file. Its two
"failed" sibling sessions were its own attempts to spawn an external UI
review, refused by the admission lease; it said so plainly rather than
claiming review-complete. I was that review.

HeroAppMock now renders its rail, status, menu rows and window chrome through
DemoThreadScene/DemoThreadRail/DemoWindowChrome. Thread rows are anchors with
SSR-restorable hrefs (/?thread=<id>#hero-thread-<id>, sharing the Spawn
routing module); done-checks became attention-driven unread state;
selectedId: null carries the Extensions/Automations/New Thread views without
discarding read state.

Verified live: 28px thread anchors, 32px menu rows, 48px chrome, zero legacy
.trow, all four views render, read state survives a view roundtrip, sidebar
toggle 320 -> 0 -> restore, search filters and escapes, rename, picker opens
and escapes, diff controls present, and the "Edited ProjectList.tsx"
disclosure at 130.4px open / 0 closed.

That last one produced a false alarm worth recording: the disclosure measured
0 when open. Cause: the automation tab was hidden (visibilityState "hidden",
zero rAF frames, both CSSTransitions pending at currentTime 0), so every
class-toggled transition reported its start value forever -- the same frozen
timeline behind the theme-switching bug. Not a regression. Measuring
transitioned properties in this environment requires finishing the animations
first (getAnimations().forEach(a => a.finish())).

Gates: build, typecheck 0, 104 tests (was 98).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Step 6 of the parity refactor, done directly after the delegate failed three
times on infrastructure (one admission refusal, one lease wait, one runner
death under a 176-process codex pile) and zero times on content.

Live DOM audit first: zero legacy row/status classes remained anywhere on the
landing page -- all 153 rows are demo-thread-row. The only live consumer was
the changelog's two sidebar media cards, so they moved onto the primitives
too: machines become 32px project rows over 28px threads with the child at
the rail's real indent and the working spinner in the status slot; the
selected row is a real anchor to that release's own #0-0-30; plugin pages are
32px DemoSidebarActionRows with 16px glyphs. One rail everywhere.

Then the deletion, bounded to declarations and list-aware: 30 dead blocks
removed, 6 shared selector lists trimmed with their live members kept. Two
hazards surfaced and were fixed before the build passed: a comma split inside
:where(...) heads mangled two all-legacy blocks, and a hover media block was
left holding an orphaned head with no body. Each is exactly the class of
deletion accident this session has already paid for once.

App-recreation glyphs moved onto the app's 12/14/16 vocabulary (nav chevrons,
toggle, diff chevron and file icon, pr/context glyphs, sidebar action glyphs);
the Telegram and phone recreations keep their own, since they depict other
apps.

The guard grows three bans -- no legacy class in index.tsx or changelog.tsx,
no legacy selector in the stylesheet, and the named glyph rules held to
12/14/16 -- each proved by violating it (exit 1, named failure) and restored
(exit 0).

Verified live on both pages: every demo at 28/32/48 with one painted
selection and no horizontal scroll; changelog cards at 32/28 with 1px
siblings and the nested child at depth 1. Prerender carries every title with
zero legacy classes on either page.

Gates: build, typecheck 0, 107 tests (was 104).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Step 7's acceptance matrix, and the one thing it caught that metrics could
not: three demos (gang, build, spawn) bottom-anchored a short transcript with
`.gang-feed { justify-content: flex-end }` while the hero's feed read
top-down -- two recreations of one pane disagreeing. The app's timeline is
ThreadTimelineSurface's `flex-1` ConversationTimeline, a plain top-down
container; the only justify-end in the thread views is horizontal (user
bubbles, action buttons). The anchoring was a convention the app does not
have, with a comment arguing it was "truer". Removed. A short transcript now
leaves its space below the last line, above the composer, everywhere.

Verified: first line 16px from the pane top in all three demos, controls
still bottom-anchored at 16px, panes unchanged at 592.

The rest of the matrix, verified live at 1440: dark and light re-tint every
surface with the selection at the app's 11.8% of ink in both; 13
reduced-motion rules settle every demo animation at rest; coarse-pointer rows
go to --thread-row-h-coarse; anchors carry a 2px focus-visible ring, scenery
rows are untabbable with no href and a default cursor, action rows are real
buttons; row transitions are none; the primitives carry zero animations;
prerender holds every title with zero legacy classes; no horizontal scroll.

Parity refactor complete: seven steps, five demos plus the changelog cards on
one primitive, guarded at the source. 107 tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The "Ask for a feature, watch it appear" section promised a review-queue
plugin — a bb review-queue command, a skill, and a sidebar panel — but the
demo built Tasks and showed a Tasks board. Replace it with two sibling threads
carrying the verbatim transcripts of real bb runs (Codex and Claude Code) that
each built the plugin on a seeded storefront workspace, and swap the panel for
the real Review queue those runs produced (pending → reviewing → completed).
The composer context reflects each thread's provider, and the head copy now
names bb review-queue. Storefront fixtures only; no user data.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…r density

Two parity defects made the demos feel unlike the app:

1. Window height shifted when switching threads. The window wrappers used
   `min-height: var(--demo-window)`, so a taller transcript grew the window
   past it and every thread switch resized the whole container. Pin the height
   with `height: var(--demo-window)` and let the timeline scroll inside
   (`.gang-feed` gets `min-height: 0; overflow-y: auto`), with the rail and
   panel scrolling internally too — the app's fixed-viewport model, where the
   window never resizes and only the conversation scrolls.

2. The rail was looser than the app. The app's sidebar nav accessories and
   thread rows are h-7 (28px) at 13px, and project labels a tight 20px
   (ProjectList.tsx:798) — not the 32px SidebarMenuButton the demo modeled.
   Drop the rail to 13px, nav/action rows to --thread-row-h (28px), and add a
   --project-label-h (20px) token for the label. Update the CSS-contract guard
   to the app-accurate values.

Verified against the live app at localhost:13030. Gates green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
At <=900px the app-window demos kept desktop behaviour and read poorly:

- The build demo's window stayed pinned to its 640px desktop height while its
  panes stacked, so the Review panel was squeezed to ~160px and its four rows
  spilled over the footer. There is no thread switching at this width (the rail
  is hidden), so the fixed height has no purpose here — let the stacked demo
  grow to its content and nothing overlaps.
- The hero mock kept its real 980px width and faded off the right edge instead
  of reflowing. Every other demo reflows to a full-width, thread-only view, so
  the hero now matches: rail and changes pane hidden (already), conversation
  full-width and wrapping, aligned to the page content — the way the app's own
  thread reads on a phone.

Verified at 390-500px; desktop untouched (all changes are media-query scoped).
Gates green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The demo sidebar mirrors the app's, but its selected/hover tints were only
defined once (light: --state-active 11.8%, --sidebar-accent 8%) and never
re-picked for the dark ground — so in dark mode, where the demos render, a
selected row read at 11.8% while the app's reads at 22.5%, and hover at 8%
vs the app's 12%. The demo's states were visibly fainter than the app's.

Re-pick both in the .dark block to the app's dark values (22.5% / 12%), the
same way the block already re-picks border/surface for a dark ground.

Add a cross-package parity guard (demo-app-parity-guard.test.ts): it reads the
app's real theme.css and asserts the demo tokens still track it — row height
(--bb-sidebar-row-height) and both tint percentages, light and dark. When the
app moves one of these, CI fails and forces a re-sync, so the structural 1:1
survives the app evolving. It was this guard's logic that surfaced the tint
drift. Content and chrome text stay curated fixtures and are not asserted.

Gates green (111 tests). Guard verified to fail on a deliberate drift.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two layout bugs in the hero mock, both from text wrapping a single-line row:

- The context row's "Full Access" chip (.ctx-perm) had no white-space rule, so
  the two-word label wrapped to a second line — doubling the row to 44px in a
  clipped 24px row and spilling the label over the floating send pill below it.
  Add white-space: nowrap, as .ctx-branch already has.
- The collapsed composer's long placeholder ("Ask for a follow-up. @ to
  mention files, folders, sections, or threads") wrapped in the one-line
  textarea and the row height cut off the second line. Clip it at the right on
  a single line, as the app does; expanded (5 rows) it still wraps.

Verified at 1440 / 874 / 500px, light and dark. Gates green (111 tests).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The hero told a split story: the thread was "Fix sidebar search" editing
ProjectList.tsx, but the Changes panel showed promo.test.ts — an unrelated
storefront-promo diff — and the window sat in ~246px of dead space below a
short transcript.

- Re-author the diff panel (DIFF_LINES + file label) to the ProjectList.tsx
  search-filter change the thread describes, so the panel and thread tell one
  story. The +/- stats derive from the lines, so the header follows.
- Add the function's render lines so the panel fills its pane rather than
  trailing off into empty space; context lines don't change the stat.
- Size the mock to its content (700px -> 544px at >=1100) so both the thread
  and the panel nearly fill it — a busy slice of the product, not a mostly
  empty one. Thread dead space drops 246px -> 90px.

Audited across 1314 / 874 / 500px in dark and light: no overflow, no page
scroll, no wraps. Gates green (111 tests).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The demo window chrome headers didn't match the app's thread header:

- The app's thread title is its body size at regular weight and full ink — a
  text-sm <p> computing to 13px / 400 / --ink. The hero's .bar-title rendered it
  13.5px / 600 (bolder, larger), and the other demos' .dwin-title rendered it
  12px / 550 / --dim (smaller, bolder, dimmed). Both now read 13px / 400 / ink,
  as the app does.
- The hero's Commit button was 32px (the composer button height) at weight 500;
  the app's header controls are 28px (thread-row height) at 400. Match it.

Measured against the live app thread header at localhost:13030. Gates green
(111 tests).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Computed-style diff against the live app: every piece of the app's thread text
is 13px / 400 — the user message, the assistant message, the composer input.
The demos ran 0.5-1px larger: the assistant/say text inherited the 13.5px demo
base (.build-demo / .gang-demo / .spawn-demo / .sub-demo, plus the hero's
.msg-say / .msg-user), and the user bubble (.gang-you) was 14px. All now 13px,
matching the app — the same root as the rail and title 13.5-vs-13.

Explicit sizes stay as measured: context chips 12px, diff lines Fira Code,
window title 13px. Gates green (111 tests).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.