feat(ui): E31 S4b — the admin pages behind one rail row, Configuration as a sectioned list, Statistics as tiles and bars

- NavRailGroup: one "Admin" rail row opening a panel of the admin pages (links, so a
  middle-click still opens a tab); the rail fits 133% zoom without a hidden scroll
- sysconfig.SECTIONS orders the config groups under four headings (every group in exactly
  one, test-held); the page shows them as Settings' side list (Tabs `group` headings);
  the lone registration group reads "Registration" under "Access"
- StatTiles / StatTile / StatBars: the Statistics overview's figures as tiles, the
  per-operation quota use as horizontal bars (a zero draws none)
This commit is contained in:
2026-10-02 00:07:00 +02:00
parent 25905aa786
commit be156fc52b
21 changed files with 753 additions and 223 deletions
@@ -7,8 +7,8 @@ import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { Bell } from "lucide-react";
import { NavRailAction, NavRailItem } from "./NavRail";
import { Activity, Bell, Shield, Users } from "lucide-react";
import { NavRailAction, NavRailGroup, NavRailItem } from "./NavRail";
vi.mock("../../lib/hints", () => ({ hintsEnabled: () => true, subscribeHints: () => () => {} }));
@@ -123,3 +123,86 @@ describe("NavRailAction", () => {
expect(src).not.toMatch(/<IconButton[\s>]/);
});
});
// E31 (the owner's pick): the five admin rows sat below the fold at 133% zoom behind a fade alone, so
// they are one "Admin" row opening a panel of them. The panel's pages stay links, as the rail's are.
describe("NavRailGroup", () => {
const pages = (active: string | null, onSelect = vi.fn()) =>
[
{ id: "scheduler", icon: Activity, label: "Scheduler" },
{ id: "users", icon: Users, label: "Users" },
].map((p) => ({ ...p, href: `/?page=${p.id}`, active: p.id === active, onSelect }));
it("is one row, current while any of its pages is, opening a panel of links", () => {
render(
<NavRailGroup
icon={Shield}
label="Administration"
shortLabel="Admin"
showLabel={false}
pages={pages("users")}
/>,
);
const row = screen.getByRole("button", { name: "Administration" });
expect(row.getAttribute("aria-current")).toBe("page");
expect(screen.queryByRole("link")).toBeNull();
fireEvent.click(row);
// jsdom never positions the panel, so it stays visibility:hidden and nameless to the role query.
const panel = screen.getByRole("dialog", { hidden: true });
expect(panel.getAttribute("aria-label")).toBe("Administration");
const links = Array.from(panel.querySelectorAll("a"));
expect(links.map((a) => [a.textContent, a.getAttribute("href")])).toEqual([
["Scheduler", "/?page=scheduler"],
["Users", "/?page=users"],
]);
expect(links.map((a) => a.getAttribute("aria-current"))).toEqual([null, "page"]);
});
it("is not current while none of its pages is", () => {
render(
<NavRailGroup
icon={Shield}
label="Administration"
shortLabel="Admin"
showLabel
pages={pages(null)}
/>,
);
const row = screen.getByRole("button", { name: "Admin" });
expect(row.getAttribute("aria-current")).toBeNull();
expect(row.className).not.toContain("bg-accent/15");
});
it("a plain click goes to the page and closes the panel; a middle or Ctrl click is the browser's", () => {
const onSelect = vi.fn();
render(
<NavRailGroup
icon={Shield}
label="Administration"
shortLabel="Admin"
showLabel={false}
pages={pages(null, onSelect)}
/>,
);
fireEvent.click(screen.getByRole("button", { name: "Administration" }));
const link = () =>
screen.getByRole("dialog", { hidden: true }).querySelector<HTMLAnchorElement>("a")!;
const leftToBrowser: boolean[] = [];
const record = (e: Event) => {
leftToBrowser.push(!e.defaultPrevented);
e.preventDefault();
};
window.addEventListener("click", record);
try {
fireEvent.click(link(), { button: 1 });
fireEvent.click(link(), { button: 0, ctrlKey: true });
expect(onSelect).not.toHaveBeenCalled();
fireEvent.click(link(), { button: 0 });
} finally {
window.removeEventListener("click", record);
}
expect(leftToBrowser).toEqual([true, true, false]);
expect(onSelect).toHaveBeenCalledTimes(1);
expect(screen.queryByRole("dialog", { hidden: true })).toBeNull();
});
});
+115 -1
View File
@@ -1,6 +1,8 @@
import clsx from "clsx";
import type { CSSProperties, ReactNode, Ref } from "react";
import { useState, type CSSProperties, type ReactNode, type Ref } from "react";
import type { LucideIcon } from "lucide-react";
import { PopoverSurface } from "./AnchoredPopover";
import { useAnchoredPopover } from "./anchoredPopoverHooks";
import { Pressable } from "./Pressable";
import { SURFACE } from "./surfaces";
import Tooltip from "./Tooltip";
@@ -171,3 +173,115 @@ export function NavRailAction({
</Tooltip>
);
}
/** One page of a `NavRailGroup`'s panel. */
export interface NavRailGroupPage {
id: string;
icon: LucideIcon;
/** The page's full name, written out in the panel. */
label: string;
href: string;
active: boolean;
/** The in-app navigation, run only for a plain left click. */
onSelect: () => void;
}
/** One rail row standing for a set of rare pages (E31, the owner's pick: the admin pages), opening a
* panel of them to its right. The rail fits a 133–150% zoom without a scroll nobody sees: five admin
* rows sat below the fold behind a fade alone. The row is "you are here" while any of its pages is.
* The panel's pages are real links, as the rail's own rows are (a middle-click opens a tab), so it is a
* `dialog` popover with its own Tab order, not a roving menu of buttons. */
export function NavRailGroup({
icon: Icon,
label,
shortLabel,
showLabel,
pages,
testId,
}: {
icon: LucideIcon;
/** The group's full name: the tooltip, the row's and the panel's accessible name, the panel heading. */
label: string;
shortLabel: string;
showLabel: boolean;
pages: readonly NavRailGroupPage[];
testId?: string;
}) {
const [open, setOpen] = useState(false);
const pop = useAnchoredPopover({
open,
onOpenChange: setOpen,
mode: "dialog",
role: "dialog",
placement: "right-start",
offset: 8,
});
const active = pages.some((p) => p.active);
return (
<>
<Tooltip hint={label} side="right" always openDelay={350}>
<Pressable
ref={pop.refs.setReference}
{...pop.getReferenceProps()}
data-testid={testId}
aria-label={showLabel ? shortLabel : label}
aria-current={active ? "page" : undefined}
className={clsx(
RAIL_ROW,
showLabel ? "py-1.5" : "h-9",
active ? "bg-accent/15 text-accent" : "text-muted state-layer hover:text-fg",
)}
>
{active && (
<span
aria-hidden="true"
className="absolute top-1.5 bottom-1.5 -left-1 w-0.75 rounded-r-full bg-accent"
/>
)}
<Icon className={RAIL_GLYPH} aria-hidden="true" />
{showLabel && (
<span className="max-w-full truncate text-3xs font-medium tracking-tight">
{shortLabel}
</span>
)}
</Pressable>
</Tooltip>
<PopoverSurface
popover={pop}
ariaLabel={label}
className={`${SURFACE.popover} w-56 p-1.5 animate-[popIn_0.16s_ease]`}
>
<div className="px-2 pt-1 pb-1.5 text-2xs font-semibold tracking-wide text-muted uppercase">
{label}
</div>
<ul className="flex flex-col gap-0.5">
{pages.map(({ id, icon: PageIcon, label: name, href, active: here, onSelect }) => (
<li key={id}>
<a
data-testid={`${testId ?? "group"}-${id}`}
href={href}
aria-current={here ? "page" : undefined}
onClick={(e) => {
if (!isPlainLeftClick(e)) return; // the browser opens its tab or window
e.preventDefault();
setOpen(false);
onSelect();
}}
className={clsx(
"flex items-center gap-2.5 rounded-lg px-2 py-1.5 text-sm transition touch-target",
here ? "bg-accent/15 text-accent" : "text-fg state-layer",
)}
>
<PageIcon
className={clsx(RAIL_GLYPH, "shrink-0", !here && "text-muted")}
aria-hidden="true"
/>
<span className="min-w-0 truncate">{name}</span>
</a>
</li>
))}
</ul>
</PopoverSurface>
</>
);
}
+7 -7
View File
@@ -36,13 +36,13 @@ domain-free.
## What lives here
| area | components |
| ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| overlays & anchoring | `AnchoredPopover` + `anchoredPopoverHooks` (the popover engine: arrows, typeahead, Escape, anchor-flip), `ActionMenu`, `ContextMenu` + `useContextMenu` (the SAME menu, opened by right-click / long-press / Shift+F10 / a row's `MoreButton` "…" via `buttonProps`; `anchor` for a panel a row opens; `PageMenu` for a page's background menu), `Modal` (+ `ModalFooter`; `initialFocus` by purpose, C4), `Overlay`, `SidePanel`, `Tooltip`, `Toaster` (+ `toastLevels`, `overlayOrder`; `lib/toastDock` docks one control under the stack, in every host), `CommandPalette`, `KeyboardHelp` |
| actions & inputs | `Button`, `IconButton`, `Pressable` (`selectable` when its text is a title or a name), `Chip` + `chipStyles`, `DragHandle` + `OrderButtons` / `useOrderItems` (the non-drag reorder, WCAG 2.5.7) (one per button family, docs/DESIGN.md; a raw `<button>` or a bare `FocusSafeButton` outside the control primitives — eslint `BUTTON_PRIMITIVES` — is a lint error), `Input` + `fieldStyles`, `NativeControls`, `MenuCheck` (the drawn check of a menu row or a whole-row checkbox button), `SelectTrigger` (the field family; a raw `<input>` is a lint error), `useReturnFocus`, `surfaces` (the B5 glass roles; a bare glass class is a lint error), `Badge` (a static label), `LinkButton` / `IconLink` (a link wearing the button face) over `buttonStyles`, `Skeleton` (B8 first loads, through `LoadingState shape`), `NavRail` (B7), `form`, `DraftSaveBar`, `Combobox`, `FilterSelect`, `SegmentedToggle`, `SortControl`, `ColumnSortControl`, `PriorityStepper`, `UndoToolbar` |
| data display | `DataTable`, `tableHeader` + `tableAlign`, `HiddenFilterCue` (names a filter whose column is off screen), `Pager` + `pagerContent`, `VirtualGrid` + `gridColumns` (the column/width maths, incl. the ultrawide `maxCols` cap), `readAhead`, `Carousel`, `MediaCard` + `mediaCardStyles` (the one media card, docs/DESIGN.md "Media card") + `MediaPreview` / `useMediaPreview` (its mouse-rest preview), `NavGrid` (a plain grid, list or table body the keyboard walks — `DataTable`, `VirtualGrid` and `Carousel` take `keyboardNav`), `Avatar`, `CountBadge`, `ItemCount`, `FilterChipGroup`, `RelativeTime` ("3d ago" with the exact minute on hover, docs/DESIGN.md "Copy"), `ClampedText` (a long text clamped to a few lines; More / Less only when it is cut) |
| page & panel structure | `PageShell` + `pageToolbarSlot` + `pageHeading`, `PageScroller`, `Headings` (`PageHeading`, `SectionLabel`), `SkipLink` + `landmarks`, `FilterFold` (B4), `PanelGroup`, `PanelGroups`, `Tabs`, `BackToTop` |
| state & messaging | `QueryState`, `Banner`, `InlineNotice` |
| area | components |
| ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| overlays & anchoring | `AnchoredPopover` + `anchoredPopoverHooks` (the popover engine: arrows, typeahead, Escape, anchor-flip), `ActionMenu`, `ContextMenu` + `useContextMenu` (the SAME menu, opened by right-click / long-press / Shift+F10 / a row's `MoreButton` "…" via `buttonProps`; `anchor` for a panel a row opens; `PageMenu` for a page's background menu), `Modal` (+ `ModalFooter`; `initialFocus` by purpose, C4), `Overlay`, `SidePanel`, `Tooltip`, `Toaster` (+ `toastLevels`, `overlayOrder`; `lib/toastDock` docks one control under the stack, in every host), `CommandPalette`, `KeyboardHelp` |
| actions & inputs | `Button`, `IconButton`, `Pressable` (`selectable` when its text is a title or a name), `Chip` + `chipStyles`, `DragHandle` + `OrderButtons` / `useOrderItems` (the non-drag reorder, WCAG 2.5.7) (one per button family, docs/DESIGN.md; a raw `<button>` or a bare `FocusSafeButton` outside the control primitives — eslint `BUTTON_PRIMITIVES` — is a lint error), `Input` + `fieldStyles`, `NativeControls`, `MenuCheck` (the drawn check of a menu row or a whole-row checkbox button), `SelectTrigger` (the field family; a raw `<input>` is a lint error), `useReturnFocus`, `surfaces` (the B5 glass roles; a bare glass class is a lint error), `Badge` (a static label), `LinkButton` / `IconLink` (a link wearing the button face) over `buttonStyles`, `Skeleton` (B8 first loads, through `LoadingState shape`), `NavRail` (B7; `NavRailGroup` folds a set of rare pages into one row with a panel), `form`, `DraftSaveBar`, `Combobox`, `FilterSelect`, `SegmentedToggle`, `SortControl`, `ColumnSortControl`, `PriorityStepper`, `UndoToolbar` |
| data display | `DataTable`, `tableHeader` + `tableAlign`, `HiddenFilterCue` (names a filter whose column is off screen), `Pager` + `pagerContent`, `VirtualGrid` + `gridColumns` (the column/width maths, incl. the ultrawide `maxCols` cap), `readAhead`, `Carousel`, `MediaCard` + `mediaCardStyles` (the one media card, docs/DESIGN.md "Media card") + `MediaPreview` / `useMediaPreview` (its mouse-rest preview), `NavGrid` (a plain grid, list or table body the keyboard walks — `DataTable`, `VirtualGrid` and `Carousel` take `keyboardNav`), `Avatar`, `CountBadge`, `ItemCount`, `FilterChipGroup`, `RelativeTime` ("3d ago" with the exact minute on hover, docs/DESIGN.md "Copy"), `ClampedText` (a long text clamped to a few lines; More / Less only when it is cut), `StatTiles` + `StatTile` / `StatBars` (number tiles, a breakdown as horizontal bars) |
| page & panel structure | `PageShell` + `pageToolbarSlot` + `pageHeading`, `PageScroller`, `Headings` (`PageHeading`, `SectionLabel`), `SkipLink` + `landmarks`, `FilterFold` (B4), `PanelGroup`, `PanelGroups`, `Tabs`, `BackToTop` |
| state & messaging | `QueryState`, `Banner`, `InlineNotice` |
## The size contract (E10 S1c)
@@ -0,0 +1,46 @@
// @vitest-environment jsdom
//
// E31 (the owner's pick for the Statistics overview): a figure's name sits by its number in a tile, and a
// breakdown is horizontal bars, largest first, so what spends the most is seen before a number is read.
import { afterEach, describe, expect, it } from "vitest";
import { cleanup, render, screen } from "@testing-library/react";
import { StatBars, StatTile, StatTiles } from "./StatTiles";
afterEach(cleanup);
describe("StatTile", () => {
it("holds its value and its name together", () => {
render(
<StatTiles>
<StatTile testId="t" label="Channels" value="398" />
</StatTiles>,
);
expect(screen.getByTestId("t").textContent).toBe("398Channels");
});
});
describe("StatBars", () => {
const rows = [
{ id: "b", label: "Delete playlist", value: 100 },
{ id: "z", label: "Live search", value: 0 },
{ id: "a", label: "Sync playlists", value: 400 },
];
it("lists the largest first, each bar its share of the largest, each number formatted", () => {
render(<StatBars label="By action" rows={rows} format={(n) => `${n} u`} />);
const items = Array.from(screen.getByRole("list", { name: "By action" }).children);
expect(items.map((li) => li.textContent)).toEqual([
"Sync playlists400 u",
"Delete playlist100 u",
"Live search0 u",
]);
const widths = items.map((li) => li.querySelector<HTMLElement>("[data-bar]")!.style.width);
expect(widths.slice(0, 2)).toEqual(["100%", "25%"]);
});
it("draws no bar for a zero", () => {
render(<StatBars label="By action" rows={rows} format={String} />);
const zero = screen.getByText("Live search").closest("li")!;
expect(zero.querySelector<HTMLElement>("[data-bar]")!.style.width).toBe("0px");
});
});
+85
View File
@@ -0,0 +1,85 @@
import type { ReactNode } from "react";
import { HintLabel } from "./form";
import { SURFACE } from "./surfaces";
// E31 (the owner's pick for the Statistics overview): label…value rows stretched across a wide column
// made the eye travel from a name to its number. A tile keeps the two together, a row of them is read at
// a glance, and a breakdown is a set of horizontal bars, so what spends the most is seen before a number
// is read.
/** A row of number tiles, as many to a line as fit. */
export function StatTiles({ children }: { children: ReactNode }) {
return (
<div className="grid grid-cols-[repeat(auto-fill,minmax(10rem,1fr))] gap-2">{children}</div>
);
}
/** One figure: its value large, its name under it (the name's hint on a resting pointer). */
export function StatTile({
label,
hint,
value,
testId,
}: {
label: string;
hint?: string;
/** Already formatted for the reader's language. */
value: ReactNode;
testId?: string;
}) {
return (
<div data-testid={testId} className={`${SURFACE.card} flex flex-col gap-0.5 px-3 py-2.5`}>
<span className="text-xl leading-tight font-semibold tabular-nums">{value}</span>
<span className="text-xs text-muted">
<HintLabel hint={hint}>{label}</HintLabel>
</span>
</div>
);
}
export interface StatBar {
id: string;
label: string;
value: number;
}
/** A breakdown as horizontal bars, largest first, each as long as its share of the largest. */
export function StatBars({
rows,
format,
label,
}: {
rows: readonly StatBar[];
/** The number as the reader's language writes it. */
format: (n: number) => string;
/** The list's accessible name. */
label: string;
}) {
const sorted = [...rows].sort((a, b) => b.value - a.value);
const max = Math.max(1, ...sorted.map((r) => r.value));
return (
// One grid for the whole list (each row a subgrid), so the name column is as wide as the longest
// name and every bar starts at the same line.
<ul
aria-label={label}
className="grid grid-cols-[minmax(0,max-content)_minmax(6rem,1fr)_auto] gap-x-3 gap-y-1.5"
>
{sorted.map((r) => (
<li key={r.id} className="col-span-3 grid grid-cols-subgrid items-center text-sm">
<span className="truncate text-muted" title={r.label}>
{r.label}
</span>
<span aria-hidden="true" className="h-2 overflow-hidden rounded-full bg-border/40">
<span
data-bar
className="block h-full rounded-full bg-accent"
// A zero draws no bar: a sliver would claim some use.
style={{ width: r.value > 0 ? `${Math.max(2, (r.value / max) * 100)}%` : "0" }}
/>
</span>
<span className="tabular-nums">{format(r.value)}</span>
</li>
))}
</ul>
);
}
+47 -32
View File
@@ -1,4 +1,4 @@
import { useId, useRef, type KeyboardEvent } from "react";
import { Fragment, useId, useRef, type KeyboardEvent } from "react";
import { panelId, tabId } from "./tabPanel";
import { type LucideIcon } from "lucide-react";
import CountBadge from "./CountBadge";
@@ -24,6 +24,9 @@ export interface TabDef {
badge?: number; // optional count pill (e.g. pending requests); hidden when 0/undefined
icon?: LucideIcon; // optional leading icon (the vertical settings rail)
testId?: string; // optional data-testid, to preserve existing e2e hooks
/** The vertical rail's heading this tab sits under (E31: Configuration's sixteen groups under four).
* Drawn above the first tab of each run; tabs of one heading stay together. */
group?: string;
}
export default function Tabs({
@@ -87,40 +90,52 @@ export default function Tabs({
onKeyDown={onKeyDown}
className={wrapCls}
>
{tabs.map((tb) => {
{tabs.map((tb, i) => {
const on = tb.id === active;
const Icon = tb.icon;
const heading = vertical && tb.group && tb.group !== tabs[i - 1]?.group ? tb.group : null;
return (
<button
key={tb.id}
ref={(node) => {
if (node) refs.current.set(tb.id, node);
else refs.current.delete(tb.id);
}}
type="button"
role="tab"
id={tabId(base, tb.id)}
aria-selected={on}
aria-controls={idBase ? panelId(idBase) : undefined}
tabIndex={tb.id === stop ? 0 : -1}
data-testid={tb.testId}
onClick={() => onChange(tb.id)}
className={
tone === "media"
? `touch-target flex-1 items-center justify-center whitespace-nowrap rounded px-2 py-1 text-2xs ${
on ? "bg-white/15 text-accent" : "text-white/70 hover:bg-white/10"
}`
: `pill touch-target items-center rounded-lg px-3 text-sm ${
vertical
? "inline-flex shrink-0 gap-1.5 py-1.5 sm:flex sm:w-full sm:justify-start sm:gap-2 sm:py-2 sm:text-left"
: "inline-flex gap-1.5 py-1.5"
} ${on ? "font-medium shadow-md shadow-accent/20" : "state-layer"}`
}
>
{Icon && <Icon className="h-4 w-4 shrink-0" />}
{tb.label}
{tb.badge != null && tb.badge > 0 && <CountBadge count={tb.badge} active={on} />}
</button>
<Fragment key={tb.id}>
{/* A heading, not a tab: no role a tab list would have to own, its text still read. Only
on the side rail; the phone's scrolling row has no room for it. */}
{heading && (
<div
role="presentation"
className="hidden px-3 pt-3 pb-1 text-2xs font-semibold tracking-wide text-muted uppercase first:pt-1 sm:block"
>
{heading}
</div>
)}
<button
ref={(node) => {
if (node) refs.current.set(tb.id, node);
else refs.current.delete(tb.id);
}}
type="button"
role="tab"
id={tabId(base, tb.id)}
aria-selected={on}
aria-controls={idBase ? panelId(idBase) : undefined}
tabIndex={tb.id === stop ? 0 : -1}
data-testid={tb.testId}
onClick={() => onChange(tb.id)}
className={
tone === "media"
? `touch-target flex-1 items-center justify-center whitespace-nowrap rounded px-2 py-1 text-2xs ${
on ? "bg-white/15 text-accent" : "text-white/70 hover:bg-white/10"
}`
: `pill touch-target items-center rounded-lg px-3 text-sm ${
vertical
? "inline-flex shrink-0 gap-1.5 py-1.5 sm:flex sm:w-full sm:justify-start sm:gap-2 sm:py-2 sm:text-left"
: "inline-flex gap-1.5 py-1.5"
} ${on ? "font-medium shadow-md shadow-accent/20" : "state-layer"}`
}
>
{Icon && <Icon className="h-4 w-4 shrink-0" />}
{tb.label}
{tb.badge != null && tb.badge > 0 && <CountBadge count={tb.badge} active={on} />}
</button>
</Fragment>
);
})}
</div>
+1 -1
View File
@@ -72,7 +72,7 @@ export function Section({
/** A label that reveals an explanatory caption on hover (dotted underline) when `hint` is set.
* No-op styling when there's no hint, so it's safe to use unconditionally. */
function HintLabel({ hint, children }: { hint?: string; children: ReactNode }) {
export function HintLabel({ hint, children }: { hint?: string; children: ReactNode }) {
return (
<Tooltip hint={hint ?? ""}>
<span
@@ -46,7 +46,10 @@ const PLAIN: Record<string, [number, string]> = {
"ui/ActionMenu.tsx": [1, "a menu row"],
"ui/BackToTop.tsx": [1, "a control"],
"ui/CommandPalette.tsx": [1, "a command row"],
"ui/NavRail.tsx": [1, "the action rows (About, Sign out); a page row is a link (E31)"],
"ui/NavRail.tsx": [
2,
"the action rows (About, Sign out) and the group row that opens its panel; a page row is a link (E31)",
],
"ui/PanelGroup.tsx": [2, "a group's collapse toggle and its label"],
"ui/SidePanel.tsx": [2, "panel controls"],
"ui/tableHeader.tsx": [1, "a column's sort header"],