Landing page elevation: real bb surfaces, section rhythm, and a rebuilt demo set - #6
Landing page elevation: real bb surfaces, section rhythm, and a rebuilt demo set#6lnittman wants to merge 319 commits into
Conversation
## 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.
There was a problem hiding this comment.
💡 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".
| .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 { |
There was a problem hiding this comment.
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 👍 / 👎.
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 | | --- | --- | --- | |  |  |  | ### Machine detail | Original | After get-bb#1996 | This PR | | --- | --- | --- | |  |  |  | ### Narrow screens at 320px | Page | Before | After | | --- | --- | --- | | List |  |  | | Detail |  |  | ## 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
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>
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.mjsand 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/--inkanchors 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.