Skip to content

feat(web): design system foundation — Tailwind v4, semantic OKLCH tokens, enforced contrast, UI primitives - #763

Merged
edspencer merged 4 commits into
mainfrom
design/foundation
Aug 9, 2026
Merged

feat(web): design system foundation — Tailwind v4, semantic OKLCH tokens, enforced contrast, UI primitives#763
edspencer merged 4 commits into
mainfrom
design/foundation

Conversation

@edspencer

@edspencer edspencer commented Aug 8, 2026

Copy link
Copy Markdown
Owner

Paddock has never had a visual design document. All eight DESIGN-*.md files are
architecture, and the visual language lived entirely in tailwind.config.js and a
494-line index.css. That is how the app arrived at 1017 raw palette-step uses,
722 hand-written dark: pairs, 200 arbitrary text-[Npx] values, zero shared React
primitives
— and a light mode that measurably failed WCAG AA at its four
most-used tokens.

This PR is the foundation: a semantic token layer, an enforced contrast floor, a
primitive library, and the document. It is a refactor — the app looks
essentially unchanged
, apart from a short list of deliberate fixes below.

Light mode now passes AA

One ramp had been tuned against a dark canvas and reused unchanged against a light
one. Light and dark are now derived separately, in OKLCH.

token uses on main now (light / dark)
help text, .field-label 96 3.75:1 6.71 / 6.62
muted text + placeholder 163 2.81:1 4.90 / 4.87
primary button, white on accent 40 4.17:1 5.53 / 5.53

The button fix is why the primary fill is --accent-600 rather than the raw brand
accent — the accent stays the brand colour everywhere it does not carry text.

Contrast is enforced, not asserted

packages/web/src/styles/tokens.test.ts parses the real stylesheet — resolving
oklch(), rgb(var(--x)), color-mix(in oklab, …) and var() chains through a new
src/lib/color.ts — and fails the build if, in either mode:

  • any of --text / --text-muted / --text-subtle / --accent-text drops below
    4.5:1 on any of the six surfaces;
  • any status hue's text drops below 4.5:1 on a surface or on its own -soft;
  • any -fg drops below 4.5:1 on its -solid;
  • --border-strong drops below 3:1 (WCAG 1.4.11);
  • an oklch() value falls outside the sRGB gamut — it would clip silently in the
    browser and quietly lose the contrast you thought you had;
  • an accent token stops deriving from the PADDOCK_BRAND_ACCENT seam (Configurable per-instance branding: title, logo, and action-button (accent) color #34).

This is what makes a future theme trustworthy rather than an opinion. It also holds
the three "never again" lint rules: no literal hex or palette step in component
code, no arbitrary text-[Npx], no transition-all or bare outline focus ring.

The numbers

main this PR
raw *-paddock-N uses 1017 0
hand-written dark: variants 722 0
arbitrary text-[Npx] 200 0
shared UI primitives 0 18

The mode swap now happens once, at :root / .dark in
packages/web/src/styles/tokens.css.

Tailwind 3.4.13 → 4.3.3

@tailwindcss/vite; tailwind.config.js and postcss.config.js deleted;
autoprefixer and postcss dropped. Configuration is CSS now. The can-hover:
variant is preserved (it exists because iOS Safari applies a sticky :hover
after a tap), and dark: is restored as a class variant.

Deliberate changes beyond the refactor

  • Config no longer reflows while you type. isWide() was called with the live
    per-keystroke value, so crossing 38 characters jumped a field from half to full
    width and re-packed every field after it, moving the caret under the user. It now
    reads the saved baseline.
  • Home's five stacked grey-italic boxes ("No files yet.", "No OVERVIEW.md
    yet.", …) become EmptyStates. A fresh project's Home was five identical voids
    in one viewport with no next step.
  • Dialogs trap and restore focus; menus support arrow keys. Neither worked
    before — eight ad-hoc overlays, zero focus traps, and two menus that claimed
    role="menu" without implementing it.
  • Chat messages no longer animate in. The old fade-in was 250 ms with a
    translate
    , firing on a 100+/day event. prefers-reduced-motion now collapses
    every transition.
  • text-ink-subtle was a dead class — never defined, so ~12 spans in the
    transcript were silently inheriting their parent's colour. They now render as
    intended.
  • The two untrusted-HTML iframes are pinned light (html-preview): they render an
    arbitrary document that assumes a light page, so theming them would leave dark
    text on a dark surface.

On the primitives: shipped, tested, mostly not yet adopted

Only EmptyState has call sites so far (Home's five stacked voids). The other
seventeen exist because they are the seam four parallel visual directions are
about to build on
— a direction should be able to restyle every button in the
app by editing one variant string. Adopting them across 46 component files in
this PR would have made an already-large diff unreviewable and would have
turned a refactor into a behaviour change.

They are not untested, though: components/ui/ui.test.tsx pins the things they
do that the ad-hoc markup they replace did not — Dialog's focus trap and
focus restore, Menu's arrow-key roving and outside-click dismissal, Field's
aria-describedby wiring, Button's loading/disabled coupling. Class strings
are deliberately not asserted: a direction is expected to change every one
of them, so naming them in a test would just be a tax. Colour is covered by
ratio rather than by name, in tokens.test.ts.

The document

docs/DESIGN.md — token architecture and naming rules, the five scales, the
primitives, the craft floor, a "Reject this" section aimed squarely at a future
coding agent (the three documented default-AI-aesthetic clusters plus the local
rules), and a step-by-step "How to add a direction". CLAUDE.md gained a
UI-conventions section pointing at it so this does not decay again.

Verification

typecheck clean · web suite 1064/1064 · E2E 88/88 · build green.
Eyeballed in both modes, on desktop and at a dense chat transcript, against a
credential-free preview instance. The server suite is unchanged by this PR
(nothing outside packages/web is touched) and is left to CI, which runs it in
a clean environment — locally it false-fails here from this box's leaked
PADDOCK_* env.

One E2E test caught a real regression, worth recording because it will recur.
The type scale's rungs carry line-heights; the text-[Npx] values they replace
do not (an arbitrary Tailwind font-size sets no line-height and inherits the
parent's). The chat row's timestamp went text-[11px] — inheriting 20px from
the row's text-sm — to text-2xs, which carries its own 16px. That shortened
the row 58px -> 54px, moving its vertical centre, which is where a click
lands
, from 1px above the hover action strip to 1px inside it. Clicking a chat
started hitting an invisible opacity-0 rename button. Fixed with an explicit
leading-5 and a comment saying why.

Two follow-ups that fix belongs to, both pre-existing on main: a 1px clearance
is not a design, and the real fix is that opacity-0 action buttons should not
intercept pointer events — which also means a touch user tapping the
bottom-right of a chat row today hits a hidden rename/archive/delete rather than
the row.

Per-instance branding (PADDOCK_BRAND_ACCENT) is unchanged and covered by a test.
The one known limitation, documented in docs/DESIGN.md: contrast is verified for
the default accent — an operator who sets a pale brand colour can still drive
white-on-accent below 4.5:1, and nothing at build time can catch it.

Paddock added 4 commits August 7, 2026 19:48
…mitives

Foundation for the design system. No component migrated yet — the compat
palette in index.css keeps the existing 1017 raw `paddock-N` uses rendering
while the sweep lands.

- Tailwind 3.4.13 -> 4.3.3 via @tailwindcss/vite; tailwind.config.js and
  postcss.config.js deleted, autoprefixer/postcss dropped. `can-hover:` and
  class-based `dark:` preserved as @custom-variant.
- styles/tokens.css: OKLCH semantic tokens, light and dark ramps derived
  separately (the old palette reused one ramp for both, which is why light mode
  measured 3.75:1 at its most-used token).
- lib/color.ts + styles/tokens.test.ts: parses the real stylesheet and asserts
  every text-on-surface pair at 4.5:1 in both modes, plus gamut and the
  PADDOCK_BRAND_ACCENT seam (#34).
- components/ui/: Button, Card, Section, EmptyState, Input, Textarea, Select,
  Field, Label, Hint, Toggle, Checkbox, Chip, StatusDot, Callout, Dialog, Menu.
The sweep: 1017 raw `*-paddock-N` uses -> 0, 200 arbitrary `text-[Npx]` -> 0,
722 hand-written `dark:` variants -> 0. Every colour in every route and pane
now names a meaning, and the light/dark swap happens once, at `:root`/`.dark`.

The compat palette is deleted and the guard test asserts it stays deleted.

Deliberate, listed changes beyond the pure refactor:
- Config no longer reflows while you type. `isWide()` was called with the live
  per-keystroke value, so crossing 38 characters jumped a field from half to
  full width and re-packed every field after it. It now reads the saved
  baseline, so width depends on the field's identity, not the in-flight edit.
- Home's five stacked grey-italic boxes become `EmptyState`s.
- Dialogs get a focus trap and focus restoration; menus get arrow-key nav.
- Chat messages no longer animate in; `prefers-reduced-motion` is honoured.
- `text-ink-subtle` was a dead class (never defined), so ~12 spans in the
  transcript were silently inheriting their parent's colour. They now render.
- The HTML-preview iframes are pinned light (`html-preview`): they render an
  arbitrary document that assumes a light page, so theming them would leave
  dark text on a dark surface.
…row clicks

The type scale carries line-heights; the arbitrary `text-[11px]` it replaced did
not, and inherited 20px from the row's `text-sm`. That shortened the chat row
from 58px to 54px, which moved the row's vertical centre — where a click lands —
1px INSIDE the absolutely-positioned action strip, so clicking a chat hit an
invisible `opacity-0` rename button instead of opening the chat.

Measured, not guessed: on main the centre clears the strip by exactly one pixel.
`leading-5` restores that. The comment says so, because the next person to
tighten this line-height will otherwise rediscover it.

The 1px margin is pre-existing and far too tight — the real fix is that the
invisible buttons should not intercept pointer events at all, which also means a
touch user currently cannot tap the bottom-right of a row without hitting a
hidden action. Out of scope for a refactor; filed for follow-up.

Caught by journey-chat.spec.ts's resume test.
The primitives shipped with one consumer (HomePane's EmptyState) and no tests,
which is a fair thing to object to. These cover what the primitives do that the
ad-hoc markup they replace did NOT: Dialog's focus trap and focus restore,
Menu's arrow-key roving and outside-click dismissal, Field's aria-describedby
wiring, Button's loading/disabled coupling. Class strings are deliberately not
asserted — a direction is expected to change every one of them, and colour is
covered by ratio rather than by name in tokens.test.ts.

Also: the colour-discipline rules scanned raw lines, so a comment EXPLAINING a
banned pattern tripped the rule that motivated it — the new note on the chat
row's timestamp quotes the `text-[11px]` it replaced and failed the build. They
now blank block comments in place (newlines preserved, so line numbers stay
true) and drop `//` lines. Verified both directions: prose is ignored, and a
real `text-[13px]` in a component is still reported at its exact line.
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Aug 8, 2026

Copy link
Copy Markdown

Deploying paddock with  Cloudflare Pages  Cloudflare Pages

Latest commit: fbd48ab
Status: ✅  Deploy successful!
Preview URL: https://dec3c6dd.paddock-7u2.pages.dev
Branch Preview URL: https://design-foundation.paddock-7u2.pages.dev

View logs

@edspencer
edspencer merged commit 67e493f into main Aug 9, 2026
5 checks passed
@edspencer
edspencer deleted the design/foundation branch August 9, 2026 13:14
@github-actions github-actions Bot mentioned this pull request Aug 9, 2026
edspencer pushed a commit that referenced this pull request Aug 9, 2026
Foundation (#763) landed on main as a squash, so the four original
foundation commits on this branch collided with their own content under
a new SHA. All three conflicts were spurious: main's version of each
file was byte-identical to this branch's version before its unique
commit, so the branch side is main's content plus the Config restructure.

Verified: the merge result differs from origin/main by exactly the
restructure commit's four files.
edspencer pushed a commit that referenced this pull request Aug 9, 2026
Both #763 (foundation) and #768 (Config restructure) landed on main as
squashes, so this branch's four foundation commits collided with their
own content under new SHAs.

Four conflicts, each resolved by proof rather than by a blanket side-pick:
- HomePane.tsx: main's copy is byte-identical to this branch's foundation
  tip, so the branch side is main's content plus the empty states -> ours.
- InstanceConfigForm.tsx, SettingsPane.tsx, ui/Surfaces.tsx: this branch
  never touched them (its copies are byte-identical to the foundation
  tip), while main carries #768's restructure -> theirs.

A blanket 'take ours' here would have silently reverted #768.

Verified: the merge result differs from origin/main by exactly the empty
states commit's four files, and InstanceConfigForm.tsx matches main's.
edspencer pushed a commit that referenced this pull request Aug 9, 2026
main now carries three squashes this branch predates (#763 foundation,
#768 Config restructure, #769 Home empty states), so all four of this
branch's foundation commits collided with their own content.

Nine conflicts. Eight were mechanically decidable by comparing each side
against the foundation tip fbd48ab:
- ours   (main == foundation, branch adds): docs/DESIGN.md, index.css,
         lib/color.ts, styles/tokens.css, styles/tokens.test.ts
- theirs (branch == foundation, main adds): SettingsPane.tsx,
         ui/Surfaces.tsx, ProjectView/HomePane.tsx

The ninth, InstanceConfigForm.tsx, is the only genuinely contested file,
and is resolved as a GRAFT rather than a side-pick: #768's restructure is
the structure, and this branch's Appearance section is applied onto it.
Concretely, main's copy is the base and this branch's own +81/-7 against
the foundation tip is applied over it. Everything #768 built - the single
-column rows, the fixed control slot, the absolutely-positioned dirty
marker, SectionScroller - is retained; the only lines removed from main's
copy are the five visibleGroups -> railGroups rewires and the field-count
span, which becomes conditional so a zero-field Appearance shows no '0'.

SectionScroller is pointed at railGroups too. It is the narrow-viewport
form of the same rail and postdates this branch, so it was still on the
unprefixed list: without this, Appearance appears in the rail above
1024px and silently vanishes below it.

docs/DESIGN.md was an add/add. Taking this branch's copy is the content
merge rather than a side-pick: it descends from the same foundation text,
and each line it drops is deliberately superseded (the 'baseline theme is
deliberately neutral' paragraph becomes the four-theme table). The §4
rule about compositing and painted pixels is present.
edspencer pushed a commit that referenced this pull request Aug 9, 2026
This branch is stacked on #780, which has now been squash-merged along
with #763, #768 and #769 — so all four squashes' content collided with
the original commits still carried here.

Four conflicts, all decided the same way: this branch never touched any
of them (each is byte-identical to its copy at 49bdda4, #780's head), so
main's landed version wins. That matters for InstanceConfigForm.tsx in
particular: main carries the reconciled graft of #768's restructure with
#780's Appearance section, and this branch only has #780's pre-graft
copy. Taking theirs keeps the graft, SectionScroller on railGroups, and
the explicit sectionDomId the rail jumps to.

Verified: all four files are byte-identical to main's, and the merge
result differs from main by exactly this branch's own fleet-readout work.

The readout is outside <main> and renders no <h3>, so it does not
disturb the Home section-order assertions that landed with #769.
edspencer pushed a commit that referenced this pull request Aug 10, 2026
root-home, adopt-row, adopt-modal, project-settings and the trigger tool
picker were all shot before the design work landed (#763/#768/#769/#780/#801).
Every one showed chrome, surfaces, type and accent the redesign replaced, on
pages whose prose is in the present tense.

Shot from the docs-media rig at v0.69.0, Foundation/dark, hue null, tint 0 —
the out-of-the-box appearance.  capture.mjs now pins the theme via
addInitScript (the keys are read by a pre-paint script, so a post-goto
evaluate catches a mid-swap frame) and asserts it applied.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant