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 docs site mirrors the product's data table. That mirror is three files, and only the first one is genuinely shared:
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.
The product's table components carry their styling as inline React objects and Tailwind utility classes in JSX. Neither travels as CSS:
| Component | inline style={{ }} before | after | what moved |
|---|---|---|---|
DataTableCore.tsx | 26 | 26 | the 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.tsx | 43 | 37 | the bar's chrome → .pf-bar*; the popover's measurement spans stay, deliberately |
TablePagination.tsx | 4 | 1 | its embedded stylesheet lifted whole; the one left is the container scope |
TableSearch.tsx | 8 | 5 | the focus ring → :focus, and it started working |
Card.tsx + DataTable.tsx | 6 | 6 | excluded — 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.
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 wrong | Where the real value lived |
|---|---|
| Cross-links rendered blue; the product's are deliberately neutral | LinkCell.tsx — inline color |
Header band white instead of #F4F4F6 grey | DataTableCore.tsx:1704 — inline background on <thead> |
| Filter bar and pagination sat outside the card | DataTable.tsx:461-556 — JSX composition |
| Pagination showed 3 slots, not 7 | TablePagination.tsx:44-77 — a TypeScript algorithm |
| Current page rendered greyed out | class-name mismatch in the hand-written mirror |
The obvious answer is an npm package. Two facts rule it out:
propflowai is not a monorepo. No workspaces, no packages/ directory. A package means a registry, versioning and release discipline that doesn't exist yet.propflow-docs has no bundler and no React. Its entire dependency list is marked, mermaid, wrangler; the build is node build.mjs emitting static HTML. And ~180 artifact pages are hand-written HTML that could never import a React component.Prior art: a React-importing bridge existed (vendor/datatable-sync) and was retired 2026-08-04 — it served one page and had zero readers.
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:
docs/adrpublic/fontsdist/sync-mirror --check in CIAdding a stylesheet to that list costs one line. The question was never distribution.
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."
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.
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.
| Bug | Evidence | Fixed 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 |
| Phase | Repo | What lands | Risk |
|---|---|---|---|
| 0 — the ADR done | propflowai | The decision, written at decision time per ADR-0004. Next free number is 0131; verify at PR time, collisions have happened three times. | none |
1 — DataTableCore merged #6894 | propflowai | 26 inline objects → .dt-* classes in component CSS. Behaviour-neutral; the diff is style-location only. | wide |
| 2 — search, pagination, bar merged #6908, #6918 | propflowai | The remaining 61 inline objects and 41 utility usages. | wide |
| 3 — widen the mirror done | propflow-docs | Sparse-checkout the new CSS, extend sync-mirror, delete the transcribed rules it replaces. | low |
| 4 — check composition, not just paint done | propflow-docs | Now 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.
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.
src/styles/components/<name>.css imported by globals.css), or keep growing the .dt-* block inside globals.css? The former generalises to nav, cards and pills; the latter needs no new convention.tokens.ts already names them, which argues for real CSS plus generated tokens rather than a docs-only fix.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="'.