admin: phosphor icons replace hand-drawn svgs and text-character glyphs
Release / guard (push) Successful in 39s
Gates / frontend (push) Failing after 2m25s
Gates / package (push) Skipped
Gates / container (push) Skipped
Gates / frontend (push) Failing after 1m59s
Gates / package (push) Skipped
Gates / container (push) Skipped
Gates / test-aarch64 (push) Successful in 8m18s
CI / gates (push) Failing after 11m34s
Gates / test (push) Successful in 2m18s
Gates / test-aarch64 (push) Successful in 7m23s
Release / gates (push) Failing after 18m51s
Release / publish (push) Skipped
Gates / test (push) Successful in 2m36s

health strip marks, menu ticks, chip crosses, the search magnifier, the checkbox check and the back arrows all come from @phosphor-icons/react now, pinned exactly and entered in the license ledger. changelog and version bump for 0.0.15, including the bundle budget raise to 900000 bytes.
This commit is contained in:
2026-08-31 18:04:25 +02:00
parent b774b05456
commit 2525b01893
19 changed files with 123 additions and 53 deletions
+15
View File
@@ -4,6 +4,21 @@ All notable changes to nxdns are recorded here. The format follows [Keep a Chang
Sections are written by hand. Nothing here is generated from commit messages: the point of the file is to say what changed for an operator, which a commit subject rarely does.
## [0.0.15] - 2026-08-31
The Activity page's history filters become a toolbar you can actually use. Filters apply as you set them, the client field stops asking you to remember IP addresses, and the whole admin picks up one coherent icon set.
### Changed
- **History filters apply live from a toolbar.** The form-and-Apply-button row is gone. Domain text filters as you type (debounced), result and time are one-click controls, and a custom date range applies as one change. Every filter state is a bookmarkable URL.
- **The client filter is a picker, not a text field.** It lists the clients the server knows — named, sorted, multi-select — instead of asking for an exact address. Selected clients show as removable chips (first three, then a count), and a query can filter on up to 32 clients at once; the API accepts the same list.
- **One icon set.** Interface glyphs (dropdown carets, checkbox ticks, the search magnifier, status marks, back arrows) are now Phosphor icons instead of a mix of text characters and hand-drawn shapes.
- **The admin bundle budget rises from 800,000 to 900,000 bytes.** The client picker, the accessible menu and dialog primitives behind it, and the Phosphor icon components are the arrivals that spend it; the built assets sit at about 840,000 bytes.
### Added
- **Accessibility pass over the admin.** Focus-visible rings on every control, labels wired to their inputs, keyboard-reachable menus and dialogs, and visible text where information previously lived only in hover titles.
## [0.0.14] - 2026-08-28
Schema changes stop costing you your query history. querylog.db is now version-stamped and migrated in place; the server refuses to start rather than ever reset a healthy file, and the release tooling refuses to ship a schema change that is neither migratable nor explicitly disclosed with recovery steps. Three releases (0.0.6, 0.0.9, 0.0.12) each discarded the log on upgrade; this ends that.
+1 -1
View File
@@ -8,7 +8,7 @@
"name": "nxdns-admin",
"version": "0.0.0",
"dependencies": {
"@phosphor-icons/react": "^2.1.10",
"@phosphor-icons/react": "2.1.10",
"@stylexjs/stylex": "0.19.0",
"@tanstack/react-query": "5.101.4",
"@tanstack/react-router": "1.170.18",
+1 -1
View File
@@ -25,7 +25,7 @@
"trailingComma": "all"
},
"dependencies": {
"@phosphor-icons/react": "^2.1.10",
"@phosphor-icons/react": "2.1.10",
"@stylexjs/stylex": "0.19.0",
"@tanstack/react-query": "5.101.4",
"@tanstack/react-router": "1.170.18",
@@ -252,7 +252,7 @@ test("the back link restores the investigation the reader came from", async () =
renderDetail(19, "?mode=history&domain=shop&since=1600000000&blocked=true");
await screen.findByRole("heading", { name: "shop.example" });
expect(hrefSearch(screen.getByRole("link", { name: "Activity" }))).toEqual({
expect(hrefSearch(within(screen.getByRole("main")).getByRole("link", { name: "Activity" }))).toEqual({
mode: "history",
domain: "shop",
since: "1600000000",
@@ -307,7 +307,7 @@ test("a row retention has pruned explains the 404 and keeps the way back to the
await screen.findByRole("alert");
expect(screen.getByText(/no such query/)).toBeTruthy();
expect(hrefSearch(screen.getByRole("link", { name: "Activity" }))).toEqual({
expect(hrefSearch(within(screen.getByRole("main")).getByRole("link", { name: "Activity" }))).toEqual({
mode: "history",
domain: "gone",
});
@@ -1,3 +1,4 @@
import { ArrowLeft } from "@phosphor-icons/react";
import { useQuery } from "@tanstack/react-query";
import { Link, useParams, useSearch } from "@tanstack/react-router";
import * as stylex from "@stylexjs/stylex";
@@ -12,11 +13,17 @@ import type { ActivitySearch } from "./search";
const styles = stylex.create({
back: {
display: "inline-flex",
alignItems: "center",
gap: "0.25rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.primaryOnSurface,
textDecorationLine: "none",
},
backIcon: {
display: "inline-flex",
},
loading: {
marginTop: "1rem",
color: colors.textMuted,
@@ -32,7 +39,10 @@ const styles = stylex.create({
function BackLink({ origin }: { origin: ActivitySearch }) {
return (
<Link to="/activity" search={origin} {...stylex.props(styles.back, shared.focusRing)}>
Activity
<span aria-hidden="true" {...stylex.props(styles.backIcon)}>
<ArrowLeft size={12} />
</span>
Activity
</Link>
);
}
@@ -157,7 +157,7 @@ function clientChips(): string[] {
return within(screen.getByRole("group", { name: "Clients" }))
.getAllByRole("button")
.slice(1)
.map((chip) => chip.textContent?.replace("\u00d7", "") ?? "");
.map((chip) => chip.textContent ?? "");
}
/**
@@ -368,7 +368,7 @@ test("the domain field is search-shaped, unspellchecked, and labelled without a
expect(domain.getAttribute("autocomplete")).toBe("off");
expect(domain.getAttribute("placeholder")).toBe("Filter domains…");
// The magnifier is decoration over the field, never a second thing to read.
expect(document.querySelector("svg[aria-hidden='true']")).toBeTruthy();
expect(domain.parentElement?.querySelector("[aria-hidden='true'] svg")).toBeTruthy();
});
test("a bound change does not reset the domain draft that is still being typed", () => {
@@ -553,7 +553,7 @@ test("the menu ticks the clients the url names, not the ones last picked here",
const ticked = screen
.getAllByRole("menuitemcheckbox")
.filter((item) => item.getAttribute("aria-checked") === "true");
expect(ticked.map((item) => item.textContent?.replace("✓", ""))).toEqual(["laptop.lan — 192.0.2.11"]);
expect(ticked.map((item) => item.textContent)).toEqual(["laptop.lan — 192.0.2.11"]);
});
test("a rename moves the label and leaves the filter on the address", async () => {
@@ -29,6 +29,7 @@
* would promise filtering that is not happening.
*/
import { MagnifyingGlass } from "@phosphor-icons/react";
import { useCallback, useEffect, useState, type FormEvent, type KeyboardEvent } from "react";
import * as stylex from "@stylexjs/stylex";
import { Button, Menu, MenuItem, MenuTrigger, Popover, Radio, RadioGroup } from "react-aria-components";
@@ -76,6 +77,7 @@ const styles = stylex.create({
display: "flex",
},
searchIcon: {
display: "inline-flex",
position: "absolute",
insetInlineStart: "0.5rem",
top: "50%",
@@ -502,19 +504,9 @@ export default function ActivityFilters({ applied, onApply, onClear }: Props) {
<form onSubmit={flush}>
<div {...stylex.props(styles.toolbar)}>
<div {...stylex.props(styles.searchWrap)}>
<svg
aria-hidden="true"
viewBox="0 0 16 16"
width="14"
height="14"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
{...stylex.props(styles.searchIcon)}
>
<circle cx="7" cy="7" r="4.5" />
<path d="M10.5 10.5 14 14" strokeLinecap="round" />
</svg>
<span aria-hidden="true" {...stylex.props(styles.searchIcon)}>
<MagnifyingGlass size={14} />
</span>
<input
type="search"
aria-label="Filter domains"
+5 -3
View File
@@ -22,7 +22,7 @@
* visible and removable either way.
*/
import { CaretDown } from "@phosphor-icons/react";
import { CaretDown, Check, X } from "@phosphor-icons/react";
import { useEffect, useMemo, useRef } from "react";
import * as stylex from "@stylexjs/stylex";
import { Button, Menu, MenuItem, MenuTrigger, Popover } from "react-aria-components";
@@ -113,6 +113,7 @@ const styles = stylex.create({
tick: {
width: "0.75rem",
flexShrink: 0,
display: "inline-flex",
},
chip: {
cursor: "pointer",
@@ -131,6 +132,7 @@ const styles = stylex.create({
lineHeight: "1.25rem",
},
chipCross: {
display: "inline-flex",
color: colors.textMuted,
},
/** Not a button: it removes nothing, and nothing about it is pressable. */
@@ -329,7 +331,7 @@ export default function ClientFilter({ options, selected, onChange }: Props) {
}
>
<span aria-hidden="true" {...stylex.props(styles.tick)}>
{chosen.has(option.ip) ? "✓" : ""}
{chosen.has(option.ip) && <Check size={12} />}
</span>
{option.label}
</MenuItem>
@@ -353,7 +355,7 @@ export default function ClientFilter({ options, selected, onChange }: Props) {
>
{chipFor(ip, options)}
<span aria-hidden="true" {...stylex.props(styles.chipCross)}>
×
<X size={10} />
</span>
</button>
))}
@@ -48,7 +48,7 @@ test("an id the list does not contain renders the missing-client state (D9)", as
await screen.findByRole("heading", { name: "No such client" });
expect(screen.getByText(/no client with id 99/i)).toBeTruthy();
expect(screen.getByRole("link", { name: "All clients" })).toBeTruthy();
expect(screen.getByRole("link", { name: "All clients" })).toBeTruthy();
});
test("policy links to the group that filters this client", async () => {
@@ -1,4 +1,5 @@
import { useState } from "react";
import { ArrowLeft } from "@phosphor-icons/react";
import { useQuery } from "@tanstack/react-query";
import { Link, useParams } from "@tanstack/react-router";
import * as stylex from "@stylexjs/stylex";
@@ -17,11 +18,17 @@ const nowInSeconds = () => Math.floor(Date.now() / 1000);
const styles = stylex.create({
back: {
display: "inline-flex",
alignItems: "center",
gap: "0.25rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.primaryOnSurface,
textDecorationLine: "none",
},
backIcon: {
display: "inline-flex",
},
heading: {
marginTop: "0.5rem",
fontSize: "1.5rem",
@@ -95,7 +102,10 @@ const styles = stylex.create({
function BackLink() {
return (
<Link to="/clients" search={{}} {...stylex.props(styles.back, shared.focusRing)}>
All clients
<span aria-hidden="true" {...stylex.props(styles.backIcon)}>
<ArrowLeft size={12} />
</span>
All clients
</Link>
);
}
@@ -195,7 +195,7 @@ test("every code renders its own title, impact and remediation", async () => {
test("an event retention has removed shows the server's message, not an empty page", async () => {
renderDetail(999);
await screen.findByText("no such event");
expect(screen.getByRole("link", { name: "All diagnostics" })).toBeTruthy();
expect(screen.getByRole("link", { name: "All diagnostics" })).toBeTruthy();
});
test("an unavailable store reports the failure instead of loading forever", async () => {
@@ -219,5 +219,5 @@ test("an unavailable store reports the failure instead of loading forever", asyn
const alert = await screen.findByRole("alert");
expect(alert.textContent).toContain("The server is starting or degraded.");
expect(screen.queryByText("Loading event…")).toBeNull();
expect(screen.getByRole("link", { name: "All diagnostics" })).toBeTruthy();
expect(screen.getByRole("link", { name: "All diagnostics" })).toBeTruthy();
});
@@ -1,4 +1,5 @@
import { useState } from "react";
import { ArrowLeft } from "@phosphor-icons/react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Link, useNavigate, useParams } from "@tanstack/react-router";
import * as stylex from "@stylexjs/stylex";
@@ -13,11 +14,17 @@ import { componentLabel, copyFor } from "./eventCopy";
const styles = stylex.create({
back: {
display: "inline-flex",
alignItems: "center",
gap: "0.25rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.primaryOnSurface,
textDecorationLine: "none",
},
backIcon: {
display: "inline-flex",
},
headingRow: {
marginTop: "0.5rem",
display: "flex",
@@ -105,6 +112,17 @@ const styles = stylex.create({
},
});
function BackLink() {
return (
<Link to="/diagnostics" {...stylex.props(styles.back, shared.focusRing)}>
<span aria-hidden="true" {...stylex.props(styles.backIcon)}>
<ArrowLeft size={12} />
</span>
All diagnostics
</Link>
);
}
export default function DiagnosticDetailPage() {
const { id } = useParams({ from: "/shell/diagnostics/$id" });
const eventId = Number(id);
@@ -132,9 +150,7 @@ export default function DiagnosticDetailPage() {
if (data === undefined) {
return (
<section>
<Link to="/diagnostics" {...stylex.props(styles.back, shared.focusRing)}>
All diagnostics
</Link>
<BackLink />
<InlineError error={error} onRetry={() => void refetch()} />
</section>
);
@@ -146,9 +162,7 @@ export default function DiagnosticDetailPage() {
return (
<section>
<Link to="/diagnostics" {...stylex.props(styles.back, shared.focusRing)}>
All diagnostics
</Link>
<BackLink />
<div {...stylex.props(styles.headingRow)}>
<h1 {...stylex.props(styles.heading)}>{copy.title}</h1>
<SeverityBadge severity={data.severity} />
+12 -2
View File
@@ -15,6 +15,8 @@
* a claim about the current state, until a poll succeeds again.
*/
import type { ReactNode } from "react";
import { Circle, Pause, Warning, X } from "@phosphor-icons/react";
import { useQuery } from "@tanstack/react-query";
import { Link } from "@tanstack/react-router";
import * as stylex from "@stylexjs/stylex";
@@ -95,6 +97,9 @@ const styles = stylex.create({
danger: {
color: colors.dangerText,
},
icon: {
display: "inline-flex",
},
message: {
marginTop: "0.5rem",
fontSize: "0.875rem",
@@ -106,7 +111,12 @@ const styles = stylex.create({
const TONES = { ok: styles.ok, notice: styles.notice, warn: styles.warn, danger: styles.danger } as const;
/** Text and icon carry the state; the colour only agrees with them. */
const ICONS: Record<FactTone, string> = { ok: "●", notice: "‖", warn: "!", danger: "✕" };
const ICONS: Record<FactTone, ReactNode> = {
ok: <Circle size={12} weight="fill" />,
notice: <Pause size={12} />,
warn: <Warning size={12} />,
danger: <X size={12} />,
};
function FactLinkAnchor({ link }: { link: FactLink }) {
if (link.kind === "filter") {
@@ -151,7 +161,7 @@ function FactLinkAnchor({ link }: { link: FactLink }) {
function Fact({ fact }: { fact: HealthFact }) {
return (
<li {...stylex.props(styles.fact, fact.tone === "ok" ? styles.quiet : styles.highlighted)}>
<span aria-hidden="true" {...stylex.props(TONES[fact.tone])}>
<span aria-hidden="true" {...stylex.props(styles.icon, TONES[fact.tone])}>
{ICONS[fact.tone]}
</span>
<span {...stylex.props(styles.label)}>{fact.label}</span>
+3 -16
View File
@@ -11,6 +11,7 @@
*/
import type { ReactNode } from "react";
import { Check } from "@phosphor-icons/react";
import * as stylex from "@stylexjs/stylex";
import { Checkbox as AriaCheckbox } from "react-aria-components";
import { colors } from "./tokens.stylex";
@@ -100,21 +101,6 @@ const styles = stylex.create({
},
});
/** `currentColor` so the mark follows the selected box's foreground token. */
function CheckMark() {
return (
<svg aria-hidden="true" viewBox="0 0 12 12" width="10" height="10" fill="none">
<path
d="M2.5 6.5 5 9l4.5-5.5"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
export default function Checkbox({ children, ...state }: Props) {
return (
<AriaCheckbox {...state} className={() => stylex.props(styles.label).className ?? ""}>
@@ -128,7 +114,8 @@ export default function Checkbox({ children, ...state }: Props) {
renderProps.isDisabled && styles.boxDisabled,
)}
>
{renderProps.isSelected && <CheckMark />}
{/* Bold: the regular stroke thins to near-invisible at this size. */}
{renderProps.isSelected && <Check aria-hidden="true" size={10} weight="bold" />}
</span>
{children}
</>
+1 -1
View File
@@ -1,6 +1,6 @@
.{
.name = .nxdns,
.version = "0.0.14",
.version = "0.0.15",
.minimum_zig_version = "0.16.0",
.paths = .{""},
.fingerprint = 0x3307b311dded1d91,
+2
View File
@@ -42,6 +42,7 @@ sqlite url=https://sqlite.org/2026/sqlite-amalgamation-3530400.zip hash=N-V-__8A
@internationalized/date 3.12.3 Apache-2.0
@internationalized/number 3.6.7 Apache-2.0
@internationalized/string 3.2.10 Apache-2.0
@phosphor-icons/react 2.1.10 MIT
@react-types/shared 3.36.1 Apache-2.0
@stylexjs/stylex 0.19.0 MIT
@swc/helpers 0.5.23 Apache-2.0
@@ -129,6 +130,7 @@ alpine:3.22@sha256:14358309a308569c32bdc37e2e0e9694be33a9d99e68afb0f5ff33cc1f695
[npm packages bundled into admin/dist]
@internationalized/string
@phosphor-icons/react
@stylexjs/stylex
@tanstack/history
@tanstack/query-core
+6
View File
@@ -150,6 +150,12 @@
.note = "The dialog, alert dialog, tab and select behaviour of the admin UI, bundled into the JavaScript embedded in the binary. The first Apache-2.0 npm dependency this project has taken, and the first non-MIT one: Mokhtar Mial accepted Apache-2.0 inbound for nxdns on 2026-08-12, which is the decision that let these four ship. Apache-2.0 Section 4 attribution is satisfied by carrying the licence text in THIRD-PARTY-NOTICES, which the file below does; none of the four ships a NOTICE file, so 4(d) adds nothing. All four carry a byte-identical LICENSE. In the tarballs and in the image.",
.file = "react-aria-apache-2.0.txt",
},
.{
.component = "Phosphor Icons",
.version = "@phosphor-icons/react 2.1.10",
.note = "The icon set of the admin UI — carets, ticks, crosses, the search magnifier, status marks and back arrows — bundled into the JavaScript embedded in the binary. Tree-shaken: only the imported icon components ship. The npm tarball carries the LICENSE this text is copied from.",
.file = "phosphor-mit.txt",
},
.{
.component = "clsx",
.version = "clsx 2.1.1",
+1
View File
@@ -52,6 +52,7 @@ pub const texts: []const Text = &.{
.{ .name = "tanstack-mit.txt", .body = @embedFile("tanstack-mit.txt") },
.{ .name = "tanstack-store-mit.txt", .body = @embedFile("tanstack-store-mit.txt") },
.{ .name = "react-aria-apache-2.0.txt", .body = @embedFile("react-aria-apache-2.0.txt") },
.{ .name = "phosphor-mit.txt", .body = @embedFile("phosphor-mit.txt") },
.{ .name = "clsx-mit.txt", .body = @embedFile("clsx-mit.txt") },
.{ .name = "stylex-mit.txt", .body = @embedFile("stylex-mit.txt") },
.{ .name = "styleq-mit.txt", .body = @embedFile("styleq-mit.txt") },
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2020 Phosphor Icons
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.