Uh oh!
There was an error while loading. Please reload this page.
web: move the panel onto the ClickHouse Click UI design system - #411
Draft
alex-clickhouse wants to merge 21 commits into
Draft
web: move the panel onto the ClickHouse Click UI design system#411alex-clickhouse wants to merge 21 commits into
alex-clickhouse wants to merge 21 commits into
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for freeto join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Moves the Nerve web panel onto
@clickhouse/click-uiand adopts the ClickHouselook 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
maintoday, so it carries the last 60 commits including thelazy 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 onthem — 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,Tooltipand friends, incomponents/ui/. Raw form controlswent 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
Accessibility
Audited all 17 routes at 1440x900 and 390x844, both themes — 52 combinations —
measuring contrast from rendered styles rather than from the stylesheet.
mainThe bulk of it was one mistake.
--theme-text-dimwas mapped onto Click UI'stext-disabledand--theme-text-faintonto 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-mutedhad to move too: Click UI's lighttext-mutedis itself only4.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 landson. 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-defaultand contrast is not linear in an oklabmix, so dark's numbers put light's
text-faintat 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
ModalandDrawerkeep their own shells. Radix'sDismissableLayercalls
preventDefault()on Escape but neverstopPropagation(), and thisapp's global shortcuts never consult
defaultPrevented— zero hits repo-wide.Escape would have closed the dialog and stopped the running session.
them, to meet AA. Each is commented with its measurement and how to revert.
Known, not introduced here
Loaders.csshas three@keyframesusing0 {instead of0% {, so esbuild warns on every build. Upstream's, harmless.ClickUIProvider30 KB, the icon set 42 KB (Click UI's
<Icon name>is a runtime map over all175 glyphs and the package
exportsmap has no wildcard, so individual iconscannot 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