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:/workspaceand/opsdeclare 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.
| Level | What it catches | Where |
|---|---|---|
| Gate | Rules 1.1, 1.2, 1.3, 1.6 | frontend/src/__tests__/portal-ui-rules.test.ts โ fails the suite |
| Gate | Rule 4.2 | frontend/src/__tests__/table-geometry.test.ts โ app-wide, not portal-only |
| Gate | Rule 2.4 | frontend/src/__tests__/shell-geometry.test.ts โ scans every source file for a loose shell dimension |
| Gate | Rule 3.1 | frontend/src/__tests__/ds-control-sheets.test.ts โ the shared controls against their sheets |
| Gate | Rule 6.2, /ops only | frontend/src/app/ops/__tests__/no-retired-conversations-call.test.ts |
| Lint | Rule 2.5 | frontend/eslint.config.mjs โ RETIRED_INLINE_FONT_SIZES, against CI's warning ceiling |
| Contract | Section 6 | ADR-045 / ADR-046 โ a violation there is an architecture violation, not a style call |
| Review | Everything else | This document; cite the rule number |
| Browser | Whether it actually looks right | verify-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-portalon 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 ownhtml.darkrule; 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/:
| Module | Holds |
|---|---|
shellGeometry.ts | rail 214, collapsed 88, top bar 55, gutter 24, rail header 80 |
tableGeometry.ts | 44 / 72 / 16, for the grid surfaces that cannot inherit .hw-table-* |
navGeometry.ts | the rail row โ 40px box, r=8, the two insets, the selected pill |
controlGeometry.ts | the 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.
| Component | Spec | Replaced |
|---|---|---|
ClientControlButton | 30px (row) / 48px (block), radius 8, 12px inset, 14/600 | Ops size="md" (32px) and three hand-rolled buttons |
ClientTag | 24px pill, 8px inset, 12px label, five tones | shared Badge, whose ramps run cool where the portal runs warm |
SettingsSaveRow | the commit control plus its status line | three Save rows disagreeing on height, padding, disabled opacity and whether saving showed a spinner (826c60ddd) |
ClientPageHeader | title (24px serif), subtitle, titleAdornment, action | seven per-page PageHeader usages |
ClientEmptyState | the portal's bare mark, no card | EmptyState's Card chrome inside a region that is already the panel |
.hw-portal-eyebrow | 12px, letter-spacing: 0.04em, uppercase | the 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.
PageLoadingSkeletonstill insets itself the same 32px, across 13 consumers and 17 render sites. Two are inside the portal, where this rule binds โ/client/specialists/[id]/onboardingandโฆ/meetings/[meetingId]โ and both wrap it in apadding: 24of 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:
- What is this? View state, product state, session identity, transcript, agent configuration, or a runtime capability.
- Who is authoritative? AgentFS, SessionDB, Hermes, or the API.
- What already exists? A native mechanism, or an API surface that reads one.
- 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 need | Call |
|---|---|
| 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 outcome | listSessions โ 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/clientswraps itsCREATEDdate (rule 2.4) โ43e59d723took all nine bands to the frame's widths, which settled the disagreement this entry used to record in the spec's favour.CONVERSATIONSwent 120 โ 140 and the header no longer runs intoMEMBERS; a scan of 25 rows ร 9 columns finds no cell crossing its band.CREATEDwent the other way, 110 โ 90, and the date now sets on two lines โ 36px tall atfontSize: 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. EitherCREATEDtakes ~20px back from a neighbour or the cell drops the year.- Settings content is still on the old palette โ
SettingsCardon--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 in8813c319d, onto the DS toggle sheet. --primaryportal-wide (rule 1.5) โ 66 sites across 22 files still paint it inside the portal, against two brand classes and 23 directvar(--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 afontSizeproperty 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/badgeis still the cool ramp across 48 importers (rule 3) โemerald/amber/red/skyath-5, against the sheet's 24px warm pill. The blocker is gone:c6b584accpromoted the--tag-*palette to:rootfor 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'sAPPROVALS_POLL_INTERVAL_MSinterval, 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 onlistSessionsOrThrowand carriesthreadsUnavailable; 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) andregenerateDraft(/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.