admin: ui polish pass, thirty findings from the emil audit
Gates / frontend (push) Failing after 1m6s
Gates / package (push) Skipped
Gates / container (push) Skipped
Gates / test (push) Failing after 3m29s
Gates / test-aarch64 (push) Successful in 9m53s
CI / gates (push) Failing after 13m23s

selected states stop changing font weight, buttons gain a pressed scale and scoped 120ms transitions with a reduced-motion override, every loading and empty state reserves its height, charts measure before first paint, hit targets rise to the 44px enhanced target where layout permits, long domains clamp to two lines on an unpadded inner span, chips and name cells truncate, tabular figures on counts and time columns, a z-index layer scale replaces magic numbers and fixes the dialog-over-confirm tie, history's empty state gains a clear-filters action, page headings balance, font smoothing and color-scheme land on the html reset.
This commit is contained in:
2026-09-01 23:23:07 +02:00
parent 2ae0c974a4
commit e656670dd4
30 changed files with 399 additions and 82 deletions
+1
View File
@@ -25,6 +25,7 @@ const styles = stylex.create({
fontSize: "1.5rem",
lineHeight: "2rem",
fontWeight: 600,
textWrap: "balance",
},
probing: {
marginTop: "1rem",
@@ -24,8 +24,10 @@ const styles = stylex.create({
backIcon: {
display: "inline-flex",
},
/** About the height of the filled detail surface, so it does not jump in. */
loading: {
marginTop: "1rem",
minHeight: "20rem",
color: colors.textMuted,
},
});
+10 -11
View File
@@ -34,7 +34,7 @@ import { useCallback, useEffect, useState, type FormEvent, type KeyboardEvent }
import * as stylex from "@stylexjs/stylex";
import { Button, Menu, MenuItem, MenuTrigger, Popover, Radio, RadioGroup } from "react-aria-components";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
import { colors, metrics } from "@/ui/tokens.stylex";
import ClientFilter, { joinClients, parseClients, useClientOptions } from "./ClientFilter";
import { datetimeField, editDatetimeField, resolveDatetimeField, type DatetimeField } from "./datetime";
import type { ActivitySearch } from "./search";
@@ -57,9 +57,6 @@ const PRESETS = [
const CUSTOM_ITEM = "Custom…";
/** The pointer-target floor `ui/Checkbox` and the dialog Close button already set. */
const HIT_TARGET = 44;
const styles = stylex.create({
toolbar: {
marginTop: "1rem",
@@ -87,7 +84,7 @@ const styles = stylex.create({
},
/** Every control in the row is a pointer target before it is anything else. */
field: {
minHeight: HIT_TARGET,
minHeight: metrics.hitTarget,
},
searchInput: {
width: "100%",
@@ -95,8 +92,8 @@ const styles = stylex.create({
},
/** A button is text-sized by default; this is the hit area around the text. */
hitTarget: {
minHeight: HIT_TARGET,
minWidth: HIT_TARGET,
minHeight: metrics.hitTarget,
minWidth: metrics.hitTarget,
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
@@ -113,8 +110,11 @@ const styles = stylex.create({
paddingInline: "0.625rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
minHeight: HIT_TARGET,
minWidth: HIT_TARGET,
// The weight is on the base, not on the selected state: a bolder label is a
// wider label, and the row would shift under the pointer on every pick.
fontWeight: 500,
minHeight: metrics.hitTarget,
minWidth: metrics.hitTarget,
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
@@ -133,7 +133,6 @@ const styles = stylex.create({
"@media (prefers-color-scheme: dark)": "oklch(37% 0.013 285.805)",
},
color: colors.text,
fontWeight: 500,
},
segmentIdle: {
backgroundColor: { default: "transparent", ":hover": colors.surfaceHover },
@@ -158,7 +157,7 @@ const styles = stylex.create({
fontSize: "0.875rem",
lineHeight: "1.25rem",
whiteSpace: "nowrap",
minHeight: HIT_TARGET,
minHeight: metrics.hitTarget,
display: "flex",
alignItems: "center",
},
@@ -146,6 +146,26 @@ function submitFilters() {
fireEvent.submit(domainInput().closest("form")!);
}
/**
* The footer's count line, matched on the whole sentence.
*
* The number sits in a span of its own so it can carry tabular digits, so the
* line is several text nodes and the default string matcher — which reads one
* node at a time — cannot see it whole.
*/
function countMatcher(pattern: RegExp) {
return (_: string, element: Element | null): boolean =>
element?.tagName === "P" && pattern.test(element.textContent ?? "");
}
function countLine(pattern: RegExp): HTMLElement {
return screen.getByText(countMatcher(pattern));
}
function findCountLine(pattern: RegExp): Promise<HTMLElement> {
return screen.findByText(countMatcher(pattern));
}
/** The custom range lives behind the Time menu; the two bounds only exist there. */
function openCustomRange() {
fireEvent.click(screen.getByRole("button", { name: /^Time: / }));
@@ -176,7 +196,7 @@ test("renders the first page with the seven columns filled in", async () => {
// Route cell says how: this is the pair the old Status column could not show.
expect(within(blocked).getAllByText("Blocked")).toHaveLength(2);
expect(within(blocked).getByText("—")).toBeTruthy();
expect(screen.getByText(/Showing 2 queries/)).toBeTruthy();
expect(countLine(/Showing 2 queries/)).toBeTruthy();
});
test("resolves each row's client to its display name, reading the IP out with it", async () => {
@@ -225,7 +245,7 @@ test("load more appends the next page and stops at the end of the log", async ()
await screen.findByText("older.example");
expect(screen.getByText("first.example")).toBeTruthy();
expect(screen.getByText(/Showing 3 queries — end of log/)).toBeTruthy();
expect(countLine(/Showing 3 queries — end of log/)).toBeTruthy();
expect(screen.queryByRole("button", { name: "Load more" })).toBeNull();
});
@@ -239,7 +259,7 @@ test("applying a filter puts it in the url, refetches, and resets the accumulate
fireEvent.change(domainInput(), { target: { value: "ads" } });
submitFilters();
await screen.findByText(/Showing 1 query /);
await findCountLine(/Showing 1 query /);
expect(history.location.search).toContain("domain=ads");
expect(screen.getByText("ads.example")).toBeTruthy();
expect(screen.queryByText("first.example")).toBeNull();
@@ -264,7 +284,7 @@ test("a load-more that resolves after a filter change is discarded", async () =>
fireEvent.change(domainInput(), { target: { value: "ads" } });
submitFilters();
await screen.findByText(/Showing 1 query /);
await findCountLine(/Showing 1 query /);
releaseLoadMore();
await act(async () => {
@@ -272,7 +292,7 @@ test("a load-more that resolves after a filter change is discarded", async () =>
});
expect(screen.queryByText("older.example")).toBeNull();
expect(screen.getByText(/Showing 1 query /)).toBeTruthy();
expect(countLine(/Showing 1 query /)).toBeTruthy();
expect(screen.queryByRole("alert")).toBeNull();
});
@@ -320,7 +340,7 @@ test("load more is disabled while a filter change shows placeholder data, then u
await screen.findByText("ads.older.example");
expect(queryCalls()).toContain("/api/queries?domain=ads&before=7");
expect(screen.getByText(/Showing 2 queries — end of log/)).toBeTruthy();
expect(countLine(/Showing 2 queries — end of log/)).toBeTruthy();
});
test("a background refetch after new rows arrive leaves no gap between the loaded pages", async () => {
@@ -371,7 +391,7 @@ test("a background refetch after new rows arrive leaves no gap between the loade
await screen.findByText("n22.example");
const shown = screen.getAllByText(/^n\d+\.example$/).map((cell) => cell.textContent);
expect(shown).toEqual(["n22.example", "n21.example", "n20.example", "n19.example", "n18.example", "n17.example"]);
expect(screen.getByText(/Showing 6 queries — end of log/)).toBeTruthy();
expect(countLine(/Showing 6 queries — end of log/)).toBeTruthy();
});
test("a 401 on load more routes through handleUnauthorized instead of the inline error", async () => {
@@ -619,6 +639,38 @@ test("Clear empties the url as well as the form", async () => {
expect(domainInput()).toHaveProperty("value", "");
});
test("the empty result offers the clear it names, and it clears the same filters the toolbar does", async () => {
stubFetch((url) => {
if (url === "/api/clients") return json({ clients: CLIENTS });
if (url === "/api/queries") return json(PAGES["/api/queries"]);
return json({ queries: [], next_before: null, coverage: COMPLETE } satisfies QueriesPage);
});
const { history } = renderPage("/activity?mode=history&domain=nothing&blocked=true");
await screen.findByText("No queries match the current filters.");
fireEvent.click(screen.getByRole("button", { name: "Clear filters" }));
await waitFor(() => {
expect(history.location.search).not.toContain("domain");
});
expect(history.location.search).not.toContain("blocked");
expect(domainInput()).toHaveProperty("value", "");
// The unfiltered log is back, so the button did the toolbar's Clear and not a
// reset of its own that only emptied the form.
expect(await screen.findByText("first.example")).toBeTruthy();
});
test("an empty log offers no clear, because there is no filter to blame for it", async () => {
stubFetch((url) => {
if (url === "/api/clients") return json({ clients: CLIENTS });
return json({ queries: [], next_before: null, coverage: COMPLETE } satisfies QueriesPage);
});
renderPage();
await screen.findByText("No queries logged yet.");
expect(screen.queryByRole("button", { name: "Clear filters" })).toBeNull();
});
test("the domain field debounces into the url, and Enter flushes it at once", async () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
try {
@@ -739,7 +791,7 @@ test("the coverage watermark reads under the results, never over them", async ()
await screen.findByText("kept.example");
const watermark = screen.getByText(/Query history is available from/);
const count = screen.getByText(/Showing 1 query/);
const count = countLine(/Showing 1 query/);
// After the count in document order, which is what "footer" means here.
expect(count.compareDocumentPosition(watermark) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
});
+2 -1
View File
@@ -35,6 +35,7 @@ const styles = stylex.create({
fontSize: "1.5rem",
lineHeight: "2rem",
fontWeight: 600,
textWrap: "balance",
},
switch: {
display: "flex",
@@ -177,7 +178,7 @@ export default function ActivityPage() {
*/}
<TabPanel id="history" className={panelClass}>
<ActivityFilters applied={search} onApply={apply} onClear={clear} />
<HistoryActivity search={search} />
<HistoryActivity search={search} onClear={clear} />
</TabPanel>
<TabPanel id="live" className={panelClass}>
<p {...stylex.props(styles.liveNote)}>
+21 -10
View File
@@ -30,7 +30,7 @@ import * as stylex from "@stylexjs/stylex";
import { Button, Menu, MenuItem, MenuTrigger, Popover } from "react-aria-components";
import { clientLabel, useClientNames } from "@/features/clients/clientNames";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
import { colors, metrics } from "@/ui/tokens.stylex";
import { MAX_CLIENTS } from "./search";
/**
@@ -47,9 +47,6 @@ export interface ClientOption {
name: string | null;
}
/** The pointer-target floor `ui/Checkbox` and the dialog Close button already set. */
const HIT_TARGET = 44;
/**
* How many chips are shown before the rest become a count.
*
@@ -72,8 +69,8 @@ const styles = stylex.create({
color: colors.textMuted,
},
trigger: {
minHeight: HIT_TARGET,
minWidth: HIT_TARGET,
minHeight: metrics.hitTarget,
minWidth: metrics.hitTarget,
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
@@ -100,7 +97,7 @@ const styles = stylex.create({
fontSize: "0.875rem",
lineHeight: "1.25rem",
whiteSpace: "nowrap",
minHeight: HIT_TARGET,
minHeight: metrics.hitTarget,
display: "flex",
alignItems: "center",
gap: "0.5rem",
@@ -119,10 +116,12 @@ const styles = stylex.create({
},
chip: {
cursor: "pointer",
transitionProperty: metrics.transitionProperty,
transitionDuration: { default: metrics.transitionDuration, "@media (prefers-reduced-motion: reduce)": "0s" },
display: "inline-flex",
alignItems: "center",
gap: "0.375rem",
minHeight: HIT_TARGET,
minHeight: metrics.hitTarget,
paddingInline: "0.625rem",
borderRadius: "999px",
borderWidth: 1,
@@ -133,15 +132,24 @@ const styles = stylex.create({
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
/** A hostname can be longer than the toolbar; the cross stays outside the cut. */
chipLabel: {
maxWidth: "14rem",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
},
/** Decoration inside the button, so a click on it is a click on the button. */
chipCross: {
display: "inline-flex",
pointerEvents: "none",
color: colors.textMuted,
},
/** Not a button: it removes nothing, and nothing about it is pressable. */
chipMore: {
display: "inline-flex",
alignItems: "center",
minHeight: HIT_TARGET,
minHeight: metrics.hitTarget,
paddingInline: "0.625rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
@@ -352,10 +360,13 @@ export default function ClientFilter({ options, selected, onChange }: Props) {
// The chip reads as a name and removes an address, so the name alone
// would not say what the button does to a reader who cannot see it.
aria-label={`Remove client ${displayFor(ip, options)}`}
// The chip label is cut to fit the row, so the pointer can still read
// the whole of what it names.
title={displayFor(ip, options)}
onClick={() => remove(ip)}
{...stylex.props(styles.chip, shared.focusRing)}
>
{chipFor(ip, options)}
<span {...stylex.props(styles.chipLabel)}>{chipFor(ip, options)}</span>
<span aria-hidden="true" {...stylex.props(styles.chipCross)}>
<X size={10} />
</span>
+28 -12
View File
@@ -24,8 +24,17 @@ import { queriesFilterOf, type ActivitySearch } from "./search";
const styles = stylex.create({
empty: {
marginTop: "1.5rem",
minHeight: "6rem",
display: "flex",
flexDirection: "column",
alignItems: "flex-start",
gap: "0.75rem",
color: colors.textMuted,
},
/** About a default page of rows, so the table does not jump in under the reader. */
loading: {
minHeight: "24rem",
},
tableWrap: {
marginTop: "1rem",
overflowX: "auto",
@@ -75,7 +84,13 @@ function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
export default function HistoryActivity({ search }: { search: ActivitySearch }) {
interface Props {
search: ActivitySearch;
/** The toolbar's own Clear, so the empty state offers the way out it names. */
onClear: () => void;
}
export default function HistoryActivity({ search, onClear }: Props) {
const filter = queriesFilterOf(search);
const base = useInfiniteQuery(queriesInfiniteQuery(filter));
const clientNames = useClientNames();
@@ -107,7 +122,7 @@ export default function HistoryActivity({ search }: { search: ActivitySearch })
}
if (base.data === undefined) {
return (
<p {...stylex.props(styles.empty, shared.pulse)} role="status">
<p {...stylex.props(styles.empty, styles.loading, shared.pulse)} role="status">
Loading activity
</p>
);
@@ -126,16 +141,15 @@ export default function HistoryActivity({ search }: { search: ActivitySearch })
</p>
)}
{rows.length === 0 ? (
<>
<p {...stylex.props(styles.empty)}>
{filterActive ? "No queries match the current filters." : "No queries logged yet."}
</p>
{coverage !== undefined && (
<div {...stylex.props(styles.spacedTop)}>
<CoverageNotice coverage={coverage} variant="note" />
</div>
<div {...stylex.props(styles.empty)}>
<p>{filterActive ? "No queries match the current filters." : "No queries logged yet."}</p>
{filterActive && (
<button type="button" onClick={onClear} {...stylex.props(shared.button, shared.focusRing)}>
Clear filters
</button>
)}
</>
{coverage !== undefined && <CoverageNotice coverage={coverage} variant="note" />}
</div>
) : (
<>
<div {...stylex.props(styles.tableWrap)}>
@@ -169,7 +183,9 @@ export default function HistoryActivity({ search }: { search: ActivitySearch })
</div>
<div {...stylex.props(styles.footer)}>
<p {...stylex.props(styles.note)}>
Showing {rows.length} {rows.length === 1 ? "query" : "queries"}
{/* The count is the one part of this line that moves as pages load. */}
Showing <span {...stylex.props(shared.tabularNums)}>{rows.length}</span>{" "}
{rows.length === 1 ? "query" : "queries"}
{hasMore ? "" : " — end of log"}
</p>
{coverage !== undefined && <CoverageNotice coverage={coverage} variant="note" />}
@@ -28,6 +28,7 @@ const styles = stylex.create({
fontSize: "1.5rem",
lineHeight: "2rem",
fontWeight: 600,
textWrap: "balance",
},
intro: {
marginTop: "0.5rem",
+33 -2
View File
@@ -25,6 +25,9 @@ import { colors } from "@/ui/tokens.stylex";
const DARK = "@media (prefers-color-scheme: dark)";
/** Wide enough for an ordinary hostname, narrow enough to leave the six other columns room. */
const DOMAIN_MAX_WIDTH = "24rem";
const styles = stylex.create({
head: {
backgroundColor: { default: "oklch(98.5% 0 none)", [DARK]: "oklch(21% 0.006 285.885)" },
@@ -46,6 +49,9 @@ const styles = stylex.create({
breakAll: {
wordBreak: "break-all",
},
domainCell: {
maxWidth: DOMAIN_MAX_WIDTH,
},
small: {
fontSize: "0.75rem",
lineHeight: "1rem",
@@ -53,10 +59,32 @@ const styles = stylex.create({
muted: {
color: colors.textMuted,
},
/**
* The padding makes the whole row height clickable, and the equal negative
* margin gives that height back to the row. The clamp lives on the inner
* span, not here: a padded `-webkit-box` can paint a third clipped line
* inside its own padding.
*/
domainLink: {
display: "block",
maxWidth: DOMAIN_MAX_WIDTH,
paddingBlock: "0.5rem",
marginBlock: "-0.5rem",
color: colors.primaryOnSurface,
textDecorationLine: "none",
},
/**
* A domain is unbounded; the column is not. The clamp counts only the lines
* of the element that directly holds the text, so every cell wraps its text
* in this span — a `td` that took `display: -webkit-box` would stop being a
* table cell.
*/
domainClamp: {
display: "-webkit-box",
WebkitBoxOrient: "vertical",
WebkitLineClamp: 2,
overflow: "hidden",
},
/**
* The badge shape and its weight are the signal; the tint only says which
* kind of unhappy answer this was. A monochrome or colour-blind reading of
@@ -136,8 +164,11 @@ export function ActivityCells({
return (
<>
<td {...stylex.props(styles.cell, styles.nowrap, styles.muted)}>{formatTime(row.ts)}</td>
<td {...stylex.props(styles.cell, styles.small, styles.breakAll, shared.mono)}>
{renderDomain(row.id, row.domain)}
<td
title={row.domain}
{...stylex.props(styles.cell, styles.small, styles.breakAll, styles.domainCell, shared.mono)}
>
{renderDomain(row.id, <span {...stylex.props(styles.domainClamp)}>{row.domain}</span>)}
</td>
<td {...stylex.props(styles.cell, styles.small, styles.nowrap)}>
<ClientName ip={row.client_ip} names={clientNames} />
@@ -34,6 +34,7 @@ const styles = stylex.create({
fontSize: "1.5rem",
lineHeight: "2rem",
fontWeight: 600,
textWrap: "balance",
},
address: {
marginTop: "0.25rem",
@@ -93,7 +94,12 @@ const styles = stylex.create({
link: {
color: colors.primaryOnSurface,
},
/**
* Room for the heading, the facts panel and the two sections under it, so
* the page settles at roughly its filled height instead of growing into it.
*/
loading: {
minHeight: "24rem",
marginTop: "1rem",
color: colors.textMuted,
},
+12 -1
View File
@@ -50,6 +50,15 @@ const styles = stylex.create({
fontSize: "1.5rem",
lineHeight: "2rem",
fontWeight: 600,
textWrap: "balance",
},
/** A long reverse-DNS name would otherwise widen the column past the table. */
name: {
display: "block",
maxWidth: "18rem",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
},
empty: {
marginTop: "1rem",
@@ -203,7 +212,9 @@ export default function ClientsPage() {
</Link>
</td>
<td {...stylex.props(styles.cell)}>
<ClientDisplayName client={client} />
<span {...stylex.props(styles.name)}>
<ClientDisplayName client={client} />
</span>
</td>
<td {...stylex.props(styles.cell)}>{client.group}</td>
<td {...stylex.props(styles.cell)}>{formatTime(client.first_seen)}</td>
@@ -203,7 +203,7 @@ function AssignmentsEditor({ prefixes, groups }: Props) {
return (
<>
{state.rows.length === 0 ? (
<p {...stylex.props(styles.empty)}>No network assignments configured.</p>
<p {...stylex.props(styles.empty)}>No network assignments configured. Add one below.</p>
) : (
<ul {...stylex.props(styles.rows)}>
{state.rows.map((row, index) => (
@@ -21,6 +21,13 @@ const styles = stylex.create({
lineHeight: "1.25rem",
color: colors.textMuted,
},
/**
* Room for a few checkboxes and the button row, so the sections below the
* panel do not jump up the page when the assignment lands.
*/
loading: {
minHeight: "8rem",
},
root: {
marginTop: "0.75rem",
},
@@ -44,7 +51,7 @@ export default function GroupSourcesEditor({ groupId, blocklists }: Props) {
if (sources.isPending) {
return (
<p role="status" {...stylex.props(styles.note)}>
<p role="status" {...stylex.props(styles.note, styles.loading)}>
Loading sources
</p>
);
@@ -167,7 +167,9 @@ function GroupsMasterDetail({ groups, status }: { groups: Group[]; status: Confi
</nav>
</div>
{selected === undefined ? (
<p {...stylex.props(config.empty)}>No groups exist.</p>
<p {...stylex.props(config.empty)}>
{fileMode ? "No groups exist." : "No groups exist. Create one with the New group field."}
</p>
) : fileMode ? (
<GroupDetailReadOnly group={selected} />
) : (
@@ -432,7 +434,10 @@ function GroupRules({ group, editable }: { group: Group; editable: boolean }) {
return (
<>
{scoped.length === 0 ? (
<p {...stylex.props(config.empty)}>No allow or block rules for this group.</p>
<p {...stylex.props(config.empty)}>
No allow or block rules for this group.
{editable && " Add one below."}
</p>
) : (
<RulesTable rules={scoped} editable={editable} />
)}
@@ -3,7 +3,17 @@ import type { UseQueryResult } from "@tanstack/react-query";
import * as stylex from "@stylexjs/stylex";
import InlineError from "@/lib/InlineError";
import { styles as shared } from "@/ui/styles";
import { styles } from "./styles";
import { styles as config } from "./styles";
const styles = stylex.create({
/**
* Room for a panel heading and the first rows of the collection, so the
* content below a panel does not jump up the page when its data lands.
*/
pending: {
minHeight: "6rem",
},
});
/**
* One panel's data, with the loading and error surfaces the fire-and-forget
@@ -20,7 +30,7 @@ export default function QueryPanel<T>({
}) {
if (query.isPending) {
return (
<p role="status" {...stylex.props(styles.pending, shared.pulse)}>
<p role="status" {...stylex.props(config.pending, styles.pending, shared.pulse)}>
Loading
</p>
);
@@ -36,6 +36,7 @@ const styles = stylex.create({
fontSize: "1.5rem",
lineHeight: "2rem",
fontWeight: 600,
textWrap: "balance",
},
purgeAction: {
marginInlineStart: "auto",
@@ -107,6 +108,9 @@ const styles = stylex.create({
color: colors.primaryOnSurface,
},
loading: {
// The heading row, the subject and the seven-row facts panel, which is
// what stands above the fold once the event lands.
minHeight: "20rem",
marginTop: "1rem",
color: colors.textMuted,
},
@@ -47,6 +47,15 @@ const RESOLVED = page([
event(30, { code: "disk.space", component: "disk", subject: "data", resolved_at: NOW_S - 7200 }),
]);
/**
* The footer count sets its number in tabular figures, so the sentence is split
* across elements. Matched on the paragraph's whole text rather than on a
* fragment of it.
*/
function footerLine(text: string): HTMLElement {
return screen.getByText((_content, element) => element?.tagName === "P" && element.textContent === text);
}
/** A stubbed response that carries a non-200 status instead of a payload. */
class Failure {
constructor(
@@ -153,7 +162,7 @@ test("active episodes come first, each with its title, subject, age and count",
// The resolved history is a separate section, below the active list.
const table = within(screen.getByRole("table"));
expect(table.getByText("Disk space low")).toBeTruthy();
expect(screen.getByText(/Showing 1 resolved entry — end of history/)).toBeTruthy();
expect(footerLine("Showing 1 resolved entry — end of history")).toBeTruthy();
});
test("nothing open reads as good news, not as a broken page", async () => {
@@ -223,7 +232,7 @@ test("load more appends the next page of resolved history", async () => {
fireEvent.click(screen.getByRole("button", { name: "Load more" }));
await screen.findByText("TLS certificate reload failed");
expect(screen.getByText(/Showing 2 resolved entries — end of history/)).toBeTruthy();
expect(footerLine("Showing 2 resolved entries — end of history")).toBeTruthy();
});
test("an unavailable store reports the failure instead of loading forever", async () => {
@@ -46,6 +46,7 @@ const styles = stylex.create({
fontSize: "1.5rem",
lineHeight: "2rem",
fontWeight: 600,
textWrap: "balance",
},
intro: {
marginTop: "0.25rem",
@@ -94,6 +95,18 @@ const styles = stylex.create({
lineHeight: "1.25rem",
color: colors.textMuted,
},
/** One card: the badge and title row, the meta line, and the card's padding. */
activeLoading: {
minHeight: "4rem",
},
/**
* The table header and three rows, at 2.25rem each. Deliberately short of a
* full page of history: an install with nothing resolved collapses to one
* muted line, and a taller reserve would leave a hole on the common case.
*/
historyLoading: {
minHeight: "9rem",
},
cardList: {
marginTop: "0.75rem",
display: "flex",
@@ -339,8 +352,8 @@ function HistoryRow({ event, onPurge, busy }: { event: DiagnosticEvent; onPurge:
</Link>
</td>
<td {...stylex.props(styles.cell)}>{event.subject}</td>
<td {...stylex.props(styles.cell, styles.nowrap)}>{formatTime(event.first_seen)}</td>
<td {...stylex.props(styles.cell, styles.nowrap)}>
<td {...stylex.props(styles.cell, styles.nowrap, shared.tabularNums)}>{formatTime(event.first_seen)}</td>
<td {...stylex.props(styles.cell, styles.nowrap, shared.tabularNums)}>
{event.resolved_at === null ? "—" : formatTime(event.resolved_at)}
</td>
<td {...stylex.props(styles.cell, styles.nowrap, shared.tabularNums)}>{event.occurrences}</td>
@@ -436,7 +449,7 @@ export default function DiagnosticsPage() {
{active.status === "error" ? (
<InlineError error={active.error} onRetry={() => void active.refetch()} />
) : active.data === undefined ? (
<p {...stylex.props(styles.empty, shared.pulse)} role="status">
<p {...stylex.props(styles.empty, styles.activeLoading, shared.pulse)} role="status">
Loading diagnostics
</p>
) : activeRows.length === 0 ? (
@@ -474,7 +487,7 @@ export default function DiagnosticsPage() {
{history.status === "error" ? (
<InlineError error={history.error} onRetry={() => void history.refetch()} />
) : history.data === undefined ? (
<p {...stylex.props(styles.empty, shared.pulse)} role="status">
<p {...stylex.props(styles.empty, styles.historyLoading, shared.pulse)} role="status">
Loading history
</p>
) : historyRows.length === 0 ? (
@@ -509,7 +522,8 @@ export default function DiagnosticsPage() {
</table>
</div>
<p {...stylex.props(styles.footer, styles.note)}>
Showing {historyRows.length} resolved {historyRows.length === 1 ? "entry" : "entries"}
Showing <span {...stylex.props(shared.tabularNums)}>{historyRows.length}</span> resolved{" "}
{historyRows.length === 1 ? "entry" : "entries"}
{hasMore(history) ? "" : " — end of history"}
</p>
<MoreButton section={history} />
@@ -109,6 +109,14 @@ const styles = stylex.create({
lineHeight: "1.25rem",
color: colors.textMuted,
},
/**
* One row of facts — a fact's own line, its detail line, and the padding —
* plus the extra top margin the list carries. A floor, not a match: below
* 1100px the strip stacks and grows past it.
*/
loading: {
minHeight: "3.5rem",
},
});
const TONES = { ok: styles.ok, notice: styles.notice, warn: styles.warn, danger: styles.danger } as const;
@@ -182,7 +190,7 @@ export default function HealthStrip() {
return health.isError ? (
<InlineError error={health.error} onRetry={() => void health.refetch()} />
) : (
<p role="status" {...stylex.props(styles.message, shared.pulse)}>
<p role="status" {...stylex.props(styles.message, styles.loading, shared.pulse)}>
Loading status
</p>
);
+15 -1
View File
@@ -34,6 +34,11 @@ const THICKNESS = 36;
const OUTER_RADIUS = SIZE / 2;
const INNER_RADIUS = OUTER_RADIUS - THICKNESS;
/** The gap `body` puts between the ring and the legend, in pixels: 1.25rem. */
const BODY_GAP = 20;
/** One `legend` row, in pixels: its 1.25rem line height. */
const LEGEND_ROW = 20;
const numberFormat = new Intl.NumberFormat();
/** The width at which the page puts the two donuts side by side, and the page's
@@ -42,11 +47,19 @@ const numberFormat = new Intl.NumberFormat();
const TWO_COLUMN = "@media (min-width: 1280px)";
const styles = stylex.create({
/**
* The reserve is the ring and a legend, not the ring alone: 180 + 20 + 20 =
* 220px. `body` wraps once the panel is narrower than the ring plus the
* legend's 12rem floor, and below that width the filled panel is the ring, the
* body gap and at least one legend row. Side by side the same 220px holds a
* legend of nine rows, which is more than either breakdown draws — the API
* caps neither, so the ring's own height is not a ceiling.
*/
empty: {
display: "flex",
alignItems: "center",
justifyContent: "center",
minHeight: SIZE,
minHeight: SIZE + BODY_GAP + LEGEND_ROW,
borderRadius: "0.25rem",
borderWidth: 1,
borderStyle: "dashed",
@@ -99,6 +112,7 @@ const styles = stylex.create({
gap: "0.5rem",
},
swatch: {
pointerEvents: "none",
flexShrink: 0,
alignSelf: "center",
display: "inline-block",
+21 -2
View File
@@ -13,7 +13,7 @@ import { useNavigate, useSearch } from "@tanstack/react-router";
import { Radio, RadioGroup } from "react-aria-components";
import type { Period } from "@/lib/types";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
import { colors, metrics } from "@/ui/tokens.stylex";
import { DEFAULT_PERIOD, PERIODS } from "./period";
const styles = stylex.create({
@@ -33,19 +33,31 @@ const styles = stylex.create({
fontSize: "1.5rem",
lineHeight: "2rem",
fontWeight: 600,
textWrap: "balance",
},
periodGroup: {
display: "flex",
gap: "0.25rem",
},
/**
* The weight lives here rather than on the selected variant: selection may
* change colour, but a heavier label would re-measure the row and shift every
* option beside it.
*/
period: {
cursor: "pointer",
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
minHeight: metrics.hitTarget,
minWidth: metrics.hitTarget,
borderStyle: "none",
borderRadius: "0.25rem",
paddingInline: "0.625rem",
paddingBlock: "0.25rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
fontWeight: 500,
},
/** A Radio is a `label`, so RAC drives the ring rather than `:focus-visible`. */
periodFocusVisible: {
@@ -61,13 +73,20 @@ const styles = stylex.create({
"@media (prefers-color-scheme: dark)": "oklch(37% 0.013 285.805)",
},
color: colors.text,
fontWeight: 500,
},
periodIdle: {
backgroundColor: { default: "transparent", ":hover": colors.surfaceHover },
color: colors.textSecondary,
transitionProperty: metrics.transitionProperty,
transitionDuration: { default: metrics.transitionDuration, "@media (prefers-reduced-motion: reduce)": "0s" },
},
/**
* The height approximates the filled overview — stat tiles, a 240px chart and
* a 180px donut with the panel chrome around them — so that the page does not
* jump when the window lands. That is where the number comes from.
*/
loading: {
minHeight: "48rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.textMuted,
+12 -9
View File
@@ -9,14 +9,14 @@
* StyleX tokens instead.
*/
import { useEffect, useRef, useState } from "react";
import { useLayoutEffect, useRef, useState } from "react";
import * as stylex from "@stylexjs/stylex";
import { AxisBottom, AxisLeft, type TickRendererProps } from "@visx/axis";
import { GridRows } from "@visx/grid";
import { scaleBand, scaleLinear } from "@visx/scale";
import { TooltipWithBounds } from "@visx/tooltip";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
import { colors, layers } from "@/ui/tokens.stylex";
export const CHART_HEIGHT = 240;
export const MARGIN = { top: 8, right: 8, bottom: 22, left: 44 } as const;
@@ -53,15 +53,18 @@ export function plotArea(width: number): Plot {
}
/**
* The container's width, measured on mount and on every resize. Deliberately
* `useEffect` rather than `useLayoutEffect`: the first paint draws at the
* fallback width and the measured width lands a frame later, which is the
* timing the charts have always had.
* The container's width, measured on mount and on every resize.
*
* `useLayoutEffect` rather than `useEffect`, because the difference is visible:
* a layout effect measures and re-renders before the browser paints, so the
* first painted frame is already the real width. Under `useEffect` the measured
* width lands one painted frame later, and the reader sees a chart drawn at
* `FALLBACK_WIDTH` snap to its container.
*/
export function useMeasuredWidth(): [React.RefObject<HTMLDivElement | null>, number] {
const ref = useRef<HTMLDivElement>(null);
const [width, setWidth] = useState(0);
useEffect(() => {
useLayoutEffect(() => {
const el = ref.current;
if (el === null) return;
setWidth(el.clientWidth);
@@ -159,7 +162,7 @@ const styles = stylex.create({
tooltip: {
pointerEvents: "none",
position: "absolute",
zIndex: 10,
zIndex: layers.tooltip,
borderRadius: "0.25rem",
borderWidth: 1,
borderStyle: "solid",
@@ -240,7 +243,7 @@ function TickLabel({ x, dx, dy, textAnchor, dominantBaseline, formattedValue }:
dy={dy}
textAnchor={textAnchor}
dominantBaseline={dominantBaseline}
{...stylex.props(styles.axisLabel)}
{...stylex.props(styles.axisLabel, shared.tabularNums)}
>
{formattedValue}
</text>
+24
View File
@@ -58,6 +58,9 @@ export interface RouterContext {
const styles = stylex.create({
pending: {
// Holds the page open while the route's chunk lands, so a navigation does
// not collapse the main column to one line and scroll the shell.
minHeight: "24rem",
padding: "2rem",
textAlign: "center",
color: colors.textMuted,
@@ -399,7 +402,28 @@ const systemRoute = createRoute({
component: lazyRouteComponent(() => import("@/features/configuration/SystemPage")),
});
// PROTO-OVERVIEW fence start (throwaway — delete with admin/src/proto/)
// A dev-only design-exploration route: four full-page Overview variants behind a
// floating picker. It hangs off the root rather than the shell, so a variant is
// judged as a page and not as the nav around it. `import.meta.env.DEV` is a
// literal `false` in a production build, so the array folds to empty and the
// dynamic import below is dead code Rollup drops — the proto bytes never reach
// dist.
const protoRoutes = import.meta.env.DEV
? [
createRoute({
getParentRoute: () => rootRoute,
path: "/proto/overview",
// No `validateSearch`: the picker owns `?v=` with history.replaceState,
// and this route never navigates, so the parameter survives untouched.
component: lazyRouteComponent(() => import("@/proto/ProtoOverview")),
}),
]
: [];
// PROTO-OVERVIEW fence end
const routeTree = rootRoute.addChildren([
...protoRoutes,
loginRoute,
shellRoute.addChildren([
indexRoute,
+6 -2
View File
@@ -11,7 +11,7 @@ import { diagnosticsBadge } from "./diagnosticsBadge";
import ConfigStatusNotices from "./ConfigStatusNotices";
import AuthorityLine from "@/features/configuration/AuthorityLine";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
import { colors, metrics } from "@/ui/tokens.stylex";
/** The one breakpoint the shell has: below it the sidebar becomes a drawer. */
const WIDE = "@media (min-width: 768px)";
@@ -48,6 +48,9 @@ const styles = stylex.create({
borderRadius: "0.25rem",
paddingInline: "0.75rem",
paddingBlock: "0.375rem",
// Carried by every item, active or not: a weight that changes on
// navigation would reflow the whole nav list.
fontWeight: 500,
textDecorationLine: "none",
},
navLabel: {
@@ -93,11 +96,12 @@ const styles = stylex.create({
navActive: {
backgroundColor: { default: "oklch(92% 0.004 286.32)", [DARK]: "oklch(27.4% 0.006 286.033)" },
color: colors.text,
fontWeight: 500,
},
navIdle: {
backgroundColor: { default: "transparent", ":hover": colors.surfaceHover },
color: { default: colors.textSecondary, ":hover": colors.text },
transitionProperty: metrics.transitionProperty,
transitionDuration: { default: metrics.transitionDuration, "@media (prefers-reduced-motion: reduce)": "0s" },
},
versionFooter: {
paddingInline: "1rem",
+4
View File
@@ -35,6 +35,10 @@
html {
line-height: 1.5;
-webkit-text-size-adjust: 100%;
/* The tokens carry both schemes; this tells the UA to match its own chrome — form controls, scrollbars. */
color-scheme: light dark;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
font-family:
system-ui,
-apple-system,
+2 -2
View File
@@ -11,7 +11,7 @@
import type { ReactNode } from "react";
import * as stylex from "@stylexjs/stylex";
import { Dialog as AriaDialog, Heading, Modal, ModalOverlay } from "react-aria-components";
import { colors } from "./tokens.stylex";
import { colors, layers } from "./tokens.stylex";
import { styles as shared } from "./styles";
interface Props {
@@ -36,7 +36,7 @@ const styles = stylex.create({
overlay: {
position: "fixed",
inset: 0,
zIndex: 50,
zIndex: layers.confirm,
display: "flex",
alignItems: "center",
justifyContent: "center",
+5 -5
View File
@@ -13,7 +13,7 @@
import type { ReactNode } from "react";
import * as stylex from "@stylexjs/stylex";
import { Dialog as AriaDialog, Heading, Modal, ModalOverlay } from "react-aria-components";
import { colors } from "./tokens.stylex";
import { colors, layers, metrics } from "./tokens.stylex";
import { styles as shared } from "./styles";
interface Props {
@@ -30,7 +30,7 @@ const styles = stylex.create({
overlay: {
position: "fixed",
inset: 0,
zIndex: 50,
zIndex: layers.overlay,
display: "flex",
alignItems: "center",
justifyContent: "center",
@@ -88,10 +88,10 @@ const styles = stylex.create({
lineHeight: "1.75rem",
fontWeight: 600,
},
/** 44px on both axes: the pointer-target floor, which the word alone misses. */
/** The pointer-target floor on both axes, which the word alone misses. */
close: {
minWidth: 44,
minHeight: 44,
minWidth: metrics.hitTarget,
minHeight: metrics.hitTarget,
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
+5 -1
View File
@@ -13,7 +13,7 @@
import type { ReactNode } from "react";
import * as stylex from "@stylexjs/stylex";
import { Tab, TabList, TabPanel, Tabs as AriaTabs } from "react-aria-components";
import { colors } from "./tokens.stylex";
import { colors, metrics } from "./tokens.stylex";
export interface TabSpec {
id: string;
@@ -44,6 +44,10 @@ const styles = stylex.create({
borderBottomColor: colors.border,
},
tab: {
/** The padding alone leaves the label short of the pointer-target floor. */
minHeight: metrics.hitTarget,
display: "flex",
alignItems: "center",
marginBottom: -1,
borderBottomWidth: 2,
borderBottomStyle: "solid",
+26 -1
View File
@@ -8,10 +8,20 @@
*/
import * as stylex from "@stylexjs/stylex";
import { colors } from "./tokens.stylex";
import { colors, metrics } from "./tokens.stylex";
const FOCUS = ":focus-visible";
const DISABLED = ":disabled";
const ACTIVE = ":active";
const REDUCED_MOTION = "@media (prefers-reduced-motion: reduce)";
// Press depression and hover settling, shared by every button variant; inert
// when the reader asked the platform for reduced motion.
const press = {
transform: { default: "none", [ACTIVE]: { default: "scale(0.97)", [REDUCED_MOTION]: "none" } },
transitionProperty: metrics.transitionProperty,
transitionDuration: { default: metrics.transitionDuration, [REDUCED_MOTION]: "0s" },
};
/** The half-fade loop a placeholder runs while its data is in flight. */
const pulseFrames = stylex.keyframes({
@@ -60,6 +70,8 @@ export const styles = stylex.create({
},
button: {
minHeight: 40,
...press,
cursor: { default: "pointer", [DISABLED]: "not-allowed" },
borderRadius: "0.25rem",
borderWidth: 1,
@@ -70,7 +82,13 @@ export const styles = stylex.create({
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
/**
* Deliberately below the 44px hit floor: it sits inline in table rows and
* filter bars where a padded-out target would break the row rhythm. Still
* above WCAG 2.5.8's 24px minimum.
*/
smallButton: {
...press,
cursor: { default: "pointer", [DISABLED]: "not-allowed" },
borderRadius: "0.25rem",
borderWidth: 1,
@@ -82,6 +100,7 @@ export const styles = stylex.create({
lineHeight: "1.25rem",
},
largeButton: {
...press,
cursor: { default: "pointer", [DISABLED]: "not-allowed" },
borderRadius: "0.25rem",
borderWidth: 1,
@@ -93,6 +112,7 @@ export const styles = stylex.create({
},
primaryButton: {
...press,
cursor: { default: "pointer", [DISABLED]: "not-allowed" },
borderRadius: "0.25rem",
borderStyle: "none",
@@ -106,6 +126,7 @@ export const styles = stylex.create({
opacity: { default: 1, [DISABLED]: 0.5 },
},
largePrimaryButton: {
...press,
cursor: { default: "pointer", [DISABLED]: "not-allowed" },
borderRadius: "0.25rem",
borderStyle: "none",
@@ -118,6 +139,7 @@ export const styles = stylex.create({
},
rowButton: {
...press,
cursor: { default: "pointer", [DISABLED]: "not-allowed" },
borderRadius: "0.25rem",
borderStyle: "none",
@@ -129,6 +151,7 @@ export const styles = stylex.create({
color: colors.primaryOnSurface,
},
linkButton: {
...press,
cursor: { default: "pointer", [DISABLED]: "not-allowed" },
borderStyle: "none",
backgroundColor: "transparent",
@@ -139,6 +162,7 @@ export const styles = stylex.create({
color: colors.primaryOnSurface,
},
dangerLinkButton: {
...press,
cursor: { default: "pointer", [DISABLED]: "not-allowed" },
borderStyle: "none",
backgroundColor: "transparent",
@@ -150,6 +174,7 @@ export const styles = stylex.create({
opacity: { default: 1, [DISABLED]: 0.5 },
},
retryButton: {
...press,
cursor: { default: "pointer", [DISABLED]: "not-allowed" },
marginTop: "0.75rem",
borderRadius: "0.25rem",
+32
View File
@@ -62,3 +62,35 @@ export const colors = stylex.defineVars({
/** The focus ring colour. The ring itself is a floor, not a variant. */
focus: { default: "oklch(54.6% 0.245 262.881)", [DARK]: "oklch(54.6% 0.245 262.881)" },
});
/**
* The stacking order. Three layers is the whole app: a chart tooltip floats
* over its own panel, a dialog overlay covers the page, and a confirmation sits
* over the dialog that opened it. Values are strings because a StyleX var holds
* a CSS token, not a number.
*/
export const layers = stylex.defineVars({
tooltip: "10",
overlay: "50",
confirm: "60",
});
/**
* Values that are shared but are not theme: they never vary by colour scheme,
* and they are the same number wherever they appear.
*
* `defineConsts` rather than a plain exported constant, and in this module
* rather than in `ui/styles`, because of how the compiler reads a
* `stylex.create` body. A constant imported from an ordinary module is rejected
* outright, and a plain export from a `.stylex.ts` module is read as a variable
* object rather than a literal — so neither can be shared. `defineConsts` is
* the one mechanism that inlines a literal across module boundaries, which is
* why these are strings with their units baked in.
*/
export const metrics = stylex.defineConsts({
/** WCAG 2.5.5's enhanced 44px target, applied where layout permits; some inline controls stop at 40px or above 2.5.8's 24px minimum. */
hitTarget: "44px",
/** The press and hover settle shared by every control that styles its own states. */
transitionProperty: "background-color, color, border-color, transform",
transitionDuration: "120ms",
});