feat(web): design system foundation — Tailwind v4, semantic OKLCH tokens, enforced contrast, UI primitives - #763
Merged
Merged
Conversation
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.
Deploying paddock with
|
| Latest commit: |
fbd48ab
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://dec3c6dd.paddock-7u2.pages.dev |
| Branch Preview URL: | https://design-foundation.paddock-7u2.pages.dev |
This was referenced Aug 8, 2026
Merged
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Paddock has never had a visual design document. All eight
DESIGN-*.mdfiles arearchitecture, and the visual language lived entirely in
tailwind.config.jsand a494-line
index.css. That is how the app arrived at 1017 raw palette-step uses,722 hand-written
dark:pairs, 200 arbitrarytext-[Npx]values, zero shared Reactprimitives — 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.
main.field-labelThe button fix is why the primary fill is
--accent-600rather than the raw brandaccent — the accent stays the brand colour everywhere it does not carry text.
Contrast is enforced, not asserted
packages/web/src/styles/tokens.test.tsparses the real stylesheet — resolvingoklch(),rgb(var(--x)),color-mix(in oklab, …)andvar()chains through a newsrc/lib/color.ts— and fails the build if, in either mode:--text/--text-muted/--text-subtle/--accent-textdrops below4.5:1 on any of the six surfaces;
-soft;-fgdrops below 4.5:1 on its-solid;--border-strongdrops below 3:1 (WCAG 1.4.11);oklch()value falls outside the sRGB gamut — it would clip silently in thebrowser and quietly lose the contrast you thought you had;
PADDOCK_BRAND_ACCENTseam (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], notransition-allor bareoutlinefocus ring.The numbers
main*-paddock-Nusesdark:variantstext-[Npx]The mode swap now happens once, at
:root/.darkinpackages/web/src/styles/tokens.css.Tailwind 3.4.13 → 4.3.3
@tailwindcss/vite;tailwind.config.jsandpostcss.config.jsdeleted;autoprefixerandpostcssdropped. Configuration is CSS now. Thecan-hover:variant is preserved (it exists because iOS Safari applies a sticky
:hoverafter a tap), and
dark:is restored as a class variant.Deliberate changes beyond the refactor
isWide()was called with the liveper-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.
yet.", …) become
EmptyStates. A fresh project's Home was five identical voidsin one viewport with no next step.
before — eight ad-hoc overlays, zero focus traps, and two menus that claimed
role="menu"without implementing it.fade-inwas 250 ms with atranslate, firing on a 100+/day event.
prefers-reduced-motionnow collapsesevery transition.
text-ink-subtlewas a dead class — never defined, so ~12 spans in thetranscript were silently inheriting their parent's colour. They now render as
intended.
html-preview): they render anarbitrary 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
EmptyStatehas call sites so far (Home's five stacked voids). The otherseventeen 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.tsxpins the things theydo that the ad-hoc markup they replace did not —
Dialog's focus trap andfocus restore,
Menu's arrow-key roving and outside-click dismissal,Field'saria-describedbywiring,Button's loading/disabled coupling. Class stringsare 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, theprimitives, 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.mdgained aUI-conventions section pointing at it so this does not decay again.
Verification
typecheckclean · 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/webis touched) and is left to CI, which runs it ina 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 replacedo 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 fromthe row's
text-sm— totext-2xs, which carries its own 16px. That shortenedthe 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-0rename button. Fixed with an explicitleading-5and a comment saying why.Two follow-ups that fix belongs to, both pre-existing on
main: a 1px clearanceis not a design, and the real fix is that
opacity-0action buttons should notintercept 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 forthe 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.