Dashboard design
The dashboard at / is built on ClickHouse's design system, Click UI, in its dark theme. This page says where the look and behaviour come from, how to change them, and the rules a dashboard change is checked against.
Click UI is the component library
@clickhouse/click-ui (pinned exactly in frontend/package.json) is a runtime dependency. src/main.tsx loads its dark tokens and page defaults directly (the click-ui-styles alias in vite.config.ts) instead of mounting ClickUIProvider, whose barrel import would ship every component's CSS and both themes; the Radix tooltip provider Click UI's components expect is mounted there directly; index.html sets data-cui-theme="dark". Controls are Click UI components, imported by subpath (@clickhouse/click-ui/Button), so only the components the dashboard imports are bundled, and those only lazy views use load with those views:
| Need | Use |
|---|---|
| a button | Button (type: primary, secondary, empty, danger; always pass htmlType) |
| a field | TextField, SearchField, PasswordField, NumberField, TextAreaField, Select |
| a check or switch | Checkbox, Switch (labels are plain strings) |
| tabs, a menu, a popover | Tabs (name the Tabs.TriggersList), Dropdown, Popover (with Loom's triggers below) |
| a table, a badge, an alert, pagination | Table, Badge, Alert, Pagination, ProgressBar |
| a text link or a link-styled action | Link (component="button" for an action; ui/ExternalLink for a new tab) |
| an icon | Icon with a Click UI name, aria-hidden unless it is the only content |
A few Loom primitives in src/components wrap Click UI where Click UI 0.13 falls short. Use them, not the Click UI component underneath:
| Primitive | Wraps | Because |
|---|---|---|
ui/Modal, ModalFooter |
Dialog |
Click UI's close button is named after its icon ("cross"), its overlay has no z-index, and focus returns to the opener only from a Dialog.Trigger |
IconButton |
IconButton + Tooltip |
an icon-only control needs a name and a tooltip; a Radix trigger's type="button" lands on Click UI's variant prop |
ui/Menu: MenuTrigger, PopoverTrigger |
Radix triggers | Click UI's Dropdown.Trigger and Popover.Trigger put aria-expanded on a wrapping div and return focus to it |
ui/AnchoredPopover |
Popover |
a link that opens a popover on a plain click and is followed on a modified one: Radix's trigger toggles only on a click its child has not prevented |
ui/Unavailable |
— | a control that is unavailable for a stated reason: aria-disabled, its tooltip and description |
ui/PageHeader |
— | every page's title row: a 20 px h1 with no icon, as Click UI's page titles, the scope badge beside it and the actions at the end |
ui/ChoiceGroup |
RadioGroup |
Click UI's group label does not name the group, and a non-text item label is announced as "[object Object]" |
ui/ExternalLink |
Link |
Click UI's icon prop names its icon ("popout"), read after the link text; this hides the icon and says "(opens in a new tab)" |
ui/Tooltip (Tip) |
Tooltip |
one tooltip shape, on the provider main.tsx mounts (specs get it from src/test/rtl.tsx); Click UI's disabled switches Radix between controlled and uncontrolled, and a tip that is off still counts as open, so the next tip skips its delay |
ui/TabTrigger |
Tabs.Trigger |
Click UI's Icon is a block, so an icon stacks above the label without a wrapper |
Toast (toast()) |
— | Click UI's Toast has no keyed replacement or request-id line and its close is named "cross"; Loom's card uses Click UI's toast tokens, bottom right |
ConfirmDialog (confirmAction()) |
Modal + Button |
Click UI's ConfirmationDialog focuses the confirm button; Loom focuses Cancel |
ui/fieldError ({...fieldError(id, message)}) |
a field's error |
Click UI draws the error as a bare <div> and sets neither aria-invalid nor aria-describedby; this ties the message to the input |
ui/Alert |
Alert |
Click UI's Alert puts a role on its root but its props type declares none; a live alert takes role="alert" or role="status", with no wrapper |
ui/DraftSearch |
SearchField |
a search box over a server read that keeps what is typed to itself and hands the page the settled text (after a pause, or on Enter), so a keystroke renders the box and not the page; a key change empties it |
ui/Stat |
BigStat |
one figure in a row of them: the small BigStat with a hint under the value, which BigStat has no slot for |
Loom keeps a few elements of its own, on Click UI tokens:
Chip(Click UI has no filter chip),EmptyStateandSkeleton(none in Click UI).Panel: Click UI's card look with a header slot and a scrolling body, which Click UI'sPanelcontainer does not have.- The memory inspector drawer: a Radix dialog sheet, because Click UI's
Flyoutcollapses to a hover strip on narrow screens and takes no ref. - Disclosure toggles: a native button or a Click UI
emptyButton, witharia-expandedand a Click UI chevron, because Click UI'sCollapsiblesets noaria-expanded. - Triggers that show an avatar or a namespace name: native buttons on tokens; Click UI's
emptybutton is link text. - Date and date-time fields (the graph's "Valid as of", a task's due time): native
dateanddatetime-localinputs drawn with Click UI's field tokens, because Click UI'sDatePickertakes nonameoridfor a label and has no time. - The tracker source's repository list: a native input inside a field-styled box that turns each entry into a removable tag, because Click UI has no tag input.
- File pickers: a hidden native
fileinput opened by a Click UIButton. Where files may also be dropped (the document dialogs), the button sits inui/DropZone, a dashed drop target (Click UI has none) tinted while files are over it; its hint hides under a coarse pointer. - An id field that suggests known ids and still takes any other (the Assistant's user and agent field): a Click UI
TextFieldwith a nativedatalist, because Click UI'sAutoCompletepicks from its list and keeps no free text. - The Ask AI button below xl: Click UI's frameless
Buttondrawn with the ghostIconButton's tokens (.assistant-launcherinstyles.css), rather than anIconButton: one element at every width keeps the focus on it across a resize, and from xl the same button is a secondaryButtonwith its word, whichIconButtonhas no slot for. While the panel is open it takes the pressed look from its type's:activetokens (the secondaryButton's from xl, the ghostIconButton's below), since Click UI'sButtonhas no selected state. - "Go to Ask AI": a native button styled like the skip link it follows, because it opens the panel rather than moving to an anchor.
- The Ask AI panel's History button while History shows: Click UI's ghost
IconButtonwitharia-pressed, drawn with that button's:activetokens (.assistant-headinstyles.css), since Click UI'sIconButtonhas no selected state. - The search's Ask AI option's "Ask": a pill on the primary
Button's tokens (.search-ask), because a listbox option holds no button. The answer card's Continue in chat is a Click UIButtongiven the option role, so Enter and the arrows reach it from the field as they reach the results under it; the option the keys moved to takes the focus ring (.search-continue). - Sidebar nav items: anchors drawn with Click UI's
--click-sidebar-*tokens. Click UI'sSidebarNavigationItemis adivwhose label may hold a link, which would put the selected state on thediv, not the link. - Loom-specific widgets (the graph canvas, the task grid and board, the patterns board), with Click UI controls inside them.
- A task's lease bar: a 56 × 4 px bar that turns the warning colour under ten minutes left. Click UI's small
ProgressBarhas one colour and a label row, and a lease about to lapse is a warning.
Component or design values
Import the Click UI component whenever the element is that component: the same anatomy, the same role and the same behaviour (keys, focus, ARIA, portal, positioning). Where Click UI 0.13 gets the behaviour wrong, wrap it once in components/ui/, the wrapper's comment naming the upstream issue, rather than redraw it (ui/SplitButton: ClickHouse/click-ui#1272).
Draw an element in Loom CSS only when it is not a Click UI component (another anatomy, or nothing in Click UI matches: a list row, a chip, a skeleton), and then take every colour, padding, gap, radius and font from the --click-* tokens of the component it stands in for, and a gap or margin that component has no token for from Click UI's generic --spaces-* scale, in one named class in styles.css's components layer. Not hand-typed px, and not Tailwind arbitrary values (px-[var(--click-…)]). A hand size is a measured one, named in its class's comment. The class's comment names the component it stands in for and why the component does not fit. CSS that reaches into a Click UI component's markup ([data-testid=…], its child elements) belongs to that component's wrapper and is rechecked on every Click UI release.
Why: an imported component brings its keyboard and focus behaviour, which a redrawn one has to rebuild and gets wrong; a token-drawn element costs CSS only, which suits a static shell (a row frame, a chip) where the component would ship script for nothing; and the tokens are Click UI's stable contract, where its markup is not.
Styles
- Layer order (inline in
index.html, before any stylesheet):properties, theme, base, clickui, components, utilities. Click UI sits above Tailwind's reset and below the utilities, so put only layout utilities (margin, width, flex placement) on a Click UI component; a colour or padding utility would override Click UI. - Tokens in
src/styles.css: Loom's semantic names (--color-bg,--color-surface,--color-text-muted,--color-accent,--color-success, the type scale, radii, shadows) are defined from Click UI's variables, for Loom's own elements. Click UI components read Click UI's variables directly. - Colours are Click UI variables or derived from them (a
color-mixtint, abg-x/15), andstyles.cssredefines none of Click UI's variables. The focus ring is Click UI's outline colour, 2 px at a 2 px offset, on every focusable element: Click UI draws a typed button's ring in that button's own stroke colour (a secondary button's is 1.6:1, under 3:1) and draws none on checkboxes, switches, tabs, links and its dialog close. A ring override sits outside every CSS layer, beside*:focus-visibleinstyles.css, or the unlayered ring wins; a row spanning a clipping box (a tab, an Explorer outline item, a task lane's header and rows, the namespace picker's options, group buttons and a group's "Show all", the Library's text preview, which fills its pane) or stacked flush (an inbox) draws its ring inset, and a clipping box that holds other controls (the Explorer facets, a toolbar's scrolling filters, a task lane's "Show all", the picker's withdraw and "Find public namespaces to join" buttons) leaves them 4 px of room.frontend/e2e/focus-rings.spec.tstabs through every page and fails on any ring a box clips. - Tab rows:
styles.csskeeps every tab label on one line and lets a tab row wider than the page scroll sideways in its own box, with the tab's focus ring drawn inside it. - Disabled with a reason: Click UI styles only a natively disabled button;
ui/Unavailablemarks its buttondata-unavailable(with the button's type), andstyles.cssgives that button Click UI's disabled colours. - Layers on top: the assistant panel over the page z-[15], the expanded window too (its scrim z-[14];
<main>isolates the page's own z-indexes), the memory inspector's overlay z-20 above it, dialogs z-50 (a confirm z-[60]), toasts z-[65], every Radix popper (menus, selects, popovers, tooltips) z-[70]. - Type: Click UI's sizes, 10 to 32 px: captions are 12 px (
text-xs), the small badge 10 px and the small avatar 11 px; page titles are 20 px. Inter and Inconsolata, latin and latin-ext, are declared instyles.cssunder Click UI's family names.
What ships of Click UI
vite.config.tsaliases Click UI's flag, logo and payment-mark maps to an empty module (src/lib/emptyAssetMap.ts): Click UI'sIconimports them whole to resolve any name, and the dashboard draws only interface icons. Click UI ships ~95 kB gz of script this way instead of ~325 kB.vite.config.tsputs the Click UI modules the shell imports statically in oneclickuichunk (~64 kB gz) that every page loads; Rollup splits the rest (Select and its sortable list, Table, Pagination) into the lazy views that use them, and merges a shared chunk under 1 kB (a primitive such asfieldErrorthat only lazy views use) into one that loads with it.npm run budgetfails when the boot scripts pass 198 kB gz.- Click UI's
Badgemeasures its text on every render. A long list of them memoises its rows and passes them stable props, asMemoryCarddoes, or one change re-measures every row: a callback, set or element made in render is a new prop each time, so the list makes it withuseCallbackoruseMemo. Where a list's search text lives follows from that: over memoised rows the page holds the text, so a row can write it (a tag picked), and filters the loaded rows byuseDeferredValueof it, so a keystroke paints the box before the rows render again (Library, Profile, Audit log, Namespaces in your org, Repositories, Tasks; Memories debounces the search it sends instead). Over rows that are not memoised, where the settled text starts a server read, the box is aui/DraftSearchand a keystroke renders the box alone (Runs reads after a pause, Conversations on Enter). src/lib/clickUiFixes.tsnames what Click UI 0.13's Select leaves unnamed in its option list: the filter field, its clear button and the dialog that holds them. It also keeps keyboard focus through a Button'sloadingstate, which Click UI implements asdisabledand so drops focus to the page: the button pressed takes focus back when it is enabled again, and a dialog it opened returns focus to it (ui/Modal). A button that stays on the page while its own read runs (Refresh, Load more, Search) is aui/BusyButtoninstead: it keeps the focus throughout, and when the last page removes a Load more,lib/useLastPageFocushands focus to the first row that page added, else the last row shown, else the list's search.
Follow a Click UI release
npm --prefix frontend i --save-exact @clickhouse/click-ui@<version>, then run the frontend checks below. Re-check each workaround in the primitives table and in clickUiFixes.ts against the release notes: delete a workaround once Click UI fixes it. A new icon, flag or logo map in Click UI may need adding to the alias.
Rules a change is checked against
- Use the Click UI component, or the Loom primitive above, for every control. A Tailwind-styled imitation of a button, field, dialog, tab or badge is a bug.
- Colours, radii, shadows and text sizes come from tokens: no
text-[13px],rounded-[6px], hex colour or raw palette class in TSX. - Sentence case for every label, title and button.
- A page is titled with
PageHeader, which carries no icon. A panel, card or pane section is titled withSectionHeading(Click UI'sTitle, 14 px semibold)..section-label(12 px, muted) labels a group inside a list, menu or rail. - A toggle keeps one label;
aria-pressedcarries its state. A single choice among a few options is aButtonGroup. - A field's hint is tied to its control with
aria-describedby. - A shortcut of one character key checks
shortcutOff(src/lib/shortcuts.ts): the help dialog's switch turns them all off (WCAG 2.1.4).useKeyboarddoes this for its bindings. - An
Alerttakes its lead-in as<strong>intext, nottitle: Click UI renders the title as an<h6>, which breaks the heading order. - One primary button per surface. Footers are right-aligned, secondary first, the primary last.
- A control that is unavailable and has a tooltip saying why uses
aria-disabled="true"instead ofdisabled, so the reason stays reachable. - A control inside a page that the caller's role cannot use stays visible as
ui/Unavailable. Its reason isneedsRole(role, namespace)fromlib/namespaceRole.ts, orcallReason(op, …)fromlib/callReason.tsfor a gated op, which also says when the role is not known yet or is the server's to decide (its doc comment lists the cases). A control whose role the dashboard cannot know shows the server-decides line and is unavailable, except the Library's writes on a memory namespace, which stay available and let the server decide. A list's selection, which holds only its bulk writes, shows only where they are available (useRowSelectionenabled and the checkboxes rendered on the same condition). - A menu item a role cannot use is
aria-disabled, not Radix-disabled, which would drop it from keyboard focus, and its reason sits once below the items:ui/GatedItem, or areasonon aui/RowMenuitem. An icon button takesui/GatedIconButton. - These are offered only where the dashboard can tell the token may use them: the Library's Sources section; on Repositories a repository's Docs and Sources facets and a tracker's record sources; a repository card's maintainer controls (its Settings, a branch's Re-index and Remove branch, and Add branch); and a tracker's Edit mapping, Rotate and Retire. The repository's Access tab shows a reader its access controls with their reasons. A restricted namespace's own gate (a token of its org that names it, and a direct grant) is checked by its settings, its budget cap, a repository's Access tab and Refresh now; its other controls follow the role, and the server refuses a token that gate does not admit. The header's New menu follows the role the dashboard knows, and the server decides the rest. On a repository's namespace, a member's memory writes (add, and edit or delete a row) stay available whatever its role, and the server checks that the row is the member's. Only what is for the org or the platform rather than a namespace is hidden from a role that cannot use it: the platform pages, Health's Workers, Operation mix and Projection failures tabs, Settings › Models & keys and the admin drawer's navigation.
- Semantic elements before ARIA; a widget that needs a WAI-ARIA pattern (a grid, a combobox) names the pattern in a comment.
- Text holds 4.5:1 and focus rings 3:1, with three gaps that are Click UI dark's own values: a field's placeholder text is 3.5:1, an incomplete onboarding step's title 2.6:1, and the control boundaries (checkbox and radio 1.6:1, a field 1.5:1, a switch 2.6:1) are under 3:1.
- Animations live in
styles.css, which turns them off underprefers-reduced-motion: reduce, along with Click UI's dialog, popover, menu and tooltip entrances.
Lists, loading and navigation
- Colour carries meaning, never decoration. Status is a Click UI
Badge(success, warning, danger, info, neutral); feedback is anAlert. Links, the focus ring, the one primary action, a selected row's tint and charts are the other uses. Tags, agents, keys, counts, headings, icons and timestamps stay neutral, and nothing that is not a warning uses the warning colour. - A list row is
ui/ListRow, on Click UI's CardHorizontal tokens: hover tints it, the row whose detail is open takes the active stroke, a checked row Click UI's Table row tint, though its checkbox carries the state: the tint is faint, and Click UI's Table gives it to the active row only. Tags areui/TagList(outlined chips, three, then "+N"); under a coarse pointer a row's pressable tags stay on one line and truncate, since each one's 44 px hit area reaches past its 24 px box and a second line would lie under the first, and the drawer shows them in full. An agent is a filled neutral badge. Columns of comparable fields (runs, users, audit) are a Click UITable. A row addressed by a key (a memory, a profile section) leads with the key in 12 px mono and puts its value in the 14 px body, the reverse of CardHorizontal's title over description: the key is an identifier, and the value is what a reader scans. A memory row clamps that value to two lines, since one line cut most values mid-sentence, and the list asksmemory.listfor the 320 characters two lines hold at 1400 px wide: a row with tags is 126 px, 148 px under 640 px wide, where its meta takes its own line. - A list page has one sticky element,
ui/ListToolbar: the search, the filters in force and the count in one 48 px row. Selected rows' actions take its place in the same row, so it never grows over the rows. Section headings inside a list do not stick. - Paging is
ui/ListPager, Click UI's Pagination as its full bar, under the list: the range, the pages, a 25/50/100 page size remembered per view. A page change moves focus to the list's top and announces the page. A cursor without a total gets "Load more"; ranked search results get "Show more". - Loading is
ui/Loadingorui/LoadingSkeleton, Click UI's loader with a "Loading" label, or a labelled skeleton shaped like the rows; neither shows before 300 ms. A refresh keeps what is on screen, marked aria-busy. A rawSkeletonoutsideLoadingSkeletonis a bug (Loading.rules.test.tschecks it). A loader has no minimum time on screen: it unmounts when its data lands, and holding it longer would delay the data; the 300 ms wait already keeps a fast answer from flashing one. - Reading something keeps you where you are: a related object opens in the drawer, a dialog or a popover. Going to another tool navigates, pushes history, and Back restores the scroll, the open drawer and the focus.
- Shortcut hints appear only in tooltips, the help dialog and the global search field (
⌘Kon Apple platforms,Ctrl Kelsewhere). Click UI has no shortcut component; the keys themselves all stay. Ask AI has no key and shows none: every action is a visible control, and Enter is only a text field's or a list option's own (it sends in the question field, Shift+Enter adds a line; it picks the highlighted search option). - Icons are Click UI's (
@clickhouse/click-ui/Icon), never a text glyph standing in for one. - A row's time is
ui/ListTime(listTimeinlib/format.ts): "just now", "12m ago", "3h ago", "3d ago", then "Sep 20" or "Sep 20, 2025", in a<time dateTime>with the UTC time in a tooltip. A server time is read withparseChTime, neverDate.parseornew Date(<string>), which read a zoneless time as local (format.rules.test.tsfails on anyDate.parse). - Per-row actions are
ui/RowMenu: Click UI's Dropdown behind an always-visibledots-verticalbutton named "Actions for", at the row's far right; a destructive item is the danger item. At most one other action sits inline. A line of the row's content may link to what it sums up (a public namespace row's guide summary opens the guide on Namespaces in your org), a .list-row-controlabove the open button; the menu then leaves that action out. - A list with no rows to show is
ui/ListState:empty(theEmptyState, one action; Repositories draws itsEmptyStateitself, because its first-run steps sit inside it),filtered("No documents match tag:billing" andClear filterson one line) orerror("Couldn't load documents", the message, the request id and a secondaryRetry, never the empty state). - An error says what happened and what to do, and names no cause it is not sure of.
humanizeError(lib/errorWords.ts, which gives the order a line is built in) words every failure: a lead-in, the server's words, and whether to try again, taken from the body (retryable,retry_after_s, the category) and never from a code's sentence (forSIGNED_IN_ELSEWHEREandANSWER_UNREADABLE, which the dashboard makes, from the code); a server message that already gives that advice is left to give it. A caller with its own lead-in passesbare, so a line never reads "Couldn't delete x: Couldn't do that: …"; a caller that only read passesread, which drops "it may have been saved", the safe default for a write. Toasts go throughtoastError(context, e), ortoastErrorLinefor one that builds its own sentence (how much of a batch went through) with the error wordedbare, and a read that fails says why beside its "Couldn't load" line (errorLine). A write answeredPUBLICATION_PENDINGcommitted, and only lists and search lag:toastErrorshows it as information whose line says the page may still show the old state and not to send it again. The write's own page treats it as made too: a write site's catch callscommittedAnyway(context, e)(lib/errorWords.ts, outside the boot chunk), which toasts astoastErrordoes and returns true for this answer (a dialog that words its failures inline checksisPublicationPendingfirst), and on true goes on as after a success (closes the dialog, keeps the optimistic change, reads the list again), so nothing on screen invites a repeat. A rename is the one exception:memory.moveanswers this before it redirects or deletes the old key, so the form stays open and says that a second Rename finishes the move. A delete's Undo thatknowledge.restorerefuses for now (BACKEND_UNAVAILABLE) comes back on its notice. A page's own line about a committed write alone goes throughtoastCommitted, which gives it the request id and an error's reading time; a page's own info line about a refused call goes throughtoastWithId, which does the same, andtoastErrorLineis its error-toned form.lib/errorWords.rules.test.tschecks the lead-ins and that an error's words end the line they are put in. The server's codes are in the errors reference; the three the dashboard makes areSIGNED_IN_ELSEWHERE(a raced sign-in) andANSWER_UNREADABLE(readJson) inapi.ts, andSTREAM_ENDEDinapi/assistantChat.ts.npm run budgetchecks that the build keeps the wording's chunk namederrorWords-*. - A row list is one tab stop, through
useRowNav(lib/useRowNav.ts) on the rows' container:↑/↓andj/kmove,Home/Endjump,Enteropens,Spacetoggles the row's checkbox; Escape is the page's. The row's open button carriesdata-row-open, and the focus ring is drawn on the row (.list-row:has([data-row-open]:focus-visible)), never on the button. Only the current row's controls (its tag chips, its menu) are tab stops, after its open button, and Tab goes from the last of them out of the list; every other row's are at tabindex -1, and focusing one (a click) makes its row current. Checkboxes are never tab stops (Space). There is no Left/Right grid movement. A Click UI Table whose rows open something (Runs, the review queue) is a row list too: the open button carriesdata-row-open, anduseRowNavtreats itstras the row. A table whose rows only carry actions (an audit event's Details, a guardrail's Delete) keeps a tab stop per action, as any table does. The one exception is the Tasks list (views/tasks/TaskList.tsx): a WAI-ARIA grid of lanes, where Left/Right move between a row and the controls inside it. - An agent is
AgentBadge: at most 180 px with an ellipsis, the whole name in a tooltip when cut. Another identity name (an author, a user id) is the same badge asNameBadge. - A Loom primitive that brings a heavy Click UI component (
ListToolbar,ListPager,RowMenu,ListState) is imported by path, not fromcomponents/ui/index.ts, or it lands in every page's boot chunk.
Before a change goes to review
Every list-page change is checked against these before it is reviewed; each line is a defect class list pages are prone to.
- A server a release behind: every new request input is ignored by it (and named in
ignored_args); the page still works with what that server answers (no total, the first page again, exact matching). Test it with a fake server that ignores the input. - Case: a typed filter matches in any case, on the server and in the browser; a value sent to an exact-match field is sent as it is stored. The one exception is a typed agent on Memories:
memory.listmatches agents exactly, so an agent the list has not seen yet is sent as typed. - States: loading, empty, a filter that matches nothing, an error, and a read still in flight are five different screens; the toolbar shows in every one, and "nothing matches" is said only of a read of the current filter.
- The whole set: a filter, a sort, a count or a select-all covers what the server holds, or says what it covers ("the newest 1,000").
- Focus: anything that unmounts while focused (a button, a bar, a page) hands focus somewhere named; a page change lands on a visible heading.
- Phone: measured at 375 px with a touch pointer (
hasTouch): nothing clipped or zero-width (a row's truncated tags excepted: the drawer shows them in full); 44 px targets; the first row within the chrome budget. - Primitives, not copies: a second copy of a hook, a time format or a state pattern goes into
lib/orcomponents/uiinstead; a raw skeleton, a hex colour or a glyph icon is a bug. - Unicode: a case-insensitive match folds both sides in one place (ClickHouse
lowerUTF8on the row and on the asked value, notlower, which folds only A–Z, and not a second fold in Python); test with a non-ASCII value. - Late answers: every read a page starts (load, reload, Refresh, Retry, Load more) can be overtaken by a newer one; an answer for a scope or filter the page has left is dropped (abort it, or check a scope stamp).
- What the change touched: comments, tests and docs that describe the old behaviour are updated in the same change.
Checks
make lintand CI run Biome's accessibility rules overfrontend/src(biome.jsonoverrides). A suppression names its rule and the reason.frontend/e2e/a11y.spec.tsruns axe (WCAG 2.0–2.2 A and AA) on every route at desktop and phone width, and on the shell's dialogs, menus and popovers. The explorer specs also run at 1280×720.npm --prefix frontend run budgetcaps the boot scripts, the stylesheets and the index chunk;e2e/perf-budget.spec.tsande2e/route-budget.spec.tscap paint time, long tasks and layout shift.