Skip to content

Generate the vendored bb UI instead of editing it - #13

Open
ariofrio wants to merge 15 commits into
mainfrom
bb/share-code-between-plugins-thr_zqcve598g7
Open

Generate the vendored bb UI instead of editing it#13
ariofrio wants to merge 15 commits into
mainfrom
bb/share-code-between-plugins-thr_zqcve598g7

Conversation

@ariofrio

Copy link
Copy Markdown
Owner

Every file under a plugin's components/ui/ is a copy of bb's own
packages/shared-ui, generated verbatim into the @bb registry. Editing one
forks the code running in the window around it, and nothing said so — so three
plugins each kept their own components.json, Breadcrumbs and Icons had their
pin bumped to v0.39 without a re-vendor, and four hand edits had accumulated.

The generator

vendor-ui.json holds the single registry pin and the items each plugin imports
directly; registryDependencies supply the closure. scripts/vendor-ui.mjs is
the only writer, and vendor-ui.lock.json lets --check verify the tree
offline — cheap enough for a pre-commit hook and needing no network in CI.

It reports five things, each of which fired for real while building it:

failure how it showed up
a hand-edited component the four edits below
a deleted one
a pin bumped without rebuilding proven by bumping bb-app to 0.40.0
a file no closure explains found both orphaned use-pointer-coarse.ts copies, dropped upstream at v0.39
a copied literal that drifted proven by editing CHROME_SECTION_LABEL_CLASS

The pin is anchored to each plugin's bb-app devDependency — the thing that
decides the build toolchain and shim configuration — so both sides live in the
repository and the check stays offline and hard-fails.

Two values copied out of bb by hand, which no registry item can supply, are now
named in vendor-ui.json: build:ui refuses any it can no longer find upstream,
and the lock records them so an edit is caught offline.

The four edits, composed away

None survive, and three made the plugin's own code more consistent:

  • responsive={false} → bb's CompactViewportOverrideProvider, which exists for exactly this
  • destructive context items → the className line from bb's own app, which restores data-[last-hovered]:text-destructive that the fork had dropped
  • showChevron / indicator="check" → one shared FilterRowCheck. The actionable filter row already hand-rendered its check; now the plain row does too, so the projects/sections menu finally renders one selected state instead of a check on some rows and a circle on others
  • Icons' popover titlemobileTitle on compact, a plain h2 on desktop; exactly one title per mode, no nullable prop

Along the way

scripts/bb-cli.mjs resolves the bb CLI to an absolute path and refuses
anything else. A bare BB_CLI re-enters the launcher shim through npm's
node_modules/.bin, and a plugin whose build script is bb plugin build then
forks without bound. run.mjs, install-plugins.mjs and verify-types.mjs all
held that trap; six regression tests cover it.

npm run dev:stack boots a throwaway bb with these plugins installed — its own
data directory, server and host daemon — for trying a change by hand.

AGENTS.md gains the rules this work produced, including why a wait keyed to a
name bb also uses fails silently on one machine and loudly on another.

Verified

Per-plugin npm ci --workspaces=false, as CI runs it: thread-stages 208/208,
icons 71/71, breadcrumbs 21/21, missing-keyboard-shortcuts 62/62, all typechecking
clean. Root suite 51/51. check:ui, check:heading-icons and check:screenshots
green.

Checked in the compiled stylesheet rather than the source, since markup that
reads correctly can still render nothing: [&>span:first-child]:hidden,
[&>svg]:hidden and all four destructive utilities are present in dist/app.css.
And confirmed in a live bb that the Breadcrumbs crumb and the Icons header icon
still mount after regenerating every vendored component.

Recapturing moved no image — only the lock, which digests the plugin sources a
shot was captured from.

Written by Claude

@ariofrio
ariofrio force-pushed the bb/share-code-between-plugins-thr_zqcve598g7 branch 2 times, most recently from 2b203ed to ccf5406 Compare August 22, 2026 21:07
ariofrio and others added 11 commits August 23, 2026 02:32
The @bb registry is generated verbatim from bb's own packages/shared-ui, so
every file under a plugin's components/ui is a copy of the code running in the
window around it. Editing one forks bb's UI kit, and nothing said so: three
plugins each kept their own components.json, Breadcrumbs and Icons had their
pin bumped to v0.39 without a re-vendor, and four local edits had accumulated
across the copies.

vendor-ui.json now holds the single pin and the items each plugin imports
directly; registryDependencies supply the closure. scripts/vendor-ui.mjs is the
only writer, and vendor-ui.lock.json lets --check verify the tree offline, from
a pre-commit hook and from CI. It reports a hand-edit, a deletion, a pin bumped
without a rebuild, and a file no closure explains — which is how the two
use-pointer-coarse copies surfaced, orphaned when responsive-overlay stopped
importing it at v0.39.

The four edits are composed instead. Menus that must stay dropdowns on a narrow
window use bb's own CompactViewportOverrideProvider; destructive context items
take the classes bb's app gives them, including the last-hovered text colour the
fork had dropped; and the thread filter draws its own check and submenu chevron
the way its actionable rows already did, so its two row types finally render the
same selected state. The Icons picker hands its label to the drawer shell on
compact and renders its own heading on desktop, leaving exactly one title per
mode without a nullable mobileTitle.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A bare "bb" in BB_CLI forks without bound. npm prepends node_modules/.bin to
PATH, and a plugin depending on bb-app has a `bb` symlink there pointing at the
launcher shim. The launcher spawns `process.env.BB_CLI ?? <bundled cli>`, so a
bare name re-resolves through that same PATH and re-enters the shim — and since
the launcher passes its environment down, every child repeats it. A plugin whose
build script is `bb plugin build` then spawns itself until the machine runs out
of memory.

The new dev-stack script did exactly that by passing BB_CLI: "bb" literally.
run.mjs and install-plugins.mjs held the same trap one step further back, in a
`process.env.BB_CLI ?? "bb"` fallback that only stayed harmless while BB_CLI
happened to be set; verify-types.mjs spawned the bare name directly.

scripts/bb-cli.mjs now resolves it once, refusing anything that is not an
absolute path outside a node_modules/.bin directory, and saying why. It walks
PATH itself rather than shelling out to `which`, which would have to be found on
the very PATH being inspected.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The fixture furnishes the window; it is not what the stack is for. Seeding now
warns and steps over a failure instead of taking the server down with it, so a
stack whose plugins all installed stays usable.

Also print the invocation that actually reaches this stack. BB_DATA_DIR alone
leaves the CLI talking to the developer's own bb — BB_SERVER_URL is what
retargets it, and an agent shell's inherited thread, project, and environment
ids have to be cleared or they name rows this stack has never seen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Thread stages hand-wrote components/ui/chrome-style-tokens.ts, picking one
constant out of bb's file of the same name. That file is one of the eighteen
shared-ui sources the plugin registry does not publish, so no closure can
explain the copy and no generator can produce it — vendor-ui.json has to say it
belongs to the plugin, or check:ui reports it as an orphan forever.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three things were still free to drift.

The registry pin had nothing holding it to the bb the plugins build against, so
a bb release could move while every check stayed green — the same shape as the
bug this guard was written for, one step further out. check:ui now compares the
pin to each plugin's bb-app devDependency. Both live in the repository, so it
stays offline and fails rather than warning.

Two values were copied out of bb by hand and no registry item can supply them:
the chrome token Thread stages excerpts from a shared-ui file the registry does
not publish, and the destructive-item classes bb's own app passes to
ContextMenuItem. vendor-ui.json now names them, build:ui refuses any it can no
longer find upstream, and the lock records them so an edit is caught offline.

The terminal panel selectors sat inline in the keyboard-shortcuts app entry,
untested, while every comparable assumption in that plugin already lives in a
module of its own. They now do too. A fixture test pins what we expect, not
what bb renders — only a check against a running bb can do that — but the
assumption is now in one readable place and our own probing cannot regress
unnoticed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Seeding it produced an undici EPIPE that named neither Node nor the harness,
and the stack reported it as a fixture that had failed for its own reasons. It
was the wrong Node major, which require-node.mjs already turns back into a
sentence for capturing. The dev stack runs the same seed, so it asks the same
question first.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
main now says to wait for what the plugin drew, inside the container only that
plugin installs. Two things it does not say, both learned the hard way tonight
and both from thr_s887gqkb97's corrections.

The two failure modes are not equally bad. Keyed to the plugin's own marker a
wait can only be late, which a timeout absorbs and a failure reports. Keyed to a
shared name it is wrong by succeeding — the shot is taken, the header is bare,
and nothing is raised. Playwright's locator wait samples once an animation frame
(coreBundle.js re-arms its check on requestAnimationFrame), so sampling makes a
wait late, never silent.

And the symptom does not identify it. One wait on one line failed both ways here:
silently on one machine, and loudly at thirty seconds on another. What decides it
is where the wait lands in the proxy's lifetime, not load — measured, not argued:
the loud failure came at load 23 on an idle box. So the loud form presents as a
slow machine, collects a longer timeout, and the longer timeout widens the window
the proxy can answer in. The remedy feeds the disease.

Supersedes three commits of mine that refined wording main has since replaced.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two mistakes in the resolver I added, both of which only a machine without the
desktop app could show, which is every CI runner.

It refused a node_modules/.bin entry as a shim. In CI that shim is the only bb
there is — bb-app is a devDependency and nothing installs the app — so
verify:types could no longer find a CLI at all. The entry is a symlink, and its
target is absolute and outside .bin, so following it answers honestly and still
cannot re-enter the launcher. A shim that links nowhere is still refused.

And run.mjs resolved at module load, so `--check` demanded a CLI it never
invokes. It reads the lock and the plugin sources and nothing else. Resolution
now happens where it is first used, which is only on the capture path.

Confirmed against CI's own situation: `env -i PATH=/usr/bin:/bin node
scripts/screenshots/run.mjs --check` now runs with no bb anywhere.

Two tests were passing for the wrong reason — their fixture shim was a regular
file, so realpath returned it unchanged. They use real symlinks now.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Vendored files were scattered across three roots per plugin, because a registry
item's target decides where it lands and the targets do not agree: use-media-query
and use-browser-dimming-modal are both registry:hook and land in
components/ui/hooks and hooks respectively. So the checker scanned a hardcoded
list of three directories, an item targeting a fourth would have gone unnoticed,
and thread-stages had three files of its own sitting among BB's — kept legal by a
pluginOwned allowlist that a reader had to consult to know whose code they were
looking at.

They now live under src/vendor/, which the generator owns outright, with BB's
layout preserved beneath it. Imports say @/vendor/ and the path answers the
question. The checker scans one root at any depth, the allowlist is gone, and a
stray file in there is an orphan by definition rather than by omission.

thread-stages keeps chrome-style-tokens.ts and dialog-position.ts in src/lib and
its tooltip test beside its other component tests. It also gains the
vitest.config.ts that Breadcrumbs and Icons already carry: tsc reads the tsconfig
path mapping for @/ and vitest does not.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both found by review, and both are the failure this branch's own testing rule
names: wrong by succeeding.

The entrypoint guard compared process.argv[1], which is the path as typed,
against import.meta.url, which Node resolves through symlinks. Under macOS /tmp,
a symlinked home, or a symlinked worktree they differ, the whole CLI body is
skipped, and check:ui exits 0 having verified nothing — silently approving a
hand-edited component. heading-icons.mjs carries the same idiom and the same
hole, so both are realpathed now.

And resolveBbCli followed a node_modules/.bin entry to bb-app/dist/bb.js, which
is the launcher rather than the CLI it stands in for. Handing that back as
BB_CLI makes the launcher spawn itself — the exact loop this module was written
to prevent, reintroduced by the fix for it. A shim is now followed past the
launcher to bb-app/host-daemon/dist/bb, which ships beside it; a launcher with
no CLI beside it is refused rather than returned.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The literal-drift branch was the only one in inspect() with no test: deleting it
outright left every test passing, while it is the sole guard on the two values
copied out of bb by hand. Three tests now cover matching, edited, and a symbol
deleted from under the lock — and each fails when that branch is removed.

The escape check on a registry target was a prefix match, so a target of
a/../../../etc/passwd passed it. It normalises now, and a test pins the case the
prefix check missed.

build() removed only what a previous lock listed, so a stray that was never
locked survived every rebuild while --check demanded one — advice that could not
work. It now clears the owned root of anything the build does not write.

Also: prepare wrote core.hooksPath without --local, which is shared repository
config and reaches every worktree; and the vendored files are marked eol=lf,
because the lock digests bytes and a CRLF checkout would have failed check:ui on
all forty-one.

Recaptured on an idle machine; no image moved.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@ariofrio
ariofrio force-pushed the bb/share-code-between-plugins-thr_zqcve598g7 branch from ccf5406 to 1b27b6f Compare August 23, 2026 06:36
github-actions Bot and others added 4 commits August 23, 2026 06:41
The filter rows hide glyphs bb draws for itself and draw their own in the same
slots, by class rather than by prop. jsdom applies no stylesheet, so the rewrite
landed with nothing able to tell a working row from one rendering both glyphs.

Two guards, because the gap has two halves and neither covers the other.

filter-row-glyphs.test.tsx pins the structure the selectors name: that bb's radio
indicator is still the first child, that the row's own span is never first, and
that bb's chevron is still the only direct-child svg. Moving bb's indicator after
{children} fails two of the three, which is the re-vendor that would otherwise
change nothing visible until a screenshot.

verify-styles.mjs fails the release check when an arbitrary variant written in
source is absent from dist/app.css. Being exact about its reach, because testing
disproved the obvious claim: a class assembled at runtime is invisible to
Tailwind and equally invisible to this, so the two agree and it catches nothing.
What it catches is a literal both see that Tailwind declines to emit — a
misspelled or unsupported utility, or a file fallen out of the content scan.

Neither proves the rule takes effect. That still needs a capture.

Also moves the dev stack out of this branch; it is unrelated to vendoring and
lands better on its own.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This tsconfig's lib has no DOM.Iterable, so spreading an HTMLCollection does not
typecheck — the same reason terminal-dom.ts reaches for Array.from rather than
iterating a NodeList. Vitest transpiles without typechecking, so running the new
test proved nothing about whether tsc would accept it, and CI found what I had
not run.

Co-Authored-By: Claude Opus 5 (1M context) <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.

1 participant