Skip to content

web: move the panel onto the ClickHouse Click UI design system - #411

Draft
alex-clickhouse wants to merge 21 commits into
mainfrom
alex-clickhouse/click-ui-refactor
Draft

web: move the panel onto the ClickHouse Click UI design system#411
alex-clickhouse wants to merge 21 commits into
mainfrom
alex-clickhouse/click-ui-refactor

Conversation

@alex-clickhouse

@alex-clickhousealex-clickhouse commented Aug 25, 2026

Copy link
Copy Markdown
Collaborator

Moves the Nerve web panel onto @clickhouse/click-ui and adopts the ClickHouse
look as the default theme. Re-skin plus a primitive layer — not a page-by-page
rebuild — so the diff is wide but shallow: 17 routes, one shared token file, one
set of primitives.

Rebased onto main today, so it carries the last 60 commits including the
lazy System/Archived session groups, drag-to-nest and the unread marker.

What changed

Tokens. Nerve keeps its own --theme-* names — 2,495 call sites depend on
them — but every value now derives from a Click UI token, so there is one
palette definition and light/dark switching is handled by data-cui-theme.

Primitives.Button, IconButton, TextField, Select, Badge,
Modal, Drawer, Tooltip and friends, in components/ui/. Raw form controls
went 297 -> ~49, and every remainder is documented in place.

Icons. lucide -> Click UI via a shim that keeps lucide's call signature, so
77 import lines changed rather than 449 call sites. 93 of the 118 names map to a
real Click UI glyph; 27 are drawn locally on the same 24x24 / 1.5-stroke grid.

Type scale. 707 arbitrary text-[Npx] sizes -> 0, all on named steps.
Raw palette utilities (emerald-400, red-500, ...) 366 -> 0.

Before/After

CMP-chat-laptop-darkCMP-chat-laptop-lightCMP-tasks-darkCMP-tasks-light (1)CMP-chat-mobile-darkCMP-chat-mobile-light

Accessibility

Audited all 17 routes at 1440x900 and 390x844, both themes — 52 combinations —
measuring contrast from rendered styles rather than from the stylesheet.

failing nodesworst
main1,9691.00:1
this branch0

The bulk of it was one mistake. --theme-text-dim was mapped onto Click UI's
text-disabled and --theme-text-faint onto a mix of it toward the page.
Those are control colours, meant for a greyed-out button, and the panel spends
them on timestamps, counts, file paths and the nav rail — 2.44:1 and 1.39:1 in
light. text-muted had to move too: Click UI's light text-muted is itself only
4.77:1 on the muted surface, so there was no room to fit two legible steps
underneath it.

All five steps are now solved against a target ratio, against
--theme-surface-raised — the lightest of the three backgrounds body text lands
on. Solving against the mid surface looks fine and still leaves every badge on a
raised surface at 4.43:1. The two themes need different percentages: they travel
opposite directions from text-default and contrast is not linear in an oklab
mix, so dark's numbers put light's text-faint at 3.86:1.

This is the one part worth a careful look — it is visible in both themes, and
it is the last commit, so it reverts cleanly on its own if you would rather keep
Click UI's values and take the AA hit.

Deliberate divergences

  • Modal and Drawer keep their own shells. Radix's DismissableLayer
    calls preventDefault() on Escape but never stopPropagation(), and this
    app's global shortcuts never consult defaultPrevented — zero hits repo-wide.
    Escape would have closed the dialog and stopped the running session.
  • Three light-mode tokens sit one palette step darker than Click UI ships
    them, to meet AA. Each is commented with its measurement and how to revert.

Known, not introduced here

  • Click UI's own Loaders.css has three @keyframes using 0 { instead of
    0% {, so esbuild warns on every build. Upstream's, harmless.
  • Main entry chunk grows 305 -> 609 KB gzipped. Measured: ClickUIProvider
    30 KB, the icon set 42 KB (Click UI's <Icon name> is a runtime map over all
    175 glyphs and the package exports map has no wildcard, so individual icons
    cannot be imported), the rest is the Radix dependency chain. Lazy boundaries
    are intact — every other chunk is byte-identical. Worth its own follow-up.

🤖 Generated with Claude Code

alex-clickhouseand others added 20 commits August 25, 2026 13:40
Foundation for moving the web panel onto the ClickHouse design system
(@clickhouse/click-ui 0.10.0).
- Mount <ClickUIProvider> above <App>, fed by the existing themeStore.
- themeStore now exposes `resolved`, collapsing our tri-state preference
('system' | 'light' | 'dark') to the light/dark pair Click UI accepts,
and mirrors it onto <html data-cui-theme>. Nerve's own `data-theme`
stays the source of truth for Tailwind; persistTheme is off so there is
only one writer of theme state.
- A 'system' preference now re-resolves when the OS flips mid-session.
- Declare the cascade layer order up front. Click UI ships its component
rules and its design tokens inside one `clickui` layer; naming it first
guarantees every Tailwind layer outranks it regardless of the order
Vite injects the CSS Module stylesheets.
No visual change yet: the tokens are loaded but nothing points at them.
The previous commit declared `clickui` as the weakest layer, which was
backwards. Tailwind's preflight (the `base` layer) resets bare button,
input, select and textarea to transparent / zero-padding / zero-radius /
font:inherit. Layer order beats specificity, so preflight was outranking
Click UI's own component styles and would have rendered every control
unstyled.
Correct order is base < clickui < utilities: preflight loses to Click UI,
Click UI loses to Tailwind utilities, so `className` overrides still work
without !important.
Two things were needed to make it stick:
- index.css must be the first import in main.tsx. A layer's position is
fixed where it is first seen, and Click UI's stylesheets ride in on its
JS import, so importing it earlier pinned `clickui` to the weakest slot
before our declaration was ever parsed.
- Lightning CSS prunes already-declared names from a bare @layer
statement, so the declaration only has an effect if it is genuinely
parsed first.
Also pins click-ui to an exact version: it is 0.x with a 1.0 being staged,
where minor bumps are allowed to break.
Verified in the built CSS: layer order is properties, theme, base, clickui,
components, utilities.
Every --theme-* variable now derives from a Click UI token instead of a
hardcoded hex. The ~42 names are unchanged, so all 2,495 semantic utility
call sites re-skin without being touched. The accent becomes ClickHouse
yellow (#faff69) in dark, near-black (#151515) in light.
Because Click UI's tokens already switch on data-cui-theme, the three
palette blocks collapse to one: the [data-theme="light"] block and the
verbatim @media (prefers-color-scheme: light) duplicate are both gone.
themeStore writes the resolved theme to data-cui-theme on every branch,
including 'system', so nothing is lost.
Adds what the app was missing and worked around:
- a type scale, so the 707 arbitrary `text-[Npx]` sizes have somewhere to
go. Tailwind's built-in xs/sm/base/lg/xl are already Click UI's
12/14/16/18/20, so they are left alone; only 10px and 32px are new.
- status tokens with background and border variants. Only text variants
existed before, which is exactly why 366 raw palette utilities exist.
- --theme-on-accent for text sitting on the accent.
White text on the old indigo accent was fine; on yellow it is unreadable.
All 21 solid `bg-accent … text-white` call sites become text-on-accent
(15.4:1 dark, 18.3:1 light). Tinted `bg-accent/N` sites are unaffected.
The remaining text-white uses sit on solid red/emerald and are correct.
Fonts: Inter and Inconsolata self-hosted via @fontsource rather than a
Google Fonts link, so the panel keeps working offline. Click UI names
'Basier Square' for display type but it is commercial and falls back.
index.html's pre-paint script now also stamps data-cui-theme, mirroring
themeStore's resolution including the 'system' case. Click UI's tokens
default to LIGHT when that attribute is absent while Nerve defaults to
dark, so without it every cold load flashed white.
Also removes: an invalid `:where(@media ...)` selector that had never
matched, both duplicated highlight.js light blocks (now one, driven from
tokens), the unused .plan-panel rule, and .line-clamp-2 which shadowed
Tailwind v4's native utility.
Gives Wave 2 something to migrate 297 hand-styled form controls and 449
icon call sites *to*.
icons.tsx maps all 118 lucide names the app uses onto Click UI glyphs
behind lucide's call signature, so a file moves onto the design system by
changing one import line rather than every icon in it. 93 names resolve to
real Click UI glyphs; 25 are drawn locally on the same 24x24 / 1.5-stroke
grid, reusing Click UI's own path data where possible. Icons stay
aria-hidden as lucide had them — Click UI's default role="img" would have
put 449 nodes announced as "cross" or "loading" into the a11y tree and
into every getByRole query.
Primitives: Button, IconButton, TextField, TextArea, Select, Checkbox,
Badge, Tooltip. Variants come from counting what the app actually does
(ghost 51, subtle 71, secondary 23, primary 21, danger 17) rather than
from Click UI's abstract set. IconButton makes `label` required, which
names ~100 icon-only buttons that had no accessible name at all.
Modal and Drawer deliberately keep their own behaviour shells rather than
becoming Click UI Dialog/Flyout, on evidence:
- Radix's DismissableLayer calls preventDefault() on Escape but never
stopPropagation(), and this app's global shortcuts never consult
defaultPrevented (zero hits repo-wide). Escape would close the dialog
AND stop the running session.
- Radix renders overlay and content as siblings, so backdrop-on-mousedown
would have to be rebuilt on onPointerDownOutside.
- Flyout unmounts on close, so the slide-out cannot animate, and it has
no safe-area handling.
Both are re-skinned through the tokens instead, which is where the
ClickHouse look actually comes from.
Modal.test.tsx's three brittle assertions now query .modal-backdrop rather
than getByRole('dialog').parentElement. That lookup would not have failed
loudly under a wrapper node — it would have retargeted, still not called
onClose, and passed while testing nothing.
vite.config.ts gains test.server.deps.inline for the package: with
test.css:false and Vitest externalising it, Click UI's own `import
'./Icon.css'` reaches Node, which refuses the extension. Any spec
rendering any Click UI component fails at collection without it.
Primitives use the new named type scale, not the arbitrary pixel sizes
they are meant to replace.
Tailwind v4 emits same-property utilities in alphabetical order of class
name, so between two colour classes on one element the later-*sorting*
name wins — not the one written last, and not the one from the map that
looks more specific. `.text-accent` sorts before every other colour token
in this app. Three bugs came out of that, none of which produce a build
error, a test failure, or anything visible in jsdom:
1. `active` was a no-op on ghost, subtle and tab. The selected treatment
was appended after an already-coloured base, so the background tint
landed but the text never turned accent, and an active tab kept
`border-transparent`. That is every sidebar row, list selection and
nav highlight in the app, rendering as a hover state. Fixed
structurally: a variant with an active treatment now sets no colour of
its own, so exactly one of the rest/active sets is ever on the element
and sort order stops mattering. IconButton had it on all five variants.
2. `primary` was `text-white bg-accent` — white on ClickHouse yellow.
A call site could not correct it, because `.text-white` outsorts
`.text-on-accent`.
3. `dangerSolid` was `text-white bg-hue-red`, which measures 2.61:1 in
dark. `hue-red` is an identity hue for text on the page background, so
it flips to a light #ff7575. Adds `--theme-error-solid`, a
theme-independent palette entry (#c10000 both ways, 6.43:1), because
every semantic red we have flips with the theme and so cannot hold
white in both. The previous comment here asserted the opposite.
New variants for cases the table could not express: success, warning and
info (tinted, like danger — solid status fills are unusable because
--theme-success is a feedback *foreground*, a pale mint at 1.12:1 against
white), accent (an accent text button that is not a selection), and link
(an inline affordance, with padding dropped via its own size table since
`p-0` from a call site would lose to `px-3` on the same ordering rule).
Two light-mode tokens corrected to meet AA. Click UI's own values fail on
their intended backgrounds: --theme-info/--theme-link 3.84:1 on white and
--theme-success 4.27:1 on its tint. Both move one palette step darker,
staying inside Click UI's ramp — info-600 is what Click UI itself uses for
link-hover. These are the only places we knowingly diverge, and the
comment says how to revert.
Button.test.tsx guards all of it: for every variant, active must render
differently from inactive, and no element may carry two classes setting
the same colour property. The variant list is a Record keyed on the union
rather than an array, so adding a variant without enrolling it is a
compile error — which is how the four new ones were picked up
automatically.
…ir absence
`bg-error` is the wrong token for a solid fill, and the MIGRATION REFERENCE
told six agents to use it. The bare status token is Click UI's feedback
*foreground* — a colour meant to sit as text on the matching `-bg` tint —
so in dark mode it is pale: error #ffbaba, success #ccffd0, warning
#ffb88f. `bg-error text-white` measures 1.62:1, and that is what the
notification count badges in NavRail and BottomNav became when they were
migrated off `bg-red-500` exactly as instructed.
`hue-*` is no better: it is an identity colour for text on the page
background, so it flips per theme and white on it is 2.61:1 in dark.
Adds --theme-{error,success,warning,info}-solid, raw palette ramp entries
rather than semantic tokens because the ramp is theme-independent, which
is the only way a single fill holds white text in both themes. 6.4-7.7:1
each. The reference now states the rule that was missing: a coloured dot
with nothing on top can use `bg-error` happily; the moment something has
to be legible on the fill, it needs `-solid`.
Also, from three agents hitting the primitive layer's edges:
- `accentSoft` — the accent member of the tinted family. `primary` is too
loud for a list of equally-valid answer options, and `accent` has no
fill so it reads as a link. Four call sites in NotificationsPage used
this exact string by hand.
- `bare` on TextField/TextArea. Full-bleed editing surfaces (the markdown
editor pane, the chat composer) could not use the primitives at all: the
chrome cannot be removed from a call site, because `bg-bg-sunken` and
`p-5` on the caller lose to `bg-surface-raised` and `px-3` here at equal
specificity. `bare` drops the surface and keeps the focus, placeholder
and disabled behaviour.
- `labelSize`/`labelTone` on Checkbox, for the same reason: the wrapper's
`text-sm text-text-secondary` cannot be overridden by `labelClassName`.
Naming the two properties the label actually varies keeps it type-checked
and puts exactly one class per property on the element.
…tives
Three of the six surface partitions. Each file moves its icons to the
shim, its arbitrary pixel sizes to the named type scale, its raw palette
classes to status or identity tokens, and its hand-styled controls to the
primitives.
Tasks: the dnd-kit board is converted for icons, type and colour only —
no draggable was wrapped, no DOM depth inside a sortable changed, and the
CSS.Translate inline styles are byte-identical. dropIntent.ts and
announcements.ts are untouched, as is TaskDetailBody's dirty guard and
StatusSelect's fallback for a status that has since been renamed away.
The markdown editor pane and the two opacity-0 colour inputs stay native:
a form field's chrome would be wrong on both, and it cannot be removed
from a call site.
Chat tool blocks: colour here does two jobs and they are now separated.
A tool's hue says *which tool this is* and stays an identity hue — a Gmail
row is not an error because it is red, and a plan_decline that succeeded
is not a failure. The ~20 `isError` branches say *how it went* and become
status tokens.
App shell: the notification badges use bg-error-solid, not bg-error. They
were migrated off bg-red-500 exactly as the reference then instructed,
which put white text on a pale #ffbaba at 1.62:1.
Two accessibility fixes fall out of the primitives rather than being
sought: IconButton's required `label` names every icon-only control it
touches, and the password inputs gain one (they had a placeholder and no
label). FileEditor's edit/preview toggle had an active state of
bg-surface-raised inside a bg-surface-raised group — invisible — and now
takes the accent tint.
The dead `prose*` classes are replaced with `.markdown-content`, which the
chat transcript already uses, so source messages and agent messages render
identically and their headings, tables and code get styling for the first
time. @tailwindcss/typography is not installed, so those classes had been
generating nothing.
EmailRenderer's iframe stays light in both themes, deliberately. HTML mail
is authored against white and much of it sets foreground colours without a
background, so painting the document dark leaves that mail dark-on-dark.
The misleading comment claiming dark-mode support is replaced with the
reasoning.
Two partitions, seventeen pages. Zero lucide imports, zero arbitrary
pixel font sizes, zero raw palette utilities and zero raw form controls
remain across them.
The colour maps that drove chips by hand — STATUS_STYLES, TYPE_COLORS,
statusClasses(), sourceBadgeColor(), APPROVAL_BUTTON_STYLES — return a
BadgeTone or a ButtonVariant now rather than a class string, so the
decision stays in the page and the styling stays in the primitive.
Two latent bugs fell out of that:
MemuPage built its tint with `color + '20'`, string-concatenating an alpha
suffix onto a hex. One entry was already `var(--theme-accent)`, producing
the invalid `var(--theme-accent)20` — profile badges have been rendering
with no tint at all. Now color-mix, which works for both.
WorkflowRunsPage's budget bar applied three hard-coded hexes through an
inline style, so it never followed the theme. Now tokens.
Diagnostics keeps its semantic <table> markup: Click UI's Table would have
meant re-expressing per-cell text-right, tabular-nums and truncate for no
gain. Its refresh control had no accessible name at all, and IconButton's
required label gives it one.
Three multi-line card buttons stay native and commented. Button is
deliberately a single-row control, and a variant that undid its
inline-flex/items-center/justify-center base would be a different
component wearing the same name. If more accumulate, that is a CardButton.
Badge's status tones now resolve to the status tokens rather than identity
hues, so a chip reading "failed" and a banner reading "failed" are finally
the same red. Its accent, purple and neutral tones stay hue-based: they
label a kind of thing, not an outcome.
The variant guard is extended to spacing. Tailwind orders numerically as
well as alphabetically, so a smaller value sorts earlier and loses — a
call site's `py-1` against a variant's `py-2` is a silent no-op, and one
agent found twelve of them in a single pass. The suite now asserts that no
variant-size pairing emits two of the same property, which covers the
colour case and the spacing case with one rule. 91 tests.
Note the guard's enrolment check is a *compile* error, so it is enforced
by tsc rather than by vitest, which transpiles without typechecking.
The last and largest partition — the session sidebar, composer, side
panel, review-loop cards and the message stream.
Five files are byte-identical on purpose: diffTheme.ts, MessageList.tsx,
MarkdownContent.tsx, ChatWidthHandle.tsx and StreamingMessage.tsx. They
own the shadow-root theme bridge, stream autoscroll, the markdown
pipeline and the drag-resized --chat-width, none of which is styling.
ChatInput's ACTION_CONFIG hex map becomes var(--theme-*) while staying
data-driven, so the composer's action colours follow the theme for the
first time.
Two dead classes in ApprovalCard were generating nothing: text-text-primary
and bg-surface-deep are not tokens. And SelectionToolbar had a light-mode
bug — hover:text-white on a bg-surface-hover chip, i.e. white on near-white.
The composer textarea stays native. FieldSize has no step above `md`, and
`md` pins text-sm, so converting it would have silently dropped the
composer from 15px to 14px; `bare` does not rescue it either, because the
composer genuinely has chrome and FIELD_BARE's border-0 outsorts a call
site's border. An `lg` field size would make it a one-line drop-in later.
Five controls whose colour *is* their data stay native too — the backend
radios, the rewrite and review-loop toggles, the starred-session menu.
A variant's text colour always wins, so a control coloured from state
cannot use one. A colourless variant would cover all five.
Also adds the last of the guard: `subtle` must keep its text hover,
because its surface hover is invisible when nested in a surface it
matches — 2/255 in dark, 4/255 in light. The obvious future cleanup
("we already hover the background, drop the text move") is backwards, and
a comment cannot fail a build.
Three things reported from the running preview, and one more the audit
turned up.
The sidebar reads too large. The type scale rounds up, so the migration
took the session rows from 13px to text-sm (14px) — on the densest
surface in the app. They move down a step to text-xs (12px), which is on
the scale rather than a reintroduced arbitrary size.
Starred sessions showed an outline star. `fill-hue-yellow` was on the call
site and doing nothing: Click UI renders each glyph inside a wrapper
element, so `className` lands on that box and never reaches the <path>.
The class was applied — just to the wrong node, which is why nothing
looked broken. Adds a filled star to the shim using Click UI's own path
data, so the two are the same shape at the same optical weight.
The implement-session glyph was violet at 60% opacity, which measures
2.79:1 on the sidebar — cold against ClickHouse's warm dark, and genuinely
dim. It and its twin in ReviewLoopCard become cyan at 70% (3.61:1), which
also stays distinct from the yellow star sitting in the same row.
Auditing the rest of the identity hues for the same fault found a worse
one that had not been reported: MemoryToolBlock's 10px labels at 2.03:1.
The cause is shared — violet is the most saturated ramp in the palette and
collapses fastest under an opacity modifier, which is how these are nearly
always used. Dark mode now takes violet two ramp steps lighter (-200/-300
rather than -300/-400), and the two label sites that are text rather than
glyphs drop their opacity modifier entirely, since only full opacity
clears the 4.5:1 text floor.
The rebase brought 60 commits of main onto this branch, 15 of them touching
web/. Their new UI was written against the old conventions, so it landed
un-migrated and would have re-opened three of the counts this branch closed.
Session sidebar (lazy System/Archived groups, drag-to-nest, unread marker):
the group headers, the row menu's Unarchive and Remove-from-parent entries,
and the "..." load-more row move onto Button; the parked-session dot moves
off `bg-violet-400`/`bg-violet-500`, which are dark-theme values that do not
follow the light theme, onto `hue-violet`, which does.
Composer (Run later menu): its trigger becomes an IconButton and its two
menu levels become Buttons, so the menu matches the session menu it mirrors.
Type scale: seven `text-[Npx]` sites from upstream mapped onto the named
steps (10 -> 2xs, 11/12 -> xs, 13 -> sm).
Two glyphs are new. ArchiveRestore and Unlink have no Click UI equivalent,
so they are drawn locally on the same 24x24 / 1.5-stroke grid as the other
25 drawn icons.
ThemedApp moves out of main.tsx into its own module. It was the branch's one
new lint error: a component sharing a module with side-effect imports cannot
be hot-reloaded, so every theme tweak full-reloaded the page. index.css stays
the first import in main.tsx — that ordering is still load-bearing.
tsc clean, 206 tests pass, build passes, and eslint is at exact parity with
origin/main (156 problems, none of them new).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Audited all 17 routes at 1440x900 and 390x844, in both themes, measuring
contrast from *rendered* styles rather than from the stylesheet. 1,805 nodes
of text were below AA. Every one of them traced to the bottom of the text
ramp, and the two worst cases were the two tokens that carry the most copy.
`--theme-text-dim` was mapped onto Click UI's `text-disabled` and
`--theme-text-faint` onto a mix of it toward the page. Those are *control*
colours — deliberately inert, meant for a greyed-out button, never for copy.
Nerve spends them on timestamps, counts, file paths, table labels and the
nav rail. In light that measured 2.44:1 and 1.39:1. `text-muted` had to move
too: Click UI's light `text-muted` is itself only 4.77:1 on the muted
surface, so there was no room to fit two legible steps underneath it.
All five steps are now solved against a target ratio rather than picked by
eye, and the solve is done against `--theme-surface-raised` — the lightest
of the three backgrounds body text sits on. Solving against the mid surface
looks fine and still leaves every badge on a raised surface at 4.43:1, which
is the kind of miss that only shows up if you measure all three. The two
themes need different percentages: they travel opposite directions from
`text-default`, and contrast is not linear in an oklab mix, so dark's
numbers put light's `text-faint` at 3.86:1.
Four call sites then failed on their own account, all the same mistake —
an alpha modifier stacked on a token already sitting at the floor:
text-text-faint/60 on counts and timestamps 2.28 -> 5.14
tone="text-hue-yellow/70" on the Starred header 2.96 -> 6.4
tone="text-success/70" on the Running header 3.72 -> 7.3
Badge tone `purple`, hue over a 15% tint of itself 3.68 -> 5.37
The Badge note already said the status tones were measured and that `purple`
was not; it was the one that failed. It now takes `hue-violet`, the same ramp
two steps lighter, which index.css already keeps lighter for exactly this
reason. `--theme-hue-emerald` joins the existing light-mode AA corrections
for the same reason as `--theme-success` and lands on the same palette step.
Result across the 52 page/viewport/theme combinations: 1,805 -> 0 failures.
origin/main measures 1,969 for comparison, with a worst case of 1.00:1.
Console errors are unchanged at 129, identical to main. tsc clean, 206 tests
pass, eslint at parity with main.
The harness that produced these numbers is in the branch notes, not in the
repo — it needs a running instance and an auth token.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Tailwind v4 emits same-property utilities in alphabetical/numeric order, so
between two of them the later-*sorting* class name wins rather than the one
written last in the attribute. Every primitive in components/ui therefore beat
its own callers at whatever it happened to set, and five migrated call sites
were quietly asking for something they never got: PromptRewriteCard's inline
disclosure kept a button's 8px box, TodoPanel's completed row rendered 12px
against 14px neighbours, ReviewLoopCard's collapsed strip carried 12px of
padding each side that it had asked to drop — which is what made it overflow a
375px viewport — and two more lost a radius and a gap.
`overridable` merges the caller's `className` over the defaults with
tailwind-merge, so the documented escape hatch does what its name says.
It merges the override and nothing else. Passing the whole string through
`twMerge` in one go would be shorter and would also silently resolve collisions
*between* the variant and size tables — which is the bug class Button.test.tsx
exists to catch, and which has shipped four times on this branch. Filtering
per default class keeps internal duplicates visible in the DOM where the test
can still see them.
tailwind-merge is taught `text-display`, the one step in our type scale its
t-shirt-size validator does not recognise; without that it would read the class
as a colour and displace the wrong property.
Measured in a real browser against the production stylesheet, both themes: all
five reported call sites now take the caller's value. 92 -> 110 specs on the
primitives, including a matrix over padding, vertical spacing, type size, gap
and radius, and two that pin the boundary above so the "simplification" that
erases it fails.
Costs 8.8 KB gzipped on the main chunk.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`ClickUIProvider` always nests a `ToastProvider`, which always renders a Radix
toast viewport, and Radix gives that viewport a default F8 hotkey implemented as
a bare `document` keydown listener that focuses it. Click UI renders
`<RadixUIToast.Viewport className={...} />` and forwards nothing, so `hotkey: []`
— which Radix would honour, it guards on `hotkey.length !== 0` — cannot be
reached from outside the package.
Nerve has no Click UI toast call sites, so pressing F8 moved focus from whatever
the user was typing in into a permanently empty `<ol tabindex="-1">`, with no
visible feedback. The page shortcut handlers decide whether to act by asking
whether focus is in a text field, so the next plain `b`, `l`, `n` or `/` then ran
a shortcut instead of typing a character. It could also pull focus out of an open
modal.
A capture-phase listener at `window` is the earliest point in the dispatch, so
the event never reaches Radix's `document` listener regardless of mount order.
`preventDefault` is deliberately not called: F8 belongs to the browser and the
platform, and this is about one library's listener rather than about the key.
Verified in Chromium in both themes — focus stays in the composer, the draft
survives, and the next keystroke still types. The vitest spec renders the real
provider and leads with a negative control that reproduces the theft, so the
suite cannot go green by the viewport quietly disappearing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Four controls announced a richer keyboard model than they have. Each of the
attributes commits the author to a specific pattern, and none of the three
patterns were built, so a screen-reader user was told to press keys that do
nothing — worse than the plain button semantics they replaced, and invisible to
a sighted mouse user.
- InteractiveQuestionCard's answer options were `role="radio"` / `"checkbox"`
with `aria-checked`, with no radiogroup, no roving tab stop and no arrow-key
movement. They are toggle buttons and now say `aria-pressed`.
- ChatInput's kebab and SessionSidebar's session menu claimed
`aria-haspopup="menu"` over a `div` of ordinary buttons — no menu/menuitem
roles, no focus entry, no arrow keys, no Escape. They are disclosures, and
`aria-expanded` already said so truthfully.
- SidePanel's tab strip used `aria-selected`, which is only defined on
option/tab/row/gridcell — on a bare button it is a false promise and invalid
ARIA. Same fix as the answer pills.
BackendSelector keeps its radio roles: it is not part of this migration and it
does at least wrap them in a real `radiogroup`.
The specs assert the absence of the claim as well as the presence of the honest
one, because the failure mode is re-adding the attribute while everything still
looks and behaves right.
`scrollIntoView` joins the other jsdom gaps in test/setup.ts — jsdom ships no
implementation at all, so a component that pins a live region to the bottom
throws during mount for reasons unrelated to the test.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… strict npm
Two ways the dependency graph contradicted what we tell people.
**The Node floor.** @clickhouse/click-ui declares `engines.node >=22.12.0`
while install.sh still accepted 20.19 and returned success — a supported
configuration that a strict `npm ci` refuses. The floor moves to 22.12.0 in
install.sh, in a new `engines` block in web/package.json, and in the README and
docs/setup.md, which both still said 18+. openSUSE's `nodejs20` package cannot
reach the floor, so that becomes `nodejs22`, and since neither a distro package
nor nodesource's `setup_lts.x` promises to clear it, the installer now checks
what actually landed instead of trusting the install to have been enough. Also
fixes an unbound `MIN_NODE_MAJOR` in an error path that `set -u` would have
turned into a different failure than the one being reported.
**The peer graph.** Click UI's Radix tree pulls react-remove-scroll@2.6.0, whose
peer range stops at React 18 while this app is on React 19. The default `npm ci`
installed it anyway and said nothing, but `npm ls` exited ELSPROBLEMS and anyone
whose npm has strict peers on could not install this lockfile at all. A scoped
`overrides` entry moves it to 2.7.2, which accepts React 19; the three nested
2.6.0 copies collapse into one hoisted 2.7.2 and nothing else in the lock moves.
All three came only from click-ui, so the override reaches exactly them.
CI installs with `--strict-peer-deps` so the next one of these fails visibly
rather than arriving unannounced. Deliberately not `--engine-strict`: npm
applies that to every package in the lockfile including dev-only tooling, whose
Node floors are already above anything we would declare as Nerve's (jsdom
22.22.2, undici 22.19.0) — it is unsatisfiable at 22.12.0 on `main` too, and it
cannot express the property worth checking. A new `frontend-floor` job installs
and builds on exactly 22.12.0 instead, which is what keeps install.sh, the docs
and `engines` from drifting apart. It does not run the suite, because the test
toolchain's own floor is higher than the app's.
Verified: `bash -n install.sh`; clean `npm ci --strict-peer-deps` and
`npm ls --omit=dev --all` both clean; a full `npm ci` plus `npm run build` on
exactly Node 22.12.0; audit unchanged at 9 advisories; backend suite 3360
passed, since installer guidance moved.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…reen
On a phone the composer row wraps: the textarea carries `basis-full order-1` so
it takes a line of its own below the controls. The run-later kebab carried
`order-2`, which sorted it *after* that line — so it wrapped onto a third line
of its own, alone at the left margin under the message box, looking like three
stray dots between the composer and the bottom nav. Measured at 390px: kebab at
x=16, y=671, with the textarea ending at y=663.
Its popup is right-aligned to its trigger, so from there it opened off the left
of the screen — 170px wide anchored to a 40px box 16px from the left edge, about
114px of it outside the viewport.
Two changes. The kebab loses `order` entirely and rejoins the controls line next
to Send, which is where its own comment already said it was. And the popup now
hangs off the composer row rather than off the trigger, so `right-0` means the
right edge of the composer and it cannot leave the viewport wherever the trigger
has wrapped to.
The popup's width becomes explicit at the same 170px it already rendered at:
`min-w-[170px]` was what decided it before, because an absolutely-positioned box
with only `right` set had nothing but a 40px trigger to shrink-to-fit against.
Against the composer there is ~500px of room and it took all of it for four
short labels; `w-max` does not help, Chrome's max-content for these `w-full`
children lands in the same place.
Pre-existing on `main` — same `order-2`, same anchor — and not visible on
desktop, where the row is `md:flex-nowrap` so nothing wraps and `order` is inert.
Measured in Chromium at 390 / 768 / 1440: the kebab is on the controls line, and
the popup, submenu expanded, is 170px and fully on screen at all three. Desktop
is unchanged. The spec pins the two structural facts, since `test.css: false`
means jsdom can see classes but not geometry.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The textarea was `text-[15px]` before the type scale landed, and the migration
rounded it *up* to the nearest named step — `text-base`, 16px. That reads
oversized against a 14px transcript and fits noticeably less text on a line at
the composer's 430px.
`text-sm`, which is 14px and what every other input in the app already uses:
`FIELD_SIZES.md`, the size `TextField` and `Select` default to, is `text-sm`.
So this is on-scale rather than another one-off.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three things from a first proper look at the panel, all in the token layer.
**Light mode had elevation upside down.** The shared derivation maps `bg` onto
Click UI's `background-default` and `surface` onto `background-muted`. In dark
that is right — muted #1c1c1c is lighter than default #151515, so a card lifts
off the page. In light it inverts: default is #ffffff and muted is #f6f7fa, so
the page became the brightest thing on screen and every card sat *below* it.
On the task board that put cards 2/255 from the lane behind them — measured
1.016:1 card-to-lane against 1.071 on `main` — and they disappeared.
Light now gets its own ramp: grey page, white cards, controls tinted back down
toward the page. That is the model `main` had (bg #f5f5f5, surface #ffffff,
raised #f8f8f8) expressed in Click UI's palette. Card-to-lane 1.016 -> 1.053.
**Emphasis stopped reading.** The panel spends `font-medium` on 151 of its 170
weighted elements, which worked when the stack was `-apple-system, …` and 500
resolved to SF Pro Medium or Segoe UI Semibold. Inter's steps sit much closer
together — one string at 14px measures 186.6px at 400 and 188.9px at 500, 1.2%
— so headings, labels and counts stopped separating from body copy.
`--font-weight-medium` moves to 600 and `--font-weight-semibold` to 700, in the
theme rather than across 151 call sites: one knob, `font-medium` keeps meaning
"the emphasised one", and both faces are already loaded so it costs nothing.
**The green was a forest green.** Click UI's light `success` is 3.90:1 on the
darkest light background, so this branch had taken the whole step down to
success-800 #006108 — safe at 6.67 but barely green. #007109 is the greenest
point on that line still clearing 4.5:1 everywhere the sweep finds it, and the
case that binds is not a plain surface: MemuPage's type chips put the colour on
a 15% wash of *itself*, where the tint moves with the foreground. Plain worst
5.05, wash worst 4.65. The comment records what going further costs.
Verified: the full 52-combination sweep (13 routes x desktop/mobile x both
themes, contrast measured from rendered styles) reports **0 failing nodes**,
with overflow and console-error counts identical to before. Dark mode is
untouched — every change is inside the light block or the shared weight scale.
tsc clean, 235 tests, lint 148 unchanged, build passes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`--chat-width` was one cap on two things that want different widths. A reading
column wants to stay narrow — long prose lines are hard to track — while the
composer is a control surface that only ever gets *narrower*, because the model
picker and five buttons take a fixed 338px out of whatever it is given. At the
default 768 that left the textarea 430px, and the only way to widen it was to
drag the reading column out to match, which makes the transcript worse to read.
There is no reason to trade those against each other, so the composer stack —
the input row and the rewrite, quote and attachment strips above it, plus the
review-loop panel that renders among them — drops the cap and fills the pane,
inside the `px-4` the block already had.
Measured at 1512 wide with the reading column left at its default: transcript
stays 768, textarea goes 430 -> 846. At 1920, 1254. Mobile is untouched, since
there the pane was always narrower than the cap.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Comments described the migration: what a class string used to be, how many
call sites carried it, which defect a spec came from. Each one now describes
only what the code does and why. The contrast measurements, the Tailwind
class-order rules, the cascade-layer notes and the reasons for not using a
Click UI component stay.
Also drops ChatInputComposer.test.tsx. It asserted Tailwind class names on
the composer row, which pins the implementation and not the layout.
Co-Authored-By: Claude <noreply@anthropic.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@alex-clickhouse