Compare commits
1
Commits
v0.0.15
...
e656670dd4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e656670dd4
|
@@ -25,6 +25,7 @@ const styles = stylex.create({
|
|||||||
fontSize: "1.5rem",
|
fontSize: "1.5rem",
|
||||||
lineHeight: "2rem",
|
lineHeight: "2rem",
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
|
textWrap: "balance",
|
||||||
},
|
},
|
||||||
probing: {
|
probing: {
|
||||||
marginTop: "1rem",
|
marginTop: "1rem",
|
||||||
|
|||||||
@@ -24,8 +24,10 @@ const styles = stylex.create({
|
|||||||
backIcon: {
|
backIcon: {
|
||||||
display: "inline-flex",
|
display: "inline-flex",
|
||||||
},
|
},
|
||||||
|
/** About the height of the filled detail surface, so it does not jump in. */
|
||||||
loading: {
|
loading: {
|
||||||
marginTop: "1rem",
|
marginTop: "1rem",
|
||||||
|
minHeight: "20rem",
|
||||||
color: colors.textMuted,
|
color: colors.textMuted,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ import { useCallback, useEffect, useState, type FormEvent, type KeyboardEvent }
|
|||||||
import * as stylex from "@stylexjs/stylex";
|
import * as stylex from "@stylexjs/stylex";
|
||||||
import { Button, Menu, MenuItem, MenuTrigger, Popover, Radio, RadioGroup } from "react-aria-components";
|
import { Button, Menu, MenuItem, MenuTrigger, Popover, Radio, RadioGroup } from "react-aria-components";
|
||||||
import { styles as shared } from "@/ui/styles";
|
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 ClientFilter, { joinClients, parseClients, useClientOptions } from "./ClientFilter";
|
||||||
import { datetimeField, editDatetimeField, resolveDatetimeField, type DatetimeField } from "./datetime";
|
import { datetimeField, editDatetimeField, resolveDatetimeField, type DatetimeField } from "./datetime";
|
||||||
import type { ActivitySearch } from "./search";
|
import type { ActivitySearch } from "./search";
|
||||||
@@ -57,9 +57,6 @@ const PRESETS = [
|
|||||||
|
|
||||||
const CUSTOM_ITEM = "Custom…";
|
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({
|
const styles = stylex.create({
|
||||||
toolbar: {
|
toolbar: {
|
||||||
marginTop: "1rem",
|
marginTop: "1rem",
|
||||||
@@ -87,7 +84,7 @@ const styles = stylex.create({
|
|||||||
},
|
},
|
||||||
/** Every control in the row is a pointer target before it is anything else. */
|
/** Every control in the row is a pointer target before it is anything else. */
|
||||||
field: {
|
field: {
|
||||||
minHeight: HIT_TARGET,
|
minHeight: metrics.hitTarget,
|
||||||
},
|
},
|
||||||
searchInput: {
|
searchInput: {
|
||||||
width: "100%",
|
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. */
|
/** A button is text-sized by default; this is the hit area around the text. */
|
||||||
hitTarget: {
|
hitTarget: {
|
||||||
minHeight: HIT_TARGET,
|
minHeight: metrics.hitTarget,
|
||||||
minWidth: HIT_TARGET,
|
minWidth: metrics.hitTarget,
|
||||||
display: "inline-flex",
|
display: "inline-flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
@@ -113,8 +110,11 @@ const styles = stylex.create({
|
|||||||
paddingInline: "0.625rem",
|
paddingInline: "0.625rem",
|
||||||
fontSize: "0.875rem",
|
fontSize: "0.875rem",
|
||||||
lineHeight: "1.25rem",
|
lineHeight: "1.25rem",
|
||||||
minHeight: HIT_TARGET,
|
// The weight is on the base, not on the selected state: a bolder label is a
|
||||||
minWidth: HIT_TARGET,
|
// wider label, and the row would shift under the pointer on every pick.
|
||||||
|
fontWeight: 500,
|
||||||
|
minHeight: metrics.hitTarget,
|
||||||
|
minWidth: metrics.hitTarget,
|
||||||
display: "inline-flex",
|
display: "inline-flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
@@ -133,7 +133,6 @@ const styles = stylex.create({
|
|||||||
"@media (prefers-color-scheme: dark)": "oklch(37% 0.013 285.805)",
|
"@media (prefers-color-scheme: dark)": "oklch(37% 0.013 285.805)",
|
||||||
},
|
},
|
||||||
color: colors.text,
|
color: colors.text,
|
||||||
fontWeight: 500,
|
|
||||||
},
|
},
|
||||||
segmentIdle: {
|
segmentIdle: {
|
||||||
backgroundColor: { default: "transparent", ":hover": colors.surfaceHover },
|
backgroundColor: { default: "transparent", ":hover": colors.surfaceHover },
|
||||||
@@ -158,7 +157,7 @@ const styles = stylex.create({
|
|||||||
fontSize: "0.875rem",
|
fontSize: "0.875rem",
|
||||||
lineHeight: "1.25rem",
|
lineHeight: "1.25rem",
|
||||||
whiteSpace: "nowrap",
|
whiteSpace: "nowrap",
|
||||||
minHeight: HIT_TARGET,
|
minHeight: metrics.hitTarget,
|
||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -146,6 +146,26 @@ function submitFilters() {
|
|||||||
fireEvent.submit(domainInput().closest("form")!);
|
fireEvent.submit(domainInput().closest("form")!);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The footer's count line, matched on the whole sentence.
|
||||||
|
*
|
||||||
|
* The number sits in a span of its own so it can carry tabular digits, so the
|
||||||
|
* line is several text nodes and the default string matcher — which reads one
|
||||||
|
* node at a time — cannot see it whole.
|
||||||
|
*/
|
||||||
|
function countMatcher(pattern: RegExp) {
|
||||||
|
return (_: string, element: Element | null): boolean =>
|
||||||
|
element?.tagName === "P" && pattern.test(element.textContent ?? "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function countLine(pattern: RegExp): HTMLElement {
|
||||||
|
return screen.getByText(countMatcher(pattern));
|
||||||
|
}
|
||||||
|
|
||||||
|
function findCountLine(pattern: RegExp): Promise<HTMLElement> {
|
||||||
|
return screen.findByText(countMatcher(pattern));
|
||||||
|
}
|
||||||
|
|
||||||
/** The custom range lives behind the Time menu; the two bounds only exist there. */
|
/** The custom range lives behind the Time menu; the two bounds only exist there. */
|
||||||
function openCustomRange() {
|
function openCustomRange() {
|
||||||
fireEvent.click(screen.getByRole("button", { name: /^Time: / }));
|
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.
|
// 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).getAllByText("Blocked")).toHaveLength(2);
|
||||||
expect(within(blocked).getByText("—")).toBeTruthy();
|
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 () => {
|
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");
|
await screen.findByText("older.example");
|
||||||
|
|
||||||
expect(screen.getByText("first.example")).toBeTruthy();
|
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();
|
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" } });
|
fireEvent.change(domainInput(), { target: { value: "ads" } });
|
||||||
submitFilters();
|
submitFilters();
|
||||||
|
|
||||||
await screen.findByText(/Showing 1 query /);
|
await findCountLine(/Showing 1 query /);
|
||||||
expect(history.location.search).toContain("domain=ads");
|
expect(history.location.search).toContain("domain=ads");
|
||||||
expect(screen.getByText("ads.example")).toBeTruthy();
|
expect(screen.getByText("ads.example")).toBeTruthy();
|
||||||
expect(screen.queryByText("first.example")).toBeNull();
|
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" } });
|
fireEvent.change(domainInput(), { target: { value: "ads" } });
|
||||||
submitFilters();
|
submitFilters();
|
||||||
await screen.findByText(/Showing 1 query /);
|
await findCountLine(/Showing 1 query /);
|
||||||
|
|
||||||
releaseLoadMore();
|
releaseLoadMore();
|
||||||
await act(async () => {
|
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.queryByText("older.example")).toBeNull();
|
||||||
expect(screen.getByText(/Showing 1 query /)).toBeTruthy();
|
expect(countLine(/Showing 1 query /)).toBeTruthy();
|
||||||
expect(screen.queryByRole("alert")).toBeNull();
|
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");
|
await screen.findByText("ads.older.example");
|
||||||
|
|
||||||
expect(queryCalls()).toContain("/api/queries?domain=ads&before=7");
|
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 () => {
|
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");
|
await screen.findByText("n22.example");
|
||||||
const shown = screen.getAllByText(/^n\d+\.example$/).map((cell) => cell.textContent);
|
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(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 () => {
|
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", "");
|
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 () => {
|
test("the domain field debounces into the url, and Enter flushes it at once", async () => {
|
||||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||||
try {
|
try {
|
||||||
@@ -739,7 +791,7 @@ test("the coverage watermark reads under the results, never over them", async ()
|
|||||||
await screen.findByText("kept.example");
|
await screen.findByText("kept.example");
|
||||||
|
|
||||||
const watermark = screen.getByText(/Query history is available from/);
|
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.
|
// After the count in document order, which is what "footer" means here.
|
||||||
expect(count.compareDocumentPosition(watermark) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
expect(count.compareDocumentPosition(watermark) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ const styles = stylex.create({
|
|||||||
fontSize: "1.5rem",
|
fontSize: "1.5rem",
|
||||||
lineHeight: "2rem",
|
lineHeight: "2rem",
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
|
textWrap: "balance",
|
||||||
},
|
},
|
||||||
switch: {
|
switch: {
|
||||||
display: "flex",
|
display: "flex",
|
||||||
@@ -177,7 +178,7 @@ export default function ActivityPage() {
|
|||||||
*/}
|
*/}
|
||||||
<TabPanel id="history" className={panelClass}>
|
<TabPanel id="history" className={panelClass}>
|
||||||
<ActivityFilters applied={search} onApply={apply} onClear={clear} />
|
<ActivityFilters applied={search} onApply={apply} onClear={clear} />
|
||||||
<HistoryActivity search={search} />
|
<HistoryActivity search={search} onClear={clear} />
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
<TabPanel id="live" className={panelClass}>
|
<TabPanel id="live" className={panelClass}>
|
||||||
<p {...stylex.props(styles.liveNote)}>
|
<p {...stylex.props(styles.liveNote)}>
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ import * as stylex from "@stylexjs/stylex";
|
|||||||
import { Button, Menu, MenuItem, MenuTrigger, Popover } from "react-aria-components";
|
import { Button, Menu, MenuItem, MenuTrigger, Popover } from "react-aria-components";
|
||||||
import { clientLabel, useClientNames } from "@/features/clients/clientNames";
|
import { clientLabel, useClientNames } from "@/features/clients/clientNames";
|
||||||
import { styles as shared } from "@/ui/styles";
|
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";
|
import { MAX_CLIENTS } from "./search";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -47,9 +47,6 @@ export interface ClientOption {
|
|||||||
name: string | null;
|
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.
|
* How many chips are shown before the rest become a count.
|
||||||
*
|
*
|
||||||
@@ -72,8 +69,8 @@ const styles = stylex.create({
|
|||||||
color: colors.textMuted,
|
color: colors.textMuted,
|
||||||
},
|
},
|
||||||
trigger: {
|
trigger: {
|
||||||
minHeight: HIT_TARGET,
|
minHeight: metrics.hitTarget,
|
||||||
minWidth: HIT_TARGET,
|
minWidth: metrics.hitTarget,
|
||||||
display: "inline-flex",
|
display: "inline-flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
@@ -100,7 +97,7 @@ const styles = stylex.create({
|
|||||||
fontSize: "0.875rem",
|
fontSize: "0.875rem",
|
||||||
lineHeight: "1.25rem",
|
lineHeight: "1.25rem",
|
||||||
whiteSpace: "nowrap",
|
whiteSpace: "nowrap",
|
||||||
minHeight: HIT_TARGET,
|
minHeight: metrics.hitTarget,
|
||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
gap: "0.5rem",
|
gap: "0.5rem",
|
||||||
@@ -119,10 +116,12 @@ const styles = stylex.create({
|
|||||||
},
|
},
|
||||||
chip: {
|
chip: {
|
||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
|
transitionProperty: metrics.transitionProperty,
|
||||||
|
transitionDuration: { default: metrics.transitionDuration, "@media (prefers-reduced-motion: reduce)": "0s" },
|
||||||
display: "inline-flex",
|
display: "inline-flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
gap: "0.375rem",
|
gap: "0.375rem",
|
||||||
minHeight: HIT_TARGET,
|
minHeight: metrics.hitTarget,
|
||||||
paddingInline: "0.625rem",
|
paddingInline: "0.625rem",
|
||||||
borderRadius: "999px",
|
borderRadius: "999px",
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
@@ -133,15 +132,24 @@ const styles = stylex.create({
|
|||||||
fontSize: "0.875rem",
|
fontSize: "0.875rem",
|
||||||
lineHeight: "1.25rem",
|
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: {
|
chipCross: {
|
||||||
display: "inline-flex",
|
display: "inline-flex",
|
||||||
|
pointerEvents: "none",
|
||||||
color: colors.textMuted,
|
color: colors.textMuted,
|
||||||
},
|
},
|
||||||
/** Not a button: it removes nothing, and nothing about it is pressable. */
|
/** Not a button: it removes nothing, and nothing about it is pressable. */
|
||||||
chipMore: {
|
chipMore: {
|
||||||
display: "inline-flex",
|
display: "inline-flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
minHeight: HIT_TARGET,
|
minHeight: metrics.hitTarget,
|
||||||
paddingInline: "0.625rem",
|
paddingInline: "0.625rem",
|
||||||
fontSize: "0.875rem",
|
fontSize: "0.875rem",
|
||||||
lineHeight: "1.25rem",
|
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
|
// 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.
|
// would not say what the button does to a reader who cannot see it.
|
||||||
aria-label={`Remove client ${displayFor(ip, options)}`}
|
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)}
|
onClick={() => remove(ip)}
|
||||||
{...stylex.props(styles.chip, shared.focusRing)}
|
{...stylex.props(styles.chip, shared.focusRing)}
|
||||||
>
|
>
|
||||||
{chipFor(ip, options)}
|
<span {...stylex.props(styles.chipLabel)}>{chipFor(ip, options)}</span>
|
||||||
<span aria-hidden="true" {...stylex.props(styles.chipCross)}>
|
<span aria-hidden="true" {...stylex.props(styles.chipCross)}>
|
||||||
<X size={10} />
|
<X size={10} />
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -24,8 +24,17 @@ import { queriesFilterOf, type ActivitySearch } from "./search";
|
|||||||
const styles = stylex.create({
|
const styles = stylex.create({
|
||||||
empty: {
|
empty: {
|
||||||
marginTop: "1.5rem",
|
marginTop: "1.5rem",
|
||||||
|
minHeight: "6rem",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "flex-start",
|
||||||
|
gap: "0.75rem",
|
||||||
color: colors.textMuted,
|
color: colors.textMuted,
|
||||||
},
|
},
|
||||||
|
/** About a default page of rows, so the table does not jump in under the reader. */
|
||||||
|
loading: {
|
||||||
|
minHeight: "24rem",
|
||||||
|
},
|
||||||
tableWrap: {
|
tableWrap: {
|
||||||
marginTop: "1rem",
|
marginTop: "1rem",
|
||||||
overflowX: "auto",
|
overflowX: "auto",
|
||||||
@@ -75,7 +84,13 @@ function errorMessage(error: unknown): string {
|
|||||||
return error instanceof Error ? error.message : String(error);
|
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 filter = queriesFilterOf(search);
|
||||||
const base = useInfiniteQuery(queriesInfiniteQuery(filter));
|
const base = useInfiniteQuery(queriesInfiniteQuery(filter));
|
||||||
const clientNames = useClientNames();
|
const clientNames = useClientNames();
|
||||||
@@ -107,7 +122,7 @@ export default function HistoryActivity({ search }: { search: ActivitySearch })
|
|||||||
}
|
}
|
||||||
if (base.data === undefined) {
|
if (base.data === undefined) {
|
||||||
return (
|
return (
|
||||||
<p {...stylex.props(styles.empty, shared.pulse)} role="status">
|
<p {...stylex.props(styles.empty, styles.loading, shared.pulse)} role="status">
|
||||||
Loading activity…
|
Loading activity…
|
||||||
</p>
|
</p>
|
||||||
);
|
);
|
||||||
@@ -126,16 +141,15 @@ export default function HistoryActivity({ search }: { search: ActivitySearch })
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{rows.length === 0 ? (
|
{rows.length === 0 ? (
|
||||||
<>
|
<div {...stylex.props(styles.empty)}>
|
||||||
<p {...stylex.props(styles.empty)}>
|
<p>{filterActive ? "No queries match the current filters." : "No queries logged yet."}</p>
|
||||||
{filterActive ? "No queries match the current filters." : "No queries logged yet."}
|
{filterActive && (
|
||||||
</p>
|
<button type="button" onClick={onClear} {...stylex.props(shared.button, shared.focusRing)}>
|
||||||
{coverage !== undefined && (
|
Clear filters
|
||||||
<div {...stylex.props(styles.spacedTop)}>
|
</button>
|
||||||
<CoverageNotice coverage={coverage} variant="note" />
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</>
|
{coverage !== undefined && <CoverageNotice coverage={coverage} variant="note" />}
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div {...stylex.props(styles.tableWrap)}>
|
<div {...stylex.props(styles.tableWrap)}>
|
||||||
@@ -169,7 +183,9 @@ export default function HistoryActivity({ search }: { search: ActivitySearch })
|
|||||||
</div>
|
</div>
|
||||||
<div {...stylex.props(styles.footer)}>
|
<div {...stylex.props(styles.footer)}>
|
||||||
<p {...stylex.props(styles.note)}>
|
<p {...stylex.props(styles.note)}>
|
||||||
Showing {rows.length} {rows.length === 1 ? "query" : "queries"}
|
{/* The count is the one part of this line that moves as pages load. */}
|
||||||
|
Showing <span {...stylex.props(shared.tabularNums)}>{rows.length}</span>{" "}
|
||||||
|
{rows.length === 1 ? "query" : "queries"}
|
||||||
{hasMore ? "" : " — end of log"}
|
{hasMore ? "" : " — end of log"}
|
||||||
</p>
|
</p>
|
||||||
{coverage !== undefined && <CoverageNotice coverage={coverage} variant="note" />}
|
{coverage !== undefined && <CoverageNotice coverage={coverage} variant="note" />}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ const styles = stylex.create({
|
|||||||
fontSize: "1.5rem",
|
fontSize: "1.5rem",
|
||||||
lineHeight: "2rem",
|
lineHeight: "2rem",
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
|
textWrap: "balance",
|
||||||
},
|
},
|
||||||
intro: {
|
intro: {
|
||||||
marginTop: "0.5rem",
|
marginTop: "0.5rem",
|
||||||
|
|||||||
@@ -25,6 +25,9 @@ import { colors } from "@/ui/tokens.stylex";
|
|||||||
|
|
||||||
const DARK = "@media (prefers-color-scheme: dark)";
|
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({
|
const styles = stylex.create({
|
||||||
head: {
|
head: {
|
||||||
backgroundColor: { default: "oklch(98.5% 0 none)", [DARK]: "oklch(21% 0.006 285.885)" },
|
backgroundColor: { default: "oklch(98.5% 0 none)", [DARK]: "oklch(21% 0.006 285.885)" },
|
||||||
@@ -46,6 +49,9 @@ const styles = stylex.create({
|
|||||||
breakAll: {
|
breakAll: {
|
||||||
wordBreak: "break-all",
|
wordBreak: "break-all",
|
||||||
},
|
},
|
||||||
|
domainCell: {
|
||||||
|
maxWidth: DOMAIN_MAX_WIDTH,
|
||||||
|
},
|
||||||
small: {
|
small: {
|
||||||
fontSize: "0.75rem",
|
fontSize: "0.75rem",
|
||||||
lineHeight: "1rem",
|
lineHeight: "1rem",
|
||||||
@@ -53,10 +59,32 @@ const styles = stylex.create({
|
|||||||
muted: {
|
muted: {
|
||||||
color: colors.textMuted,
|
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: {
|
domainLink: {
|
||||||
|
display: "block",
|
||||||
|
maxWidth: DOMAIN_MAX_WIDTH,
|
||||||
|
paddingBlock: "0.5rem",
|
||||||
|
marginBlock: "-0.5rem",
|
||||||
color: colors.primaryOnSurface,
|
color: colors.primaryOnSurface,
|
||||||
textDecorationLine: "none",
|
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
|
* 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
|
* kind of unhappy answer this was. A monochrome or colour-blind reading of
|
||||||
@@ -136,8 +164,11 @@ export function ActivityCells({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<td {...stylex.props(styles.cell, styles.nowrap, styles.muted)}>{formatTime(row.ts)}</td>
|
<td {...stylex.props(styles.cell, styles.nowrap, styles.muted)}>{formatTime(row.ts)}</td>
|
||||||
<td {...stylex.props(styles.cell, styles.small, styles.breakAll, shared.mono)}>
|
<td
|
||||||
{renderDomain(row.id, row.domain)}
|
title={row.domain}
|
||||||
|
{...stylex.props(styles.cell, styles.small, styles.breakAll, styles.domainCell, shared.mono)}
|
||||||
|
>
|
||||||
|
{renderDomain(row.id, <span {...stylex.props(styles.domainClamp)}>{row.domain}</span>)}
|
||||||
</td>
|
</td>
|
||||||
<td {...stylex.props(styles.cell, styles.small, styles.nowrap)}>
|
<td {...stylex.props(styles.cell, styles.small, styles.nowrap)}>
|
||||||
<ClientName ip={row.client_ip} names={clientNames} />
|
<ClientName ip={row.client_ip} names={clientNames} />
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ const styles = stylex.create({
|
|||||||
fontSize: "1.5rem",
|
fontSize: "1.5rem",
|
||||||
lineHeight: "2rem",
|
lineHeight: "2rem",
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
|
textWrap: "balance",
|
||||||
},
|
},
|
||||||
address: {
|
address: {
|
||||||
marginTop: "0.25rem",
|
marginTop: "0.25rem",
|
||||||
@@ -93,7 +94,12 @@ const styles = stylex.create({
|
|||||||
link: {
|
link: {
|
||||||
color: colors.primaryOnSurface,
|
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: {
|
loading: {
|
||||||
|
minHeight: "24rem",
|
||||||
marginTop: "1rem",
|
marginTop: "1rem",
|
||||||
color: colors.textMuted,
|
color: colors.textMuted,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -50,6 +50,15 @@ const styles = stylex.create({
|
|||||||
fontSize: "1.5rem",
|
fontSize: "1.5rem",
|
||||||
lineHeight: "2rem",
|
lineHeight: "2rem",
|
||||||
fontWeight: 600,
|
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: {
|
empty: {
|
||||||
marginTop: "1rem",
|
marginTop: "1rem",
|
||||||
@@ -203,7 +212,9 @@ export default function ClientsPage() {
|
|||||||
</Link>
|
</Link>
|
||||||
</td>
|
</td>
|
||||||
<td {...stylex.props(styles.cell)}>
|
<td {...stylex.props(styles.cell)}>
|
||||||
|
<span {...stylex.props(styles.name)}>
|
||||||
<ClientDisplayName client={client} />
|
<ClientDisplayName client={client} />
|
||||||
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td {...stylex.props(styles.cell)}>{client.group}</td>
|
<td {...stylex.props(styles.cell)}>{client.group}</td>
|
||||||
<td {...stylex.props(styles.cell)}>{formatTime(client.first_seen)}</td>
|
<td {...stylex.props(styles.cell)}>{formatTime(client.first_seen)}</td>
|
||||||
|
|||||||
@@ -203,7 +203,7 @@ function AssignmentsEditor({ prefixes, groups }: Props) {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{state.rows.length === 0 ? (
|
{state.rows.length === 0 ? (
|
||||||
<p {...stylex.props(styles.empty)}>No network assignments configured.</p>
|
<p {...stylex.props(styles.empty)}>No network assignments configured. Add one below.</p>
|
||||||
) : (
|
) : (
|
||||||
<ul {...stylex.props(styles.rows)}>
|
<ul {...stylex.props(styles.rows)}>
|
||||||
{state.rows.map((row, index) => (
|
{state.rows.map((row, index) => (
|
||||||
|
|||||||
@@ -21,6 +21,13 @@ const styles = stylex.create({
|
|||||||
lineHeight: "1.25rem",
|
lineHeight: "1.25rem",
|
||||||
color: colors.textMuted,
|
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: {
|
root: {
|
||||||
marginTop: "0.75rem",
|
marginTop: "0.75rem",
|
||||||
},
|
},
|
||||||
@@ -44,7 +51,7 @@ export default function GroupSourcesEditor({ groupId, blocklists }: Props) {
|
|||||||
|
|
||||||
if (sources.isPending) {
|
if (sources.isPending) {
|
||||||
return (
|
return (
|
||||||
<p role="status" {...stylex.props(styles.note)}>
|
<p role="status" {...stylex.props(styles.note, styles.loading)}>
|
||||||
Loading sources…
|
Loading sources…
|
||||||
</p>
|
</p>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -167,7 +167,9 @@ function GroupsMasterDetail({ groups, status }: { groups: Group[]; status: Confi
|
|||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
{selected === undefined ? (
|
{selected === undefined ? (
|
||||||
<p {...stylex.props(config.empty)}>No groups exist.</p>
|
<p {...stylex.props(config.empty)}>
|
||||||
|
{fileMode ? "No groups exist." : "No groups exist. Create one with the New group field."}
|
||||||
|
</p>
|
||||||
) : fileMode ? (
|
) : fileMode ? (
|
||||||
<GroupDetailReadOnly group={selected} />
|
<GroupDetailReadOnly group={selected} />
|
||||||
) : (
|
) : (
|
||||||
@@ -432,7 +434,10 @@ function GroupRules({ group, editable }: { group: Group; editable: boolean }) {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{scoped.length === 0 ? (
|
{scoped.length === 0 ? (
|
||||||
<p {...stylex.props(config.empty)}>No allow or block rules for this group.</p>
|
<p {...stylex.props(config.empty)}>
|
||||||
|
No allow or block rules for this group.
|
||||||
|
{editable && " Add one below."}
|
||||||
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<RulesTable rules={scoped} editable={editable} />
|
<RulesTable rules={scoped} editable={editable} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -3,7 +3,17 @@ import type { UseQueryResult } from "@tanstack/react-query";
|
|||||||
import * as stylex from "@stylexjs/stylex";
|
import * as stylex from "@stylexjs/stylex";
|
||||||
import InlineError from "@/lib/InlineError";
|
import InlineError from "@/lib/InlineError";
|
||||||
import { styles as shared } from "@/ui/styles";
|
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
|
* One panel's data, with the loading and error surfaces the fire-and-forget
|
||||||
@@ -20,7 +30,7 @@ export default function QueryPanel<T>({
|
|||||||
}) {
|
}) {
|
||||||
if (query.isPending) {
|
if (query.isPending) {
|
||||||
return (
|
return (
|
||||||
<p role="status" {...stylex.props(styles.pending, shared.pulse)}>
|
<p role="status" {...stylex.props(config.pending, styles.pending, shared.pulse)}>
|
||||||
Loading…
|
Loading…
|
||||||
</p>
|
</p>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ const styles = stylex.create({
|
|||||||
fontSize: "1.5rem",
|
fontSize: "1.5rem",
|
||||||
lineHeight: "2rem",
|
lineHeight: "2rem",
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
|
textWrap: "balance",
|
||||||
},
|
},
|
||||||
purgeAction: {
|
purgeAction: {
|
||||||
marginInlineStart: "auto",
|
marginInlineStart: "auto",
|
||||||
@@ -107,6 +108,9 @@ const styles = stylex.create({
|
|||||||
color: colors.primaryOnSurface,
|
color: colors.primaryOnSurface,
|
||||||
},
|
},
|
||||||
loading: {
|
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",
|
marginTop: "1rem",
|
||||||
color: colors.textMuted,
|
color: colors.textMuted,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -47,6 +47,15 @@ const RESOLVED = page([
|
|||||||
event(30, { code: "disk.space", component: "disk", subject: "data", resolved_at: NOW_S - 7200 }),
|
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. */
|
/** A stubbed response that carries a non-200 status instead of a payload. */
|
||||||
class Failure {
|
class Failure {
|
||||||
constructor(
|
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.
|
// The resolved history is a separate section, below the active list.
|
||||||
const table = within(screen.getByRole("table"));
|
const table = within(screen.getByRole("table"));
|
||||||
expect(table.getByText("Disk space low")).toBeTruthy();
|
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 () => {
|
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" }));
|
fireEvent.click(screen.getByRole("button", { name: "Load more" }));
|
||||||
|
|
||||||
await screen.findByText("TLS certificate reload failed");
|
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 () => {
|
test("an unavailable store reports the failure instead of loading forever", async () => {
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ const styles = stylex.create({
|
|||||||
fontSize: "1.5rem",
|
fontSize: "1.5rem",
|
||||||
lineHeight: "2rem",
|
lineHeight: "2rem",
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
|
textWrap: "balance",
|
||||||
},
|
},
|
||||||
intro: {
|
intro: {
|
||||||
marginTop: "0.25rem",
|
marginTop: "0.25rem",
|
||||||
@@ -94,6 +95,18 @@ const styles = stylex.create({
|
|||||||
lineHeight: "1.25rem",
|
lineHeight: "1.25rem",
|
||||||
color: colors.textMuted,
|
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: {
|
cardList: {
|
||||||
marginTop: "0.75rem",
|
marginTop: "0.75rem",
|
||||||
display: "flex",
|
display: "flex",
|
||||||
@@ -339,8 +352,8 @@ function HistoryRow({ event, onPurge, busy }: { event: DiagnosticEvent; onPurge:
|
|||||||
</Link>
|
</Link>
|
||||||
</td>
|
</td>
|
||||||
<td {...stylex.props(styles.cell)}>{event.subject}</td>
|
<td {...stylex.props(styles.cell)}>{event.subject}</td>
|
||||||
<td {...stylex.props(styles.cell, styles.nowrap)}>{formatTime(event.first_seen)}</td>
|
<td {...stylex.props(styles.cell, styles.nowrap, shared.tabularNums)}>{formatTime(event.first_seen)}</td>
|
||||||
<td {...stylex.props(styles.cell, styles.nowrap)}>
|
<td {...stylex.props(styles.cell, styles.nowrap, shared.tabularNums)}>
|
||||||
{event.resolved_at === null ? "—" : formatTime(event.resolved_at)}
|
{event.resolved_at === null ? "—" : formatTime(event.resolved_at)}
|
||||||
</td>
|
</td>
|
||||||
<td {...stylex.props(styles.cell, styles.nowrap, shared.tabularNums)}>{event.occurrences}</td>
|
<td {...stylex.props(styles.cell, styles.nowrap, shared.tabularNums)}>{event.occurrences}</td>
|
||||||
@@ -436,7 +449,7 @@ export default function DiagnosticsPage() {
|
|||||||
{active.status === "error" ? (
|
{active.status === "error" ? (
|
||||||
<InlineError error={active.error} onRetry={() => void active.refetch()} />
|
<InlineError error={active.error} onRetry={() => void active.refetch()} />
|
||||||
) : active.data === undefined ? (
|
) : active.data === undefined ? (
|
||||||
<p {...stylex.props(styles.empty, shared.pulse)} role="status">
|
<p {...stylex.props(styles.empty, styles.activeLoading, shared.pulse)} role="status">
|
||||||
Loading diagnostics…
|
Loading diagnostics…
|
||||||
</p>
|
</p>
|
||||||
) : activeRows.length === 0 ? (
|
) : activeRows.length === 0 ? (
|
||||||
@@ -474,7 +487,7 @@ export default function DiagnosticsPage() {
|
|||||||
{history.status === "error" ? (
|
{history.status === "error" ? (
|
||||||
<InlineError error={history.error} onRetry={() => void history.refetch()} />
|
<InlineError error={history.error} onRetry={() => void history.refetch()} />
|
||||||
) : history.data === undefined ? (
|
) : history.data === undefined ? (
|
||||||
<p {...stylex.props(styles.empty, shared.pulse)} role="status">
|
<p {...stylex.props(styles.empty, styles.historyLoading, shared.pulse)} role="status">
|
||||||
Loading history…
|
Loading history…
|
||||||
</p>
|
</p>
|
||||||
) : historyRows.length === 0 ? (
|
) : historyRows.length === 0 ? (
|
||||||
@@ -509,7 +522,8 @@ export default function DiagnosticsPage() {
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<p {...stylex.props(styles.footer, styles.note)}>
|
<p {...stylex.props(styles.footer, styles.note)}>
|
||||||
Showing {historyRows.length} resolved {historyRows.length === 1 ? "entry" : "entries"}
|
Showing <span {...stylex.props(shared.tabularNums)}>{historyRows.length}</span> resolved{" "}
|
||||||
|
{historyRows.length === 1 ? "entry" : "entries"}
|
||||||
{hasMore(history) ? "" : " — end of history"}
|
{hasMore(history) ? "" : " — end of history"}
|
||||||
</p>
|
</p>
|
||||||
<MoreButton section={history} />
|
<MoreButton section={history} />
|
||||||
|
|||||||
@@ -109,6 +109,14 @@ const styles = stylex.create({
|
|||||||
lineHeight: "1.25rem",
|
lineHeight: "1.25rem",
|
||||||
color: colors.textMuted,
|
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;
|
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 ? (
|
return health.isError ? (
|
||||||
<InlineError error={health.error} onRetry={() => void health.refetch()} />
|
<InlineError error={health.error} onRetry={() => void health.refetch()} />
|
||||||
) : (
|
) : (
|
||||||
<p role="status" {...stylex.props(styles.message, shared.pulse)}>
|
<p role="status" {...stylex.props(styles.message, styles.loading, shared.pulse)}>
|
||||||
Loading status…
|
Loading status…
|
||||||
</p>
|
</p>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -34,6 +34,11 @@ const THICKNESS = 36;
|
|||||||
const OUTER_RADIUS = SIZE / 2;
|
const OUTER_RADIUS = SIZE / 2;
|
||||||
const INNER_RADIUS = OUTER_RADIUS - THICKNESS;
|
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();
|
const numberFormat = new Intl.NumberFormat();
|
||||||
|
|
||||||
/** The width at which the page puts the two donuts side by side, and the page's
|
/** 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 TWO_COLUMN = "@media (min-width: 1280px)";
|
||||||
|
|
||||||
const styles = stylex.create({
|
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: {
|
empty: {
|
||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
minHeight: SIZE,
|
minHeight: SIZE + BODY_GAP + LEGEND_ROW,
|
||||||
borderRadius: "0.25rem",
|
borderRadius: "0.25rem",
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderStyle: "dashed",
|
borderStyle: "dashed",
|
||||||
@@ -99,6 +112,7 @@ const styles = stylex.create({
|
|||||||
gap: "0.5rem",
|
gap: "0.5rem",
|
||||||
},
|
},
|
||||||
swatch: {
|
swatch: {
|
||||||
|
pointerEvents: "none",
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
alignSelf: "center",
|
alignSelf: "center",
|
||||||
display: "inline-block",
|
display: "inline-block",
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import { useNavigate, useSearch } from "@tanstack/react-router";
|
|||||||
import { Radio, RadioGroup } from "react-aria-components";
|
import { Radio, RadioGroup } from "react-aria-components";
|
||||||
import type { Period } from "@/lib/types";
|
import type { Period } from "@/lib/types";
|
||||||
import { styles as shared } from "@/ui/styles";
|
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";
|
import { DEFAULT_PERIOD, PERIODS } from "./period";
|
||||||
|
|
||||||
const styles = stylex.create({
|
const styles = stylex.create({
|
||||||
@@ -33,19 +33,31 @@ const styles = stylex.create({
|
|||||||
fontSize: "1.5rem",
|
fontSize: "1.5rem",
|
||||||
lineHeight: "2rem",
|
lineHeight: "2rem",
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
|
textWrap: "balance",
|
||||||
},
|
},
|
||||||
periodGroup: {
|
periodGroup: {
|
||||||
display: "flex",
|
display: "flex",
|
||||||
gap: "0.25rem",
|
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: {
|
period: {
|
||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
minHeight: metrics.hitTarget,
|
||||||
|
minWidth: metrics.hitTarget,
|
||||||
borderStyle: "none",
|
borderStyle: "none",
|
||||||
borderRadius: "0.25rem",
|
borderRadius: "0.25rem",
|
||||||
paddingInline: "0.625rem",
|
paddingInline: "0.625rem",
|
||||||
paddingBlock: "0.25rem",
|
paddingBlock: "0.25rem",
|
||||||
fontSize: "0.875rem",
|
fontSize: "0.875rem",
|
||||||
lineHeight: "1.25rem",
|
lineHeight: "1.25rem",
|
||||||
|
fontWeight: 500,
|
||||||
},
|
},
|
||||||
/** A Radio is a `label`, so RAC drives the ring rather than `:focus-visible`. */
|
/** A Radio is a `label`, so RAC drives the ring rather than `:focus-visible`. */
|
||||||
periodFocusVisible: {
|
periodFocusVisible: {
|
||||||
@@ -61,13 +73,20 @@ const styles = stylex.create({
|
|||||||
"@media (prefers-color-scheme: dark)": "oklch(37% 0.013 285.805)",
|
"@media (prefers-color-scheme: dark)": "oklch(37% 0.013 285.805)",
|
||||||
},
|
},
|
||||||
color: colors.text,
|
color: colors.text,
|
||||||
fontWeight: 500,
|
|
||||||
},
|
},
|
||||||
periodIdle: {
|
periodIdle: {
|
||||||
backgroundColor: { default: "transparent", ":hover": colors.surfaceHover },
|
backgroundColor: { default: "transparent", ":hover": colors.surfaceHover },
|
||||||
color: colors.textSecondary,
|
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: {
|
loading: {
|
||||||
|
minHeight: "48rem",
|
||||||
fontSize: "0.875rem",
|
fontSize: "0.875rem",
|
||||||
lineHeight: "1.25rem",
|
lineHeight: "1.25rem",
|
||||||
color: colors.textMuted,
|
color: colors.textMuted,
|
||||||
|
|||||||
@@ -9,14 +9,14 @@
|
|||||||
* StyleX tokens instead.
|
* StyleX tokens instead.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useLayoutEffect, useRef, useState } from "react";
|
||||||
import * as stylex from "@stylexjs/stylex";
|
import * as stylex from "@stylexjs/stylex";
|
||||||
import { AxisBottom, AxisLeft, type TickRendererProps } from "@visx/axis";
|
import { AxisBottom, AxisLeft, type TickRendererProps } from "@visx/axis";
|
||||||
import { GridRows } from "@visx/grid";
|
import { GridRows } from "@visx/grid";
|
||||||
import { scaleBand, scaleLinear } from "@visx/scale";
|
import { scaleBand, scaleLinear } from "@visx/scale";
|
||||||
import { TooltipWithBounds } from "@visx/tooltip";
|
import { TooltipWithBounds } from "@visx/tooltip";
|
||||||
import { styles as shared } from "@/ui/styles";
|
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 CHART_HEIGHT = 240;
|
||||||
export const MARGIN = { top: 8, right: 8, bottom: 22, left: 44 } as const;
|
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
|
* The container's width, measured on mount and on every resize.
|
||||||
* `useEffect` rather than `useLayoutEffect`: the first paint draws at the
|
*
|
||||||
* fallback width and the measured width lands a frame later, which is the
|
* `useLayoutEffect` rather than `useEffect`, because the difference is visible:
|
||||||
* timing the charts have always had.
|
* a layout effect measures and re-renders before the browser paints, so the
|
||||||
|
* first painted frame is already the real width. Under `useEffect` the measured
|
||||||
|
* width lands one painted frame later, and the reader sees a chart drawn at
|
||||||
|
* `FALLBACK_WIDTH` snap to its container.
|
||||||
*/
|
*/
|
||||||
export function useMeasuredWidth(): [React.RefObject<HTMLDivElement | null>, number] {
|
export function useMeasuredWidth(): [React.RefObject<HTMLDivElement | null>, number] {
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
const [width, setWidth] = useState(0);
|
const [width, setWidth] = useState(0);
|
||||||
useEffect(() => {
|
useLayoutEffect(() => {
|
||||||
const el = ref.current;
|
const el = ref.current;
|
||||||
if (el === null) return;
|
if (el === null) return;
|
||||||
setWidth(el.clientWidth);
|
setWidth(el.clientWidth);
|
||||||
@@ -159,7 +162,7 @@ const styles = stylex.create({
|
|||||||
tooltip: {
|
tooltip: {
|
||||||
pointerEvents: "none",
|
pointerEvents: "none",
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
zIndex: 10,
|
zIndex: layers.tooltip,
|
||||||
borderRadius: "0.25rem",
|
borderRadius: "0.25rem",
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderStyle: "solid",
|
borderStyle: "solid",
|
||||||
@@ -240,7 +243,7 @@ function TickLabel({ x, dx, dy, textAnchor, dominantBaseline, formattedValue }:
|
|||||||
dy={dy}
|
dy={dy}
|
||||||
textAnchor={textAnchor}
|
textAnchor={textAnchor}
|
||||||
dominantBaseline={dominantBaseline}
|
dominantBaseline={dominantBaseline}
|
||||||
{...stylex.props(styles.axisLabel)}
|
{...stylex.props(styles.axisLabel, shared.tabularNums)}
|
||||||
>
|
>
|
||||||
{formattedValue}
|
{formattedValue}
|
||||||
</text>
|
</text>
|
||||||
|
|||||||
@@ -58,6 +58,9 @@ export interface RouterContext {
|
|||||||
|
|
||||||
const styles = stylex.create({
|
const styles = stylex.create({
|
||||||
pending: {
|
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",
|
padding: "2rem",
|
||||||
textAlign: "center",
|
textAlign: "center",
|
||||||
color: colors.textMuted,
|
color: colors.textMuted,
|
||||||
@@ -399,7 +402,28 @@ const systemRoute = createRoute({
|
|||||||
component: lazyRouteComponent(() => import("@/features/configuration/SystemPage")),
|
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([
|
const routeTree = rootRoute.addChildren([
|
||||||
|
...protoRoutes,
|
||||||
loginRoute,
|
loginRoute,
|
||||||
shellRoute.addChildren([
|
shellRoute.addChildren([
|
||||||
indexRoute,
|
indexRoute,
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { diagnosticsBadge } from "./diagnosticsBadge";
|
|||||||
import ConfigStatusNotices from "./ConfigStatusNotices";
|
import ConfigStatusNotices from "./ConfigStatusNotices";
|
||||||
import AuthorityLine from "@/features/configuration/AuthorityLine";
|
import AuthorityLine from "@/features/configuration/AuthorityLine";
|
||||||
import { styles as shared } from "@/ui/styles";
|
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. */
|
/** The one breakpoint the shell has: below it the sidebar becomes a drawer. */
|
||||||
const WIDE = "@media (min-width: 768px)";
|
const WIDE = "@media (min-width: 768px)";
|
||||||
@@ -48,6 +48,9 @@ const styles = stylex.create({
|
|||||||
borderRadius: "0.25rem",
|
borderRadius: "0.25rem",
|
||||||
paddingInline: "0.75rem",
|
paddingInline: "0.75rem",
|
||||||
paddingBlock: "0.375rem",
|
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",
|
textDecorationLine: "none",
|
||||||
},
|
},
|
||||||
navLabel: {
|
navLabel: {
|
||||||
@@ -93,11 +96,12 @@ const styles = stylex.create({
|
|||||||
navActive: {
|
navActive: {
|
||||||
backgroundColor: { default: "oklch(92% 0.004 286.32)", [DARK]: "oklch(27.4% 0.006 286.033)" },
|
backgroundColor: { default: "oklch(92% 0.004 286.32)", [DARK]: "oklch(27.4% 0.006 286.033)" },
|
||||||
color: colors.text,
|
color: colors.text,
|
||||||
fontWeight: 500,
|
|
||||||
},
|
},
|
||||||
navIdle: {
|
navIdle: {
|
||||||
backgroundColor: { default: "transparent", ":hover": colors.surfaceHover },
|
backgroundColor: { default: "transparent", ":hover": colors.surfaceHover },
|
||||||
color: { default: colors.textSecondary, ":hover": colors.text },
|
color: { default: colors.textSecondary, ":hover": colors.text },
|
||||||
|
transitionProperty: metrics.transitionProperty,
|
||||||
|
transitionDuration: { default: metrics.transitionDuration, "@media (prefers-reduced-motion: reduce)": "0s" },
|
||||||
},
|
},
|
||||||
versionFooter: {
|
versionFooter: {
|
||||||
paddingInline: "1rem",
|
paddingInline: "1rem",
|
||||||
|
|||||||
@@ -35,6 +35,10 @@
|
|||||||
html {
|
html {
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
-webkit-text-size-adjust: 100%;
|
-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:
|
font-family:
|
||||||
system-ui,
|
system-ui,
|
||||||
-apple-system,
|
-apple-system,
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import * as stylex from "@stylexjs/stylex";
|
import * as stylex from "@stylexjs/stylex";
|
||||||
import { Dialog as AriaDialog, Heading, Modal, ModalOverlay } from "react-aria-components";
|
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";
|
import { styles as shared } from "./styles";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -36,7 +36,7 @@ const styles = stylex.create({
|
|||||||
overlay: {
|
overlay: {
|
||||||
position: "fixed",
|
position: "fixed",
|
||||||
inset: 0,
|
inset: 0,
|
||||||
zIndex: 50,
|
zIndex: layers.confirm,
|
||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import * as stylex from "@stylexjs/stylex";
|
import * as stylex from "@stylexjs/stylex";
|
||||||
import { Dialog as AriaDialog, Heading, Modal, ModalOverlay } from "react-aria-components";
|
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";
|
import { styles as shared } from "./styles";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -30,7 +30,7 @@ const styles = stylex.create({
|
|||||||
overlay: {
|
overlay: {
|
||||||
position: "fixed",
|
position: "fixed",
|
||||||
inset: 0,
|
inset: 0,
|
||||||
zIndex: 50,
|
zIndex: layers.overlay,
|
||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
@@ -88,10 +88,10 @@ const styles = stylex.create({
|
|||||||
lineHeight: "1.75rem",
|
lineHeight: "1.75rem",
|
||||||
fontWeight: 600,
|
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: {
|
close: {
|
||||||
minWidth: 44,
|
minWidth: metrics.hitTarget,
|
||||||
minHeight: 44,
|
minHeight: metrics.hitTarget,
|
||||||
display: "inline-flex",
|
display: "inline-flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import * as stylex from "@stylexjs/stylex";
|
import * as stylex from "@stylexjs/stylex";
|
||||||
import { Tab, TabList, TabPanel, Tabs as AriaTabs } from "react-aria-components";
|
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 {
|
export interface TabSpec {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -44,6 +44,10 @@ const styles = stylex.create({
|
|||||||
borderBottomColor: colors.border,
|
borderBottomColor: colors.border,
|
||||||
},
|
},
|
||||||
tab: {
|
tab: {
|
||||||
|
/** The padding alone leaves the label short of the pointer-target floor. */
|
||||||
|
minHeight: metrics.hitTarget,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
marginBottom: -1,
|
marginBottom: -1,
|
||||||
borderBottomWidth: 2,
|
borderBottomWidth: 2,
|
||||||
borderBottomStyle: "solid",
|
borderBottomStyle: "solid",
|
||||||
|
|||||||
+26
-1
@@ -8,10 +8,20 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import * as stylex from "@stylexjs/stylex";
|
import * as stylex from "@stylexjs/stylex";
|
||||||
import { colors } from "./tokens.stylex";
|
import { colors, metrics } from "./tokens.stylex";
|
||||||
|
|
||||||
const FOCUS = ":focus-visible";
|
const FOCUS = ":focus-visible";
|
||||||
const DISABLED = ":disabled";
|
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. */
|
/** The half-fade loop a placeholder runs while its data is in flight. */
|
||||||
const pulseFrames = stylex.keyframes({
|
const pulseFrames = stylex.keyframes({
|
||||||
@@ -60,6 +70,8 @@ export const styles = stylex.create({
|
|||||||
},
|
},
|
||||||
|
|
||||||
button: {
|
button: {
|
||||||
|
minHeight: 40,
|
||||||
|
...press,
|
||||||
cursor: { default: "pointer", [DISABLED]: "not-allowed" },
|
cursor: { default: "pointer", [DISABLED]: "not-allowed" },
|
||||||
borderRadius: "0.25rem",
|
borderRadius: "0.25rem",
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
@@ -70,7 +82,13 @@ export const styles = stylex.create({
|
|||||||
fontSize: "0.875rem",
|
fontSize: "0.875rem",
|
||||||
lineHeight: "1.25rem",
|
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: {
|
smallButton: {
|
||||||
|
...press,
|
||||||
cursor: { default: "pointer", [DISABLED]: "not-allowed" },
|
cursor: { default: "pointer", [DISABLED]: "not-allowed" },
|
||||||
borderRadius: "0.25rem",
|
borderRadius: "0.25rem",
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
@@ -82,6 +100,7 @@ export const styles = stylex.create({
|
|||||||
lineHeight: "1.25rem",
|
lineHeight: "1.25rem",
|
||||||
},
|
},
|
||||||
largeButton: {
|
largeButton: {
|
||||||
|
...press,
|
||||||
cursor: { default: "pointer", [DISABLED]: "not-allowed" },
|
cursor: { default: "pointer", [DISABLED]: "not-allowed" },
|
||||||
borderRadius: "0.25rem",
|
borderRadius: "0.25rem",
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
@@ -93,6 +112,7 @@ export const styles = stylex.create({
|
|||||||
},
|
},
|
||||||
|
|
||||||
primaryButton: {
|
primaryButton: {
|
||||||
|
...press,
|
||||||
cursor: { default: "pointer", [DISABLED]: "not-allowed" },
|
cursor: { default: "pointer", [DISABLED]: "not-allowed" },
|
||||||
borderRadius: "0.25rem",
|
borderRadius: "0.25rem",
|
||||||
borderStyle: "none",
|
borderStyle: "none",
|
||||||
@@ -106,6 +126,7 @@ export const styles = stylex.create({
|
|||||||
opacity: { default: 1, [DISABLED]: 0.5 },
|
opacity: { default: 1, [DISABLED]: 0.5 },
|
||||||
},
|
},
|
||||||
largePrimaryButton: {
|
largePrimaryButton: {
|
||||||
|
...press,
|
||||||
cursor: { default: "pointer", [DISABLED]: "not-allowed" },
|
cursor: { default: "pointer", [DISABLED]: "not-allowed" },
|
||||||
borderRadius: "0.25rem",
|
borderRadius: "0.25rem",
|
||||||
borderStyle: "none",
|
borderStyle: "none",
|
||||||
@@ -118,6 +139,7 @@ export const styles = stylex.create({
|
|||||||
},
|
},
|
||||||
|
|
||||||
rowButton: {
|
rowButton: {
|
||||||
|
...press,
|
||||||
cursor: { default: "pointer", [DISABLED]: "not-allowed" },
|
cursor: { default: "pointer", [DISABLED]: "not-allowed" },
|
||||||
borderRadius: "0.25rem",
|
borderRadius: "0.25rem",
|
||||||
borderStyle: "none",
|
borderStyle: "none",
|
||||||
@@ -129,6 +151,7 @@ export const styles = stylex.create({
|
|||||||
color: colors.primaryOnSurface,
|
color: colors.primaryOnSurface,
|
||||||
},
|
},
|
||||||
linkButton: {
|
linkButton: {
|
||||||
|
...press,
|
||||||
cursor: { default: "pointer", [DISABLED]: "not-allowed" },
|
cursor: { default: "pointer", [DISABLED]: "not-allowed" },
|
||||||
borderStyle: "none",
|
borderStyle: "none",
|
||||||
backgroundColor: "transparent",
|
backgroundColor: "transparent",
|
||||||
@@ -139,6 +162,7 @@ export const styles = stylex.create({
|
|||||||
color: colors.primaryOnSurface,
|
color: colors.primaryOnSurface,
|
||||||
},
|
},
|
||||||
dangerLinkButton: {
|
dangerLinkButton: {
|
||||||
|
...press,
|
||||||
cursor: { default: "pointer", [DISABLED]: "not-allowed" },
|
cursor: { default: "pointer", [DISABLED]: "not-allowed" },
|
||||||
borderStyle: "none",
|
borderStyle: "none",
|
||||||
backgroundColor: "transparent",
|
backgroundColor: "transparent",
|
||||||
@@ -150,6 +174,7 @@ export const styles = stylex.create({
|
|||||||
opacity: { default: 1, [DISABLED]: 0.5 },
|
opacity: { default: 1, [DISABLED]: 0.5 },
|
||||||
},
|
},
|
||||||
retryButton: {
|
retryButton: {
|
||||||
|
...press,
|
||||||
cursor: { default: "pointer", [DISABLED]: "not-allowed" },
|
cursor: { default: "pointer", [DISABLED]: "not-allowed" },
|
||||||
marginTop: "0.75rem",
|
marginTop: "0.75rem",
|
||||||
borderRadius: "0.25rem",
|
borderRadius: "0.25rem",
|
||||||
|
|||||||
@@ -62,3 +62,35 @@ export const colors = stylex.defineVars({
|
|||||||
/** The focus ring colour. The ring itself is a floor, not a variant. */
|
/** 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)" },
|
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",
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user