diff --git a/admin/src/auth/LoginPage.tsx b/admin/src/auth/LoginPage.tsx index 57aa0b2..5f32232 100644 --- a/admin/src/auth/LoginPage.tsx +++ b/admin/src/auth/LoginPage.tsx @@ -25,6 +25,7 @@ const styles = stylex.create({ fontSize: "1.5rem", lineHeight: "2rem", fontWeight: 600, + textWrap: "balance", }, probing: { marginTop: "1rem", diff --git a/admin/src/features/activity/ActivityDetailPage.tsx b/admin/src/features/activity/ActivityDetailPage.tsx index 9a955cf..3dc3285 100644 --- a/admin/src/features/activity/ActivityDetailPage.tsx +++ b/admin/src/features/activity/ActivityDetailPage.tsx @@ -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, }, }); diff --git a/admin/src/features/activity/ActivityFilters.tsx b/admin/src/features/activity/ActivityFilters.tsx index f88c4fb..e90759f 100644 --- a/admin/src/features/activity/ActivityFilters.tsx +++ b/admin/src/features/activity/ActivityFilters.tsx @@ -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", }, diff --git a/admin/src/features/activity/ActivityPage.test.tsx b/admin/src/features/activity/ActivityPage.test.tsx index 6314289..3845882 100644 --- a/admin/src/features/activity/ActivityPage.test.tsx +++ b/admin/src/features/activity/ActivityPage.test.tsx @@ -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 { + 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(); }); diff --git a/admin/src/features/activity/ActivityPage.tsx b/admin/src/features/activity/ActivityPage.tsx index e3249a7..ac6e6c8 100644 --- a/admin/src/features/activity/ActivityPage.tsx +++ b/admin/src/features/activity/ActivityPage.tsx @@ -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() { */} - +

diff --git a/admin/src/features/activity/ClientFilter.tsx b/admin/src/features/activity/ClientFilter.tsx index 6d9e2cb..dc9a7cb 100644 --- a/admin/src/features/activity/ClientFilter.tsx +++ b/admin/src/features/activity/ClientFilter.tsx @@ -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)} + {chipFor(ip, options)} diff --git a/admin/src/features/activity/HistoryActivity.tsx b/admin/src/features/activity/HistoryActivity.tsx index a224e6c..272d7f9 100644 --- a/admin/src/features/activity/HistoryActivity.tsx +++ b/admin/src/features/activity/HistoryActivity.tsx @@ -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 ( -

+

Loading activity…

); @@ -126,16 +141,15 @@ export default function HistoryActivity({ search }: { search: ActivitySearch })

)} {rows.length === 0 ? ( - <> -

- {filterActive ? "No queries match the current filters." : "No queries logged yet."} -

- {coverage !== undefined && ( -
- -
+
+

{filterActive ? "No queries match the current filters." : "No queries logged yet."}

+ {filterActive && ( + )} - + {coverage !== undefined && } +
) : ( <>
@@ -169,7 +183,9 @@ export default function HistoryActivity({ search }: { search: ActivitySearch })

- Showing {rows.length} {rows.length === 1 ? "query" : "queries"} + {/* The count is the one part of this line that moves as pages load. */} + Showing {rows.length}{" "} + {rows.length === 1 ? "query" : "queries"} {hasMore ? "" : " — end of log"}

{coverage !== undefined && } diff --git a/admin/src/features/activity/PolicyTestPage.tsx b/admin/src/features/activity/PolicyTestPage.tsx index 87b3c8d..b70897e 100644 --- a/admin/src/features/activity/PolicyTestPage.tsx +++ b/admin/src/features/activity/PolicyTestPage.tsx @@ -28,6 +28,7 @@ const styles = stylex.create({ fontSize: "1.5rem", lineHeight: "2rem", fontWeight: 600, + textWrap: "balance", }, intro: { marginTop: "0.5rem", diff --git a/admin/src/features/activity/cells.tsx b/admin/src/features/activity/cells.tsx index f5529fa..91805bb 100644 --- a/admin/src/features/activity/cells.tsx +++ b/admin/src/features/activity/cells.tsx @@ -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 ( <> {formatTime(row.ts)} - - {renderDomain(row.id, row.domain)} + + {renderDomain(row.id, {row.domain})} diff --git a/admin/src/features/clients/ClientDetailPage.tsx b/admin/src/features/clients/ClientDetailPage.tsx index e479cf8..6c219a0 100644 --- a/admin/src/features/clients/ClientDetailPage.tsx +++ b/admin/src/features/clients/ClientDetailPage.tsx @@ -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, }, diff --git a/admin/src/features/clients/ClientsPage.tsx b/admin/src/features/clients/ClientsPage.tsx index 7cf39d8..cd63dfb 100644 --- a/admin/src/features/clients/ClientsPage.tsx +++ b/admin/src/features/clients/ClientsPage.tsx @@ -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() { - + + + {client.group} {formatTime(client.first_seen)} diff --git a/admin/src/features/clients/NetworkAssignments.tsx b/admin/src/features/clients/NetworkAssignments.tsx index 15cdde6..1aeb142 100644 --- a/admin/src/features/clients/NetworkAssignments.tsx +++ b/admin/src/features/clients/NetworkAssignments.tsx @@ -203,7 +203,7 @@ function AssignmentsEditor({ prefixes, groups }: Props) { return ( <> {state.rows.length === 0 ? ( -

No network assignments configured.

+

No network assignments configured. Add one below.

) : (
    {state.rows.map((row, index) => ( diff --git a/admin/src/features/configuration/GroupSourcesEditor.tsx b/admin/src/features/configuration/GroupSourcesEditor.tsx index c13315c..a080a75 100644 --- a/admin/src/features/configuration/GroupSourcesEditor.tsx +++ b/admin/src/features/configuration/GroupSourcesEditor.tsx @@ -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 ( -

    +

    Loading sources…

    ); diff --git a/admin/src/features/configuration/ProtectionGroups.tsx b/admin/src/features/configuration/ProtectionGroups.tsx index 32c1f4e..dbc04eb 100644 --- a/admin/src/features/configuration/ProtectionGroups.tsx +++ b/admin/src/features/configuration/ProtectionGroups.tsx @@ -167,7 +167,9 @@ function GroupsMasterDetail({ groups, status }: { groups: Group[]; status: Confi
{selected === undefined ? ( -

No groups exist.

+

+ {fileMode ? "No groups exist." : "No groups exist. Create one with the New group field."} +

) : fileMode ? ( ) : ( @@ -432,7 +434,10 @@ function GroupRules({ group, editable }: { group: Group; editable: boolean }) { return ( <> {scoped.length === 0 ? ( -

No allow or block rules for this group.

+

+ No allow or block rules for this group. + {editable && " Add one below."} +

) : ( )} diff --git a/admin/src/features/configuration/QueryPanel.tsx b/admin/src/features/configuration/QueryPanel.tsx index 7c2e2f4..a0d4ed8 100644 --- a/admin/src/features/configuration/QueryPanel.tsx +++ b/admin/src/features/configuration/QueryPanel.tsx @@ -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({ }) { if (query.isPending) { return ( -

+

Loading…

); diff --git a/admin/src/features/diagnostics/DiagnosticDetailPage.tsx b/admin/src/features/diagnostics/DiagnosticDetailPage.tsx index 20c7a4f..c564257 100644 --- a/admin/src/features/diagnostics/DiagnosticDetailPage.tsx +++ b/admin/src/features/diagnostics/DiagnosticDetailPage.tsx @@ -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, }, diff --git a/admin/src/features/diagnostics/DiagnosticsPage.test.tsx b/admin/src/features/diagnostics/DiagnosticsPage.test.tsx index 2e9ad63..c6ce2a0 100644 --- a/admin/src/features/diagnostics/DiagnosticsPage.test.tsx +++ b/admin/src/features/diagnostics/DiagnosticsPage.test.tsx @@ -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 () => { diff --git a/admin/src/features/diagnostics/DiagnosticsPage.tsx b/admin/src/features/diagnostics/DiagnosticsPage.tsx index 32e20fa..2b13cc9 100644 --- a/admin/src/features/diagnostics/DiagnosticsPage.tsx +++ b/admin/src/features/diagnostics/DiagnosticsPage.tsx @@ -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: {event.subject} - {formatTime(event.first_seen)} - + {formatTime(event.first_seen)} + {event.resolved_at === null ? "—" : formatTime(event.resolved_at)} {event.occurrences} @@ -436,7 +449,7 @@ export default function DiagnosticsPage() { {active.status === "error" ? ( void active.refetch()} /> ) : active.data === undefined ? ( -

+

Loading diagnostics…

) : activeRows.length === 0 ? ( @@ -474,7 +487,7 @@ export default function DiagnosticsPage() { {history.status === "error" ? ( void history.refetch()} /> ) : history.data === undefined ? ( -

+

Loading history…

) : historyRows.length === 0 ? ( @@ -509,7 +522,8 @@ export default function DiagnosticsPage() {

- Showing {historyRows.length} resolved {historyRows.length === 1 ? "entry" : "entries"} + Showing {historyRows.length} resolved{" "} + {historyRows.length === 1 ? "entry" : "entries"} {hasMore(history) ? "" : " — end of history"}

diff --git a/admin/src/features/diagnostics/HealthStrip.tsx b/admin/src/features/diagnostics/HealthStrip.tsx index 1e22aa6..11ae4a0 100644 --- a/admin/src/features/diagnostics/HealthStrip.tsx +++ b/admin/src/features/diagnostics/HealthStrip.tsx @@ -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 ? ( void health.refetch()} /> ) : ( -

+

Loading status…

); diff --git a/admin/src/features/overview/Donut.tsx b/admin/src/features/overview/Donut.tsx index 772067c..219a5e7 100644 --- a/admin/src/features/overview/Donut.tsx +++ b/admin/src/features/overview/Donut.tsx @@ -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", diff --git a/admin/src/features/overview/OverviewFrame.tsx b/admin/src/features/overview/OverviewFrame.tsx index fffe552..f1cd274 100644 --- a/admin/src/features/overview/OverviewFrame.tsx +++ b/admin/src/features/overview/OverviewFrame.tsx @@ -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, diff --git a/admin/src/features/overview/chartKit.tsx b/admin/src/features/overview/chartKit.tsx index 0c370be..3addda9 100644 --- a/admin/src/features/overview/chartKit.tsx +++ b/admin/src/features/overview/chartKit.tsx @@ -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, number] { const ref = useRef(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} diff --git a/admin/src/routes.tsx b/admin/src/routes.tsx index b7a3250..99b55cb 100644 --- a/admin/src/routes.tsx +++ b/admin/src/routes.tsx @@ -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, diff --git a/admin/src/shell/AppShell.tsx b/admin/src/shell/AppShell.tsx index 23f1b6c..2ca4261 100644 --- a/admin/src/shell/AppShell.tsx +++ b/admin/src/shell/AppShell.tsx @@ -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", diff --git a/admin/src/styles.css b/admin/src/styles.css index 8cb7b37..910bf46 100644 --- a/admin/src/styles.css +++ b/admin/src/styles.css @@ -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, diff --git a/admin/src/ui/ConfirmDialog.tsx b/admin/src/ui/ConfirmDialog.tsx index 742d048..b6f747e 100644 --- a/admin/src/ui/ConfirmDialog.tsx +++ b/admin/src/ui/ConfirmDialog.tsx @@ -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", diff --git a/admin/src/ui/Dialog.tsx b/admin/src/ui/Dialog.tsx index 8893c96..fd5ae32 100644 --- a/admin/src/ui/Dialog.tsx +++ b/admin/src/ui/Dialog.tsx @@ -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", diff --git a/admin/src/ui/Tabs.tsx b/admin/src/ui/Tabs.tsx index da3b103..90364ec 100644 --- a/admin/src/ui/Tabs.tsx +++ b/admin/src/ui/Tabs.tsx @@ -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", diff --git a/admin/src/ui/styles.ts b/admin/src/ui/styles.ts index b92e82a..84f836a 100644 --- a/admin/src/ui/styles.ts +++ b/admin/src/ui/styles.ts @@ -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", diff --git a/admin/src/ui/tokens.stylex.ts b/admin/src/ui/tokens.stylex.ts index b172a57..a23d0bc 100644 --- a/admin/src/ui/tokens.stylex.ts +++ b/admin/src/ui/tokens.stylex.ts @@ -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", +});