@aiquants/console-ui
v0.4.3
Published
Host-agnostic UI foundation for React admin consoles, with a React entry, a React-free core entry, a DOM testing entry and a stylesheet.
Readme
@aiquants/console-ui
Host-agnostic UI foundation for React admin consoles: design tokens computed from a few formulas on a 4 px lattice and a 44 px target (TypeScript is the source, CSS is generated), a light and a dark palette with a WCAG 2 contrast computation and the pair table of both schemes at AAA, layout arithmetic, number, zoned-time and instant formatting, a read contract with a keep-last-good state machine and a keyset paging state machine, the minimal components a console is drawn with, and the colour-role scan a console runs on its own screens.
Prerequisites
- Node.js >= 18.0.0
- React >= 19.0.0 and React DOM >= 19.0.0 (peer dependencies; the layout components emit hoisted
<style href precedence>elements)
Installation
pnpm add @aiquants/console-uiEntry Points
| Import path | Contents |
| --- | --- |
| @aiquants/console-ui | Components and hooks; re-exports everything from @aiquants/console-ui/core. |
| @aiquants/console-ui/core | React-free: the tokens with the geometry of their formulas and the contrast computation, the layout arithmetic, number, zoned-time and instant formatting, the label catalogs, the read contract with its keep-last-good machine, and the keyset paging machine (listed below). Server-side loaders and hosts import from here without loading React. |
| @aiquants/console-ui/testing | DOM-only: scanColourRoles(root), the colour-role scan a console runs on its own rendered DOM (see Tokens and colours). It takes an element and loads no React. |
| @aiquants/console-ui/styles/console-ui.css | The stylesheet: the token variables on [data-cui-root] and the component rules, all inside @layer cui. |
The /core exports:
- Tokens and contrast:
TOKENS,cssVar,contrastRatio,hexToSrgb,CONTRAST_PAIRS,CONTRAST_MINIMUM, and the typesColorScheme,SchemeColorandMotionTokenbeside the other token names. - The geometry the tokens are computed on:
LATTICE_PX(4),TARGET_PX(44),ROW_WITH_CONTROL_PX(52) andceilToLattice(px)(the smallest lattice multiple at or above a length). - Layout arithmetic:
textWidthEm,monoTextWidthEm,ceil8,tileGridPlan,tableMinInlinePx,masterDetailPlan. - Formatting:
formatCount,toOffsetIso,toWallTime,formatInstant,ZonedTimeError. - Labels:
CONSOLE_UI_LABEL_CATALOGS,resolveConsoleUiLabels(a UI language's catalog, whoseloadFailed,refreshFailedandforbiddenBecausebuild the failure sentences and whosefailureSentenceis the one rule that chooses between them),reasonAside(a failure's reason as a parenthetical). - The read contract: the types
ResourceOutcome<T>/ResourceState<T>/ReloadCause,HTTP_FORBIDDEN(the status of a refusal),keepLastGood,beginReload,mapOutcomeandflatMapOutcome. - The command contract: the type
CommandOutcome<T, R>(a command the host can refuse with a structured body, which the failure carries asrejection). - The keyset paging machine: the types
KeysetPage<T, C, M>/NextPage<T, C>/PagedValue<T, C, M>/PagedState<T, C, M>/PageFailure/PagedContent<T, M>,beginPagedReload,settleFirstPage,beginPage,settlePageandmapPagedList.
@aiquants/console-ui and @aiquants/console-ui/core share one module instance of the core code: the build emits the code several entries reach as a shared chunk per format (ESM / CommonJS), so a host that imports both paths in the same module format gets one copy of every core export and of any module state behind it (for example the Intl.NumberFormat cache of formatCount).
Usage
Load the stylesheet once, bind the package's neutral font variables to the host's fonts, and wrap the console in ConsoleUiRoot.
Loading the stylesheet
Every rule of the stylesheet sits in the cascade layer cui. Cascade layers rank in the order they are first declared, and the cui layer must rank above any layer that holds a reset: the paddings and borders the layout arithmetic counts (the card's 1 px rule and the 23 px padding inside it, the 44 px controls and rows, the 52 px rows that hold a control) are otherwise overridden by the reset (Tailwind's preflight sets padding: 0; border: 0 solid on every element in its base layer).
Unlayered host rules beat every layer whatever their order or specificity, so the host's reset must itself sit in a layer ranked below cui: an unlayered reset (normalize.css, modern-normalize, Tailwind v3's @tailwind base) overrides the package.
Tailwind v4 host:
@importthe stylesheet in the CSS entry after the Tailwind layer statement, socuiis declared afterbase(it then ranks last), or name the order explicitly:@layer theme, base, components, utilities; @import "@aiquants/console-ui/styles/console-ui.css";@layer theme, base, components, cui, utilities; @import "@aiquants/console-ui/styles/console-ui.css";Importing it as
@import "@aiquants/console-ui/styles/console-ui.css" layer(components);also works:cuibecomes a sub-layer ofcomponents, abovebase. The package's rules match only the package's own elements (and its focus ring only the elements it lets take focus), so rankingcuiaboveutilitiesdoes not restyle host elements inside the root.Host with an unlayered reset: import the reset into a layer before the package, so the reset's layer is declared first and ranks below
cui:@import "normalize.css" layer(base); @import "@aiquants/console-ui/styles/console-ui.css";Host without a CSS build: import it from JavaScript (
import "@aiquants/console-ui/styles/console-ui.css") or link it (<link rel="stylesheet" href=".../dist/styles/console-ui.css">) after any stylesheet that declares a reset layer.
Fonts
[data-cui-root] {
--cui-font-sans: var(--font-sans);
--cui-font-mono: var(--font-mono);
}Unbound, the variables fall back to system-ui, sans-serif and ui-monospace, monospace. The text-width models the layout arithmetic sizes columns with assume a sans face for textWidthEm (full-width 1 em, half-width 0.55 em) and a monospace face for monoTextWidthEm (half-width 0.6 em, Text with face="mono").
They are estimates, not an upper bound on every face: digits, uppercase Latin and wide letters run wider in some faces (a 13 px 2026/04/01 00:00:00 that the model gives 135.9 px is 137.5 px in DejaVu Sans and 139.5 px in IPAPGothic), so a column sized from the model has no margin beyond the 8 px rounding.
Bind the variables to faces the models fit; with the generic fallback or while a web font has not loaded, a text that overruns its column wraps inside its cell instead of spilling into the next one.
Wrapping a console
import { Card, ConsoleUiRoot, Measured } from "@aiquants/console-ui"
export const Console = ({ orphanCount }: { orphanCount: number | null }) => (
<ConsoleUiRoot locale="ja" formatLocale="ja-JP" timeZone="Asia/Tokyo" testId="console">
<Card title="Tri-Store データ整合性監査" testId="audit">
<Measured value={orphanCount} unit="件" testId="orphans" />
</Card>
</ConsoleUiRoot>
)ConsoleUiRoot has no default language (locale is "en" or "ja"; anything else throws), validates formatLocale as a BCP 47 tag and timeZone as an IANA time zone Intl knows (both reach the context in canonical form), and has no default for either.
A console shows every instant with formatInstant(iso, formatLocale, timeZone) from the values useConsoleUi() returns, so every panel names the same instant with the same string and a page rendered on the server hydrates without a mismatch (the ambient time zone differs between the server and the browser).
A console has exactly one root: a ConsoleUiRoot rendered inside another one throws, because a second root would add a second live region and a second token scope and could switch the language partway through the console. A view that is also used inside a console built on the package therefore takes the root from its host instead of wrapping itself in one.
The root sets no font property: host content inside the root keeps its own font family, size, weight and line height, and inherits only the root's text colour (the fg role) and the --cui-* variables, drawn over the root's surface background. The package's body type (13/20) and sans face are declared by Card for what is drawn in it.
The root declares no color-scheme either: every colour is a light-dark() pair, so the palette follows the scheme the root inherits. A host with its own light and dark modes binds them to color-scheme (html { color-scheme: light; } and html.dark { color-scheme: dark; }), so the console and the host never show different schemes; color-scheme: light dark on :root would follow the operating system instead and can disagree with the host's own switch.
Every component that renders an element throws outside the root, because the token variables and the colour roles are scoped to the root's DOM and would not reach it; the hooks useFocusRescue, useEscapeToClose, useResource and usePagedResource do not need the root. For the same reason an overlay must render inside the root's DOM, not in a portal to <body>: a portal keeps the React context but loses every --cui-* variable.
Draw a modal as a Dialog: a modal <dialog> (showModal()) makes everything outside it inert, the root's live region included, so Dialog holds a live region of its own, which takes the announcements while it is the topmost open modal (see Focus and announcements). Draw any other overlay as a non-modal <dialog> (opened with show()) or a popover placed in the tree. A modal that is not the console's Dialog, or a host's inert or aria-hidden="true" on the console, hides the announcer: a write to it then throws instead of being dropped; announce after that modal closes.
Tokens and colours
- Every length, type step and duration is computed from a few formulas (no value is written by hand):
- a 4 px lattice (
LATTICE_PX) and the spaces 4, 8, 12, 16, 24, 32, 48, 64 and 96 (space-1…space-24, multipliers whose prime factors are 2 and 3); - type steps of round(16 × 1.25^k) px, never below 11 px, each line the smallest lattice multiple at or above 1.5 times its size (WCAG 1.4.8;
title, beside controls, andprose, the reading line, at 1.75):caption11/20,body13/20,prose13/24,title16/28,heading20/32,display25/40,display-lg31/48; - the 44 px target (
TARGET_PX: abodyline with 12 px above and below), which is also the height of a control and of a table row (size-target,size-control,size-row), and the 52 px row that holds a control (ROW_WITH_CONTROL_PX: the target with one lattice unit above and below,size-row-control); - motion of 120, 180 and 270 ms (
motion-1…motion-3, 120 × 1.5^(k − 1)). Onlybody(20) andtitle(28) lines, whose difference from 44 is a multiple of 8, can be centred in a 44 px box on the lattice, so every control draws its text inbodyortitle.
- a 4 px lattice (
- The token variables are declared on
[data-cui-root](never:root) with lengths in rem, the strokes (border-width1,focus-width2,focus-offset2,marker-width2) in px and the durations in ms. A browser snaps a used border width down to whole device pixels, so a rem stroke would leave the paddings that compensate for the border (calc(… - var(--cui-border-width))) off the lattice for a reader who enlarges the root font size; in px every lattice measure scales exactly with the root font size around a 1 px border. - Every duration is read from the
--cui-motion-*variables, which are0mswhile the reader prefers reduced motion (prefers-reduced-motion: reduce), so a component that moves stops without a rule of its own; no rule writes a duration by hand, and the indeterminate progress segment sweeps only for a reader who allows motion. - Text and surface colours are applied through role attributes only.
data-cui-fgpaints text,data-cui-surfacepaints the surface text sits on,data-cui-accentpaints non-text graphics. Every role has a light and a dark value, written aslight-dark(<light>, <dark>)(see Wrapping a console for the scheme).CONTRAST_PAIRS.lightandCONTRAST_PAIRS.darklist the same pairs: for every role, the surfaces it may be drawn on with the WCAG 2 ratio on each in that scheme. Every pair meets AAA (CONTRAST_MINIMUM: 7 for text, 4.5 for large text, 3 for non-text; the lowest text and non-text ratios are 7.12 and 3.47 in the light scheme, 9.29 and 5.54 in the dark one), and a pair not in the table is never drawn.Textis the only component that paints caller text, and it paints only through a text role. - The remaining colours are fixed non-text graphics, each drawn against listed surfaces of
CONTRAST_PAIRS: the control border (border-control) of inputs, secondary buttons, segments and the switch's track, the slider's rail and a pending step's ring, the invalid border (danger-accent), the selected-row band, the radio mark, the current tab's marker and the slider's thumb (info-accent), and the focus ring (focus). The solid buttons', the pressed segment's and the checked switch track's border is their own fill, and the rules of cards, tables, comparison headings, description lists and dialogs (border-subtle) are decorative, never a component's only boundary. A switch's thumb stands out from its track by at least 3 : 1 in both schemes. - In forced-colors mode (Windows high contrast), which removes surfaces, shadows and backgrounds and paints transparent borders, the package redraws its non-colour cues in system colours: the selected row's start band (
Highlight), the progress and meter tracks and fills (CanvasText/Highlight), the current tab's marker (Highlight, the other tabs' markers sunk intoCanvas), the switch's thumb (CanvasText,HighlightTexton aHighlighttrack when on), the slider's rail and thumb (CanvasText/Highlight), and the pressed segment and the current step's marker (HighlightwithHighlightText). The band is the row's own inline-start border, taken out of its inline-start padding (the row's box and text stay where an unselected row has them, and the band stays at the start in a right-to-left root). The focus ring sets onlyoutline, so a selected row that has focus keeps its band. Every rule is a border (the cell's own bottom border, taken out of the cell's bottom padding; no rule is drawn as a shadow, which forced colours remove), so it stays in the forcedCanvasTextwith no rule of its own, and nothing is positioned for forced colours, so the focus rings of rows and of controls in cells stay whole. - A console scans its own rendered DOM with
scanColourRoles(root)from@aiquants/console-ui/testing, the same oracle the package runs on its components: for each text node it reads the nearestdata-cui-fganddata-cui-surfaceand looks the pair up inCONTRAST_PAIRS(both schemes list the same pairs), and it fails a text with no role, a pair not in the table, and any inline paint declaration from the text up to its farther role element — every declared inline property whose name containscolor,background,fill,strokeorshadow(so every longhand such asborder-top-colororoutline-color), theborder,outline,text-decoration,column-ruleandtext-emphasisshorthands,border-image,opacity, filters and blend modes. It returns{failures, measured, texts}; assertfailuresempty andmeasurednon-empty.contrastRatio(sRGB channels in [0, 1], withhexToSrgbfor#rrggbb) is the one contrast computation.
Layout
- The outermost element of each layout component (
TileGrid,DataTable,ComparisonGrid,MasterDetail) is an anonymous container (container-type: inline-size) whose width the component queries; no viewport breakpoint is used.Cardhas a 1 px rule and 23 px padding (the rule's width comes out of the padding, so rule and padding sum to 24 and the card's outer box stays on the 4 px lattice),CardSectionhas no padding, rule or surface, so inside a card every layout measures the card's content width. - Layout components need a definite inline size from their parent. A container's content does not size it, so the anonymous container takes
inline-size: 100%of its parent: in block flow (Stack,Card,CardSection, aMasterDetailpane) that is the parent's width, and in a wrapping flex row (Cluster, a host flex row) it claims a full line. Inside a parent that sizes itself to its content (a card'sactions, an inline-block, a float) it gets no width of its own, so do not place a layout component there. - Container-query rules are emitted as React 19
<style href precedence="cui">elements keyed by their inputs (identical grids emit one element), outside thecuilayer, so they win over the layered base rules. TileGridchooses its column count among the divisors of the tile count (tileGridPlan), so the last row is never short.DataTableistable-layout: fixedwith rem column widths from px numbers; flexible columns share the rest equally, so their minimums must be equal (tableMinInlinePxrejects the table otherwise).ComparisonGridstacks one<dl>per column belowattributePx + columns × columnMinPx.MasterDetailgoes side by side when both minimums and the 24 px gap fit, the master preferring the 1 : φ share while neither pane goes below its minimum: master = min(max(masterMin, (c − 24) / (1 + φ)), c − 24 − detailMin), and the detail takes the rest. The upper clamp matters only when detailMin > φ × masterMin (a narrow list beside a wide detail).- Every rule that draws caller text wraps anywhere; nothing is cut with an ellipsis. A
Pillkeeps its text width + 16 px and wraps inside itself only when its text is longer than the column was sized for, instead of spilling into the next column. - Every
Textkeeps its own type step's line wherever it is placed (no rule forces a line height), and every line is a multiple of 4, so a cell, a list row or a stack mixing steps stays on the lattice: an outcome over its mutedcaptionreason paragraph in a table cell is 12 + 20 + 20 + 11 + 1 = 64 px. An inlineTextof a larger step than the line it sits on stretches that line off the lattice by the difference of the faces' metrics; give it a block of its own (as="p"). - The table's scroll frame is
position: relative, so it is the containing block of visually hidden text in its cells (for example the full name of a count button): the frame clips that text and scrolls it with the table, and a wide table scrolls inside its frame without widening the page. - A visible table caption (
captionHidden={false}) wraps at the frame's visible width, not at the table's width, and stays at the frame's start while the table scrolls sideways, so the whole name of a table wider than its card is readable without scrolling. - A table row is 12 + 20 + 11 + 1 = 44 px: 12 px above a
bodyline, 11 px below and the row's rule as each cell's own 1 px bottom border (the tables separate their borders, since a collapsed border adds half a rule to each row). A cell of kbodylines is 24 + 20k px. Table cells are top-aligned. In a table whose rows hold controls (rowSize="row-control", 52 px rows: 4 + 44 + 3 + 1) the cells are centred, which puts a one-line text on the centre line of a one-line bordered button's label. Size the button column so the label fits on one line (its width + 24 px padding + 2 px border): a label that wraps to k lines makes the button 24 + 20k px and the row 32 + 20k px, and its centred lines no longer share the text's line. This also holds only for buttons without anunavailablereason: an unavailable button belongs in card actions or aCluster, because its reason is drawn beside it inbody(13/20) and wraps under it in a narrow cell. - Controls size by
box-sizing: border-boxwhatever the host's reset: a text input, a select, a one-line bordered button and a segment are all 44 px high (1 + 11 + 20 + 11 + 1), a bordered label that wraps to k lines is 24 + 20k px, and a text area of k rows is 24(k + 1) px (1 + 11 + 24k + 11 + 1), never the height of a one-line field. - A
RowButtonisbodytext with 12 px of block padding, so a one-line row is the 44 px target. Its direct children are lines 4 px apart (the reach of aPillbeyond the 20 px line it sits on), each keeping its own type step's line, so a row of kbodylines is 20 + 24k px and a row mixing atitlename andbodylines stays on the lattice. - A
Pillis 28 px high (acaptionline with 4 px above and below): in a 44 px control or row it sits 8 px from each edge, and on abodyline it overlaps the line by 4 px above and below (negative block margins), so its box is centred on the line and the line stays 20 px. Lines stacked next to a line of pills therefore need 4 px between them (aRowButtonkeeps them apart), and pills that may wrap onto several lines belong in aClusterwith a gap of at leastspace-2, whose wrapped rows keep the 28 px boxes apart (pills inline in one run of text overlap by 8 px when the run wraps). - A card's
actionsare centred on the title's 28 px line: their box is 44 px high with its content centred and overlaps the title line by 8 px above and below, so a 44 px bordered button and a 28 pxPillshare the title's centre and the header stays as high as the title line. When the title and the actions do not fit on one line, the actions wrap below the title 8 px apart (the header's 16 px row gap gives back the 8 px overlap); the wrapped row still overhangs the header box by 8 px, so the card's next child is 8 px below it instead of 16. ADialog's header shares these rules. - A
RadioGrouplegend is 4 px above its first option, like aFieldlabel above its control (the rendered legend sits outside the fieldset's flex box, so it carries the 4 px as its own padding). Each radio mark is a 20 px box (abodyline) without the browser's default margins: it starts at the legend's edge, sits centred in the 44 px option line (12 px above and below), and the option text starts 20 + 8 = 28 px from the edge. Button variant="link"andLinkdraw abodyline (13/20) with 12 px of block padding in the regular weight of the surrounding text,info-fgand an underline, as the sameinline-flexbox at least 44 px wide: a one-line link is exactly the 44 px target and a label that wraps to k lines is 24 + 20k px, on the lattice (a 44 px minimum height with the text centred would put a two-line label 2 px off it). EveryButtonandLinkdraws its label (children, orbusyLabelwhile busy) inside one label span, the only in-flow item of its flex box, so a label that mixes text and elements (所見 <Measured …/> 件を開く) is one paragraph: its spaces survive, it wraps as one text (24 + 20k px for the link-looking controls and for a bordered button) and its lines align like the control (start, or end in an end-aligned column, for the link-looking controls; centred in a bordered button, as the browser centres a button's text), whilejustify-contentplaces the label in a box wider than it. Visually hidden parts of the label stay out of flow. Every control is at least 44 px high, so place it on a line of its own, in card actions, in aClusteror in arow-controltable row: inside a sentence its box stretches that line. Bordered buttons draw their label inbody(13/20) and medium. Every clickable control (Button,Link,RowButton,Switch,SegmentedControl's segments,Disclosure's button) inheritsfont-variant-numeric(the browser'sfontshorthand on<button>resets it), so a clickable count in an end-aligned table column keeps the column's tabular figures.- The added controls are on the same lattice: a tab is 12 + 20 + 10 + 2 = 44 px (the 2 px marker is its bottom border, so its text stays centred), a breadcrumb item, a switch, a slider, a disclosure button and a step are 44 px lines, a switch's track is 52 × 28 px (two 20 px thumb places and three 4 px gaps across), a slider's thumb is a 20 px disc on a 4 px rail centred in its 44 px box, a step's marker is 28 px (the pill's height) and a description-list row is a table row (44 px, 24 + 20k px for k lines).
- In the stacked layout of
ComparisonGrideach column is its heading, 8 px above its definition list and underlined by the same decorative rule as the table layout's header row, then the attribute pairs 8 px apart; columns are 16 px apart.
Focus and announcements
Buttoninbusyorunavailable: {reason}state isaria-disabled="true"with a press that does nothing (neverdisabled, which drops focus to<body>); the reason is drawn beside the button and is its accessible description.useFocusRescue(getTarget)returns a ref with one identity for the owner's lifetime; when the region it is attached to disappears while holding focus, focus moves togetTarget()right after the commit that removed it — also when the region is rendered and removed by a descendant that re-renders on its own. Focus resting on<body>or elsewhere is never taken, neither then nor on a later re-render. Exception: a pointer click on text inside aDataTablefocuses its row (tabIndex={-1}, theLoadMorefocus target) or its scroll frame (tabIndex={0}), not<body>, so a region holding a table the operator clicked into counts as holding focus, and its disappearance moves focus (and scrolls) to the target.LoadMoremoves focus to the first row the page appended (or the end-of-records text) only if focus is still on its button, or left with it; an operator who moved on while the page loaded keeps their place. When the button and the end-of-records text swap outside a load (a re-read exhausts the records, or brings more while the end text has focus) and the one leaving held focus, focus moves to the one now rendered. The appended rows are told from the others by element identity, not by position, because a write's reply may remove, replace or add rows while the page is in flight: the rows shown at the press and at every commit whilebusyis up come before the page, the page's rows arrive in the commit in whichbusyfalls (asusePagedResourcedelivers them), and the first of them is the marked element right after the last row shown before. A row a write added in front of or among the shown rows is not the page's, and a page that appended nothing after them (an empty last page) counts as adding nothing. Withoutbusy, the rows shown at the press come before the page.data-cui-row-focusis the public row-focus hookLoadMorefinds rows by: insiderows.refeach shown row carries exactly one element with it (rows.countin all), and a row keeps that element while it stays shown (key rows by their record).DataTablerows andRowButtonset it; a custom row list (cards, a tree, grouped rows) setsdata-cui-row-focus=""on each row's focusable element (for example withtabIndex={-1}) and passes the element holding them asrows.ref. In the first commit after a press,LoadMorethrows whenrows.refis not attached or holds a number of marked elements other thanrows.count.- The focus ring (2 px
focus, 2 px outset) is drawn only on the elements the package lets take focus; host controls placed inside the root keep their own focus style. Toolbaris one stop in the Tab order (a roving tabindex over its buttons,aria-disabledones included): Tab enters it at the control that last had focus (the first until then), the arrow keys move to the next and previous control and wrap around (swapped in a right-to-left toolbar), and Home and End move to the first and the last.TabsandBreadcrumbare real links, each in the Tab order.Dialogopens withshowModal(), so the browser traps focus in it, makes the rest of the document inert and closes it on Escape; focus starts on its close button and returns, when it closes or unmounts open, to the element that had it when it opened.onCloseis called when the operator closes it (the close button or Escape), not when the caller setsopentofalse. Escape does not close it while focus is in a text field holding a value, asuseEscapeToClose.- The root owns the one announcer:
useAnnounce()returnsannounce(text). It writes to the root's polite live region or, while aDialogof the console is the topmost open modal, to that dialog's own polite region (which the modal leaves reachable while it hides the root's). Calls are coalesced over a quiet window: every call restarts a timer ofANNOUNCE_SETTLE_MS(150 ms), and when it passes without a call (or 1 s after the first pending call, so a stream of calls cannot hold the write back) the pending sentences are written to the region once, as one string in call order. A browser exposes the accessibility tree per rendering update, not per task, while one operator action spreads its calls over several tasks (an async handler continues afterawait, and React runs a default-lane commit's passive effects in a later task), so a write per task would let a later sentence replace an earlier one before it is read. The merge is by time, not by cause: calls less than 150 ms apart are one write unless the 1 s deadline of the pending write falls between them. The calls of one commit or one action are therefore one write when they come within the window and before that deadline; a handler that awaits longer than the window between two calls, or a commit whose passive effects run after the deadline, gives two writes. Any two writes are at least 150 ms apart, so no sentence is replaced before it is exposed. The same sentence announced again later is read again.announcethrowsRangeErrorfor an empty or blank sentence (a missing label or outcome text), which would otherwise clear the region or pad the merged string.AlertMessage(role="alert", keyed by the failure'soccurrence) is used for failures only;Noticehas no live-region role. A live region hidden from assistive technology drops every sentence without an error, so the write throwsError(from the timer, as an uncaught error that names the lost sentences) when, at that moment, the console is rendered but its announcer is hidden from assistive technology: an ancestor of the announcer isinertoraria-hidden="true"(in any letter case, as ARIA reads the value), or the topmost open modal<dialog>does not contain the console and is not aDialogof the console (everything outside the topmost modal is inert, lower modal dialogs included). The DOM does not expose which open modal is topmost, so it is told from focus, which inert content cannot hold: the candidates are the open modal dialogs that contain the focused element, or all of them while focus is in none. The write lands in the root's region when every candidate contains the console; otherwise, when the innermost candidate (the one inside all the others, which is the topmost) is aDialogof the console, it lands in that dialog's region; otherwise it throws when no candidate contains the console, and when only some do (modal dialogs inside and outside the console's ancestors are open at once, and focus is in none of them) whether the sentence would be heard cannot be told, and the write throws as well. The check runs at the write, not at the call, so a handler may announce its outcome and close the modal in the same task, or let an effect close it, as long as the modal is closed when the quiet window passes: an outcome announced inside aDialogthat then closes is heard in the root's region.JobCard's state line is a live region of its own (polite and atomic, holding the job's hidden name and its state pill), so a changed state is read as<name> <state>without moving focus or interrupting (WCAG 2.2.4), exactly as drawn. The check needs the:modalselector (Chrome 105, Firefox 103, Safari 15.6):ConsoleUiRootprobes it once when it mounts and throws there, naming the requirement, in a document without it. A console that is not rendered is not checked: an ancestor withhiddenordisplay: none,visibility: hiddenorcontent-visibility: hidden, a closed<details>or<dialog>, or<Activity mode="hidden">(which detaches the announcer while keeping the root) hides the console from the operator as well, so its announcements are dropped without an error, as after unmount. A host that must not lose an outcome keeps the console rendered until the outcome is read. useEscapeToClose(onClose)ignores Escape during IME composition and in a text field that holds a value, including a partly typed date, time or number field (itsvalueis""whilevalidity.badInputis true): closing the region would discard what the operator typed. Escape changes nothing in most of these fields (atext,numberortextareavalue stays; only asearchfield clears its value), so the operator leaves the field (Tab) or empties it before Escape closes the region.
Reads
A host read resolves to
ResourceOutcome<T>({ok: true, value}or{ok: false, status, reason}) and takes anAbortSignalas its last argument. The status and thereason, of a command's outcome as well, carry different facts. A refusal is the status's to state (HTTP_FORBIDDEN), and every sentence that shows a failure states it through one rule,labels.failureSentence(failure, refusal, failed)(labelsfromuseConsoleUi(), orresolveConsoleUiLabels(locale)outside React): a 403 isforbiddenBecause(refusal, reason), the caller's own fixed refusal followed by the declared cause, and any other status, ornullfor no response, isfailed(reason), the caller's own failed sentence of the reason (the catalog'sloadFailedandrefreshFailedfit asfailed; a screen's own sentence places the reason withreasonAside).failedis never called for a 403, so no sentence shows a refusal without stating it.ResourceFailedbuilds its sentence with it from itsforbiddenMessage, and so does a screen whose own sentence shows a failure (its own re-read, a command):const sentence = labels.failureSentence(outcome, "最新の内容の読み込みは拒まれました。", (reason) => `最新の内容を読み込めませんでした${reasonAside(reason)}。`) // 403 and "" → 最新の内容の読み込みは拒まれました。 // 403 and "閲覧の権限に行のスコープが設定されています" → 最新の内容の読み込みは拒まれました。閲覧の権限に行のスコープが設定されています。 // 503 and "HTTP 503" → 最新の内容を読み込めませんでした (HTTP 503)。reasonis a phrase without a closing full stop, because the sentence that shows it ends it. A 403'sreasonnames only the cause the route declared (which permission or scope to fix) and states no refusal, not even as a consequence of the cause (閲覧の権限に列のスコープが設定されています, not…ため、閲覧できません), and is""when the route declared none, since a phrase the host makes up there (the status, "refused") only repeats the status the failure already carries; only a 403'sreasoncan be"".""means that no cause was declared, and every sentence built from areasonleaves it out, the package's and a screen's alike:loadFailed("")is読み込めませんでした。, and a screen that places areasonin parentheses in its own sentence does so withreasonAside(reason)(a space and the reason in parentheses, or""for""). A sentence that does not branch on the status (loadFailed,refreshFailed) then says only that the read failed.PageFailurecarries no status, so a host whose later page can be refused routes that 403 to a read that carries it: a re-read of the first page, whose 403 withdraws the shown list (keepLastGood, below) forResourceFailedto state as above. Adapt a host read to the shape a hook reads withmapOutcome(outcome, map), which maps a success's value and passes a failure through as the same object (itsstatusandreason, so a 403 stays a 403 forResourceFailed). When a host success is not always a success of that shape (a later page the host answers as not measured, which the list shows as a failed page), useflatMapOutcome(outcome, map):mapreturns an outcome, a success or a failure, and a failure of the host read still passes through as the same object without callingmap.A command the host can refuse with a structured body (a code, the current row of a stale command, field errors) resolves to
CommandOutcome<T, R>:{ok: true, value}or{ok: false, status, reason, rejection}, whererejectionis the refusal body the host parsed with its own schema (R), andnullwhen there was no response or the body was not one the schema reads (a network failure, a status the command does not declare, an unreadable body). The screen derives its sentence and its state change fromrejectionwhen it is present, narrowing on the rejection's own discriminant (!outcome.ok && outcome.rejection?.code === "STALE"narrows to what that code carries, for example the current row it puts in place withputorupdateList), and showsreasonotherwise. Every failure carriesrejection, so a failure that leaves it out is a type error, and everyCommandOutcome<T, R>is aResourceOutcome<T>.keyholds every input that selects the target or the list. Encode several inputs injectively withJSON.stringify([...inputs])(for a findings list, the run and the cell:JSON.stringify([runUid, store, scopeKey, itemClass])): joining free text with a separator is not injective (categoryAwith queryx|yand categoryA|xwith queryygive the sameA|x|y). The read functions are read through a ref, so changing them alone never re-reads, and a key that leaves out a selecting input, or encodes two inputs to the same key, keeps showing the first target's data without an error.useResource(key, load, revision)isidlefor anullkey, starts a new key fromloading, re-reads the same key on arevisionchange,refresh()orreload()while keeping the shown value (beginReload,keepLastGood), aborts the read in flight on every re-read and discards its answer. A failure stays on screen while it is re-read; only areload()marks itretrying(its reload button busy), arevisionorrefresh()re-read leaves it as it is. A rejection that is not the abort of a superseded read is rethrown to the nearest error boundary. A failed re-read keeps the shown value and raisesrefreshFailure, all but a 403: the operator may no longer read the value, sokeepLastGoodwithdraws it and the resource isfailedwithoccurrence1, exactly as a first read answered 403 is (ResourceFailedthen states the refusal and its cause, with no reload). A 403 failure then stays as it is, its reason and itsoccurrenceincluded, through every later answer that carries no value (another failure such as a 503, a re-read in flight, another 403): none of them says anything about the permission the 403 revoked, so the refusal is announced once and no later failure brings the value back. Only a value read again for the key (a read's success, orput) or another key ends it.reload()is the operator's re-read (theReloadbutton ofResourceFailed);refresh()is the screen's own re-read of the same target, the same as arevisionchange, for a re-read a screen starts after a command (a count after a decision) without a prop of its host's.put(key, value)shows the value a write's reply carries (the decided detail, or the current detail a 409 returns) as the success of a read, only whilekeyis still the shown key (pass the key the command started on); a read of that key in flight is aborted and started again, because its answer may predate the write.usePagedResource(key, {first, next}, revision)reads a keyset-paged list:first(signal)resolves toResourceOutcome<KeysetPage<T, C, M>>({rows, next, meta}:nextis the cursor the next page starts after,nullwhen the records are exhausted, andmetais the list's own data, such as a total or category choices, which only the first page carries), andnext(cursor, signal)toResourceOutcome<NextPage<T, C>>({rows, next}; a later page that carriesmetais a type error). The first page followsuseResource(idlefor anullkey,loadingfor a new key,revision,refresh()andreload()keep the shown list while re-reading, and a 403 withdraws it), and a re-read's success replaces the whole list with the new first page, dropping the pages read after it together with any focus inside them.loadMore()appends the next page; a failed page keeps every shown row and advances its ownmoreFailure.occurrence. A key change, arevisionchange,refresh(),reload()and unmounting abort the page in flight, no page starts while the first page is re-read, and a page that settles after the list moved on is discarded, so a stale page is never appended to a new first page.loadMore()throws outside a shown list with a next cursor, no page in flight and no re-read in flight. Reflect a command on a row withupdateList(key, map), not withreload()orrevision:mapreceives the shown{rows, meta}(PagedContent<T, M>) and returns them changed together, replacing the row with the reply's row, leaving a deleted row out or adding a registered one, and changing the list's own data with it (a new category choice, a total). It keeps every page read so far, the cursor (a value, still valid when its row goes) and the rows' DOM, so focus on a row past the first page stays. It applies only whilekeyis still the shown list's (a reply that comes after the operator changed the filter is dropped); a first-page read ofkeyin flight is aborted and started again, because its answer may predate the write, and a page in flight continues, its answer predating the write as well, so everymapapplied while it is in flight is applied again, in order, once its rows are appended (a row the write replaced or removed arrives replaced or removed). Whenkeyshows no rows (the operator came back to the list and it is read from scratch, or its first read failed) the reply changes nothing, and a read in flight is still started again.mapmust be idempotent and give the same list whether or not the shown list already holds the write: when the operator leaves the list and comes back, the list is read again after the command was sent, and that read can settle before the reply, and a page in flight gets the samemapagain. A replace or a removal is idempotent by itself; an add must be an upsert by the row's identity, and a change ofmetamust be derived the same way (add a category only when absent, change a total only when the upsert inserted the row). The value maps ontoLoadMore;busyincludes a re-read of the first page, so the button cannot start a page the re-read would drop:const findingsKey = JSON.stringify([runUid, store, scopeKey, itemClass]) const { state, loadMore, updateList } = usePagedResource(findingsKey, { first: loadFirstFindings, next: loadNextFindings }, revision) // state.kind === "ready" <LoadMore onLoad={loadMore} busy={state.value.loadingMore || state.refreshing} done={state.value.next === null} failure={state.value.moreFailure} limit={FINDINGS_PAGE_LIMIT} rows={{ count: state.value.rows.length, ref: tableRef }} testId="findings-more" /> // a command started on the list of listKey; its reply carries the row's new state updateList(listKey, ({ rows, meta }) => ({ rows: rows.map((row) => (row.authorityId === reply.row.authorityId ? reply.row : row)), meta })) // a register: an upsert, so a list that already holds the new row keeps one copy, and a category choice added only when absent const sameRow = (row: AuthorityRow) => row.authorityId === reply.row.authorityId updateList(listKey, ({ rows, meta }) => ({ rows: rows.some(sameRow) ? rows.map((row) => (sameRow(row) ? reply.row : row)) : [reply.row, ...rows], meta: meta.categories.includes(reply.row.category) ? meta : { ...meta, categories: [...meta.categories, reply.row.category] }, }))ResourceFailedis the one way to draw a failed read:labels.failureSentence(failure, forbiddenMessage, failed)in anAlertMessage, wherefailedisloadFailedor the section's own sentence (failureMessage), and aReloadbutton that stays busy while re-reading. A 403, a first read's or a re-read's that withdrew the shown value, draws no reload, andlabels.forbiddenBecause(forbiddenMessage, reason): the caller's fixed message, a complete sentence ending with the UI language's full stop (。or.), followed by the declared cause as a second sentence (閲覧する権限がありません。andファイルの閲覧権限に列のスコープが設定されていますgive閲覧する権限がありません。ファイルの閲覧権限に列のスコープが設定されています。; English addsReason: <reason>.). A 403 never reaches the section's own sentence (failureMessage). The caller'suseFocusRescueref (rescueRef) is attached to theReloadbutton, so focus on it moves to the section heading both when the reload succeeds (the block goes) and when it comes back 403 (the button goes).
Components
| Component / hook | Contract |
| --- | --- |
| ConsoleUiRoot, useConsoleUi, useAnnounce, ANNOUNCE_SETTLE_MS | Root, token scope, language, format locale, time zone, announcer and its quiet window. |
| Text, VisuallyHidden | Text in a role, a type step and optionally a face (face="mono" for identifiers); text read by assistive technology but not drawn. |
| Card, CardSection | h2 card (a named region that draws its content in the body type and the sans face) and flat h3 section (a landmark only with region, for a non-modal region such as an inspector); headingRef makes the heading a programmatic focus target (tabIndex={-1}). A card or section that another control expands takes that control's aria-controls target as its id (the detail of a SelectableList is <CardSection id={detailId} …>), so the reference resolves. |
| Stack, Cluster | Vertical and wrapping horizontal layout with a spacing token. |
| Button, Link | Buttons that keep focus when they cannot act; an in-document move drawn as a link (no href). |
| Pill | A text status label, 28 px high (a body line it sits on stays 20 px), text width + 16 px wide, vertical-align: top; as a line of a RowButton it keeps that width. A line mixing a Pill and text is one Text span containing the Pill. |
| Notice, AlertMessage | Quiet note; failure message read once per occurrence. |
| Measured | A count, or the unmeasured label for null (never 0). |
| MetricTile, TileGrid | Metric tiles (100 px high) and the divisor grid. count is checked against the element children: a Fragment's children count one by one, a condition that renders no tile (false, null) does not count, a text child throws, and a component child counts as one tile whatever it renders (a wrapper returning null or two tiles is still one), so render MetricTile elements directly. |
| DataTable, LoadMore | Fixed-layout table in a focusable named scroll frame; load-more that moves focus to the first row the page appended or the end-of-records text. |
| CodeBlock | Preformatted text (a JSON document, an excerpt of a log) drawn exactly as given in the mono face and the body type, without wrapping, in a scroll frame on sunken with 8 px padding: a region named by label that takes keyboard focus, so a long line can be scrolled into view from the keyboard. As a focusable frame it is never lower than the 44 px target (one line draws 44 px; k lines draw 2 × 8 + 20k px from two lines on). |
| SelectableList, RowButton | Master list of selectable rows (data-selected, aria-expanded, aria-controls); the list carries an explicit role="list", which WebKit needs to expose an unstyled list as a list. |
| ComparisonGrid | Side-by-side comparison: a table named by its (visually hidden) caption, or one group per column named by its heading. Both layouts are in the DOM; each cell is a function called once per layout with "table" or "stack", so every cell is rendered and mounted twice (the hidden copy included) and the caller gives each copy its own ids, test ids and refs. Cells must be presentational: no data read (useResource), announcement (useAnnounce) or focus management (useFocusRescue); keep state and effects above the grid and pass values into the cells. |
| Field, TextInput, PasswordInput, TextArea, Select, RadioGroup | Fields tied to their label, description and error with useId. PasswordInput masks what is typed, tells the browser's password manager what the password is (autoComplete: new-password for one the operator sets, current-password for a sign-in) and turns spell checking and capitalization off. |
| ProgressBar | Determinate with a maximum (clamped at 100 %); indeterminate without, drawn as a quarter-length segment that sweeps the track when motion is allowed and rests in its middle otherwise. The visible label names the bar; the visible value text is hidden from assistive technology, which reads it once as the bar's aria-valuetext. |
| Meter | A value at its place in a fixed range (a score in [0, 1], a share of a quota), not progress: role="meter" named by label (no visible label; the row or field around it says what it measures), its fill in info-accent on a sunken track as long as the value's place in the range, beside the value text in fg (hidden from assistive technology, which reads it once as the meter's aria-valuetext). It is phrasing content that fits one body line, so it can be a line of a RowButton: it takes its parent's whole width even where the parent aligns its lines to the start (a RowButton), and the 8 px track is centred on the value text's 20 px line and takes the width left after the text, never less than 48 px. Refuses a range that is not finite or whose min is not below max, and a value outside the range. |
| MasterDetail | Master–detail layout by container width; side by side, neither pane goes below its minimum. |
| Heading | A page or region heading outside a Card: h1 to h3 in a heading step (title, heading, display, display-lg), bold, without margins; with headingRef a programmatic focus target (tabIndex={-1}) for the focus a host moves after a navigation. |
| Tabs | The tab row of a console whose tabs are routes: a named nav of real links (not an ARIA tablist), the current one aria-current="page" with an info-accent marker and the medium weight. A plain click hands the tab's href to onNavigate (the host's router); a click with a modifier key or another button keeps the link's own behaviour. |
| Breadcrumb | The trail to the current page: a nav named by the UI language's breadcrumb label around an ordered list of the ancestors' links (drawn like Link) and the current page's name as aria-current="page" text; separators are hidden from assistive technology. Clicks as Tabs. |
| Toolbar | role="toolbar" with a roving tabindex over its buttons (see Focus and announcements), laid out like a Cluster with an 8 px gap. |
| Dialog | A modal <dialog> named by its title, with its close button and its own live region (see Focus and announcements); at most 40 ideographs plus its padding wide (WCAG 1.4.8), never wider than the viewport less 24 px on each side. |
| Switch | An on/off setting that takes effect at once: role="switch" with aria-checked, named by its label; the thumb sits at the track's start when off and at its end when on, moving over motion-1. |
| Slider | A labelled native input type="range" whose value text is its aria-valuetext, drawn beside it and hidden from assistive technology; changes arrive as numbers. Refuses a range that is not finite, min not below max, a step that is not positive and a value outside the range. |
| SegmentedControl | One choice among a few options: a named group of toggle buttons (aria-pressed) with exactly one pressed (info-solid with on-solid text); a press on the pressed option asks for nothing. Needs two or more options with unique values. |
| Disclosure | A button with aria-expanded and aria-controls over the panel it shows and hides (hidden while closed), with a ▸ / ▾ marker; the open state is the caller's. |
| DescriptionList | A dl of name–value rows (dt and dd grouped in a div), the term a third of the row in fg-secondary. |
| Stepper | A job's steps as a named ordered list: the current step aria-current="step", the others followed by the hidden stepDone or stepPending word; a finished step's marker is a check mark on success-solid, the current one its number on info-solid. The states must run done, at most one current, then pending. |
| JobCard | One job in a Card: its state pill in a live region of its own, an optional ProgressBar and an optional cancel button in the card's actions that is busy while the cancel is under way. |
| useEscapeToClose, useFocusRescue, useResource, usePagedResource, ResourceFailed | See above. |
Every component except Text requires testId, exposes its state as data-* attributes, takes no className, and accepts caller data-* attributes through dataAttributes where a host needs to mark it (data-cui-* and data-testid are reserved and rejected there). The one data-cui-* attribute a host writes itself is the row-focus hook data-cui-row-focus, on the row elements of its own row list (see Focus and announcements).
The test ids a component derives from its testId are public test hooks:
| Component | Derived test ids |
| --- | --- |
| ConsoleUiRoot | <testId>-announcer (the live region) |
| Tabs | <testId>-<id> (each tab's link) |
| Breadcrumb | <testId>-<id> (each ancestor's link), <testId>-current (the current page's name) |
| Dialog | <testId>-close (the close button), <testId>-announcer (the dialog's live region) |
| Switch | <testId>-track, <testId>-thumb |
| Slider | <testId>-field (the field root; testId itself is on the input), <testId>-value (the drawn value text) |
| SegmentedControl | <testId>-<value> (each option's button) |
| Disclosure | <testId>-button, <testId>-panel |
| DescriptionList | <testId>-<key> (each row) |
| Stepper | <testId>-<id> (each step) |
| JobCard | <testId>-status (the live region), <testId>-status-pill, <testId>-progress (with the ProgressBar ids), <testId>-cancel |
| Button | <testId>-reason (the unavailable reason) |
| DataTable | <testId>-frame (the scroll frame), <testId>-empty (the empty text) |
| SelectableList | <testId>-empty (the empty text) |
| LoadMore | <testId>-button, <testId>-failure, <testId>-end |
| ResourceFailed | <testId>-alert, <testId>-reload |
| TextInput, PasswordInput, TextArea, Select | <testId>-field (the field root; testId itself is on the control), <testId>-description, <testId>-error |
| Field, RadioGroup | <testId>-description, <testId>-error; RadioGroup also <testId>-option-<value> |
| ProgressBar | <testId>-bar (the progressbar), <testId>-fill, <testId>-value (the visible value text) |
| Meter | <testId>-bar (the meter), <testId>-fill, <testId>-value (the visible value text) |
| CodeBlock | <testId>-code (the pre; testId itself is on the frame) |
| MetricTile | <testId>-value (the Measured value) |
| MasterDetail | <testId>-master, <testId>-detail |
Development
pnpm --filter @aiquants/console-ui run build # dist/index.*, dist/core.*, dist/testing.*, shared dist/chunk-*, dist/styles/console-ui.css
pnpm --filter @aiquants/console-ui run clean # removes dist
pnpm --filter @aiquants/console-ui run typecheck
pnpm --filter @aiquants/console-ui run test:coverage # exits non-zero below 100 % statements / branches / functions / linesThe stylesheet tests read src/styles/components.css with node:fs and parse it with postcss.
Testing in jsdom
Announcements are written once the quiet window passes (ANNOUNCE_SETTLE_MS, 150 ms, exported from @aiquants/console-ui). The write does not go through React state, so reading it needs no act.
- By default read the live region (
<testId>-announcer) with real timers and a waiting query:await waitFor(() => expect(screen.getByTestId("console-announcer")).toHaveTextContent("承認しました。"))orawait screen.findByText("承認しました。"). The window is well inside their 1 s default timeout, and user-event works as usual. - Fake the clock only to assert timing (nothing is written before the window ends, two writes a window apart). Faking
setTimeoutfakes it for the whole test, including thesetTimeoutthat Testing Library'swaitFor/findBy*and user-event wait on (their own clock advance goes through ajestglobal that Vitest does not define), so withvi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] })everyawait user.click(...),findBy*andwaitForhangs until the test times out. Either drive the screen withfireEventinsideactand advance withvi.advanceTimersByTime(ANNOUNCE_SETTLE_MS)(React's scheduler keeps its realsetImmediate), or addshouldAdvanceTime: trueso the fake clock follows real time and those helpers settle; a "not written yet" assertion is then reliable only before the firstawait.
ConsoleUiRoot needs a test DOM that parses the :modal selector (see Focus and announcements): jsdom 27 or later. On jsdom 26 and earlier the root throws when it mounts (ConsoleUiRoot requires the :modal selector …). jsdom 27 and later, and happy-dom, parse :modal but never match it, even after showModal(), so a test of an announcement under a modal dialog answers document.querySelectorAll("dialog:modal") itself with the dialogs a browser would return, and moves focus as the browser would (into the topmost modal).
jsdom 27 reflects a dialog's open but implements neither showModal() nor close(), which Dialog calls, so a test of a Dialog installs both on HTMLDialogElement.prototype first: a model that sets and removes open, focuses the dialog's first focusable descendant, returns focus on close to the element that had it at the open, and fires close in a later task (the browser's order). It cannot make the rest of the document inert.
jsdom does not evaluate container queries, so only the layout components' default rules apply there. For ComparisonGrid that is the stacked layout: the column groups and the stack copy of every cell are shown and reachable by role, while the table is display: none and therefore not in the accessibility tree. Query the stacked copy (for example a cell test id tie-stack, or getByRole("group", { name })), and pass hidden: true to reach the table (getByRole("table", { name, hidden: true })); toBeVisible() is false for the table and its cells.
License
MIT
