0131 — PropFlow ships its UI as CSS, so other surfaces can consume it instead of re-typing it
- Status: Accepted
- Date: 2026-09-03
- Deciders: Gera (raised it), Fede (merged #6894, #6908)
- Supersedes nothing. Related: the retired
vendor/datatable-syncbridge (2026-08-04).
Context
docs.propflowai.co mirrors the product's data table. Measured on 2026-09-03,
that mirror was three files and only the first was genuinely shared:
| file | lines | how it got there |
|---|---|---|
app-verbatim.css |
56 | copied byte-for-byte by bin/sync-mirror |
data-table.css |
918 | transcribed by hand out of DataTableCore.tsx |
data-table.js |
127 | behaviour, rewritten |
5% copied, 83% hand-typed. Not for want of a pipe: sync-mirror already
copies and --check already fails CI on drift. There was almost nothing in the
app for it to copy, because the table's styling lived in 87 inline
style={{…}} objects and 41 Tailwind className usages against 12 real CSS
rules. A stylesheet cannot copy a React style object.
Every visual gap found in the mirror on 2026-09-02/03 traced to a value living
somewhere CSS could not reach — LinkCell's inline colour, the <thead>'s
inline background, the card composition in JSX, a pagination window that is a
TypeScript algorithm. None was a typo.
Two failure modes showed up that are specific to styling held in JavaScript, and both had shipped:
TableSearch's focus ring had never rendered. The look was driven by a Reactfocusedflag. Onmain, clicking the input gavedocument.activeElement === the inputwhile its inline style stayed byte-identical: grey border,box-shadow: none, faint icon — focused. The state never reached the DOM, so the styling the component's own code asked for silently never happened.FilterBar's Clear button did hover in JavaScript, two handlers writingelement.styleon mouse enter/leave — working, unreachable to any other surface, and fighting thetransition-colorsclass beside it.
A stylesheet cannot have the first failure at all: :focus either matches or it
does not.
Decision
Component styling that can be static CSS is written as CSS classes in
globals.css, not as inline style objects. Inline style stays for what is
genuinely per-instance: a column's textAlign and width, a dragged column's
overflow, a computed popover's top/left, a container's dynamic scope.
The distribution channel is the one that already exists — propflow-docs CI
sparse-checks-out this repo (that is how ADRs and the brand font travel), and
bin/sync-mirror copies the CSS with --check gating drift. No package, no
registry, no bundler. An npm package was rejected: this repo has no
workspaces, and propflow-docs has no bundler and no React (its dependencies
are marked, mermaid, wrangler), with ~180 hand-written HTML artifacts
that could never import a component.
This is idiomatic under the Tailwind v4 already in use — v4 is CSS-first — and
it extends a pattern that was already here: the .dt-row / .dt-link-cell /
.dt-col-sticky rules are exactly what the mirror copies today.
How it was made safe
An inline style outranks any selector. A class and an inline style declaring the same property is therefore inert, which splits the migration into two independently safe steps — add the class (a provable no-op), then remove the inline declaration — where a mistake in the second step is a revert of that step alone.
Each PR carried a computed-style capture (32→58 elements, 47 properties each)
taken against /leasing/prospects before and after, requiring zero
differences. The instrument was proven able to fail: changing .dt-td
padding from 7px to 9px reported 59 differences as the 2px cascaded into row
and card heights; restoring returned it to zero. A green check that has never
gone red is not evidence.
Where an element did not render on the reachable page (the Clear affordance needs an applied pill filter), its rule was measured directly rather than claimed untested.
Consequences
- The docs mirror consumes real bytes for the migrated surface; those rules stop drifting on their own and drift becomes a CI failure rather than something a reader notices.
- Two latent bugs are fixed as a side effect (the focus ring, the JS hover).
TableSearchloses auseStateand a re-render per focus change.- Class names are now part of the contract between this repo and the docs site.
Renaming
.dt-*,.pf-bar-*or.pf-pgn-*breaks a consumer;sync-mirror --checkis what makes that loud rather than silent. - Not everything moved, and that is deliberate. Tailwind utility classes in
JSX still carry geometry and remain uncopyable — converting them is a
separate decision, not an oversight.
Cardis excluded: it spreads a caller-suppliedstyleand wraps every card in the app, so moving its base styling changes precedence against callerclassNames far beyond the table. The popover's measurement spans stay inline because they are mechanism, not styling.
Alternatives considered
- npm package exporting the React component — rejected above; also the one
previous attempt at a bridge (
vendor/datatable-sync) was retired in August for serving one page with zero readers. - Web components, one implementation for every surface — the only option that would also share behaviour. Rejected for now: it only pays off if the product adopts them too, otherwise it is a third implementation to keep in sync, and behaviour is the part that changes least (127 lines).
- Leave it and keep transcribing — the status quo, which produced a wall of blue links, a white header band and a footer outside the card, none of which a reader could attribute to a mirror being hand-typed.
Follow-on
src/lib/brand/tokens.ts says outright that it and globals.css are "kept in
lock-step by hand because CSS can't import TS at build time." That is the
same class of problem — a value that must be remembered in two places will
eventually be wrong in one — and generating one from the other removes it.
Public write-up, with the measurements: https://docs.propflowai.co/a/app-ships-css-not-inline-styles.html