Skip to main content

Client portal โ€” UI rules

Binding rules for anything rendered inside .hw-client-portal. Every one of them exists because it was broken first: each rule names the defect it prevents and the commit that fixed it.

Scope: 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. Section 6 is the exception โ€” it comes from the runtime ADRs and binds every Humanwork client surface.

The density premise in that scope note is contested. Measured against the Expert and Superadmin Figma exports, all three surfaces share one geometry โ€” 214/55 shell, 44/72/16 tables, 24px pills, one card grid. UI_FOUNDATION.md sets out the measurements and proposes widening sections 2โ€“4.

Two halves of that proposal have since shipped and are gated: the table default was inverted, so 44/72/16 is the base rule rather than a portal override (78d60b842, rule 4.2), and the shell's dimensions moved to one module scanned app-wide (f29409227, rule 2.4). Widening the document is still unadopted, and section 1 stays portal-scoped for the reason UI_FOUNDATION ยง6 gives: /workspace and /ops declare no theme scope at all, so rule 1.1 would pass there by having nothing to check.

Cross-reference: DESIGN_PRINCIPLES.md (platform-wide), ADR-045 and ADR-046 (runtime authority), BRANDING.md, frontend/src/app/globals.css.


0. How these are enforcedโ€‹

Five levels, and the level matters โ€” most of these rules are invisible to the two things people normally trust.

LevelWhat it catchesWhere
GateRules 1.1, 1.2, 1.3, 1.6frontend/src/__tests__/portal-ui-rules.test.ts โ€” fails the suite
GateRule 4.2frontend/src/__tests__/table-geometry.test.ts โ€” app-wide, not portal-only
GateRule 2.4frontend/src/__tests__/shell-geometry.test.ts โ€” scans every source file for a loose shell dimension
GateRule 3.1frontend/src/__tests__/ds-control-sheets.test.ts โ€” the shared controls against their sheets
GateRule 6.2, /ops onlyfrontend/src/app/ops/__tests__/no-retired-conversations-call.test.ts
LintRule 2.5frontend/eslint.config.mjs โ€” RETIRED_INLINE_FONT_SIZES, against CI's warning ceiling
ContractSection 6ADR-045 / ADR-046 โ€” a violation there is an architecture violation, not a style call
ReviewEverything elseThis document; cite the rule number
BrowserWhether it actually looks rightverify-ui-in-browser, before and after

Every Gate row reads CSS or sources as text. A component test that happens to assert a value is not one of these, and is not grounds for a gated marker.

A green Jest run is not evidence about anything visual. jsdom loads no stylesheet and erases var() from every colour property, so toHaveStyle can never assert a token and no component test has ever observed a colour in this repo. That is why the gate reads the CSS as text, and why rule 5.1 exists.


1. Colour and themeโ€‹

1.1 Every portal token declared with a literal colour needs a dark counterpart ยท gatedโ€‹

The portal palette is sampled off Figma frames that only exist in light. A token added to .hw-client-portal with no answer in html.dark .hw-client-portal paints a near-white wash on a near-black canvas โ€” and nobody working in light mode ever sees it.

This shipped and survived weeks of work. In dark mode the Knowledge table's "Needs your approval" row was a cream band at lab 97.9 under lab 59.4 text; Refresh, Add document and the pagers sat at ~1.9:1; the client's own chat bubble was white on white. Fixed in a0109f1db.

Dark values mirror the light relationship rather than inverting the colour:

--tag-warning-label: oklch(0.85 0.12 77); /* ~0.8โ€“0.85 lightness */
--tag-warning-surface: color-mix(in oklab, oklch(0.75 0.15 77) 18%, transparent);
--tag-warning-edge: color-mix(in oklab, oklch(0.75 0.15 77) 38%, transparent);

A translucent wash, not opaque paper: the same token is spent as a tag background and as a table-row tint, and it has to composite correctly over both.

A token that genuinely holds one colour in both themes goes in the gate's THEME_AGNOSTIC map with its reason. Bright glyphs and filled controls qualify; washes and labels never do.

1.2 A scope that moves the surface must re-point the derived aliases ยท gatedโ€‹

var() inside a custom property is substituted on the element that declares it, so :root's --bg-canvas: var(--background) keeps :root's value and never sees a scoped override. #5637's whole surface split was therefore inert on desktop and shipped that way (fa197350d). Only Tailwind's bg-sidebar re-pointed, because @theme inline resolves to --sidebar directly โ€” which is exactly why it looked like it worked.

Override --background or --sidebar and you must also re-declare --bg-canvas, --bg-primary, --bg-sidebar in the same block.

1.3 No colour named outright in a client surface ยท gated (ratchet)โ€‹

A hardcoded #fff cannot follow the theme. The gate holds a ledger of the 25 files that still do it โ€” every one a surface the redesign has not reached. The list may only shrink. A new entry fails; so does a stale one, so the ledger cannot rot.

1.4 A portalled surface carries its own valuesโ€‹

Radix renders dialogs, dropdowns and tooltips out of .hw-client-portal, so nothing scoped there reaches them. Two ways out, and the choice is not arbitrary:

  • Put hw-client-portal on the portalled content, when it should inherit the whole palette โ€” InboxDialog (c471266e4).
  • Declare local values, when it is one component with a spec of its own โ€” .hw-ds-menu (d7bbd5545). These then need their own html.dark rule; the account menu drew a white hairline and a white-on-white hover row for exactly this reason.

.hw-ds-menu has since stopped being one component's local values โ€” it is the Dropdown sheet, and all five <DropdownMenuContent> sites in the app carry it (d00fe96bb). Three shipped on the shadcn default panel until then, and TicketActions had grown an --info selected-row treatment the sheet does not draw. Its dark rule is gated by 1.1; its adoption is not โ€” a sixth menu can ship on the library default and nothing will say so. Add the class rather than re-deriving the panel.

1.5 --primary is not the brand in this portalโ€‹

--primary is near-black platform-wide while the DS calls for claret #7E1C1E. Portal controls that commit something name --brand directly (.hw-control-brand, .hw-row-action-brand). Rule 1.7 is the app-wide answer for the control; what 1.5 still covers is everything else --primary paints inside the portal โ€” links, spinner tracks, the Inbox's unread tint โ€” where repointing is still an open decision rather than a drive-by.

1.6 :root answers its own literal colours too ยท gated (ratchet)โ€‹

Rule 1.1 gated the portal scopes. It did not gate the file where tokens actually live โ€” design-tokens.css, generated from design-tokens.ts and imported by globals.css. A token added there with a literal colour and no .dark answer passed the suite green.

Extending the gate over the merged cascade found 15 :root tokens already in that state: --warning, --success, --info and --selected, each carrying its -subtle wash, plus --bg-error, --bg-warning, --border-error, --border-warning, --danger-subtle, --gray-900 and --primary-light. Every one paints a light-mode value on the dark canvas.

--primary-light is the worst of them and shows why 1.2 and this rule are the same disease: it mixes var(--primary), substituted where it is declared, so it holds :root's near-black even under .dark.

The ledger may only shrink, like 1.3's. Deliberately theme-agnostic tokens go in THEME_AGNOSTIC with their reason โ€” vendor channel colours and the dark navy company bar qualify; a status colour never does.

1.7 A solid control fills from --brandโ€‹

--primary is Grey 950; --brand is the claret the frames draw. Until #5879 every solid button in the app filled from the first one. The cause was mechanical rather than a decision: --brand had a utility and --brand-foreground had no @theme mapping, so the wrong token was the reachable one (d8a059adc). ui/button.tsx now carries the whole family โ€” Red 900 resting, Red 800 hover, Red 300 disabled โ€” and the twelve raw <button>s the Buttons sheet governs came onto it (038286c4c).

The gate refuses bg-primary in that file outright, because any shadcn-derived snippet reintroduces it. This is not the repoint 1.5 declines: --primary still holds Grey 950 everywhere else. What moved is the control.


2. Where a style livesโ€‹

2.1 Geometry inline, state in the stylesheetโ€‹

Numbers taken off a frame stay inline with the component, where they can be checked against the frame. Anything with a :hover, :focus-visible, aria-current or data-state variant must be a class in globals.css.

This is not a preference. An inline style beats every stylesheet rule, so a hand-rolled hover on an element that also carries an inline color is dead on arrival โ€” see the note above .hw-rail-item.

2.2 A component that inline-styles its cells cannot be re-skinnedโ€‹

TanStackTable carried thStyle / tdStyle objects, which made the portal's table geometry unreachable from CSS. Moving them to .hw-table-th / .hw-table-td was the enabling refactor for the whole Knowledge pass (2b9e6c924); the portal now re-skins them with .hw-client-portal .hw-table-th, and Ops was verified byte-identical afterwards.

2.3 No magic numbers, no repeated literalsโ€‹

Named constants grouped by concern, at the top of the file. A value used in two places goes to a shared home โ€” check whether one already exists before adding a private copy.

2.4 Measured geometry lives in a named module ยท gated (shell)โ€‹

2.3's "shared home" is not a metaphor. There are four, one per concern, all under components/layout/:

ModuleHolds
shellGeometry.tsrail 214, collapsed 88, top bar 55, gutter 24, rail header 80
tableGeometry.ts44 / 72 / 16, for the grid surfaces that cannot inherit .hw-table-*
navGeometry.tsthe rail row โ€” 40px box, r=8, the two insets, the selected pill
controlGeometry.tsthe Buttons sheet's S step: 30 box, 12 inset, r=8, 14/600

The shell one is gated (f29409227), and the gate scans for the literal, not the name, because a wrong value was never the failure mode: nobody edits 214 to 220, they add a fourth 214 somewhere and it stops moving with the others. A private const RAIL_HEADER_HEIGHT = 64 fails the same suite โ€” that shape is how the 80 hid, written as 64 + 16.

controlGeometry.ts exists because the portal and the Expert queue had each declared the same box privately, four copies of the radius alone (038286c4c).

2.5 Inline type is on the eight-step scale ยท gated (lint)โ€‹

12 / 14 / 16 / 18 / 20 / 24 / 30 / 36 โ€” Inter Body sโ€ฆxl, Source Serif Title xsโ€ฆxl (Figma/design-system/FOUNDATIONS.md ยง1). Nothing between them.

The drift had one shape: 13 and 11, one pixel under Body m and Body s, each locally reasonable and neither a step. Six passes substituted them directory by directory (8f13067db, 677b1fa76, 57e4f7149, 1fb5854ab, d3c84e1fc, c08d3385a); the seventh found the files no pass had claimed and widened the ban to src/** (4c355435d). Inline numeric sizes now sit at 1,908 of 1,942 on a step.

RETIRED_INLINE_FONT_SIZES in eslint.config.mjs is the gate. It matches a descendant of the fontSize property rather than its direct value, so fontSize: dense ? 14 : 11 fails too.

Green does not mean on-scale. The rule is a denylist of nine values, not an allowlist of eight: fontSize: 22 and fontSize: 26 pass it and are off the scale โ€” 34 declarations are in that state, some deliberate (the analytics instruments at 40), most unaudited. It also cannot see a string, a constant declared elsewhere, or a Tailwind arbitrary value; see section 7 for the 78 that survive on that last one.

2.6 Three weights, and only threeโ€‹

400 / 500 / 600. The DS has no step heavier than 600, and the app had shipped 650, 680, 700, 720, 750, 760 and 800 โ€” each a weight hand-picked for one label, none of them on the ramp (096dddd6f).

This one is not gated, and the sweep that fixed it substituted direct values only. Five ternary branches survive โ€” fontWeight: unread ? 700 : 600 and four siblings, two of them inside .hw-client-portal โ€” which is exactly the form 2.5's descendant selector catches for size and nothing catches for weight. Read a weight census as a floor, not a score.


3. Reuse before you buildโ€‹

Hand-rolling any of these is a review rejection. Each replaced a set of near-copies that had drifted apart, and each hand-rolled <button> had lost its focus ring.

ComponentSpecReplaced
ClientControlButton30px (row) / 48px (block), radius 8, 12px inset, 14/600Ops size="md" (32px) and three hand-rolled buttons
ClientTag24px pill, 8px inset, 12px label, five tonesshared Badge, whose ramps run cool where the portal runs warm
SettingsSaveRowthe commit control plus its status linethree Save rows disagreeing on height, padding, disabled opacity and whether saving showed a spinner (826c60ddd)
ClientPageHeadertitle (24px serif), subtitle, titleAdornment, actionseven per-page PageHeader usages
ClientEmptyStatethe portal's bare mark, no cardEmptyState's Card chrome inside a region that is already the panel
.hw-portal-eyebrow12px, letter-spacing: 0.04em, uppercasethe frames' small-caps label, re-derived per surface

Do not reach for the Ops control scale. size="md" is 40px with a 16px inset since #5879 (d8a059adc) re-specced the whole matrix โ€” it was 32/10 when this was written, and the Replaced column above records what ClientControlButton absorbed at the time. It is still what the Ops surfaces reach for: 14 of the 18 files that pass it are under ops/, and none is in the portal. The portal's scale is ClientControlButton's 30/8.

3.1 A shared control is drawn from its sheet, not from its library ยท gatedโ€‹

Checkbox, radio, toggle, text input, button, avatar, tooltip, toast and modal each have a sheet in Figma/design-system/components/, and each shipped carrying its library's values instead. The three portalled boxes are the clearest case: one radius each in the sheets, and 6.4 / 8 / 11.2 in the build against 8 / 16 / 32 โ€” invisible until the thing is open on screen.

ds-control-sheets.test.ts is the gate (8813c319d, extended by d8a059adc and f92a2d4e5). It reads the tokens and the sources as text, because none of it is observable from jsdom: fifteen palette steps at their canonical hex, the Buttons sheet's 48 / 40 / 30 boxes, the toggle's four (checked, disabled) cells, the input's four border states, and the three radii above. Two of its assertions are pure CSS no component test could reach โ€” the native checkbox and radio may name no colour, and appearance: none is only allowed where a :focus-visible rule puts an outline back.

What it protects is the shared half: four sheets spend the same palette step on the same role, and the way that came apart before was a component quietly picking its own near-miss instead of the token.


4. Layoutโ€‹

4.1 The content region is the panelโ€‹

No card, no tinted band, no wrapper. A page's <main> is padding: 32px 24px 24px, gap: 24, overflowY: auto, scrollbarGutter: stable, and no max-width โ€” the region is already inset by the rail.

Card-in-card is the specific failure: the shared EmptyState and Card draw chrome that duplicates the panel around them.

Two portal surfaces still depart from it and are debt: /client/chats caps itself at maxWidth: 1040 and pads 28px 32px 48px with no scrollbar gutter, and /client/chat's briefing column runs gap: 32. Both predate this rule.

/ops and /workspace do not hand-roll the region at all โ€” components/layout/AppContent.tsx owns the scroll container and the gutter for every routed surface on those two, at SHELL_CONTENT_GUTTER. The portal is the one that still builds it per page.

4.2 One table geometry: 44 / 72 / 16 ยท gatedโ€‹

Header 44px, row 72px, 16px cell inset. Home's thread board, the DS Table sheets and the Superadmin frames (Figma/super-admin/UI-SPEC.md ยง2, ยง4, ยง5) agree on it independently. Two tables on one canvas cannot disagree about how tall a row is.

This is the base .hw-table-* rule now, not a portal override โ€” /ops and /workspace carried a denser one until 78d60b842. The gate refuses a second scope re-declaring height, padding or font-size, which is how the two drifted apart before.

The geometry has two gated expressions: .hw-table-* in globals.css for real <table>s, and components/layout/tableGeometry.ts for the grid surfaces that cannot inherit a class โ€” the Expert queue. Anything that is not a <table> imports the module. A private literal is a third source neither half of the gate can see, and there is one live: briefing/ThreadGroupTable.tsx declares its own 44 / 72 / 16 and applies them inline, on the very board this rule cites as independent agreement.

4.3 A placeholder occupies the box its content willโ€‹

A skeleton must not inset itself, cap itself, or otherwise differ from the loaded state โ€” otherwise the page re-settles when data lands. SettingsLoadingSkeleton carried its own padding: 32 and maxWidth: 640 against uncapped content (1d73d5816). Wrap a skeleton in the same column its content uses.

PageLoadingSkeleton still insets itself the same 32px, across 13 consumers and 17 render sites. Two are inside the portal, where this rule binds โ€” /client/specialists/[id]/onboarding and โ€ฆ/meetings/[meetingId] โ€” and both wrap it in a padding: 24 of their own, so the placeholder sits 56px in against uncapped content. Those two are in scope; the rest are not.

4.4 Sortable headers and other controls inside .hw-portal-eyebrowโ€‹

A <button> loses the small-caps: the UA's own text-transform: none outranks inheritance. Add text-transform: inherit, and give the button the cell's full height โ€” the label alone is a 17px target in a 44px row (2e430b7eb).

4.5 Anything the user typed is width-capped where it rendersโ€‹

overflow: hidden + text-overflow: ellipsis + white-space: nowrap do nothing on their own โ€” an ellipsis needs a constraint to overflow. The top bar's identity block carried all three and no cap, so a 255-character display name (what the profile form's maxLength accepts) grew the trigger to 1844px: it began left of the breadcrumb, which flex had already crushed to 0 width, and ran 698px past the viewport. Capping the block at 160 holds the trigger at 242px with the name ellipsised (#5704).

minWidth: 0 is not a cap. It permits shrinking, and nothing here shrinks โ€” the top bar's right-hand group is flexShrink: 0, so the growth lands on its neighbours instead.

Names, org names, thread subjects, filenames: cap the box at the point it renders.

4.6 One collapse control, on the thing it collapsesโ€‹

/client used a rotating arrow in its top bar; /workspace and /ops used a hamburger in theirs. Three surfaces, three glyphs, three state models, none of them attached to the rail it acted on. SidebarRail replaced all three (9fed6d645): it straddles the rail's right border, centred on the top bar's line โ€” the one measurement the three rails share, since the rail header is 80 on /ops and /workspace and 64 on /client.

The frames draw no collapse control at all, so this is a deliberate departure; UI_FOUNDATION.md ยง1 records it so the next person reading the frames does not revert it as a bug.

Below 768px it is not the control. The rail is a closed off-canvas Sheet and anything mounted on its border goes off-screen with it (#1210), so /workspace and /ops keep MobileNavTrigger in the top bar. The two are complementary rather than a reprise of the problem โ€” SidebarRail renders nothing on a phone, MobileNavTrigger nothing on a desktop. The invariant is the pairing: exactly one control at every width, never zero. A test per side asserts it, and a fourth surface has to satisfy both.

And the cap must not be the only copy of the value. A native title answers a mouse and nothing else โ€” keyboard and touch never see it โ€” and neither of the two shared tooltips closes that gap alone. The DS Tooltip is available on any client surface, contrary to what this rule said until now: providers.tsx wraps the whole tree in a TooltipProvider and 19 files import it. But it is a Radix wrapper, and Radix does not open on touch. shared/Tooltip does โ€” it reads (hover: none) and toggles on click โ€” but it is hand-rolled and off the sheet (rule 3.1): r=6 with a 10px inset and no minimum height, against the sheet's r=8, 8px inset and 29px bubble. Three files use it, against the DS one's 19. Put the full value somewhere a click, a tap and Enter all reach โ€” for the account menu, its own header row.


5. Verification โ€” what counts as evidenceโ€‹

5.1 Pin the branch in the DOM, check the colour in a browserโ€‹

Since jsdom cannot see a colour, a test can only assert which branch was taken. Put the decision on a data-* attribute and assert that: data-tone, data-stale, data-side, data-ready. Then confirm the colour itself in Chrome.

Never assert a class name as a proxy for a colour โ€” the class can exist while the rule that colours it does not.

5.2 Measure before and after, in both themesโ€‹

Run the verify-ui-in-browser skill against the real surface, capture computed styles and boxes before the change, and diff them after. Three defects in the last two passes were found this way and by nothing else: a pagination rule a shade heavier than its neighbours, a system-blue search-clear button, and the Inbox overlay staying open on top of the thread it had just opened.

Both themes, every state. The live data usually carries a subset of the states โ€” the Knowledge table only ever showed three of its five statuses, so the other two were rendered deliberately to check them.

5.3 Mutation-check a new rule testโ€‹

Break the rule on purpose, confirm exactly that case goes red, restore. A gate that passes whether or not the rule holds is worse than no gate, because it reads as coverage.


6. Runtime authority โ€” what the UI may treat as trueโ€‹

Unlike everything above, these are not portal-scoped and they are not style calls. They come from ADR-045 and ADR-046: Hermes is the runtime, AgentFS is the agent's durable home, SessionDB is the session and transcript store. The API is an operator, configurator, product surface and integration layer โ€” and the UI is the largest thing it legitimately owns.

Breaking one of these never looks wrong on screen. It produces a second answer to "which thread is this?" or "what happened in it?", and the screen then renders whichever answer it happened to be holding.

The test to apply before designing a surface: if the API vanished after the agent was configured, would the agent still be an agent? The UI, the org workflows and the integrations may vanish with it. Identity, config, session and transcript may not โ€” if they would, the dependency is in the wrong place.

Four questions before the first component:

  1. What is this? View state, product state, session identity, transcript, agent configuration, or a runtime capability.
  2. Who is authoritative? AgentFS, SessionDB, Hermes, or the API.
  3. What already exists? A native mechanism, or an API surface that reads one.
  4. What does the UI add? A workflow, an operator experience, an integration โ€” never a store.

6.1 The native locator is the identityโ€‹

There is no standalone create call. streamFirstSessionMessage mints the session and runs the first turn in one request (POST /sessions/messages/stream); the Hermes-minted id arrives on an early minted SSE frame and again on the terminal one, so a severed first message adopts its thread instead of re-minting it (#6019, fd826061a). There is no client-generated placeholder id anywhere in this flow, and every transcript row carries its own locator: TranscriptLocator (api.ts). Address a thread or a message by those.

A conversation id, a platformMessageId or a client-generated placeholder is never a substitute (ADR-046 ยง6) โ€” which is why the conversations controller was deleted outright in the cutover (a04b4f581) and assignSession (PATCH /sessions/:id/assign) is the only assignment route. /conversations/:id/assign does not answer 410; it does not resolve at all.

6.2 One thread surface ยท gated on /opsโ€‹

There is no longer a mirror to take by accident. getConversations and listConversations were deleted in 83dca9eee once the cutover had repointed every caller; api.ts keeps a tombstone where they were. /sessions is the only thread reader.

The product state went with it. Resolve outcome and would-have-paid, status, notes, assignment and feedback are all PATCH/POST /sessions/:id/โ€ฆ โ€” the two-column split this rule used to draw no longer exists. The only /conversations route the API still serves is conversations/:conversationId/directives.

Which reader you take still matters, and it is now a rule 6.6 question rather than an authority one:

What you needCall
thread list, identity, transcript, and the product state hanging off a thread/sessions โ€” listSessionsOrThrow, getSession
the same read where an empty list and a dead read are genuinely the same outcomelistSessions โ€” the swallowing wrapper, and only there

fetchAll is a documented no-op kept for call-site parity with the retired reader. It is not pagination, and there is none to add.

History worth keeping: #5598 was written against the mirror and stalled after the cutover, because the PR and dev disagreed about which surface one portal hook reads. It landed on /sessions (d0b2e75c0); the mirror it was written against was deleted the next day. The /ops layout is gated against re-importing the retired reader โ€” a tripwire against reintroduction, not a check on live code, and it reads that one file only.

6.3 The transcript is read, never mirroredโ€‹

ADR-046 ยง9: rendering opens the session's SessionDB authority, reads every canonical turn, and serializes it into the response transiently. Humanwork does not persist that as a Postgres transcript, cache a mirrored prose history, or construct a UI projection. So no localStorage transcript, no client-side prose store that outlives its read, no reconstructed "local progress" ledger (AgentFS's own ledger is the audit authority), and no client-built Sources footer โ€” citations belong to Hermes' canonical reply evidence.

The one sanctioned local copy is the optimistic send echo, and it is the standing demonstration of the price. useMessages merges "authoritative server rows + every local row, claimed or not", which forces it to pair every echo with the canonical row that replaces it. That single projection produced two bugs and two fixes: #5674 (fixed by #5677), a permanent false "Failed โ€” Retry" pinned under a message the server had delivered, then #5720 (fixed by #5721), a delivered message disappearing when a failed echo took the row a live send was waiting on.

The shape of that second fix is what the rule now rests on (1c0ea2541). An echo is never deleted โ€” it is retained holding canonicalReplacementId, "the only record that its row is spoken for". The pairing is revalidated against every snapshot and revoked when the row leaves it, live sends claim before failed ones, and visibleMessages โ€” not the reducer โ€” is what drops a claimed echo from the screen. Delete the echo instead and the row becomes indistinguishable from ordinary history on the next pass, which is what made the original mis-pairing unrecoverable.

The pairing is still provisional and re-derived every pass; only a turn returning its own row's locator makes it exact. Keep the echo store to the send lifecycle, and never extend it to anything the transcript already answers.

6.4 Never hide a duplicate or a conflictโ€‹

Idempotency belongs at the canonical SessionDB write boundary, so a client-side de-duplication mask hides a write defect instead of fixing it (ADR-046 ยง6) โ€” conflicting prose has to surface as a conflict. Section 9 of the same ADR is the same instinct: a new reply may not replace, hide, or regenerate the previous Specialist reply. The portal appends.

6.5 Freshness is event-driven; idle time reads nothingโ€‹

ADR-046 ยง8 forbids interval polling, periodic full-transcript rereads, timer-driven all-status snapshots, and read-to-check-whether-anything-changed loops. An explicit user open, reload or refresh may perform one bounded authoritative read; idle time performs none. A mutation emits an event carrying its exact locator, so a consumer applies that one change or invalidates one scoped cache entry.

The message path complies โ€” #804 removed the polling fallback and the socket reconnect path replaced it (PortalChat.tsx). The approvals badge does not; see section 7.

6.6 A failed read is not an empty stateโ€‹

A read that ends catch { return []; } renders an expired token, a 500 and a genuinely empty inbox identically, as "nothing waiting" (#5588). An authoritative read is only worth having if the surface can tell "none" from "could not read": carry the distinction out of the hook โ€” threadsUnavailable, { threads, complete }, or the error itself โ€” and draw a retry. Rule 4.1's empty state is for empty.

Take listSessionsOrThrow. listSessions is the same read with the failure swallowed, and its own docblock scopes it: only where an empty list and a dead read are genuinely the same outcome. A surface that counts these threads must never use it, which is #5588 exactly. ConversationsTab.wrapper-contract.test.tsx is the worked example โ€” it stubs fetch against the real wrapper and asserts an error banner rather than the empty state.

6.7 A form may edit agent configuration; it does not own itโ€‹

Writing configuration through the API is legitimate โ€” an API-operated configuration experience is one of the things the API is for. It stops being legitimate the moment the UI becomes the second canonical copy: re-read the authoritative record after the write, never treat an edited local copy as truth, and never add a client-side default that disagrees with what the runtime will load. ADR-046 ยง9 shows how cheaply this goes wrong โ€” the review/confidence columns were deleted from the schema outright so that no dormant value could recreate a reply gate the runtime does not have.


7. Known debt this leavesโ€‹

Live, deliberately unfixed, and not for a drive-by:

  • /ops/clients wraps its CREATED date (rule 2.4) โ€” 43e59d723 took all nine bands to the frame's widths, which settled the disagreement this entry used to record in the spec's favour. CONVERSATIONS went 120 โ†’ 140 and the header no longer runs into MEMBERS; a scan of 25 rows ร— 9 columns finds no cell crossing its band. CREATED went the other way, 110 โ†’ 90, and the date now sets on two lines โ€” 36px tall at fontSize: 12, not the 14 this entry once assumed. A wrap is not an overrun, so nothing flags it and the column widths are the frame's own. Either CREATED takes ~20px back from a neighbour or the cell drops the year.
  • Settings content is still on the old palette โ€” SettingsCard on --bg-secondary, and the Inbox's blue unread tint on --primary-rgb. The chrome was restyled; the forms were not. The toggles came off this list in 8813c319d, onto the DS toggle sheet.
  • --primary portal-wide (rule 1.5) โ€” 66 sites across 22 files still paint it inside the portal, against two brand classes and 23 direct var(--brand) uses. Rule 1.7 settled the control; this is everything else.
  • PageLoadingSkeleton (rule 4.3) โ€” 13 consumers, two of them inside the portal, where its 32px inset lands on top of a wrapper's own 24.
  • 25 files still name a colour outright (rule 1.3) โ€” the gate's ledger is the list.
  • 78 text-[Npx] utilities are off the type scale (rule 2.5) โ€” 33ร—11, 24ร—10, 10ร—13, 8ร—11.5 and three singletons. The ban matches a fontSize property and cannot see an arbitrary utility, so the lint gate is green with every one of them standing. A design read per site, not a substitution.
  • ui/badge is still the cool ramp across 48 importers (rule 3) โ€” emerald / amber / red / sky at h-5, against the sheet's 24px warm pill. The blocker is gone: c6b584acc promoted the --tag-* palette to :root for exactly this, and StatusBadge, TaskClassificationBadge and ChannelBadge already moved. 48 files is why it stopped there.
  • The approvals badge polls on a 45s timer (rule 6.5) โ€” ClientShell's APPROVALS_POLL_INTERVAL_MS interval, plus reads on tab-focus and socket reconnect, because "the socket carries no approval mutation event" (#2912). The compliant shape is that event on the socket that already exists; the poll is only what bounds staleness until it does.
  • Two surfaces still read the swallowing listSessions (rule 6.6) โ€” /ops/inbox, which draws "No conversations yet" off a dead read, and /workspace/files, where the read is label-only and the swallow is deliberate. Every portal reader is on listSessionsOrThrow and carries threadsUnavailable; what they still lack is a retry control rather than prose.
  • Two draft readers call routes the API does not serve (rule 6.2) โ€” listConversationDrafts (/expert-queue/conversations/:id/drafts) and regenerateDraft (/conversations/drafts/:id/regenerate) survived the cutover that deleted the hold/draft machinery end-to-end. No controller answers either path, and there are four live callers in the Expert queue.