0131 — PropFlow ships its UI as CSS, so other surfaces can consume it instead of re-typing it

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:

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

Alternatives considered

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