Skip to main content

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:

NeedCanonical home hereStatus
Semantic colour layershadcn's --background / --muted / --border tier, primitives β†’ semantic β†’ componentexists, incomplete
Token β†’ utility@theme inline in globals.cssexists, in use
Dark modenext-themes + @custom-variant darkexists
Per-surface themingcontainer-scoped custom properties (.hw-client-portal)exists
Size / tone scalesa cva variant table on the one componentexists β€” use it, do not add parallel components
Menus, tabs, togglesdropdown-menu.tsx, tabs.tsx, toggle.tsx (Radix)exist
Table stylingheadless 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 cva variant, never a new component. Shipping ControlButton beside Button is the same move that let ui/Badge (20px, cool ramps) drift from ClientTag (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 β†’ @import chain is homegrown, and its own header records that a repo-wide sweep deleted the source as dead code and silently broke npm 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 portalExpertSuperadmin
Nav rail214214214 (88 collapsed)
Top bar555555
Content region1226 Γ— 845 at (214, 55)samesame
Page gutter24 β†’ content starts x=238samesame
Usable width117911791179
Nav item pitch48 β†’ 52, see below48 β†’ 40, see below48 β†’ 32, see below
Table header444444
Table row727272
Cell inset161616
Column header type12/400 uppercase, 0.04emsamesame
Status pill24 tall, r=12, 8 inset, 12/400samesame
Cardr=16, 1px #F4F0E8samesame
Card gutter151515
Menu / popoverr=10, 1px #EEEEEEsamesame
Modalr=32, scrim #0C0B09 @ 10%samesame
Avatar32 (also 44, 72)3232 (also 40, 48, 72)
TitlesSource Serif 4samesame
UI textInter 12/14/16samesame

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.

  1. 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.
  2. Block controls. 48px full-width buttons appear only in the client portal (7 instances); 56px only in Expert (6). Both are legitimate surface-specific affordances.
  3. 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:

HeightInstancesWhat it is
29 / 30213the primary control (one control; 29 is the same box with a 1px stroke inset)
3388filter chip
3741menu item
3230β€”
4018modal primary
487block button (client portal)
566block 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 framesCanonical homeChange
30px control, r=8, 12px inset, 14/600 (213 instances)buttonVariants in ui/button.tsxadd size: control (and block for 48)
24px warm pill, 8px inset, 12/400 (276 instances)badgeVariants in ui/badge.tsxadd the warm tone set; retire the emerald/amber/red Tailwind ramps
12px / 0.04em / uppercase eyebrow.hw-portal-eyebrow β†’ .hw-eyebrowrename, 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 elementMeasuredRestyle, don't build
Filter chip33px, r=6, #FFFFFF β†’ #FAFAF9 hover, 14/400 (88 instances)a badgeVariants or buttonVariants variant
Menu item37px, r=8, β†’ #F9F7F3 hover, 14/400, in a r=10 / #EEEEEE popover (41 instances)ui/dropdown-menu.tsx β€” Radix, already present
Segmented control182 Γ— 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 / elementFramesShippedWhy
--action-resolveGreen 600Green 800white on Green 600 is 2.94:1; Green 800 reaches 4.78:1 and stays on the ramp
Composer SendRed 300 fill, white label--primary1.9:1 β€” the worst failure in the set (UI-SPEC Β§6.1)
Empty-state body#7C7C7C--muted-foreground4.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:

ElementSheetShippedWhy
Size S padding612the 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
Focused2px Red 900 stroke, no fillfill kept, ring outsideas drawn, focus erases a solid button
Pressedidentical to Enabled1px translatethe 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:

  1. 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.
  2. 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 all320click targets, not controls
Padded and rounded and painted, not a list row40the nominated set
…below the sheet's S step5stay, per the decision above
…surviving a hand read11absorbed

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); MobileThreadSheet drew the same action on bg-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 home was 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.

faceinitialsstatus dot
2412/5006
3214/5008
4016/50010
4818/50012
5620/50014
7224/50016

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.

ElementSheetShippedWhy
Status dot placement~0.75 of the radius, varying per step45Β° (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 ring56 + 4 + 4 = the 64 the control already occupied, so nothing on four settings pages moves
ConversationHistoryone faceper-role fill keptthe 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​

  1. Semantic tokens (Β§4.4) β€” unblocks everything, changes no pixels. Includes promoting --divider to :root and closing the gate hole.
  2. Invert the table default (Β§4.2) β€” stops new drift.
  3. Promote the three atoms (Β§4.1) β€” blocked on step 1.
  4. Add the three missing primitives (Β§4.3) β€” new code, lowest urgency.

5. Cost, measured​

  • Β§4.2 is the only change with visual blast radius. TanStackTable renders 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 one TanStackTable mount is inside .hw-client-portal (KbInventoryTable at /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. Add ControlButton alongside 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 reconciliation
  • Figma/expert/UI-SPEC.md β€” shell, components, colour usage, contrast
  • Figma/super-admin/UI-SPEC.md β€” operator tables, DLQ, feature flags
  • frontend/src/app/globals.css, frontend/src/design-tokens.ts
  • frontend/src/components/client/, frontend/src/components/ui/