Skip to content

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), EmptyState and Skeleton (none in Click UI).
  • Panel: Click UI's card look with a header slot and a scrolling body, which Click UI's Panel container does not have.
  • The memory inspector drawer: a Radix dialog sheet, because Click UI's Flyout collapses to a hover strip on narrow screens and takes no ref.
  • Disclosure toggles: a native button or a Click UI empty Button, with aria-expanded and a Click UI chevron, because Click UI's Collapsible sets no aria-expanded.
  • Triggers that show an avatar or a namespace name: native buttons on tokens; Click UI's empty button is link text.
  • Date and date-time fields (the graph's "Valid as of", a task's due time): native date and datetime-local inputs drawn with Click UI's field tokens, because Click UI's DatePicker takes no name or id for 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 file input opened by a Click UI Button. Where files may also be dropped (the document dialogs), the button sits in ui/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 TextField with a native datalist, because Click UI's AutoComplete picks from its list and keeps no free text.
  • The Ask AI button below xl: Click UI's frameless Button drawn with the ghost IconButton's tokens (.assistant-launcher in styles.css), rather than an IconButton: one element at every width keeps the focus on it across a resize, and from xl the same button is a secondary Button with its word, which IconButton has no slot for. While the panel is open it takes the pressed look from its type's :active tokens (the secondary Button's from xl, the ghost IconButton's below), since Click UI's Button has 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 IconButton with aria-pressed, drawn with that button's :active tokens (.assistant-head in styles.css), since Click UI's IconButton has 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 UI Button given 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's SidebarNavigationItem is a div whose label may hold a link, which would put the selected state on the div, 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 ProgressBar has 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-mix tint, a bg-x/15), and styles.css redefines 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-visible in styles.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.ts tabs through every page and fails on any ring a box clips.
  • Tab rows: styles.css keeps 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/Unavailable marks its button data-unavailable (with the button's type), and styles.css gives 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 in styles.css under Click UI's family names.

What ships of Click UI

  • vite.config.ts aliases Click UI's flag, logo and payment-mark maps to an empty module (src/lib/emptyAssetMap.ts): Click UI's Icon imports 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.ts puts the Click UI modules the shell imports statically in one clickui chunk (~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 as fieldError that only lazy views use) into one that loads with it. npm run budget fails when the boot scripts pass 198 kB gz.
  • Click UI's Badge measures its text on every render. A long list of them memoises its rows and passes them stable props, as MemoryCard does, 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 with useCallback or useMemo. 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 by useDeferredValue of 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 a ui/DraftSearch and a keystroke renders the box alone (Runs reads after a pause, Conversations on Enter).
  • src/lib/clickUiFixes.ts names 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's loading state, which Click UI implements as disabled and 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 a ui/BusyButton instead: it keeps the focus throughout, and when the last page removes a Load more, lib/useLastPageFocus hands 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 with SectionHeading (Click UI's Title, 14 px semibold). .section-label (12 px, muted) labels a group inside a list, menu or rail.
  • A toggle keeps one label; aria-pressed carries its state. A single choice among a few options is a ButtonGroup.
  • 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). useKeyboard does this for its bindings.
  • An Alert takes its lead-in as <strong> in text, not title: 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 of disabled, so the reason stays reachable.
  • A control inside a page that the caller's role cannot use stays visible as ui/Unavailable. Its reason is needsRole(role, namespace) from lib/namespaceRole.ts, or callReason(op, …) from lib/callReason.ts for 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 (useRowSelection enabled 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 a reason on a ui/RowMenu item. An icon button takes ui/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 under prefers-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 an Alert. 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 are ui/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 UI Table. 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 asks memory.list for 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/Loading or ui/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 raw Skeleton outside LoadingSkeleton is a bug (Loading.rules.test.ts checks 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 (⌘K on Apple platforms, Ctrl K elsewhere). 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 (listTime in lib/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 with parseChTime, never Date.parse or new Date(<string>), which read a zoneless time as local (format.rules.test.ts fails on any Date.parse).
  • Per-row actions are ui/RowMenu: Click UI's Dropdown behind an always-visible dots-vertical button 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-control above the open button; the menu then leaves that action out.
  • A list with no rows to show is ui/ListState: empty (the EmptyState, one action; Repositories draws its EmptyState itself, because its first-run steps sit inside it), filtered ("No documents match tag:billing" and Clear filters on one line) or error ("Couldn't load documents", the message, the request id and a secondary Retry, 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 (for SIGNED_IN_ELSEWHERE and ANSWER_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 passes bare, so a line never reads "Couldn't delete x: Couldn't do that: …"; a caller that only read passes read, which drops "it may have been saved", the safe default for a write. Toasts go through toastError(context, e), or toastErrorLine for one that builds its own sentence (how much of a batch went through) with the error worded bare, and a read that fails says why beside its "Couldn't load" line (errorLine). A write answered PUBLICATION_PENDING committed, and only lists and search lag: toastError shows 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 calls committedAnyway(context, e) (lib/errorWords.ts, outside the boot chunk), which toasts as toastError does and returns true for this answer (a dialog that words its failures inline checks isPublicationPending first), 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.move answers 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 that knowledge.restore refuses for now (BACKEND_UNAVAILABLE) comes back on its notice. A page's own line about a committed write alone goes through toastCommitted, which gives it the request id and an error's reading time; a page's own info line about a refused call goes through toastWithId, which does the same, and toastErrorLine is its error-toned form. lib/errorWords.rules.test.ts checks 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 are SIGNED_IN_ELSEWHERE (a raced sign-in) and ANSWER_UNREADABLE (readJson) in api.ts, and STREAM_ENDED in api/assistantChat.ts. npm run budget checks that the build keeps the wording's chunk named errorWords-*.
  • A row list is one tab stop, through useRowNav (lib/useRowNav.ts) on the rows' container: ↑/↓ and j/k move, Home/End jump, Enter opens, Space toggles the row's checkbox; Escape is the page's. The row's open button carries data-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 carries data-row-open, and useRowNav treats its tr as 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 as NameBadge.
  • A Loom primitive that brings a heavy Click UI component (ListToolbar, ListPager, RowMenu, ListState) is imported by path, not from components/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.list matches 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/ or components/ui instead; 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 lowerUTF8 on the row and on the asked value, not lower, 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 lint and CI run Biome's accessibility rules over frontend/src (biome.json overrides). A suppression names its rule and the reason.
  • frontend/e2e/a11y.spec.ts runs 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 budget caps the boot scripts, the stylesheets and the index chunk; e2e/perf-budget.spec.ts and e2e/route-budget.spec.ts cap paint time, long tasks and layout shift.