- 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() {
+ 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:
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",
+});