Compare commits
1
Commits
v0.0.15
...
e656670dd4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e656670dd4
|
@@ -25,6 +25,7 @@ const styles = stylex.create({
|
||||
fontSize: "1.5rem",
|
||||
lineHeight: "2rem",
|
||||
fontWeight: 600,
|
||||
textWrap: "balance",
|
||||
},
|
||||
probing: {
|
||||
marginTop: "1rem",
|
||||
|
||||
@@ -24,8 +24,10 @@ const styles = stylex.create({
|
||||
backIcon: {
|
||||
display: "inline-flex",
|
||||
},
|
||||
/** About the height of the filled detail surface, so it does not jump in. */
|
||||
loading: {
|
||||
marginTop: "1rem",
|
||||
minHeight: "20rem",
|
||||
color: colors.textMuted,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -34,7 +34,7 @@ import { useCallback, useEffect, useState, type FormEvent, type KeyboardEvent }
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
import { Button, Menu, MenuItem, MenuTrigger, Popover, Radio, RadioGroup } from "react-aria-components";
|
||||
import { styles as shared } from "@/ui/styles";
|
||||
import { colors } from "@/ui/tokens.stylex";
|
||||
import { colors, metrics } from "@/ui/tokens.stylex";
|
||||
import ClientFilter, { joinClients, parseClients, useClientOptions } from "./ClientFilter";
|
||||
import { datetimeField, editDatetimeField, resolveDatetimeField, type DatetimeField } from "./datetime";
|
||||
import type { ActivitySearch } from "./search";
|
||||
@@ -57,9 +57,6 @@ const PRESETS = [
|
||||
|
||||
const CUSTOM_ITEM = "Custom…";
|
||||
|
||||
/** The pointer-target floor `ui/Checkbox` and the dialog Close button already set. */
|
||||
const HIT_TARGET = 44;
|
||||
|
||||
const styles = stylex.create({
|
||||
toolbar: {
|
||||
marginTop: "1rem",
|
||||
@@ -87,7 +84,7 @@ const styles = stylex.create({
|
||||
},
|
||||
/** Every control in the row is a pointer target before it is anything else. */
|
||||
field: {
|
||||
minHeight: HIT_TARGET,
|
||||
minHeight: metrics.hitTarget,
|
||||
},
|
||||
searchInput: {
|
||||
width: "100%",
|
||||
@@ -95,8 +92,8 @@ const styles = stylex.create({
|
||||
},
|
||||
/** A button is text-sized by default; this is the hit area around the text. */
|
||||
hitTarget: {
|
||||
minHeight: HIT_TARGET,
|
||||
minWidth: HIT_TARGET,
|
||||
minHeight: metrics.hitTarget,
|
||||
minWidth: metrics.hitTarget,
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
@@ -113,8 +110,11 @@ const styles = stylex.create({
|
||||
paddingInline: "0.625rem",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
minHeight: HIT_TARGET,
|
||||
minWidth: HIT_TARGET,
|
||||
// The weight is on the base, not on the selected state: a bolder label is a
|
||||
// wider label, and the row would shift under the pointer on every pick.
|
||||
fontWeight: 500,
|
||||
minHeight: metrics.hitTarget,
|
||||
minWidth: metrics.hitTarget,
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
@@ -133,7 +133,6 @@ const styles = stylex.create({
|
||||
"@media (prefers-color-scheme: dark)": "oklch(37% 0.013 285.805)",
|
||||
},
|
||||
color: colors.text,
|
||||
fontWeight: 500,
|
||||
},
|
||||
segmentIdle: {
|
||||
backgroundColor: { default: "transparent", ":hover": colors.surfaceHover },
|
||||
@@ -158,7 +157,7 @@ const styles = stylex.create({
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
whiteSpace: "nowrap",
|
||||
minHeight: HIT_TARGET,
|
||||
minHeight: metrics.hitTarget,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
},
|
||||
|
||||
@@ -146,6 +146,26 @@ function submitFilters() {
|
||||
fireEvent.submit(domainInput().closest("form")!);
|
||||
}
|
||||
|
||||
/**
|
||||
* The footer's count line, matched on the whole sentence.
|
||||
*
|
||||
* The number sits in a span of its own so it can carry tabular digits, so the
|
||||
* line is several text nodes and the default string matcher — which reads one
|
||||
* node at a time — cannot see it whole.
|
||||
*/
|
||||
function countMatcher(pattern: RegExp) {
|
||||
return (_: string, element: Element | null): boolean =>
|
||||
element?.tagName === "P" && pattern.test(element.textContent ?? "");
|
||||
}
|
||||
|
||||
function countLine(pattern: RegExp): HTMLElement {
|
||||
return screen.getByText(countMatcher(pattern));
|
||||
}
|
||||
|
||||
function findCountLine(pattern: RegExp): Promise<HTMLElement> {
|
||||
return screen.findByText(countMatcher(pattern));
|
||||
}
|
||||
|
||||
/** The custom range lives behind the Time menu; the two bounds only exist there. */
|
||||
function openCustomRange() {
|
||||
fireEvent.click(screen.getByRole("button", { name: /^Time: / }));
|
||||
@@ -176,7 +196,7 @@ test("renders the first page with the seven columns filled in", async () => {
|
||||
// Route cell says how: this is the pair the old Status column could not show.
|
||||
expect(within(blocked).getAllByText("Blocked")).toHaveLength(2);
|
||||
expect(within(blocked).getByText("—")).toBeTruthy();
|
||||
expect(screen.getByText(/Showing 2 queries/)).toBeTruthy();
|
||||
expect(countLine(/Showing 2 queries/)).toBeTruthy();
|
||||
});
|
||||
|
||||
test("resolves each row's client to its display name, reading the IP out with it", async () => {
|
||||
@@ -225,7 +245,7 @@ test("load more appends the next page and stops at the end of the log", async ()
|
||||
await screen.findByText("older.example");
|
||||
|
||||
expect(screen.getByText("first.example")).toBeTruthy();
|
||||
expect(screen.getByText(/Showing 3 queries — end of log/)).toBeTruthy();
|
||||
expect(countLine(/Showing 3 queries — end of log/)).toBeTruthy();
|
||||
expect(screen.queryByRole("button", { name: "Load more" })).toBeNull();
|
||||
});
|
||||
|
||||
@@ -239,7 +259,7 @@ test("applying a filter puts it in the url, refetches, and resets the accumulate
|
||||
fireEvent.change(domainInput(), { target: { value: "ads" } });
|
||||
submitFilters();
|
||||
|
||||
await screen.findByText(/Showing 1 query /);
|
||||
await findCountLine(/Showing 1 query /);
|
||||
expect(history.location.search).toContain("domain=ads");
|
||||
expect(screen.getByText("ads.example")).toBeTruthy();
|
||||
expect(screen.queryByText("first.example")).toBeNull();
|
||||
@@ -264,7 +284,7 @@ test("a load-more that resolves after a filter change is discarded", async () =>
|
||||
|
||||
fireEvent.change(domainInput(), { target: { value: "ads" } });
|
||||
submitFilters();
|
||||
await screen.findByText(/Showing 1 query /);
|
||||
await findCountLine(/Showing 1 query /);
|
||||
|
||||
releaseLoadMore();
|
||||
await act(async () => {
|
||||
@@ -272,7 +292,7 @@ test("a load-more that resolves after a filter change is discarded", async () =>
|
||||
});
|
||||
|
||||
expect(screen.queryByText("older.example")).toBeNull();
|
||||
expect(screen.getByText(/Showing 1 query /)).toBeTruthy();
|
||||
expect(countLine(/Showing 1 query /)).toBeTruthy();
|
||||
expect(screen.queryByRole("alert")).toBeNull();
|
||||
});
|
||||
|
||||
@@ -320,7 +340,7 @@ test("load more is disabled while a filter change shows placeholder data, then u
|
||||
await screen.findByText("ads.older.example");
|
||||
|
||||
expect(queryCalls()).toContain("/api/queries?domain=ads&before=7");
|
||||
expect(screen.getByText(/Showing 2 queries — end of log/)).toBeTruthy();
|
||||
expect(countLine(/Showing 2 queries — end of log/)).toBeTruthy();
|
||||
});
|
||||
|
||||
test("a background refetch after new rows arrive leaves no gap between the loaded pages", async () => {
|
||||
@@ -371,7 +391,7 @@ test("a background refetch after new rows arrive leaves no gap between the loade
|
||||
await screen.findByText("n22.example");
|
||||
const shown = screen.getAllByText(/^n\d+\.example$/).map((cell) => cell.textContent);
|
||||
expect(shown).toEqual(["n22.example", "n21.example", "n20.example", "n19.example", "n18.example", "n17.example"]);
|
||||
expect(screen.getByText(/Showing 6 queries — end of log/)).toBeTruthy();
|
||||
expect(countLine(/Showing 6 queries — end of log/)).toBeTruthy();
|
||||
});
|
||||
|
||||
test("a 401 on load more routes through handleUnauthorized instead of the inline error", async () => {
|
||||
@@ -619,6 +639,38 @@ test("Clear empties the url as well as the form", async () => {
|
||||
expect(domainInput()).toHaveProperty("value", "");
|
||||
});
|
||||
|
||||
test("the empty result offers the clear it names, and it clears the same filters the toolbar does", async () => {
|
||||
stubFetch((url) => {
|
||||
if (url === "/api/clients") return json({ clients: CLIENTS });
|
||||
if (url === "/api/queries") return json(PAGES["/api/queries"]);
|
||||
return json({ queries: [], next_before: null, coverage: COMPLETE } satisfies QueriesPage);
|
||||
});
|
||||
const { history } = renderPage("/activity?mode=history&domain=nothing&blocked=true");
|
||||
await screen.findByText("No queries match the current filters.");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Clear filters" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(history.location.search).not.toContain("domain");
|
||||
});
|
||||
expect(history.location.search).not.toContain("blocked");
|
||||
expect(domainInput()).toHaveProperty("value", "");
|
||||
// The unfiltered log is back, so the button did the toolbar's Clear and not a
|
||||
// reset of its own that only emptied the form.
|
||||
expect(await screen.findByText("first.example")).toBeTruthy();
|
||||
});
|
||||
|
||||
test("an empty log offers no clear, because there is no filter to blame for it", async () => {
|
||||
stubFetch((url) => {
|
||||
if (url === "/api/clients") return json({ clients: CLIENTS });
|
||||
return json({ queries: [], next_before: null, coverage: COMPLETE } satisfies QueriesPage);
|
||||
});
|
||||
renderPage();
|
||||
|
||||
await screen.findByText("No queries logged yet.");
|
||||
expect(screen.queryByRole("button", { name: "Clear filters" })).toBeNull();
|
||||
});
|
||||
|
||||
test("the domain field debounces into the url, and Enter flushes it at once", async () => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
try {
|
||||
@@ -739,7 +791,7 @@ test("the coverage watermark reads under the results, never over them", async ()
|
||||
await screen.findByText("kept.example");
|
||||
|
||||
const watermark = screen.getByText(/Query history is available from/);
|
||||
const count = screen.getByText(/Showing 1 query/);
|
||||
const count = countLine(/Showing 1 query/);
|
||||
// After the count in document order, which is what "footer" means here.
|
||||
expect(count.compareDocumentPosition(watermark) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
});
|
||||
|
||||
@@ -35,6 +35,7 @@ const styles = stylex.create({
|
||||
fontSize: "1.5rem",
|
||||
lineHeight: "2rem",
|
||||
fontWeight: 600,
|
||||
textWrap: "balance",
|
||||
},
|
||||
switch: {
|
||||
display: "flex",
|
||||
@@ -177,7 +178,7 @@ export default function ActivityPage() {
|
||||
*/}
|
||||
<TabPanel id="history" className={panelClass}>
|
||||
<ActivityFilters applied={search} onApply={apply} onClear={clear} />
|
||||
<HistoryActivity search={search} />
|
||||
<HistoryActivity search={search} onClear={clear} />
|
||||
</TabPanel>
|
||||
<TabPanel id="live" className={panelClass}>
|
||||
<p {...stylex.props(styles.liveNote)}>
|
||||
|
||||
@@ -30,7 +30,7 @@ import * as stylex from "@stylexjs/stylex";
|
||||
import { Button, Menu, MenuItem, MenuTrigger, Popover } from "react-aria-components";
|
||||
import { clientLabel, useClientNames } from "@/features/clients/clientNames";
|
||||
import { styles as shared } from "@/ui/styles";
|
||||
import { colors } from "@/ui/tokens.stylex";
|
||||
import { colors, metrics } from "@/ui/tokens.stylex";
|
||||
import { MAX_CLIENTS } from "./search";
|
||||
|
||||
/**
|
||||
@@ -47,9 +47,6 @@ export interface ClientOption {
|
||||
name: string | null;
|
||||
}
|
||||
|
||||
/** The pointer-target floor `ui/Checkbox` and the dialog Close button already set. */
|
||||
const HIT_TARGET = 44;
|
||||
|
||||
/**
|
||||
* How many chips are shown before the rest become a count.
|
||||
*
|
||||
@@ -72,8 +69,8 @@ const styles = stylex.create({
|
||||
color: colors.textMuted,
|
||||
},
|
||||
trigger: {
|
||||
minHeight: HIT_TARGET,
|
||||
minWidth: HIT_TARGET,
|
||||
minHeight: metrics.hitTarget,
|
||||
minWidth: metrics.hitTarget,
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
@@ -100,7 +97,7 @@ const styles = stylex.create({
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
whiteSpace: "nowrap",
|
||||
minHeight: HIT_TARGET,
|
||||
minHeight: metrics.hitTarget,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "0.5rem",
|
||||
@@ -119,10 +116,12 @@ const styles = stylex.create({
|
||||
},
|
||||
chip: {
|
||||
cursor: "pointer",
|
||||
transitionProperty: metrics.transitionProperty,
|
||||
transitionDuration: { default: metrics.transitionDuration, "@media (prefers-reduced-motion: reduce)": "0s" },
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: "0.375rem",
|
||||
minHeight: HIT_TARGET,
|
||||
minHeight: metrics.hitTarget,
|
||||
paddingInline: "0.625rem",
|
||||
borderRadius: "999px",
|
||||
borderWidth: 1,
|
||||
@@ -133,15 +132,24 @@ const styles = stylex.create({
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
},
|
||||
/** A hostname can be longer than the toolbar; the cross stays outside the cut. */
|
||||
chipLabel: {
|
||||
maxWidth: "14rem",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
},
|
||||
/** Decoration inside the button, so a click on it is a click on the button. */
|
||||
chipCross: {
|
||||
display: "inline-flex",
|
||||
pointerEvents: "none",
|
||||
color: colors.textMuted,
|
||||
},
|
||||
/** Not a button: it removes nothing, and nothing about it is pressable. */
|
||||
chipMore: {
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
minHeight: HIT_TARGET,
|
||||
minHeight: metrics.hitTarget,
|
||||
paddingInline: "0.625rem",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
@@ -352,10 +360,13 @@ export default function ClientFilter({ options, selected, onChange }: Props) {
|
||||
// The chip reads as a name and removes an address, so the name alone
|
||||
// would not say what the button does to a reader who cannot see it.
|
||||
aria-label={`Remove client ${displayFor(ip, options)}`}
|
||||
// The chip label is cut to fit the row, so the pointer can still read
|
||||
// the whole of what it names.
|
||||
title={displayFor(ip, options)}
|
||||
onClick={() => remove(ip)}
|
||||
{...stylex.props(styles.chip, shared.focusRing)}
|
||||
>
|
||||
{chipFor(ip, options)}
|
||||
<span {...stylex.props(styles.chipLabel)}>{chipFor(ip, options)}</span>
|
||||
<span aria-hidden="true" {...stylex.props(styles.chipCross)}>
|
||||
<X size={10} />
|
||||
</span>
|
||||
|
||||
@@ -24,8 +24,17 @@ import { queriesFilterOf, type ActivitySearch } from "./search";
|
||||
const styles = stylex.create({
|
||||
empty: {
|
||||
marginTop: "1.5rem",
|
||||
minHeight: "6rem",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "flex-start",
|
||||
gap: "0.75rem",
|
||||
color: colors.textMuted,
|
||||
},
|
||||
/** About a default page of rows, so the table does not jump in under the reader. */
|
||||
loading: {
|
||||
minHeight: "24rem",
|
||||
},
|
||||
tableWrap: {
|
||||
marginTop: "1rem",
|
||||
overflowX: "auto",
|
||||
@@ -75,7 +84,13 @@ function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
export default function HistoryActivity({ search }: { search: ActivitySearch }) {
|
||||
interface Props {
|
||||
search: ActivitySearch;
|
||||
/** The toolbar's own Clear, so the empty state offers the way out it names. */
|
||||
onClear: () => void;
|
||||
}
|
||||
|
||||
export default function HistoryActivity({ search, onClear }: Props) {
|
||||
const filter = queriesFilterOf(search);
|
||||
const base = useInfiniteQuery(queriesInfiniteQuery(filter));
|
||||
const clientNames = useClientNames();
|
||||
@@ -107,7 +122,7 @@ export default function HistoryActivity({ search }: { search: ActivitySearch })
|
||||
}
|
||||
if (base.data === undefined) {
|
||||
return (
|
||||
<p {...stylex.props(styles.empty, shared.pulse)} role="status">
|
||||
<p {...stylex.props(styles.empty, styles.loading, shared.pulse)} role="status">
|
||||
Loading activity…
|
||||
</p>
|
||||
);
|
||||
@@ -126,16 +141,15 @@ export default function HistoryActivity({ search }: { search: ActivitySearch })
|
||||
</p>
|
||||
)}
|
||||
{rows.length === 0 ? (
|
||||
<>
|
||||
<p {...stylex.props(styles.empty)}>
|
||||
{filterActive ? "No queries match the current filters." : "No queries logged yet."}
|
||||
</p>
|
||||
{coverage !== undefined && (
|
||||
<div {...stylex.props(styles.spacedTop)}>
|
||||
<CoverageNotice coverage={coverage} variant="note" />
|
||||
</div>
|
||||
<div {...stylex.props(styles.empty)}>
|
||||
<p>{filterActive ? "No queries match the current filters." : "No queries logged yet."}</p>
|
||||
{filterActive && (
|
||||
<button type="button" onClick={onClear} {...stylex.props(shared.button, shared.focusRing)}>
|
||||
Clear filters
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
{coverage !== undefined && <CoverageNotice coverage={coverage} variant="note" />}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div {...stylex.props(styles.tableWrap)}>
|
||||
@@ -169,7 +183,9 @@ export default function HistoryActivity({ search }: { search: ActivitySearch })
|
||||
</div>
|
||||
<div {...stylex.props(styles.footer)}>
|
||||
<p {...stylex.props(styles.note)}>
|
||||
Showing {rows.length} {rows.length === 1 ? "query" : "queries"}
|
||||
{/* The count is the one part of this line that moves as pages load. */}
|
||||
Showing <span {...stylex.props(shared.tabularNums)}>{rows.length}</span>{" "}
|
||||
{rows.length === 1 ? "query" : "queries"}
|
||||
{hasMore ? "" : " — end of log"}
|
||||
</p>
|
||||
{coverage !== undefined && <CoverageNotice coverage={coverage} variant="note" />}
|
||||
|
||||
@@ -28,6 +28,7 @@ const styles = stylex.create({
|
||||
fontSize: "1.5rem",
|
||||
lineHeight: "2rem",
|
||||
fontWeight: 600,
|
||||
textWrap: "balance",
|
||||
},
|
||||
intro: {
|
||||
marginTop: "0.5rem",
|
||||
|
||||
@@ -25,6 +25,9 @@ import { colors } from "@/ui/tokens.stylex";
|
||||
|
||||
const DARK = "@media (prefers-color-scheme: dark)";
|
||||
|
||||
/** Wide enough for an ordinary hostname, narrow enough to leave the six other columns room. */
|
||||
const DOMAIN_MAX_WIDTH = "24rem";
|
||||
|
||||
const styles = stylex.create({
|
||||
head: {
|
||||
backgroundColor: { default: "oklch(98.5% 0 none)", [DARK]: "oklch(21% 0.006 285.885)" },
|
||||
@@ -46,6 +49,9 @@ const styles = stylex.create({
|
||||
breakAll: {
|
||||
wordBreak: "break-all",
|
||||
},
|
||||
domainCell: {
|
||||
maxWidth: DOMAIN_MAX_WIDTH,
|
||||
},
|
||||
small: {
|
||||
fontSize: "0.75rem",
|
||||
lineHeight: "1rem",
|
||||
@@ -53,10 +59,32 @@ const styles = stylex.create({
|
||||
muted: {
|
||||
color: colors.textMuted,
|
||||
},
|
||||
/**
|
||||
* The padding makes the whole row height clickable, and the equal negative
|
||||
* margin gives that height back to the row. The clamp lives on the inner
|
||||
* span, not here: a padded `-webkit-box` can paint a third clipped line
|
||||
* inside its own padding.
|
||||
*/
|
||||
domainLink: {
|
||||
display: "block",
|
||||
maxWidth: DOMAIN_MAX_WIDTH,
|
||||
paddingBlock: "0.5rem",
|
||||
marginBlock: "-0.5rem",
|
||||
color: colors.primaryOnSurface,
|
||||
textDecorationLine: "none",
|
||||
},
|
||||
/**
|
||||
* A domain is unbounded; the column is not. The clamp counts only the lines
|
||||
* of the element that directly holds the text, so every cell wraps its text
|
||||
* in this span — a `td` that took `display: -webkit-box` would stop being a
|
||||
* table cell.
|
||||
*/
|
||||
domainClamp: {
|
||||
display: "-webkit-box",
|
||||
WebkitBoxOrient: "vertical",
|
||||
WebkitLineClamp: 2,
|
||||
overflow: "hidden",
|
||||
},
|
||||
/**
|
||||
* The badge shape and its weight are the signal; the tint only says which
|
||||
* kind of unhappy answer this was. A monochrome or colour-blind reading of
|
||||
@@ -136,8 +164,11 @@ export function ActivityCells({
|
||||
return (
|
||||
<>
|
||||
<td {...stylex.props(styles.cell, styles.nowrap, styles.muted)}>{formatTime(row.ts)}</td>
|
||||
<td {...stylex.props(styles.cell, styles.small, styles.breakAll, shared.mono)}>
|
||||
{renderDomain(row.id, row.domain)}
|
||||
<td
|
||||
title={row.domain}
|
||||
{...stylex.props(styles.cell, styles.small, styles.breakAll, styles.domainCell, shared.mono)}
|
||||
>
|
||||
{renderDomain(row.id, <span {...stylex.props(styles.domainClamp)}>{row.domain}</span>)}
|
||||
</td>
|
||||
<td {...stylex.props(styles.cell, styles.small, styles.nowrap)}>
|
||||
<ClientName ip={row.client_ip} names={clientNames} />
|
||||
|
||||
@@ -34,6 +34,7 @@ const styles = stylex.create({
|
||||
fontSize: "1.5rem",
|
||||
lineHeight: "2rem",
|
||||
fontWeight: 600,
|
||||
textWrap: "balance",
|
||||
},
|
||||
address: {
|
||||
marginTop: "0.25rem",
|
||||
@@ -93,7 +94,12 @@ const styles = stylex.create({
|
||||
link: {
|
||||
color: colors.primaryOnSurface,
|
||||
},
|
||||
/**
|
||||
* Room for the heading, the facts panel and the two sections under it, so
|
||||
* the page settles at roughly its filled height instead of growing into it.
|
||||
*/
|
||||
loading: {
|
||||
minHeight: "24rem",
|
||||
marginTop: "1rem",
|
||||
color: colors.textMuted,
|
||||
},
|
||||
|
||||
@@ -50,6 +50,15 @@ const styles = stylex.create({
|
||||
fontSize: "1.5rem",
|
||||
lineHeight: "2rem",
|
||||
fontWeight: 600,
|
||||
textWrap: "balance",
|
||||
},
|
||||
/** A long reverse-DNS name would otherwise widen the column past the table. */
|
||||
name: {
|
||||
display: "block",
|
||||
maxWidth: "18rem",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
},
|
||||
empty: {
|
||||
marginTop: "1rem",
|
||||
@@ -203,7 +212,9 @@ export default function ClientsPage() {
|
||||
</Link>
|
||||
</td>
|
||||
<td {...stylex.props(styles.cell)}>
|
||||
<ClientDisplayName client={client} />
|
||||
<span {...stylex.props(styles.name)}>
|
||||
<ClientDisplayName client={client} />
|
||||
</span>
|
||||
</td>
|
||||
<td {...stylex.props(styles.cell)}>{client.group}</td>
|
||||
<td {...stylex.props(styles.cell)}>{formatTime(client.first_seen)}</td>
|
||||
|
||||
@@ -203,7 +203,7 @@ function AssignmentsEditor({ prefixes, groups }: Props) {
|
||||
return (
|
||||
<>
|
||||
{state.rows.length === 0 ? (
|
||||
<p {...stylex.props(styles.empty)}>No network assignments configured.</p>
|
||||
<p {...stylex.props(styles.empty)}>No network assignments configured. Add one below.</p>
|
||||
) : (
|
||||
<ul {...stylex.props(styles.rows)}>
|
||||
{state.rows.map((row, index) => (
|
||||
|
||||
@@ -21,6 +21,13 @@ const styles = stylex.create({
|
||||
lineHeight: "1.25rem",
|
||||
color: colors.textMuted,
|
||||
},
|
||||
/**
|
||||
* Room for a few checkboxes and the button row, so the sections below the
|
||||
* panel do not jump up the page when the assignment lands.
|
||||
*/
|
||||
loading: {
|
||||
minHeight: "8rem",
|
||||
},
|
||||
root: {
|
||||
marginTop: "0.75rem",
|
||||
},
|
||||
@@ -44,7 +51,7 @@ export default function GroupSourcesEditor({ groupId, blocklists }: Props) {
|
||||
|
||||
if (sources.isPending) {
|
||||
return (
|
||||
<p role="status" {...stylex.props(styles.note)}>
|
||||
<p role="status" {...stylex.props(styles.note, styles.loading)}>
|
||||
Loading sources…
|
||||
</p>
|
||||
);
|
||||
|
||||
@@ -167,7 +167,9 @@ function GroupsMasterDetail({ groups, status }: { groups: Group[]; status: Confi
|
||||
</nav>
|
||||
</div>
|
||||
{selected === undefined ? (
|
||||
<p {...stylex.props(config.empty)}>No groups exist.</p>
|
||||
<p {...stylex.props(config.empty)}>
|
||||
{fileMode ? "No groups exist." : "No groups exist. Create one with the New group field."}
|
||||
</p>
|
||||
) : fileMode ? (
|
||||
<GroupDetailReadOnly group={selected} />
|
||||
) : (
|
||||
@@ -432,7 +434,10 @@ function GroupRules({ group, editable }: { group: Group; editable: boolean }) {
|
||||
return (
|
||||
<>
|
||||
{scoped.length === 0 ? (
|
||||
<p {...stylex.props(config.empty)}>No allow or block rules for this group.</p>
|
||||
<p {...stylex.props(config.empty)}>
|
||||
No allow or block rules for this group.
|
||||
{editable && " Add one below."}
|
||||
</p>
|
||||
) : (
|
||||
<RulesTable rules={scoped} editable={editable} />
|
||||
)}
|
||||
|
||||
@@ -3,7 +3,17 @@ import type { UseQueryResult } from "@tanstack/react-query";
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
import InlineError from "@/lib/InlineError";
|
||||
import { styles as shared } from "@/ui/styles";
|
||||
import { styles } from "./styles";
|
||||
import { styles as config } from "./styles";
|
||||
|
||||
const styles = stylex.create({
|
||||
/**
|
||||
* Room for a panel heading and the first rows of the collection, so the
|
||||
* content below a panel does not jump up the page when its data lands.
|
||||
*/
|
||||
pending: {
|
||||
minHeight: "6rem",
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* One panel's data, with the loading and error surfaces the fire-and-forget
|
||||
@@ -20,7 +30,7 @@ export default function QueryPanel<T>({
|
||||
}) {
|
||||
if (query.isPending) {
|
||||
return (
|
||||
<p role="status" {...stylex.props(styles.pending, shared.pulse)}>
|
||||
<p role="status" {...stylex.props(config.pending, styles.pending, shared.pulse)}>
|
||||
Loading…
|
||||
</p>
|
||||
);
|
||||
|
||||
@@ -36,6 +36,7 @@ const styles = stylex.create({
|
||||
fontSize: "1.5rem",
|
||||
lineHeight: "2rem",
|
||||
fontWeight: 600,
|
||||
textWrap: "balance",
|
||||
},
|
||||
purgeAction: {
|
||||
marginInlineStart: "auto",
|
||||
@@ -107,6 +108,9 @@ const styles = stylex.create({
|
||||
color: colors.primaryOnSurface,
|
||||
},
|
||||
loading: {
|
||||
// The heading row, the subject and the seven-row facts panel, which is
|
||||
// what stands above the fold once the event lands.
|
||||
minHeight: "20rem",
|
||||
marginTop: "1rem",
|
||||
color: colors.textMuted,
|
||||
},
|
||||
|
||||
@@ -47,6 +47,15 @@ const RESOLVED = page([
|
||||
event(30, { code: "disk.space", component: "disk", subject: "data", resolved_at: NOW_S - 7200 }),
|
||||
]);
|
||||
|
||||
/**
|
||||
* The footer count sets its number in tabular figures, so the sentence is split
|
||||
* across elements. Matched on the paragraph's whole text rather than on a
|
||||
* fragment of it.
|
||||
*/
|
||||
function footerLine(text: string): HTMLElement {
|
||||
return screen.getByText((_content, element) => element?.tagName === "P" && element.textContent === text);
|
||||
}
|
||||
|
||||
/** A stubbed response that carries a non-200 status instead of a payload. */
|
||||
class Failure {
|
||||
constructor(
|
||||
@@ -153,7 +162,7 @@ test("active episodes come first, each with its title, subject, age and count",
|
||||
// The resolved history is a separate section, below the active list.
|
||||
const table = within(screen.getByRole("table"));
|
||||
expect(table.getByText("Disk space low")).toBeTruthy();
|
||||
expect(screen.getByText(/Showing 1 resolved entry — end of history/)).toBeTruthy();
|
||||
expect(footerLine("Showing 1 resolved entry — end of history")).toBeTruthy();
|
||||
});
|
||||
|
||||
test("nothing open reads as good news, not as a broken page", async () => {
|
||||
@@ -223,7 +232,7 @@ test("load more appends the next page of resolved history", async () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: "Load more" }));
|
||||
|
||||
await screen.findByText("TLS certificate reload failed");
|
||||
expect(screen.getByText(/Showing 2 resolved entries — end of history/)).toBeTruthy();
|
||||
expect(footerLine("Showing 2 resolved entries — end of history")).toBeTruthy();
|
||||
});
|
||||
|
||||
test("an unavailable store reports the failure instead of loading forever", async () => {
|
||||
|
||||
@@ -46,6 +46,7 @@ const styles = stylex.create({
|
||||
fontSize: "1.5rem",
|
||||
lineHeight: "2rem",
|
||||
fontWeight: 600,
|
||||
textWrap: "balance",
|
||||
},
|
||||
intro: {
|
||||
marginTop: "0.25rem",
|
||||
@@ -94,6 +95,18 @@ const styles = stylex.create({
|
||||
lineHeight: "1.25rem",
|
||||
color: colors.textMuted,
|
||||
},
|
||||
/** One card: the badge and title row, the meta line, and the card's padding. */
|
||||
activeLoading: {
|
||||
minHeight: "4rem",
|
||||
},
|
||||
/**
|
||||
* The table header and three rows, at 2.25rem each. Deliberately short of a
|
||||
* full page of history: an install with nothing resolved collapses to one
|
||||
* muted line, and a taller reserve would leave a hole on the common case.
|
||||
*/
|
||||
historyLoading: {
|
||||
minHeight: "9rem",
|
||||
},
|
||||
cardList: {
|
||||
marginTop: "0.75rem",
|
||||
display: "flex",
|
||||
@@ -339,8 +352,8 @@ function HistoryRow({ event, onPurge, busy }: { event: DiagnosticEvent; onPurge:
|
||||
</Link>
|
||||
</td>
|
||||
<td {...stylex.props(styles.cell)}>{event.subject}</td>
|
||||
<td {...stylex.props(styles.cell, styles.nowrap)}>{formatTime(event.first_seen)}</td>
|
||||
<td {...stylex.props(styles.cell, styles.nowrap)}>
|
||||
<td {...stylex.props(styles.cell, styles.nowrap, shared.tabularNums)}>{formatTime(event.first_seen)}</td>
|
||||
<td {...stylex.props(styles.cell, styles.nowrap, shared.tabularNums)}>
|
||||
{event.resolved_at === null ? "—" : formatTime(event.resolved_at)}
|
||||
</td>
|
||||
<td {...stylex.props(styles.cell, styles.nowrap, shared.tabularNums)}>{event.occurrences}</td>
|
||||
@@ -436,7 +449,7 @@ export default function DiagnosticsPage() {
|
||||
{active.status === "error" ? (
|
||||
<InlineError error={active.error} onRetry={() => void active.refetch()} />
|
||||
) : active.data === undefined ? (
|
||||
<p {...stylex.props(styles.empty, shared.pulse)} role="status">
|
||||
<p {...stylex.props(styles.empty, styles.activeLoading, shared.pulse)} role="status">
|
||||
Loading diagnostics…
|
||||
</p>
|
||||
) : activeRows.length === 0 ? (
|
||||
@@ -474,7 +487,7 @@ export default function DiagnosticsPage() {
|
||||
{history.status === "error" ? (
|
||||
<InlineError error={history.error} onRetry={() => void history.refetch()} />
|
||||
) : history.data === undefined ? (
|
||||
<p {...stylex.props(styles.empty, shared.pulse)} role="status">
|
||||
<p {...stylex.props(styles.empty, styles.historyLoading, shared.pulse)} role="status">
|
||||
Loading history…
|
||||
</p>
|
||||
) : historyRows.length === 0 ? (
|
||||
@@ -509,7 +522,8 @@ export default function DiagnosticsPage() {
|
||||
</table>
|
||||
</div>
|
||||
<p {...stylex.props(styles.footer, styles.note)}>
|
||||
Showing {historyRows.length} resolved {historyRows.length === 1 ? "entry" : "entries"}
|
||||
Showing <span {...stylex.props(shared.tabularNums)}>{historyRows.length}</span> resolved{" "}
|
||||
{historyRows.length === 1 ? "entry" : "entries"}
|
||||
{hasMore(history) ? "" : " — end of history"}
|
||||
</p>
|
||||
<MoreButton section={history} />
|
||||
|
||||
@@ -109,6 +109,14 @@ const styles = stylex.create({
|
||||
lineHeight: "1.25rem",
|
||||
color: colors.textMuted,
|
||||
},
|
||||
/**
|
||||
* One row of facts — a fact's own line, its detail line, and the padding —
|
||||
* plus the extra top margin the list carries. A floor, not a match: below
|
||||
* 1100px the strip stacks and grows past it.
|
||||
*/
|
||||
loading: {
|
||||
minHeight: "3.5rem",
|
||||
},
|
||||
});
|
||||
|
||||
const TONES = { ok: styles.ok, notice: styles.notice, warn: styles.warn, danger: styles.danger } as const;
|
||||
@@ -182,7 +190,7 @@ export default function HealthStrip() {
|
||||
return health.isError ? (
|
||||
<InlineError error={health.error} onRetry={() => void health.refetch()} />
|
||||
) : (
|
||||
<p role="status" {...stylex.props(styles.message, shared.pulse)}>
|
||||
<p role="status" {...stylex.props(styles.message, styles.loading, shared.pulse)}>
|
||||
Loading status…
|
||||
</p>
|
||||
);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -9,14 +9,14 @@
|
||||
* StyleX tokens instead.
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useLayoutEffect, useRef, useState } from "react";
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
import { AxisBottom, AxisLeft, type TickRendererProps } from "@visx/axis";
|
||||
import { GridRows } from "@visx/grid";
|
||||
import { scaleBand, scaleLinear } from "@visx/scale";
|
||||
import { TooltipWithBounds } from "@visx/tooltip";
|
||||
import { styles as shared } from "@/ui/styles";
|
||||
import { colors } from "@/ui/tokens.stylex";
|
||||
import { colors, layers } from "@/ui/tokens.stylex";
|
||||
|
||||
export const CHART_HEIGHT = 240;
|
||||
export const MARGIN = { top: 8, right: 8, bottom: 22, left: 44 } as const;
|
||||
@@ -53,15 +53,18 @@ export function plotArea(width: number): Plot {
|
||||
}
|
||||
|
||||
/**
|
||||
* The container's width, measured on mount and on every resize. Deliberately
|
||||
* `useEffect` rather than `useLayoutEffect`: the first paint draws at the
|
||||
* fallback width and the measured width lands a frame later, which is the
|
||||
* timing the charts have always had.
|
||||
* The container's width, measured on mount and on every resize.
|
||||
*
|
||||
* `useLayoutEffect` rather than `useEffect`, because the difference is visible:
|
||||
* a layout effect measures and re-renders before the browser paints, so the
|
||||
* first painted frame is already the real width. Under `useEffect` the measured
|
||||
* width lands one painted frame later, and the reader sees a chart drawn at
|
||||
* `FALLBACK_WIDTH` snap to its container.
|
||||
*/
|
||||
export function useMeasuredWidth(): [React.RefObject<HTMLDivElement | null>, number] {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [width, setWidth] = useState(0);
|
||||
useEffect(() => {
|
||||
useLayoutEffect(() => {
|
||||
const el = ref.current;
|
||||
if (el === null) return;
|
||||
setWidth(el.clientWidth);
|
||||
@@ -159,7 +162,7 @@ const styles = stylex.create({
|
||||
tooltip: {
|
||||
pointerEvents: "none",
|
||||
position: "absolute",
|
||||
zIndex: 10,
|
||||
zIndex: layers.tooltip,
|
||||
borderRadius: "0.25rem",
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
@@ -240,7 +243,7 @@ function TickLabel({ x, dx, dy, textAnchor, dominantBaseline, formattedValue }:
|
||||
dy={dy}
|
||||
textAnchor={textAnchor}
|
||||
dominantBaseline={dominantBaseline}
|
||||
{...stylex.props(styles.axisLabel)}
|
||||
{...stylex.props(styles.axisLabel, shared.tabularNums)}
|
||||
>
|
||||
{formattedValue}
|
||||
</text>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
import type { ReactNode } from "react";
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
import { Tab, TabList, TabPanel, Tabs as AriaTabs } from "react-aria-components";
|
||||
import { colors } from "./tokens.stylex";
|
||||
import { colors, metrics } from "./tokens.stylex";
|
||||
|
||||
export interface TabSpec {
|
||||
id: string;
|
||||
@@ -44,6 +44,10 @@ const styles = stylex.create({
|
||||
borderBottomColor: colors.border,
|
||||
},
|
||||
tab: {
|
||||
/** The padding alone leaves the label short of the pointer-target floor. */
|
||||
minHeight: metrics.hitTarget,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
marginBottom: -1,
|
||||
borderBottomWidth: 2,
|
||||
borderBottomStyle: "solid",
|
||||
|
||||
+26
-1
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user