This is spec/screen-spec.md from the repo, converted as written. The write-up reports the builds against it.
Screen spec: UI library comparison
Status: settled, 2026-09-02. The questions the first draft left open are answered in section 13.
Demo document. The domain, the data, and the numbers below are fictional sample content, matching demo-resume.md.
1. Why this exists
Eight UI libraries build the same screen so they can be compared on ergonomics, bundle size, and accessibility defaults. The comparison only holds if the screen is identical, so this spec is the fixed input. A library that cannot meet a requirement fails that requirement rather than changing it.
The eight are shadcn/ui, Material UI, Chakra UI, Ant Design, Headless UI, Vuetify, PrimeVue, and Quasar. The first five build in React, the last three in Vue 3. Every build uses TypeScript, Vite, Jest, and Biome.
2. The screen
One route, /tickets, holding a filterable support ticket table, a filter form above it, a record modal, and toast notifications. No navigation shell, no auth, no routing beyond the single page. Anything a library gives for free counts in its favor and must still meet the requirements here.
3. Data
Rows come from a fixed fixture file checked into each build, the same 240 tickets in the same order, so bundle and render numbers compare directly. No network calls.
A ticket has:
id, string, formatTCK-0001, unique, displayedsubject, string, 1 to 120 characters, displayedstatus, one ofopen,pending,resolved,closed, displayed as a badgepriority, one oflow,normal,high,urgent, displayed as a badgeassignee, string or null, a person's name, null renders asUnassignedcreatedAt, ISO 8601 date, displayed asMMM d, yyyyupdatedAt, ISO 8601 date, displayed as a relative time such as3 days ago
4. Table
- Columns in order: ID, Subject, Status, Priority, Assignee, Created, Updated
- Sortable columns: ID, Subject, Created, Updated. Status and Priority are not sortable
- One sort at a time, ascending then descending then back to the default, which is
updatedAtdescending - The active sort column shows a direction indicator and carries
aria-sort - Pagination is client side, 25 rows per page, with first, previous, next, and last controls and a
Showing 1 to 25 of 240count - Changing a filter resets to page 1
- Clicking a row opens the record modal for that ticket. The row is reachable by keyboard and opens on Enter and Space
5. Filter form
Sits above the table and filters on every keystroke or change, with no submit button.
Search, text input, case insensitive substring match againstidandsubject, debounced 250msStatus, multi-select, no selection means all, multiple selections are an OR within the fieldPriority, multi-select, same rulesAssignee, single select of the names present in the fixture, plus anUnassignedoptionCreated between, two date inputs, either one may be left empty, the range is inclusiveClear filters, button, resets all five fields and returns to page 1, disabled when nothing is set
Fields combine with AND across the five, OR within a field. The active filter count shows next to the Clear button.
6. Record modal
Opens from a row click and edits that ticket.
- Title reads the ticket
idandsubject - Editable fields:
subjecttext input,statusselect,priorityselect,assigneeselect includingUnassigned - Read only:
id,createdAt,updatedAt - Validation:
subjectis required and capped at 120 characters, with the error shown under the field and announced.statusandprioritymust be one of their allowed values Saveapplies the change to the in memory row and closes.Canceland Escape discard and close- Closing with unsaved changes asks for confirmation first. An untouched form closes without asking
- Focus moves to the first field on open, is trapped inside while open, and returns to the originating row on close
- The dialog carries
role="dialog",aria-modal="true", and a label tied to its title
7. Toasts
- Save succeeds:
Ticket TCK-0001 updated, polite, dismisses after 4 seconds - Save fails validation: no toast, the field error carries it
- Filters cleared:
Filters cleared, polite, 3 seconds - A toast is dismissable by button and by Escape while focused
- Toasts stack to a maximum of 3, oldest dropped first
- The container is a live region,
aria-live="polite", present in the DOM before the first toast
8. Table states
- Loading: a skeleton of 5 rows held for a fixed 600ms on first mount, so every build shows the state
- Empty fixture:
No tickets yet, with no filter controls disabled - Empty after filtering:
No tickets match these filters, with aClear filtersaction inside the message - Error: a fixture flag forces a failure, showing
Could not load ticketsand aRetrybutton
9. Accessibility
Every build meets WCAG 2.2 AA. These are the specific requirements, not the whole standard.
- Semantic HTML first, ARIA only where no element carries the meaning
- Every control reachable and operable by keyboard, with a visible focus indicator at 3:1 contrast against its background
- Tab order follows the visual order: filter form, table, pagination
- The table uses real
tablesemantics with acaptionor anaria-label - Sort state announced through
aria-sort, page changes announced in a live region - axe-core runs against each build in CI with zero violations at the
seriousandcriticallevels - A manual pass through NVDA on Windows and VoiceOver on macOS, covering filter, sort, page, open modal, edit, save
10. Scoring
Each build gets a number in four categories, published in the write-up with the reasoning.
- Bundle size, measured twice. The total is the production build gzipped, application code plus library code, excluding the fixture. The delta is that total minus a framework baseline, a minimal app that mounts React or Vue and renders nothing else. The delta is the number compared across all eight, since a total carrying React or Vue is not a library measurement. Budget is 180 KB on the total, and a build over it fails rather than scoring low
- Accessibility defaults, measured: axe-core violations before any manual fix, plus the number of requirements in section 9 the library needed custom code to meet
- Ergonomics, counted rather than judged: lines of application code for the screen, number of library imports, number of type assertions or
anyneeded, and whether the modal, select, and toast came from the library or had to be hand built - Time to first render, measured: Lighthouse on the deployed static build, median of 5 runs
Ergonomics is the only category that risks being opinion, so it is reported as those four counts, and the write-up draws the conclusion in prose rather than the score doing it.
Results are published in two groups, the five React builds and the three Vue builds. The delta is the only number quoted across the groups. Inside the write-up, shadcn/ui and Headless UI are labeled assembly kits rather than component suites, so their higher hand built counts read as the trade they are and not as a loss.
The write-up names no single winner. It publishes the numbers and picks per situation, a suite when speed matters and an assembly kit when control does.
11. Acceptance criteria
Each criterion is one Jest test per build, named for its number, so a build passes or fails the same list.
- The table renders 25 rows on first paint and reports 240 total
- Sorting by Subject ascending puts
AbeforeZ, and clicking twice reverses it - Sorting sets
aria-sorton the active column and clears it on the others - Searching
TCK-0007narrows to one row - Selecting
openandpendingin Status shows only those two statuses - Status
openwith Priorityurgentshows only rows matching both - A Created range excludes rows outside it and includes rows on the boundary dates
- Clear filters restores 240 rows, resets all five fields, and disables itself
- Changing a filter while on page 4 returns to page 1
- Enter on a focused row opens the modal for that row
- Saving an empty subject blocks the save and shows the field error
- Saving a valid change updates the row and fires one success toast
- Escape on a touched form asks for confirmation, on an untouched form closes
- Focus returns to the originating row after the modal closes
- Filtering to no matches shows the empty state with a working Clear action
- The forced error state shows the message and a Retry that reloads the fixture
- axe-core reports zero serious or critical violations on the loaded table
- axe-core reports zero serious or critical violations with the modal open
12. Out of scope
No authentication, no server, no persistence beyond the page session, no dark mode, no internationalization, no mobile specific layout below 768px, and no animation requirements. A library that ships any of these gets a mention in the write-up and no score for it.
13. Decisions
These were the open questions in the first draft. All seven were settled on 2026-09-02, and the reasoning is kept here so nobody reopens them cold.
- Domain stays
/tickets. Support tickets overlap none of the other seven projects, and the shape carries the filters, badges, and modal the comparison needs. - Budget is 180 KB gzipped on the total, not 120 KB. At 120 KB the suites fail on framework weight rather than on anything they chose, which measures React and Vue instead of the libraries. The delta in section 10 does the real comparing.
- Two reporting groups, React and Vue, with the delta spanning both. A single table comparing shadcn/ui against Quasar would fold framework cost into the library number.
- Headless UI runs all 18 acceptance criteria like the rest. It is labeled an assembly kit alongside shadcn/ui, so what it does not ship shows up as a hand built count and not as a failure.
- No winner. The numbers publish, and the conclusion is a pick per situation.
- Jest, not Vitest. It is deliberate here even though the rest of the work uses Vitest.
- The write-up shows the fixture generator and 10 sample rows, not all 240. Enough to reproduce the run, short enough to read.
14. Repo layout
One repo, a pnpm workspace, with eight builds as siblings off a shared spec, fixture, and test suite. Everything lives here. There are no per library repos, the write-up has no separate home, and the demo sites publish from this repo's CI. The comparison rests on one commit holding all eight builds, the spec they were measured against, and the results that came out, so a reader can check any number against the code that produced it.
ui-library-comparison/
spec/screen-spec.md this document, the fixed input
packages/fixture/ the 240 tickets and the generator, one source for all ten apps
packages/criteria/ the 18 acceptance criteria as shared Jest suites
packages/harness/ the adapter interface each build implements
builds/react-shadcn/ eight builds, same package.json shape and scripts
builds/react-mui/
builds/react-chakra/
builds/react-antd/
builds/react-headless/
builds/vue-vuetify/
builds/vue-primevue/
builds/vue-quasar/
baselines/react/ mounts the framework, renders nothing, feeds the delta
baselines/vue/
scripts/measure.ts gzip size, delta, Lighthouse, axe-core, writes results/
results/ one committed JSON per build, read by the write-up
write-up/ the published article
.github/workflows/ci.yml
packages/criteria is the load bearing piece. The 18 tests in section 11 live once and run against every build, so each build ships an adapter answering questions like give me the row at index n and open the modal, rather than the tests reaching into Ant Design's DOM. Eight copies of the suite would drift within a week and the comparison would stop being fair.
Every build exposes the same four scripts, dev, build, test, and measure, so CI never special cases a library.
results/ is committed. A scoring run then shows up as a diff a reader can check, and the write-up reads its numbers from there instead of carrying them by hand.
CI runs a matrix over the ten apps: build, measure size against the budget, run the shared Jest suite, run axe-core, run Lighthouse. It publishes the eight demo builds as static sites alongside the write-up. The two baselines are measured and never published.
15. Parallel work
The eight builds are independent, so they can run at the same time, whether by people or by agents. The slice is one build, not one feature. An agent takes a library from empty folder through the screen, the adapter, the 18 criteria, and a measure run that lands its results file. A feature slice such as filtering would put eight workers inside the same eight folders and turn every merge into a conflict.
Two phases, in order.
Phase one, one owner, nothing parallel. Sections 1 to 13 of this document, packages/fixture, packages/criteria, and the adapter interface in packages/harness. These are the fixed input, and a comparison where they moved partway through is not a comparison. Nothing in builds/ starts until they are settled.
Phase two, eight workers, one build each. The baselines are a small separate task and belong to whoever owned phase one.
The rules that keep phase two honest:
- A worker writes only inside its own
builds/<name>/folder and its ownresults/<name>.json. Separate files per build mean eight writers never touch the same line - Nobody edits
packages/criteriaorpackages/harness. A test that seems wrong goes back to the owner as a question, because a criterion patched locally silently ends the comparison - A library that cannot meet a requirement records the failure and moves on. Section 1 already says the spec does not bend, and that holds harder when eight workers each have a reason to bend it
- Each build lands as its own pull request, and CI runs the shared suite against it
- The write-up is written once, after all eight results files exist, by one owner reading
results/