npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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-ui

Entry 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 types ColorScheme, SchemeColor and MotionToken beside the other token names.
  • The geometry the tokens are computed on: LATTICE_PX (4), TARGET_PX (44), ROW_WITH_CONTROL_PX (52) and ceilToLattice(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, whose loadFailed, refreshFailed and forbiddenBecause build the failure sentences and whose failureSentence is 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, mapOutcome and flatMapOutcome.
  • The command contract: the type CommandOutcome<T, R> (a command the host can refuse with a structured body, which the failure carries as rejection).
  • 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, settlePage and mapPagedList.

@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: @import the stylesheet in the CSS entry after the Tailwind layer statement, so cui is declared after base (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: cui becomes a sub-layer of components, above base. The package's rules match only the package's own elements (and its focus ring only the elements it lets take focus), so ranking cui above utilities does 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, and prose, the reading line, at 1.75): caption 11/20, body 13/20, prose 13/24, title 16/28, heading 20/32, display 25/40, display-lg 31/48;
    • the 44 px target (TARGET_PX: a body line 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)). Only body (20) and title (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 in body or title.
  • The token variables are declared on [data-cui-root] (never :root) with lengths in rem, the strokes (border-width 1, focus-width 2, focus-offset 2, marker-width 2) 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 are 0ms while 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-fg paints text, data-cui-surface paints the surface text sits on, data-cui-accent paints non-text graphics. Every role has a light and a dark value, written as light-dark(<light>, <dark>) (see Wrapping a console for the scheme). CONTRAST_PAIRS.light and CONTRAST_PAIRS.dark list 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. Text is 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 into Canvas), the switch's thumb (CanvasText, HighlightText on a Highlight track when on), the slider's rail and thumb (CanvasText / Highlight), and the pressed segment and the current step's marker (Highlight with HighlightText). 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 only outline, 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 forced CanvasText with 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 nearest data-cui-fg and data-cui-surface and looks the pair up in CONTRAST_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 contains color, background, fill, stroke or shadow (so every longhand such as border-top-color or outline-color), the border, outline, text-decoration, column-rule and text-emphasis shorthands, border-image, opacity, filters and blend modes. It returns {failures, measured, texts}; assert failures empty and measured non-empty. contrastRatio (sRGB channels in [0, 1], with hexToSrgb for #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. Card has 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), CardSection has 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, a MasterDetail pane) 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's actions, 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 the cui layer, so they win over the layered base rules.
  • TileGrid chooses its column count among the divisors of the tile count (tileGridPlan), so the last row is never short. DataTable is table-layout: fixed with rem column widths from px numbers; flexible columns share the rest equally, so their minimums must be equal (tableMinInlinePx rejects the table otherwise). ComparisonGrid stacks one <dl> per column below attributePx + columns × columnMinPx.
  • MasterDetail goes 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 Pill keeps 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 Text keeps 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 muted caption reason paragraph in a table cell is 12 + 20 + 20 + 11 + 1 = 64 px. An inline Text of 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 body line, 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 k body lines 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 an unavailable reason: an unavailable button belongs in card actions or a Cluster, because its reason is drawn beside it in body (13/20) and wraps under it in a narrow cell.
  • Controls size by box-sizing: border-box whatever 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 RowButton is body text 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 a Pill beyond the 20 px line it sits on), each keeping its own type step's line, so a row of k body lines is 20 + 24k px and a row mixing a title name and body lines stays on the lattice.
  • A Pill is 28 px high (a caption line with 4 px above and below): in a 44 px control or row it sits 8 px from each edge, and on a body line 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 (a RowButton keeps them apart), and pills that may wrap onto several lines belong in a Cluster with a gap of at least space-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 actions are 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 px Pill share 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. A Dialog's header shares these rules.
  • A RadioGroup legend is 4 px above its first option, like a Field label 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 (a body line) 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" and Link draw a body line (13/20) with 12 px of block padding in the regular weight of the surrounding text, info-fg and an underline, as the same inline-flex box 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). Every Button and Link draws its label (children, or busyLabel while 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), while justify-content places 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 a Cluster or in a row-control table row: inside a sentence its box stretches that line. Bordered buttons draw their label in body (13/20) and medium. Every clickable control (Button, Link, RowButton, Switch, SegmentedControl's segments, Disclosure's button) inherits font-variant-numeric (the browser's font shorthand 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 ComparisonGrid each 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

  • Button in busy or unavailable: {reason} state is aria-disabled="true" with a press that does nothing (never disabled, 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 to getTarget() 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 a DataTable focuses its row (tabIndex={-1}, the LoadMore focus 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.
  • LoadMore moves 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 while busy is up come before the page, the page's rows arrive in the commit in which busy falls (as usePagedResource delivers 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. Without busy, the rows shown at the press come before the page.
  • data-cui-row-focus is the public row-focus hook LoadMore finds rows by: inside rows.ref each shown row carries exactly one element with it (rows.count in all), and a row keeps that element while it stays shown (key rows by their record). DataTable rows and RowButton set it; a custom row list (cards, a tree, grouped rows) sets data-cui-row-focus="" on each row's focusable element (for example with tabIndex={-1}) and passes the element holding them as rows.ref. In the first commit after a press, LoadMore throws when rows.ref is not attached or holds a number of marked elements other than rows.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.
  • Toolbar is one stop in the Tab order (a roving tabindex over its buttons, aria-disabled ones 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. Tabs and Breadcrumb are real links, each in the Tab order.
  • Dialog opens with showModal(), 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. onClose is called when the operator closes it (the close button or Escape), not when the caller sets open to false. Escape does not close it while focus is in a text field holding a value, as useEscapeToClose.
  • The root owns the one announcer: useAnnounce() returns announce(text). It writes to the root's polite live region or, while a Dialog of 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 of ANNOUNCE_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 after await, 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. announce throws RangeError for 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's occurrence) is used for failures only; Notice has no live-region role. A live region hidden from assistive technology drops every sentence without an error, so the write throws Error (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 is inert or aria-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 a Dialog of 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 a Dialog of 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 a Dialog that 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 :modal selector (Chrome 105, Firefox 103, Safari 15.6): ConsoleUiRoot probes 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 with hidden or display: none, visibility: hidden or content-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 (its value is "" while validity.badInput is true): closing the region would discard what the operator typed. Escape changes nothing in most of these fields (a text, number or textarea value stays; only a search field 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 an AbortSignal as its last argument. The status and the reason, 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) (labels from useConsoleUi(), or resolveConsoleUiLabels(locale) outside React): a 403 is forbiddenBecause(refusal, reason), the caller's own fixed refusal followed by the declared cause, and any other status, or null for no response, is failed(reason), the caller's own failed sentence of the reason (the catalog's loadFailed and refreshFailed fit as failed; a screen's own sentence places the reason with reasonAside). failed is never called for a 403, so no sentence shows a refusal without stating it. ResourceFailed builds its sentence with it from its forbiddenMessage, 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)。

    reason is a phrase without a closing full stop, because the sentence that shows it ends it. A 403's reason names 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's reason can be "". "" means that no cause was declared, and every sentence built from a reason leaves it out, the package's and a screen's alike: loadFailed("") is 読み込めませんでした。, and a screen that places a reason in parentheses in its own sentence does so with reasonAside(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. PageFailure carries 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) for ResourceFailed to state as above. Adapt a host read to the shape a hook reads with mapOutcome(outcome, map), which maps a success's value and passes a failure through as the same object (its status and reason, so a 403 stays a 403 for ResourceFailed). 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), use flatMapOutcome(outcome, map): map returns an outcome, a success or a failure, and a failure of the host read still passes through as the same object without calling map.

  • 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}, where rejection is the refusal body the host parsed with its own schema (R), and null when 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 from rejection when 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 with put or updateList), and shows reason otherwise. Every failure carries rejection, so a failure that leaves it out is a type error, and every CommandOutcome<T, R> is a ResourceOutcome<T>.

  • key holds every input that selects the target or the list. Encode several inputs injectively with JSON.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 (category A with query x|y and category A|x with query y give the same A|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) is idle for a null key, starts a new key from loading, re-reads the same key on a revision change, refresh() or reload() 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 a reload() marks it retrying (its reload button busy), a revision or refresh() 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 raises refreshFailure, all but a 403: the operator may no longer read the value, so keepLastGood withdraws it and the resource is failed with occurrence 1, exactly as a first read answered 403 is (ResourceFailed then states the refusal and its cause, with no reload). A 403 failure then stays as it is, its reason and its occurrence included, 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, or put) or another key ends it. reload() is the operator's re-read (the Reload button of ResourceFailed); refresh() is the screen's own re-read of the same target, the same as a revision change, 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 while key is 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 to ResourceOutcome<KeysetPage<T, C, M>> ({rows, next, meta}: next is the cursor the next page starts after, null when the records are exhausted, and meta is the list's own data, such as a total or category choices, which only the first page carries), and next(cursor, signal) to ResourceOutcome<NextPage<T, C>> ({rows, next}; a later page that carries meta is a type error). The first page follows useResource (idle for a null key, loading for a new key, revision, refresh() and reload() 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 own moreFailure.occurrence. A key change, a revision change, 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 with updateList(key, map), not with reload() or revision: map receives 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 while key is still the shown list's (a reply that comes after the operator changed the filter is dropped); a first-page read of key in 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 every map applied 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). When key shows 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. map must 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 same map again. A replace or a removal is idempotent by itself; an add must be an upsert by the row's identity, and a change of meta must be derived the same way (add a category only when absent, change a total only when the upsert inserted the row). The value maps onto LoadMore; busy includes 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] },
    }))
  • ResourceFailed is the one way to draw a failed read: labels.failureSentence(failure, forbiddenMessage, failed) in an AlertMessage, where failed is loadFailed or the section's own sentence (failureMessage), and a Reload button 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, and labels.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 adds Reason: <reason>.). A 403 never reaches the section's own sentence (failureMessage). The caller's useFocusRescue ref (rescueRef) is attached to the Reload button, 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 / lines

The 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("承認しました。")) or await 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 setTimeout fakes it for the whole test, including the setTimeout that Testing Library's waitFor / findBy* and user-event wait on (their own clock advance goes through a jest global that Vitest does not define), so with vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }) every await user.click(...), findBy* and waitFor hangs until the test times out. Either drive the screen with fireEvent inside act and advance with vi.advanceTimersByTime(ANNOUNCE_SETTLE_MS) (React's scheduler keeps its real setImmediate), or add shouldAdvanceTime: true so the fake clock follows real time and those helpers settle; a "not written yet" assertion is then reliable only before the first await.

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