The app should ship CSS, not inline styles

Why the docs site can't just copy the product's table today, and the one upstream change that would let every surface reuse PropFlow's UI instead of re-typing it.

2026-09-03 · decided and shipped · ADR-0131 · Gera raised it, Fede merged it

It started at 5% copied. It is now 16%, and the parts that kept drifting are all on the copied side. The product styled its table with React inline-style objects, and a stylesheet cannot copy those — so they moved into CSS. Shipped as #6894, #6908 and #6918, recorded in ADR-0131.

The shape of the problem

The docs site mirrors the product's data table. That mirror is three files, and only the first one is genuinely shared:

copied byte-for-byte — 193 lines (was 56) transcribed by hand — 870 lines behaviour, rewritten — 127 lines

After the change, 2026-09-03: app-verbatim.css 193 · data-table.css 870 · data-table.js 127. Before: 56 / 918 / 127.

bin/sync-mirror copies the green slice out of the app and --check fails CI when it drifts. The mechanism was always sound — there was simply almost nothing in the app for it to copy. That is what changed.

Why — the styling isn't in the stylesheet

The product's table components carry their styling as inline React objects and Tailwind utility classes in JSX. Neither travels as CSS:

Componentinline style={{ }} beforeafterwhat moved
DataTableCore.tsx2626the static half of every th and td.dt-th / .dt-td. The count is unchanged because the objects remain — they now hold only per-column props.
FilterBar.tsx4337the bar's chrome → .pf-bar*; the popover's measurement spans stay, deliberately
TablePagination.tsx41its embedded stylesheet lifted whole; the one left is the container scope
TableSearch.tsx85the focus ring → :focus, and it started working
Card.tsx + DataTable.tsx66excluded — Card spreads a caller style and wraps every card in the app
globals.css.dt-* / .pf-*12 → 63 rules

87 inline style objects against 12 real CSS rules became 75 against 63. The 41 Tailwind utility usages are untouched: they carry geometry, they are equally uncopyable, and converting them is a separate decision rather than an oversight.

What it cost, concretely

Every visual gap found in the 2026-09-02/03 pass traced back to a style the mirror could not see. None was a typo; each was a value living somewhere CSS can't reach.

What was wrongWhere the real value lived
Cross-links rendered blue; the product's are deliberately neutralLinkCell.tsx — inline color
Header band white instead of #F4F4F6 greyDataTableCore.tsx:1704 — inline background on <thead>
Filter bar and pagination sat outside the cardDataTable.tsx:461-556 — JSX composition
Pagination showed 3 slots, not 7TablePagination.tsx:44-77 — a TypeScript algorithm
Current page rendered greyed outclass-name mismatch in the hand-written mirror

Why not just publish a package

The obvious answer is an npm package. Two facts rule it out:

Prior art: a React-importing bridge existed (vendor/datatable-sync) and was retired 2026-08-04 — it served one page and had zero readers.

The pipe already exists

Nothing new has to be built to move files. The docs build already sparse-checks-out the app — that is how ADRs arrive, and, since 2026-09-03, the brand font:

propflowaidocs/adr
public/fonts
deploy.ymlsparse-checkout, 1 line per path
build.mjscopies into dist/
docs.propflowai.coplus sync-mirror --check in CI

Adding a stylesheet to that list costs one line. The question was never distribution.

The proposal

Today

Styling lives in JSX. The mirror is a person reading .tsx and re-typing values into .pfdt-* rules.

Drift is found by eye, or by a reader saying "it still looks different."

Proposed

Styling lives in CSS classes in the app. The mirror is cp, and --check fails CI on drift.

Docs consumes the same bytes the product renders.

Tailwind v4 makes this the native pattern, not a workaround. The app is already on @import "tailwindcss" with @theme blocks — v4 is CSS-first, so authoring component styles as real CSS is the idiomatic move. And the pattern already exists in-repo: those 12 .dt-* rules are exactly what gets copied today. This widens something that works; it does not invent architecture.

What it buys, and what it doesn't

What it found on the way

Two bugs, both the same shape: styling held in JavaScript that was wired up correctly and still did not paint. Neither is possible in a stylesheet.

BugEvidenceFixed by
The search focus ring had never rendered. TableSearch drove it from a React focused flag. On main, clicking the input gives document.activeElement === the input while its inline style stays byte-identical — grey border, box-shadow: none, faint icon, while focused. :focus / :focus-within, with the values the code always intended
The Clear button did hover in JavaScript. Two handlers writing element.style.color and .backgroundColor on mouse enter/leave — unreachable to any other surface, and fighting the transition-colors class beside it. .pf-bar-clear:hover, verified to resolve to the same values

Scope

PhaseRepoWhat landsRisk
0 — the ADR donepropflowaiThe decision, written at decision time per ADR-0004. Next free number is 0131; verify at PR time, collisions have happened three times.none
1DataTableCore merged #6894propflowai26 inline objects → .dt-* classes in component CSS. Behaviour-neutral; the diff is style-location only.wide
2 — search, pagination, bar merged #6908, #6918propflowaiThe remaining 61 inline objects and 41 utility usages.wide
3 — widen the mirror donepropflow-docsSparse-checkout the new CSS, extend sync-mirror, delete the transcribed rules it replaces.low
4 — check composition, not just paint donepropflow-docsNow in bin/check-mirror, which already runs weekly: it reads DataTable.tsx to confirm the app still wraps FilterBar → DataTableCore → TablePagination in a <Card>, then asserts the docs card nests bar → table → footer in that order. Proven by reproducing the original bug — moving the footer outside the card — and watching it fail.low

Phases 1 and 2 touch every page with a table. They want their own PRs with before/after screenshots — never bundled into feature work.

Free adjacent win

src/lib/brand/tokens.ts says it plainly: it and globals.css are "kept in lock-step by hand because CSS can't import TS at build time." Generating one from the other removes that hand-sync too, and it is the same class of problem — a value that has to be remembered in two places will eventually be wrong in one.

Open questions

Numbers in this page were measured directly against both checkouts on 2026-09-03 and are reproducible: line counts via grep -vc '^\s*$', style counts via grep -c 'style={{' and grep -c 'className="'.

PropFlow Docs