A common foundation for every surface
Status: proposal. Nothing here is binding yet. CLIENT_PORTAL_UI_RULES.md is the binding document; this argues for widening the scope of its sections 2β4 and names what would have to change to do that.
Stack conventions this must obeyβ
The frontend is a conventional 2026 React design-system stack: shadcn/ui (components.json),
Tailwind v4.3 CSS-first (@import "tailwindcss", @theme inline, @custom-variant dark),
Radix primitives, cva for variants, next-themes for the .dark class, TanStack
Table v8 headless.
Nothing below should invent a mechanism this stack already documents:
| Need | Canonical home here | Status |
|---|---|---|
| Semantic colour layer | shadcn's --background / --muted / --border tier, primitives β semantic β component | exists, incomplete |
| Token β utility | @theme inline in globals.css | exists, in use |
| Dark mode | next-themes + @custom-variant dark | exists |
| Per-surface theming | container-scoped custom properties (.hw-client-portal) | exists |
| Size / tone scales | a cva variant table on the one component | exists β use it, do not add parallel components |
| Menus, tabs, toggles | dropdown-menu.tsx, tabs.tsx, toggle.tsx (Radix) | exist |
| Table styling | headless table + CSS component classes (Tailwind v4 discourages @apply) | exists |
Two consequences worth stating plainly, because earlier revisions of this document got them wrong:
- A new size is a
cvavariant, never a new component. ShippingControlButtonbesideButtonis the same move that letui/Badge(20px, cool ramps) drift fromClientTag(24px, warm) β one component per role is the point of the cva model. - Tailwind v4 authors tokens in CSS. The
design-tokens.tsβscripts/generate-css-vars.tsβapp/design-tokens.cssβ@importchain is homegrown, and its own header records that a repo-wide sweep deleted the source as dead code and silently brokenpm run generate:tokens. See Β§4.4.
The questionβ
The client portal was rebuilt on its Figma frames in 40210c8ae, and that work produced a set of
primitives β ClientControlButton, ClientTag, ClientPageHeader, ClientEmptyState,
.hw-portal-eyebrow β plus a rulebook that governs them. The rulebook opens by disclaiming the
rest of the app:
Sections 1β5 are the client portal only. The Expert workspace and Ops surfaces have a different density and are explicitly not governed by the geometry here.
We now have all three surfaces as parseable Figma exports: 7 client frames, 54 Expert, 29 Superadmin. That premise is measurably false. The three surfaces already share one geometry. What differs is not the design β it is which code path each surface happens to render through.
Every number below is parsed from the SVG geometry in Figma/, not estimated from a render.
1. What is already commonβ
Measured across all three surfaces. No exceptions found.
| Client portal | Expert | Superadmin | |
|---|---|---|---|
| Nav rail | 214 | 214 | 214 (88 collapsed) |
| Top bar | 55 | 55 | 55 |
| Content region | 1226 Γ 845 at (214, 55) | same | same |
| Page gutter | 24 β content starts x=238 | same | same |
| Usable width | 1179 | 1179 | 1179 |
| Nav item pitch | 48 β 52, see below | 48 β 40, see below | 48 β 32, see below |
| Table header | 44 | 44 | 44 |
| Table row | 72 | 72 | 72 |
| Cell inset | 16 | 16 | 16 |
| Column header type | 12/400 uppercase, 0.04em | same | same |
| Status pill | 24 tall, r=12, 8 inset, 12/400 | same | same |
| Card | r=16, 1px #F4F0E8 | same | same |
| Card gutter | 15 | 15 | 15 |
| Menu / popover | r=10, 1px #EEEEEE | same | same |
| Modal | r=32, scrim #0C0B09 @ 10% | same | same |
| Avatar | 32 (also 44, 72) | 32 | 32 (also 40, 48, 72) |
| Titles | Source Serif 4 | same | same |
| UI text | Inter 12/14/16 | same | same |
The table geometry is the headline. Eight distinct tables across three surfaces are 44 / 72 / 16 β the client Home thread board, the Expert queue, and the Superadmin clients, payments, subscriptions, KB documents and feature-flag tables. Rule 4.2 already says this ("Home's thread board and the DS Table sheets agree on it independently"); what is new is that the surfaces the rule excludes agree with it too.
The card grid is a single system: 1179px of content, 15px gutters, radius 16, #F4F0E8 hairline.
Every card row in the product falls out of it β 2-up at 582, 3-up at 383, 4-up at 283.5.
Deviations from the framesβ
The table above read "no exceptions found" when it was measured. #5984 introduced two, both deliberate. Recorded here because without the record the next person reads the frames and reverts either one as a bug.
The rail carries a collapse control. Figma/expert/UI-SPEC.md Β§2 draws the left nav with no
collapse control, and SHELL_RAIL_HEADER_HEIGHT = 80 was derived from that absence β
shellGeometry.ts says so in its own comment. The control now sits on the rail's right border,
centred on the top bar's line, on all three surfaces: one control for a job that had two, in the
place that says what it acts on. Before this, /client used a rotating arrow in its top bar and
/workspace and /ops used a hamburger in theirs β different glyphs, different state models,
none of them attached to the thing being collapsed.
Only above 768px. Below it the rail is a closed off-canvas Sheet, and a control mounted on its
border goes off-screen with it (#1210), so /workspace and /ops keep a hamburger in the top bar
β MobileNavTrigger, which renders nothing on desktop exactly as SidebarRail renders nothing on
a phone. The two are complementary, not a reprise of the two-controls problem: exactly one exists
at any width.
Nav rows sit flush on the two dense rails. The frames put expert nav baselines 48px apart
(105 / 153 / 201 / 249 β¦), and the super-admin rail's 921px content height is measured on that
grid. On /workspace and /ops the rows keep their height and lose the space between them, so two
highlighted rows meet with no rail ground showing and square their corners at the join β
/workspace on a 40px pitch, /ops on 32. /ops was already off the 48 grid before this change
(its rows are the primitive's default h-8); bringing them up is a follow-up rather than something
widened into this one.
/client is the exception: 40px rows, 12px apart. Those two rails carry 12 and 18 rows and
read as a dense list; the client rail has three, and closing the gap left them crowded against a
rail with room to spare. It costs that surface nothing β the squared join only fires where hover
fills a row, and on the client portal only the current route fills.
2. What genuinely differsβ
Three things, and only three.
- Avatar sizes. The client portal uses 44 and 72 where Expert and Superadmin use 32. That is real β the client surface is person-centric, the operator surfaces are row-centric.
- Block controls.
48pxfull-width buttons appear only in the client portal (7 instances);56pxonly in Expert (6). Both are legitimate surface-specific affordances. - Chat. Message bubbles, composer and the Ask-Specialist thread exist only where there is a conversation. Not a density difference β a component that one surface does not have.
Nothing here justifies a separate geometry, a separate table, or a separate control scale.
The /ops rail comes up to the other two β #5894β
The follow-up named above is done. /ops rows were the Sidebar primitive's own defaults β a
32px box at r=6.4 (--radius * 0.8) with the primitive's own selected fill β where both other
rails draw the frames' 40px box, secondary label, lighter glyph and claret-edged pill. They now
read from components/layout/navGeometry.ts, which is where /workspace's copies moved so the
vocabulary has one home rather than three.
Rows stay flush rather than going to the frames' 48px pitch. The frames' 921px rail content is
measured on 18 rows; ours carries 24, and at 48 the rail scrolls at every viewport height. 40-flush
is what /workspace already chose for the other dense rail, so the two agree.
Still deferred to #5961, which owns rail IA: the frames' rail carries no icons, People and
Specialists are leaves rather than groups, the build names a group Specialist Studio where the
frame says Specialists, and the frame's System β Runtime runs has no route behind it.
Page headings, and the breadcrumb that displaced themβ
All 29 super-admin frames carry a page heading in Source Serif 4 at 24/500, at x=238 under the
top bar. 18 /ops pages were passing hideTitle to PageHeader on the stated rationale that
"breadcrumbs already communicate location".
The frames invert that: their top bar carries no breadcrumb at all β the only text in the band
is the identity β so the heading is the sole wayfinding in the content region. The heading is
restored on every /ops route; the breadcrumb stays, because it is separately-scored shell chrome
the other surfaces' frames do draw. Carrying both is a superset of either.
Two exceptions, both fillsViewport routes whose scroll containers measure available height:
/ops/queue and /ops/inbox keep hideTitle.
Card radius has no step on the derived scaleβ
Β§1 measures the card at r=16 on all three surfaces. globals.css derives every radius from
--radius: 0.5rem, which yields 11.2 / 14.4 / 17.6 and never 16 β so /ops drew its 19 cards at
rounded-xl = 11.2. Added --radius-card: 16px and moved /ops onto it.
/client and /workspace still draw cards mostly at rounded-lg (8). Reconciling them is
app-wide and not this change.
3. Where the code disagrees with all three surfacesβ
This is the actual problem, and it is not about the Figma files.
3.1 The correct table values are scoped as an exceptionβ
globals.css today:
.hw-table-th { /* base β "the ops/workspace values" */
padding: 10px 14px;
font-size: 11px;
font-weight: 600;
}
.hw-client-portal .hw-table-th { /* override */
height: 44px;
padding: 0 16px;
font-size: 12px;
font-weight: 400;
}
The override matches every frame on every surface. The base matches none of them β no frame uses
an 11px header, a 600-weight header, or a 14px inset. The design-correct values are the
exception and the drift is the default. Any new table built outside .hw-client-portal inherits
the wrong geometry by default, and looks wrong next to one that does not.
3.2 The shared control scale overlaps the frames at one valueβ
ui/button.tsx offers h-6 / h-7 / h-8 / h-9 β 24 / 28 / 32 / 36. Used in 108 files.
Control heights measured in the frames:
| Height | Instances | What it is |
|---|---|---|
| 29 / 30 | 213 | the primary control (one control; 29 is the same box with a 1px stroke inset) |
| 33 | 88 | filter chip |
| 37 | 41 | menu item |
| 32 | 30 | β |
| 40 | 18 | modal primary |
| 48 | 7 | block button (client portal) |
| 56 | 6 | block button (Expert) |
The two scales meet only at 32. The most common control in the product β 213 instances at
30px β has no size in ui/Button, and ClientControlButton (30 / 48, r=8, 12px inset) is the
only component that implements it.
3.3 The shared badge is the wrong size and the wrong paletteβ
ui/badge.tsx is h-5 (20px) with emerald-* / amber-* / red-* tones. Used in 47 files.
The frames use a 24px pill in the warm ramps β Yellow 50/200/800, Green 50/200/900, Blue
50/200/800, Red 50/200/800 from Figma/design-system/foundations/colors.svg.
ClientTag (24px, 8px inset, 12px label, five tones) matches the frames exactly, on all three
surfaces. Rule 3 already calls this out for the portal; it holds everywhere.
3.4 The patternβ
ClientControlButton, ClientTag, ClientPageHeader, ClientEmptyState and
.hw-portal-eyebrow are named for one surface but implement values that measurement shows are
universal. The ui/ primitives they replaced carry pre-redesign values that match no frame on any
surface. The "client" components are the design-system components, mis-scoped by their name.
4. Proposalβ
4.1 Share atoms and tokens; do not share compositionsβ
The costs are asymmetric, and they decide where to cut.
Tokens are cheap to share and expensive to duplicate. Figma keeps one Colors page and one
Typography page for all three surfaces. Three copies of #7E1C1E become three clarets, and nobody
notices until a client screenshot sits beside an operator screenshot.
Compositions are expensive to share and cheap to duplicate. A PageHeader serving three
audiences accumulates props until it is worse than three simple ones, and every change to it needs
three-surface browser verification in both themes per rule 5.2. That is a real tax.
So fold the atoms into the existing variant tables β they are too small to hold opinions and measure identical on all three surfaces β and leave compositions alone.
Not new components. Add the measured values as variants on what already exists:
| Measured in the frames | Canonical home | Change |
|---|---|---|
| 30px control, r=8, 12px inset, 14/600 (213 instances) | buttonVariants in ui/button.tsx | add size: control (and block for 48) |
| 24px warm pill, 8px inset, 12/400 (276 instances) | badgeVariants in ui/badge.tsx | add the warm tone set; retire the emerald/amber/red Tailwind ramps |
12px / 0.04em / uppercase eyebrow | .hw-portal-eyebrow β .hw-eyebrow | rename, 3 files |
ClientControlButton (4 importers) and ClientTag (1) then become thin wrappers or disappear.
This keeps one Button and one Badge β which is the whole point of the cva model, and the thing
whose absence let ui/Badge drift from ClientTag in the first place.
Not proposed: replacing ui/PageHeader or ui/EmptyState. Measured, those two collide on API
rather than migrate β 35 of 60 PageHeader call sites use props ClientPageHeader does not have
(hideTitle 22, className 8, size 4, eyebrow 2), and 28 of 55 EmptyState sites likewise
(variant 13, description 10, action 7). 48 of 61 EmptyState sites rely on the Card wrapper
that ClientEmptyState deliberately drops. That is a redesign of two shared components, not a
rename, and it should be driven by frames for the surfaces it would change β which do not exist.
Blocker for even the three cheap moves. ClientTag and ClientControlButton read tokens
declared only inside the .hw-client-portal block (globals.css 491β567). Promoted to ui/
and mounted in ops/workspace β 47 real files β they would render with those custom properties
unresolved. Β§4.4 is a hard prerequisite, not a nicety.
4.2 Invert the table defaultβ
Make 44 / 72 / 16 the value of .hw-table-th / .hw-table-td, and scope the current
11px/600/14px base to Ops as .hw-ops-dense for the surfaces that have not migrated. The
override becomes the default; the exception becomes the exception.
This is the one change that stops new drift, because it changes what a developer gets by
accident. It is also narrower than it sounds: TanStackTable is the sole consumer of those two
classes, so this is one component's stylesheet, not a sweep.
It has a trap, and it is rule 1.2's exact trap. The portal's table borders resolve
var(--divider), and --divider is declared once, inside .hw-client-portal
(globals.css:501). Lift those rules to the base and every affected table loses its borders
outside the portal. --divider must be promoted to :root in the same change β the derived
alias has to move with the surface.
Two other things that are not affected, and were checked: ui/DataTable is dead code (zero
consumers), and the four files using the shadcn ui/table carry Tailwind classes that this rule
never touches.
4.3 Restyle three existing primitives to their measured valuesβ
These three appear in the frames with no component matching them. All three already have a canonical home β none is new code:
| Frame element | Measured | Restyle, don't build |
|---|---|---|
| Filter chip | 33px, r=6, #FFFFFF β #FAFAF9 hover, 14/400 (88 instances) | a badgeVariants or buttonVariants variant |
| Menu item | 37px, r=8, β #F9F7F3 hover, 14/400, in a r=10 / #EEEEEE popover (41 instances) | ui/dropdown-menu.tsx β Radix, already present |
| Segmented control | 182 Γ 40, r=20, #F4F0E8 track (7d Β· 30d Β· 90d, all three surfaces) | ui/tabs.tsx, or Radix ToggleGroup |
4.4 Name the colours before anything elseβ
Figma/design-system/FOUNDATIONS.md Β§4 has the derivation: of the 48 hexes the Expert surface
paints, 36 are named Figma primitives and only 12 are off-palette, but 29 of those 36 have no
semantic token in design-tokens.ts.
Do not use the names this document first suggested. --border-subtle and --border-strong
already exist at :root (globals.css:104-105), aliased to var(--border) and var(--input),
with 35 live references across 22 files β mostly ops and workspace. Re-pointing them at
Grey 200 / Beige 200 would repaint every one of those call sites silently. Pick unused names, or
repoint deliberately with browser verification. The same check is owed to every other name below
before it is added.
Needed, by measured usage: a tertiary text tone (Grey 400, 796 uses across all 51 Expert frames), an input/hairline border (Grey 200), an outline-control border (Beige 200), and the four status triples (Yellow 50/200/800, Green 50/200/900, Blue 50/200/800, Red 50/200/800).
What #5884 added, and what it deliberately did notβ
Two of the four above already existed under other names, as exact oklch equivalents of the
primitive: the tertiary tone is --text-tertiary and the four status triples are the --tag-*
set #5880 promoted to :root. Checking for that before adding a name is the rule this section
already states, one layer up.
Ten names landed. Seven are new β --surface-subtle (Grey 50), --surface-inset (Grey 100),
--control-border (Grey 200), --control-border-strong (Grey 300), --control-accent-soft
(Red 200), --disabled-foreground (Beige 300) and --table-row-hover. Three were already
written but scoped to .hw-client-portal, where /workspace and /ops could not see them:
--control-outline-border, --control-outline-label and --action-resolve{,-hover}. That
scoping is why the expert chat still drew its own control colours.
Three values depart from what is drawn, each because the frame fails a check the code has to pass:
| Token / element | Frames | Shipped | Why |
|---|---|---|---|
--action-resolve | Green 600 | Green 800 | white on Green 600 is 2.94:1; Green 800 reaches 4.78:1 and stays on the ramp |
Composer Send | Red 300 fill, white label | --primary | 1.9:1 β the worst failure in the set (UI-SPEC Β§6.1) |
| Empty-state body | #7C7C7C | --muted-foreground | 4.17:1, fails AA at 14px |
Two more depart because the frame does not cover the state at all. Checkbox, radio and text input
keep a focus ring β the sheets draw none, and appearance: none takes the platform's with it.
The chat list gives selection Beige 100 rather than the sheet's Beige 50, because the sheet gives
selected and hover the same fill and a two-pane layout has to tell them apart.
--divider was not re-pointed. FOUNDATIONS Β§3.2 argues for Grey 200 over Beige 100 on the
grounds that a divider should not be the hover fill, which is sound β but it repaints every
hairline on a signed-off surface on reasoning rather than measurement. It needs its own pass with
browser verification, as this section requires of every rename.
What #5879 added, and the blocker that had already half-clearedβ
FIGMA-1 (#5895 Β§2) held DS-1 shut on the grounds that the Buttons sheet draws on Beige 900 /
950 / 300, "three steps the semantic layer never adopted". Two of the three had been adopted by
the time anyone went back to it: #5884 landed Beige 900 as --control-outline-label and Beige 300
as --disabled-foreground. Only Beige 950 was genuinely missing. It lands as
--control-outline-label-strong, the hover and focus tone for every non-solid label, and the
blocker is spent. This is the second time in this section that a name already existed under
another name β check first, as the rule above says.
Four more names, all exact ramp steps, all completing a triple that already had its resting value:
--brand-hover (Red 800) and --brand-disabled (Red 300) beside --brand, and
--action-resolve-disabled (Green 300) beside --action-resolve{,-hover}. --brand-foreground
existed but had no @theme mapping, which is the mechanical reason every solid control in the app
reached for --primary (Grey 950) instead of the brand red the frames draw.
Three departures, on top of the --action-resolve substitution the table above already records
and which the success button hierarchy now inherits:
| Element | Sheet | Shipped | Why |
|---|---|---|---|
| Size S padding | 6 | 12 | the measured 30px text offset is icon-inclusive and every S cell is "Text + Icon"; 6 crowds a 14/600 label on the text-only majority |
| Focused | 2px Red 900 stroke, no fill | fill kept, ring outside | as drawn, focus erases a solid button |
| Pressed | identical to Enabled | 1px translate | the sheet gives the state no colour of its own β verified shape by shape |
Two findings for #5895 came out of building it. The sheet's danger family is off-ramp β
#DB3E3E resting and #E94A55 hover, against Red 600 #DD2629 and Red 500 #F14347, the same
near-miss signature Β§3 describes on the Table sheets β so destructive stays on the --destructive
#5888 already set to the frames' Red 600. And the sheet's canonical family is the brand one: its
labels are Inter 14/600 while the danger and success families are Alliance No.1 14/400, which
reads as two stale drafts left beside a finished one.
The gate hole this section names is closed for the light column: ds-control-sheets.test.ts
asserts each of these tokens against its canonical hex, and portal-ui-rules.test.ts already
reads :root out of the generated file, so a token added without a dark value now fails.
The pipeline does not enforce itself, and it should probably not exist. Adding one token today
is a six-step manual chain β edit the light block in design-tokens.ts, edit the dark block,
run generate:tokens, commit the generated design-tokens.css, hand-add the @theme inline
mapping, verify in a browser β and nothing gates steps 2, 3 or 5. The portal-ui-rules suite never
reads design-tokens.css, so a token added there with no dark value passes green: rule 1.1's
protection does not reach this file.
Before adding a step to that chain, ask whether the chain belongs. Tailwind v4 is CSS-first by
design β tokens are authored in @theme in CSS, which this repo already does for everything
@theme inline maps. The TypeScript source plus emitter is a homegrown layer on top, and its own
header comment records the cost: a repo-wide unused-export sweep read design-tokens.ts as dead
and deleted it, silently breaking generate:tokens.
Two canonical options, in order of preference:
- Author the tokens in CSS and delete
design-tokens.ts+generate-css-vars.ts. Fewest moving parts, matches the framework, removes the un-enforced steps entirely. - If a non-CSS source of truth is genuinely wanted (to feed email templates, native, or Figma), use the W3C Design Tokens Community Group format with Style Dictionary rather than a hand-rolled emitter β that is the documented multi-target path and it has a validating build.
Either way the dark-counterpart rule needs to reach wherever the tokens end up living, or the first token added will reproduce the exact defect rule 1.1 exists to prevent.
Adding these is the prerequisite for 4.1β4.3, and it is what makes rule 1.3's ratchet finishable: files still hardcoding colours are waiting on names that do not exist.
The absorption pass, and the size question that was the real blockerβ
With the tokens in place the sheet was buildable, and what still held DS-1 shut was the second question: 73 controls are drawn below the sheet's smallest step, S being a 30px box with a 14/600 label. Decided rather than referred β there is no XS step. The sheet has three sizes and a fourth is not ours to invent; controls drawn under 30 are table row actions and chips, which the Table and Tags sheets govern. That is also the answer closest to what already renders, so nothing moves for a control that stays put.
That fixes the work-list at what a <button> census actually nominates, and the census is worth
reading carefully before trusting the headline number. Of 442 raw <button>s outside tests:
| Count | ||
|---|---|---|
No className at all | 320 | click targets, not controls |
| Padded and rounded and painted, not a list row | 40 | the nominated set |
| β¦below the sheet's S step | 5 | stay, per the decision above |
| β¦surviving a hand read | 11 | absorbed |
The gap between 40 and 12 is all false positives, and each kind is worth naming because a
regex will keep finding them: chips with aria-pressed (ClarifyingCardView) belong to the Tags
sheet, segmented selectors (computer) to Tabs, hw-thread-band / hw-status-tile /
hw-chat-card are list rows and tiles, and three sites matched only because border: "none" and
padding: 0 satisfy a naive "is it painted / padded" test.
CrisisModePanel keeps both of its buttons, and the pair is the reason. Its save button
cannot be absorbed β the red / amber / green fill signals which of three modes you are about to
activate, and the sheet has no vocabulary for a three-way semantic control. Its Cancel sibling
looked absorbable on its own, and was, until review pointed out that a 40px <Button> beside an
unchanged ~28px partner misaligns a matched row. Absorb a control row together or not at all:
the size a <Button> brings with it is the row's problem, not the button's. Both stay, and the
row keeps its off-palette bg-red-600 / amber-600 / green-600 β an amber has no DS home, so
this needs the file owner before it can move.
The absorbed set is mostly one defect: a solid button painted --primary (Grey 950) where the
sheet's solid step is the brand. --brand-foreground having had no @theme mapping is the
mechanical reason, as the section above records. Measured after: Red 900 rgb(126,28,30), 40px,
r=8, 14/600 on the M step, and the outline variant resolving to a Beige 200 edge with a Beige
900 label β the adopted steps doing the job they were adopted for.
Two of the twelve are worth calling out.
- The client portal disagreed with itself about New thread. The desktop rail draws it on
var(--brand);MobileThreadSheetdrew the same action onbg-primary. The portal is signed off at 1.0, so this reads as a regression risk until you check which value the surface's own rail uses β it is the mobile sheet that was wrong. - The 404's
Go homewas never in the census, because it is a<Link>, not a<button>. Correct at 40 / r=8 / 14 but weight 500 on--text-primary. Its file states a deliberate reason for inline styles, so it keeps them and only its two wrong values move.
How many more links are styled as buttons: one, and it is not absorbable either. The same
padded-rounded-painted test over <Link> and <a> returns nine, of which eight are nav items,
settings rows, stat cards and an add-new dropzone. The ninth is TelegramSetupPanel's Done, and
it sits in a four-control row whose Disconnect is a danger outline β our destructive variant
is a soft tint, so the row hits the same missing-variant wall as CrisisModePanel. Two independent
rows now blocked on the same gap is the argument for raising it: the sheet has no outline
danger and no amber, and until it does, those rows stay as they are.
The 15 cluster, swept while the census was openβ
fontSize: 15 was the largest surviving off-scale group at 37 sites and it does not resolve
by value β it resolves by role. Four went to 14 (two onboarding CTAs, whose label the Buttons
sheet sets at 14/600, and two weight-500 row titles that keep their row height); the other 33 went
to 16, Body l. Inline type is now 1,908 / 1,942 on the eight steps β 98.2%, from 96.3%.
15 joins RETIRED_INLINE_FONT_SIZES so it cannot come back, and the ban holding at 170/0 covers
the identifier and ternary forms. It does not cover a string, and one 15 was hiding as one β
not-found.tsx set fontSize: "0.9375rem", which is 15px, in the same file whose button this
branch had already corrected. The rule matches a numeric literal on a fontSize property; a rem
string is neither. Read "the ban is green" as "no numeric literal survives", never as "the size is
gone".
The other declaration forms were audited at the same time and are close to clean: of the 32
identifier constants feeding a fontSize, 31 are on the scale and the one that is not is
WORDMARK_SIZE = 22, the logotype β a glyph sized as type, already one of the three classes Β§11
excludes. The em-relative values in blockMarkdown, inlineMarkdown and legalMarkdown
(0.9em, 0.92em, 0.95em) are the rendered-cascade class, also already excluded.
Reading a style property needs the enclosing style object, not a line window. A Β±10-line read took a modal header's size off the close button beside it and a subtitle's weight off the title above it β both would have shipped a wrong value with a confident-looking reason attached.
What is left off-scale is 34 display sizes β 14Γ22, 5Γ40, 5Γ28, 4Γ26, 2Γ34, and one each of
78 / 32 / 27 / 17 β plus the 79 Tailwind text-[Npx] utilities, every one of them off-scale
(33Γ11, 25Γ10, 10Γ13, 8Γ11.5, and three singletons) and none of them visible to a rule that
matches a fontSize property. Those are a design read per site, not a substitution.
The control box had four homes, and the shared one had no consumersβ
CONTROL_RADIUS = 8 was declared privately in four files, MOBILE_TOUCH_TARGET = 44 in four more,
CONTROL_FONT_SIZE = 14 in three β 72 duplicate constant declarations across 29 names in all.
The control family is one concept spread over two surfaces: 30px box, 12px inset, r=8, 14/600 is
the Buttons sheet's S step, the same 213 instances Β§3.2 counts. It now lives in
components/layout/controlGeometry.ts, beside shellGeometry and tableGeometry, and the
eighteen copies are gone β 72 declarations down to 57.
components/workspace/tokens.ts already exported touchTargets.minSize = 44 and nothing
imported it, while four client components each declared their own 44. A shared home nobody
imports does not prevent the duplication it exists to prevent, so the value moved to sit next to
the control box it constrains. badgeTokens and buttonTokens went the same way for a stronger
reason: zero consumers, and both describe a control badgeVariants and buttonVariants now own.
A second, unused description of a solved thing reads as the answer until you check what imports it.
The audit's note on that file's radius scale was half right, and acting on it literally would
have shipped a regression. Β§11.8 reads md: 6 // buttons, inputs against the Text input sheet's
8 and concludes the value is stale. The label is stale; the value is not. md's only consumers
are the filter chip and filter button, and Β§4.3 measures that chip at r=6. So the labels were
corrected and bubble: 16 and modal: 32 added for the steps the sheets need and this scale never
had β no value moved under a live consumer. Verified in the browser: the chat controls still render
at 30 / r=8 / 14.
Not consolidated, deliberately: MAX_FILES, MAX_KB_PAGES, MAX_OVERRIDE_RATE, TITLE_SIZE and
TITLE_FONT_SIZE share a name across unrelated domains. A repeated name is not a repeated
concept, and merging those would couple two things that only look alike.
What #5885 added, and why the sheet is half the size it looksβ
The Avatar sheet draws twelve rows, which reads as twelve sizes and is why "one avatar scale"
looked like a choice between candidates. It is six. The rows at 28 / 36 / 44 / 52 / 60 / 76 are
the rows at 24 / 32 / 40 / 48 / 56 / 72 with a 4px ring: each carries a stroke-width="4"
about the same centre, so the exported box grows by 4 while the visible face does not move. The
72-face row proves it twice β its ringed twin exports at 76 and the plain scale stops at 72.
| face | initials | status dot |
|---|---|---|
| 24 | 12/500 | 6 |
| 32 | 14/500 | 8 |
| 40 | 16/500 | 10 |
| 48 | 18/500 | 12 |
| 56 | 20/500 | 14 |
| 72 | 24/500 | 16 |
sm, the default and lg already measured 24 / 32 / 40, so the three larger steps are an
extension and no existing call site moves. Four names, all exact ramp steps: --avatar-fill
(Red 50), --avatar-label (Red 950), --avatar-ring (Red 100) and --avatar-online (Green 500).
--avatar-online shares a hex with the dark theme's --action-resolve-hover; a presence dot and
a button hover are different roles, so they keep different names, the same call Β§4.4 made for
--control-accent-soft.
The fill is the finding. Four implementations drew this one object and each had picked its own
ground β the primitive and AvatarUpload on --brand (Red 900), UserIdentity and the account
menu on --text-muted (Grey 600), ExpertsTab on --primary and --success-subtle. None of the
four is the Red 50 face the sheet draws, and nothing in the build would have caught it: each was
locally reasonable.
| Element | Sheet | Shipped | Why |
|---|---|---|---|
| Status dot placement | ~0.75 of the radius, varying per step | 45Β° (0.707) | the sheet's offset drifts 0.5px at 24 and 2.5px at 72, which is hand-placement, not a rule |
AvatarUpload face | β (64 is not a step) | 56 inside the ring | 56 + 4 + 4 = the 64 the control already occupied, so nothing on four settings pages moves |
ConversationHistory | one face | per-role fill kept | the fill is the only thing naming the speaker in that view |
Verified in a browser rather than assumed: the face renders #FEF2F2 / #450A0B at 14/500 (32)
and 20/500 (56); AvatarUpload measures 56 inside a ring at 64 outer; the portal's specialist
avatar is 72 with a 16px Green 500 dot ringed 1.5px, placed at 0.708 of the radius. Both seeded
accounts have a photo, so the fallback face only renders with the image request failed β which
is also how a stale avatarUrl behaves in production, and is the path that used to need
AvatarUpload's hand-rolled imgBroken flag. Radix owns that now.
4.5 Orderβ
- Semantic tokens (Β§4.4) β unblocks everything, changes no pixels. Includes promoting
--dividerto:rootand closing the gate hole. - Invert the table default (Β§4.2) β stops new drift.
- Promote the three atoms (Β§4.1) β blocked on step 1.
- Add the three missing primitives (Β§4.3) β new code, lowest urgency.
5. Cost, measuredβ
- Β§4.2 is the only change with visual blast radius.
TanStackTablerenders 21 table surfaces across 17 app-router routes, every one under/ops/*or/workspace/*. At most 19 are reachable by a single user β two pairs are mutually exclusive role branches on one route. Exactly oneTanStackTablemount is inside.hw-client-portal(KbInventoryTableat/client/knowledge) and is unaffected./superadmin/*adds nothing: all 15 of its pages are server-side redirects into/ops/*. This needs browser verification per route, both themes, per rule 5.2. - Β§4.1 is 8 importers once Β§4.4 lands, and inert before it.
- Β§4.3 is additive.
- Retiring
ui/Button's scale is not proposed here. 108 files is too broad to move on this evidence, and most of those call sites are Ops screens with no frame to check against. AddControlButtonalongside it and let the redesign pull call sites over as it reaches them.
6. What changes in the rulebookβ
If this is adopted, one edit does most of the work β the scope note in
CLIENT_PORTAL_UI_RULES.md:
Sections 1β5 are the client portal only. The Expert workspace and Ops surfaces have a different density and are explicitly not governed by the geometry here.Sections 2β4 bind every Humanwork surface; the frames agree on geometry across the client portal, Expert workspace and Superadmin. Section 1 remains portal-scoped while the other surfaces have no dark-mode frames. Section 6 comes from the runtime ADRs and always bound everything.
Section 1 stays scoped for a real reason: the Expert and Superadmin frames only exist in light, exactly as rule 1.1 says the portal's did. Widening the colour rules before those surfaces have dark frames would repeat the defect that rule was written for.
There is a second reason, and it is worse. Neither /workspace nor /ops declares a theme
scope at all, so rule 1.1 β which tests scopes for a dark counterpart β would be vacuous
there: it would pass by having nothing to check. And rule 1.3's ratchet is client-scoped and caps
the file list rather than the literal count, so the ungated debt outside it is 297 hardcoded
colour literals across 85 files β 4.7Γ the 63 the ledger governs. Widening section 1 honestly
means giving those surfaces a theme scope first; declaring them in scope while the gate cannot see
them would be worse than the current carve-out, because it would read as covered.
Rule 4.2 gains its evidence: eight tables, three surfaces, one geometry.
Sourcesβ
Figma/design-system/FOUNDATIONS.mdβ palette, type scale, token reconciliationFigma/expert/UI-SPEC.mdβ shell, components, colour usage, contrastFigma/super-admin/UI-SPEC.mdβ operator tables, DLQ, feature flagsfrontend/src/app/globals.css,frontend/src/design-tokens.tsfrontend/src/components/client/,frontend/src/components/ui/