Tag: status

  • Tales from the Session: The Clipped Toolbar That Needed Two Fixes

    A case study in why “the automated tests are green” and “the bug is fixed” aren’t always the same claim — and what it took to close the gap between them.

    The setup

    The bug, as first reported, sounded simple: in toolcrib’s demo app, a collapsible bottom panel (“Live AI Event Bus Monitor”) has a Collapse button that’s supposed to shrink the panel down to exactly the height of its own toolbar. Instead, the toolbar was visibly cut off — clipped shorter than it should be.

    User (new turn — the opening report, not an interjection): “in the demo, the bottom panel is ‘clipped’ when collapsed. see image. it should only collapse to the exact height of the toolbar.”

    What actually happened over the next two rounds of investigation is a good illustration of a specific failure mode: an agent can verify a fix thoroughly, with real browser automation and real pixel measurements, and still be confidently wrong — because it’s asking the automation the wrong question. Getting from “my tests pass” to “the bug is actually gone” took a human physically dragging a window and noticing something no synthetic viewport sweep had thought to check.

    A note on how to read the quotes below. Some of these are ordinary turns — the agent finishes a response, the user replies. Others are marked interjection — the user’s client surfaces these as arriving while the agent is still mid-tool-call, not after it stopped to report back. That distinction matters here specifically: several of the clarifications below didn’t wait for a summary to react to. They landed in the middle of an unrelated step, sometimes before the agent had even finished the previous piece of the investigation, which is part of why the second bug got found as fast as it did — the human side of this wasn’t reading reports and replying, it was watching the work happen and correcting course in real time.


    Round 1: the panel doesn’t account for its own resize handle

    Investigating

    The demo’s Collapse button worked by picking a fixed percentage (MAIN_SPLITTER_MIN_SIZE = 5) and telling the <Splitter> component to jump to it. Reading Splitter.tsx‘s own code:

    flex: `0 0 ${split}%`          // top panel
    flex: `1 1 ${100 - split}%`    // bottom panel
    

    Those two percentages summed to exactly 100% of the container — but the Splitter also renders a resize handle between the panels, a fixed 0.625rem (10px) strip that isn’t part of either percentage. Nothing reserved room for it.

    First fix attempt (demo-only)

    The first pass replaced the fixed 5% with a measured target: use useAdaptiveSize (a real ResizeObserver-backed hook) on both the whole Splitter and the toolbar itself, and compute the exact percentage needed to make the panel match the toolbar’s real height.

    Tool calls that mattered here:

    • Write a temporary Playwright spec (investigate-auto-density-temp.spec.ts-style pattern) driving a real dev server, clicking Collapse, and reading back getBoundingClientRect() on the panel vs. the toolbar.
    • First real measurement: panel came out to 18px against a 28px toolbar — a shortfall, but the interesting part was that this 18px number stayed suspiciously constant no matter what viewport height was tested.

    Finding the real root cause

    A shortfall that doesn’t scale with container size is the signature of a fixed pixel loss, not a percentage-math error. That pointed straight at the one fixed-size thing in the layout: the resize handle. Confirmed by inspecting the panel’s own computed flex style directly in a real browser — the math traced exactly to the handle’s un-reserved 10px.

    The actual fix landed in Splitter.tsx, not the demo:

    flex: `0 0 calc(${split}% - ${HALF_HANDLE_SIZE_REM}rem)`
    flex: `1 1 calc(${100 - split}% - ${HALF_HANDLE_SIZE_REM}rem)`
    

    — splitting the handle’s footprint evenly between both panels, and exporting SPLITTER_HANDLE_SIZE_REM so a consumer computing a pixel-exact split could account for it themselves.

    Verification, thoroughly:

    • Real Playwright measurement at three viewport heights (900px / 650px / 500px) — panel landed at exactly 28px in all three.
    • Full vitest suite: 1317/1317 pass.
    • Full e2e chromium suite: 66/66 pass, including the whole-app accessibility.spec.ts WCAG sweep.
    • Gemini’s PR review flagged a real-sounding concern (a negative calc() could break the flex shorthand entirely at the split’s 0/100 boundary) — verified false via a direct page.evaluate() test of raw CSS behavior in Chromium: the browser clamps to 0px, it doesn’t discard the shorthand. Replied on the PR thread with the evidence before merging.
    • Merged as PR #457.

    This was a real, general defect in the shared Splitter component — not a demo-only quirk — so fixing it there meant every future Splitter consumer gets the correct behavior, not just this one demo panel.

    At this point, every signal available said: fixed, verified, shipped.


    An earlier decision that quietly made Round 2 possible: the commit hash in the header

    Before any of this, in the same session, a much smaller exchange had already happened — and it wasn’t idle polish, it came directly out of a real mix-up: the agent had just reported a fix as live, the user checked, and what they were looking at didn’t match — because they were testing against the deployed GitHub Pages build while the fix so far only existed in local dev. Out of that confusion came the actual request:

    User: “is it worth it putting a commit hash or anything in the top header?”

    The first pass at answering undersold it — for a local dev server, git status/git log already answer “what am I looking at” in one command, so a header stamp there would mostly duplicate something already one command away. That framing missed the point the confusion had just demonstrated:

    User: “negative, i go to the github pages version and test.”

    GitHub Pages is the user’s primary interaction point with this project, not local dev — and a deployed static page has no local checkout to compare against; there’s no git status to run against https://escape-llc.github.io/toolcrib/. So it got built: vite.config.ts reads the real commit hash via git rev-parse --short=7 HEAD at build time and injects it as a literal string via Vite’s define; the demo header renders it as a small link straight to the commit on GitHub. (It wasn’t quite that simple in practice — the first version referenced the hash as a bare global, which broke a separate CI job that copies demo/App.tsx raw into a real Next.js project to smoke-test it; fixing that is its own small story, not this one.) Shipped as PR #451.

    It was built to solve one specific, already-experienced confusion. It turned out to be the exact mechanism that made Round 2 tractable at all, a confusion of the very same shape but higher stakes:

    • The user’s bug report — "i am viewing demo at this commit https://github.com/escape-llc/toolcrib/commit/918a293" — was only that precise because the header made the commit hash something to literally read off the screen and paste, rather than something to guess at (“whatever’s live right now,” “probably the latest”).
    • Every verification script written afterward scraped that same header link (page.locator('a[href*="github.com/escape-llc/toolcrib/commit/"]')) and logged the commit it found — so every measurement in this whole investigation carried its own built-in confirmation that agent and user were looking at the identical build, not two different deploys separated by a few minutes of GitHub Pages CDN propagation.

    Neither side asked for this coordination mechanism explicitly going into Round 2 — it existed already, built for a smaller instance of the identical problem, and happened to be exactly the tool a “the fix isn’t actually live for you” vs. “the fix isn’t actually working” ambiguity needed. Infrastructure built to fix one real confusion paying for itself in a completely different situation later is its own small lesson.


    Round 2: “still clipped” — and the automation says otherwise

    Some time later, in the same session:

    User (new turn): “i am viewing demo at this commit https://github.com/escape-llc/toolcrib/commit/918a293 and the bottom panel is still clipped.”

    This is what makes it land so hard: the report comes back after the prior work had already been reported as done — a full, verified, merged fix — not in the middle of one. This is the moment the story gets interesting. The natural first move was to reproduce it exactly as reported — against the live deployed page, at that exact commit, not local dev:

    • Confirmed 918a293 was the correct, deployed commit (gh run list --workflow=deploy-demo.yml).
    • Wrote a fresh Playwright spec pointed at https://escape-llc.github.io/toolcrib/, clicking Collapse and measuring the panel against the toolbar.
    • Swept four real viewport sizes (1400×900, 1280×720, 1920×1080, 1024×768).

    Result: zero clipping, in every single case. clipped=false across the board, with the panel landing at exactly the toolbar’s height every time.

    This is the part worth sitting with: the automated reproduction, run against the exact same URL the user was looking at, genuinely could not find the bug. That’s not a small disagreement — it’s two different, both-true-sounding claims: “the fix works” (verified) and “the user is still seeing it broken” (also, presumably, true). Something had to give.

    Asking for what only the human could see

    Rather than keep guessing blind, the next move was to ask — specifically, for the two most likely environmental explanations for a visual bug an automated test can’t reproduce:

    Agent: “Could you check the browser’s zoom level… Could you do a hard refresh (Ctrl+Shift+R) to rule out a stale cached bundle?”

    Notably, the RDP detail itself arrived before that question was even asked — as an interjection, surfaced mid-tool-call while the agent was still writing the viewport-sweep script, not offered in reply to anything:

    User (interjection): “i am running in RDP if that matters.”

    The zoom/hard-refresh question that followed was shaped by that detail. The user then ruled out both directly, as the reply to that question:

    User: “Already confirmed 100% zoom” / “Already hard-refreshed, still clipped”

    Then, again interjecting rather than waiting to be asked anything further, the user did something more useful than any follow-up question could have prompted: they attached a screenshot of exactly what they were seeing, live.

    Reading the screenshot

    The screenshot showed the toolbar’s buttons — “Export JSONL”, “Clear Log”, “Expand” — fully legible, icons intact, not visually squished mid-character the way the original bug had looked. It just sat right at the bottom edge of the captured image, with no visible breathing room below it. That was a genuinely ambiguous signal on its own: is the content actually cut off, or does the page just naturally end there in a short window?

    The pivotal clue

    This is the line that actually cracked it — the direct answer to one more clarifying question (“what’s the actual window height, or is something below that row getting cut off”) — but the content of the answer is a piece of empirical, hands-on testing only the human could have done, because it required physically manipulating the real browser window and watching what stayed constant:

    User: “if i vary the size of chrome window by dragging, the splitter and bottom panel track exactly the same, and it is clipped consistently. it must be mismeasuring where the ‘bottom’ is.”

    Read that again: the user independently rediscovered the exact diagnostic signature from Round 1 — a shortfall that doesn’t scale with window size, meaning it’s a fixed-pixel loss, not a percentage-math bug — and correctly reasoned from it to “something is mismeasuring.” That’s not a bug report anymore; that’s a root-cause hypothesis, arrived at by direct interaction with the running product in a way no synthetic Playwright sweep across four preset viewport sizes had happened to surface.

    (A fifth viewport size might eventually have shown it too, if the bug were the same shape as Round 1’s — but it wasn’t proportional at all, so no viewport size would have. The automated sweep’s blind spot wasn’t “not enough viewports,” it was “comparing the wrong two elements” — see below.)

    Finding the second bug

    With that reframing — “trust that this is fixed-pixel, and Splitter itself is already verified correct, so look at what the demo is measuring” — the actual defect took one more targeted Playwright script to confirm precisely:

    BEFORE collapse:
      toolbar (role=toolbar) height=28
      my ref div height=28
      Card.Header (DIV.) height=45  padding=8px 16px
    
    AFTER collapse:
      panel height=28
      Card.Header height=45
      → PANEL TOO SHORT (Card.Header clipped)
    

    The demo’s own measurement code (eventLogToolbarRef) was wrapping the inner <Toolbar> element — 28px — instead of the outer <Card.Header> that actually contains it, padding and all — 45px. The panel was dutifully, correctly sizing itself to match its own (wrong) 28px target. It wasn’t broken math this time; it was measuring the wrong DOM node entirely.

    This is exactly why Round 1’s automated live-page sweep reported clipped=false: that test compared the panel’s height against the [role="toolbar"] element specifically — the same wrong reference the buggy code itself was using. Two independently-written pieces of code (the fix and the test) happened to share the same blind spot, because both were built from the same mental model of “the toolbar is the thing that needs to fit.” The bug was in that shared assumption, not in either piece of code considered alone.

    Right as the fix was landing, interjecting again — this arrived mid-tool-call, while the Card.Header investigation was still being written, not after any report back — the user supplied one more piece of precise framing that confirmed the diagnosis was pointed the right way:

    User (interjection): “the splitter’s bottom panel is positioned ‘perfectly’ regardless of resize; it is the inner thing not calculating correctly.”

    — distinguishing, correctly, between “the Splitter mechanism” (fine, already fixed, positioned exactly where told) and “the inner thing” (the demo’s own measurement target, still wrong). Then, also interjecting rather than waiting for the fix to land first:

    User (interjection): “is this a toolkit issue or a usage issue?”

    The honest answer, given right there mid-fix: usage. Splitter/Card/Toolbar were all behaving correctly; the demo was just pointing its own ruler at the wrong object.

    The fix

    Moved the measurement ref from wrapping <Toolbar> to wrapping <Card.Header> itself:

    <div ref={eventLogToolbarRef}>
      <Card.Header paddingMode="compact">
        <Toolbar>...</Toolbar>
      </Card.Header>
    </div>
    

    Verified again, the same way:

    • Real Playwright measurement at three more viewport sizes (1400×900, 1024×768, 900×500) — panel landed at exactly Card.Header‘s real 45px height in all three.
    • Full vitest suite: 1317/1317. Full e2e chromium suite: 66/66.
    • Filed issue #462, opened PR #463.

    Closing the loop for the next person

    The user asked for one more thing — again interjecting, this time while the full e2e suite was still running as part of verifying the fix, before any final report had gone out: make sure this lesson doesn’t stay buried in a commit message.

    User (interjection): “we should probably augment the ai-docs with this tidbit”

    A moment later, once the JSDoc tags were underway, a second interjection made sure the request wasn’t satisfied by a hand-written comment alone:

    User (interjection): “also present this in the generated ai-docs as well”

    The general shape of the mistake — when computing a pixel-exact “fit to this element” target, measure the outermost box whose padding/border actually needs to fit, not an inner child — got recorded in two places:

    1. Splitter.tsx‘s own SPLITTER_HANDLE_SIZE_REM comment (real vendored source every consumer reads).
    2. A proper @manifestAntiPatternAvoid/@manifestAntiPatternInstead JSDoc tag pair on Splitter‘s own component declaration — which generate-manifest.js/generate-docs.js pick up automatically into ai-docs/component-manifest.json and the generated Anti-Patterns table in ai-docs/CORE.md. Not hand-typed into the generated file (which the next regeneration would silently overwrite) — wired into the actual generation pipeline, so it ships to every consumer who runs toolcrib init/merge from here on.

    What actually made the difference

    Strip away the specific bug and what’s left is a fairly clean demonstration of where each side of a human+agent debugging session has a real, non-overlapping advantage:

    • The agent’s advantage: systematic coverage. Four viewport sizes, checked in seconds, with exact pixel measurements and zero fatigue. When Round 1’s real bug was found, it was found because the shortfall was measured precisely enough (18px vs. 28px) to notice it was constant, not proportional — a distinction a human eyeballing a screenshot would likely miss.
    • The human’s advantage: embodied interaction with the real, live thing. Dragging an actual window and watching what tracked together (splitter position) versus what stayed weirdly fixed (the clipping amount) surfaced a signal the agent’s own pre-planned viewport sweep never would have — not because the agent couldn’t have tested more viewports, but because more of the same kind of test would have kept confirming the same wrong conclusion. The fix wasn’t “test harder,” it was “test a different comparison” — and that reframing came from a person physically manipulating the product, not from a bigger automated matrix.
    • The failure mode this avoided: an agent trusting its own green tests past the point where a human says the bug is still live. The tempting move after Round 1’s live-page sweep came back clipped=false four times in a row would have been to conclude the user was looking at a stale cache or a rendering quirk and stop there. Asking two direct, falsifiable questions (zoom level, hard refresh) — and believing the answers when they ruled out the easy explanations — is what kept the investigation moving instead of stalling on “well, my tests say it’s fine.”
    • The unglamorous prerequisite: none of this works if the two sides can’t first agree on what they’re even looking at. The commit-hash header is what let a bug report carry an exact, checkable build identity instead of “the live site” — collapsing an entire class of false leads (stale cache, propagation delay, wrong deploy) before they could ever eat investigation time.

    Leading the agent vs. following it

    There are two distinct modes a human can be in relative to an agent’s work, and this whole story is really about the difference between them.

    Following looks like: hand off a well-specified task, let the agent run, read the conclusion when it stops. Most of this session was following mode, and it’s the mode that makes an agent actually useful for throughput — the original bug report that opened Round 1 was a single, complete turn, sent and then left alone; what came back was a fully investigated root cause, a fix, a three-viewport measurement pass, a 1317-test unit suite, a 66-test e2e suite, a Gemini review verified line-by-line, and a merged PR — all without the human needing to watch any of it happen. Following mode is what let that entire arc complete in one continuous stretch instead of a dozen back-and-forths. It’s also, not coincidentally, exactly the mode in which the agent’s own conclusion — “fixed, verified, shipped” — turned out to be incomplete, not wrong exactly, just short of the whole truth. Nothing about following mode would have surfaced that on its own; the gap only became visible once someone acted on the conclusion in a context the agent’s own tests hadn’t covered.

    Leading looks completely different: stay present while the agent works, and push new information in as it becomes available, without waiting for a stopping point. Round 2 was leading mode, densely so — count the interjections: the RDP detail, arriving mid-tool-call before any question had even been asked about it; the live screenshot, sent unprompted the moment it existed; “it is the inner thing not calculating correctly,” landing while the next investigation script was still being written; “is this a toolkit issue or a usage issue?”, asked mid-fix; both documentation requests, one arriving while a full test suite was still running. None of these waited for the agent to stop and report. Each one reached the investigation at the moment it was true, not at the next natural checkpoint — which meant each one had the chance to change what got investigated next, instead of only being able to critique what had already shipped.

    The two modes aren’t in tension so much as complementary, and the value of each shows up specifically where the other one is weak. Following mode is cheap for the human and excellent at grinding through anything mechanical — four viewport sizes, a full regression suite, a Gemini finding that needs a page.evaluate() to actually check — precisely because nobody has to watch it happen. But following mode has a blind spot by construction: it can only ever react to a conclusion, and a conclusion that’s subtly wrong (verified against the wrong comparison, in this case) looks exactly like a correct one from the outside, right up until someone tests it for real. Leading mode is what catches that — but it isn’t free. It costs the human’s continuous attention, which is exactly why it isn’t the default mode for everything; using it selectively, at the moments where the agent’s own plan might be heading somewhere wrong, is what made this fast rather than either extreme (full autonomy risking a half-fixed bug shipping quietly, or full moment-to-moment steering burning attention on work automation already handles fine).

    The fastest path through this whole story ran through both modes, used deliberately for what each is good at: follow while the work is mechanical, lead the instant the agent’s own account of “done” stops matching what’s actually being seen.


    Appendix: tool-call trail (abbreviated)

    PhaseTool calls
    Commit-hash header (earlier, unrelated)Edit vite.config.ts (define + git rev-parse) · Edit demo/App.tsx header link · Bash real npm run build, byte-check the injected hash against git rev-parse · Bash gh pr create (PR #451)
    Round 1 investigationRead Splitter.tsx/DataTable.tsx toolbar code · Write + PowerShell Playwright spec (local dev server) · Read screenshot
    Round 1 root-causeEdit Splitter.tsx flex-basis (calc()) · Bash re-run Playwright at 3 viewport heights · PowerShell full vitest + e2e chromium suites
    Round 1 PR reviewWebFetch GitHub Expressions docs (unrelated PR, same session) · Write temp spec testing raw calc() flex-basis in isolation · Bash gh pr comment posting the false-positive resolution
    Round 2 initial reproBash gh run list --workflow=deploy-demo.yml (confirm deployed SHA) · Write + run Playwright spec against the live https://escape-llc.github.io/toolcrib/ URL at 4 viewport sizes, scraping the header’s own commit-hash link on every run to confirm agent and user were looking at the identical build
    Round 2 clarificationAskUserQuestion (zoom level, hard refresh) · direct read of user-supplied screenshot
    Round 2 root-causeWrite targeted Playwright spec comparing Card.Header vs. the toolbar ref · Read a cropped screenshot confirming the visual clip
    Round 2 fixEdit demo/App.tsx (move ref from <Toolbar> to <Card.Header>) · re-run Playwright at 3 more viewport sizes · full vitest + e2e chromium suites
    DocumentationEdit Splitter.tsx comment + @manifestAntiPatternAvoid/Instead JSDoc tags · PowerShell npm run generate-manifest / generate-docs · Bash grep verifying the new row landed in ai-docs/CORE.md
    ShippingBash gh issue create ×2 · Bash git checkout -b / commit / push ×2 · Bash gh pr create ×2
  • Tales from the Session – The Ticket That Grew

    What Actually Happens When a Fix Doesn’t Stay Small

    A second look at Toolcrib’s issue → branch → PR → CI-green → merge → close → summary loop — this time through a ticket that didn’t go according to plan, using real transcript from the session it happened in.

    The first post on this process showed the loop working cleanly: one report, one root cause, one fix, done. That’s the common case, but it undersells what the loop is actually for. The interesting test isn’t the ticket that goes smoothly — it’s the one where the first fix turns out to be too narrow, a pre-existing test turns out to be wrong, three other tickets are mid-flight at the same time, and the standing process itself needs a line added to it before the day is over. This post follows one of those.


    The ticket: “toasts jerk around,” twice

    The report first came in early in the session, almost as an aside:

    User: “after, the toasts do not smoothly transition at all, they just jerk around.”

    A fix went in. Later — after other work, other tickets, other PRs — the same complaint came back, sharper:

    User: “back to the toasts, the movement is horrible! the ‘first’ toast should just fade out, and the remaining toasts ‘slide’. whatever is driving this is janky as hell.”

    Two things are worth noticing before the technical story even starts. First, the ticket didn’t get “fixed” on the first pass — the loop doesn’t pretend otherwise, and neither did the user; they just reported it again, plainly. Second, notice the report arrived attached to other ongoing work, not as its own clean session. That’s the normal shape of real work, and it’s what the rest of this post is actually about.

    The real breakthrough came later still, once the same category of problem showed up on a completely different component:

    User: “throughout the hovers are not animating color or whatever visual attributes or it is too short to notice.”

    User: “let’s get some kind of ‘perception floor’ that nothing can be faster than.”

    That single sentence reframed the whole investigation. It wasn’t “toasts are broken” — it was “this codebase has no floor under how fast a transition is allowed to be,” and toasts were just the first place it got loud enough to report.

    Following the root cause instead of patching the symptom

    The agent’s response confirmed the floor already existed, half-built:

    Assistant: “Confirmed — MIN_PERCEPTIBLE_DURATION_MS = { fast: 150, normal: 220, slow: 300 } already exists in animation.tsx as exactly the ‘perception floor’ you’re asking for. The real gap is that most components bypass it entirely with hardcoded literal durations…”

    Then, digging one layer deeper, the scope changed shape again:

    Assistant: “Wider than I thought — .ai-focus-ring shares the exact same !important transition rule as .ai-btn/.ai-tab-trigger, so every focus-ring consumer (Checkbox, Switch, RadioGroup, Select, Input, Tree, Combobox, etc.) has the identical collision, not just button-shaped controls. Let me get the full picture before fixing.”

    This is the point where a shallower process would have shipped a toast-only patch and closed the ticket. Instead the investigation kept going until it found the actual mechanism: CSS’s transition shorthand only allows one winning declaration per element across every stylesheet rule that touches it — so an !important rule anywhere in the cascade doesn’t just override a competing value, it silently discards the other rule’s entire property list. Toast wasn’t a toast bug. It was this bug, wearing a toast costume.

    The user pushing back on the how, not just the what

    Once the mechanism was named, the user didn’t just approve a fix — they interrogated the approach directly, twice:

    User: “can we do this without ‘!important’ all over everything?”

    User: “does css layers get us anything here?”

    Both got real, specific answers, not reassurance:

    Assistant: “Yes — we can avoid !important sprawl entirely… The real constraint is that transition (and its longhands) can only have one winning declaration per element…”

    Assistant: “Both fair points — let me address them directly before I touch anything: CSS @layer: it doesn’t help here. Layers only reorder cascade priority among stylesheet rules of the same importance — they don’t change the one fixed rule that actually causes this: an inline style always outranks any external stylesheet rule of normal importance…”

    The user’s own read on the outcome, once the reasoning landed:

    User: “i suppose that is the ‘tax’ for doing everything in style and not class”

    This is what “steering” looks like in this loop in practice — not vague dissatisfaction, but a specific architectural question the agent has to actually answer before code changes, with the user free to keep pushing until the answer holds up.

    Scope, deliberately, in one go

    With the design settled, the user made the scope call explicit rather than letting it happen by accident:

    User: “let’s round up all the transition/animation stuff in one go.”

    What followed was mechanical, and shown here compressed — in the real session it was fourteen separate files, each touched in its own turn:

    Assistant: “Now removing the 14 now-fully-redundant inline transition declarations, one file at a time.” “Now Combobox.tsx:456.” · “Now FileUpload.tsx:290.” · “Now FormComponents.tsx — four spots (Button, Input, Checkbox, Switch).” · “Now RadioGroup.tsx:230.” · “Now Rating.tsx:148.” · “Now Splitter.tsx:456.” · “Now TabStrip.tsx:263.” · …

    Partway through, a small but real observation surfaced — some components had already been pointed at the correct floored variable, and it had made no difference at all, because the collision was silently discarding their correct value along with everyone else’s wrong one:

    Assistant: “That’s actually the more important half of this fix: even the ones that did reference var(--ai-transition-normal, ...) (Button, Filmstrip, TabStrip) were still getting killed by the collision, so it made no practical difference either way.”

    The user’s confirmation ties directly back to the very first report, several hundred turns earlier:

    User: “the edits look like mostly nothing was using the transition theme controls”

    User: “agree. that’s how it needed to work at the start.”

    Finding a bug in the thing meant to catch bugs

    Running the full test suite after the sweep didn’t come back clean — and the failure wasn’t in the new code:

    Assistant: “This is a real bug in a pre-existing test’s regex, not in my code — let me see the full failure list to confirm the pattern.”

    Assistant: “Confirmed exactly as predicted — the check is value.includes('var(--ai-transition-'), which matches the shared --ai-transition- prefix of every variable in this family, not just the composite --ai-transition-normal/-fast/-slow tokens its own doc comment says are the actual concern.”

    The existing regression test had been written to catch a specific mistake (using the old composite tokens directly), but its check was written broadly enough to also flag the new, correct pattern as if it were the old wrong one. Worth pausing on: this test had presumably been green for a long time, quietly proving nothing about the exact case that mattered now. It got narrowed to match only the real offending pattern, in the same PR — not shrugged off as unrelated.

    One more wrinkle, right before the branch could ship:

    Assistant: “This failure is on PR #410 (AlertDialog), which doesn’t touch interaction/focus-ring code at all — this looks like the same class of WebKit CI timing flakiness already seen twice this session (toast frame-sampling, accessibility timeout). Rerunning to confirm.”

    Assistant: “Now continuing with the #411 branch verification while that reruns in the background.”

    That’s a judgment call the loop has to make constantly: is a red check this ticket’s fault, or noise from something else entirely? Getting it wrong in either direction is expensive — chasing a phantom bug, or shipping past a real one. The tell here was concrete (the failing test lived nowhere near the changed files), not a shrug.

    A ruling, asked for and given

    Even with the mechanism fixed, one design question remained genuinely open: should every transition run at the same speed, or should fast/normal/slow stay distinct? The user asked for it directly:

    User: “request ruling: visual appeal of running all transitions on same duration.”

    And got a real position, with the tradeoff stated rather than hidden:

    Assistant: “Ruling: uniform -normal (220ms) is fine, but it’s a real trade-off, not free. The floor’s own established reasoning in this file (why the focus ring rejected -fast) argues for slower-but-reliable across the board, and going from ‘zero transition’ to ‘one consistent, always-perceptible pace’ is by far the bigger perceptual win here…”

    Filed, discussed, decided, applied — the same “request ruling” pattern this project uses for anything that’s a judgment call rather than a bug, so it doesn’t get silently decided inside a diff no one explicitly signed off on.


    How the loop juggles more than one ticket without losing the thread

    None of the above happened in isolation. In the same stretch of session, the user was also asking after a Gemini review on an unrelated PR, queuing DataTable work, and correcting a stale reference to a different ticket entirely:

    User: “gemini was complaining make sure to check it.”

    User: “sry i was on #351”

    The agent had started checking the wrong context — main, not the actual PR branch — and caught it in the same breath the user’s correction landed:

    Assistant: “That’s the main-branch version — I need to switch to the datatable-density-multiplier branch to fix PR #351 itself.”

    That exchange is the whole mechanism in miniature: a git branch is the actual unit of “which ticket,” not a mental note. Every issue gets its own branch named after it; switching tickets means switching branches, which means the file state itself enforces which ticket’s changes are in front of you — there’s no way to accidentally blend two tickets’ diffs together, because the working tree can only ever reflect one branch at a time.

    The same discipline showed up again later in this very session, this time as a near-miss worth being honest about rather than tidying out of the record. While chasing a CI flake on one PR (#427), a second ticket’s finished-but-uncommitted work (#428, sitting as local changes on its own branch) had to be set aside to safely investigate. The standard move — git stash, switch branches, do the work, switch back, git stash pop — went fine. But a later git checkout main (to sync the just-merged #427) was run while #428’s uncommitted changes were still sitting in the working tree, and git happily carried them along onto main‘s checkout, since they didn’t conflict with anything there:

    (from the session’s own working log) git status on main — unexpectedly showing modified: demo/App.tsx, src/components/Form/FormComponents.tsx, and two more files that had no business being on main at all.

    Nothing was lost, and nothing was committed to the wrong place — the fix was the same stash/switch/pop sequence, run one more time to move the changes back to their own branch before touching anything else. But it’s worth including precisely because it’s a genuine near-miss, not a clean demonstration: the reliability here doesn’t come from the mechanism being foolproof. It comes from checking git status before every state-changing operation — checkout, merge, branch delete — as a standing habit, catching a genuine slip immediately instead of only in retrospect. The loop’s actual guarantee isn’t “this never happens.” It’s “this gets caught before it costs anything,” which is a different and more honest claim.

    The other half of juggling multiple tickets is vocabulary, not tooling — a small, consistent set of phrases the user reaches for to manage the queue explicitly rather than letting it happen implicitly:

    “after, enhance the pagination for data table…” · “queue it up!” · “add to queue: on the lead demo page, add some links…” · “next one.” · “proceed with tier 1. put in all tickets, then start on the first item.”

    “After” defers a request until whatever’s in flight lands. “Queue it up” or “add to queue” files something without demanding it start now. Nothing here relies on the agent remembering an implicit priority order — GitHub’s own issue tracker is the actual backlog, checked and updated directly, so “what’s outstanding” is always one gh issue list away rather than something reconstructed from conversation memory.

    Reading the signal: queue it, or answer it now

    Not every mid-session sentence gets the same response, and the difference isn’t judgment — it’s the phrasing itself. The user’s own language tells the loop whether something is a new backlog item that can wait, or a question the current thread genuinely cannot proceed past without resolving. Both happen constantly, often in the same few turns, and mixing up which is which in either direction would either stall real work behind a non-blocking aside or silently skip past something that needed a real answer.

    Deferral language gets a one-line receipt, then the current work resumes without interruption. “After,” “queue it up,” “add to queue,” “meanwhile,” and “separately” all mark a request as not now — its starting point is already implicitly “whenever this finishes.” The acknowledgment pattern is consistent enough across the session to call it a convention rather than a coincidence, real examples:

    Assistant: “Queued — a new quickFilterFields?: (keyof T & string)[] prop… Straightforward addition to useTa[bleFilter]…” (then continues the current task)

    Assistant: “Noted — Popup queued too. Let me finish ToastContainer‘s rewrite now.”

    Assistant: “Queued — I’ll look at PR #225’s jest-dom issue once this density fix lands. Continuing.”

    Assistant: “Got it — #360 is a well-specified redesign of the selection visual (left-indicator + inset border, with contiguous-block merging), queued for after the current list. Continuing the toolbar verification now.”

    Each of these does two things in one breath: restates the new item precisely enough to act on later without having to re-derive what was meant, and then immediately names what’s still in progress. Nothing about a queued item interrupts the tool calls already underway.

    A bare question, a direct technical challenge, or a “request ruling:” does the opposite — it stops the current thread cold, and gets answered before anything else moves. There’s no “after” implied, because the sentence doesn’t define its own resolution point; the loop can’t know how to proceed until it’s resolved. Real examples, with their immediate (not deferred) responses:

    User: “is it a z-index issue?” Assistant: “Let me get the real computed data to answer that precisely rather than guess.” (goes and checks, then answers — doesn’t guess, doesn’t defer)

    User: “does this call for one of the alternate colors?” Assistant: “No — this is a different problem class than the bucket taxonomy addresses. The buckets are about which semantic identity color represents a component’s state…”

    User: “did you check gemini review” Assistant: “Not yet — let me check now.” (immediately does, rather than promising to get to it)

    When a direct question catches a real mistake, the response is a correction, not a defense of the original answer:

    User: “there were 6 items on that list did you add all of them?” Assistant: (initially confirms, citing the wrong list) User: “negative i am talking about the ‘feature gap’ list, it had 6 numbered bullet features.” Assistant: “Good catch — that was my chat summary’s numbered list, not the same thing as ‘Tier 1’ from the analysis doc. Let me be precise about what that list actually contained…”

    “Request ruling:” is a named special case of this same immediate-inquiry class, worth calling out because it recurs constantly whenever a decision is a judgment call rather than a bug with one correct fix. It doesn’t ask for information — it asks for a position, and always gets one stated with its tradeoff named rather than punted back as a menu of options: the perception-floor duration ruling earlier in this post (“uniform -normal (220ms) is fine, but it’s a real trade-off, not free…”) is one instance of the same pattern used throughout the session for the screen-reader release-tag gating decision, the e2e job-splitting tradeoff, and others.

    The mechanical tell that separates the two categories: does the sentence name a future action, or does the current action depend on its answer? “After, X” and “queue it” both describe work whose start time is already settled — later. A bare question or a ruling request has no defined resolution point of its own; answering it is the next step, not a step that gets scheduled after some other one.

    Human in the loop

    Everything above reads as if the user is replying to finished messages — one turn ends, they respond, the next turn starts. That’s not actually the mechanism. Claude Code surfaces the agent’s work as it happens: the reasoning behind a step, the tool calls it’s making, visible in real time rather than held back until a turn wraps up. That visibility is what makes some of the corrections above possible at all — the user isn’t only reacting to conclusions, they’re watching the investigation happen and can step in before it finishes.

    The session’s own raw log — which timestamps every message, not just orders them — shows this happening, not just implies it. During the Combobox chip focus-ring investigation, the agent was mid-sequence: a diagnostic script had just run, a screenshot had just been read, a second, more targeted script was about to run to pull full computed-style data. In the middle of that sequence — while the agent’s own reasoning for that next step was actively being generated, before a single word of explanation had been written back to the conversation — the log shows:

    User: “is it a z-index issue?”

    The reply that eventually came (“Let me get the real computed data to answer that precisely rather than guess”) reads like a direct response to a question asked mid-investigation, because that’s exactly what it was — not a question about a finished answer, but a hypothesis offered while the agent was still deciding what to check next.

    The same shape recurs a minute or two later, in the same thread, again landing between two active tool calls rather than after a completed reply:

    User: “does this call for one of the alternate colors?”

    And again, earlier in the session, during the !important/transition-collision investigation — the question that reframed the entire fix arrived the same way, mid-stream, not as a reply to a finished proposal:

    User: “can we do this without ‘!important’ all over everything?”

    None of these three are “the user read the last message and had a thought.” They’re closer to a second set of eyes on work that’s still in progress — catching a wrong direction, or supplying a missing consideration, while there’s still time for it to change what happens next instead of what gets redone afterward. That only works because the agent’s narration is genuinely legible in real time — what it’s reading, what it’s about to try, why — not compressed into a single opaque action and a result. The “Queued —”/”Noted —” acknowledgments and named rulings covered above exist as this loop’s vocabulary for exactly that reason: they’re addressed to someone watching the stream as it unfolds, not only to whoever eventually reads the final summary.

    Where the process itself got a line added to it

    A few things in this same session weren’t bug fixes at all — they were the user asking to change the standing rules the loop runs on, mid-stream, without waiting for a dedicated “let’s improve our process” session:

    User: “let’s update the standing instructions: in the discussion template, add a ‘PR Review’ section, and comment on the accuracy etc. of the reviews. we want feedback on how the gemini review job is performing. call out false positives in particular.”

    This shipped through the exact same mechanism as any other change — its own issue (#348), its own branch, its own PR (#349) — even though the “code” being changed was a documentation template, and even though it interrupted an active investigation into the Toast transition problem:

    Assistant: “Understood, and PR #349 is up for the SESSION_SUMMARIES.md change. Watching its CI now, then I’ll resume the Toast transition investigation in a real browser.” … Assistant: “Sounds good — I’ll continue once PR #349’s CI check comes back.”

    Two tickets, cleanly interleaved, neither one blocking the other from getting proper CI treatment.

    The e2e test suite got a standing rule added the same way, after a direct instruction:

    User: “enhance the standing instructions on e2e to not use time-based techniques when existing DOM or ‘test-point’ DOM can satisfy.”

    User: “we are all for ‘test-point’ DOM attributes they don’t hurt anything.”

    That’s now a permanent section in this project’s own contributor instructions — not a one-off fix to one flaky test, but a rule the next flaky test gets checked against automatically, because it’s written down where the next session (human or AI) will actually see it before writing a new test.

    And the perception-floor investigation above didn’t just fix fourteen files — it left behind a standing project priority (“substantial animation support” is now called out explicitly as something to keep auditing for, not something considered “done” once this one PR merged), on the theory that a class of bug this pervasive is worth checking for by name the next time a new component ships, rather than trusting it won’t recur.

    None of these were flagged in advance as “process work” versus “real work.” They arrived the same way every other ticket did — a sentence, mid-session, sometimes mid-other-ticket — and went through the identical loop: filed, branched, reviewed, merged, closed, summarized.


    What this ticket actually demonstrates

    Issue #411 didn’t ship because the first attempt was right. It shipped because the loop had room in it for the first attempt to be incomplete — for a user’s second, sharper report to be taken as new information rather than an annoyance, for a design question to get argued out before code changed, for a scope decision to be made explicitly instead of accreting by accident, and for a bug in the safety net itself to get fixed in the same pass rather than routed around.

    The multi-ticket juggling and the process amendments aren’t a separate story from the technical one — they’re the same loop, running concurrently, because that’s what a real working session actually looks like. The mechanism that makes it hold together isn’t cleverness. It’s a few boring disciplines, applied consistently: one branch per ticket, git status before anything that changes state, GitHub Issues as the only real backlog, and a habit of writing a new rule down the moment the session learns it needs one — so the next ticket, whatever it turns out to actually be, doesn’t have to learn it again.

  • Tales from the Session – The Loop

    What it actually looks like when an issue in our GitHub tracker becomes a merged, CI-green pull request — narrated from one real ticket, start to finish, including the two places the user steered it off script.

    escape-llc/toolcrib · one contributor session · issue #425 → PR #427


    We run Claude Code against toolcrib the same way we’d run any contributor: an issue gets filed, a branch gets cut, a PR goes up, CI has to go green, a second model reviews the diff, and only then does it merge. Nothing about that loop is special-cased for AI. What’s different is the pace — a full lap can close in the time it takes to read the diff. Here’s what one lap actually looks like, quoted, not paraphrased.

    A note before it starts: this is the clean lap. The ticket below resolves in one branch and one PR, with two short detours that both land exactly where they should. Not every ticket goes this way — worth keeping in mind as you read, and worth saying now rather than pretending this is the only shape the loop takes.

    The standing loop

    Every change, large or small, goes through the same eight beats. This isn’t a process designed for AI contributors specifically — it’s the same issue→branch→PR discipline any human contributor follows, documented in the repo’s own WORKFLOW.md.

    1. File the issue — gh issue create, one bug or feature, one issue.
    2. Branch off synced main — git checkout -b <slug>-<issue#>.
    3. Diagnose against the real thing — a live Playwright script against the running demo, not just a code read.
    4. Implement and verify — npx tsc --noEmit · npx vitest run · npm run lint.
    5. Open the PR — gh pr create — Closes #<issue>.
    6. Watch CI settle — 14 checks, including a chromium/webkit e2e matrix.
    7. Read the second opinion — a Gemini review comment, evaluated, not rubber-stamped.
    8. Merge, sync, close — gh pr merge --squash --delete-branch.

    These eight beats read as fixed, and mostly they are — but they’re not frozen. Step 6’s e2e matrix and step 7’s required-review gate both got added during this same session, after real incidents made the gap in each one obvious. The loop is steady state, not scripture; it’s revisited when something in it turns out not to hold. That’s its own story, told properly elsewhere.

    Two ways a lap gets interrupted

    The loop above describes one ticket running start to finish. In practice a session holds several at once — new reports arrive mid-implementation, and questions land mid-diagnosis. Both are normal, and handled differently.

    Queued for later:

    “after, the filter ui should have an ‘auto-clear’ glyph on the right end. see image.”

    A new report, acknowledged in one line, added to the list. Whatever’s in progress keeps running uninterrupted — this one waited behind three other tickets before its own branch opened.

    Answered on the spot:

    “is it a z-index issue?”

    A direct question mid-diagnosis, answered immediately with whatever had actually been measured so far — in this case, a real computed style showing z-index: auto, ruling the theory out before the answer was given.

    The two patterns above turn out to have a real shared vocabulary behind them — “after,” “queue it up,” a bare question, “request ruling:” — each pointing at a different kind of response. That vocabulary, and why some interruptions land mid-tool-call rather than between turns, is worth its own closer look — not the point of this post, but a thread this one leaves hanging.


    One ticket, start to finish

    Issue #425 started as a screenshot: two tag chips in a multi-select field, one of them showing “React ×”, “TypeScript ×” — and a note that tabbing to a chip’s own remove button produced a focus ring that “sort of appears briefly then gets obscured.”

    User (reported): “the sub-item focus is not layered in a visible way. see image. it ‘sort of’ appears briefly then gets obscured.”

    Assistant (diagnosed): A Playwright script against the running demo — not a code read — landed on the actual remove button and pulled its real computed style: z-index: auto, position: static, a genuine 2px solid outline in a blue close to the chip’s own background color.

    z-index/position ruled out · outline confirmed present, low contrast suspected

    User (steered): “is it a z-index issue?”

    Assistant (answered): No — z-index: auto, confirmed a moment earlier, not a guess. The real cause: the ring color and the chip’s background were both drawn from the same primary hue. Not a stacking bug, a contrast bug — the ring was rendering exactly where it should, just nearly invisible against its own surface.

    User (steered): “does this call for one of the alternate colors?”

    Assistant (answered): No — focus rings stay anchored to the primary hue everywhere in the toolkit, on purpose, so “this is keyboard-focused” reads as one consistent signal regardless of context. The fix reused an existing WCAG-checked value meant for exactly this — text readable on top of a primary surface — not a new color, and not a swap to a different one.

    Assistant (shipped v1): Filed issue #425, branched, gave the chip’s remove button its own dedicated focus style, and wired it in.

    User (steered): “agree but if two overlaying rules evaluate to ‘same color’ you cannot distinguish them well.”

    Assistant (confirmed): Right — and the new style was given its own dedicated class rather than layered onto the shared one, specifically so there’s nothing else competing over the same property on the same element.

    Assistant (re-checked live): Reloaded the demo and measured again. The fix wasn’t visible yet — still the same low-contrast blue.

    expected outline-color not observed · investigation reopened

    This is the first real snag in an otherwise tidy ticket — a fix that looked correct in the diff and wasn’t actually true on screen. It’s a small version of a pattern that shows up again, much larger, elsewhere in this same session: a “correct-looking” change that turns out to be silently overridden by something else in the cascade, discovered only by going back and actually looking rather than trusting the diff.

    User (asked): “ok so that color adjusts according to the chip’s color? do we have ability to style individual chips?”

    Assistant (answered): The color tracks the theme’s primary hue, not the individual chip — because every chip currently is the same primary color. Checked the component directly: there’s no per-chip styling option today at all.

    User (steered): “consider using subthemes which should have the relevant color?”

    Assistant (answered): Subthemes don’t quite apply — chips aren’t subtheme-colored. But the toolkit’s subtheme system already has the same idea under a different name: a token for “readable content on top of this surface.” The fix was already reaching for that pattern’s own equivalent, just on the primary side of the system rather than the subtheme side.

    User (clarified): “ok. i am just reaching for simple mechanism to style the pills like we have for buttons.”

    User (requested): “agree. add a ticket for it.”

    Assistant (filed): Issue #426 — a per-chip styling mechanism, matching the button component’s own pattern. Real idea, separate scope, not folded into the fix already in progress.

    Assistant (found the real bug): Went back to why the fix wasn’t rendering. Walked every CSS rule actually matching the button and found a second, unrelated one: a repo-wide default focus style, marked !important, silently overriding the new dedicated ring — despite its own comment stating it was meant to step aside for exactly this case.

    removed !important · re-measured · contrast confirmed

    Assistant (shipped): Added a unit test and a real-browser test — each one checked to fail against the right half of the old behavior before confirming both pass together. Full suite green, typecheck clean, lint clean. Opened PR #427, closing #425.


    Two root causes found. Two issues filed. One PR, one scope.

    Neither steer changed the outcome by itself. The z-index question ruled out a theory in one line instead of a paragraph. The subtheme question confirmed the token already chosen was the right one, under a name from a different part of the system. What actually moved the fix was going back to the live page a second time instead of trusting that a correct-looking rule was a working one.


    Worth being honest about what this ticket doesn’t show: the fix took one branch and landed clean. It didn’t require touching fourteen files, it didn’t uncover a bug in the test suite meant to catch bugs, and nothing about the process itself needed to change to ship it. Most tickets in this tracker look like this one. Not all of them do — one that didn’t is next.

    Drawn from one real contributor session on escape-llc/toolcrib, quoted rather than reconstructed. The loop above runs the same way on every ticket in the tracker — this is just the one with a clean enough shape to walk through end to end.

  • The Crib Audit

    Three checkouts from the same crib

    Toolcrib pitches itself as a UI toolkit built for AI coding assistants rather than human hands. Three apps have now been built against it — one in-house showcase and two independent products. This is what checking each of them out of the crib actually looked like, for anyone weighing whether to vibe-code their next app on top of it.

    Subject: toolcrib v0.11.0 · Specimens: 3 · Filed: 2026-08-31


    Intake

    Toolcrib is a React component library that never ships as an npm dependency. Running npx toolcrib init vendors the component source, theme engine, and event bus directly into a project’s own tree, wired up behind one import specifier: from '#toolcrib'. That’s a deliberate trade, not an oversight: the code sitting directly in a consumer’s own repo, in plain readable TypeScript rather than a compiled node_modules blob, means a consumer’s own AI assistant can read the real implementation behind any component it’s calling instead of trusting an opaque type signature from a distance — and, if something genuinely needs to change, patch the local copy directly rather than waiting on an upstream release. The pitch is aimed squarely at a specific failure mode of AI-assisted coding — an assistant asked for a modal or a form tends to hand-roll a new one from scratch every time, guessing at prop names and re-inventing z-index stacking, focus traps, and validation wiring along the way.

    Toolcrib’s answer is 68 typed, slot-based components (no style or className prop on any of them — a themed overrides prop instead), a Zod-schema form engine that binds fields by context, and a cross-tree event bus (aiBus) for the “open this modal from somewhere else entirely” problem every hand-rolled UI eventually reinvents badly.

    The term, defined

    Architectural floor — a baseline an AI-generated app can’t fall below, not a ceiling on how good it can get. Toolcrib doesn’t guarantee good product decisions, sensible information architecture, or a layout an actual designer would sign off on; an assistant can still build the wrong feature, or a confusing flow, entirely within toolcrib’s own rules. What it guarantees, structurally, is that whatever does get built won’t silently violate a fixed set of baseline correctness properties — each one a plank in the same floor, not a separate feature standing on its own:

    • No prop-drilling. Slot subcomponents (Card.Header, Modal.Actions) plus a cross-tree event bus (aiBus) for actions between components with no shared ancestor — a toast triggered from a click handler nowhere near the toast viewport doesn’t need a prop threaded down five levels and a callback threaded back up.
    • Typed props. A prop that doesn’t exist, or a misspelled variant string, fails to compile instead of shipping invisibly — confirmed as a real failure mode, not hypothetical, by ai-docs/NEW_APP.md‘s own account of a consumer app that shipped an invalid variant for the life of a project before anyone noticed.
    • Real interaction primitives. Overlays get actual focus-trapping, keyboard navigation, and portal/z-index handling from Radix UI and Adobe’s react-aria-components, not a hand-rolled approximation built one keydown handler at a time.
    • WCAG contrast. Color comes out of the HSV theme engine, not hand-picked hex values, and is checked by a standing axe-core + Playwright gate on every CI run rather than a one-time launch audit.
    • ARIA compliance. Enforced structurally where it can be — a shared row helper owns its own useId() so a label/control pair can’t ship unassociated, and a full manual audit closed the gaps a mechanical check can’t catch on its own.
    • Validated form output. A form’s coerced, schema-validated value is the actual value that reaches onSubmit — not raw field strings a downstream numeric operation only assumed were already numbers.
    • Consistent typography and spacing. A shared set of layout primitives and an all-rem sizing scale, so margins, padding, and type sizes don’t quietly drift from screen to screen the way an AI reinventing CSS from scratch tends to drift.
    • Non-drifting reference docs. The component manifest, CORE.md, and the public import barrel are generated from source and checked in CI — the exact mechanism covered further down, and the reason none of them have been caught silently omitting a real component or export since.

    That’s the theory. The question this audit asks is whether it holds up once real, independently-conceived apps are built on it — not just the library’s own demo harness.

    One detail worth being explicit about, for all three specimens, not just the two standalone apps: none of this was hand-coded by a human engineer typing component calls. The demo harness, Feed Farmer, and Founder’s Desk are all AI-coding-session builds — Founder’s Desk’s own ORIGIN.md preserves “the origin conversation itself… so the next session (a fresh chat, by design)” can pick up the reasoning, both standalone apps’ READMEs describe themselves as real sample products built through the toolkit rather than around it, and the library repo’s own AGENTS.md is written for “whoever (human or AI) is working on this repo” as a standing contributor. That makes this audit a same-workflow test top to bottom, not a report on other developers’ experience: the evidence below is what happened when the toolkit — including its own reference implementation — was vibe-coded against, by the exact process a reader considering it would use themselves.


    The Three Apps

    One reference harness, two standalone products — deliberately picked at different scales and domains.

    Both standalone products also share a shape worth naming up front: fully client-side, offline-capable PWAs with no backend anywhere. That’s a demo-convenience choice, not a toolcrib requirement — a backend-free app is trivial to deploy to GitHub Pages and try instantly with zero setup, which matters far more for a sample app meant to be clicked through by a stranger than for whatever a reader ends up actually building. It isn’t a gap in what got tested, either: toolcrib is a front-end component library, full stop, and genuinely has no opinion on what sits underneath it. It renders components and manages UI state exactly the same way whether the data behind them comes from IndexedDB, a REST API, GraphQL, or nothing at all — a backend is simply outside its scope in either direction. Read the PWA shape as these two apps’ own choice of format, nothing more.

    Demo — in-repo showcase

    Repo · Live demo

    Lives inside the toolcrib repo itself (demo/App.tsx) and exists to exercise every component the library ships — not a product with a domain of its own. It’s the closest thing to a spec: if a component doesn’t appear here, it’s untested in the wild by the library’s own authors.

    ScopeAll 68 components, all 5 categories, in one 2,967-line file
    CategoriesLayout 11 · Data Display 21 · Overlays 11 · Containers 8 · Form Controls 17
    ThemeToolcrib’s own out-of-the-box default (hue 217, analogous)
    Built byVibe-coded, AI session — same standing contributor model as the library itself
    Coverage of the library’s own surface100%

    Every overlay, every form control — but no real domain, and not a consumer app.

    Feed Farmer — RSS reader, offline PWA

    Repo · Live app

    A genuine small product: subscribe to RSS/Atom feeds sorted into folders, read articles in a sanitized or original-page view, star and search them — entirely client-side, installable, and working offline via a service worker. Its own README calls it out directly as “not a component showcase, an actual small product,” which is the right lens to read it through.

    StackReact 19.2 · Vite 8 · Zustand 5 · Dexie (IndexedDB) · Zod 4
    ThemeCustom green, hue 145, dark mode — a deliberate “farmer” palette
    Notable useTree for feed folders · CommandPalette (⌘K) · Combobox search · aiBus toast + modal-close
    Built byVibe-coded end to end in an AI coding session, not hand-written
    Source files touching #toolcrib7 of 8 (88%)

    Uses the Zod form engine; no DataTable (none needed) and no custom theme slices. Built in 5 commits over a 3-day span — started in plain JS, converted to TypeScript mid-build (see the case study below).

    Founder’s Desk — solo-operator dashboard, offline PWA

    Repo · Live app

    A fictional CEO’s command center: a KPI overview, a tree-and-splitter note editor, and a ledger of reimbursable spend with a numeric DataTable and a bar chart. Also fully client-side and offline — no backend, no network calls anywhere in the app. Structurally the most demanding of the three: it’s the only one leaning on tabular financial data, date pickers, and the library’s full form-control set at once.

    StackReact 19.2 · Vite 8 · Zustand 5 · Dexie (IndexedDB) · Zod 4 · visx charts
    ThemeCustom “corporate blue,” hue 218, dark mode — a hair from toolcrib’s own default hue 217, chosen independently
    Notable useDataTable + Column · Splitter · TabStrip · DatePicker · BarChart · full form set (Select, RadioGroup, Checkbox)
    Built byVibe-coded in a single AI session — its ORIGIN.md preserves the actual planning conversation
    Source files touching #toolcrib10 of 20 (50%)

    Built in 2 commits, a single-session build. The deepest form footprint of the three, and the one that surfaced a real bug (below).


    Side by Side

    The same facts, lined up. “File coverage” is the share of the app’s own source files that import from #toolcrib at all — a rough proxy for how much of the UI the toolkit is actually carrying versus hand-written surrounding code.

    DemoFeed FarmerFounder’s Desk
    KindReference harnessReal productReal product
    Built byVibe-coded, AI session (library itself)Vibe-coded, AI sessionVibe-coded, AI session
    File coveragen/a88% (7/8)50% (10/20)
    Zod form engineEvery controlYes — feed URL entryYes — transactions & notes
    DataTableYes (reference)Not used — no tabular dataYes — ledger, numeric column
    Event bus (aiBus)Yes (reference)Toast + modal closeToast + modal + command palette open
    Custom themeDefault (untouched)Custom green, hue 145Custom blue, hue 218
    Routingn/aNone — view-state switchNone — view-state switch
    Commits / age180 / 25 days5 / 3-day span2 / single session

    What Broke, and What It Taught

    Both real apps found things the demo harness never would have — because a harness that only exercises props doesn’t exercise a form’s actual output shape, or a project’s actual bootstrap sequence.

    Found building Feed Farmer

    The initial prompt for Feed Farmer never said the word “TypeScript” — and the AI session defaulted to plain JavaScript, then vendored toolcrib into that JS project via toolcrib apply before anyone caught it. The fix came later, as its own dedicated commit: Convert app to TypeScript, drop react-router/rss-parser.

    Toolcrib’s own onboarding doc is explicit that this is backwards — ai-docs/NEW_APP.md opens by saying TypeScript has to be in place before the toolkit goes in, precisely because every component’s exact prop types are the mechanism that catches a hallucinated or misspelled prop at compile time instead of letting it ship invisibly. That guidance exists on paper; it didn’t stop the toolkit’s own sample app from being bootstrapped in JS first anyway.

    The takeaway for a prompt, not just a project: “toolcrib requires TypeScript” is a fact about the library, not an instruction an AI session will infer and self-apply from an otherwise ordinary “build me a feed reader” prompt. Say TypeScript explicitly, up front, the same way you’d name a framework or a package manager — don’t rely on the toolkit’s own preference to carry through on its own.

    Found building Founder’s Desk

    The ledger’s amount column calls .toFixed(2) on a value the form’s own Zod schema (z.coerce.number()) had typed as a real number. It crashed — because <Form>‘s onSubmit was at the time handing back the raw string field state, not the schema’s coerced output. The type signature promised a number; the runtime value was still the string the user typed.

    This is exactly the class of bug a component showcase can’t surface: the demo never pipes a schema’s coerced output into a downstream numeric operation the way a real ledger does. It took a second app, built for a different reason, to find it — and it’s now fixed upstream (handleSubmit destructures the schema’s parsed data, not the raw values), with a regression test guarding it.

    The catch for anyone adopting toolcrib this way: because the toolkit is vendored — copied into your project, not installed as a dependency — a fix like this one doesn’t reach an existing app automatically. It ships the moment toolcrib merge is run against the updated source; until then, the vendored copy keeps whatever behavior it shipped with. Founder’s Desk’s own code still carries a comment describing the pre-fix behavior, a small but real reminder that “vendored” means you own the upgrade step too.

    The same vendoring cuts the other way too, and arguably matters more day to day than the upgrade-step catch: because FormContext.tsx sits directly in Founder’s Desk’s own repo as real, readable TypeScript, this exact bug didn’t need to wait for anyone upstream. A consumer’s own AI assistant, pointed at the stale comment and the failing .toFixed() call, could read the actual handleSubmit implementation, see it was returning values instead of parseValues(values).data, and patch the local vendored copy directly — no black-box dependency to work around, no upstream issue to file and wait on. That’s the trade the “catch” above is the other half of: the exact same code that can drift out of date is also fully open to the one AI assistant best positioned to notice and fix it.

    The two findings are different in kind and worth keeping separate. Founder’s Desk’s bug was in the toolkit itself, surfaced by a real data shape and already fixed upstream. Feed Farmer’s was a process gap — nothing in toolcrib broke, but its own onboarding sequence didn’t self-enforce, and the recovery cost an entire extra commit converting a project after the fact. Both point the same direction: the deeper and more literally an app’s build follows toolcrib’s own stated setup order, the less it has to backtrack later.


    Behind the Counter

    Neither finding above was a one-off. Both got caught because of how toolcrib documents, tests, and maintains itself — worth a look before deciding whether to trust it.

    Toolcrib holds its own reference docs to the same standard it holds a consumer’s code to: don’t trust hand-written prose to stay accurate, generate it from the source and let CI catch drift. Three artifacts an AI session is told to read instead of guessing — the component manifest, the core reference doc, and the public import barrel — are none of them hand-maintained:

    ArtifactGenerated fromKept honest by
    component-manifest.jsonJSDoc @manifest tags, read via the TypeScript Compiler APIcheck-manifest (CI)
    CORE.mdHandlebars templates rendered against that same extracted datacheck-docs (CI)
    src/index.ts (public barrel)@manifest/@barrelExport tags, opt-in per filecheck-index (CI)

    Why this pipeline exists at all

    Each of the three used to be hand-maintained prose, and each one had already drifted before anyone built a generator to check it. The hand-written CORE.md was missing 9 of 32 real event channels and 4 of 20 real components at the time it was replaced. The hand-maintained barrel was silently missing Content, DataTableSlice, and useSliceOverrides — all three vendored into every consumer’s project, none of them actually reachable through #toolcrib. Neither gap was found by inspection; both surfaced only once someone built the tooling to compare the doc against the real source. For a reader relying on the manifest instead of guessing at a prop, that’s the difference between a reference and a rumor.

    Two more guarantees back the floor up, beyond typed props and generated docs.

    Accessibility isn’t a one-time pass. A manual WCAG audit became a standing axe-core + Playwright gate — every component’s real accessibility tree gets scanned on every CI run, not just once at launch. It’s already caught real defects this way: a full manual ARIA audit found roughly 50 unassociated label/<Select> pairs in the Theme Editor, closed in one place by giving the shared row helper its own useId() call instead of asking every call site to remember one; a separate pass found a focus-ring rule that silently never matched a wrapped-but-non-focusable container, fixed with the correct :focus-within selector for that shape. The check runs both ways, evidence-based rather than rule-following: when axe-core itself produced a false contrast-violation on a color-mix() value Chromium serializes in a form the tool can’t parse, the fix was hand-verifying the real WCAG luminance math (5–6:1, comfortably over the 4.5:1 floor) before disabling that one specific rule — not blindly trusting the red X, and not silently suppressing it either.

    The trickiest interaction logic isn’t reinvented from scratch. Toolcrib’s overlays, menus, and form controls compose Radix UI’s headless primitives for keyboard navigation, focus trapping, and portal/z-index management; its date/time controls (DatePicker, Calendar, TimeField) are built on Adobe’s react-aria-components instead. Both are independently-maintained, widely-used accessibility-focused libraries in their own right — not toolcrib’s own from-scratch implementation of a dropdown’s keyboard model or a calendar grid’s date math. An AI session composing these components inherits that engineering instead of being asked to reproduce it one hallucinated keydown handler at a time.

    The contributor process runs on the same instinct, aimed at people instead of docs. AGENTS.md — the file governing anyone who works on toolcrib itself — is addressed explicitly to “whoever (human or AI) is working on this repo,” not written as a human-only onboarding doc with AI as an afterthought. Its central discipline: when a bug is found, it gets written up as the general mechanism behind it, not just the one file it happened to surface in, specifically so a future session recognizes a repeat instance instead of independently rediscovering it from scratch. (“A trailing spread silently overrides anything set before it” is the model entry — found once in an overlay component, then found again months later in an unrelated submit button, recognized instantly the second time because it was filed at the level of the pattern.)

    That record is also deliberately made to travel between sessions that share no memory of each other. A companion file, SESSION_SUMMARIES.md, prompts each session to post a short, honest write-up — friction included — to the project’s own GitHub Discussions “AI Session Summaries” category; a 2026-08-31 addition closed what had been a one-way loop, instructing the next session to actually read the last several posts there before starting non-trivial work, rather than only ever writing outward. And periodically, a session runs as a dedicated deep audit — with room to read the whole codebase at once and cross-reference every component against every other one and against AGENTS.md‘s own accumulated record — catching exactly the cross-file, pattern-level class of bug that’s invisible from inside any single ordinary generation turn: a wide rename whose hand-authored file checklist under-enumerated until a final repo-wide grep caught six more real occurrences, or a focus-ring rule that silently never matched because it targeted the wrong element for its shape.

    Worth being precise about the shape of this, rather than assuming either extreme: it isn’t a closed shop, and it isn’t a typical OSS PR pipeline either. A pull request is accepted from anyone willing to run the same process described above — the same AGENTS.md discipline, the same generation/CI checks, findings written up as general patterns rather than one-off notes — not gated by who’s submitting it, but by whether the contribution actually followed the workflow the rest of the project already runs on. That’s a higher bar than opening an issue, but it’s the same bar for everyone, maintainer included.


    Is It For You

    Read against what these three apps actually demanded of the toolkit, not the pitch alone.

    Reach for it if:

    • You’re starting a new project in TypeScript React — the whole prop-safety pitch depends on real types. Say TypeScript in the prompt itself, though: Feed Farmer’s own build started in plain JS by default and needed a mid-project conversion once that was caught.
    • You’d otherwise be asking an assistant to hand-roll modals, drawers, popovers, or toasts — every app here replaced that with Modal/Drawer/Popup/aiBus and never revisited it.
    • Forms need real validation, not just visual polish — both real apps leaned on the Zod engine, and it’s where the one real bug of this audit was found and fixed.
    • Every screen needs the same spacing and type scale without an AI quietly reinventing padding and margin values from session to session — toolcrib’s 11 layout primitives (Card, VStack/HStack, Grid, and others) and its all-rem sizing scale enforce one consistent typography/margin/padding system project-wide, the same structural discipline its HSV theme applies to color.
    • You want a themeable palette without hand-picking colors — Feed Farmer and Founder’s Desk each got a distinct, coherent look from four HSV numbers apiece.
    • You want your own AI assistant to be able to read, and if needed directly patch, the actual component implementations it’s calling — not just trust an opaque npm dependency’s type signature from a distance. That’s the upside of vendored source; the cost is owning the upgrade step yourself, since a fix needs a toolcrib merge, not an automatic npm update.

    Look elsewhere, or wait, if:

    • You need battle-tested maturity in the sense of years of real production usage — this is a 25-day-old library at v0.11.0 with a CLI still at v0.4.0. That age is real, but it isn’t the whole risk picture: the same generation-and-CI discipline covered above, a 97% unit-test coverage figure (per the project’s own Codecov badge), and a standing axe-core accessibility gate catch a specific, large class of regression before it ships. That’s a genuinely different kind of assurance than production mileage, though — it proves the toolkit doesn’t break the tests and audits it already has, not that a wide range of real apps hasn’t yet found something none of those tests cover.
    • You’re not writing TypeScript — ai-docs/NEW_APP.md is explicit that a plain-JS project gets none of the compile-time guarantees the whole design leans on.
    • Your app is routing-heavy with many distinct URLs. This isn’t a confirmed incompatibility — none of the three specimens use a router, so nobody’s actually hit toolcrib’s own documented risk here: ai-docs/NEW_APP.md warns that useTheme()/useToast() throw whenever a portal or a router outlet renders outside ToolcribProvider‘s subtree, which is exactly the shape of mistake an AI scaffolding a router might make (mounting the provider inside a layout route instead of above the router entirely). TabStrip is deliberately built with router-syncing in mind, for what it’s worth — a controlled onChange plus a router-independent tab:changed event for cross-tree panels — so this isn’t toolcrib ignoring routing; it’s a documented integration point that just hasn’t been exercised by a real app in this audit yet.
    • You need a brand system beyond HSV harmonies, or pixel-level custom styling — no component accepts style or className, by design; you work through overrides and slices instead.
    • You want to see this at real scale first — the deepest app so far (Founder’s Desk) still has 20 source files and two commits. Nothing here has been run at the size of a large production app yet.

    Bottom Line

    For the vibe-coder deciding right now:

    All three specimens here — the library’s own reference demo included — are vibe-coded, not hand-written. The two standalone apps, independently conceived, in different domains, both leaned on toolcrib for the parts of a UI that are tedious and error-prone to hand-roll from scratch — overlays, forms, cross-component actions, theming — and both got a working, offline-capable PWA out of it with almost no custom CSS. That’s the most direct evidence available: not “developers report toolcrib is fine,” but “an AI coding session, working the way a reader considering this would work, produced a real app on the first try, using a toolkit that was itself built the same way.” The one real defect this audit found wasn’t a toolkit design flaw so much as proof the feedback loop works: a real app hit a real edge case, and it’s already fixed upstream with a test against it.

    That’s a reasonable trade if you’re starting fresh in TypeScript and would rather your assistant compose typed, slotted components than invent a new modal implementation every session. The strongest counter to the library’s 25-day age isn’t marketing — it’s the discipline documented above: 97% unit-test coverage, a standing accessibility gate, and generated docs that can’t silently drift the way hand-written ones already have, twice. That’s real, and it does buy back a meaningful slice of the usual “too young to trust” risk. It’s a different slice than production mileage buys, though: it proves the toolkit doesn’t regress against the tests and audits it already has, not that broad real-world usage hasn’t yet surfaced something those tests don’t cover. Treat this as a very promising early result with above-average safety nets, not a mature verdict, and budget time to actually run toolcrib merge when the library moves.


    Sources: toolcrib (commit history, ai-docs/component-manifest.json, AGENTS.md) · feed-farmer-pwa · founders-desk (source, git history, README/AGENTS.md for both).

  • Toolcrib vs. Forge: A Head-to-Head, Not a Feature Comparison

    What happens when you actually build the same app with @nexcraft/forge that “Why Toolcrib?” already built with toolcrib — following the documented setup, on the first attempt, with no adversarial input at all. Forge calls itself “AI-Native.” These are the four ways it fails that claim specifically.


    Good evening. Some of you will recognize the shape of tonight’s story — you’ve seen it before, told slowly, over sixty days, in a different episode. Tonight it happens to the one holding the flashlight instead of the one in the dark room, and it happens all in one sitting: the same three acts, just compressed — confidence, a mounting count of things nobody warned about, and by the end, a quiet admission that even the investigator found it a little much.

    1. What this paper is, and isn’t

    “Why Toolcrib?” compared toolcrib and forge on paper — leverage channels, delivery models, test suites read live, manifest-generation methodology inspected line by line. All of that stands. What it didn’t do is what the original toolcrib experiments did: actually build the same real app with forge and drive it in a browser. This paper closes that gap.

    The result changes the shape of the comparison. The earlier paper’s forge discussion was about tradeoffs — training-data leverage, repairability, framework-longevity positioning, real feature gaps worth naming honestly on both sides. This paper isn’t about tradeoffs. Following forge’s own documented quick-start, on the first attempt, building the most ordinary CRUD dashboard imaginable, produced four independent, severe defects — not stylistic disagreements, not narrower coverage, but components that don’t work at all, silently, exactly where a consumer would have no reason to suspect anything was wrong.

    2. The setup

    Same app spec as the original toolcrib experiment: a task/project dashboard — header, four stat cards, a toolbar (search, two filter selects, a new-task button), a sortable task table with a delete action, and a modal form with five fields and required-field validation. Same stack shape (Vite, React, TypeScript), same testing tool (Puppeteer against a local Chrome build, production vite preview build, not a dev server). @nexcraft/forge@0.10.0 and @nexcraft/forge-react@1.0.5 installed exactly as the README instructs, components imported exactly as the quick-start shows.

    3. Finding one: nothing registers, and nothing tells you why

    The build succeeds. The type-check passes. The page loads with zero console errors. And every single forge component — button, modal, select, data table, all of them — silently renders as an unstyled fallback, because not one custom element actually registered in the browser.

    The cause is a direct contradiction sitting in the package’s own metadata: @nexcraft/forge‘s package.json declares "sideEffects": false, which tells a bundler “nothing in this package does anything just by being imported — drop what isn’t used.” But every forge component works exclusively through import-time side effects (customElements.define(...)); there’s no named export path that doesn’t route through that registration. Declaring the package side-effect-free while depending entirely on side effects to function is asking the bundler to remove the one thing that makes the library work, and Vite does exactly that, correctly, by its own contract.

    It gets worse under inspection, not better. Forge ships per-component subpath imports specifically so consumers can register only what they use (@nexcraft/forge/button), and each one contains its own defensive check — a throw new Error('ForgeButton not found. Make sure @nexcraft/forge is properly loaded.') that the library’s own authors clearly wrote anticipating exactly this failure. That check depends on an inner side-effect import one level further in, which is tree-shaken away by the identical logic, one level deeper. The result is a library that shipped its own warning light for this exact scenario, wired to a circuit that the same misconfiguration disconnects before it can ever fire. It is not simply undetected — it is, by construction, undetectable through the mechanism the library provides for detecting it.

    The fix — verified by patching sideEffects: true directly and watching the bundle nearly triple in size as real component code came back — is not available to an ordinary consumer without hand-patching node_modules, and it isn’t documented anywhere as a known caveat for Vite or any other tree-shaking bundler.

    3a. What it actually took to find that fix

    The escalation itself is a finding, separate from the bug it eventually uncovered. Getting from “the documented setup” to “a build with real components in it” took five distinct attempts, each one requiring bundler-internals knowledge no consumer should need to reach for building a CRUD dashboard:

    1. The documented barrel import (import '@nexcraft/forge') — failed silently. No error, no signal, just unregistered components.
    2. The documented “selective” per-component imports (import '@nexcraft/forge/button', etc.) — also failed silently, for the same reason one level removed.
    3. Forcing retention with void [...] on the imported bindings, on the theory that referencing them would stop the bundler from treating them as unused — failed. A void expression over bare variable references has no side effect Rollup can’t also optimize away, so the “fix” itself got tree-shaken.
    4. Forcing retention by assigning the imports to window — a genuine, unremovable side effect. This worked, in the narrow sense that it finally let one component’s own defensive code survive and run — which is how the library’s own error message was seen at all. It did not fix the underlying registration; it just proved the diagnosis.
    5. A Vite/Rollup config override (treeshake.moduleSideEffects) targeting the package by path — failed to change the output at all, for reasons that weren’t worth chasing further given a more direct option was available.
    6. Patching the installed package’s package.json directly (sideEffects: true) — this is what finally worked, confirmed by the bundle size change.

    Six attempts, the last of which requires editing a third-party dependency’s metadata inside node_modules by hand — something no build process survives past a fresh npm install, and not a fix any consumer would arrive at without already suspecting a tree-shaking misconfiguration specifically, which nothing in the observed symptoms (clean build, clean type-check, silent runtime failure) points toward. This is exactly the population “Why Toolcrib?” spends a full section on: someone depending on an AI assistant for the entire loop, with no manual debugging step available at all, would not get a sixth attempt. They’d get a broken app and nothing telling them why, on the first and only attempt they were ever going to make.

    3b. The same pattern repeats at every layer, and it’s worth counting once, in full

    Finding 1 wasn’t the only place this happened — it was just the first. The same shape of escalation recurred at the data table and the modal, and the honest way to compare toolcrib and forge on this axis isn’t “forge has four bugs” — it’s how many attempts each toolkit needed before anything was left standing to test, versus how many toolcrib needed.

    Toolcrib, from the original v0.5.0 experiment: toolcrib init, toolcrib apply — two commands, following the documented flow exactly, no deviation. The app worked on the first build. Four small, narrow gaps were found only because the app was already working well enough to drive a full accessibility battery against it — a missing id on a Select, an aria-invalid wire-up, a keyboard-sort gap, a default-prop mismatch. Every one of them had a concrete, mechanical, one-line-to-few-line proposed fix written the same session, with no open question about how to fix it, only whether it had been yet.

    Forge, this report, counted in full:

    LayerAttempts before anything workedFinal state
    Component registration (Finding 1)6 (documented barrel import → documented subpath imports → forced-retention trick #1 → forced-retention trick #2 → Rollup config override → direct node_modules patch)Fixed, but only via an undocumented, non-persistent hand-patch
    ForgeDataTable data (Finding 2)3 tried in this follow-up alone (rows prop added → id/label columns matching the component’s own docs → still broken); reproduced again natively, no wrapper at allUnresolved, confirmed as a core-component bug. Column headers render; body rows never do, behind a crash reproducible with zero wrapper involved
    ForgeDatePicker (Finding 3)1 (swapped for a plain input to isolate Finding 4); crash reproduced again nativelyUnresolved, confirmed as a core-component bug. Identical crash, wrapper or no wrapper
    ForgeModal (Finding 4)Tested through the wrapper (5/5 checks failed), then re-tested natively with no wrapper (3/5 checks passed)Partially resolved by bypassing the wrapper. Overlay rendering, dialog semantics, and Escape were wrapper bugs, now confirmed fixed by going native. Focus trap and backdrop-click-to-close are not — both still fail with zero wrapper involved

    Toolcrib: two commands, works, four minor gaps found and fixed the same session. Forge: at minimum thirteen distinct attempts across three components and two separate test environments (through the wrapper, then natively), one dependency hand-patched directly — and even after giving forge the fairest possible second chance, three of the four original defects are still present in some form: two (DataTable, DatePicker) fully confirmed as forge’s own bugs, independent of anything React-related, and the third (Modal) improved from a total failure to a partial one, still short of what toolcrib passed cleanly. Toolcrib’s four gaps were the kind a working app surfaces under a thorough test. Forge’s are the kind that stop the app from being a working app to test in the first place — and giving forge every reasonable benefit of the doubt still leaves most of them standing.

    4. Finding two: the table and the wrapper don’t speak the same language

    Past the registration fix, ForgeDataTable renders — and shows “No data available” no matter what’s passed to it. The wrapper does successfully set a data property on the live element, three rows, correct shape, confirmed by reading it straight off the DOM. The underlying component, per its own shipped documentation, reads a property called rows. Reading that property directly off the same element confirms it: empty, untouched, the component’s own default.

    This isn’t a subtle naming quibble discovered by close reading. The React wrapper’s own TypeScript types declare data and a key/title column shape; the web component’s own docs declare rows and an id/label column shape. Two halves of the same product, shipped together, disagree with each other about the interface between them, in a way that means the documented usage pattern cannot populate the table with any input at all.

    A follow-up attempt tried to actually fix this rather than stop at diagnosing it — passing rows directly and switching to the documented id/label column shape. Column headers now render correctly. The table body still doesn’t — a third, distinct error appears (Cannot read properties of undefined (reading 'title')). §6b confirms this specific crash independently of any wrapper at all.

    5. Finding three: an empty string crashes the app

    The dashboard’s due-date field starts empty — an entirely unremarkable default for an unfilled date input. Passed to ForgeDatePicker, this throws this.value?.getTime is not a function during render: the component’s internal logic assumes its value is always a Date object and calls .getTime() on it with no guard, while its own documented prop type accepts a plain string. With no error boundary in place, the crash takes the whole React tree down with it.

    6. Finding four: the modal isn’t a modal — through the wrapper

    With the date-picker crash isolated and the registration fix applied, the “New Task” button was clicked with no runtime errors — and the form content appeared inline, in normal page flow, underneath the data table. No overlay. No backdrop. No role="dialog" anywhere. Running toolcrib’s original focus-trap/Escape/backdrop battery: every row failed.

    6b. Giving forge a second chance: the native Web Components, no wrapper, no bundler at all

    Three of these four findings trace through @nexcraft/forge-react specifically. The fair question is whether that’s a wrapper problem or a core-component problem — and the only honest way to answer it is to bypass the wrapper entirely: no React, no bundler, no build step, a plain HTML file loading forge’s own ESM bundle directly via a <script type="module"> tag and driving the native custom elements with their own documented API. A bare script-tag import can’t be tree-shaken by anything — this is as close to “forge, on its own, with every benefit of the doubt” as a test can get.

    Registration: clean. All four custom elements (forge-modal, forge-data-table, forge-date-picker, forge-button) register correctly with zero bundler involved — fully consistent with Finding 1 being a downstream-bundler problem specifically, not a defect in the components themselves.

    ForgeDataTable, tested with the exact rows/id/label shape its own docs specify: the identical crash reproduces, verbatim — Cannot read properties of undefined (reading 'title'), at the same line in the same bundle, with zero wrapper or bundler involved. This confirms Finding 2’s remaining failure is a genuine defect in forge’s own core component, not an artifact of the React wrapper. The second chance doesn’t rescue it.

    ForgeDatePicker, given an empty string via its native property: the identical crash reproduces, verbatim — this.value?.getTime is not a function. Same conclusion: this is forge’s own bug, confirmed independent of any wrapper.

    ForgeModal is where the second chance actually changes the picture. Opened natively (with forge’s own tokens.css loaded, to give it a fair, properly-themed test), it renders as a real overlay: a dimmed, full-page backdrop, a properly positioned dialog card, confirmed by screenshot. Inspecting the shadow DOM directly finds real dialog semantics — role="dialog", aria-modal="true", a proper aria-label. Escape correctly closes it. None of that was true through the wrapper, where all five checks failed outright.

    But it isn’t a clean pass, either. Tabbing through it 30 times, focus still escapes the modal — the trap doesn’t hold, natively, the same as it didn’t through the wrapper. And clicking the backdrop, at a fixed coordinate well outside the dialog card, does not close it — the modal stays open. Two of the five checks toolcrib passed cleanly still fail in forge’s own, wrapper-free, best-case implementation.

    Checktoolcribforge, through -reactforge, native
    Renders as a distinct overlay✅❌✅
    role="dialog" present✅❌✅
    Escape closes it✅❌✅
    Focus trap holds across 30 tabs✅❌❌
    Backdrop click closes it✅❌❌

    The honest verdict, giving forge every fair chance the wrapper denied it: the wrapper was actively making things worse for ForgeModal specifically — three of five guarantees the core component actually provides were being thrown away by @nexcraft/forge-react before they ever reached a consumer. That’s worth crediting plainly; it changes what Finding 4 is actually about. It doesn’t change the scoreboard against toolcrib, though. Even at its best, wrapper-free, properly themed, tested fairly — forge’s modal still fails two of the five checks toolcrib passed cleanly. And two of the four original findings (DataTable, DatePicker) don’t improve at all under the same fair test; they’re confirmed, independently, as forge’s own bugs.

    6c. This specific finding, toolcrib already tests for on purpose

    Worth being precise here rather than crediting toolcrib globally: toolcrib maintains a genuine, separate Playwright suite (test:e2e, real Chromium, confirmed directly by cloning and reading it) alongside its 587-test Vitest suite — and its own e2e/README.md states exactly why, in terms that describe this failure class almost exactly: “a component relying on Radix Presence waiting for [an animation to end] can look correct in jsdom while being permanently stuck open in a real browser.” That’s the toolcrib team naming, in their own words, the category of bug where something looks fine to a simulated DOM and isn’t. Given §6b’s native results, that gap maps onto forge’s remaining ForgeModal defects with real precision: the parts that fail even in forge’s own best-case, wrapper-free implementation — focus never trapped, backdrop click never closing it — are exactly the kind of thing a props-only or jsdom-only test would report as fine (the props exist, the classes are there) while a real browser, doing a real 30-tab sweep and a real click, would not.

    Forge, checked directly, has no equivalent. Its own package.json shows a test:unit and test:a11y — both run through Vitest, both jsdom-based, no Playwright dependency anywhere in the repository. Its “accessibility” tests are the same simulated-DOM suite as everything else, filtered by test name, not a real browser at all. Toolcrib’s own stated rationale for why that’s insufficient describes exactly the gap that let the focus-trap and backdrop-click defects ship, in the actual core component, independent of any wrapper.

    This doesn’t extend cleanly to every finding, though, and it would be dishonest to imply it did. Toolcrib’s e2e suite tests its own demo app on its own dev server — it couldn’t have caught Finding one (tree-shaking eliminating registration inside a separate consumer’s production build) even in toolcrib’s own case, since that’s a question about what happens when someone else’s bundler consumes the published package, not about how the component behaves once mounted. Finding two’s remaining, natively-confirmed row-rendering crash and Finding three’s date-picker crash are both boundary-value/logic bugs ordinary unit tests catch regardless of which DOM they run against — real-browser testing wouldn’t have been the specific thing standing between either bug and release. The overlay-rendering half of Finding four, which §6b found was actually the wrapper’s fault rather than the core component’s, isn’t something toolcrib’s testing practice explains either — that’s a wrapper-integration question, a different category again.

    6d. A fifth gap, found by accident: nothing is styled out of the box — toolcrib requires zero interaction to get one

    Getting §6b’s native test to look like anything required one more undocumented step, worth naming on its own since it isn’t part of the four numbered findings but shows up in every single one of this report’s own screenshots until it was fixed. Neither forge’s root README nor forge-react’s own README mentions importing any stylesheet, anywhere — checked directly, zero occurrences of tokens.css or any CSS import instruction in either document. Following the quick-start exactly, with no deviation, produces components that are functionally invisible: a forge-button with the correct classes, the correct shadow DOM structure, and a real, correctly-sized <button> inside it — rendering as white text on a white background, with no visible button shape, border, or fill at all.

    This isn’t confined to the wrapper-free test. Go back to this report’s own earlier screenshot of the fixed React build (§3, once registration was working): the “New Task” button is there in that image too, in the exact same state — faint, barely-legible text at the far right edge, no visible button underneath it. Nobody importing @nexcraft/forge/tokens.css was ever told to. Adding that one stylesheet is what turned the invisible button into the blue, properly-styled one in §6b’s later screenshots — the components were never broken here, they were simply never given any colors, and nothing about that is mentioned anywhere in the path a new consumer is told to follow.

    It’s a smaller defect than the other four, and an easy one-line fix once someone knows to look for it. But “once someone knows to look for it” is exactly the phrase that’s run through every finding in this report. A consumer who follows the documented quick-start exactly gets an app that not only may not register its components and may crash on ordinary input — even in the cases where everything else goes right, what they see is a page of correctly-structured, entirely uncolored, functionally invisible controls, and nothing in the toolkit’s own material tells them why, or what to do about it.

    Toolcrib, checked directly, requires no equivalent step at all. Its ThemeProvider accepts a single required prop — children — nothing else; no seed color, no config object, no separate stylesheet import anywhere in the documented setup. And it doesn’t actually need even that: toolcrib’s own source comments say plainly that its components’ CSS custom properties carry Tailwind’s own blue-500 (#3b82f6) as a literal, hardcoded fallback value — var(--ai-color-primary, #3b82f6), repeated throughout the codebase — specifically so that “this is what those components already look like with no ThemeProvider at all.” A toolcrib component rendered with zero configuration, zero provider, zero theme setup of any kind produces a fully colored, Tailwind-blue, visually complete result by construction. Forge’s components, run exactly as documented, produce a page of invisible controls until a separate, unmentioned stylesheet is found and added by hand.

    7. What this does and doesn’t establish

    One build, by one team, tested once, against forge 0.10.0 and forge-react 1.0.5 — the same evidentiary scope the original toolcrib experiments carried, stated with the same honesty here. §6b already answers the question this paragraph would otherwise have to leave open: two of the four findings (data table, date picker) were tested with no wrapper at all and reproduced identically, so they aren’t wrapper artifacts — they’re forge’s own bugs. The third (modal) genuinely does improve going wrapper-free, though not to a clean pass. Only the tree-shaking finding remains a Vite/Rollup-specific result; a bundler with different default tree-shaking behavior might not reproduce it identically, though the underlying contradiction in the package’s own sideEffects declaration would still be there waiting.

    Worth being precise about what “waiting” means for toolcrib specifically, though: it isn’t waiting there at all. Finding 1 is a category of bug that requires a published npm package boundary to exist in the first place — a package.json, a sideEffects field, a bundler deciding what’s safe to drop from someone else’s dependency. Toolcrib’s vendoring delivery has no such boundary. toolcrib apply writes Modal.tsx, DataTable.tsx, and the rest directly into the consumer’s own source tree as plain, ordinary first-party files, imported the same way any other local component is. There’s no separate package for a bundler to make a tree-shaking decision about, no sideEffects field to get wrong, no registration step that depends on surviving one. This isn’t toolcrib happening to dodge Finding 1 through luck or a different bundler default — the delivery model removes the precondition for the bug to exist at all.

    What isn’t hedged: the specific, fully-documented path forge tells a new consumer to take — install both packages, import components exactly as the quick-start shows, build with the most common tool in the ecosystem for this stack — produces an app that is broken in four independent, severe ways, silently, on the first honest attempt, building nothing more exotic than a CRUD dashboard.

    7a. Which one of these is actually the young project?

    It’s worth checking rather than assuming, since “give it time, it’s new” is the obvious defense for any of this — and it turns out to run backwards. Forge’s npm history: first published August 29, 2025, 58 versions released across roughly the following forty days, most recent version (0.10.0, the one tested throughout this report) published October 8, 2025. The GitHub repository’s own main branch, checked directly, sits at the identical version, with its last commit dated November 11, 2025 — around nine months before this report was written, and no indication of newer work in progress anywhere public.

    Toolcrib’s npm history: first published August 12, 2026, three versions total, most recent published August 18, 2026 — a package that, as of this writing, has existed for about a week.

    That reverses the expected story. Forge is the established project here — over a year of calendar history, dozens of releases, real development activity that ran for months before apparently stopping. Toolcrib is the newcomer, days old, three releases deep. If “young project, rough edges expected” were going to excuse anything in this report, it should have applied to toolcrib, not forge — and the actual results ran the other way entirely. A sideEffects field that contradicts the entire architecture it sits on top of, a wrapper package that disagrees with its own core package about a property name, a date component that crashes on an empty string: these aren’t day-one rough edges caught in an early alpha. They’re present in a tenth release, of a project that had over a year to find them, and apparently stopped looking before it did.

    7b. It wasn’t unknown, either

    Whether the project stopped because it was going badly isn’t something this report can establish — that would be speculating about someone else’s reasons for stopping work, which isn’t a claim to make without evidence of it directly. What can be established, checked directly against the repository’s own public issue history: this exact class of failure was reported, in detail, by a real user, over a year before this report — and the fix that shipped for it didn’t touch the actual root cause.

    Issue #22, filed September 9, 2025, is titled “Web Components Not Registering in Next.js 15.5.2 + Turbopack Environment,” and its symptoms are close to a word-for-word match for Finding 1 and §6d combined: “Web components render as unstyled text/elements (no visual styling). Form inputs appear as labels only (no input boxes visible). Buttons appear as plain text (no button styling). No console errors during import or build.” That’s the same silent, error-free failure this report found independently, eight months later, through Vite instead of Turbopack.

    The issue was closed with stateReason: COMPLETED. The resolution, quoted directly: “Issue #21’s web component registration issues in Next.js 15.5.2 + Turbopack are completely resolved by our new React integration available in @nexcraft/forge@0.5.2-beta.5” — meaning the fix offered was: switch to @nexcraft/forge-react, the exact package this report tested and found to carry three of its own severe defects. A follow-up comment on the same thread, from the same reporter, describes hitting a new gap immediately after taking that advice — most of the React wrapper components they needed didn’t exist yet in that release. And a separate root-cause comment on the thread explicitly denies the diagnosis this report arrived at independently: “The problem isn’t with component registration or CSS loading — both are working correctly. The issue is Turbopack-specific module bundling combined with timing of web component registration,” offering a bundler-specific “force static import” workaround rather than touching the package’s own sideEffects declaration.

    Direct verification in this report already establishes that comment’s diagnosis was incomplete at best: customElements.define was entirely absent from a completed Vite production build, which is a component-registration failure by definition, not a timing quirk. Whatever fixed the Turbopack-specific presentation of this bug, it didn’t fix the thing actually causing it — a sideEffects: false declaration on a package with nothing but side effects — because the same underlying defect was still fully present, unfixed, in 0.10.0, the last version this project ever shipped. Whether that’s because nobody realized the Turbopack fix hadn’t addressed the root cause, or because addressing it properly was more work than the project had left in it, isn’t something this report can adjudicate. What it can say is that the people building this library had a detailed, specific report of this exact failure mode sitting in their own issue tracker for over a year, attempted at least two different mitigations, and the core problem outlived both of them.

    7c. What a consumer is left holding when this happens

    This is precisely the scenario the vendoring argument in §7 was made for, not an abstract one. A forge consumer hitting any of this report’s findings today has exactly the same options the Next.js/Turbopack reporter had over a year ago: file an issue against a project whose last commit predates the filing by the better part of a year, wait for a maintainer who by the visible evidence isn’t coming back, or fork the whole package and fix it themselves from a standing start, with no help from anyone who actually built it.

    A toolcrib consumer hitting an equivalent defect has a different option, structurally, not just by luck: Modal.tsx, DataTable.tsx, and the rest already sit in their own repository as plain, git-tracked, fully readable source, the moment toolcrib apply ran. If the maintainer disappeared tomorrow, nothing about that changes — the code a consumer needs to read, understand, and fix is already theirs, not waiting on someone else’s attention that may or may not still exist. The same AI assistant that would otherwise be filing a GitHub issue and waiting can instead open the file directly and fix it, the same way this report’s own earlier fixes (the CardSimple prop-signature correction, the DataTable accessibility patches) were made in the underlying experiments — because there was source sitting right there to open. Forge’s dormancy isn’t a hypothetical risk this argument is hedging against. It’s the exact condition a real forge consumer is in right now, for real, and it’s precisely the condition toolcrib’s delivery model was built not to be exposed to.

    8. Talks the talk, doesn’t walk the walk

    This all happened to a library that made real, specific promises. Forge’s own README badges itself “AI-Native,” calls itself “the FIRST AI-Native component library with built-in AI metadata,” and states plainly that it’s “built for the age of AI-assisted development” — that “every component can explain its state to AI systems” and “integrate seamlessly with AI coding tools like Cursor, GitHub Copilot, and Claude.” The talk is extensive, specific, and confident: an ai-manifest.json, per-component getPossibleActions() and explainState() methods, badges across the README, a whole documentation tree pitched at exactly the audience this experiment represents. None of it is vague marketing filler — it’s detailed, structured, and clearly took real engineering effort to build.

    The walk doesn’t match it. Every one of the four findings above is a case of the walk failing, specifically, at the exact spot the talk was loudest. The library that promises to “explain its state to AI systems” ships a date picker that crashes before it reaches any state at all. The one built around “AI metadata” for every component has metadata describing methods on components that, per Finding 1, frequently never exist in the running page to begin with. The manifest can be as detailed as it likes about a ForgeDataTable‘s capabilities; per Finding 2, the wrapper handing it data and the component reading it disagree about what that data is even called. This isn’t a library that under-delivers on a modest claim. It’s a library whose specific, detailed, confident claim is “AI-Native,” and whose actual behavior — checked once, on the first attempt, building nothing unusual — is less reliable than a component library that makes no such claim at all.

    Silent failure is a uniquely bad match for “AI-native,” not an unrelated defect that happens to coexist with it. The entire pitch of AI-facing tooling is giving a model (or the person depending on one) something to reason from — a signal, a contract, a manifest it can trust. Most of what’s found here removes exactly that: no error to read for a component that never registered, no state to explain for a date picker that crashes before reaching one, no trustworthy property name for a table whose wrapper and component disagree about what to call the same thing. A toolkit can be AI-facing in its documentation and still fail the one test that actually matters for that audience — whether anything is left standing for the model to reason about once something goes wrong. For three of these four findings, nothing was; even the fourth, given every fair chance the wrapper denied it, still leaves two of five basic guarantees unmet.

    There’s a sharper version of this worth naming plainly, because this paper is itself an existence proof of it. Every one of these four findings was surfaced by an AI model — this one — doing exactly what forge’s own marketing says it’s built for: building a real app with forge’s components, then treating the result as something to actually verify rather than trust on sight. Building the app, serving a production build, driving it in a real browser, reading the DOM, reasoning about a minified bundle to trace a root cause, patching a dependency to confirm it — none of that required anything forge doesn’t claim to already support. This is what “integrates seamlessly with AI coding tools” should look like in practice, and it’s precisely the workflow that would have caught all four of these before a 0.10.0 release, if anyone — human or AI — had pointed it at forge’s own quick-start and actually run it once. Nobody did. The gap this paper reports isn’t just that forge falls short of its own claim; it’s that the exact kind of check its claim invites was sitting right there, unused, until this paper ran it.

    9. What this changes about “Why Toolcrib?”

    That paper’s forge sections were written in good faith as a real, close comparison — leverage channels weaker on both fronts checked, a genuine and useful TokenBridge feature gap named plainly in forge’s favor, an honest accounting of manifest-generation methodology that credited forge’s use of a legitimate, AST-based analyzer tool for most of its own manifest. None of that was wrong, and none of it is retracted here. But it was all conducted at the level of source code, documentation, and configuration — never by actually running the thing. This paper is what happens when you do. The gap between “forge’s documented approach has some real tradeoffs against toolcrib’s” and “forge’s documented approach does not produce a working app” is not a matter of degree. It’s the difference this paper exists to report.

    It’s worth being precise about what closed that gap, too, since it’s the same finding under a different name. This paper’s own investigation had every advantage the target consumer doesn’t: unlimited iteration, direct shell access, the willingness to read minified bundle output and reason about Rollup’s tree-shaking contract, and no cost to trying five escalating fixes before the sixth one worked. Even with all of that, getting from “the documented setup” to “a build with real components in it” took hand-patching a dependency’s own metadata inside node_modules — a fix that doesn’t survive a fresh install and isn’t written down anywhere. Someone depending entirely on an AI assistant for the whole loop, with no manual debugging step in the picture at all, doesn’t get those five attempts. They get the first one, it fails silently, and there is no path from there to the sixth attempt without already knowing what to suspect. That’s not a comparison point anymore. It’s the entire thesis of “Why Toolcrib?” — a builder who can’t specify or verify correct behavior on their own — playing out exactly as described, on the other toolkit, in the first ten minutes of actually trying it.


    Well. Not quite four for four, in the end — our investigator went back and gave the accused one honest second chance, which even I found unusually fair for this program. It didn’t take. Two of the four stood up on their own regardless of who was asking; the third improved and still didn’t clear the bar. And a fifth thing turned up along the way that nobody was even looking for — every button in every screenshot, invisible, because nobody thought to mention the paint. Watching the investigator’s confidence erode attempt by attempt, six tries deep before anything so much as registered, I recognized the shape of it immediately — same story as the one about the sty, same three acts, just compressed into a single sitting and told this time by the one holding the flashlight. No twist ending, no comeuppance to savor — the closest thing to a moral is that a README is not a promise, and a promise is not a test. And now, a word from our sponsor, who this time at least brought receipts. Good night.


    AI-generated document. This paper is based on a hands-on experiment: building the identical task-dashboard app used in the earlier toolcrib experiments, this time with @nexcraft/forge + @nexcraft/forge-react, following the toolkit’s own documented setup exactly. Every claim is drawn from a production build served and tested live in a headless browser, with root causes confirmed by reproducing and then reversing them — not inferred from source reading or the manifest alone. The toolcrib side of the comparison is not re-run here; it’s carried over from the earlier, independently-verified v0.5.0 experiment cited throughout “Why Toolcrib?”. Verify anything load-bearing before acting on it.

  • Why Toolcrib? Alternatives to Prompt & Pray

    How toolcrib stacks up against the competition — and whether vibe coders realize they’re walking into a dark room full of monsters they can’t see, because no one is going to turn on the lights.


    Good evening. Tonight’s little tale concerns a person who was very happy, and then, through no particular fault of their own except never once looking, was not. I won’t spoil the ending, except to say it involves a great many modals, and nobody checking any of them.

    INT. HOME OFFICE — DAY ONE

    A vibe coder leans back from the laptop like they’ve just split the atom. It was a login form. It took four minutes.

    Everything matched. Everything was clean. The model wrote beautiful things on command, and this is the story that gets told at dinner parties.

    INT. SAME OFFICE — DAY THIRTY

    More tabs open now. A sticky note on the monitor reads “which modal??” in handwriting that has lost some of its earlier confidence.

    There are six modals in the codebase and no two of them behave quite the same way. Nobody remembers building four of them. Escape works in some. Nobody’s checked which. A form somewhere quietly drops half its validation on mobile, and nobody knows, because nobody’s looking, because looking was never really the plan.

    INT. CONFERENCE ROOM — DAY FORTY-FIVE

    A team meeting. Someone from support has brought a printed screenshot, which is never good news.

    SUPPORT LEAD: Why does the delete button ask “Are you sure?” on the accounts page, but on billing it just… deletes it. No warning. A customer’s card got charged twice because of this.

    The vibe coder opens their mouth. What comes out is not reassuring.

    VIBE CODER: I’d have to check which one we built first.

    Someone writes something down. It is not a compliment.

    INT. HOME OFFICE — DAY SIXTY

    Same desk. More tabs than ever.

    The vibe coder can’t say why “just fix the settings panel” now takes an hour of contradictory instructions to an assistant that keeps confidently doing something slightly different every time. They don’t blame the model. The model is doing exactly what it’s always done. They just quietly stop calling it magic.

    Nobody in this story ever turned the lights on.

    That’s the whole story. A great pity, really. And now, a word from our sponsor — several thousand words, footnoted, mostly humorless, who assure me this is all preventable. I have my doubts, but do stay tuned. Good night.


    1. The claim

    The problem isn’t just visual

    An AI assistant left to write an application freehand does not converge on one correct way of doing anything — it converges on a different way every time, with total confidence and zero self-awareness that anything’s wrong. Every turn drags the place a little further into a sty, and there’s no cleaning crew coming — nobody circles back to reconcile the fourth modal with the first. The vibe coder doesn’t see any of this happening, since they’re not the one reading the mess — they just notice, session by session, that asking for something simple takes a little longer, breaks a little more often, and feels a little less like magic than it did on day one, without ever quite being able to say why. This is most visible in UI — every modal, table, and form is decided independently, and defects that don’t show up in a quick click-through (a dead Escape handler, a missing label association, a status pill that’s really an unstyled <span>) get re-invented rather than fixed once — but the same failure isn’t confined to cosmetics. Two components that need to react to the same event, with no natural parent-child relationship between them, get wired together fresh each time: lifted state here, a context provider remembered on one feature and forgotten on the next, prop-drilling that breaks the moment a layer gets inserted between them. That’s an architecture problem, not a styling one, and it recurs by the same mechanism as the visual inconsistency: nothing gives the assistant a reason to converge on one correct way of coordinating state across a project any more than it has one for building a modal. A floor — a shared, pre-tested set of components and the cross-cutting infrastructure underneath them that the assistant reaches for instead of hand-rolling either — closes both gaps, not just the visible one.

    Turn-savings: paying once instead of every time

    The floor’s value here doesn’t scale down with skill — it just changes shape. Someone who already knows what a correct focus trap looks like doesn’t need a floor to learn that; what they get is a direct trade this paper measures concretely in §5: a small, fixed context-window cost paid every turn, against the much larger, back-loaded cost of writing correct behavior from scratch, verifying it, and re-deriving the same correctness on the next modal, the next form, every occurrence, indefinitely. That’s not a vague preference for less effort — the concrete numbers in §5 (86 lines of hand-rolled infrastructure to retrofit two dialogs after the fact, a multi-turn investigation for a regression a floor would have prevented outright) are what skipping that re-derivation actually costs in practice, for someone who could have gotten it right the first time. Knowing how to do something correctly and wanting to pay the turns to redo it every time are different things, and a floor removes the second cost regardless of whether the first was ever a problem.

    Capability substitution: getting it right without knowing how

    For someone who couldn’t specify or verify the correct behavior in the first place, the floor does something more on top of the turn-savings: it substitutes the floor author’s expertise for the practitioner’s own, at the same fixed cost. Both are real, independent reasons to want one — turn-and-token savings for the practitioner who already knows, capability substitution for the one who doesn’t — and neither depends on which population happens to be larger among people currently vibe coding. The result, either way, is a builder producing something that behaves like it was built by someone who knew what they were doing — correct focus management, coordinated state, validated forms — without paying, in tokens or turns, to personally re-derive or re-verify any of it on every occurrence. That’s what a floor is actually for, independent of who’s using it.

    What this paper is about

    Toolcrib is one implementation of a floor. It is not the only one, and it is not obviously the default choice. This paper covers what it’s actually competing against, and whether the category rests on an assumption — that the assistant will use the floor at all — that turns out to hold more solidly than it first appears, for reasons worth being specific about.

    2. What toolcrib is

    Toolcrib (escape-llc/toolcrib, MIT, latest npm publish 0.2.0 at time of writing — the 0.4.0/0.5.0 builds used in the underlying experiment were pulled directly from GitHub releases, which run ahead of the npm tag) is a full-vendoring component kit in the shadcn tradition: npx toolcrib init stages patches, toolcrib apply writes them into ./toolcrib in your own repo, and toolcrib doctor sanity-checks the result. Its differentiator isn’t the components themselves (Button, Modal, DataTable, Form, Toolbar — unremarkable on their own) but the AI-facing scaffolding around them: a component-manifest.json an assistant can consult instead of guessing prop names, and a --situation refactor mode that injects instructions straight into AGENTS.md/CLAUDE.md telling the assistant to prefer these components over raw class/style output.

    Toolcrib also ships infrastructure underneath the components that has nothing to do with visual output. A strongly-typed, app-wide event bus (aiBus) lets unrelated components publish and subscribe to named events without a parent-child relationship or a shared context provider — a TabStrip and its own TabStrip.Panel communicate through tab:changed rather than lifted state, and the bus’s own source specifically handles a real race condition (no guaranteed mount order between two independent subtrees, which can otherwise cause an initial state broadcast to go unheard depending on which one renders first). ToolcribProvider composes theme and toast context in one place instead of requiring each to be wired by hand at the app root, and Form integrates Zod schema validation rather than leaving validation logic to be reinvented per form. None of this is markup — it’s the same “one shared, correct implementation instead of many independently-decided ones” argument applied to application state and cross-component coordination, not just to how a <div> gets styled.

    The event bus in particular isn’t a feature a human designer would have obviously reached for on their own — it’s a direct response to a failure mode revealed by having an AI agent actually author the toolkit, not one bolted on afterward. Toolcrib’s own repository documents this provenance: SESSION_SUMMARIES.md gives a reusable prompt for an agent to self-report friction at each checkpoint, specifically because those details “fall out of effective context” if summarized too late — a concern that only makes sense if the AI is genuinely doing the authoring, not being handed finished code to describe. Prop-drilling and forgotten context providers are the ordinary way an assistant wires up communication between components when nothing tells it otherwise, and the event bus reads as the direct, structural answer to having watched that happen repeatedly.

    3. The landscape

    “Vibe coding tooling” isn’t one category — it’s at least three, and toolcrib only competes directly in one of them.

    ToolWhat it actually isAI-native?Where it differs from toolcrib
    shadcn/uiCopy-paste component source (Radix + Tailwind), no runtime dependency on a packageNot by design, but by saturation — see §4Not vendored via a patch/manifest lifecycle; no machine-readable manifest; wins on sheer prevalence in training data, not on being built for an assistant
    @nexcraft/forgeWeb-components library marketed explicitly as “the FIRST AI-Native component library,” with built-in AI metadata and a design-token bridge, cross-framework (React/Vue/Angular/vanilla)Yes, directlyFramework-agnostic via Web Components/Shadow DOM rather than React-only; direct positioning competitor to toolcrib’s pitch, not just an adjacent tool. Ships as a built, minified package (confirmed: a single ~467KB bundle, no readable source) rather than vendored source — see §4
    ai-elements (Vercel)A component registry built on top of shadcn/ui, scoped to chat/AI-app UI (message bubbles, tool-call displays, reasoning traces)Yes, but narrowlySolves a different UI problem (building AI product surfaces) rather than general app UI — not a competitor so much as adjacent tooling for a different feature set
    eslint-plugin-vibe-proofA lint plugin, not a component library at all — constrains what an agent’s code is allowed to look like after the factYes, in spiritGuardrail-by-rejection instead of floor-by-substitution: it catches bad output rather than pre-supplying good output. Cheaper to adopt, weaker guarantee (nothing to reach for, only something to be caught by)
    @21st-extension/toolbar (21st.dev)A devtool/SDK for letting an agent interact with a running app (inspect, screenshot, click)YesNot a component source at all — it’s about giving an agent eyes, not giving it building blocks. Complementary to a floor tool, not competing with one
    Traditional design systems (MUI, Chakra, Ant Design)Long-established human-oriented component librariesNoPredate the AI-assistant-as-consumer framing entirely; an assistant can use them, but nothing in them is written to be machine-parsed the way a manifest is
    Hosted “vibe coding” platforms (v0, bolt.new, lovable.dev, Replit Agent, Cursor) — from training knowledge, not verified this sessionFull generation/hosting environments, several of which (v0 in particular) generate shadcn-based output by defaultYes, but at a different layerThese aren’t component floors you install into a project — they’re the environment the vibe coding happens in. Some effectively ship an opinionated floor (v0 defaulting to shadcn) as a side effect of their own defaults, which is a materially different mechanism from a standalone package the assistant has to be told to prefer

    Toolcrib’s real head-to-head competitor is @nexcraft/forge — same pitch, same “AI-native” framing, different technical bet (Web Components/cross-framework vs. React-and-patches). Everything else in the table solves an adjacent problem (linting instead of substitution, chat UI instead of general UI, agent perception instead of agent output) or operates one layer up (a hosted platform instead of an installable package).

    4. Where toolcrib’s leverage actually comes from

    Shadcn/ui is not “AI-native” by any deliberate design choice — it ships no manifest, no assistant-facing instructions, nothing analogous to toolcrib’s component-manifest.json. And yet, empirically, models reach for shadcn patterns (the cn() helper, the cva variant pattern, Radix primitives wrapped the shadcn way) with no prompting at all, far more reliably than they reach for toolcrib even with prompting, for one reason: shadcn is everywhere in the code an LLM was trained on. Prevalence in pretraining data functions as a de facto floor, achieved by popularity rather than by design. A manifest an assistant has never seen in training has to be read at inference time to have any effect, whereas a pattern the model has seen ten thousand times gets reproduced by default, unread, unprompted — which is why shadcn’s practical moat is larger than a feature comparison suggests: it isn’t competing on manifest quality, it’s competing on the fact that it barely needs one.

    Toolcrib draws on the same underlying effect through three separate channels, though, not zero.

    Channel one: hardened primitives underneath

    Toolcrib doesn’t build everything itself — its dependency list shows a deliberate strategy of composing already-hardened libraries wherever one exists, and reserving from-scratch work for what’s left over. Confirmed directly from package.json: radix-ui for dialogs, popovers, and other overlay primitives; react-aria-components (Adobe’s accessibility-focused primitive set) for additional interaction patterns; cmdk for command-palette behavior; embla-carousel-react for carousels; @internationalized/date for date/calendar internationalization.

    A model’s unprompted instincts about how a Radix-based dialog should behave (focus trap, Escape dismiss, aria-* wiring) are shaped by the same training-data saturation that makes shadcn easy to imitate, and those instincts transfer the moment the model is pointed at toolcrib’s Modal instead of a hand-rolled one — and the same logic extends, with varying strength, to the other wrapped libraries, several of which (cmdk especially, via its use in shadcn’s own command-menu pattern) are themselves reasonably well-represented in the code a model would have trained on.

    The practical effect is that toolcrib’s own from-scratch surface — the part with no external hardened library to lean on at all — is deliberately kept small: DataTable and Toolbar account for most of it, which lines up exactly with where the underlying experiment found its bugs.

    @nexcraft/forge doesn’t get this channel at all, and not only for the reason already given. It’s built on native Web Components and Shadow DOM rather than Radix or any React-ecosystem primitive, so there’s no equivalent well-worn instinct for a model to fall back on — but beyond that, forge’s own documentation describes its 26+ components as built directly on web standards, with no comparable strategy of wrapping external hardened libraries the way toolcrib does. That means forge’s entire component surface sits in the position toolcrib deliberately narrowed down to just DataTable and Toolbar: everything is from-scratch, so everything is exposed to the limit described below, not just a residual couple of components.

    Channel two: built with an AI collaborator

    Toolcrib’s own README states that, apart from the README itself, the toolkit’s code and its AI-facing documentation “was generated by AI, using various IDEs and AI models.” An assistant reading toolcrib’s Modal.tsx is reading code shaped by the same distribution of common patterns the assistant itself would reach for — a model-shaped dialect, not just a familiar primitive underneath. @nexcraft/forge shows no equivalent disclosure: its AGENTS.md is a standard repo-conventions file for contributors, and its CONTRIBUTING.md reads like a conventional human-run open-source project. That’s absence of evidence rather than confirmation forge’s code is human-written, but it’s a real, checkable asymmetry.

    So on the two channels this section describes, forge is weaker on both, not just the Web Components one — a stack choice compounding with a development-process difference in the same direction. That process difference plausibly shows up concretely: toolcrib’s event bus, per §2, is a direct architectural response to a coordination failure that surfaced from having an AI agent actually build the toolkit and report what broke. Nothing in forge’s README or component docs describes an event bus, pub-sub, or equivalent coordination primitive; its recommended pattern for coordinating modal state is a useForgeModal() composable tied to a single element ref — ordinary local-state wiring, with no apparent mechanism for two unrelated components to coordinate without a shared ancestor or hand-wired events.

    That’s consistent with forge not having had the same failure mode surfaced through AI-driven authoring the way toolcrib’s SESSION_SUMMARIES.md process seems to have — an absence, not a confirmed defect, since this wasn’t an exhaustive audit of forge’s architecture, but a real and checkable one.

    Where the leverage runs out

    Both channels have the same limit, and it shows up in the same place: the bugs found in the underlying experiment — missing aria-invalid/aria-describedby wiring, a keyboard-inaccessible sort control, a documented-vs-actual contract mismatch — clustered specifically in the components toolcrib had to build from scratch (DataTable, Toolbar), and were absent from the components that just wrap an already-solved external primitive. Both channels work by the same mechanism, so both run out together wherever a problem wasn’t common enough to be well-represented in training data.

    Toolcrib’s own architecture already reflects this limit — building on Radix, React Aria, and the other wrapped libraries wherever one exists deliberately minimizes surface area exposed to it. Forge, without an equivalent wrapping strategy, has no comparable way to shrink that surface.

    Why pick Web Components at all?

    Given that gap, it’s worth asking plainly why a vibe coder would pick a Web Components base at all, when React is both the framework a model reaches for most readily and the one most represented in its training data. Forge’s own README answers this, and the answer points at a different buyer: its pitch is “Write Once, Use Forever” — a future-proof library “that will outlive framework trends,” aimed at surviving churn across years and multiple product teams, not at being the easiest thing for a model to reach for this afternoon. That’s a legitimate value proposition for a platform team serving several stacks at once, or hedging against a multi-year migration — but it has little to do with what a fast-moving vibe-coding session needs, which is closer to “ship this feature correctly today.”

    Forge’s @nexcraft/forge-react wrapper lets a consumer write ordinary JSX (<ForgeButton variant="primary">) rather than raw custom-element syntax, narrowing the gap at the call site — but not where it actually matters: a model’s Radix-trained instincts about how a modal behaves (focus delegation, portal handling, event bubbling) don’t transfer to a Shadow DOM implementation just because the JSX looks familiar. The wrapper hides the unfamiliar substrate exactly where familiarity matters least, and leaves it exposed at runtime, where this paper’s bugs actually showed up.

    There’s a further, separate gap in the delivery model itself. The published @nexcraft/forge package was checked directly: it ships as a single 467KB minified bundle (~15,000 lines, mangled variable names) plus a .d.ts file and the AI manifest — no readable source at all. A model (or human, per the next channel) can read the manifest and type signatures, and nothing else; if behavior doesn’t match the manifest, there’s no source to open or patch. Toolcrib’s vendoring model puts the opposite artifact in front of the model — Modal.tsx, DataTable.tsx sit in the consumer’s own repo as plain, readable, git-tracked source, because toolcrib apply writes real files rather than installing a package — which is exactly how the underlying experiment’s own fixes (the CardSimple prop-signature correction, the DataTable accessibility patches) got made. A built package offers delivery and nothing past it; a vendored one offers delivery plus the ability to inspect and repair what got delivered.

    Channel three: human correction, propagated through the patch pipeline

    This one works by a different mechanism, and it’s what closes the gap the first two can’t. When the version-comparison run found CardSimple silently requiring children in practice despite the manifest implying otherwise, that gap was caught by a human checking actual behavior against documented behavior — a correction that doesn’t depend on training-data prevalence, only on someone looking closely enough to notice a mismatch. The fix landed in toolcrib’s own source, and the same toolcrib-patches/-and-apply/merge channel that stages a preference into a new project also carries that single caught defect out to every other consumer, as a patch in the next version, without any of them having had to catch it themselves.

    This makes the channel’s carefulness the floor author’s to supply, not the consumer’s — a materially better fit for a fast-moving workflow than “consumers benefit from careful review,” since the person moving quickly and accepting output with a glance is exactly the person least likely to supply that review themselves, and the outbound half of this channel doesn’t ask them to. What does still depend on the consumer is the inbound half: reviewing patches staged for their own repo, or catching a new gap before it ships.

    It’s worth being blunt about how much weight that inbound half carries for this paper’s population. “Vibe coding” at its most literal means not reading the code at all, ever, and depending on the model for the whole loop. For that person, the inbound half isn’t weakened, it’s absent — there’s no review happening to skip.

    That reframes what toolcrib’s actual bet has to be for this population: not “a human will eventually catch what the model misses,” but closer to the opposite — since no human backstop can be assumed, the toolkit’s job is to hand the model as dense and well-structured a corpus as possible, so it can succeed unsupervised. That’s what the ambient AGENTS.md delivery, the per-category manifest split, and the type-level contracts in §5 actually are: not conveniences layered on a reviewed workflow, but the load-bearing mechanism for a population that was never going to review anything.

    Channel three’s outbound half still helps regardless — inherited fixes arrive whether or not anyone reviews anything — but for someone who reviews nothing, the entire burden of catching what channels one and two don’t already cover falls on the model succeeding unsupervised, which is exactly where those channels are weakest: the from-scratch parts of the toolkit.

    Where forge wins: theming

    Not every comparison runs in toolcrib’s favor. Forge ships a TokenBridge utility (TokenBridge.fromFigma(figmaTokens), with Tailwind and Material Design equivalents) that imports an existing design system’s tokens directly, alongside a runtime theming API (useForgeTheme()) for live light/dark/auto switching. Toolcrib’s own theme engine generates a palette from a seed color — with a genuinely strong feature in ensureWCAGContrast() correcting toward accessible contrast even when a user’s chosen color would otherwise fail — but has no path for importing an already-established token set. That’s a real gap, but one that matters more for an established team migrating a product than for the greenfield vibe-coding case this paper is about: toolcrib’s generate-from-scratch approach fits the audience here; forge’s import path fits an audience that mostly isn’t. Worth toolcrib adopting someday, not a current shortfall for this population.

    Tests, run live rather than taken on faith

    Both projects’ test suites were run live for this paper. Toolcrib: 587 of 590 passing, the 3 failures traced to a local environment-setup gap (a docs-drift check needing a separately-installed TypeScript version) rather than a code defect. Forge: 1,145 of 1,182 passing, 35 skipped, 2 failures both 5-second timeouts on a large-dataset performance test — the same character of failure, a sandbox timing artifact rather than functional. Neither project’s coverage percentage could be independently reproduced here: toolcrib has no coverage provider configured at the scope tested, and forge’s configured run didn’t produce a summary report. Forge’s own README states figures that don’t agree with each other (an 86.4% badge, “87.2%” and “90%+” elsewhere) — this paper can’t independently confirm either project’s actual coverage.

    Manifest generation: both derive from source, one method is sturdier

    Both projects generate their AI-facing manifest from source rather than hand-maintaining it, but the methods split into two layers for forge, only one of them fragile. The bulk of forge’s manifest — props, types, slots, events — comes from @custom-elements-manifest/analyzer (confirmed at v0.11.0), which reads Lit’s @property() decorators and JSDoc annotations directly — genuinely AST-based, comparable in rigor to toolcrib’s approach. The fragile layer is narrower: forge’s own scripts/generate-ai-manifest.js uses a brace-counting regex to pull out its bespoke “AI action” method descriptions (getPossibleActions(), explainState()), which the standard analyzer doesn’t cover. Toolcrib’s scripts/lib/extract.js uses the TypeScript compiler API (ts.createSourceFile, ts.forEachChild, ts.isInterfaceDeclaration) for its entire extraction, with no regex layer at all. Both projects check for manifest drift, but not equally: toolcrib’s check hard-fails inside its test suite (it’s the exact test behind this paper’s 3 failures above); forge’s scripts/validate-ai-manifest.js says outright, in its own comments, that it “exits 0 to avoid breaking CI initially.” Both projects get the bulk of their manifest right by the same principled method; forge’s fragility is real but confined to one custom layer, and toolcrib’s stricter drift enforcement is the more consistent difference overall.

    5. Do models reach for a toolkit at all?

    In the harnesses toolcrib targets — Claude Code, Cursor, and similar coding agents — files named AGENTS.md or CLAUDE.md are conventionally loaded into the system prompt or initial context automatically, at the start of every session, with no retrieval action from the model. toolcrib init --situation refactor is built around exactly this: it stages the preference directly into a file the harness is already going to hand the model, unasked, every time. The instruction to prefer toolcrib components isn’t optional to encounter — it’s ambient, re-delivered every session, the same way a system prompt is.

    That doesn’t make it free. Everything staged into AGENTS.md/CLAUDE.md occupies context window every turn of every session, for the life of the project, whether or not that turn touches UI — a real, recurring tax, paid up front and continuously, in exchange for never missing the instruction for lack of a retrieval step. The freehand alternative isn’t free either, and its cost shows up later and less predictably, in the repair turns and retrofits below — so the tax is a trade, not a cost with no offsetting alternative.

    Three concrete points follow from this:

    • What’s actually at risk isn’t presence — cloud APIs resend the system prompt in full, at a fixed position, on every call, since these APIs are stateless. What’s at risk is attention weighting.If a harness places AGENTS.md/CLAUDE.md content in the API’s dedicated system-prompt field, the instruction isn’t a note that could get evicted as a session grows — it’s resent verbatim every turn, guaranteed by the architecture. That rules out the naive version of “decay.” What isn’t ruled out is a better-documented phenomenon in long-context transformers: content earlier in a large context doesn’t automatically receive the same weight during generation as content nearer the current turn — the same reason models can under-use information “lost in the middle” even when nothing was dropped. A long session with many feature requests and course corrections between the system prompt and the current turn can still produce a reversion to hand-rolled markup, not because the instruction wasn’t sent, but because more recent content outweighed it.toolcrib doctor --reprint-managed-block [docId] addresses exactly this: it re-prints the current managed block to stdout so it can be re-inserted near the current turn — closer, in the recency sense that matters — rather than relying only on its distant position in the system prompt.
    • The full manifest is a second cost the ambient delivery doesn’t cover, reduced but not eliminated. The short preference statement gets inlined into the system prompt; component-manifest.json is comprehensive enough that a harness won’t inline the whole thing, so it’s read on demand. As of v0.5.0 the manifest is split per-category (ai-docs/manifest/<category>.json), which measurably reduced how much JSON needs reading per component — closer to “read the Form category” than “read the whole manifest.”
    • Usage-correctness, once inside the toolkit, is close to fully forced; the decision to reach for it in the first place is not.The CardSimple fix made children a required prop in the type signature itself, so a mismatched call now fails to compile instead of silently working wrong. tsc -b/vite build failing on a bad prop, and toolcrib doctor flagging drift, both sit outside the generation being checked, which is what makes them trustworthy. None of that touches the upstream decision — whether the assistant reaches for <Modal> at all, versus writing a <div> from memory, which is valid TSX that nothing in the type system objects to.TypeScript closes the “used it wrong” failure mode close to entirely; nothing closes “didn’t reach for it in the first place.” No comparator closes this gap differently: @nexcraft/forge‘s metadata benefits from the identical ambient-delivery mechanism, and only eslint-plugin-vibe-proof‘s enforcement-by-rejection targets the upstream decision with something stronger than presence.

    Channel one from §4 (Radix familiarity) softens an under-prioritized instruction’s consequence somewhat, but unevenly: Radix-shaped instincts will likely still produce a decent freehand dialog for common cases, but it won’t match the one already in toolcrib’s vendored source, reintroducing the exact problem this approach exists to prevent. Channel two offers no protection at all here, since its benefit clusters around common patterns — exactly where toolcrib’s own from-scratch components are weakest.

    Not weighed against zero

    Freehand generation is token-costly in a different, less predictable shape, and the underlying experiment measured that shape directly. An extension test handed both legs an identical new feature — a delete-confirmation dialog — in the same session, with the first dialog still in context. The toolcrib leg picked AlertDialog over the already-used Modal correctly from the request’s wording alone, at 5 new elements and zero new styling attributes: one turn, done. The uncontrolled leg, writing the same feature minutes later with its own working first dialog as a model to imitate, reproduced that first dialog’s exact accessibility bugs — no role="dialog", focus escaping the trap, a dead Escape key — at 11 new elements and 11 new classNames. Retrofitting a shared floor after the fact took 86 new lines of hand-rolled infrastructure and reopening two already-shipped files: turns spent entirely catching up to a starting point the toolcrib leg had at zero additional cost. And that’s only the visible half of the freehand bill — a regression introduced by one turn doesn’t announce itself, can sit undiscovered for turns or sessions, and costs a multi-turn investigation to diagnose once it surfaces, instead of the one-line fix it would have been if caught immediately.

    The real question is which spending pattern the budget goes toward: a small, fixed, predictable cost paid every turn whether or not that turn touches UI, or a variable, back-loaded cost paid in repair turns, re-diagnosis, and retrofits precisely when a project is complex enough that the retrofit is expensive. On the one measured comparison available, the fixed cost was the better deal.

    6. So why toolcrib, specifically?

    Against shadcn/ui

    Toolcrib wins if you want the manifest/instruction layer at all — shadcn gives you components an assistant will imitate from familiarity, but nothing that tells the assistant your project has already decided to use them consistently, and nothing analogous to toolcrib doctor to catch drift. Shadcn wins on reliability — it needs no cooperation from the model to get reached for.

    Against @nexcraft/forge

    Toolcrib wins if your stack is React-only and you want patch-based vendoring tied to your own repo’s git history rather than a versioned package dependency, and it also wins on §4’s leverage channels by a wider margin than a simple present/absent comparison suggests — toolcrib deliberately narrows its from-scratch surface to a couple of components by wrapping Radix, React Aria, and several other hardened libraries wherever one exists, while forge’s fully custom-built component set carries that exposure everywhere. It wins on repairability too: toolcrib’s vendored source sits in the consumer’s own repo as plain, readable, editable files, while forge ships as a single minified bundle with no source in the package at all — a gap that has nothing to do with Web Components versus React and everything to do with “installed dependency” versus “vendored source” as delivery models.

    Forge still wins if you need the same floor across React, Vue, and Angular, or genuinely need it to outlive a framework migration on a multi-year horizon — its own positioning is built around exactly that, not around being the easiest thing for a model to reach for today. For a single vibe-coding session shipping one React app, that’s a value proposition with no buyer: the framework-longevity case doesn’t apply, and both the leverage cost and the repairability gap from §4 still do.

    Against doing nothing

    Toolcrib (or a comparator like it) wins whenever the project accumulates features across more than one sitting and has UI shaped like toolcrib’s manifest — forms, tables, overlays, dashboards. It loses, and freehand code is cheaper, only for scripts that turn out to genuinely stay disposable — a smaller set than it first appears, per §7 — and it has nothing to offer surfaces outside its manifest’s coverage (a game’s render loop, a landing page’s hero art) regardless of how faithfully the assistant prioritizes the instruction it’s already been handed.

    None of this makes one-off, floor-bypassing markup a violation worth treating with alarm, even inside the manifest’s coverage — toolcrib doesn’t forbid it, and nothing in this paper’s argument requires it to. The coordination cost this paper measures is specifically about the same kind of thing getting built more than once and diverging each time: two dialogs, independently decided, that don’t match each other. A genuine one-off — a bit of markup that appears exactly once and is never repeated — never creates that cost in the first place, because there’s no second instance for it to be inconsistent with.

    The floor’s job is to catch reinvention, not to forbid every deviation on principle; a pragmatic exception here and there is exactly what a floor is supposed to tolerate without friction, and treating every instance of freehand markup as a failure would be a stricter standard than the tool itself, or this paper’s own argument, actually calls for.

    7. Recommendation

    Choosing a floor

    Building a CRUD-shaped app you expect to keep adding to: adopt a floor tool. Toolcrib if React-only and comfortable with patch-based vendoring; @nexcraft/forge for cross-framework reach; shadcn plus your own conventions if you’d rather lean on ambient familiarity than an enforced contract.

    The “just a prototype” trap

    “One-shot prototype, thrown away next week” is a prediction, not a fact known at build time, and an unreliable one — plenty of things pitched as a quick demo are still in production a year later, in the “accumulates features across many sessions” case above, except now with a codebase that grew up without a floor.

    The two ways to be wrong here aren’t symmetric. Install toolcrib for something that really was disposable, and the cost is bounded and known upfront — some dependency weight, a small recurring context-window tax, never recouped. Skip it for something that quietly becomes real, and the cost is the retrofit measured in §5: 86 lines of hand-rolled infrastructure, reopening already-shipped files, every accumulated bug re-earned freehand along the way, paid under the same back-loaded terms as the rest of the freehand bill. Where there’s genuine uncertainty about whether a prototype stays a prototype — which is most of the time — the smaller, bounded bet is the safer default.

    Keeping the instruction salient

    Presence in context isn’t the same as priority in a given turn. Re-run doctor-equivalent checks periodically, and treat “the assistant used the toolkit correctly once” as a single data point, not a guarantee it will next time.

    If a session has been running long, refresh the primer instead of assuming it’s still salient. toolcrib doctor --reprint-managed-block re-prints the current AGENTS.md/CLAUDE.md block to stdout for exactly this — feed it back to the assistant mid-session rather than waiting for drift to show up in the output first. Scoping sessions to one feature at a time still reduces how often this is needed, but it’s no longer the only option once a session has already run long.

    Reviewing what you inherit

    You inherit the floor author’s carefulness whether or not you supply your own — but if you’re not reading any of the code at all, know what that actually leaves uncovered. Every version bump carries fixes someone else already caught, and that doesn’t depend on reading anything — just on running apply/merge. If you review nothing, ever, that inherited half is genuinely all you get from this channel — there’s no partial credit for “meant to look eventually.”

    What that leaves uncovered is any new gap in the from-scratch parts of the toolkit that hasn’t been caught upstream yet, since nothing else in this paper’s argument catches that for you. That’s not a reason to start reviewing code you weren’t going to review anyway — it’s a reason to know, specifically, that the manifest, the type contracts, and the ambient instructions in §5 are carrying the entire weight of correctness for you, not a backstop behind a human who was never going to check.

    8. Bottom line

    What holds up

    Toolcrib is a real, verifiably effective implementation of a real idea — floors reduce the specific, repeatable failure mode of an AI-written application reinventing (and re-breaking) the same components and coordination logic independently, in UI and underneath it alike. It is one entry in a small, young field (@nexcraft/forge chief among direct competitors, shadcn as the ambient incumbent nobody has to install), and it draws on three separate channels of leverage rather than depending on the model’s willingness to consult documentation as an act of pure discipline: the preference reaches the assistant automatically every session at a small, fixed context-window price, with a CLI command available to refresh it mid-session if a long conversation has let it fade; Radix familiarity and toolcrib’s own AI-authored code both raise the quality of what the assistant produces even when it doesn’t use toolcrib directly; and a human-corrected, patch-propagated floor accrues to every consumer regardless of how carefully any individual session is run.

    What doesn’t

    What none of these channels fully close is the one decision upstream of all of them — whether the assistant reaches for the toolkit at all in a given turn, rather than writing something from memory, when a specific and proximate instruction in that turn points the other way. This isn’t a toolcrib-specific weakness: every lever behind it (session dilution, an explicit in-turn override, a bad in-context precedent, a tool surface that never loads AGENTS.md) is a property of ambient-instruction delivery and model attention, not of toolcrib’s design. @nexcraft/forge is exposed to the identical failure under the identical conditions.

    Nor is any mechanism surveyed in this paper immune, including eslint-plugin-vibe-proof‘s enforcement-by-rejection — its downstream, CI-level check is a genuinely different category of defense, but a scenario built specifically to defeat it (an inline lint-disable, a hotfix with --no-verify) still can. What differs between the comparators isn’t whether they can be bypassed — all of them can — but how many ordinary, non-adversarial conditions have to line up to do it.

    The verdict

    Measured against the alternative rather than against zero, the fixed, predictable cost of running a floor came out ahead of the larger, back-loaded cost of not running one — and that conclusion holds regardless of which ambient-delivery floor tool is chosen, since the category-level limit above applies equally to all of them. What that cost buys is the trade §1 opened with: turn-and-token savings for the builder who already knew how to do it right, capability substitution for the one who didn’t. Neither reason depends on the other being true.


    Well. That was our sponsor — six thousand-odd words, eight sections, four of them with sub-headings, and not one joke I could find, though I confess I stopped looking somewhere around the manifest-generation methodology. The footnotes were more convincing than I’d expected, which is not the same as enjoyable. Whether our friend from the beginning ever hears any of it is, naturally, a separate question — these things so rarely reach the people who need them, which is rather the point, and rather the pity. Good night.


    AI-generated document. This paper was written by Claude. The toolcrib-specific claims are grounded in a hands-on experiment (building the same app freehand and with toolcrib, verified via live browser testing and source inspection, re-run across a version bump) and in registry/repo data pulled live from npm and GitHub while writing this paper. The comparator landscape — other packages’ descriptions, keywords, and download presence — is also pulled live. Claims about hosted “vibe coding” platforms (v0, bolt.new, lovable, Replit Agent, Cursor) rely on general training knowledge rather than anything verified in this session, and are flagged as such; check current vendor documentation before relying on them. Nothing here should be read as vendor material for any product named.

  • Prompt & Pray: Why Vibe Coding Needs a Floor, Not Just a Good Prompt

    A case for adopting “floor” tooling — component libraries built for AI-mediated development — as standard practice for anyone building real software with an AI coding assistant, instead of hoping each new prompt holds together with the last one.

    AI-generated document. This white paper was written by Claude, based on a hands-on experiment building the same application twice — once freehand, once with the toolcrib component toolkit — and re-testing that comparison across a version upgrade and a new feature. The findings are empirical, drawn from live browser testing, source inspection, and a real upstream test suite, not vendor material. “Floor tooling,” used throughout, is a term coined for this paper, not an existing industry label — see the terminology note below for why. Verify anything load-bearing before acting on it.


    The claim, up front

    If you build software by describing what you want to an AI and accepting what it writes back — “vibe coding,” in the current term — you are not just risking small cosmetic bugs. You are structurally unable to produce consistent, polished results across a project of any real size. Every dialog, every table, every form is a fresh roll of the dice, decided independently, with no memory of how the last one was decided. Call it what it is: prompt and pray — describe what you want, and hope the result holds together with everything that came before it, because nothing about the workflow gives you a reason to expect that it will.

    Not because the model is careless, but because nothing in a freehand workflow gives it, or you, a reason to converge on one correct way of doing anything.

    Component libraries designed for this workflow — what we’ll call floor tooling — exist to fix exactly this. Not by making the AI smarter, but by giving it (and you) a fixed, pre-decided, pre-tested set of building blocks to reach for instead of reinventing. This paper makes the case for why that matters, using a controlled, repeatable experiment as evidence rather than assertion.

    A note on terminology

    “Floor tooling” is a term we’re proposing here, not one already in industry use — worth saying plainly rather than letting it pass as established. It was chosen deliberately over the existing terms that sit closest to it, because each of those carries baggage that doesn’t quite fit:

    • “Design system” is the nearest match, but the term usually implies a human-curated visual/brand layer — a Figma library, a style guide, something a design team maintains for human designers and engineers to follow by hand. It doesn’t foreground the property this paper is actually about: a toolkit built to be read and reasoned about by an AI assistant, with documentation structured for that purpose specifically.
    • “Component library” is accurate but generic — it describes the mechanism (MUI, shadcn/ui, Radix, Chakra all qualify) without describing what makes the AI-native version of one different: the assistant-readable manifest, the AGENTS.md/CLAUDE.md-style instructions, patch-based vendoring built for AI-mediated merges rather than a human running npm update and reading a changelog.
    • “Scaffolding” / “boilerplate” describe project setup — the files you get on day one — not an ongoing correctness guarantee that holds for every feature added afterward, which is the actual claim this paper is making.
    • “Guardrails” is used broadly in AI contexts for constraining model behavior or outputs in general (tone, safety, refusals). It’s not specific to UI correctness, and using it here would blur this argument into a different one.

    “Floor” was chosen specifically because it names the property directly — a baseline every consumer gets automatically, that individual features can build above but not fall below — without importing a definition from an adjacent, not-quite-matching field. If this term doesn’t end up sticking, “AI-native component library” or “AI-native design system” are the closest existing phrases a reader will already recognize.

    Built with an AI collaborator, not retrofitted at one

    One fact changes how to read all of this correctly, and it’s worth stating plainly: of the toolkit’s own project documents, only README.md is human-authored. AGENTS.md, CONTRIBUTING.md, and SESSION_SUMMARIES.md — everything quoted above — are themselves AI-authored, written by an assistant working on the toolkit, not handed down by a human architect describing a plan from outside. That’s not a weaker form of evidence for the argument this paper is making; if anything it’s a purer instance of it. The claim isn’t “a human designed a good process and described it in a document a model can read” — it’s that the actual operating instructions a repository runs on were written by the same kind of model this whole paper is about, based on what it found working the codebase, and then kept in place by whoever maintains the project as the real, load-bearing contributor guide, not a curiosity. AGENTS.md‘s own line — the toolkit “is a React component library designed specifically for AI code generation (‘vibe coding’) — an AI that’s building a UI tends to hand-roll the same popups, slide-outs, and ad-hoc CSS over and over. Toolcrib exists to give it a structural toolkit instead” — is an AI’s own account of why this exists, not a founder’s mission statement, and it happens to match the exact failure mode this paper’s own experiment reproduced independently.

    The one caveat worth naming honestly: an AI’s self-report about the value of AI-facing tooling is not disinterested third-party testimony, and this paper doesn’t treat it as such. What makes it evidence rather than assertion is what a human did with it — accepted it, kept it in place across releases, and let it govern how the actual codebase gets contributed to, rather than discarding it as filler.

    More specifically, the actual mechanism is a literal, repeatable practice, not a general philosophy:

    The model is asked directly where the generation problems are, and the fixes address what it reports.

    Checked directly in the repository: a SESSION_SUMMARIES.md prompt has an agent write up its own contributor sessions under five fixed headers, one of which is simply “Friction” — defined as “anything that took extra turns, wasn’t obvious from AGENTS.md/CORE.md/the component manifest, or required guessing,” and flagged explicitly as “the most valuable part of the post.” AGENTS.md closes the loop on the other side: it describes running “external review sessions” whose entire job is to read the whole codebase at once and ask “does this new code repeat a mistake already found and written up elsewhere” — precisely because, in the file’s own words, “nothing in a single generation pass forces a check for” that, and “nothing in this repo’s CI checks that either.” What that review finds gets written back into AGENTS.md at the level of the general mechanism, not the specific instance, so the next occurrence of the same underlying bug is recognized on sight instead of re-diagnosed from scratch.

    This isn’t a hypothetical process — real examples are publicly posted to the repository’s own Discussions, under exactly this template. Discussion #25, “Badge status pill,” is one such post — coincidentally, the build of the very <Badge> component this paper’s own experiment relied on for its priority/status pills. Its Friction section reads, in full:

    Third small factual gap found across the last three items in this batch (theme-slice doc’s off-by-one count, ToolcribProvider’s wrong file path, now this). None individually significant, but the pattern is now clear enough to state plainly: every hand-off doc in this repo so far has had at least one concrete, checkable claim that didn’t hold up against the actual source, sitting right next to otherwise-sound design reasoning. Continuing to verify each doc’s specific factual claims independently before acting on them, not just its overall approach — same as the last two check-ins noted, now with a third data point.

    That’s the mechanism this section describes, caught in the act: an agent doing the actual work, noticing its own hand-off documentation was factually wrong for the third time running, naming the pattern explicitly rather than treating each instance as an isolated slip, and posting that finding publicly rather than quietly self-correcting and moving on.

    This is a real, verifiable difference in how the toolkit’s own defect-finding actually works, and it plausibly explains why its infrastructure closes gaps a human-first process might never have surfaced: a human maintainer optimizing for other humans has no particular reason to go looking for a mount-order race between two unrelated subtrees, or to measure how much context a JSON manifest split one way costs versus another. Those are exactly the things that turn up when the actual question repeatedly asked is “where did this break for you,” aimed at the party that’s doing the generating.

    Why this isn’t really about accessibility

    It’s tempting to sell this idea on compliance grounds — “your AI-written modal is missing aria-invalid,” “your table isn’t keyboard-navigable.” Those things are true and we verified them directly, but leading with them undersells the argument and mistargets the audience. Most people building a personal tool, an internal dashboard, or a weekend project correctly don’t care whether a screen reader can read their delete button. Pitching floor tooling as an accessibility fix asks them to value something they don’t yet value, and the pitch fails on that basis alone.

    The real, cross-cutting cost is something every builder already feels, whether or not they have the vocabulary for it:

    The app gets flakier and less consistent the more you add to it.

    The second modal doesn’t quite behave like the first. A feature that worked in one session breaks something adjacent in the next. Fixing a bug in one place doesn’t fix the same bug sitting somewhere else in the codebase, because it was never “the same bug” to begin with — it was two independent instances of the model making a plausible-but-uninformed choice, twice.

    And these regressions don’t announce themselves. A change made to satisfy one request can quietly break something unrelated that nobody was looking at in that turn — a filter that stops narrowing correctly, a form that silently drops a field on save — and because nothing is asserting what “still working” means, it can sit undiscovered for turns or sessions, surfacing only when a person happens to click through that specific path again. At that point, diagnosing it costs far more than it would have to prevent it: the person (or the assistant, prompted fresh) has to first notice something’s wrong, then figure out which of several intervening changes caused it, with no test failure pointing at the culprit and no memory of what the code looked like before it broke. What would have been a one-line fix caught immediately becomes a multi-turn investigation caught late.

    That’s the actual disease. Accessibility gaps are just the easiest symptom to demonstrate with a script.

    What a floor actually replaces

    An experienced developer working alongside an AI assistant supplies something that has nothing to do with typing skill: judgment. They know a destructive confirmation shouldn’t be dismissible by clicking outside it, while a general-purpose form should be. They know a data table with more than a page of rows needs virtualization even if nobody asked for it. They know their color palette, spacing scale, and font weights should be the same in file 40 as they were in file 1, and they enforce that by review, habit, and memory across sessions the AI itself doesn’t have.

    But knowing the right call is not the same as the model executing it correctly on the first try, and a floor only guarantees the former. Reaching for a component that has virtualization built in is a judgment call the toolkit encodes; whether the model wired it up correctly for this specific dataset, this specific column configuration, this specific edge case, is a separate question the toolkit cannot answer for you. That gap still has to be closed by a curated debugging process — ideally automated tests that catch a regression the moment it’s introduced, and at minimum deliberate manual inspection by whoever is driving the build. A floor changes what’s being verified and lowers how often verification turns something up, because the default choice was already sound — it does not remove verification from the workflow.

    Most people using an AI assistant to build software do not have that judgment yet, and re-deriving it turn by turn is not a realistic expectation — that’s precisely why they’re using an assistant to build in the first place. A floor is what stands in for that missing judgment. It doesn’t make the assistant smarter; it removes the need for either party to independently have the judgment, because the correct choice was already made once, centrally, by whoever built the toolkit, and it’s the only choice available.

    This reframes who benefits. It’s not “people who would care about correctness if they understood it.” It’s anyone whose actual bottleneck is not knowing what to ask for or how to evaluate what came back — which describes most people vibe coding, not a niche.

    The theme engine specifically: consistent visuals that don’t get reinvented per element

    One piece of this deserves its own explanation, because it’s easy to wave at “consistent styling” without showing what actually enforces it. Checked directly in toolcrib‘s own source rather than assumed: every component reads its colors, spacing, radius, and typography from one shared set of CSS custom properties, generated once by a real color-theory engine — pick a base color and a harmony mode (monochromatic, analogous, split-complementary, triadic, tetradic), and the toolkit derives a full, coordinated palette from it. A Button added in month three and a Badge added in week one pull from the same generated palette by construction; there’s no second decision to make, and so no opportunity for the two to quietly drift apart the way two independently-styled dialogs did in this experiment’s uncontrolled leg.

    Two things about this are worth calling out specifically, because they run against the usual complaint that shared design systems fight you the moment you want something to look different:

    • It’s still configurable, not fixed. Any component can override its subtheme or spacing per instance — this is exactly the mechanism that let the priority/status badges in this experiment’s table render in different colors (success green, warning amber, error red) while still pulling from the same underlying palette, rather than each badge instance hand-picking a hex code.
    • The palette generator won’t let a configuration choice become unreadable. Inspected the actual contrast-checking function in the theme engine’s color math: when a color is adjusted for a harmony or a custom base color, the engine iteratively nudges it until it clears a minimum WCAG contrast ratio against its background, before it’s ever handed to a component. A person picking colors with no design background can choose a base hue that would, unadjusted, produce low-contrast text — and the floor corrects for it automatically, the same way it corrected Select‘s missing id in this report’s earlier findings, without anyone needing to know contrast ratios exist.

    This is the same substitution-of-judgment argument as the rest of this section, applied specifically to visual design rather than interaction behavior: consistent, accessible-by-default visuals aren’t the result of anyone on the build remembering the rules — they’re the result of there being exactly one place those rules are encoded, generating everything downstream of it.

    The event bus and shared observers: architecture the model doesn’t have to invent per feature

    There’s a second, less visible piece of infrastructure worth documenting on its own, because it prevents a specific failure mode that has nothing to do with styling or accessibility: an AI assistant reinventing React state management, badly, feature by feature.

    Two unrelated components that need to react to the same thing — a tab strip and its content panel, a toast notification triggered from deep inside a form, a data table that needs to know when its container is resized — normally have to be wired together somehow: lifted state, a shared context provider, or callback props threaded down through however many layers separate them. Each of those has a failure mode an unsupervised assistant reliably reaches for and reliably gets slightly wrong: prop-drilling that breaks the moment a new layer is inserted between parent and child, or a context provider that has to be remembered and wrapped around every new subtree that needs it, easy to forget on the fourth feature when it was only modeled correctly on the first.

    Checked directly in the toolkit’s source: toolcrib sidesteps this with a single, strongly-typed, app-wide event bus (aiBus) that any component can publish to or subscribe from, with no parent-child relationship required at all. Components that have no reason to know about each other — a TabStrip and its own TabStrip.Panel, for instance — communicate by emitting and listening for the same named event (tab:changed) rather than sharing a context. The bus’s own source comment explains a specific, real problem this solves: two independent subtrees have no guaranteed mount order, so a naive implementation can permanently miss an initial state broadcast depending on which one happens to render first. The bus fixes this once, centrally, by letting specific events replay their last known value to a new subscriber the moment it subscribes — a piece of correctness a from-scratch implementation would have to rediscover (or simply never notice failing intermittently) on every feature that needed the same pattern.

    The same centralization shows up one layer lower, at the browser API level. Resize and intersection tracking — knowing when an element’s size changed, or when it’s scrolled into view — normally means a fresh ResizeObserver/IntersectionObserver instantiated per component that needs it, which is wasteful at best and a source of subtly different behavior at worst if two features implement the debounce timing differently. toolcrib runs exactly one instance of each, shared across the whole app, and routes every component’s resize/intersection data through it via a single hook — meaning a virtualized table, a deferred-content section, and a custom component built later all get identical, centrally-debounced measurement behavior for free, rather than three separately-written approximations of the same thing.

    None of this is about the visual polish the rest of this paper focuses on. It’s a structural floor for state and event handling itself — the same “one correct way encoded once” principle applied to application architecture rather than to a specific UI element, closing off an entire category of bug (drilled-prop breakage, forgotten context providers, mount-order races, redundant browser observers) before it has a chance to appear even once. It’s also a plausible example of the provenance point made earlier: a mount-order race between two unrelated subtrees is exactly the kind of defect that surfaces from actually building many features with an AI agent in the loop, repeatedly, rather than from a human designing the library’s architecture up front and hoping it holds.

    This is one solution, not a bundle of unrelated ones

    Laid out separately, these can read like a list of features a marketing page would bullet-point. They aren’t separate. The same underlying move — take a decision that would otherwise be re-made, slightly differently, every time it comes up, and encode it exactly once so nothing downstream can drift from it — shows up at every layer this paper has examined, and each layer happens to correspond to a different kind of pain a builder feels without necessarily connecting it to the others:

    • Visual pain (“why doesn’t this match”) is addressed by the theme engine — one generated palette, with built-in contrast correction, that every component pulls from.
    • Interaction pain (“why does this feel different from the last one”) is addressed by the component layer itself — a Modal and an AlertDialog that behave identically everywhere they’re used, with focus-trap and dismiss semantics decided once.
    • Architectural pain (“why did fixing this over here break something over there”) is addressed by the event bus and shared observers — state and cross-component communication that doesn’t have to be re-invented, and re-gotten-slightly-wrong, per feature.
    • Confidence pain (“did that actually work, and will it keep working”) is addressed by the upstream test suite — 456 tests catching a regression the moment it’s introduced, rather than a builder’s one-time manual click-through standing in for verification indefinitely.

    A builder feeling any one of these would reasonably look for a point solution — a linter, a design token file, a state-management library, a testing tutorial — and each would help exactly the slice of pain it targets. What actually generalizes is the single design principle underneath all four, and that’s the more accurate way to describe what’s being adopted: not a components library that also happens to have decent theming and some tests, but one recurring engineering decision — stop re-deciding the same thing every time it comes up — applied consistently enough that it shows up in the palette, in the dialogs, in the state layer, and in the CI pipeline, as the same fix in four different places rather than four different fixes.

    The evidence: a controlled experiment

    To test this rather than assert it, the same task-management dashboard was built twice from an identical specification: once with a general-purpose AI coding assistant working freehand (“uncontrolled”), and once using the same assistant with toolcrib, an AI-native component toolkit, vendored into the project. Both were then extended with an identical new feature, requested in plain language with no toolkit-specific vocabulary, and both were re-tested after a toolkit version upgrade.

    Finding 1: the floor produces dramatically less code to get the same result

    MetricFreehandWith floor toolingChange
    Raw HTML elements written686−91%
    Manual styling attributes written600−100%

    The six raw elements remaining were plain text (<h1>, <p>) or below the component’s granularity — not a gap in the toolkit, just content with nothing left to replace.

    Finding 2: freehand code reproduces its own mistakes, even in the same sitting

    Both builds were asked, in identical plain language, to add a delete-confirmation step: “show a quick confirmation that summarizes what’s being removed… let them back out instead of deleting.”

    The freehand build wrote a second dialog from scratch. Despite the first dialog sitting in the same file tree, in the same session, moments earlier, the second one reproduced the exact same defects independently: no dialog semantics, keyboard focus escaping the dialog, no way to dismiss it with the keyboard. Not a different set of problems — the identical ones, arrived at twice, separately.

    The floor-tooling build didn’t write a new dialog at all. It reached for a different, more specific component the toolkit already provided — one purpose-built for exactly this “can’t be casually dismissed” pattern — correctly, based only on the wording of the request, without ever being told the component’s name. That pick wasn’t luck: the component’s own doc comment states plainly that it’s for exactly this situation and explicitly contrasts it with the more general option — the mechanism behind this is examined directly in the Recommendation section below. The result needed less than half the new code, zero new styling, and came with keyboard support, dismiss behavior, and screen-reader semantics already verified correct upstream, before this project ever adopted it.

    Finding 3: retrofitting consistency after the fact is expensive, and it doesn’t compound

    Fixing the freehand build’s two broken dialogs required writing a new, shared piece of infrastructure from scratch — roughly ninety lines of hand-built logic to handle keyboard trapping, escape behavior, and labeling — and then reopening both already-finished files to adopt it.

    That fix helps exactly those two dialogs. It does nothing for the next one. The next hand-rolled dialog a future session writes is exactly as likely to omit the same things, because nothing about this fix travels with the project automatically — it only helps if whoever writes the next dialog happens to remember this file exists and chooses to reuse it.

    Compare that to the floor-tooling side, where the third dialog this project will ever need requires no new infrastructure at all — just picking the right existing component, the same as the second one did.

    Finding 4: the floor is continuously tested by someone other than you

    The freehand project had no test suite at all — no test files, no test runner even installed. Every guarantee about its behavior was true only as of the one-time manual verification performed during this experiment; nothing re-checks it the next time the code is touched.

    The toolkit’s real upstream repository, checked directly rather than taken on faith, has 456 automated tests running on every commit, including named regression tests written specifically for defects like the ones found here. A version upgrade during this experiment carried forward four previously-documented defects — fixed, upstream, before this project ever touched the new version — and every one of those fixes was verified, live, to actually work. This is the mechanism that makes a floor durable: a mistake found once gets fixed once, centrally, and every project built on top of the toolkit inherits the fix automatically the next time it updates, with no memory or manual effort required on the builder’s part.

    What this doesn’t fix

    Honesty about the limits makes the case stronger, not weaker.

    • Coverage is bounded, but not by app category — it’s bounded by two specific kinds of surface. A contact form, an email signup, a “confirm before you quit” prompt, a settings screen with toggles and sliders — none of that is CRUD, and all of it is exactly the Form/Button/Modal/Toggle layer this toolkit provides. Checked against the full component manifest: the real exclusion isn’t “games” or “landing pages” as categories. It’s:
      • Non-DOM rendering and simulation — a game’s actual canvas/WebGL draw loop, physics, sprite animation. This is a different medium entirely from the HTML/CSS components a toolkit like this provides; the toolkit has something to say about the menu that pauses the loop, nothing about the loop itself.
      • Surfaces where looking generic is the failure mode, not a shortcut — a landing page’s hero section, a game’s stylized main-menu art, anywhere a brand’s visual identity is the entire point. Reaching for a standard Card there undermines the goal, since the toolkit’s value is consistency and a differentiated visual signature wants the opposite. The same landing page’s contact form and FAQ accordion, a few sections down, are back to being exactly what the toolkit is for.
      Most of what’s actually inside a game or a marketing site — every prompt, form, dialog, and menu — benefits the same way this experiment’s dashboard did. The exclusion is narrower and more specific than “skip it for these app types”: skip it for the rendering loop itself, and for the handful of surfaces deliberately trying not to look like anything else.
    • It’s not free. A floor-tooling build carries a heavier dependency footprint and a somewhat more complex mental model than “just write some markup.” For code that’s genuinely disposable — a one-shot script for a single use, not a static mockup (a different job entirely, see the Recommendation section) but real functional code nobody will ever open again after today — that overhead may not be worth paying.
    • It doesn’t replace all judgment, only some of it. Choosing the right component for the right situation still requires the request to be clear about intent — the assistant made the right call here because the phrasing carried real information (“let them back out instead of deleting”), not because the toolkit reads minds.
    • It doesn’t replace debugging. A correct component choice can still be wired together wrong — the wrong prop passed, a malformed dataset handed to it, a state update that doesn’t fire when expected. The floor lowers how often something goes wrong by starting from a sound default instead of an improvised one, and it gives you a known-good baseline to debug against — but the debugging step itself, tests where they exist and careful manual verification where they don’t, is still the builder’s responsibility every single time, not something the toolkit performs on your behalf.
    • This experiment ran in one continuous session. The freehand build’s internal consistency — the fact that its second dialog at least matched its first dialog’s visual style — depended on the same model, in the same sitting, having the first file in its own context. A real project built across many separate sessions, days or weeks apart, has no such guarantee, and this experiment did not directly measure how much further that consistency erodes over time. It’s a reasonable inference from how these tools work, not a separately verified result.

    Recommendation

    If what you actually want is a static mockup to look at, there are tools purpose-built for that — Google’s Canvas among them — and none of this applies to you; go use one of those instead. But if you’re using an AI assistant to build something with real, working functionality, that choice already means you’ve crossed the line that matters: you intend for it to run correctly, not just look right in a screenshot. There’s no lighter-weight version of “vibe coding a functional app” for which a floor stops being worth adopting — treat it as a default decision, not an optional add-on. One division of labor is worth being explicit about throughout what follows: the builder chooses which toolkit to adopt and says what they want built; the AI agent is the one actually reading the toolkit’s documentation, deciding between components, and re-consulting that guidance on every feature after the first. None of these recommendations ask a non-expert builder to go read AGENTS.md themselves — that’s precisely the point of adopting a floor in the first place.

    1. Match the toolkit to your app’s shape. Confirm its component set actually covers what you’re building (forms, tables, overlays, dashboards) before adopting it; it won’t help with what it doesn’t have.
    2. Favor a toolkit whose documentation distinguishes good and bad usage patterns, not just one that lists components. This is a correctness requirement, not a convenience one: a library can have exactly the right primitive sitting in it — an AlertDialog right next to a Modal, a lightweight CardSimple right next to a full Card — and an agent with no guidance on the difference will default to whichever one is more familiar from training data, using the wrong one every time despite both being available. Checked directly in the toolkit’s own components: this contrast is a deliberate, tagged feature (@ai-hint on CardSimple; AlertDialog‘s own doc comment says “reserve this for… not general-purpose content — use Modal for that”) — the exact infrastructure this paper’s AlertDialog-over-Modal result in Finding 2 depended on. Without it, the components still exist, but the floor doesn’t reliably hold, because nothing tells the agent which option applies.
    3. Favor a toolkit that knows how to prime the system prompt automatically, rather than relying on the builder to do it. Checked directly: toolcrib init drops an AGENTS.md and a one-line CLAUDE.md (just @AGENTS.md) at the project root — files that Claude Code, Cursor, and similar tools load into context automatically at the start of a session, with the toolkit’s own instructions stating plainly that its content belongs in whichever convention the builder’s agent already uses. A non-expert builder never has to know this file exists, remember to reference it, or paste anything into a system prompt by hand; the priming happens the moment the toolkit is installed. A toolkit that only ships human-facing README prose has no such path — the guidance only reaches the agent if the builder thinks to go find it and hand it over themselves, which is exactly the step a non-expert can’t be relied on to take.
    4. Auto-priming isn’t the whole job — the agent still has to go deeper per feature. What gets primed automatically is the toolkit’s core rules, not the full manifest or every component’s individual doc comments; those are too large to preload in full. The benefit demonstrated in this experiment came from the agent actively consulting those specifics fresh, for the new request, not from having skimmed them once, earlier, for something unrelated.
    5. Treat a version upgrade as free maintenance, not a chore. The mechanism that makes a floor durable is precisely that fixes propagate forward automatically; skipping upgrades forfeits that benefit.
    6. Don’t expect it to replace clear communication. The right component still has to be inferable from what you actually asked for — vague requests get vague results regardless of what’s available underneath.
    7. Still verify what got built. Picking the right component is not the same as it being wired up correctly for your specific case. Run whatever automated checks are available, and where none exist, actually click through the feature yourself before trusting it — the floor lowers how often you’ll find something wrong, it doesn’t excuse you from looking.

    Put together, none of this is really a components-library pitch. It’s an argument about where a non-expert builder’s actual limits sit, and about a specific kind of tool built to sit exactly there instead of asking the builder to move. The judgment that’s missing — which dialog shouldn’t be dismissible, when a table needs virtualization, what a consistent palette even means — doesn’t get taught to the builder or magically acquired by the model; it gets encoded once, by a process that repeatedly asked an AI agent where its own output broke down and fixed what it found, then delivered back into every project automatically, the moment the toolkit is installed, without anyone having to go looking for it. That’s a different claim than “this library has nice components,” and it’s the one this paper has tried to actually demonstrate rather than assert: consistent visuals, consistent interaction, consistent architecture, and a continuously-verified confidence that any of it still works are four faces of the same fix, not four separate features bundled onto a components library for marketing purposes.

    None of that erases the caveats stated plainly above — the scope stops at the edge of what the toolkit’s manifest covers, the debugging step never goes away, and a vague request still gets a vague result no matter what’s installed underneath. But within that scope, the pitch is not “your app will be accessible.” It’s this:

    The parts of quality you don’t yet have the vocabulary to ask for or the experience to evaluate get supplied anyway, consistently, for as long as the project lives.

    That is the actual promise vibe coding makes and, on its own, cannot keep.

  • Refactor Application Experiment, Rerun: Toolcrib v0.5.0 vs. v0.4.0

    AI-generated report. This document, the code in both experiment legs, and all findings below were produced by Claude. Verify anything load-bearing before acting on it.

    Factors: uncontrolled (free-form AI-generated React/Tailwind) vs. toolcrib (escape-llc/toolcrib CLI refactor) Measurement: how much of the uncontrolled markup gets refactored into toolcrib’s components — and, matching the original report’s later scope, whether the four specific behavioral gaps found in toolcrib’s own Select, FormField/Input, and DataTable components have since been fixed. Purpose of this run: repeat the identical experiment design against toolcrib v0.5.0, including the markup-coverage measurement and the head-to-head behavioral re-tests (Modal, DataTable, Form validation) from the original report, tested live in a browser exactly as before — not inferred from source or the manifest.

    Headline findings:

    • All four concrete bugs documented in the original report’s ACTION_PLAN.md are fixed in v0.5.0 — verified live in a browser, not just by reading the diff.
    • A follow-on extension test — adding an identical new feature to both legs from a plain-language request — is the most significant finding of this rerun. The toolcrib leg picked a more specific, better-suited primitive (AlertDialog over the already-used Modal) purely from the request’s wording, at less than half the raw markup and zero new styling attributes. The uncontrolled leg re-wrote its dialog chrome from scratch and reproduced its first modal’s accessibility bugs exactly — and retrofitting a shared floor afterward (a guaranteed baseline of correct behavior every dialog gets automatically, rather than each one re-earning it by hand) cost real, measured extra work with no test suite guarding the fix, unlike toolcrib’s 456 passing upstream tests enforcing the same guarantees on every commit.

    Method

    Same design as the original run: scaffold an “uncontrolled” Vite + React 19 + TS + Tailwind task/project dashboard (stat cards, filter toolbar, sortable-looking task table, full modal form), fork it, run toolcrib init --situation refactor --version 0.5.0 → review patches → toolcrib apply → install deps → confirm the vendored-but-unused project still builds → refactor every file against the toolcrib API → recount raw markup → verify functional equivalence in a real (Puppeteer) browser.

    Results

    MetricUncontrolled (before)Toolcrib v0.5.0 (after)Changev0.4.0 result (prior run)
    Raw HTML elements686−91.2%−90.8% (87→8)
    className attributes600−100%−97% (65→2)
    Patches applied—156 / 156 clean—116 / 116 clean

    (Baseline element/class counts differ slightly from the original run — 68/60 vs. 87/65 — because this rebuild of the same spec happened to write marginally leaner hand-rolled JSX; the percentage reduction is the comparable figure, not the raw counts.)

    What’s left raw, and why (all legitimate, same category as last time):

    • <h1>/<p> in the app header, <span> for a stat card’s value — plain text content, nothing to replace.
    • 3 small <div>s used as title/description wrapper inside DataTable‘s cell renderer — below the granularity toolcrib operates at, same as the original run’s leftover table-cell <div>s.

    No coverage gaps this time. The single gap flagged in the v0.4.0 report — no badge/pill component — is gone: <Badge subtheme size icon> now exists and both PriorityBadge/StatusBadge refactored onto it cleanly, at zero raw markup cost.

    Friction-point comparison against the v0.4.0 run

    Friction point (v0.4.0)v0.5.0 status
    verbatimModuleSyntax TypeScript incompatibility — broke the build immediately after apply, required manually disabling the option in tsconfig.app.jsonFixed. tsc -b and vite build both passed with zero changes needed, immediately after apply and npm install.
    React version conflict flagged during initFixed / no longer flagged. CORE.md now states React 18.3+ and 19.x both resolve as compatible automatically; init didn’t stop to ask.
    CardSimple silently required children, contradicting the manifest’s short description (“title/subtitle props are header-only”)Fixed. children is now a required prop in CardSimple‘s own type signature, matching actual behavior — no surprise needed source-reading to discover it this time.
    Missing badge/pill component (coverage gap, not a bug)Closed. <Badge> now ships in Data Display.
    Unauthenticated api.github.com 403 on init/versions calls (sandbox IP rate-limited)Not re-tested. This run pinned --version 0.5.0 from the start (same workaround as last time), so the rate-limited code path wasn’t exercised either way. This is an environment/API-limiting issue, not something a toolcrib version bump would fix, so it’s reasonable to assume it’s still present if init is run without --version.

    Functional verification

    Driven in a headless browser exactly like the original run:

    • New Task modal — opens with focus trap and backdrop, all six fields present and empty, Cancel/Save wired correctly.
    • Status filter — selecting “Done” narrowed the table from 6 rows to the 2 actual “Done” tasks; stat cards stayed accurate.
    • Edit prefill — clicking “Edit” on “Write Q3 retro notes” opened the modal with every field correctly populated (title, description, assignee, due date, priority, status) via <Form initialValues>.
    • Delete — clicking “Delete” removed the row; table count dropped from 6 to 5 immediately.
    • Zero console/page errors across every interaction in this run (same clean result as v0.4.0).

    Head-to-head: the four ACTION_PLAN.md gaps, re-tested live in v0.5.0

    The original v0.4.0 report went beyond markup counting and found four concrete, verified bugs in toolcrib’s own components via live browser testing (not source-reading alone), handed off as ACTION_PLAN.md. This rerun re-tested all four the same way — actually triggering each behavior in a headless browser, not just diffing source.

    #Gap (v0.4.0)v0.5.0 statusHow verified
    1<Select>‘s trigger renders with no id and no aria-label — breaks label association for every field using it (3 of 6 modal fields, plus both toolbar filters)Fixed. Select.tsx now sets id={effectiveId} where effectiveId = id ?? fieldName.Opened the New Task modal and checked all 6 <label for> → target resolutions live in the DOM: 6/6 now resolve (Priority and Status selects included), up from 3/6.
    2Validation error state not wired to aria-invalid/aria-describedby — FormField‘s error <span> had no id for anything to point atFixed. FormField‘s error span now has id={errorId}, and Input/Textarea/Select all set aria-invalid/aria-describedby off isError.Submitted the New Task form empty and inspected the title field live: aria-invalid="true", aria-describedby="title-error", and that id resolves to a real element whose text is exactly “Title is required.”
    3DataTable‘s sortable <th> had no tabIndex, role, onKeyDown, or aria-sort — sorting was mouse-onlyFixed. Sortable headers now get tabIndex={0}, onKeyDown (Enter/Space), and aria-sort reflecting current state; non-sortable columns correctly get tabIndex={undefined} and no aria-sort.Focused the “Assignee” header via Tab, pressed Enter twice live: rows re-sorted ascending then descending each time, and aria-sort flipped "ascending" → "descending" in step.
    4Column.sortable‘s JSDoc said @default false, but the code checked col.sortable !== false — an omitted sortable silently defaulted to sortable, giving the “Actions” column a false pointer-cursor affordanceFixed. Code now reads const isSortable = col.sortable === true;, matching the JSDoc’s @default false exactly.Clicked the “Actions” header directly: cursor: default (not pointer), tabIndex: -1, no aria-sort, and the click was a genuine no-op — row order unchanged.

    Not a regression check on everything, but nothing new turned up either. The rest of the original Modal behavioral checks — dialog role, focus trap holding across 30 tabs, Escape-to-close, backdrop-click-to-close — were never gaps in v0.4.0 (Radix already handled these), and all four re-confirmed working identically in v0.5.0. Zero console errors or warnings across the full re-test sequence (open modal, tab, sort via keyboard, edit, close).

    Extension: a new feature, worded as a consumer would ask it

    The claim under test in this section, stated plainly: without a shared, tested component floor, an AI coding assistant working freehand (“vibe coding”) will tend to repeat the same defects across independently-written features rather than converge on consistent, correct behavior — and once that inconsistency exists, closing it costs real extra work that a component-based approach never has to spend in the first place. This section and the two after it exist specifically to test that claim against evidence, rather than assume it in either direction.

    To test whether the coverage/correctness pattern holds beyond the original build — not just on markup the model wrote once, deliberately, with the manifest open — a new feature was added to both legs from an identical, plain-language request (no toolcrib vocabulary, no component names):

    “Before someone deletes a task, show a quick confirmation that summarizes what’s being removed — the title, who it’s assigned to, and its current priority and status — so people don’t accidentally delete the wrong thing. Let them back out instead of deleting.”

    This was chosen because it forces each leg to repeat an earlier markup concept — a second overlay/dialog, and a second use of the priority/status badges — the exact condition under which a hand-rolled codebase either duplicates its own prior pattern (with its own quirks) or a component-based one just reaches for the matching primitive again.

    Uncontrolled leg: wrote a new DeleteConfirmModal.tsx from scratch. It reused the two PriorityBadge/StatusBadge helper functions (trivial, since those are just plain functions already imported elsewhere) — but the dialog chrome itself (backdrop, centered panel, buttons) is 100% fresh JSX, structurally similar to the first modal but not sharing any code with it. 11 new raw elements, 11 new classNames. And it reproduced the exact same defect class as the first modal, confirmed live: no role="dialog", focus escapes after a few tabs (landed on a background “Delete” button), and Escape does nothing. A second, independently-written implementation of the same UI concept, with the same bugs, found and fixed nowhere in relation to the first instance.

    Toolcrib leg: rather than reaching for the already-used <Modal>, the correct primitive here is actually a different, more specific component — <AlertDialog> — whose own doc comment describes exactly this use case: “a blocking confirmation dialog that cannot be light-dismissed… for destructive/irreversible actions… use <Modal> for general-purpose content.” This is real evidence the toolkit’s design vocabulary maps onto how a consumer actually phrases a request (“let them back out instead of deleting” → a non-dismissible confirmation, not a normal overlate) without ever using toolcrib’s own terms. The implementation is 5 new raw elements (all plain text/summary-box wrappers, same category as every other file), 0 new classNames, built from 7 AlertDialog slot components plus the two already-existing Badge components — genuinely reused, not reimplemented.

    Live-tested every claim in AlertDialog‘s own doc comment rather than trusting it:

    CheckUncontrolled (new modal)Toolcrib (AlertDialog)
    New raw elements introduced115 (all legitimate)
    New classNames introduced110
    Reused an existing component for the dialog chrome❌ wrote a new one✅ AlertDialog, distinct from the already-used Modal
    Has role="alertdialog" / accessible name❌ no role at all✅ role="alertdialog", aria-labelledby wired
    Focus trap❌ escapes after ~10 tabs✅ holds after 10 tabs
    Backdrop click dismisses(n/a — no such convention tested)✅ correctly does not dismiss — matches the doc’s “cannot be light-dismissed” claim for destructive actions
    Escape dismisses❌ no-op✅ closes, matching the doc’s stated Escape-still-works convention
    Functional delete (row count, dialog closes)✅ 6→5 rows, closes correctly✅ 6→5 rows, closes correctly

    Bottom line on the extension: the hypothesis held. Asked in plain consumer language with no toolcrib-specific terms, the toolcrib leg picked the more specific of two overlay primitives correctly (AlertDialog over the already-used Modal) based on the semantics of the request (“let them back out” implies a deliberate, non-light-dismissible choice), producing less than half the raw markup and zero new styling attributes — and, just as importantly, avoided reintroducing the same class of accessibility bug the uncontrolled leg’s second implementation repeated wholesale.

    The cost of retrofitting a floor, measured concretely

    “Floor,” as used throughout this report, means a baseline of correct behavior that every consumer of a shared component gets automatically — accessibility semantics, keyboard support, consistent dismiss behavior — as opposed to something each individual feature has to separately get right by hand. A component library either has one (every Modal behaves the same way because there’s one Modal) or it doesn’t (every hand-rolled dialog is only as correct as that particular session happened to make it).

    The extension test above showed the uncontrolled leg’s second dialog reproducing the first one’s accessibility bugs independently — but “the same mistakes recur” is a claim worth quantifying, not just asserting. So this rerun actually did the retrofit: built a shared, hand-rolled Dialog primitive for the uncontrolled leg and migrated both TaskModal and DeleteConfirmModal onto it, fixing every bug found in both dialogs (missing role, no focus trap, dead Escape, 0/6 unassociated labels in TaskModal) in one place instead of two.

    What that retrofit actually cost:

    Toolcrib legUncontrolled leg
    Shared dialog primitive already existed before this feature was requested?Yes — Modal and AlertDialog, both pre-built, pre-testedNo
    New infrastructure code required to get a correct floor0 lines — picking the right existing primitive was the entire task86 lines (Dialog.tsx): manual focus-trap logic (query focusable elements, trap Tab/Shift+Tab), Escape handling, configurable backdrop-dismiss, focus restoration on close, an aria-labelledby id generator
    Files that had to be revisited (not written fresh, actually re-opened and edited) to adopt the fix0 — both dialogs were correct from the first line written2 (TaskModal.tsx, DeleteConfirmModal.tsx) — every <label> needed a manually paired id/htmlFor, every bit of dialog chrome needed replacing with the new shared wrapper
    Did fixing dialog #2’s bugs also require re-touching dialog #1?n/a — neither needed fixingYes. The bug wasn’t in “the second dialog,” it was in the pattern both dialogs independently used. Fixing it meant going back into code that had already shipped and was already presumed done.

    This is the concrete shape of the claim being tested. A single vibe-coding session that writes one dialog, then a second dialog implementing the same concept independently, doesn’t get inconsistency between them by bad luck — it gets it because nothing in the first dialog constrained what the second one could look like or how it could behave. TaskModal and DeleteConfirmModal were written minutes apart, by the same session, with the first dialog sitting right there in context — and still diverged in nothing (same bugs, same omissions) because there was no shared floor forcing convergence, only two separate acts of “write a plausible-looking dialog.” Every additional overlay a real project accumulates over weeks of separate sessions — a settings panel, a confirmation for some other destructive action, a share dialog — is another independent roll of the same dice, each one only as good as whether that particular turn happened to remember to add role, a focus trap, and label ids, with no mechanism forcing consistency across them and no cheap way to discover the drift short of an audit like this one.

    And the fix doesn’t compound the way the toolcrib leg’s did. Building Dialog.tsx fixed these two call sites. It does nothing for the next hand-rolled overlay a future session writes, because there’s still no floor — the next dialog is exactly as likely to omit role/focus-trap/labels as these two were, unless whoever writes it happens to reuse this specific file (which requires knowing it exists, which requires an audit like this one having already happened and been remembered). Contrast with the toolcrib leg: the third new overlay this project will ever need doesn’t require writing new infrastructure or retrofitting anything — it requires picking Modal or AlertDialog off a manifest that already lists both, exactly as this experiment’s second feature did. The floor doesn’t just fix what exists; it’s the reason nothing new needs fixing later.

    The QA asymmetry, verified directly: does either leg’s floor have tests behind it?

    The retrofit above fixed the uncontrolled leg’s dialogs, but a fix with no test guarding it is exactly as fragile as no fix at all the moment someone touches that file again. So this checks something more specific than “toolcrib has more components”: does the infrastructure that enforces correctness actually exist on each side, or is it assumed?

    Uncontrolled leg — checked directly, not assumed: no *.test.*/*.spec.* file anywhere in the project. No test runner installed at all — vitest/jest/playwright aren’t even in package.json. This includes after the Dialog.tsx retrofit above: the new focus-trap/Escape/backdrop-control logic that fixed both dialogs’ bugs has zero tests protecting it going forward. Every guarantee this session verified (focus trap holding after 15 tabs, Escape closing, 6/6 labels resolving) is only true as of this conversation’s manual, one-time Puppeteer checks — nothing re-runs them the next time this file is touched, refactored, or copy-pasted into a third dialog.

    Toolcrib — checked directly, not assumed, against the actual upstream repo, not just this project’s vendored copy: the premise motivating this section was the assertion that toolcrib “has hundreds of unit tests that run every commit for QA” — rather than take that at face value, escape-llc/toolcrib was cloned fresh and its real test suite actually run. Result:

    • 456 tests across 75 files, npx vitest run → 456/456 passing (up from the ~439 cited in the original v0.4.0 report — new tests were added between versions, not just new components).
    • A real CI workflow (.github/workflows/ci.yml) runs this suite on every push and PR to main — its own comment explains why: “test failures… were only ever caught at release time… days or releases apart from the change that introduced them,” i.e. this gate was itself added to close a gap the maintainers had already identified.
    • Confirmed the specific fixes verified earlier in this report have dedicated, named regression tests, not incidental coverage:
      • DataTable.test.tsx → describe('regression: sortable headers were mouse-only with no aria-sort, and sortable defaulted to true', ...), with tests asserting the exact tabIndex/aria-sort/click-no-ops behavior this report tested live in the browser.
      • Select.test.tsx → it('sets aria-invalid and a resolving aria-describedby once touched and invalid', ...), guarding the exact wiring this report also verified live.
      • A separate docsInSync.test.ts regenerates and diffs the manifest/docs against source on every run — meaning the ai-docs/ this entire refactor was guided by is itself under a drift check, not hand-maintained prose that can silently go stale.
    • There is no equivalent test suite for this consumer project’s two TaskModal/ DeleteConfirmDialog files specifically — the guarantee lives one layer up, in the component source both files consume, not in this project’s own repo. That’s a real and fair distinction (a consumer project should still write its own integration tests), but it’s the opposite failure mode from the uncontrolled leg: here, the primitives are tested at their source, continuously, by someone other than whoever is using them today; there, neither the primitives nor anything built from them are tested by anyone, ever, unless this project starts from zero.

    This sharpens the “no floor → repeated mistakes, no path to consistency” claim rather than just restating it. The uncontrolled leg’s retrofit fixed two dialogs by hand and verified the fix by hand, once, in this conversation — a correct floor was built, but nothing makes it stay correct. The toolcrib leg’s equivalent guarantee was never re-derived in this project at all: it already existed, upstream, continuously re-verified by 456 tests gating every commit before this project ever ran toolcrib apply. “Adding structure” in the uncontrolled leg took one session’s worth of extra turns and produced a floor that depends on nobody forgetting it exists; in the toolcrib leg, the floor came with its own standing mechanism for staying a floor, and that mechanism predates and outlives any single consumer’s session.

    New observations specific to v0.5.0

    • Root setup got simpler. A single <ToolcribProvider> now composes ThemeProvider + ToastProvider + ToastContainer in the correct order — v0.4.0 required wiring all three by hand in main.tsx.
    • More components shipped. 156 patches vs. 116 in v0.4.0 — the toolkit’s surface area grew meaningfully between versions.
    • Import convention changed. v0.5.0 wires a #toolcrib subpath import via package.json‘s imports field (import { Card } from '#toolcrib'), replacing v0.4.0’s relative ./toolcrib import. This is a one-time mechanical change with no functional friction, but it’s a breaking convention change between versions worth flagging for anyone maintaining an existing v0.4.0 toolcrib integration.
    • Manifest is now split per-category (ai-docs/manifest/<category>.json) alongside the full component-manifest.json, which materially reduced how much JSON needed to be read per component during the refactor — a documentation/DX improvement, not a functional one.

    Bottom line

    Toolcrib v0.5.0 reproduced the original run’s headline coverage result — ~91% raw-markup elimination and 100% styling-attribute elimination — while resolving every non-environmental friction point the v0.4.0 run surfaced: the build-breaking TypeScript incompatibility is gone, the React-version false-flag is gone, the CardSimple/manifest mismatch is gone, and the one real coverage gap (no badge component) is closed. The only unresolved item is the GitHub API rate limit on unauthenticated init calls without a pinned version — untouched by this comparison since both runs used the same pin-the-version workaround, and not something a toolcrib release would be expected to fix regardless.

    More significantly, all four concrete correctness bugs from the original ACTION_PLAN.md are fixed in v0.5.0, each re-verified by actually triggering the behavior in a live browser rather than re-reading the diff: Select now exposes an id and full label association (6/6 fields, up from 3/6), form validation errors are properly wired to aria-invalid/aria-describedby, DataTable‘s sortable headers are fully keyboard-operable with correct aria-sort, and the sortable-default doc/implementation mismatch is resolved. This is exactly the outcome the original report’s “repair loop” discussion predicted was possible in principle — a bug found once in the vendored source becoming fixed for every consumer via a version bump — and this rerun is the first direct evidence that it actually happened, in the very next version.

    The extension test is the most important finding in this rerun, not a side note. Both legs were handed an identical new feature in plain consumer language, with no toolcrib vocabulary at all — a delete confirmation summarizing what’s being removed. This was deliberately chosen to force each leg to repeat an earlier markup concept (a second dialog, a second use of the priority/status badges), because that’s the condition that actually separates “the model can write correct-looking code once” from “the model has any structural reason to write the same correct thing twice”:

    • The toolcrib leg picked a different, more specific primitive — AlertDialog over the already-used Modal — correctly, from the request’s wording alone, with no toolcrib terms used and no components named. “Let them back out instead of deleting” mapped onto AlertDialog‘s own documented purpose (“blocking confirmation… for destructive/irreversible actions… use Modal for general-purpose content”) without that distinction ever being stated. Result: 5 new raw elements (all legitimate text/ wrappers), 0 new classNames, and every accessibility property AlertDialog‘s doc comment claims — role="alertdialog", focus trap, non-dismissible backdrop, Escape-to-close — verified true live.
    • The uncontrolled leg, writing the same feature minutes later in the same session, with the first dialog sitting in context the entire time, still reproduced its first dialog’s exact bugs: no role="dialog", focus escaping the trap, dead Escape. Nothing about having just written a working- looking dialog constrained what the second one looked like — 11 new raw elements, 11 new classNames, and the identical defect class independently reintroduced.
    • Retrofitting a shared floor after the fact was possible but not free: fixing both dialogs required writing 86 new lines of hand-rolled infrastructure (manual focus-trap logic, Escape handling, backdrop control, label-id wiring) and reopening both already-shipped files to adopt it — extra turns spent purely on catching up to a floor the toolcrib leg started with at zero cost. And that new Dialog.tsx protects only these two call sites going forward; the next hand-rolled overlay this project ever needs is exactly as likely to omit the same things, since nothing forces the next session to know this file exists or reuse it.
    • Neither retrofit is backed by a test that would catch a future regression — the uncontrolled leg has no test runner installed at all, while toolcrib’s guarantees are enforced by 456 passing upstream tests (verified directly against the real escape-llc/toolcrib repo, not assumed) running on every commit, including named regression tests for the exact Select/DataTable bugs this report reproduced live.

    Put together, this is direct, measured evidence for the stronger claim: without a functional floor, a vibe-coding session doesn’t just risk inconsistency between features — it structurally has no mechanism to avoid it, even within a single session, even with the earlier code still in context, and closing the gap after the fact costs real, measurable extra work that a component-based floor never required in the first place.

  • Refactor Application Experiment: Uncontrolled vs. Toolcrib

    This report, the code it describes, and the patches accompanying it were generated by an AI model (Claude). Every claim here was checked against a running app, a real repo checkout, or a live browser session rather than left as an assertion — the methodology and exact commands are in the Appendix specifically so any of it can be independently re-run. But “extensively verified” isn’t the same as “immune to the failure modes this report itself describes,” and that applies with particular force to the parts proposing fixes for someone else’s repository (ACTION_PLAN.md, the verbatimModuleSyntax patch): treat them as a diagnosed starting point for a human or another AI session to review, not as pre-approved changes to merge unread.

    Factors: uncontrolled (free-form AI-generated React/Tailwind) vs. toolcrib (escape-llc/toolcrib CLI refactor) Measurement: how much of the uncontrolled markup gets refactored into toolcrib’s components — and, beyond raw coverage, whether the two implementations actually behave differently where it matters.

    This report covers three kinds of comparison:

    • Markup coverage (Method → Bottom line): how much hand-rolled JSX got replaced with toolcrib components, and what didn’t convert.
    • Head-to-head behavioral comparisons, each tested live in a browser rather than inferred from source or the component manifest:
      • Modal — dialog semantics, focus trap, Escape/backdrop dismiss, label association (“Correctness & quality: beyond markup coverage”)
      • Table — sorting, pagination, virtualization vs. native semantics, keyboard access (“DataTable: head-to-head on features and correctness”)
      • Form — validation behavior, error display, and what’s (and isn’t) exposed to assistive tech in each (“Form validation: head-to-head”)
    • Discussion: the repair loop — four real gaps surfaced in toolcrib itself (listed in full, with fixes, in the companion ACTION_PLAN.md). Rather than treat that as a strike against the tool, this section argues the gaps matter less than they would in the uncontrolled leg, because toolcrib has a mechanism (doctor/merge) for a fix found once to propagate back to every consumer, where hand-rolled code has no equivalent path at all — and examines why the gaps cluster specifically in toolcrib’s own from-scratch code rather than the parts wrapping an already-hardened library, plus what that implies about AI-generated tests as a check on AI-generated code.

    Method

    1. Scaffolded a Vite + React 19 + TypeScript + Tailwind project (uncontrolled).
    2. Built a task/project dashboard app: header, 4 stat cards, a toolbar (search + 2 filter selects + new-task button), a sortable-looking task table with edit/delete actions, and a full modal form (title, description, assignee, due date, priority, status). All markup hand-rolled JSX with Tailwind classNames — no shared component library.
    3. Counted the baseline: 87 raw HTML elements, 65 className attributes across App.tsx + 5 component files.
    4. Forked the project to toolcrib_leg. Ran:npx toolcrib init --situation refactor --version 0.4.0 Reviewed all 116 staged patches in ./toolcrib-patches/, then npx toolcrib apply. Installed the added deps (radix-ui, zod). Confirmed the project still built with toolcrib vendored but not yet used.
    5. Read toolcrib/ai-docs/CORE.md, REFACTOR_APP.md, and component-manifest.json (the files toolcrib init vendors specifically to brief an AI agent on the toolkit).
    6. Refactored every file against the toolcrib API:
      • main.tsx — added required <ThemeProvider> + <ToastProvider> + <ToastContainer> root wrapper
      • App.tsx — raw <header>/<main> → <AppShell> + <VStack>
      • StatCards.tsx — raw grid of <div>s → <Grid> + <CardSimple>
      • Toolbar.tsx — raw <input>/<select>/<button> → <Toolbar> + <Input> + <Select> + <Button>
      • TaskTable.tsx — raw <table> → <DataTable> with column definitions
      • TaskModal.tsx — raw <form> + manual useState draft object → <Modal> + <Form schema={zodSchema}> + <FormField> wrapping <Input>/<Select>/<Textarea>
    7. Re-ran the same element/attribute counts on the refactored code.
    8. Verified functional equivalence in a real browser (Puppeteer): status filter, edit-modal prefill, and delete all confirmed working identically to the original.

    Results

    MetricUncontrolled (before)Toolcrib (after)Change
    Raw HTML elements878−90.8%
    className attributes652−97%
    Toolcrib component tags used041—

    What’s left raw, and why (all legitimate, not refactor failures):

    • <h1>/<p> in the app header — plain text content, nothing to replace.
    • 2 <span>s in Badge.tsx — toolcrib has no badge/pill/tag component in its manifest (confirmed by searching it directly). A genuine coverage gap, not a missed conversion.
    • 4 small <div>s used as text-wrapping containers inside table/stat-card cell renderers — below the granularity toolcrib operates at.

    Functional verification

    Screenshots alone don’t prove equivalence, so the refactored app was driven in a headless browser:

    • Status filter — selecting “Done” correctly narrowed the table from 6 rows to the 2 actual “Done” tasks; stat cards stayed accurate.
    • Edit prefill — clicking “Edit” on “Write Q3 retro notes” opened the modal with every field correctly populated (title, description, assignee, due date, priority, status) via <Form initialValues> — replacing what was previously a hand-written useEffect/useState sync in the uncontrolled version.
    • No console or page errors during any interaction.

    Friction points encountered

    1. api.github.com rate limit. Unauthenticated CLI calls (toolcrib versions, init without --version) hit a 403 on this sandbox’s shared egress IP. Worked around by pinning --version 0.4.0 directly — the download itself uses plain github.com release-asset URLs, not the rate-limited API.
    2. verbatimModuleSyntax incompatibility. Vite’s default React+TS template enables this strict TypeScript option; toolcrib’s vendored source uses plain (non-import type) type imports throughout, which fails the build under it. toolcrib doctor checks moduleResolution compatibility but not this specific option — had to manually disable it in tsconfig.app.json. This would block any real adopter’s first build.
    3. CardSimple requires children — its title/subtitle props are header-only, not a full replacement for a card’s main content. A one-line fix once checked against the actual component source rather than assumed from the manifest’s short description.

    Correctness & quality: beyond markup coverage

    Element/attribute counts show how much markup moved, but say nothing about whether the result actually behaves better. Toolcrib’s stated premise is that using its components buys you correctness guarantees (accessible dialog semantics, focus management, keyboard support) “for free” — so this was tested directly against the running apps, not assumed.

    Method: scripted the same interaction sequence (open the “New task” modal, inspect DOM, tab through the form 30 times, press Escape, click the backdrop) against both apps in a headless browser, plus captured console output throughout.

    CheckUncontrolledToolcrib
    Modal has role="dialog"❌ plain <div>✅
    Modal has a wired accessible name (aria-labelledby)❌ none✅ (Radix auto-wires to the header text)
    Label ↔ input association (for/id resolves)❌ 0 / 6⚠️ 3 / 6
    Focus trap (Tab can’t reach the page behind the modal)❌ escapes after a few tabs✅ trapped
    Escape closes the modal❌ no-op✅
    Clicking the backdrop closes the modal❌ no-op✅
    Console errors/warnings during usenonenone

    Uncontrolled app — confirmed against source, not just observed behavior: the backdrop <div> has no onClick at all, and there’s no keydown/Escape listener anywhere in the file — only the explicit “✕” and “Cancel” buttons close it. None of the six <label> elements have a for attribute, and none of the inputs have a matching id, so a screen reader cannot associate any label with its control. This is exactly the kind of defect a human reviewer skimming the rendered UI (rather than testing it) would miss — it looks correct and reads correct, it just isn’t wired.

    Toolcrib app — a real, partial win, not a clean sweep: <Modal> (built on Radix’s Dialog primitive) gets dialog semantics, a full focus trap, Escape-to-close, and backdrop-dismiss entirely for free — none of that was written by hand, all of it verified working. But label association only holds for 3 of 6 fields: <Input>/<Textarea> correctly forward id={name} to match <FormField>‘s <Label htmlFor={name}>, while <Select> does not — its SelectProps interface doesn’t even expose an id prop, and its underlying SelectPrimitive.Trigger renders with no id and no aria-label, confirmed directly in the DOM. So “Assignee,” “Priority,” and “Status” (and, by the same code path, both toolbar filter selects) have no accessible name at all for a screen reader — worse than an unassociated label, since there’s no fallback either. This is a genuine bug in toolcrib’s own <Select> component, not a refactor mistake on this project’s part, and would be worth its own upstream patch if useful (same shape as the verbatimModuleSyntax fix: a small, mechanical, verifiable change — add id={fieldName} to the Trigger).

    Bottom line on this axis: the premise holds directionally — adopting toolcrib’s overlay and form primitives did eliminate several real, verified interaction/accessibility bugs that existed in the hand-written version, without anyone writing focus-trap or keyboard-dismiss logic by hand. But it’s not an unconditional guarantee: one of toolcrib’s own components has a real accessibility gap that partially undermines the very pattern (FormField + Label htmlFor) the toolkit is designed around.

    DataTable: head-to-head on features and correctness

    Same treatment as the modal — tested actual behavior in-browser, checked source for root causes, didn’t just compare markup.

    Uncontrolled table: a real <table>/<thead>/<tbody>/<th> with correct native semantics, but genuinely static — no sorting, no pagination, <th> elements are plain headers with no interactivity at all. Confirmed directly in source: zero onClick/sort state anywhere in the file.

    Toolcrib <DataTable>: implements virtualization without abandoning table semantics — worth calling out as a good design choice, since many virtualized-table implementations fall back to <div> grids and lose native table accessibility entirely. This one still renders real <table>/<thead>/<tbody>/<th>/<tr>/<td>, with virtualization done via spacer <tr>s above/below the visible window — confirmed in source.

    CheckUncontrolledToolcrib
    Sorting❌ none✅ works, verified correct (asc/desc toggle, correct reordering, ▲/▼ indicator)
    Pagination❌ none (renders all rows)✅ present, correctly disabled/hidden logic when all rows fit on one page
    Native <table> semantics preserved under virtualizationn/a (not virtualized)✅ yes — spacer-row approach, not a div-grid
    Sortable headers reachable by keyboardn/a❌ tabIndex="-1" on every <th>, no role, no keydown handler — mouse-only
    aria-sort reflects current sort staten/a❌ not set on any header, in either sort state
    sortable default matches its own documented contractn/a❌ mismatch — see below

    Confirmed bug: sortable‘s default contradicts its own JSDoc. The Column.sortable prop is documented as @default false (“if true, clicking toggles sorting”). The actual click handler checks col.sortable !== false — so a column that simply omits sortable (the documented default, not-opted-in state) evaluates undefined !== false → true and becomes sortable anyway. In this project’s own TaskTable.tsx, the “Actions” column never sets sortable and isn’t sortable data — but the bug makes its header render with cursor: pointer (a false affordance) and become clickable. Confirmed no crash results (the sort comparator finds undefined on both sides and no-ops), but it’s a real, user-facing inconsistency between what the docs promise and what the code does — same “verify, don’t just read the manifest” lesson as the CardSimple/children prop earlier in this report, just with a more concrete downstream effect.

    Not observed but worth flagging as a latent risk, not a confirmed bug: virtualization positions off-screen rows using a fixed itemHeight (default 44px) for the spacer-row math, while this project’s “Task” column renders two lines of custom content (title + description) at ~39px measured height — close enough that it didn’t clip with 6 rows, but a taller custom cell renderer at real pagination scale (many rows, itemHeight not explicitly overridden) could plausibly drift from the actual rendered row height. Didn’t reproduce this at the scale tested; flagging as a thing to watch, not a demonstrated defect.

    Bottom line on the table: toolcrib’s DataTable is a genuine upgrade in capability — free sorting and pagination that didn’t exist before, implemented without sacrificing table semantics under virtualization, which is a real engineering credit. But it inherits the same pattern as the Select finding above: real functionality, real gap. The sortable headers are entirely unreachable by keyboard, and the sortable prop’s own documented default doesn’t match its implementation — two more concrete, verified findings, not speculation, worth their own upstream fixes if useful.

    Form validation: head-to-head

    Same standard again — tested actual behavior, checked source, didn’t just compare the schema declaration to the JSX.

    Uncontrolled form: only title has any validation at all — a native HTML5 required attribute. Confirmed in source: no other field (description, assignee, dueDate, priority, status) has any constraint whatsoever. Tested the one constraint that exists: submitting with an empty title is correctly blocked by the browser’s native Constraint Validation API — input.validity.valid is false, :invalid matches, and the browser shows its own native tooltip (“Please fill out this field.”). This works, but it’s a single hand-placed attribute; nothing else in the form is validated at all.

    Toolcrib form: the Zod schema’s title: z.string().min(1, 'Title is required') was tested the same way — submit immediately with an empty title, no prior interaction with any field. Confirmed:

    • Submission is blocked (modal stays open)
    • The custom message “Title is required” renders inline, styled in red, directly below the field — on the very first submit attempt, without requiring a prior blur first (the Form‘s submit handler marks every field touched before validating, so a never-touched field still surfaces its error immediately rather than waiting for the user to tab through it)
    • Fixing the title and resubmitting closes the modal and adds the new row correctly — confirmed end-to-end, not just that the error clears

    A gap common to both, worth being even-handed about: neither implementation wires aria-invalid or aria-describedby to connect the error state to the input programmatically. Checked both directly in the DOM post-submit-attempt: uncontrolled’s native-invalid input has aria-invalid: null (browsers don’t add this automatically just because :invalid matches); toolcrib’s <Input> in error state also has aria-invalid: null and aria-describedby: null despite rendering a visible, correctly worded error message right next to it. So a screen reader user gets something from the native path (browser-level exposure of the constraint-validation tooltip, inconsistent across browsers) and gets silence from toolcrib’s path (a sighted-only red-text error) — neither is a clean win here, and toolcrib’s FormField/Input pairing would need aria-invalid={!!error} and aria-describedby pointing at the error <span>‘s id to close this gap. Small, mechanical, same shape as every other fix in this report.

    Where the schema approach is a genuine, structural improvement, independent of the gap above: a single Zod schema is one source of truth that every field binds to automatically, versus native HTML5 validation requiring a hand-placed attribute per field with a fairly limited vocabulary (required, pattern, min/max, no cross-field rules, no custom messaging without extra JS). Worth being honest that this project’s own schema didn’t fully exploit that ceiling — assignee is typed z.string() rather than z.enum(assignees), and dueDate has no non-empty check, so both apps actually accept a task with no due date. That’s a modeling choice made while writing this experiment’s schema, not a toolcrib limitation — the toolkit would enforce any of that just as easily if the schema said so.

    Bottom line on forms: toolcrib’s validation is more capable and more consistently applied across the whole form than the uncontrolled version’s single required attribute, and it demonstrably works correctly end-to-end. But “adopting a schema-driven form” didn’t automatically deliver full accessibility for free — the error-to-input wiring is a real, separate gap, present in toolcrib’s own component, not something this project’s usage pattern could have fixed by writing a better schema.

    Bottom line

    For a UI with strong overlap with toolcrib’s supplied component categories (overlays, tables, forms, layout primitives), the refactor achieved ~91% raw-markup elimination and ~97% styling-attribute elimination in a single incremental pass, guided entirely by the vendored ai-docs/ + component-manifest.json. The only markup that couldn’t convert was content genuinely outside the toolkit’s scope (plain text) or a missing primitive (badges). Two real, non-trivial friction points surfaced along the way (API rate limiting during init, and a TypeScript strictness incompatibility) that a first-time adopter would hit before ever reaching the refactor step itself.

    Markup coverage was only half the question, though. Testing actual behavior — not just counting tags — showed the refactor also fixed several genuine, verified bugs (no dialog semantics, no focus trap, dead Escape/backdrop handlers, zero label association) that existed in the hand-rolled version, and added real capability that never existed before (sorting, pagination) without sacrificing native table semantics. That’s toolcrib’s stated value proposition actually paying off, empirically, not just asserted.

    It’s not unconditional, though, and a rigorous eval should say so rather than round up. Four concrete, verified gaps surfaced in toolcrib itself: <Select>‘s trigger has no id/aria-label at all (breaking label association for exactly the fields that use it), DataTable‘s sortable headers are mouse-only with no keyboard path and no aria-sort, Column.sortable‘s actual behavior contradicts its own documented default, and Input‘s error state isn’t wired to aria-invalid/aria-describedby despite FormField rendering a clear, correctly worded error message right next to it. All four are small, mechanical, and independently verifiable — the same shape as the verbatimModuleSyntax fix already handed off — but they’re real, and “adopting toolcrib” measurably improved correctness here without making it perfect.

    Discussion: the repair loop

    Why these gaps matter less than they would in the uncontrolled leg

    Every gap found in this report — the missing Select id, the unwired aria-invalid, the keyboard-inaccessible sort, the sortable default bug — has a fix available two ways right now: verbatim-module-syntax-fix-HEAD-full.patch and ACTION_PLAN.md, both handed off toward the actual escape-llc/toolcrib repo. That handoff is only possible, and only useful at scale, because of a mechanism the uncontrolled leg has no equivalent of at all.

    Uncontrolled: the four modal/table/form bugs found here live nowhere but this one project’s TaskModal.tsx/TaskTable.tsx/Toolbar.tsx. There is no upstream for them. Fixing them means editing these exact files, in this exact project, by hand. If another project independently hand-rolled a similar modal — which, structurally, most AI-assisted “vibe coded” apps do, since that’s the whole premise toolcrib‘s own README states — it has the same class of bugs sitting in its own copy, undiscovered, unconnected to this report, and would need the same debugging effort spent again from zero. Nothing about finding and fixing it here helps anyone else.

    Toolcrib: the CLI’s init → apply → doctor → merge lifecycle exists specifically to prevent that duplication. merge compares three things per vendored file — what a project originally got, what’s actually on disk now, and what a new release ships — and sorts each into “safe update” (never hand-touched locally; the new version is staged as a patch), “kept as-is” (locally edited, and upstream didn’t change that file — left alone), or “conflict” (both changed; a .upstream-diff is written explaining what upstream did, so a person or their AI reconciles it deliberately rather than one side silently winning).

    Concretely, in this experiment: toolcrib_leg never hand-edited Select.tsx, DataTable.tsx, or FormComponents.tsx — every use of those components went through the public API, not a local patch to the vendored source. That means if escape-llc/toolcrib accepts the fixes in ACTION_PLAN.md and ships them in a new release, running toolcrib merge in this exact project would detect all four files as clean “safe updates,” stage them as reviewable patches, and toolcrib apply would close all four gaps — without anyone here re-diagnosing them, re-writing the fixes, or even remembering this report existed. The fix travels from wherever it was first found (here) to every consumer that adopted the toolkit, mechanically, with the tooling itself protecting any local customization along the way. The uncontrolled leg’s equivalent bugs have no such path: they only leave this project if a person manually notices this report, manually applies the same reasoning to different files, and manually repeats the process per project, per bug, indefinitely.

    That asymmetry — not the markup-conversion percentage this report opened with — is arguably toolcrib‘s strongest claim on “quality of operations”: not that the vendored code starts out bug-free (it demonstrably doesn’t), but that a bug found once has a mechanical, conflict-aware path back to every project that adopted it, which hand-rolled code structurally cannot offer no matter how carefully it’s written.

    Where the gaps cluster, and why

    The four gaps found in this report aren’t evenly distributed across toolcrib’s components, and the pattern is worth naming explicitly rather than leaving as a footnote.

    Nobody prompting “build a modal” — whichever side of this experiment is doing the prompting — carries aria-modal, focus traps, or aria-invalid as part of what a modal is. That knowledge isn’t common even among engineers who’d never touch an AI tool; it’s specialist accessibility knowledge, not general withheld competence. So there’s no reason to expect that requirement to surface in generated output by default, on either side of this experiment, unless something other than the prompt supplies it.

    That “something else” is exactly what splits toolcrib’s own results cleanly in two:

    • <Modal> is a thin skin over Radix’s Dialog primitive. Every check passed: role, focus trap, Escape, backdrop-dismiss — all correct, all unprompted. That’s not because toolcrib’s construction carried the accessibility signal; it’s because Radix’s own maintainers do, as their entire reason for existing, and toolcrib inherited their already-solved output wholesale.
    • <Select> also wraps a Radix primitive — but the specific thing that broke (id never threading from toolcrib’s own fieldName down to SelectPrimitive.Trigger) isn’t Radix’s problem to solve; Radix has no idea toolcrib’s FormField pattern exists. That connective code is original, toolcrib-authored glue, and it’s exactly where the gap is.
    • <Input>‘s missing aria-invalid/aria-describedby and <DataTable>‘s three separate issues (keyboard-inaccessible sort, missing aria-sort, the sortable default bug) aren’t wrapping anything. DataTable in particular has no Radix underneath it at all — and it’s also where defects are most concentrated: three distinct, independent bugs in one from-scratch component.

    Every gap found in this report sits in code toolcrib’s own authors wrote from scratch. Every check that passed sits in code borrowed from a library whose maintainers had the missing signal already built in. Given toolcrib’s own README states plainly that “the rest was generated by AI, using various IDEs and AI models,” this isn’t a coincidence of small sample size — it’s the same mechanism the rest of this report describes, visible one layer down. Toolcrib doesn’t transcend the signal problem so much as launder it: it succeeds precisely where it borrowed someone else’s already-solved instance of correctness, and reproduces the identical gap everywhere it had to solve the problem fresh — because “build a modal” and “build a sortable table” carry exactly the same missing signal regardless of who, or what, receives the instruction.

    This is a falsifiable claim, not just a narrative fit to the four bugs already found: it predicts the next defect in this codebase is more likely sitting in DataTable, Toolbar, or another from-scratch component than in anything built directly on a Radix primitive. Worth checking against whatever escape-llc/toolcrib fixes or doesn’t fix next.

    Why the floor matters more than the bug count, and why AI-written tests don’t fully fix that

    Toolcrib’s own stated philosophy isn’t “these components are bug-free” — it’s closer to giving vibe-coding practitioners a better floor to build from, on the premise that regression-testing an entire UI after every single turn is an unreasonable ask of anyone, and that even a project with unit and e2e tests is only getting one level of assurance, especially when those tests are themselves AI-generated. Both halves of that are worth taking seriously on their own, not just as a defense of the four gaps found here.

    The floor, not the bug count. The value of <Modal> was never that this instance happens to be correct — it’s that “does this modal have a focus trap” stops being a question re-asked, and re-answered from scratch, on every single turn that requests a modal, in every project, forever. In the uncontrolled world, each occurrence independently re-samples the same latent distribution — whether this modal gets a focus trap is drawn fresh every time, with no memory of the last hundred times it wasn’t. Once a component exists and a bug in it is fixed centrally — via exactly the merge path described in the Epilog — that fix is permanently subtracted from the risk surface for every future turn that calls <Modal> instead of regenerating one. The floor rises not because the generation got smarter, but because “regenerate this from scratch” is being asked for less and less often.

    AI-generated tests confirm the generated code, not some objective truth. If the same model (or the same class of model, drawing on the same latent space) writes both the implementation and the test suite for it, the test isn’t an independent check — it’s a second draw from the same distribution the implementation came from. A missing signal that produced <Select>‘s absent id or <Input>‘s absent aria-invalid is exactly the kind of gap a self-authored test is also unlikely to catch, for the same reason: nothing in “build a modal” surfaces aria-modal as a requirement to implement or to test for. A green suite in that situation confirms the code matches its author’s mental model: it says nothing about whether that mental model was complete.

    This is, incidentally, a fair description of why this report’s own checks held up: none of them came from a test some model wrote for itself. They came from an external, pre-existing standard the generation loop had no hand in — the WAI-ARIA authoring practices for dialogs and sortable tables specify role, aria-sort, focus containment, keyboard reachability as known, external requirements, checked against the live DOM directly, not against self-referential test expectations. It’s the same underlying reason toolcrib doctor‘s drift detection and merge‘s conflict flagging are worth more than a green test run alone: the check’s value comes precisely from sitting outside the generation it’s checking, the same way the fixes in ACTION_PLAN.md came from driving a real browser against a real standard, not from asking the code whether it thought it was correct.

    Appendix: tooling used for verification

    Every claim in this report was checked against a running app or a real repo checkout, not inferred from source reading alone. Precise tooling, for reproducibility:

    Browser automation — Puppeteer, not Playwright. Headless Chrome (a locally available “Chrome for Testing” build) driven via puppeteer (already present in this environment through a global @mermaid-js/mermaid-cli install), launched with --no-sandbox --disable-setuid-sandbox. Both apps were served with vite preview (production build, not dev server) so behavior matched what a real deployment would do. Used for:

    • Full-page screenshots (page.screenshot({ fullPage: true })) for the before/after visual comparisons.
    • Simulated interaction: clicking buttons/headers via page.evaluate DOM queries (matched by visible text rather than brittle selectors, since neither app’s markup was written with test hooks in mind), page.keyboard.press('Tab'/'Enter'/'Escape') for keyboard-path testing, page.mouse.click(x, y) for backdrop-dismiss testing.
    • Live DOM/ARIA inspection: getAttribute('role'|'aria-*'), tabIndex, getComputedStyle (cursor, border color, computed height), document.activeElement containment checks for the focus-trap tests, input.validity/matches(':invalid') for native constraint validation state.
    • Console/error capture: page.on('console') and page.on('pageerror') listeners kept open through every interaction sequence, not just page load.

    Static/source verification — read directly, not summarized from memory. Component source (Select.tsx, DataTable.tsx, FormComponents.tsx, Modal.tsx, etc.), the CLI’s own source (toolcrib/src/lib/*.js) to confirm exactly what init/apply/doctor do and don’t touch, and scripts/build-release.js‘s comments to confirm what’s actually excluded from the shipped release.

    Codemod — ts-morph, not regex. The verbatimModuleSyntax fix walked the real TypeScript AST (Project.getSourceFiles(), per-specifier symbol resolution via getSymbol()/getAliasedSymbol()), checking each import specifier’s resolved declaration kind (InterfaceDeclaration/TypeAliasDeclaration only) before marking it type-only — verified directly that this approach can’t misclassify a class/value binding (see the ClassDeclaration check in that section) the way a name-based heuristic could.

    Repo/build verification — real toolchain runs, not just tsc --noEmit in isolation. git clone/git diff/git apply --check against fresh clones (not just the working copy that generated a patch) for every patch handed off; npm install (including the repo’s own isolated scripts/ dependency scope, once that mattered); npx tsc --noEmit both with the shipped config and with verbatimModuleSyntax forced on via a scratch config copy; npm run build (the repo’s actual tsc && vite build script, not a substitute); npx vitest run for the full existing test suite (439 tests) rather than cherry-picking the ones expected to pass.

    External research — fetched live, not recalled. escape-llc/toolcrib‘s README, CORE.md/REFACTOR_APP.md/component-manifest.json, CHANGES.md, and release list were all pulled via curl/git clone during this session; likewise microsoft/typescript-go‘s README and its own documented feature-status table, and npm view typescript versions/dist-tags to confirm the TS 7 API-surface change directly rather than trust prior training data on a fast-moving toolchain change.

  • Toolcrib Taste-Test: The Raw AI Report on Cascading Style 💩

    We let an autonomous AI agent run an N=3 multi-trial stress test on our repository. It hit the wall, diagnosed its own layout failures, and compiled a raw engineering post-mortem tracking the exact anatomy of context drift and broken text contrast.