From e656670dd4c082f5168c022ae699fa15f5eb02e2 Mon Sep 17 00:00:00 2001 From: m5r Date: Tue, 1 Sep 2026 23:23:07 +0200 Subject: [PATCH] admin: ui polish pass, thirty findings from the emil audit 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. --- admin/src/auth/LoginPage.tsx | 1 + .../features/activity/ActivityDetailPage.tsx | 2 + .../src/features/activity/ActivityFilters.tsx | 21 +++--- .../features/activity/ActivityPage.test.tsx | 68 ++++++++++++++++--- admin/src/features/activity/ActivityPage.tsx | 3 +- admin/src/features/activity/ClientFilter.tsx | 31 ++++++--- .../src/features/activity/HistoryActivity.tsx | 40 +++++++---- .../src/features/activity/PolicyTestPage.tsx | 1 + admin/src/features/activity/cells.tsx | 35 +++++++++- .../src/features/clients/ClientDetailPage.tsx | 6 ++ admin/src/features/clients/ClientsPage.tsx | 13 +++- .../features/clients/NetworkAssignments.tsx | 2 +- .../configuration/GroupSourcesEditor.tsx | 9 ++- .../configuration/ProtectionGroups.tsx | 9 ++- .../src/features/configuration/QueryPanel.tsx | 14 +++- .../diagnostics/DiagnosticDetailPage.tsx | 4 ++ .../diagnostics/DiagnosticsPage.test.tsx | 13 +++- .../features/diagnostics/DiagnosticsPage.tsx | 24 +++++-- .../src/features/diagnostics/HealthStrip.tsx | 10 ++- admin/src/features/overview/Donut.tsx | 16 ++++- admin/src/features/overview/OverviewFrame.tsx | 23 ++++++- admin/src/features/overview/chartKit.tsx | 21 +++--- admin/src/routes.tsx | 24 +++++++ admin/src/shell/AppShell.tsx | 8 ++- admin/src/styles.css | 4 ++ admin/src/ui/ConfirmDialog.tsx | 4 +- admin/src/ui/Dialog.tsx | 10 +-- admin/src/ui/Tabs.tsx | 6 +- admin/src/ui/styles.ts | 27 +++++++- admin/src/ui/tokens.stylex.ts | 32 +++++++++ 30 files changed, 399 insertions(+), 82 deletions(-) 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", +});