Category: ai-generated

  • 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.

  • The Toolcrib Taste-Test

    Disclaimer: This report was produced by Claude (an AI), working under a human’s direction. Every claim below was tested against the real toolcrib source, not assumed — but the testing, code, and conclusions are still AI output. Review before citing a number externally.

    Date: 2026-08-14
    Subject: Does toolcrib (an npm-installed, AI-oriented React component library) improve on unguided “vibe coding,” and in what sense? Started as a comparison of visual styling control; the evidence extended it to interaction correctness and architecture as well — see Findings 9–10.
    Scope note: This reflects toolcrib with the five patches from this evaluation applied — a WCAG contrast fix; manifest type-definition resolution (across two files); DataTable.rowSubtheme; and the matching Anti-Patterns doc entry — not the state it shipped in before this test.


    Verdict

    The evidence in this report adds up to something stronger than “toolcrib is nicer to work with”: uncontrolled, unguided output has multiple genuine risk surfaces, not one, and none of them are a taste preference — they fail silently. Four separate results in this evaluation demonstrate that, not just assert it, spanning both styling and interaction correctness:

    • A restyle attempt collapsed three distinct spacing roles (table cells, inputs, buttons) into one identical value — the script ran without error, the page rendered without error, and the task was reportable as complete. Nothing caught it (Finding 3/4).
    • One of three independently-built, ordinary-looking unguided trials shipped text at 2.54:1 contrast — below WCAG AA, genuinely hard to read for a meaningful fraction of real users, and nothing in that build process flagged it (Finding 7).
    • Given the ability to freely pick a UI accent color, 4 out of 4 plausible, ordinary-looking choices (a light yellow, a mint green, a pale pink, a light gray) produced a button with white text at 1.2–1.4:1 contrast — not “hard to read,” effectively invisible. This wasn’t an adversarial edge case; it’s what happens by default the moment color choice is unconstrained (Finding 8).
    • Not a styling defect at all: an unguided build of one of the most common interactive elements in ordinary app work — a delete-confirmation dialog — had zero of the four standard accessible-dialog behaviors: no focus trap, no Escape-to-close, no ARIA dialog semantics, no focus restoration to the triggering element. Confirmed by direct inspection of the generated code, not assumed (Finding 9). This one is arguably harder to catch than the others, since a sighted developer testing with a mouse never trips any of the four.

    None of these four were caught by a compiler, a linter, a test, or the build process itself. Each surfaced only because this evaluation went looking for it from outside the ordinary request-response loop. That’s the actual danger, and it isn’t specific to CSS: unguided output’s failures don’t announce themselves, in styling or in behavior. They ship, and stay shipped, until a human happens to notice — which for accessibility specifically (contrast or keyboard/screen-reader access) can mean a real user locked out of a control entirely, a support ticket, or in jurisdictions with accessibility law (ADA in the US, the EU Accessibility Act, and similar), a genuine compliance exposure. (Not legal advice — a general observation that this category of defect has real-world stakes beyond aesthetics, worth flagging to whoever owns that risk at an organization, not just to whoever owns the CSS.)

    This is not an argument that toolcrib specifically is required. It’s an argument that something needs to make color/spacing/contrast/interaction decisions structural rather than hoped-for — a design-token system with enforced contrast, automated accessibility testing in CI (axe-core, Lighthouse CI), a linting rule that rejects known-bad patterns, headless accessible primitives (Radix, React Aria, or similar) as the default rather than hand-rolled markup, a mandatory human accessibility review — toolcrib is simply the one mechanism this evaluation tested, and it happens to close all four of the failure modes above by construction rather than by someone remembering to check. The comparison that matters isn’t “toolcrib vs. Tailwind.” It’s “some enforced control vs. none” — and this report’s evidence is about what “none” actually produces when nobody’s looking, in more than one subsystem.

    How toolcrib specifically controls the styling risk surface, evidenced across this report: multiple, coordinated levels, not one blunt rule.

    • Compile-time: style/className aren’t valid props on any toolcrib component — a type error, not a lint warning that can be silenced or ignored (Finding 1).
    • Per-instance: components with a registered theme slice expose a typed, sparse overrides prop for one-off adjustments — enumerable values only, never a raw style object.
    • App-wide: ThemeProvider‘s parameters set color/radius/density/mode once, read by every component from one shared source, which is why a global restyle can’t produce the cross-component cascading collision an ad hoc rewrite did (Finding 3).
    • Semantic: a bounded four-category subtheme vocabulary (error/success/warning/info) applies uniformly across component types and, since this evaluation’s patch, individual table rows too (Finding 5).
    • Underneath all of the above: contrast enforcement runs automatically regardless of which level actually produced a given color — the theme parameters, a subtheme, or an end user’s own pick (Finding 7).
    • Beyond the running app: the same guarantees extend past build-time to a designer tuning the shipped default and to an end user personalizing at runtime, through one shared component rather than two separately-engineered systems (Finding 8).
    • Around all of it: a machine-readable manifest describes exactly what’s controllable at each level, so the vocabulary is discoverable by reading documentation rather than requiring a dive into source (Finding 10, and the patches in this report that closed real gaps in it).

    No single level here is airtight on its own — Finding 4 found where semantic-only control (four subthemes) runs out for a non-semantic need, Finding 2 found a harmony-mode gap in the theme level’s own reach for a neutral secondary color. But layered together, they’re the reason ad hoc styling doesn’t have anywhere convenient to hide in this system by default: every path back to arbitrary style either fails to compile, or requires actively bypassing an enumerated, typed, contrast-guaranteed alternative that was sitting right there instead.

    And the fourth bullet above — the dialog with zero accessible behaviors — is controlled the same way, in a separate subsystem. Building Modal/Popup/SlideOut/AlertDialog on Radix’s tested primitives instead of hand-rolled markup, and using a typed event bus (aiBus) for cross-tree state instead of prop-drilling, is architecturally the same move as everything in the styling list above: replace a decision an unguided build has to re-earn correctly every single time with one made once, inherited automatically. 60% of toolcrib’s component set inherits it this way (Finding 9); an unguided build re-risks the same absence — focus trap, Escape handling, ARIA semantics, focus restoration — per component, every time, with nothing in an ordinary sighted-developer testing pass likely to catch it.

    Toolcrib is a conditional improvement, not a universal one. It reliably eliminates the failure mode it targets — ad hoc, inconsistent, hard-to-globally-restyle utility-class soup — by construction: no toolcrib component’s props accept style or className at all, a compile-time guarantee rather than a convention someone can forget. It also enforces a WCAG contrast floor on every color it generates dynamically, something unguided output has no equivalent of.

    That guarantee is narrower than “no ad hoc styling anywhere,” though. It only restricts toolcrib’s own component props — plain JSX inside a slot, a child, or a render callback is exactly as stylable as ever. The real constraint bites only where a component’s own DOM is generated entirely internally and never exposed as a prop or slot at all.

    A frame that applies throughout: nothing here is automatically enforced by the AI maintaining the toolkit. Every fix reflected in this report — the contrast patch, the new row-flagging prop, the doc update — required a human to decide it was worth institutionalizing and direct it. That’s not a limitation of toolcrib specifically; it’s the shape of how any of this stays correct. What toolcrib changes is the leverage of each such decision: once a shared, constrained vocabulary exists (paddingMode, cornerRadiusMode, subtheme, rowSubtheme), a short human imperative (“make this row a warning,” “make corners sharp app-wide”) lands on one precise, testable primitive shared by the whole codebase — instead of requiring a fresh, ad hoc translation into raw styling every time, the exact failure mode demonstrated below.

    The cost this doesn’t show up in Finding 6’s token count: every unguided defect surfaced in this report — a3’s failing contrast (Finding 7), the restyle cascade bug (Finding 3), all four unreadable end-user color picks (Finding 8) — was not caught by the unguided build process itself. Nothing flagged any of them; they only surfaced because this evaluation went looking from outside the normal request-response loop. In an ordinary session, each ships silently until a human happens to notice, and only then does correction start — an indeterminate number of turns, unknowable in advance, with no guarantee a fix doesn’t introduce a new problem. Every toolcrib defect in this report required the same kind of deliberate effort to find, but once found, closed structurally and permanently — a compile-time guarantee, a shared resolver, a manifest generator — inherited by every future project built against the patched library at zero further cost. The asymmetry isn’t “toolcrib has fewer bugs.” It’s that unguided defects are per-project and non-transferable — a fresh session tomorrow re-risks the identical mistake with none of today’s corrections carried forward — while toolcrib’s, once patched, are closed for every future consumer at once.

    Project shapeRecommendation
    Long-lived app, restyled/rethemed repeatedly, multiple contributors (human or AI)Toolcrib wins clearly
    App needs end-user-facing theme customization (presets, live color picking, persistence)Toolcrib wins clearly — see Finding 8
    One-shot prototype, thrown away after a demoRoughly a wash — onboarding/context cost isn’t recouped
    Frequent narrow, non-semantic one-off visual tweaks (outside the existing subtheme/theme-axis vocabulary)Unguided/Tailwind wins on flexibility — see Findings 5–6
    Non-React stackNot applicable

    Methodology

    Prompt (identical for every trial in both conditions):

    “Build a single-page admin settings screen: a left sidebar nav (5 items), a top toolbar with a page title and a save button, and a main content area with three stacked sections — a profile card (avatar, name, email fields), a notification preferences section (4 toggle rows with label + description each), and a billing section (a small table of past invoices). End with a sticky footer bar with Save/Cancel buttons. Make it look like a polished SaaS product, fully responsive.”

    Session A (control):

    npm init -y

    No framework, bundler, or CSS approach specified — the agent was free to choose anything. Three independent trials (a1, a2, a3), each a fresh build from the same prompt with no cross-contamination (later trials weren’t shown earlier trials’ output before being built). All three independently converged on the same unprompted default: a static index.html pulling in Tailwind via the CDN <script> tag — the common “quick vibe-coding” reflex — but with genuine structural variation between them (different layout approaches: flex-column sidebar vs. CSS grid template; different accent colors: indigo, blue, violet; different spacing scales).

    Session B (toolcrib):

    npm init -y
    npx toolcrib init --version 0.1.0 --situation new
    # reviewed the staged patches in ./toolcrib-patches/
    toolcrib apply

    This vendors the component library into ./toolcrib/, wires the #toolcrib import alias into package.json, and stages an AGENTS.md/CLAUDE.md block plus the ai-docs/ folder (CORE.md, NEW_APP.md, component-manifest.json) the toolkit’s own README says an AI assistant should read before building. Those docs were read in full before any component code was written, per their own instructions. Since toolcrib is a React library, this necessarily pulled in React/Vite/TypeScript tooling — a stack difference from Session A’s static HTML that’s a genuine confound (Finding 1’s zero-Tailwind result is partly attributable to a completely different toolchain, not toolcrib alone). Three independent trials (b1, b2, b3), each composed with different component choices (Card vs. CardSimple, Toggle vs. a manual toggle group, different Toolbar slot usage) to avoid just re-running the same file three times.

    Caveat carried through every finding below: one model (Claude) built both arms of every trial, with full knowledge of the hypothesis going in. This is not a blinded experiment — a real version of this test would use separate model instances or separate human developers unaware of the comparison being drawn.


    Finding 1 — Utility-class soup

    TrialTailwind utility-class tokens
    a1386
    a2307
    a3412
    A mean368.3
    b1, b2, b30, 0, 0

    Mann-Whitney U (one-sided, A > B): U = 9.0, p = 0.032 — the smallest p-value obtainable at n=3 vs n=3, so it mostly just confirms zero overlap rather than being independently strong evidence. The stronger claim comes from source, not statistics: no toolcrib component’s TypeScript interface accepts className or style at all, confirmed by tsc --noEmit succeeding on all three B trials with zero style-prop usage. This is a compile-time guarantee, not a per-trial tendency — running more trials wouldn’t add evidence beyond what reading one interface already established with certainty.


    Finding 2 — Structural and visual similarity

    Structural (wireframe) similarity: 100% — sidebar, toolbar, profile card, notification rows, billing table, sticky footer all present in both conditions, reflecting that one agent mapped the same spec faithfully onto both toolkits.

    Visual similarity out of the box: low. Toolcrib defaults to isDarkMode: true, base hue 88° (lime, #89d82f) — nowhere near the light-mode indigo/blue/violet accents unguided sessions converged on independently (hue distance 129°–174° of a possible 180°, inverted background luminance).

    Retheming to match, using only ThemeProvider‘s own parameters (no style props):

    • Accent hue, light/dark mode, padding density: one config object, exact match.
    • Corner radius: cornerRadiusMode: 'rounded' reproduces Tailwind’s own rounded/md/lg/xl scale exactly (0.25/0.375/0.5/0.75rem) — not a coincidence, the scale appears deliberately copied.
    • Background neutrality: close but not exact — every surface color is architecturally tied to the accent’s own hue (s=4 residual tint vs. Tailwind’s fully achromatic s=0), a deliberate tradeoff, not a config gap.
    • The secondary palette role: not trivial. Every harmony mode (monochromatic/analogous/split-complementary/triadic/tetradic) hue-shifts it away from the base color by construction — none has a “keep this neutral” option. A plain gray “Cancel” button — trivial in Tailwind at zero saturation — has no equivalent one-line path here; it requires overriding --ai-color-secondary directly, outside the parameter API.

    Why exact matching has a ceiling regardless of configuration: toolcrib generates its whole palette procedurally from one seed color. Tailwind’s actual palette doesn’t work that way — checking its real published blue-family stops (500→800) shows hue drifting (217°→226°) and saturation moving non-monotonically (76%→86.6%→82.9%) as Value falls steadily, the signature of independently hand-tuned stops per family, not one formula. Matching is only exact where something reduces to an unchanged scalar (a hue number, a copied radius constant) — never for anything toolcrib has to actually derive.

    Methodology note, corrected in hindsight: the retheming above was done by computing HSV values by hand (converting target hex codes, predicting resulting backgrounds via the same formulas the engine uses). That’s not the workflow toolcrib’s own docs describe for this exact task — see Finding 8, which covers ThemeEditor as a live, drag-and-see design tool meant to replace this kind of manual color math entirely. The numbers above still hold; they likely overstate the real effort involved.


    Finding 3 — Uniform restyling

    Task: accent color, corner radius, and density changed app-wide.

    ToolcribUnguided (Tailwind)
    Edit6 lines, 1 file (ThemeProvider params)~20 separate find/replace rules across scattered literal classes
    Structural/decorative judgment neededNo — Avatar/Toggle hardcode their own radius (--ai-avatar-radius, literal 50%), decoupled from the shared corner-radius token by constructionYes — every rounded-full instance needs manual inspection to tell “circle” from “card corner”
    ResultClean, uniform, every instance updatedA real, reproducible bug: sequential text-rewrite rules matched each other’s output — px-2/px-3/px-4 (three distinct roles: table cells, inputs, buttons) all collapsed into a single px-5, silently destroying the original spacing hierarchy

    The mechanism: Tailwind classes are independent literal strings with no shared reference — “restyle uniformly” decomposes into N rewrites that must not collide with each other. Toolcrib’s theme has one value per axis, read by every component from a shared source — nothing for a second rule to collide with.


    Finding 4 — turn count for styling changes

    Finding 3 measured the size of a restyle edit. A different question: how many conversational turns does it take to reach an actually-correct end state, not just a completed-looking one?

    Toolcrib: bounded at one turn. The restyle in Finding 3 was a single edit, and when an actual mistake was made in this session while producing it — an earlier attempt used an invalid literal (paddingMode="loose", not one of the three real values) — tsc --noEmit caught it immediately, in the same turn, before anything was reported as done. The correction happened inside the attempt itself, via a compiler forcing function, not as a separate turn triggered by someone noticing later.

    Unguided: the first turn can complete “successfully” while silently wrong, with no bound on when that’s discovered. The same restyle’s sed-cascade bug (Finding 3) is the direct evidence: the script ran without error, the HTML rendered without error, and the task was reportable as done after one turn — while three distinct spacing roles had actually collapsed into one, undetected by anything automated. No compiler, linter, or test in that stack flags a semantically-wrong-but-syntactically-valid utility class. That defect surfaced in this report only because this evaluation went back and specifically diffed spacing token counts across the before/after files — a deliberate audit step, not something the original restyle turn would have triggered on its own. In an ordinary session, correctness would depend entirely on whether and when a human happens to notice the visual regression: could be the next turn, could be never.

    ToolcribUnguided
    Turns to a reportable result11
    Turns to a verified-correct result1 (compiler gates it before completion)1 + N, N indeterminate (depends on a human noticing)
    What catches a mistaketsc --noEmit, in-turnNothing automated; a later, separate review pass

    This is the same asymmetry as the discovery-cost point in the Verdict, scoped specifically to the styling-change task Finding 3 already measured: toolcrib’s guarantee isn’t just that the edit is smaller, it’s that the edit’s correctness is checked before the turn ends, where the unguided equivalent has no such gate at all.


    Finding 5 — Row-level and other narrow customization

    Toolcrib’s components fall into two categories:

    • Slot/child/render-based (Card, Form, Toolbar, DataTable‘s own cell render): a plain, fully-stylable wrapper element is always one JSX node away. The “no style/className” rule only restricts the toolkit’s own component props here — your own elements inside are unrestricted.
    • Internally-owned DOM never exposed as a prop or slot: here the restriction genuinely bites. DataTable‘s row (<tr>/<td>) is now the one exception, closed via rowSubtheme?: (record, index) => 'error'|'success'|'warning'|'info'|undefined — the row-level equivalent of the subtheme prop already used on Button/Toast/Progress. Flagging a row (e.g. a pending invoice) is one line, tinting the row’s real background/border with the same WCAG-guaranteed colors used everywhere else in the system — not a per-cell approximation. Rows with no match render exactly as before.

    Deliberately not added: a generic rowClassName/rowStyle escape hatch — that would reopen exactly the raw-style door the toolkit’s uniform-restyle guarantee (Finding 3) depends on staying closed. The boundary that remains: a row needing flagging for a non-semantic reason (“recently edited,” “selected”) still has no hook, since only the four existing subthemes are covered. General lesson for any other component with the same shape of internal-DOM restriction: check whether it exposes a slot before assuming a workaround is needed.


    Finding 6 — Token cost

    Toolcrib’s own recommended reading set (CORE.md + NEW_APP.md + component-manifest.json) is ≈83.5K characters (≈21K tokens, chars/4 estimate) of onboarding cost per fresh session — Tailwind needs none.

    Output cost is shape-dependent, not uniformly cheaper or more expensive:

    TaskUnguidedToolcrib
    Global restyle (color/corners/density, app-wide)Full-file regen — no single edit point233 chars, one config block
    Row-level flag (semantic: error/success/warning/info)~35 chars, one class~79 chars, one prop — comparable
    Row-level flag (non-semantic category)~35 chars, one classNo hook exists; would require a new theme slice — a materially bigger task

    Breakeven for the onboarding cost against repeated global restyles: roughly 11 cycles (≈1,880 tokens saved per restyle against the ≈21K upfront cost). Caveats: chars/4 is a rough proxy, not a real tokenizer; prompt caching could push the effective onboarding cost toward zero on repeat sessions against the same project; output tokens typically price higher than input tokens on API pricing, which isn’t reflected in the raw counts above.


    Finding 7 — WCAG contrast enforcement

    Every dynamically-generated palette color — primary/accent, all four subthemes, on-fill button/badge text — is nudged via ensureWCAGContrast()/pickReadableTextColor() to clear a minimum contrast ratio (3.0:1 for large text/UI, 4.5:1 for normal text) against its actual background, regardless of which base hue is chosen. Verified across five tested hues (lime/indigo/teal/blue-600/red), including --ai-text-secondary against all three surface tiers (bgPrimary/bgSurface/bgContainer) — every combination clears 4.5:1 or better.

    For comparison: one of three independent unguided trials shipped text-gray-400 on white at 2.54:1 — failing WCAG AA outright, with nothing in an unguided session catching it. That’s the exact class of defect this machinery is built to prevent, and does, for every color it touches.


    Finding 8 — dynamic theme customization has two consumers, not one

    Every finding above (except the retheme exercise in Finding 2) concerns build-time decisions locked in once. A different question: what does it take to let someone other than the original build-time decision-maker pick or change the theme, with their choice persisting? There are two distinct someones, and toolcrib’s own docs treat them as two separate, intentional use cases for the same infrastructure:

    1. The app’s own designer/developer, at development time. NEW_APP.md‘s own guidance: “Drop <ThemeEditor trigger={...}> somewhere reachable early in development… use it to pick the base colour, harmony mode, and spacing scale interactively. Once you’re happy, read the resulting parameters off useTheme()… Don’t hand-pick colours.” This is a live, visual alternative to exactly the manual HSV computation Finding 2’s retheme exercise did by hand (colorsys.rgb_to_hsv, computing predicted backgrounds via Python) — the documented workflow is drag sliders, watch the app update live, read off the result. Finding 2 didn’t use it, and in hindsight that means the real dev-time cost of matching a target palette is likely lower than the “one config object” already measured there, since the intended path removes the color-math step entirely rather than just shortening it.
    2. An end user of the deployed app, at runtime. This is what the rest of Finding 8 below actually tested.

    Toolcrib: one component serves both roles, pre-built. ThemeEditor (hosted in a SlideOut/Modal/Popup of your choosing — it has no overlay chrome of its own) bundles continuous H/S/V sliders, 6 shipped presets, a “your saved themes” library (named save/load/delete via localStorage), and file export/import, all wired to the same ThemeProvider state validated in Finding 7. Wiring it into the settings screen and adding auto-persistence via the existing theme:changed event-bus channel cost 17 lines / 660 characters across two files — and every color it can produce, whether picked by a designer tuning the shipped default or an end user personalizing later, still goes through ensureWCAGContrast()/pickReadableTextColor().

    Unguided: neither role has an equivalent tool. A developer manually edits hex codes in utility classes and reloads to see the result — no live preview, no interactive picker, nothing beyond what Finding 2’s own manual HSV math demonstrates. An end user has nothing at all unless a bespoke picker is built from scratch, tested next:

    Unguided end-user customization has to be engineered from scratch, and the accessibility guarantee doesn’t come for free. Tailwind’s utility classes are static — runtime end-user color customization means abandoning most literal color classes for CSS-variable-driven arbitrary values (bg-[var(--accent)]), hand-rolling shade derivation (a “hover” and a light tint via plain RGB math), and hand-rolling persistence. Built the equivalent for the same settings screen: 35 lines / 2,729 characters, with a single flat accent color, no presets, no named saved-theme library, no file export — and critically, no contrast checking of any kind.

    That gap isn’t hypothetical. Tested 4 plausible end-user picks (a light yellow, mint green, pale pink, light gray) against the unguided version’s text-white Save button:

    End-user pickWhite-text contrast ratioResult
    Light yellow #fde0471.32:1Unreadable
    Mint green #86efac1.40:1Unreadable
    Pale pink #fbcfe81.38:1Unreadable
    Light gray #e5e7eb1.24:1Unreadable

    All four fail outright — not edge cases, the default outcome for any light pick with the natural unguided implementation. Toolcrib’s pickReadableTextColor() provably reaches ≥4.58:1 against any background (it’s a closed-form choice between pure black/white, not a per-color check that can be forgotten) — the same guarantee applies automatically to a color the end user picked, not just ones a developer chose and could manually spot-check.


    Finding 9 — components are load-bearing architecture, not styling convenience

    Every finding above treats toolcrib’s components as, in effect, styled <div>s — measuring how easy they are to color, restyle, and keep accessible. But components like Modal, AlertDialog, SlideOut, TabStrip, and Splitter also carry real behavioral infrastructure that has nothing to do with color: focus management, portal rendering, cross-tree state coordination, and layout-aware geometry. Tested directly with a concrete feature, not just described.

    Task: add a Delete button to each row of the billing table. Clicking it opens a confirmation dialog — not nested in the row itself, since the table may be virtualized or paginated — that must correctly trap focus while open, dismiss on Escape, and return focus to the row’s Delete button on close.

    Toolcrib: the row’s button calls aiBus.openAlertDialog('confirm-delete-invoice', { invoice: record.invoice }) — one line, no callback prop threaded through DataTable at all. A single <AlertDialog> mounted once at the app root listens for that id and renders the confirmation. Total: 31 lines / 1,231 characters, and every one of the following came from AlertDialog wrapping Radix UI’s AlertDialogPrimitive correctly, not from anything hand-rolled in this feature:

    • Full focus trap while open (verified: Radix’s own accessible-dialog behavior, not toolcrib-original logic — toolcrib’s actual contribution here is guaranteeing this primitive gets used via its own anti-pattern guidance, “don’t hand-roll a popup/modal/drawer,” rather than reimplementing focus-trapping itself).
    • Escape-to-close, matching native confirm() convention (confirmed against Radix’s own source per the component’s code comments — outside-click is deliberately disabled instead, appropriate for a destructive-action dialog, not an oversight).
    • Correct ARIA dialog semantics and focus restoration to the triggering element on close.

    Unguided: built the same feature — a delete button per row, a confirmation dialog, cancel/confirm — with no special accessibility instruction given, matching how an ordinary request would actually be phrased. 32 lines / 2,019 characters (comparable cost, prop-drilling avoided here only because a flat HTML file’s global function scope happens to sidestep it — a real componentized React app without an equivalent to aiBus would need actual prop-drilling or lifted state, making this an underestimate of the real unguided cost). Checked the result directly for the four standard behaviors above, not assumed:

    BehaviorFound in unguided implementation?
    Any .focus() call (focus trap)0 occurrences
    Any keydown/keyup/Escape handling0 occurrences
    role="dialog", aria-modal, aria-labelledby, or aria-describedby0 occurrences
    Any tracking of the triggering element (focus restoration)0 occurrences

    Not “missing some” — missing all four, confirmed by direct inspection of the code produced, the same style of check as Finding 1’s className/style grep. A keyboard user could still tab through the page behind this “modal,” nothing closes it via Escape, a screen reader has no indication a dialog opened at all, and focus is simply lost on close rather than returned to the trigger. This is the same class of silent, uninstructed omission demonstrated in Findings 7 and 8, in a different subsystem: not a color contrast failure, a keyboard-accessibility and screen-reader failure, equally undetectable by anything in the unguided build process itself.

    This isn’t one component’s incidental correctness — it’s a systematic architectural choice, verified across the whole library. Checked which of toolcrib’s 30 components sit directly on a named Radix primitive versus which are hand-rolled markup:

    CountExamples
    Built on a Radix primitive18 of 30 (60%)Modal(Dialog), AlertDialog, Popup(Popover), SlideOut(Portal), TabStrip(Tabs), DropdownMenu, ContextMenu, Select, RadioGroup, Slider, Accordion, Collapsible, Toast, Tooltip, Avatar, Separator, ToggleGroup, Checkbox/Switch
    Hand-rolled, no Radix foundation12 of 30 (40%)DataTable, Splitter, Card/CardSimple, AppShell, Toolbar, UIGroup, layout primitives (Grid/Stack/Content), ThemeEditor, AIErrorBoundary

    This correlates exactly with where this report already found gaps, not by coincidence: DataTable — the one component whose internal row markup had no escape hatch at all until Finding 5’s patch — is in the hand-rolled column. TabStrip, by contrast, sits on Radix’s Tabs primitive and inherits its roving-tabindex keyboard model and ARIA tab semantics for free. This also sharpens a speculative note in Threats to Validity below: Splitter (also hand-rolled, no Radix) is the more plausible candidate for a similar internal-DOM gap to DataTable‘s; TabStrip (Radix-backed) is less so, on the same logic that predicted DataTable‘s gap in the first place. For 60% of the library, accessible focus management, keyboard navigation, and ARIA semantics aren’t something toolcrib re-implements per component and could get subtly wrong each time — they’re inherited once, from one well-tested foundation, everywhere that foundation is used.

    The generalizable point: a component library earns “load-bearing” status when removing it would require reimplementing correctness-critical behavior, not just re-picking colors. aiBus (cross-tree dispatch without prop-drilling or context restructuring), the layout-domain system (LayoutDomainContext/useLayoutDomain, which lets a <Card> nested inside a <Splitter.Panel> automatically square its corner adjacent to the resize handle — geometry-aware behavior no styling system provides), and the systematic Radix foundation underneath 60% of the component set are all in this category. None of them are visible in a screenshot comparison; all of them are visible the moment a feature requires cross-component coordination or correct keyboard/screen-reader behavior, which most real features eventually do.


    Finding 10 — the docs are AI-maintained, and what that does and doesn’t imply

    All of toolcrib’s ai-docs/*.md are themselves AI-generated and AI-maintained — only the repo’s root README isn’t. That reframes every gap in Findings 5/7 above (not Finding 8, which found no gap — its shipped capability worked as documented): not a scattered collection of unrelated bugs, but one recurring shape — documentation and tooling produced for AI consumption, by an AI, without something adversarially trying to use it the way a consumer would. Generation and adversarial-use-checking are different activities; being capable of one doesn’t guarantee the other happened.

    Where this needs tempering: this is a maintenance-process observation, not evidence of an inevitable failure loop. A human is still the one deciding a gap matters enough to fix and directing the correction — which is exactly how the five patches referenced throughout this report came to exist: gaps surfaced by actually trying to build something, a human decided they were worth institutionalizing rather than left as one-off workarounds, and verified patches came out the other side. That’s the normal corrective mechanism any human-governed codebase relies on, AI-authored docs or not.

    What this doesn’t guarantee: if toolcrib’s maintenance pipeline regenerates these docs again without incorporating this specific pattern, a structurally similar gap could reappear somewhere else — a different component’s manifest entry, a different anti-pattern the table doesn’t cover yet. The patches in this report close the specific gaps this evaluation happened to hit; they don’t retroactively audit every other component for the same shape of issue.

    Worth noting for calibration: every automated check that existed throughout this evaluation caught real regressions reliably — tsc --noEmit, the 212-test suite, and the doc-drift checks all passed cleanly on every patch. Those checks are good at “does generated output match what the generator would produce” and “does existing behavior still work.” That’s a different thing from “is the generated output complete” — which is exactly the class of gap that only surfaced by trying to build the settings screen, hitting a wall, and going looking for why.


    Patch verification methodology

    The five patches referenced above (WCAG contrast fix, manifest type-definition resolution across two files, DataTable.rowSubtheme, and the matching Anti-Patterns doc entry) were verified against the real upstream repository, not just written and assumed correct:

    1. Fresh clone of github.com/escape-llc/toolcrib (not the pre-existing working copy the patches were drafted against).
    2. Applied all five diffs in sequence via git apply --check followed by git apply — confirmed each applies cleanly with zero conflicts, in dependency order, on the clean checkout.
    3. Dependency install: npm install. One unrelated environment finding surfaced here: the repo pins "typescript": "^7.0.2", but that package’s root export (. → lib/version.cjs) is now just a version stub — the actual Compiler API moved to typescript/unstable/ast and sibling subpaths. The pre-existing, unmodified scripts/lib/extract.js (classic ts.createSourceFile/ts.ScriptTarget API, used throughout the file, not only by these patches) doesn’t run against that as installed. Verification below used typescript@5.7 installed locally (--no-save, not committed to the project) purely to execute and test the patches; this incompatibility is separate from anything in the patches themselves and would need its own fix.
    4. Typecheck: npx tsc --noEmit → clean, whole project.
    5. Manifest regeneration: node scripts/generate-manifest.js --write → 32 component(s), 39 event channel(s), 10 helper method(s), 156 CSS variable(s), including 8 newly-resolved $defs entries (Column plus 7 others with the same latent type-flattening gap: AccordionItemData, CardOverrides, MenuItemData, RadioOption, SelectOptionData, TabItem, ToggleGroupOption).
    6. Docs regeneration: node scripts/generate-docs.js --write → regenerates ai-docs/CORE.md cleanly, rowSubtheme present in both the auto-generated Component Reference table and the newly-added Anti-Patterns row.
    7. Drift checks (both must report no difference between generated output and what’s committed): node scripts/generate-docs.js --check → “ai-docs/CORE.md matches the template + source exactly”; node scripts/generate-manifest.js --check → “component-manifest.json matches source exactly”.
    8. Full test suite: npx vitest run → 48 test files, 212 tests, all passing — including the project’s own docsInSync.test.ts (which independently re-runs the drift checks above as part of its assertions) and DataTable.test.tsx (6/6, confirming the new rowSubtheme prop doesn’t change behavior for rows that don’t set it) and harmonies.test.ts (4/4, confirming the WCAG patch doesn’t break existing theme-generation tests).

    No step above was skipped or assumed to pass based on the patch’s own internal logic looking correct — each ran against the actual real source tree, post-patch, before being reported as verified.


    Threats to validity

    1. Same-agent bias. One model built both arms of every trial, aware of the hypothesis throughout. No blinding. A rigorous version of this test would use separate model instances or separate human developers unaware of the comparison being drawn.
    2. N=3 per condition for the stochastic metrics (Tailwind token counts) — enough to demonstrate the effect, not a rigorous population estimate. Doesn’t matter for the Tailwind-count result specifically (a type-system guarantee, not a sample statistic), but does matter for anything not backed by an equivalent structural proof.
    3. Stack confound. Toolcrib forces React/TSX/Vite; Session A was free to pick anything and defaulted to static HTML + Tailwind CDN. Some of every result above is inseparable from “different tooling entirely,” not purely “toolcrib vs. nothing.”
    4. No real browser rendering. A true pixel-level visual diff (Playwright/Chromium) was attempted and blocked by this environment’s network allowlist. All visual-similarity findings are derived analytically from theme/CSS source values, not screenshots.
    5. Token cost estimates are approximate (chars/4), not measured from real API usage logs or an actual tokenizer.
    6. The DataTable row-hook gap (Finding 5, now patched) was one example of one component. Finding 9’s Radix-foundation survey narrows this rather than resolving it: Splitter (also hand-rolled, no Radix) is now the more plausible candidate for a similar internal-DOM gap; TabStrip (Radix-backed) is less so. That’s an informed prediction from architectural pattern, not a verified finding — Splitter itself wasn’t tested the way DataTable was.
    7. Finding 9’s accessible-dialog behavior was tested for one component pair (AlertDialog/DataTable) and verified by static inspection of the generated code (.focus() calls, ARIA attributes present), not by driving an actual browser with a screen reader or keyboard-only navigation. The same network restriction that blocked visual screenshot testing (Threat 4) would also block that kind of live behavioral verification here.

    Further work

    None of the following would change the verdict — they’d sharpen the decimals:

    • Real screenshot-based visual diff (needs network access to a Chromium download host, or a pre-installed browser).
    • Blinded trials across genuinely separate model instances or human developers, to remove same-agent bias.
    • Real API-log token costs in place of the chars/4 proxy, ideally with prompt caching enabled to measure the true amortized onboarding cost.
    • An unguided-retheming test — does a session using toolcrib reach for ThemeEditor/initialParameters without being told to, the way the unguided sessions reached for Tailwind without being told to? (Out of scope here: theming is treated as a human-in-the-loop decision point in both conditions, not something either tool should autonomously do.)
    • Larger-N global-restyle trials to firm up the token-cost breakeven estimate.
    • A survey of which other toolcrib components expose internally-owned, unreachable DOM (like DataTable‘s row used to) versus which already expose enough slots that the constraint never bites.

    Bottom line

    Toolcrib removes ad hoc styling from its own components by construction, and that removal is real, uniform, and doesn’t have a style-prop backdoor anywhere in the toolkit’s own API surface. It doesn’t remove styling flexibility from your own code — a plain wrapper inside a slot is unrestricted — and the one place the restriction used to have no answer at all (a data table’s own row markup) now has one, scoped to the four existing semantic categories rather than reopened as a general escape hatch.

    It also turns a category of feature that’s genuinely hard to build well — end-user-facing runtime theme customization with guaranteed accessible contrast — into a near-zero-cost addition, where the unguided equivalent isn’t just more code, it’s code that fails silently the moment a real user picks a plausible color.

    None of that is the whole story, and treating it as a styling toolkit undersells what’s actually load-bearing in it. aiBus (cross-tree dispatch without prop-drilling), the Radix foundation underneath 60% of its components (Finding 9), and the layout-domain corner-squaring system are not styling conveniences — they’re what stands between “confirmation dialog” and a dialog that silently fails at keyboard access and screen-reader semantics, the same silent-failure pattern demonstrated for color throughout this report, just in a subsystem a sighted developer testing with a mouse is far less likely to ever notice is broken. An unguided build of that same feature had zero of the four standard accessible-dialog behaviors. Evaluating toolcrib only on how it handles color, and stopping there, would have missed roughly half of what this evaluation actually found.

    Adopt it where the actual pain is inconsistent, hard-to-globally-restyle output across many contributors or iterations, where interaction correctness (focus management, cross-tree state, keyboard/screen-reader access) matters as much as appearance, and where a project can accept React/TSX as the price of entry. The accessibility floor is a genuine bonus most unguided output has no equivalent of, extending automatically to colors an end user picks and to every component built on the Radix foundation, not just ones a developer happened to get right. Expect the remaining friction to be narrow and specific — non-semantic per-instance styling treatments on components that don’t expose a slot for them, and the ~40% of the component set (DataTable, Splitter, and similar) that doesn’t inherit the Radix foundation — rather than pervasive.

    The real cost comparison isn’t “tokens to generate the first draft.” It’s tokens (and turns, and calendar time) to discover a defect exists at all, since nothing in an unguided session flags one on its own — followed by an indeterminate number of correction turns once someone finally does. Toolcrib doesn’t eliminate that discovery cost; the five patches in this report all took real, deliberate effort to find. What it changes is what happens after: a fix closes structurally, once, for every future project — where an unguided project’s fix is local to that one file, and the next unguided session re-risks the identical mistake with nothing carried forward.

    If one sentence has to carry this report past the specifics of any one library: unconstrained, unguided output is not a stylistic inconvenience, it’s an unmonitored failure surface, in styling and in interaction correctness alike — this evaluation produced a silent functional regression, a genuine WCAG failure, a 100% failure rate on plausible end-user color picks, and a confirmation dialog with zero of four standard accessible-dialog behaviors, all without the build process itself raising a single flag in any case. Whether the fix is toolcrib, a different design-token system, automated accessibility testing in CI, headless accessible primitives as the default rather than hand-rolled markup, or a human review step that actually happens every time — the specific mechanism matters far less than the fact that some mechanism needs to exist, covering behavior as much as appearance. This evaluation’s evidence is about what happens when none does.

  • Toolcrib vs. shadcn/ui and Other Open-Source UI Toolkits

    Analysis based on a live clone of escape-llc/toolcrib (commit history, source, docs, and the CLI package) as of August 17, 2026. GitHub’s stars/forks/issues API was rate-limited during this review, so community-metric claims below (stars, adoption) rely on what’s visible in the repo itself, not the GitHub API — worth re-checking directly on the repo page.


    1. What Toolcrib Actually Is

    Toolcrib is not a component library in the traditional sense — it’s a UI toolkit explicitly designed to be used by an AI coding agent, not primarily by a human developer. Its stated problem: when you “vibe code” a UI with an LLM, the model regenerates Tailwind-esque class soup from scratch every turn, so nothing stays visually consistent across sessions.

    Its solution is structural, not cosmetic:

    • No className or style props are exposed on components. The model is architecturally prevented from inventing ad hoc styling — it can only pick from a constrained set of variants/props.
    • ~33 components (Button, Card, DataTable, Modal, Popup, SlideOut, TabStrip, ToggleGroup, Toolbar, AppShell, Splitter, ThemeEditor, etc.), built on top of Radix UI primitives for accessibility/behavior, with a custom CSS-variable-driven theming layer (ThemeSlice pattern) rather than Tailwind.
    • A component-manifest.json — a machine-readable API surface — meant to be fed to the agent instead of prose prop documentation, plus ai-docs/ files (CORE.md, NEW_APP.md, REFACTOR_APP.md) written specifically as agent instructions.
    • A custom CLI (npx toolcrib init/apply/doctor/merge/versions) with a genuinely clever patch-based vendoring workflow: it never touches your files directly, stages everything as reviewable .patch files, and has a real three-way merge/conflict system for upgrades (did you edit the file? did upstream change it too? → safe update / kept-as-is / conflict).
    • Full vendoring model — the same “copy the source into your repo” approach as shadcn/ui, explicitly cited as the inspiration.

    The unifying principle underneath all of it — worth naming explicitly, since it recurs at every layer covered in this report: LLMs don’t reproduce their own prior output exactly. Call it fuzziness — the same prompt, or a prompt close enough to the last one, doesn’t reliably regenerate the same answer. Toolcrib’s operating premise is that anywhere this fuzziness would surface as a real bug or as visible drift, the right response isn’t to document the correct answer more clearly and hope the model reproduces it — it’s to remove the model’s opportunity to regenerate that decision at all, and point it at one fixed, shared answer instead. The no-className slice system is this principle applied to styling. The shared infrastructure in Section 2 — one event bus, one observer manager, one z-index scale, one ID-generation primitive — is the same principle applied to behavior, closing off the exact re-solve-and-diverge failure mode described there. And the generate → review → taste-check development loop in Section 9 is the same principle applied to the project’s own process: a check the model’s fuzziness can’t route around, because a human is doing it, not the model. Three layers, one insulation strategy.

    There’s a fourth, and it sits earlier in the pipeline than any of the above — before generation happens at all. A fixed, closed vocabulary reduces fuzziness not just in what the model produces, but in what it and the human mean when they refer to something in conversation. This is genuinely worth separating from shadcn’s component naming, which offers something similar but shallower: shadcn’s Button or Dialog is a stable name too, but the vocabulary stops being closed the moment you go one level down — variant, spacing, or color is expressed in Tailwind’s open combinatorial utility-class space, where many different class combinations can express roughly the same intent, so “give it the danger color” still gets re-interpreted freely each time. Toolcrib’s manifest closes the vocabulary at that level too — verified directly in source: Button‘s variant prop is a literal union ('primary' | 'secondary' | 'outline' | 'danger' | 'ghost'), not an open string, and the $defs section of component-manifest.json enumerates the same kind of closed set for every reusable field (e.g. subtheme: 'error' | 'success' | 'warning' | 'info'). “Use the danger button” now names one of five fixed values, not a request the model reformulates in CSS each time. Two honest caveats: this isn’t unique to Toolcrib at the component-naming level (shadcn has that too) — it’s the closed vocabulary reaching below the component, into variant/slice-field territory, that’s the actual difference; and a closed vocabulary only helps once it’s navigable, which is exactly why the manifest’s own size (Gap-Closure Plan, item 1) matters as much as its closedness — a vocabulary too large to search is functionally no better than prose.

    2. The Load-Bearing Infrastructure Layer

    The comparison so far has focused on components and theming, but that undersells a real part of what Toolcrib provides: a layer of shared plumbing solving problems that are tedious and easy to get subtly wrong every time an AI model (or a human, for that matter) reinvents them per-component or per-session. This is infrastructure in the literal sense — invisible when it works, and the report would be incomplete without naming it, since it’s a genuine part of the “why does this reduce drift” answer, not just the styling story.

    Concretely, verified directly against source:

    • A strongly-typed global event bus (eventBus/eventBus.ts) for cross-tree communication without prop-drilling — aiBus.openModal(id), aiBus.showToast(message), etc. — with a genuinely subtle piece of engineering inside it: certain events (currently tab:changed) are marked sticky, replaying their last payload to a new subscriber immediately on subscribe. That exists to solve a real race condition — <TabStrip> and <TabStrip.Panel> are independent subtrees with no guaranteed mount order, so a plain pub/sub can permanently miss the initial broadcast depending on which one mounts first. This is exactly the kind of cross-component wiring bug that’s tedious to get right once and easy to get wrong every time it’s rebuilt from scratch.
    • A single shared ResizeObserver/IntersectionObserver manager (observer/observerManager.ts) rather than one observer instance per component — every observed element is tracked in one manager, debounced individually, with its own documented fix for a real bug (a second observe() call on an already-tracked element used to silently keep the first call’s config forever, so resize events kept reporting stale data after a config change).
    • A canonical z-index scale (theme/zIndex.ts) — BASE, STICKY, SPLITTER, DRAWER, MODAL, DROPDOWN, TOOLTIP, TOAST, each a fixed value with a documented reason for its position (e.g., tooltips above dropdowns and modals, toasts above everything). Z-index stacking wars — a toast trapped behind a modal, a dropdown rendering under a drawer — are a genuinely common class of ad hoc-UI bug, and this closes it with one shared constant instead of leaving every component to invent its own value.
    • SSR-safe stable ID generation (components/shared/useStableId.ts) — built on React’s own useId() rather than a Math.random()-backed ref specifically because the previous implementation caused a real hydration mismatch bug under Next.js/Remix-style SSR (server and client generating different random values for the same element). The comment in the source is explicit that fixing the shared primitive once forecloses the same class of bug for every future consumer, not just the components that hit it first.
    • Real exit-animation lifecycle handling (theme/useAnimatedMount.ts) — drives unmount off an actual animationend event rather than a guessed setTimeout duration, after that exact guessed-duration pattern caused two documented real bugs in this project’s own history: Toast’s exit animation never actually playing before removal, and SlideOut staying mounted (backdrop and all, still blocking clicks) after its timeout lost a race against React’s effect scheduling. This is precisely the kind of animation-timing bug that’s near-universal in hand-rolled overlay code and easy to reintroduce every time a model builds one from scratch.
    • Portal-aware document targeting (theme/targetDocumentContext.tsx) — a React Context (not CSS-variable inheritance, and not DOM position) tracking which Document a component’s self-injected CSS should target, specifically so a component portaled into an iframe doesn’t silently inject its :hover/:focus-visible/@keyframes rules into the outer page’s <head> instead of the iframe’s. The same Context-over-CSS-cascade reasoning also underlies StyleDomainContext (Section 6’s correction on one-off styling) — it’s a repeated architectural pattern in this codebase, not a one-off fix.
    • Automatic WCAG contrast enforcement, not just accessible markup (theme/hsv.ts, invoked from every palette generation in theme/harmonies.ts) — this is a distinct contribution from Radix’s accessibility guarantees (which cover markup/ARIA/keyboard behavior, not color choices at all). ensureWCAGContrast computes real WCAG 2.x relative luminance (the spec’s own piecewise-linearized-sRGB formula, weighted 0.2126/0.7152/0.0722 per channel) and iteratively nudges a color’s Value/Saturation until it meets a minimum contrast ratio against its background — with a documented real bug behind why this matters: an earlier version approximated luminance from HSV lightness alone, which is hue-blind, and silently passed real accessibility failures specifically for the toolkit’s own default error/info subthemes (blue and red-anchored), reporting a flat 4.56:1 “compliant” ratio across every hue when the real range was 1.89:1 (a failing blue) to 11.55:1 (yellow). A separate pickReadableTextColor handles the different case of text painted directly on a solid fill (a filled button, a badge) rather than themed text on a neutral surface, fixing a second real bug where filled-button variants hardcoded white text unconditionally and produced unreadable white-on-lime-green output for bright primary colors. Because this runs automatically inside palette generation, a non-expert user picking a color in the visual Theme Editor gets WCAG AA-compliant text by construction, with no accessibility knowledge required — reinforcing the non-Tailwind-expert positioning discussed in Section 6.

    Why this matters for the comparison specifically — and it’s a sharper problem than mere duplication: shadcn/ui ships components; it doesn’t ship an opinion on any of the above. The risk isn’t just that a model building on shadcn re-solves z-index conflicts, observer duplication, SSR-safe IDs, and overlay unmount timing independently every session — it’s that an AI model cannot reproduce its own prior output exactly, so each re-solving is liable to diverge from the last one, not just duplicate it. Two independently-guessed z-index values aren’t two valid stacking orders — they’re two values that may actively conflict the moment both components land on the same page. Two independently-timed overlay unmount guesses aren’t two working solutions — they’re two different failure windows race-conditioned against React’s own scheduling. This is the same root cause as the styling-drift problem the rest of this report is about — no persistent state carried across generations — just surfacing as behavioral/correctness bugs at the infrastructure layer instead of visual inconsistency at the styling layer. Toolcrib closing this class of problem once, as shared infrastructure instead of per-component logic that gets regenerated (and silently re-diverges) every time, is a real structural advantage the component/theming framing alone doesn’t capture.

    3. Project Maturity (important context)

    • Repo history starts August 6, 2026; current state is ~49 commits over ~10 days, tagged v0.1.0 → v0.3.0.
    • Single contributor (eScape LLC) for all 49 commits — no visible outside contributors yet.
    • The README states plainly: “This file is the only human-created content in the whole repo! The rest was generated by AI.” The commit history backs this up with a specific detail: 7 of the 49 commit messages explicitly name Claude sessions (“big fat claude session,” “claude fix campaign,” “big fat claude review session,” “big fat claude refactor,” etc.), concentrated in the project’s major refactor and review passes — so “mostly Claude” is an accurate characterization of the primary tool, not just “AI” in the generic sense.
    • MIT licensed, has a test suite (49 unit test files, Playwright e2e specs for interaction states, animations, layout, typography) — a good sign of engineering care for something this new.

    This is a pre-community, single-maintainer, days-old project, not an established ecosystem. That single fact should weight every comparison below.

    4. Component Coverage vs. shadcn/ui and Others

    CategoryToolcribshadcn/uiMantine / Chakra UI / MUI
    Primitives baseRadix UIRadix UI (mostly)Own primitives (Mantine/MUI) or Ark UI (Chakra v3)
    Styling modelCSS variables + “slices,” no className exposedTailwind classes, fully editableCSS-in-JS / CSS modules, fully editable
    Component count~33~50+ (Command, Calendar, Carousel, Sheet, Breadcrumb, Pagination, Chart, Drawer, Skeleton, Input OTP, Resizable, Navigation Menu…)100+ (mature, years of iteration)
    Data tableYes (built-in)No (community recipe w/ TanStack Table)Yes (Mantine/MUI have dedicated table packages)
    Theme editorYes, built-in visual editorNo (edit Tailwind config/CSS vars by hand)Varies (MUI has theme creator tooling)
    DistributionCLI with patch-based init/merge/doctor/upgradeCLI (shadcn add), simpler add-only modelnpm package, standard semver updates
    Community / ecosystemNone yet (10 days old)Large, very active, huge blog/tutorial corpus, de facto standard for AI-assisted React UI in 2025–2026Large, established for years
    AI-specific designPurpose-built for this (manifest, ai-docs, style lockdown)Popular with AI tools incidentally (simple, copy-pasteable, well-represented in training data)Not designed with AI agents in mind

    The honest framing: Toolcrib isn’t really competing with shadcn/ui on breadth or maturity — it’s competing on a narrower, specific bet, and that bet is broader than styling alone: that removing the model’s opportunity to regenerate a decision — whether that decision is a CSS class or a z-index value or an overlay’s unmount timing — produces more consistent, more correct AI-generated UIs than shadcn’s “here’s editable Tailwind and no shared behavioral plumbing, good luck” approach. The styling story (no className) is the most visible half of that bet; the infrastructure layer in Section 2 (event bus, observer manager, z-index scale, SSR-safe IDs, animation lifecycle) is the less visible, arguably equally important other half.

    5. Pros

    • Genuinely addresses a real, well-known pain point. Anyone who’s iterated on an LLM-generated UI has hit the “it looked great, then I asked for one tweak and everything drifted” problem. Locking out className/style is a real architectural fix, not just documentation asking the model to behave.
    • The shared infrastructure layer (Section 2) closes a class of bug the styling fix doesn’t even touch. Z-index stacking conflicts, duplicated ResizeObserver instances, SSR hydration mismatches from ad hoc ID generation, and overlay-unmount race conditions are all things a model re-solves — and, given LLM fuzziness, re-diverges on — every time it builds an overlay or a layout from scratch. A single event bus, one z-index scale, one ID primitive, and one animation-lifecycle hook close all of these once, at the infrastructure layer, rather than leaving each to be independently reinvented and silently drift out of sync with every other instance. This is arguably as consequential as the styling story and gets far less attention in most framings of what Toolcrib does, this one included until this correction.
    • A closed vocabulary, not just a component library — a distinct benefit from the manifest’s machine-readability. “Use the danger button” names one of five fixed variant values (verified in source: variant?: 'primary' | 'secondary' | 'outline' | 'danger' | 'ghost'), not a request the model re-expresses in CSS however it sees fit this session. shadcn offers stable component names too, but its vocabulary opens back up into Tailwind’s combinatorial class space the moment you’re below the component level; Toolcrib’s stays closed all the way to the variant/slice-field level (see Section 1). That reduces ambiguity in the human-model conversation itself, before generation even happens — not just in what the model outputs.
    • Semantic styling persists through refactors; raw utility styling doesn’t — and this is a sharper problem for uncontrolled styling than mere inefficiency. Verified in source: Button‘s danger variant resolves to var(--ai-subtheme-error, #ef4444) — a CSS custom property set centrally by palette generation — not a hardcoded value baked into the call site. The only thing written in JSX is the semantic label itself, variant="danger". Raw Tailwind has no equivalent separation: className="bg-red-500" conflates the resolved value and the semantic intent into one artifact, with nothing distinguishing “this red is here because it’s our danger color” from “this red is here because someone typed it once.” A later restyle pass — same model, new session, or a different one entirely — has no way to tell deliberate customization from arbitrary/leftover code by inspecting the class list alone, and can silently normalize away or overwrite a previously-correct, deliberate decision (a contrast fix, a brand-specific shade) without any signal that a regression just happened. Toolcrib’s call sites can’t regress this way, because they never encode a resolved value to begin with — a global palette change propagates to every variant="danger" automatically, with nothing to hunt down and reinterpret at each occurrence.
    • Machine-readable manifest is a smart idea. Feeding a JSON API surface to a model is more token-efficient and more reliable than prose prop docs, and avoids the model guessing prop names.
    • The CLI’s patch/merge/conflict model is more sophisticated than shadcn’s — and it’s a real mechanism for the app’s foundation to actively improve over time, not just avoid decay. shadcn’s add command is essentially one-shot copy; Toolcrib’s doctor/merge tries to solve the real problem of upgrading vendored code you’ve since hand-edited (three-way diff, conflict files) — a harder problem shadcn hasn’t fully solved either. This isn’t hypothetical: the WCAG contrast fix and the SSR-safe useStableId fix (Section 2) both landed as real commits between tagged versions, so an app that started on an earlier version and later runs toolcrib merge genuinely inherits both fixes in its own vendored code, the same way it would from a patched npm dependency — without an agent ever needing to rediscover or refix either bug independently. That’s distinct from, and stronger than, the “shared infrastructure prevents per-session divergence” argument in Section 2 — this is the toolkit’s own quality improving underneath an app that’s already been built, not just staying consistent at a fixed baseline. (See the Cons/Risks entry below on what this benefit actually depends on.)
    • Built-in theme editor is a nice differentiator — non-technical users or designers can produce a preset without touching code, then hand that preset to the AI.
    • Accessibility inherited “for free” via Radix, same trust boundary as shadcn — markup, ARIA, and keyboard behavior. Worth distinguishing from a separate accessibility contribution that’s actually Toolcrib’s own, not Radix’s: automatic WCAG contrast enforcement (Section 2) guarantees readable text against any generated palette, which Radix has no opinion on at all since it doesn’t touch color. Two different accessibility guarantees, from two different layers, both real.
    • Has real automated tests (unit + Playwright e2e) despite its youth, which is more test discipline than a lot of brand-new component libraries ship with.

    6. Cons / Risks

    • The “gets better over time” benefit above depends on two things that aren’t automatic, and its scope is narrower than “application architecture” implies. First, it only accrues to apps that actually practice merge discipline — one that vendors Toolcrib once and never runs merge again is frozen at whatever quality existed at init time, no different from ad hoc code in that respect, and any hand-edited file that upstream also later touches becomes a conflict requiring manual reconciliation, not an automatic gain. Second, and more fundamentally: everything this report has covered — the slice system, the event bus, the z-index scale, WCAG enforcement — is the presentation layer specifically. Toolcrib has no opinion on, and does nothing to insulate, state management, data fetching, routing, or business-logic organization elsewhere in the same app. An agent working in those layers is exactly as “uncontrolled” as it would be without Toolcrib at all — the curation is real, but it’s scoped to UI/presentation architecture, not the application as a whole.
    • No community, no track record. Zero external contributors, unverifiable production usage, 10 days of history. Bugs, edge cases, and abandoned-maintenance risk are all unknowns you’d be absorbing personally. shadcn/ui, Mantine, Chakra, and MUI all have years of battle-testing and large enough communities that answers to “why is X broken” already exist on the internet.
    • Much smaller component surface. No Command palette, Calendar/date picker, Carousel, Breadcrumb, Pagination, Skeleton loaders, Chart wrapper, Navigation Menu, or Drawer-as-distinct-from-modal out of the box — all standard in shadcn/ui today. You’d be building these yourself or falling back to raw Radix/other libraries anyway, which reintroduces the exact “unstyled model output” problem Toolcrib exists to prevent.
    • Losing className/inline styling is a real trade-off, but it’s not a dead end. One-off formatting isn’t unhandled — a per-component overrides prop (resolved against the component’s own registered ThemeSlice fields) and a StyleDomainProvider/useStyleDomain mechanism for subtree-level semantic states (e.g., marking a whole form section as an error state) exist specifically for this. That’s a genuinely well-designed answer to the “how do I make just this one thing different” problem, including handling portaled components (Modal/Popup/SlideOut) correctly via React Context rather than CSS-variable inheritance, which wouldn’t cross a portal boundary. The remaining gap is that this mechanism isn’t documented in the AI-facing manifest or ai-docs yet, so an agent (or a new contributor) has no way to discover it without reading source — worth fixing, but it’s a documentation gap, not a missing capability.
    • Own theming engine instead of Tailwind — a real trade-off, but not a one-directional loss. For developers who already think in Tailwind, this is a genuine ecosystem-lock-in cost: fewer Stack Overflow answers, fewer people who already know the system, and less training-data prior for the model itself (see Section 8). But Toolcrib’s actual target user isn’t necessarily a Tailwind expert — it’s explicitly aimed at people who want to style their app without needing to be one. Judged against that audience, the visual Theme Editor and CSS-variable “slice” system are arguably more approachable than Tailwind’s config-file-and-utility-class model, which has its own real learning curve for anyone who isn’t already fluent. The honest framing is: this is a bet on a different audience, not a strictly worse version of Tailwind’s approach — the risk is narrower than “lock-in,” and is really about whether that non-expert audience is large enough, and whether the toolkit does enough to actively serve them (see the gap-closure plan for how to lean into this rather than just mitigate it).
    • “Full vendoring” inherits shadcn’s known downside too: you now own and must maintain 596KB+ of copied component source in your repo, including bugs, rather than pulling a patched npm dependency.
    • Radix UI dependency uses radix-ui v1 “all-in-one” package — fine, but ties you to Radix’s release cadence and any of its known quirks (portal/z-index edge cases, etc.) just as shadcn does.
    • No visible design-system opinions beyond function. shadcn/ui, Mantine, and Chakra have gone through multiple visual redesign iterations informed by thousands of real apps; Toolcrib’s visual defaults haven’t been stress-tested at that scale.

    7. When Each Makes Sense

    • Choose shadcn/ui if you want the largest component surface, the most community support/tutorials, Tailwind (which most AI models already “know” natively), and you’re fine reviewing/fixing the occasional styling drift yourself, or you’re building a team-scale product where hiring people who already know the stack matters.
    • Choose Mantine/Chakra/MUI if you want a mature, batteries-included design system with years of edge-case handling, and you’re not primarily optimizing for AI-agent-driven development.
    • Consider Toolcrib if your primary workflow really is heavy, repeated AI-agent UI generation/editing (e.g., an internal tool built almost entirely through Claude/Copilot-style iteration) and you’re willing to accept: a much smaller component set, a bespoke styling system to learn, and zero community safety net, in exchange for a real shot at more consistent AI output turn-to-turn. It’s best understood as an experimental, opinionated bet on a specific workflow, not a shadcn replacement yet.

    8. AI-Optimization Evaluation (All Toolkits)

    “AI-optimized” is being scored here on seven dimensions that actually matter when an agent, not a human, is doing the building and iterating:

    1. Training-data prior — how much the model already “knows” about the library from pretraining, before you give it any docs at all.
    2. Styling determinism — can the model freely emit arbitrary CSS/classes (drift risk), or is it constrained to a fixed set of tokens/variants?
    3. Machine-readable API surface — is there a structured manifest/type surface an agent can consume cheaply, versus prose docs it must infer from?
    4. Cross-turn consistency mechanism — is there one source of truth (theme object/provider) the model reliably reuses, or does each turn regenerate values independently?
    5. Vendoring/inspectability — can the agent see and safely edit the actual component source, or is it a black-box npm import?
    6. Token cost per interaction — how much context is needed to use the library correctly?
    7. Purpose-built intent — was agent-driven usage an actual design goal, or a side effect of being simple/popular?
    DimensionToolcribshadcn/uiMantineChakra UI (v3)MUI
    Training-data priorLow, but not uniformly — near-zero for bespoke mechanisms (slice system, overrides, StyleDomain, event bus, z-index scale); largely inherited for conventional API shape, since components wrap Radix directly (open/onOpenChange, value/onValueChange) and a model’s deep Radix/React prior transfers by analogy without ever having seen Toolcrib specificallyVery high (dominant in 2024–2026 AI-coding tutorials/blogs)MediumMedium-highVery high (a decade of docs/StackOverflow/code in training data)
    Styling determinismHighest — no className/style at all, fixed variant props onlyLowest — full Tailwind class freedom, arbitrary values allowedMedium — style props + CSS modules, easy to bypass tokensMedium-high — token-constrained style props (p={4}), recipes system discourages arbitrary valuesLow-medium — sx prop allows arbitrary CSS-in-JS, easy to drift despite theme
    Machine-readable API surfacePurpose-built — component-manifest.json + ai-docs/ written for agentsNone formal — relies on model’s prior + component source as implicit docNone formal — relies on TS types + docs siteNone formal — relies on TS types + docs siteStrong TS types act as de facto manifest, but no agent-specific format
    Cross-turn consistency mechanismStrong — ThemeSlice system + CSS variables, single source of truth by constructionWeak — tailwind.config + CSS vars exist but nothing stops per-turn class inventionMedium — MantineProvider theme object is centralized, but style-prop overrides can still driftStrong — theme object + recipes are the idiomatic path, harder to bypass than TailwindMedium — ThemeProvider/theme object is thorough, but sx overrides bypass it constantly in practice
    Vendoring/inspectabilityFull vendor, agent sees everythingFull vendor (same model)npm package, source visible on GitHub but not in-reponpm package (v3 uses Ark UI + Panda CSS under the hood)npm package
    Token cost per interactionLow once manifest is loaded, but must be loaded (nothing free from prior)Low — model often needs little beyond the prompt due to strong priorMediumMediumMedium-high — large API surface, though offset by strong prior knowledge
    Purpose-built for agentsYes, explicitlyNo — accidental fit due to simplicity + popularityNoNoNo

    Per-toolkit take

    • Toolcrib — the only one of the five actually engineered around the premise “an AI agent is the primary user.” Wins outright on styling determinism and manifest design — the two dimensions most directly responsible for the “it looked great, then fell apart” failure mode this whole report started from. Its weakness is structural, not conceptual: it has to earn every bit of correct usage through the injected manifest/docs each session, because there’s no training-data prior to lean on yet. Worth being precise about, though: that’s not a flaw unique to Toolcrib — every one of these toolkits had zero prior once, and shadcn/MUI/Chakra’s current advantage here is simply age plus adoption, not something inherent to their design. And it’s less severe than a single “very low prior” score implies, because the gap isn’t uniform across the API surface. Every migrated overlay wraps its Radix primitive directly and exposes the exact conventional controlled pattern (Modal/Popup use open/onOpenChange over DialogPrimitive.Root/PopoverPrimitive.Root; Select uses value/onValueChange) — a model’s deep, well-worn Radix/React prior transfers here almost for free, without ever having seen Toolcrib specifically. The real gap sits narrower than “the whole library”: it’s the genuinely bespoke mechanisms with no external precedent anywhere — the slice system, overrides, StyleDomain, the event bus’s sticky-replay semantics, the fixed z-index scale — where prior can’t transfer because nothing like it exists elsewhere for the model to have learned from. And the cost of losing the rules mid-session is friction, not corruption: every migrated component’s props type is Omit<HTMLAttributes<T>, 'style' | 'className'>, so a model reaching for className gets a real compiler error (plus a dev-mode console warning for the cases TS can’t catch), not a silently drifted UI. The two “wasted turns” costs aren’t the same scale, and shouldn’t be read as comparable — and it’s actually sharper than a scale difference. Toolcrib’s cost is a one-time, self-correcting error: one compile error, one turn, done for the session. Tailwind/shadcn’s cost is the open-ended, recurring one Toolcrib’s own README names as its founding motivation (“tweaking is a horrible affair of wasted turns adjusting padding, margins, etc.”) — but “wasted turns” undersells it, because the failure mode isn’t purely inefficiency. Raw utility classes conflate a value’s resolved output with its semantic intent into one artifact (bg-red-500 doesn’t say whether that red is there because it’s the deliberate danger color or because someone typed it once and moved on), so a later restyle pass has no way to distinguish deliberate customization from arbitrary/leftover code, and can silently overwrite or normalize away a previously-correct decision with no signal that anything regressed. That’s not wasted effort — it’s active regression of work that was already right, and it matches practitioner experience rather than staying theoretical: visual regressions from uncontrolled styling are reported as a chronic, recurring cost across multiple real projects, not a rare edge case — exactly the pattern the semantic/resolved-value conflation above would predict. Framed that way, the net trade tilts toward Toolcrib more heavily than “wasted turns” alone implies — which is the whole thesis of this comparison in the first place. See the companion gap-closure plan for how MCP/RAG-backed on-demand retrieval could shrink Toolcrib’s smaller cost further — and how it should now target the narrow bespoke-mechanism gap specifically, not the whole API surface.
    • shadcn/ui — paradoxically the most “AI-optimized” toolkit in practice today despite not being designed for it: strong prior + full vendoring means a model can often produce correct, idiomatic code with almost no extra context. But it’s optimized for generation, not consistency — nothing stops the same model from producing three different-looking buttons across three turns, since Tailwind places no real ceiling on invention. This is exactly the gap Toolcrib is targeting.
    • Chakra UI (v3) — the best of the “not purpose-built” group on styling determinism, because its token-based style props and recipe system make it awkward (not impossible) for a model to reach for arbitrary values. If you want shadcn-adjacent popularity with meaningfully more built-in consistency pressure, this is the closest existing fit.
    • Mantine — decent middle ground: centralized theme object gives real consistency at the design-token level, but its style-prop API is loose enough that a model can still freelance visual details turn to turn.
    • MUI — the weakest on determinism precisely because of sx: its theme system is arguably the most mature of all five, but the sx escape hatch is so idiomatic and so heavily represented in training data that models reach for arbitrary inline overrides constantly, undermining the theme’s authority. Best type coverage of the group, though, which does help an agent avoid outright invalid prop usage even if it doesn’t stop visual drift.

    Net ranking for “keeps an AI agent’s output visually consistent across turns,” specifically: Toolcrib > Chakra UI ≥ Mantine > MUI > shadcn/ui.
    Net ranking for “produces correct code with the least hand-holding today, cold prior only”: shadcn/ui > MUI ≥ Chakra UI ≥ Mantine > Toolcrib.

    These two rankings are almost inverted — which is really the whole story. Toolcrib is a bet that the consistency ranking matters more over a long agent-driven project than the cold-start ranking; shadcn’s popularity suggests most of the market is currently betting the opposite way.

    A detailed, actionable gap-closure plan addressing every issue raised above is available as a companion document: toolcrib-gap-closure-plan.md.

    9. A Meta-Observation: The Same Feedback Loop, at Two Layers

    Toolcrib’s founding premise is that AI-generated UI needs a structural mechanism to keep it honest across turns — hence the manifest, the slice system, and the compile-time rejection of className/style. What’s notable, looking at the commit history rather than just the code, is that the project’s own development process ran on the same principle, just implemented socially instead of architecturally.

    The commit messages show a recognizable pattern, not just “AI wrote the code”:

    • “claude review session” → “apply claude review patches” (Aug 11–12) — Claude wasn’t only generating code on request; it was reviewing existing work and flagging what needed to change, with the findings applied as a distinct, later step. That’s a critic role, not just an executor role.
    • “defect campaign” / “claude fix campaign” — rounds of finding and fixing issues, implying something was actively surfacing problems that then got worked through systematically rather than everything landing correctly on the first pass.
    • “taste-test patch” — the most telling one. Nothing here was broken; someone used the toolkit and felt something was off, then corrected it on that basis. That’s a distinctly human contribution no amount of automated or AI review catches on its own, because the thing being corrected wasn’t wrong — it just wasn’t right yet.

    That same loop — generate, review, catch what review missed, apply judgment the review can’t reach — is structurally identical to what produced the corrections in this very report: several claims here (that a one-off styling escape hatch didn’t exist, that the className failure mode was a hard stop rather than a caught error, that the “wasted turns” comparison favored shadcn) were wrong on first pass and only fixed because someone with direct knowledge of the codebase caught them.

    Worth being precise about the framing, though: not “co-equal,” but two non-overlapping failure modes catching each other. Claude can review a large diff for internal consistency at a speed and breadth no solo human matches. It cannot tell you that padding feels wrong, or that a friction point encountered while actually using the toolkit wasn’t reflected in the manifest — that requires the lived experience of building with the thing, which is irreducibly a human contribution. Neither role subsumes the other; “co-equal” risks implying interchangeable, when the more accurate claim is that each role catches errors the other structurally cannot.

    The result is a tighter form of dogfooding than “an AI tool built using AI.” Toolcrib enforces this discipline architecturally for the UI code it produces — the manifest and slice system are a structural stand-in for “someone has to catch drift, so make the compiler do it instead of hoping a human notices.” The project’s own development process enforced the same discipline socially — human-AI review loops standing in for what the manifest does mechanically at the component level. Same founding insight, expressed twice, at two different layers of the same project.

    10. Bottom Line

    Toolcrib’s core idea — remove the model’s opportunity to regenerate (and silently diverge on) decisions that need to stay consistent, whether that’s a CSS class or a z-index value or an overlay’s unmount timing — is a legitimate and clever response to a real problem with AI-driven UI work, and it’s a broader idea than the styling story alone: the shared infrastructure layer (event bus, observer manager, z-index scale, SSR-safe IDs, animation lifecycle) closes a real class of behavioral bugs the no-className rule doesn’t even address. The CLI’s merge/patch/conflict tooling is arguably more thoughtfully engineered than shadcn’s for long-term maintenance, too. But it’s a solo-built, 10-day-old project with roughly two-thirds the component coverage of shadcn/ui and none of shadcn’s community or production track record. Treat it as a promising experiment worth watching or piloting on a low-stakes project, not yet as a like-for-like alternative to shadcn/ui, Mantine, Chakra, or MUI for anything that needs to be safe to build a real product on today.