36 Commits
Author SHA1 Message Date
mokhtar 13458a980e ci: run the frontend gate on the light runner
Gates / test (push) Successful in 1m37s
Gates / frontend (push) Failing after 3m23s
Gates / package (push) Skipped
Gates / container (push) Skipped
Gates / test-aarch64 (push) Successful in 6m2s
CI / gates (push) Failing after 7m41s
2026-08-12 23:46:07 +02:00
mokhtar e576704fad spec milestone 23: record the readme and conversion-order findings as closed by decision 2026-08-12 23:45:34 +02:00
mokhtar 4aaf6d3815 milestone 23: close the remaining codex findings
the vitest "something prevents Vite server from exiting" warning was
@stylexjs/unplugin, not our code: configureServer starts a 150ms polling
interval for HMR and clears it from server.httpServer's close event, which
vitest resolves to null in middleware mode. drop that one hook under VITEST
and keep the transform.

textMuted was swept rather than judged. measured against their grounds, the
39 flat text-zinc-500 sites went 4.12:1 -> 7.56:1 in dark mode, repairing a
WCAG AA failure, so that stands; the 4 already-adaptive sites went 7.40:1 ->
4.62:1 in light mode for nothing, so a textSecondary token restores their
original zinc-600/zinc-400 pair.

append the symbol families to the reset's font stack: Select renders U+25BE
and Segoe UI does not carry it.

correct two acceptance criteria that were literally false: both greps match
only comments.
2026-08-12 23:43:22 +02:00
mokhtar 0ea2e2905a milestone 23: restore the focus ring on select options, check layer order, close preflight gaps 2026-08-12 23:27:28 +02:00
mokhtar 9d5120cbad milestone 23: put the reset in a cascade layer, guard that it stays there 2026-08-12 23:06:26 +02:00
mokhtar 65f76d4427 milestone 23 s3: plan names stylex and react aria 2026-08-12 22:50:34 +02:00
mokhtar be12587b87 milestone 23 s3: remove tailwind, replace its preflight with an explicit reset 2026-08-12 22:50:25 +02:00
mokhtar 122044e6af milestone 23 s3: delete ui/classes.ts, its last importer is gone 2026-08-12 22:46:29 +02:00
mokhtar 0e9433d53b milestone 23 s3: convert features/queries/QueryLogPage.tsx to stylex 2026-08-12 22:45:53 +02:00
mokhtar ae9f7fb9f3 milestone 23 s3: convert features/lookup/LookupPage.tsx to stylex 2026-08-12 22:44:23 +02:00
mokhtar 1cd8f71a9b milestone 23 s3: convert features/dashboard/TimeseriesChart.tsx to stylex 2026-08-12 22:42:11 +02:00
mokhtar 15c895e180 milestone 23 s3: convert features/settings/SettingsPage.tsx to stylex 2026-08-12 22:40:50 +02:00
mokhtar 3b33d7afee milestone 23 s3: convert features/dashboard/UpstreamHealthTable.tsx to stylex 2026-08-12 22:38:15 +02:00
mokhtar f2b4582c0a milestone 23 s3: convert shell/AppShell.tsx to stylex 2026-08-12 22:37:16 +02:00
mokhtar 02bf1ca310 milestone 23 s3: convert features/live/LiveLogPage.tsx to stylex 2026-08-12 22:36:09 +02:00
mokhtar 4379d7599b milestone 23 s3: convert features/groups/GroupsPage.tsx to stylex 2026-08-12 22:33:58 +02:00
mokhtar 1246791fe1 milestone 23 s3: convert features/dashboard/DiskCard.tsx to stylex 2026-08-12 22:32:56 +02:00
mokhtar 2f3115117f milestone 23 s3: convert features/blocklists/SourceStatusSection.tsx to stylex 2026-08-12 22:31:14 +02:00
mokhtar e98896fc0a milestone 23 s3: convert features/dashboard/DashboardPage.tsx to stylex 2026-08-12 22:30:25 +02:00
mokhtar 1de608f9bc milestone 23 s3: convert auth/LoginPage.tsx to stylex 2026-08-12 22:28:56 +02:00
mokhtar 38d34453bb milestone 23 s3: convert features/groups/GroupSourcesEditor.tsx to stylex 2026-08-12 22:28:01 +02:00
mokhtar 368149a358 milestone 23 s3: convert features/blocklists/BlocklistForm.tsx to stylex 2026-08-12 22:27:00 +02:00
mokhtar ac0699faa1 milestone 23: amend two rulings the s2 review found the code in breach of 2026-08-12 22:26:42 +02:00
mokhtar d5b351d7fe milestone 23 s3: convert routes.tsx to stylex 2026-08-12 22:25:56 +02:00
mokhtar cddefa87e4 milestone 23 s3: convert features/pause/PauseWidget.tsx to stylex 2026-08-12 22:25:08 +02:00
mokhtar 9e12683acf milestone 23 s3: convert features/dashboard/StatCards.tsx to stylex 2026-08-12 22:23:16 +02:00
mokhtar 0fdbe25f7a milestone 23 s3: convert features/settings/RestartBanner.tsx to stylex 2026-08-12 22:21:53 +02:00
mokhtar 7e7358b3c2 milestone 23 s3: convert features/settings/ReadOnlyConfigBanner.tsx to stylex 2026-08-12 22:21:13 +02:00
mokhtar d5c4a8d978 milestone 23 s3: convert lib/InlineError.tsx to stylex 2026-08-12 22:19:59 +02:00
mokhtar 73bc180c67 milestone 23 s3: convert features/dashboard/HealthBanners.tsx to stylex 2026-08-12 22:18:23 +02:00
mokhtar 0f58966b31 milestone 23 s3: move srOnly into the shared style vocabulary 2026-08-12 22:16:58 +02:00
mokhtar bb39539fdf milestone 23: correct three comments the s1 review found inaccurate 2026-08-12 22:13:49 +02:00
mokhtar 01e455c8af milestone 23 s2: react aria primitives and their cluster 2026-08-12 22:12:18 +02:00
mokhtar 994bbf922c milestone 23 s1: stylex build integration, tokens and shared styles 2026-08-12 21:41:32 +02:00
mokhtar 7b0527d271 milestone 22: typescript 7 2026-08-12 21:30:40 +02:00
mokhtar c5e6ab9180 spec milestones 22 and 23: typescript 7, stylex and react aria 2026-08-12 21:30:35 +02:00
69 changed files with 6422 additions and 1371 deletions
+5 -1
View File
@@ -104,7 +104,11 @@ jobs:
run: zig build test-aarch64 -fqemu run: zig build test-aarch64 -fqemu
frontend: frontend:
runs-on: ubuntu-24.04 # The light runner, a second act_runner at capacity 1 that advertises only
# this label. This job peaks around 355 MB (tsc), well inside that runner's
# 1536Mi dind limit, and it runs no docker command — so it overlaps the
# heavy runner's zig and image work instead of queueing behind it.
runs-on: ubuntu-24.04-light
steps: steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
+2 -2
View File
@@ -135,7 +135,7 @@ IPv4 + IPv6 full parity for: client identity, rate limiting, logging, group assi
### 3.14 Frontend Stack (Decision I) ### 3.14 Frontend Stack (Decision I)
- Vite + React + TypeScript + Tailwind; TanStack Router + TanStack Query. SPA, no SSR. - Vite + React + TypeScript + StyleX + React Aria; TanStack Router + TanStack Query. SPA, no SSR.
- **Built assets embedded in the binary** at compile time: single self-contained artifact, no asset-path config, no binary/UI skew. `zig build` accepts the dist path; the release pipeline runs `npm run build` first (CI always does). - **Built assets embedded in the binary** at compile time: single self-contained artifact, no asset-path config, no binary/UI skew. `zig build` accepts the dist path; the release pipeline runs `npm run build` first (CI always does).
- Dev-mode flag serves assets from disk so UI iteration needs no Zig rebuild. - Dev-mode flag serves assets from disk so UI iteration needs no Zig rebuild.
@@ -232,7 +232,7 @@ src/
rules.zig local.zig lookup.zig pause.zig settings.zig rules.zig local.zig lookup.zig pause.zig settings.zig
upstream_health.zig certs.zig health.zig version.zig upstream_health.zig certs.zig health.zig version.zig
web/ # Vite + React + TS + Tailwind + TanStack web/ # Vite + React + TS + StyleX + React Aria + TanStack
vendor/ # sqlite3 amalgamation, mbedtls (pinned) vendor/ # sqlite3 amalgamation, mbedtls (pinned)
docs/ # tutorial/ how-to/ reference/ explanation/ (Diátaxis) docs/ # tutorial/ how-to/ reference/ explanation/ (Diátaxis)
tests/ # dns/ integration/ fuzz/ tests/ # dns/ integration/ fuzz/
+14 -4
View File
@@ -1,9 +1,19 @@
# nxdns # nxdns
A self-hosted DNS sinkhole for a household LAN, written in Zig 0.16. One nxdns is a DNS sinkhole for your LAN. It blocks the names you do not want, and
static musl binary, SQLite for state, a Raspberry Pi 5 as the reference forwards the rest over an encrypted connection.
target. It answers your network's DNS, blocks what you tell it to, and shows
you what asked for what. ```mermaid
flowchart LR
devices["Your devices"] -- "DNS query" --> nxdns["nxdns"]
nxdns -- "answer" --> devices
nxdns -- "allowed" --> upstream["Upstream resolvers<br/>DoH / DoT"]
upstream -- "answer" --> nxdns
nxdns -- "blocked" --> sink["0.0.0.0 / NXDOMAIN"]
```
One static Zig binary. SQLite holds the state and the query log, which the web
UI, the REST API and `/metrics` read.
## Features ## Features
+9
View File
@@ -0,0 +1,9 @@
MIT License
Copyright (c) Luke Edwards <luke.edwards05@gmail.com> (lukeed.com)
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.
+24 -1
View File
@@ -39,6 +39,12 @@ mbedtls url=https://github.com/Mbed-TLS/mbedtls/archive/refs/tags/mbedtls-3.6.7.
sqlite url=https://sqlite.org/2026/sqlite-amalgamation-3530400.zip hash=N-V-__8AAGVtrgCcOcmjrOJnagmnRyMrcKaOo09KbU-vu8w8 sqlite url=https://sqlite.org/2026/sqlite-amalgamation-3530400.zip hash=N-V-__8AAGVtrgCcOcmjrOJnagmnRyMrcKaOo09KbU-vu8w8
[npm runtime closure] [npm runtime closure]
@internationalized/date 3.12.3 Apache-2.0
@internationalized/number 3.6.7 Apache-2.0
@internationalized/string 3.2.10 Apache-2.0
@react-types/shared 3.36.1 Apache-2.0
@stylexjs/stylex 0.19.0 MIT
@swc/helpers 0.5.23 Apache-2.0
@tanstack/history 1.162.0 MIT @tanstack/history 1.162.0 MIT
@tanstack/query-core 5.101.4 MIT @tanstack/query-core 5.101.4 MIT
@tanstack/react-query 5.101.4 MIT @tanstack/react-query 5.101.4 MIT
@@ -46,24 +52,37 @@ sqlite url=https://sqlite.org/2026/sqlite-amalgamation-3530400.zip hash=N-V-__8A
@tanstack/react-store 0.9.3 MIT @tanstack/react-store 0.9.3 MIT
@tanstack/router-core 1.171.15 MIT @tanstack/router-core 1.171.15 MIT
@tanstack/store 0.9.3 MIT @tanstack/store 0.9.3 MIT
aria-hidden 1.2.6 MIT
client-only 0.0.1 MIT
clsx 2.1.1 MIT
cookie-es 3.1.1 MIT cookie-es 3.1.1 MIT
css-mediaquery 0.1.2 BSD
invariant 2.2.4 MIT
isbot 5.2.1 Unlicense isbot 5.2.1 Unlicense
js-tokens 4.0.0 MIT
loose-envify 1.4.0 MIT
react 19.2.8 MIT react 19.2.8 MIT
react-aria 3.51.0 Apache-2.0
react-aria-components 1.20.0 Apache-2.0
react-dom 19.2.8 MIT react-dom 19.2.8 MIT
react-stately 3.49.0 Apache-2.0
scheduler 0.27.0 MIT scheduler 0.27.0 MIT
seroval 1.5.6 MIT seroval 1.5.6 MIT
seroval-plugins 1.5.6 MIT seroval-plugins 1.5.6 MIT
styleq 0.2.1 MIT
tslib 2.8.1 0BSD
use-sync-external-store 1.6.0 MIT use-sync-external-store 1.6.0 MIT
[npm build-time generators whose output ships] [npm build-time generators whose output ships]
rolldown 1.1.5 MIT rolldown 1.1.5 MIT
tailwindcss 4.3.3 MIT
vite 8.1.5 MIT vite 8.1.5 MIT
[container base image] [container base image]
alpine:3.22@sha256:14358309a308569c32bdc37e2e0e9694be33a9d99e68afb0f5ff33cc1f695dce alpine:3.22@sha256:14358309a308569c32bdc37e2e0e9694be33a9d99e68afb0f5ff33cc1f695dce
[npm packages bundled into web/dist] [npm packages bundled into web/dist]
@internationalized/string
@stylexjs/stylex
@tanstack/history @tanstack/history
@tanstack/query-core @tanstack/query-core
@tanstack/react-query @tanstack/react-query
@@ -71,7 +90,11 @@ alpine:3.22@sha256:14358309a308569c32bdc37e2e0e9694be33a9d99e68afb0f5ff33cc1f695
@tanstack/react-store @tanstack/react-store
@tanstack/router-core @tanstack/router-core
@tanstack/store @tanstack/store
clsx
react react
react-aria
react-aria-components
react-dom react-dom
react-stately
scheduler scheduler
use-sync-external-store use-sync-external-store
+59 -10
View File
@@ -39,11 +39,42 @@
// stay listed in licenses/dependency-identity.txt so that a future build which // stay listed in licenses/dependency-identity.txt so that a future build which
// does pull them in trips the guard. // does pull them in trips the guard.
// //
// Four more joined that list with the StyleX build integration: css-mediaquery,
// invariant, and loose-envify with js-tokens under it. @stylexjs/stylex
// declares them, but its published runtime imports none of them — a sourcemap
// build of a component calling stylex.props shows no trace of any of the four,
// so they are dead entries of the published manifest rather than code with a
// job here. css-mediaquery is the one that matters: it is BSD, and it reaching
// web/dist would force a licence decision this project has not made.
//
// Seven more joined that list with React Aria, and the sourcemap build is again
// what settled which: @internationalized/date, @internationalized/number,
// @react-types/shared, @swc/helpers, aria-hidden, client-only and tslib are in
// the runtime closure but contribute no module to any chunk. The date and number
// packages serve the calendar and number-field components ruling 4 keeps out of
// scope; @react-types/shared is types only and emits no runtime code;
// client-only is a build-time marker for React Server Components; @swc/helpers
// and tslib are downlevelling helpers the published ESM builds never reach for;
// and aria-hidden sits behind a react-aria path the four adopted components do
// not take. Their licences differ and are recorded one by one in
// src/licenses_drift_test.zig rather than lumped together: @internationalized/
// date, @internationalized/number, @react-types/shared and @swc/helpers are
// Apache-2.0, tslib is 0BSD, aria-hidden and client-only are MIT. Keeping all
// seven out of the bundle is a fact worth re-checking rather than assuming, so
// the guard fails if any of them turns up in a `sources` list.
//
// StyleX now ships: the converted pages call stylex.props, and a sourcemap build
// puts @stylexjs/stylex in the bundle. What that build settles is not the
// obvious answer, though: only @stylexjs/stylex appears as a bundled package,
// and styleq arrives inside it. The published lib/es/stylex.mjs vendors styleq's source
// instead of importing it, so styleq's bytes ship while styleq never appears in
// a `sources` list — no guard would raise it, which is why it is written down
// here.
//
// Of the remaining devDependencies, none puts a byte in web/dist: @vitejs/ // Of the remaining devDependencies, none puts a byte in web/dist: @vitejs/
// plugin-react and typescript only transform our own sources (react-refresh is // plugin-react and typescript only transform our own sources (react-refresh is
// dev-server only, and tslib is optional and unused), @tailwindcss/vite // dev-server only, and tslib is optional and unused), lightningcss only
// orchestrates tailwindcss without contributing CSS of its own, lightningcss // minifies, and @testing-library/*, jsdom, vitest, oxlint and prettier
// only minifies, and @testing-library/*, jsdom, vitest, oxlint and prettier
// never touch the build output. // never touch the build output.
.{ .{
.{ .{
@@ -94,18 +125,36 @@
.note = "Bundled into the admin UI JavaScript. Grouped because all five carry the identical MIT text and copyright line.", .note = "Bundled into the admin UI JavaScript. Grouped because all five carry the identical MIT text and copyright line.",
.file = "tanstack-mit.txt", .file = "tanstack-mit.txt",
}, },
.{
.component = "React Aria (react-aria-components, react-aria, react-stately, @internationalized/string)",
.version = "react-aria-components 1.20.0, react-aria 3.51.0, react-stately 3.49.0, @internationalized/string 3.2.10",
.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 = "clsx",
.version = "clsx 2.1.1",
.note = "The class-name joiner React Aria calls when it merges a render-prop class name with its own. Bundled into the admin UI JavaScript. Separate entry from the other MIT packages: same MIT text, different copyright line.",
.file = "clsx-mit.txt",
},
.{
.component = "StyleX",
.version = "@stylexjs/stylex 0.19.0",
.note = "The style runtime for the admin UI. Its published dist vendors styleq's source, so the styleq entry below covers bytes that arrive inside this package. The npm tarball ships no licence file of its own; this text is the LICENSE of facebook/stylex at tag 0.19.0, which is byte-identical to the one on main.",
.file = "stylex-mit.txt",
},
.{
.component = "styleq",
.version = "styleq 0.2.1",
.note = "The class-name merger StyleX calls at runtime. Never a bundled package in its own right: @stylexjs/stylex vendors its source into lib/es/stylex.mjs, so it reaches web/dist without appearing in any sourcemap `sources` list. Separate entry from StyleX: same MIT text, different copyright line.",
.file = "styleq-mit.txt",
},
.{ .{
.component = "TanStack Store", .component = "TanStack Store",
.version = "@tanstack/store 0.9.3, @tanstack/react-store 0.9.3", .version = "@tanstack/store 0.9.3, @tanstack/react-store 0.9.3",
.note = "Bundled into the admin UI JavaScript. Separate entry from the other TanStack packages: same MIT text, different copyright line.", .note = "Bundled into the admin UI JavaScript. Separate entry from the other TanStack packages: same MIT text, different copyright line.",
.file = "tanstack-store-mit.txt", .file = "tanstack-store-mit.txt",
}, },
.{
.component = "Tailwind CSS",
.version = "tailwindcss 4.3.3",
.note = "A devDependency whose output ships: the admin UI stylesheet is generated from Tailwind's own CSS sources, so the generated CSS in the binary is a derivative of them.",
.file = "tailwindcss-mit.txt",
},
.{ .{
.component = "Vite", .component = "Vite",
.version = "vite 8.1.5", .version = "vite 8.1.5",
@@ -115,7 +164,7 @@
.{ .{
.component = "Rolldown", .component = "Rolldown",
.version = "rolldown 1.1.5", .version = "rolldown 1.1.5",
.note = "A devDependency whose own runtime source ships: the bundler Vite 8 runs prepends its CommonJS interop helpers verbatim to web/dist/assets/classes-*.js, which is embedded in the binary.", .note = "A devDependency whose own runtime source ships: the bundler Vite 8 runs prepends its CommonJS interop helpers verbatim to web/dist/assets/styles-*.js, which is embedded in the binary.",
.file = "rolldown-mit.txt", .file = "rolldown-mit.txt",
}, },
.{ .{
+4 -1
View File
@@ -51,7 +51,10 @@ pub const texts: []const Text = &.{
.{ .name = "react-mit.txt", .body = @embedFile("react-mit.txt") }, .{ .name = "react-mit.txt", .body = @embedFile("react-mit.txt") },
.{ .name = "tanstack-mit.txt", .body = @embedFile("tanstack-mit.txt") }, .{ .name = "tanstack-mit.txt", .body = @embedFile("tanstack-mit.txt") },
.{ .name = "tanstack-store-mit.txt", .body = @embedFile("tanstack-store-mit.txt") }, .{ .name = "tanstack-store-mit.txt", .body = @embedFile("tanstack-store-mit.txt") },
.{ .name = "tailwindcss-mit.txt", .body = @embedFile("tailwindcss-mit.txt") }, .{ .name = "react-aria-apache-2.0.txt", .body = @embedFile("react-aria-apache-2.0.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") },
.{ .name = "vite-mit.txt", .body = @embedFile("vite-mit.txt") }, .{ .name = "vite-mit.txt", .body = @embedFile("vite-mit.txt") },
.{ .name = "rolldown-mit.txt", .body = @embedFile("rolldown-mit.txt") }, .{ .name = "rolldown-mit.txt", .body = @embedFile("rolldown-mit.txt") },
.{ .name = "mozilla-ca-bundle-mpl-2.0.txt", .body = @embedFile("mozilla-ca-bundle-mpl-2.0.txt") }, .{ .name = "mozilla-ca-bundle-mpl-2.0.txt", .body = @embedFile("mozilla-ca-bundle-mpl-2.0.txt") },
+25
View File
@@ -0,0 +1,25 @@
React Aria: react-aria-components, react-aria, react-stately and
@internationalized/string, bundled into the admin UI JavaScript that is
embedded in every nxdns binary.
Copyright 2019 Adobe
SPDX-License-Identifier: Apache-2.0
Licensed under the Apache License, Version 2.0 (the "License"); you may not use
these files except in compliance with the License. You may obtain a copy of the
License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software distributed
under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR
CONDITIONS OF ANY KIND, either express or implied. See the License for the
specific language governing permissions and limitations under the License.
The full text of the Apache License, Version 2.0 is reproduced above, in the
Mbed TLS section of this file. The LICENSE file each of these four packages
ships is that same document. The Mbed TLS copy carries an eight-line project
preamble of its own; setting that aside, the two differ on one line, the sample
copyright inside the "APPENDIX: How to apply the Apache License to your work"
boilerplate, which is not part of the licence terms. None of the four ships a
NOTICE file, so Section 4(d) attaches nothing further.
@@ -1,6 +1,6 @@
MIT License MIT License
Copyright (c) Tailwind Labs, Inc. Copyright (c) Nicolas Gallagher
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) Meta Platforms, Inc. and affiliates.
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.
+108
View File
@@ -0,0 +1,108 @@
# Milestone 22: TypeScript 7
Goal: move the web toolchain from TypeScript 6.0.3 to TypeScript 7.0.x (the
native compiler, GA 2026-07-08). One devDependency bump; every web gate and
the release pipeline stay green.
Design written 2026-08-12 against HEAD `91a0aa9`. TypeScript 7.0.2 is
`typescript@latest` on npm at spec time; the milestone pins whatever 7.0.x is
current when the session runs.
## Implementation contract (read first)
- Read `AGENTS.md`, then this spec whole, before session work starts.
- `web/src/lib/contractSamples.gen.ts` is generated; it is type-checked by
`tsc -b` but never hand-edited. If TS7 rejects it, the fix goes in the
generator (`AGENTS.md` regeneration procedure), not the file.
- No license work: `typescript` is a devDependency, absent from both the
runtime closure and the bundled set in `licenses/dependency-identity.txt`.
The Zig drift guard (`src/licenses_drift_test.zig`) must still pass —
it recomputes from `web/package-lock.json` and dev-only changes are
invisible to it.
## Rulings (binding)
### 1. The bump is one line plus the lockfile
`web/package.json:45` `"typescript": "6.0.3"` becomes the current 7.0.x,
exact pin (repo convention: no ranges, milestone-14 ruling 12 pins by exact
version everywhere). `npm install` updates `web/package-lock.json`. Nothing
else in `dependencies` or `devDependencies` moves in this milestone.
### 2. The tsconfigs are already TS7-clean; verify, do not churn
Checked against TS7's removed options (recorded here so the session does not
re-derive it): `tsconfig.app.json` and `tsconfig.node.json` use
`moduleResolution: "bundler"`, `module: "esnext"`, targets `es2022`/`es2023`,
`paths` without `baseUrl`, `verbatimModuleSyntax`, `isolatedModules`,
`noEmit`. None of TS7's removals (`target: es5`, `downlevelIteration`,
`moduleResolution: node10`/`classic`, `module: amd/umd/system/none`,
`baseUrl`-as-alias) are present. `tsconfig.json` is a two-entry project
reference; TS7 supports `tsc -b` and project references. The session changes
a tsconfig only if `tsc -b` errors demand it, and records any such change in
`## Recorded (implementation)`.
### 3. Known TS7 edge: `skipLibCheck` no longer hides parse-level .d.ts errors
Both tsconfigs set `skipLibCheck: true`. Under TS7 that still skips type
checking of `.d.ts` files but not parse-level errors in them. If a dependency
`.d.ts` fails to parse, the fix is a dependency patch bump or an upstream
issue reference recorded in the spec — never a copied-and-edited local
`.d.ts`.
### 4. Nothing else in the toolchain consumes the TS compiler
Verified at spec time: `oxlint` (own parser), `vite`/`vitest`
(esbuild transpile, no type checking), `prettier`, and
`@vitejs/plugin-react` run no `tsc` and load no `typescript` API. The only
consumer is the `typecheck` script (`web/package.json:12`, `tsc -b`), run in
CI at `.gitea/workflows/gates.yml:113-114`. TS7 has no stable programmatic
API until 7.1; nothing in this repo needs one.
## Sessions
One session.
### Session S1: the bump
Owns: `web/package.json`, `web/package-lock.json`, and — only if ruling 2
forces it — `web/tsconfig.app.json`, `web/tsconfig.node.json`,
`web/tsconfig.json`.
- S1.1 bump per ruling 1; `npm install`.
- S1.2 run, in order: `npm run format:check`, `npm run lint`,
`npm run typecheck`, `npm test`, `npm run build`, `npm run assert-bundled`
(all from `web/`).
- S1.3 `zig build test` (the licenses drift guard and the contract-samples
guard run inside it).
Acceptance (S1):
- [ ] `web/node_modules/.bin/tsc --version` reports 7.0.x.
- [ ] `npm run typecheck` exits 0 with no tsconfig change, or the change is
recorded with the TS7 error text that forced it.
- [ ] All six web gate commands exit 0.
- [ ] `zig build test` exits 0.
- [ ] `git diff` touches only the files S1 owns.
## Module layout
New files: none. Deleted surface: none.
## Acceptance (milestone complete)
- [ ] All S1 boxes.
- [ ] `licenses/dependency-identity.txt` is byte-identical to HEAD.
## Anti-requirements
- No other dependency bumps ride along — not oxlint, not vite, not vitest,
not `@types/*`.
- No tsconfig flag additions or "modernization" beyond what a TS7 error
forces.
- No typescript-eslint, ts-jest, or any tool needing the TS programmatic API
(unavailable until 7.1, and unwanted regardless — oxlint is the linter).
- No `tsgo` binary references anywhere: the binary is `tsc`, scripts stay as
they are.
- No edits to `web/src/**` — a source change to satisfy the new compiler is a
finding to record and fix, not silently absorb, unless `tsc -b` fails
without it; then it is recorded in `## Recorded (implementation)`.
+574
View File
@@ -0,0 +1,574 @@
# Milestone 23: StyleX and React Aria replace Tailwind
Goal: every styled element in `web/src` renders through StyleX
(`@stylexjs/unplugin` at build time); Dialog, AlertDialog, Tabs and Select
come from `react-aria-components`; Tailwind is removed. TanStack Router and
TanStack Query stay (decided 2026-08-12 after review; the router swap is out
of scope permanently, not deferred).
Design written 2026-08-12 against HEAD `91a0aa9`. Needs milestone 22 landed
(the StyleX and RAC `.d.ts` surfaces are checked by the TS7 `tsc -b`).
Inventory this spec rests on: 522 `className=` occurrences across 31
`web/src` files; 17 shared class constants in `web/src/ui/classes.ts`;
`web/src/styles.css` is the single line `@import "tailwindcss"` (so dark mode
is Tailwind 4's default `prefers-color-scheme` media strategy — no class
toggle exists); the Tailwind plugin sits at `web/vite.config.ts:3,8`;
`tailwindcss` and `@tailwindcss/vite` are devDependencies
(`web/package.json:34,44`) and appear in neither the runtime closure nor the
bundled set of `licenses/dependency-identity.txt`.
## Implementation contract (read first)
- Read `AGENTS.md`, then this spec whole, before session work starts.
- `web/src/lib/contractSamples.gen.ts` and `web/src/lib/types.ts` are API
contract surfaces; this milestone is styling-only and touches neither.
- Every session leaves all gates green: `npm run format:check`, `npm run
lint`, `npm run typecheck`, `npm test`, `npm run build`,
`npm run assert-bundled` (from `web/`), and `zig build test`. The release
gate (`zig build dist` + `verify-dist`, `.gitea/workflows/gates.yml`) keeps
its byte budgets; they are not tight — `web/dist` is 440,924 bytes raw
against the 15 MiB binary budget — but `verify-dist` remains the arbiter.
- `assert-bundled` fails whenever the bundled npm package set changes. That
failure is the license workflow trigger, not an obstacle: review each new
package into `licenses/inventory.zon`, then record the new set in
`licenses/dependency-identity.txt` (procedure in that file's header).
Never update the recorded list without the inventory review.
## Rulings (binding)
### 1. Build integration is `@stylexjs/unplugin`, proven before any page moves
`stylex.vite()` from `@stylexjs/unplugin` goes into `web/vite.config.ts`
alongside the existing plugins (Tailwind stays until S3). Configuration per
the official Vite guide (stylexjs.com/docs/learn/installation/vite):
`useCSSLayers: true`, `runtimeInjection: false`, `dev` keyed off the Vite
mode. The generated CSS injects into an existing CSS asset, so
`web/src/styles.css` (imported by `main.tsx:8`) remains the CSS entry for the
whole milestone; the dev/HMR virtual-module wiring from that guide is part of
S1. The unmaintained community `vite-plugin-stylex` is forbidden. S1's
acceptance proves dev server, `vitest run` and `vite build` all handle a
StyleX component before S2 or S3 convert anything — this is a gate, not an
assumption.
### 2. Tokens live in `web/src/ui/tokens.stylex.ts`
`stylex.defineVars` with role names, not raw shades: surface, surface-raised,
border, border-strong, text, text-muted (the zinc ramp today), primary and
primary-text (blue-600/white), danger, danger-surface, danger-border (the red
ramp), focus (blue-600). Each var carries its dark value under
`@media (prefers-color-scheme: dark)` inside `defineVars` — same media
strategy the app has now. No theme toggle, no `data-theme` attribute.
### 3. `web/src/ui/styles.ts` replaces `ui/classes.ts`
The 17 exports of `web/src/ui/classes.ts` (focusRing, insetFocusRing, input,
smallInput, button, smallButton, largeButton, primaryButton,
largePrimaryButton, rowButton, linkButton, dangerLinkButton, retryButton, th,
td, tableWrap, formCard) become `stylex.create` style objects in
`web/src/ui/styles.ts`, composed at call sites via
`{...stylex.props(styles.button, extra)}`. The milestone-9 accessibility
floor restated by milestone-18 ruling 10 carries over verbatim: every
interactive element renders the focus ring (`:focus-visible` outline, 2px,
offset 2, focus token; the inset variant for controls flush against panel
edges). `ui/classes.ts` is deleted in S3 when its last importer converts.
### 4. React Aria scope is exactly four components — end state, not a phase
From `react-aria-components`: Dialog + Modal (replacing the hand-rolled
`role="dialog"` overlay in `web/src/features/clients/ClientEditDialog.tsx:27-28`),
AlertDialog composition (ruling 5), Tabs (replacing the button-pair tab
switcher in `web/src/features/local/LocalDnsPage.tsx`), Select (replacing the
7 files carrying native `<select>`: LookupPage, QueryLogPage, RecordsTab,
RulesPage, SettingsPage, PrefixesEditor, ClientEditDialog). Everything else —
buttons, text inputs, tables, links, banners — stays native HTML styled with
StyleX. A native styled `<button>` is the finished design, not an interim
one. S2 wraps the four as shared components: `web/src/ui/Dialog.tsx`,
`web/src/ui/ConfirmDialog.tsx`, `web/src/ui/Tabs.tsx`,
`web/src/ui/Select.tsx`, each styled with StyleX through RAC's render-prop
state booleans (`stylex.props(isSelected && styles.selected)`) — never
data-attribute selectors, which StyleX cannot express.
### 5. `window.confirm` dies with the conversion
All call sites become `ConfirmDialog` (RAC AlertDialog: `role="alertdialog"`,
focus trapped, danger-styled confirm button):
`web/src/features/blocklists/BlocklistsPage.tsx:61`,
`web/src/features/upstreams/UpstreamsPage.tsx:43`,
`web/src/features/rules/RulesPage.tsx:34`, and
`web/src/ui/useCrudForm.ts:49` (which carries the confirm for ZonesTab and
RecordsTab). `useCrudForm`'s `CrudFormSpec.confirmDelete` keeps its signature;
the hook returns the dialog state instead of calling `window.confirm`. The
tests that stub confirm today
(`web/src/features/upstreams/UpstreamsPage.test.tsx:140,144,188`,
`web/src/ui/useCrudForm.test.tsx:102`) are rewritten to drive the real
dialog: open, assert `role="alertdialog"` and the entity name in the message,
confirm and cancel paths both asserted. BlocklistsPage and RulesPage gain the
same coverage for their delete paths.
### 6. Styling mechanics
`stylex.create`/`stylex.props` only. Conditions are boolean-guarded style
objects; variants are separate named styles; responsive behavior (the
`md:` sidebar grid in `web/src/shell/AppShell.tsx:90-98`, the mobile drawer)
becomes `@media` conditions inside style values. No dynamic class-name
strings, no `clsx`-style helpers, no `className` literals left anywhere in
`web/src` after S3 — `className`/`style` attributes appear only as the spread
of `stylex.props(...)` (RAC render-prop `className` functions included).
TanStack `Link` `activeProps`/`inactiveProps` (`AppShell.tsx:35-42`) pass
`stylex.props(...).className` outputs.
### 7. Visual contract: drift accepted, structure pinned
No pixel parity requirement and no visual-regression harness. Pinned instead,
checkable by reading the converted file against the original: same layout
structure (grid/flex relationships, table columns, heading levels), token
roles per ruling 2 (zinc surfaces, blue primary actions, red danger), the
focus floor of ruling 3 on every interactive element, dark mode via media
query, `overflow-x-auto` table wrappers preserved.
### 8. License inventory, named per step
- S1 adds `@stylexjs/stylex` (runtime dependency; `stylex.props` ships in the
bundle) to the runtime closure and the bundled set. `@stylexjs/unplugin` is
a devDependency — runtime closure and bundled set untouched by it.
- S2 adds `react-aria-components` and its transitive closure
(`@react-aria/*`, `@react-stately/*`, `@react-types/*`,
`@internationalized/*`, `@swc/helpers` — the exact bundled subset is
whatever `npm run assert-bundled` reports after S2's conversions; every
reported name gets a reviewed `licenses/inventory.zon` entry before the
recorded list moves). This is the milestone's largest review surface.
- S3 removes nothing from the license files: Tailwind was dev-only and never
recorded. The `@tanstack/*` entries stay untouched.
### 9. PLAN.md wording moves in S3
`PLAN.md:138` "Vite + React + TypeScript + Tailwind; TanStack Router +
TanStack Query" → "Vite + React + TypeScript + StyleX + React Aria; TanStack
Router + TanStack Query". `PLAN.md:235` directory comment updates the same
way. Line 562 names loaders and Query only — untouched. No other PLAN
amendment.
### 10. Conversion order is ascending `className` count, one commit per file
So a regression bisects to one page. Counts at spec time:
| file | count |
|---|---|
| lib/InlineError.tsx | 2 |
| features/settings/ReadOnlyConfigBanner.tsx | 2 |
| features/dashboard/HealthBanners.tsx | 2 |
| features/local/LocalDnsPage.tsx | 3 |
| features/settings/RestartBanner.tsx | 3 |
| features/dashboard/StatCards.tsx | 5 |
| routes.tsx | 6 |
| auth/LoginPage.tsx | 9 |
| features/dashboard/DashboardPage.tsx | 9 |
| features/groups/GroupSourcesEditor.tsx | 9 |
| features/pause/PauseWidget.tsx | 9 |
| features/blocklists/BlocklistForm.tsx | 11 |
| features/clients/ClientEditDialog.tsx | 11 |
| features/dashboard/DiskCard.tsx | 13 |
| features/upstreams/UpstreamForm.tsx | 14 |
| features/clients/PrefixesEditor.tsx | 15 |
| shell/AppShell.tsx | 17 |
| features/live/LiveLogPage.tsx | 18 |
| features/dashboard/TimeseriesChart.tsx | 21 |
| features/dashboard/UpstreamHealthTable.tsx | 21 |
| features/upstreams/UpstreamsPage.tsx | 21 |
| features/groups/GroupsPage.tsx | 22 |
| features/lookup/LookupPage.tsx | 25 |
| features/local/ZonesTab.tsx | 26 |
| features/blocklists/SourceStatusSection.tsx | 27 |
| features/clients/ClientsPage.tsx | 28 |
| features/rules/RulesPage.tsx | 31 |
| features/blocklists/BlocklistsPage.tsx | 31 |
| features/settings/SettingsPage.tsx | 31 |
| features/local/RecordsTab.tsx | 34 |
| features/queries/QueryLogPage.tsx | 46 |
S2 takes its owned cluster out of this order (converted together because the
RAC wrappers land there); S3 walks the remainder smallest-first.
### 11. Tests that must change, known in advance
RAC changes DOM and focus behavior; these edits are expected work, not
regressions to paper over:
- Dialog: `ClientsPage.test.tsx` (ClientEditDialog now portals into an RAC
Modal; queries move to `within(screen.getByRole("dialog"))`).
- Tabs: `LocalDnsPage.test.tsx` (tab switcher becomes `role="tablist"`/
`role="tab"`; `getByRole("button", { name: ... })` queries move to
`getByRole("tab", ...)`).
- AlertDialog: `UpstreamsPage.test.tsx`, `useCrudForm.test.tsx` per ruling 5.
- Select: RAC Select renders a button + listbox, not `<select>`, so
`getByLabelText(...)` + `fireEvent.change` patterns break in the tests for
the 7 Select files (LookupPage, QueryLogPage, LocalDnsPage/RecordsTab,
RulesPage, SettingsPage, ClientsPage). They move to
`userEvent`-style open-then-select interactions.
All other tests query by role/text and are expected to survive; a test that
asserted a Tailwind class is rewritten against role or accessible name.
## Sessions
Three sessions, strictly sequential: S1 → S2 → S3. (S2 and S3 both write
`web/package.json` and the license files; the RAC wrappers S3 consumes are
built in S2.)
### Session S1: StyleX foundation, proven end to end
Owns: `web/package.json`, `web/package-lock.json`, `web/vite.config.ts`,
`web/src/styles.css`, `web/src/ui/tokens.stylex.ts` (new),
`web/src/ui/styles.ts` (new), `web/src/ui/styles.test.tsx` (new),
`licenses/inventory.zon`, `licenses/dependency-identity.txt`.
- S1.1 add `@stylexjs/stylex` (dependency) and `@stylexjs/unplugin`
(devDependency), exact pins; wire `stylex.vite()` per ruling 1. Tailwind
plugin stays.
- S1.2 tokens per ruling 2; shared styles per ruling 3. `ui/classes.ts` is
not deleted yet — both vocabularies coexist until S3.
- S1.3 `styles.test.tsx`: render a probe component using
`stylex.props(styles.button)` under vitest; assert a non-empty
`className` lands on the element (proves the transform runs in the test
pipeline, since `runtimeInjection` is off).
- S1.4 license entries per ruling 8.
Acceptance (S1):
- [ ] `npm run dev` serves a probe page where a StyleX rule visibly applies
and edits hot-reload (checked by hand, result recorded).
- [ ] `npm test` passes including `styles.test.tsx`.
- [ ] `npm run build` emits StyleX CSS into the `web/dist` assets; the built
`index.html` page renders with the probe styled.
- [ ] `npm run assert-bundled` passes with `@stylexjs/stylex` in the recorded
set; `zig build test` passes (drift guard agrees).
- [ ] All remaining gates green.
### Session S2: React Aria primitives and their cluster
Owns: `web/package.json`, `web/package-lock.json`, `web/src/ui/Dialog.tsx`
(new), `web/src/ui/ConfirmDialog.tsx` (new), `web/src/ui/Tabs.tsx` (new),
`web/src/ui/Select.tsx` (new), `web/src/ui/useCrudForm.ts`,
`web/src/ui/useCrudForm.test.tsx`, `web/src/features/clients/*`,
`web/src/features/local/*`, `web/src/features/upstreams/*`,
`web/src/features/blocklists/BlocklistsPage.tsx` and its test,
`web/src/features/rules/RulesPage.tsx` and its test,
`licenses/inventory.zon`, `licenses/dependency-identity.txt`.
- S2.1 add `react-aria-components`, exact pin; build the four wrappers per
ruling 4, styled with tokens and `ui/styles.ts`.
- S2.2 convert the owned cluster fully to StyleX while adopting the wrappers:
ClientEditDialog + PrefixesEditor + ClientsPage (Dialog, Select);
LocalDnsPage + ZonesTab + RecordsTab (Tabs, Select, ConfirmDialog via
useCrudForm); UpstreamsPage + UpstreamForm, BlocklistsPage, RulesPage
(ConfirmDialog, Select). No `className` literal survives in an owned file.
- S2.3 `window.confirm` removal and test rewrites per rulings 5 and 11.
- S2.4 license review per ruling 8 — every package `assert-bundled` names.
Acceptance (S2):
- [ ] `grep -rn 'window\.confirm(' web/src` → zero matches. The bare name
still appears in three comments in `ui/` that record what replaced it,
which is why the check is for a call and not for the name.
- [ ] Delete flows on upstreams, blocklists, rules, zones and records drive a
`role="alertdialog"` element; confirm and cancel both covered by tests.
- [ ] Tabs on `/local-dns` expose `role="tablist"`; keyboard arrow keys move
between tabs (asserted in `LocalDnsPage.test.tsx`).
- [ ] No `className=` literal in S2-owned files
(`grep -c 'className="' <file>` = 0; `stylex.props` spreads excepted).
- [ ] `assert-bundled` passes with the RAC closure recorded;
`zig build test` passes.
- [ ] All remaining gates green.
### Session S3: the sweep and the removal
Owns: every `web/src` file not owned by S1/S2 that carries `className=`
(the ruling-10 remainder: InlineError, ReadOnlyConfigBanner, HealthBanners,
RestartBanner, StatCards, routes.tsx, LoginPage, DashboardPage,
GroupSourcesEditor, GroupsPage, PauseWidget, BlocklistForm,
SourceStatusSection, DiskCard, AppShell, LiveLogPage, TimeseriesChart,
UpstreamHealthTable, LookupPage, QueryLogPage, SettingsPage) plus their
tests, `web/src/ui/classes.ts` (delete), `web/package.json`,
`web/package-lock.json`, `web/vite.config.ts`, `web/src/styles.css`,
`PLAN.md`.
- S3.1 convert the remainder smallest-first per ruling 10, one commit per
file, gates green at every commit.
- S3.2 delete `ui/classes.ts` when its import count reaches zero.
- S3.3 remove Tailwind: drop `tailwindcss` and `@tailwindcss/vite` from
`package.json`, the plugin from `vite.config.ts:3,8`, and the
`@import "tailwindcss"` line from `styles.css` (the file itself stays as
the StyleX CSS entry per ruling 1).
- S3.4 PLAN.md per ruling 9.
Acceptance (S3):
- [ ] `grep -rn 'className="' web/src` returns zero matches. No Tailwind
dependency in `web/package.json`, no plugin in `web/vite.config.ts`, no
`@import "tailwindcss"` in `web/src/styles.css`. The word still appears
in seven comments in `web/src` that name the ramps the tokens were taken
from and the reset the preflight used to supply; those are the record of
where the values came from, so the check is not for the name.
- [ ] `ui/classes.ts` is gone; nothing imports it.
- [ ] Dark scheme still follows `prefers-color-scheme` (vitest: token var
resolves differently under a mocked dark media query, or a recorded
manual check in both schemes).
- [ ] `PLAN.md:138` and `:235` name StyleX + React Aria; `git diff PLAN.md`
touches nothing else.
- [ ] All gates green, including `zig build dist` + `zig build verify-dist`
with `-Dweb-dist=web/dist` (run once at milestone end).
### Orchestrator
Verify S1 acceptance (the ruling-1 gate) before S2 starts; S2 before S3.
After S3: full gate set, then a live smoke — `zig build` a server with the
fresh `web/dist`, click through every page in light and dark scheme, exercise
one delete confirm and the local-dns tabs. Record deviations in
`## Recorded (implementation)`.
## Module layout
New files:
- `web/src/ui/tokens.stylex.ts` — the design tokens (ruling 2).
- `web/src/ui/styles.ts` — the shared style vocabulary (ruling 3).
- `web/src/ui/styles.test.tsx` — the transform-pipeline probe (S1.3).
- `web/src/ui/Dialog.tsx`, `web/src/ui/ConfirmDialog.tsx`,
`web/src/ui/Tabs.tsx`, `web/src/ui/Select.tsx` — the RAC wrappers
(ruling 4).
Deleted surface: `web/src/ui/classes.ts`; the `window.confirm` calls; the
Tailwind dependency pair and its vite plugin line and CSS import.
## Acceptance (milestone complete)
- [ ] All session boxes above.
- [ ] Zero Tailwind: no dependency, no plugin, no `@import`, no utility
`className` literal in `web/src`.
- [ ] RAC appears in exactly four wrapper files plus their consumers; no
other `react-aria-components` import (`grep -rln 'react-aria-components'
web/src` ⊆ the four wrappers).
- [ ] Focus ring visible via keyboard on every interactive element of every
page (spot-checked live, recorded).
- [ ] `licenses/dependency-identity.txt`, `licenses/inventory.zon` and the
bundled reality agree (`assert-bundled` and `zig build test` both
green).
- [ ] `web/src/lib/types.ts` and `web/src/lib/contractSamples.gen.ts` are
byte-identical to HEAD.
## Recorded (implementation)
Written during S1 and the S2 hand-off. Each entry is a place the spec was wrong
or a decision the spec did not have.
### S1 could not put StyleX in the bundle, and its acceptance says otherwise
S1 owns no page file, so no production module imports StyleX and rollup emits
none of it. Two S1 boxes — "`npm run build` emits StyleX CSS into the `web/dist`
assets" and "`assert-bundled` passes with `@stylexjs/stylex` in the recorded
set" — are therefore unmeetable inside S1 as scoped, and the recorded bundled
set is unchanged at the S1 commit. Both were proven instead against a temporary
probe component, then deferred to S2, which converts real pages and does change
the bundled set. Ruling 8's "S1 adds `@stylexjs/stylex` ... to the runtime
closure and the bundled set" is half right: the closure moves in S1, the bundled
set in S2. The inventory entry for StyleX is anticipatory at the S1 commit,
which is deliberate — see the styleq entry for why it cannot wait.
### The probe results, recorded rather than left as a claim
Against the temporary probe: the built CSS carries `@layer priority1..N` and the
token variables, resolving to `#fafafa` light and `#09090b` dark with
lightningcss `lab()` fallbacks. The dev server needs no wiring of its own — the
plugin injects `<link rel="stylesheet" href="/virtual:stylex.css">` into
`index.html`, so the `DevStyleXInject` component in StyleX's Vite guide is for
frameworks that bypass Vite's HTML transform and is not used here. Editing a
token regenerates the virtual stylesheet and the dev server issues a page
reload.
### S1 wrote outside its owned files, and outside an anti-requirement
The licence guard forced it: `licenses/stylex-mit.txt`, `licenses/styleq-mit.txt`
and their registration in `licenses/licenses.zig`, plus the `npm_not_shipped`
array in `src/licenses_drift_test.zig`. The last contradicts the "no edits ...
under `src/`" anti-requirement below. The anti-requirement is amended, not
ignored: `src/licenses_drift_test.zig` is writable for its licence-fact arrays
and, from S2, for the per-package licence change described next. The rest of
`src/` stays closed.
### Apache-2.0 inbound: a decision, not a deduction
`react-aria-components` 1.20.0 brings eight Apache-2.0 packages
(react-aria-components, react-aria, react-stately, `@react-types/shared`, three
`@internationalized/*`, `@swc/helpers`) and `tslib` under 0BSD into an EUPL-1.2
project. The user accepted Apache-2.0 inbound on 2026-08-12 and authorised the
guard change it needs. This is recorded as their decision because nothing in the
licence settles it: EUPL-1.2's appendix does not list Apache-2.0 — it names GPL,
AGPL, OSL, EPL, CeCILL, MPL, LGPL, CC BY-SA, EUPL and LiLiQ — and that appendix
governs Article 5 outbound relicensing, not inbound consumption. Any claim that
the appendix permits this is false and must not be written into the inventory.
`src/licenses_drift_test.zig:122` hardcodes a single expected licence and
becomes per-package in S2; the guard stays strict, an unexpected licence still
fails.
### Ruling 8's picture of the React Aria closure is wrong
There are no `@react-aria/*` or `@react-stately/*` scoped packages. RAC 1.20.0
ships consolidated `react-aria` and `react-stately`. The runtime closure it adds
is the eleven packages named above plus `aria-hidden`, `clsx` and `client-only`
under MIT — not the "dozens" the ruling anticipated.
### Ruling 11's claim about the tab tests is wrong
`LocalDnsPage.tsx` already renders `role="tablist"` and `role="tab"` by hand, so
no test query moves from `getByRole("button")` to `getByRole("tab")`. What RAC
adds there is keyboard arrow-key navigation, which the hand-rolled switcher
lacks, and that is what the S2 acceptance box asserts.
### `formCard` is a layout change, not a port
`space-y-3` set sibling margins on a block container; the StyleX version is a
flex column with `gap`. StyleX cannot express the `> * + *` selector `space-y`
compiles to, so `gap` is the only mechanism available. Both call sites
(`RecordsTab`, `ZonesTab`) are plain vertical form stacks, where the difference
in child sizing and margin behaviour does not show. Recorded against ruling 7,
which pins layout structure.
### S2 needed two build-config changes the spec did not anticipate
Both are in files S1 and S3 own, both were forced, and neither is optional.
`web/vitest.setup.ts` (new) plus `setupFiles` in `web/vite.config.ts`: jsdom
implements no `CSS` interface at all, and React Aria's collection code calls
`CSS.escape` unguarded when it looks an item up by key
(`react-aria/dist/private/selection/utils.mjs:22`). Every RAC Select and Tabs
test threw `Cannot read properties of undefined (reading 'escape')` before this
landed. The setup file implements CSSOM's *serialize an identifier* algorithm
rather than approximating it, because a wrong escape would break the key lookups
silently instead of loudly.
The `@` alias passed to `stylex.vite()` in `web/vite.config.ts`: StyleX resolves
the `.stylex.ts` theme file itself and cannot see Vite's `resolve.alias`, so
`import { colors } from "@/ui/tokens.stylex"` in a feature file failed the build
with "Could not resolve the path to the imported file". Relative imports were the
alternative; passing the alias keeps the repo's `@/` convention instead.
### RAC Select changes the accessible name of a labelled control
`useSelect` labels the trigger button with `aria-labelledby="<value> <label>"`,
so a select labelled "Type" showing "A" has the accessible name "A Type", and
`getByLabelText("Type")` no longer finds it. Converted tests query
`getByRole("button", { name: /Type$/ })` and read the options by opening the
listbox. RAC also renders a hidden native `<select>` for form submission; it sits
inside an `aria-hidden` container, so role and label queries skip it.
### The RAC anti-requirement forbids parts of a component it requires
`Select.tsx` imports RAC `Button`, `Popover`, `Label`, `ListBox`, `ListBoxItem`
and `SelectValue`. The anti-requirement below names `Button` and `Popover`
among the components RAC must not bring. Those two are not adopted components
here — RAC composes a Select out of exactly these parts, and there is no RAC
Select without them. The anti-requirement is amended: it forbids adopting a RAC
component as an app-wide primitive, and the internal parts of the four adopted
components are permitted where no file outside their wrapper imports them.
That last clause is the check that keeps the scope honest, and it holds:
`react-aria-components` is imported only by the four `ui/` wrappers. A RAC
`Button` in a feature file would still be a breach.
### The focus-ring floor was briefly amended on a false premise
S2 left the Select's options with their outline removed, showing keyboard focus
as a background inversion alone. This section previously amended ruling 3 to
excuse that, arguing RAC drives option focus through an `isFocused` render prop
so a `:focus-visible` rule could never match. That argument is wrong.
`useSelectableItem` gives options a roving `tabIndex` and focuses the option's
own DOM node (`react-aria/dist/private/selection/useSelectableItem.mjs:64`),
which `ListBoxItem` spreads onto its element — so `:focus-visible` matches and
the ring was simply missing. The amendment is withdrawn and ruling 3 stands
unchanged. `Select.tsx` now composes the shared inset ring onto every option:
inset because an option flush against a scrolling popover clips an outset
outline, and recoloured to `primaryText` on the highlighted row because the
focus token is the same blue that row paints behind it. The inversion stays as
the listbox convention; it is no longer the only indicator.
### Two notes for S3
`textMuted` resolves to zinc-400 in dark mode. Several current `text-zinc-500`
call sites carry no dark override and stay zinc-500, so the token cannot replace
those without a shade shift — decide per call site rather than sweeping.
Vitest began reporting "Tests closed successfully but something prevents Vite
server from exiting" with the StyleX plugin in the pipeline. The suite passes
and exits 0. It should not be allowed to become the accepted baseline.
### Both S3 notes are now settled
**The vitest warning is fixed, and it was not our code.** Bisecting a trivial
one-assertion test against a bare config showed `react()` exits clean and
`stylex.vite()` does not. `@stylexjs/unplugin@0.19.0`'s
`lib/es/vite.mjs:59` starts a 150ms `setInterval` in `configureServer` to poll
its CSS store for HMR, and clears it from the `close` event of
`server.httpServer`. A probe plugin confirmed vitest resolves `httpServer` to
`null`, because it runs the dev server in middleware mode — so the timer is
never cleared and holds the event loop open until vitest kills the worker on a
ten-second timeout. `web/vite.config.ts` now strips that one hook when
`process.env.VITEST` is set; the transform hook, which the tests do need, is
untouched, and `ui/styles.test.tsx` still proves it runs. `npm test` reports 31
files and 183 tests passed, with no warning, and exits 0.
**`textMuted` was swept; here is the per-call-site judgement.** Measuring the
two shifts against their grounds settles it in opposite directions:
- 39 call sites were a flat `text-zinc-500` with no dark override. On the dark
ground that measures **4.12:1**, which fails WCAG AA. The token's zinc-400
dark value measures **7.56:1**. The sweep repaired a real accessibility
defect there, so it stands, and the token comment now records why.
- 4 call sites were `text-zinc-600 dark:text-zinc-400` — already adaptive. The
sweep moved their light value to zinc-500, from **7.40:1** to **4.62:1**.
That clears AA by a hair on small text: the query-log table header, the
dashboard period button, the inactive nav item and the chart legend. It buys
nothing, so a second token `textSecondary` (zinc-600 light, zinc-400 dark)
restores the original pair at exactly those four.
Contrast was computed from the tokens' own oklch values through linear sRGB to
WCAG relative luminance, not read off a chart.
### The reset's font stack is not Tailwind's, deliberately
Tailwind is uninstalled, so its default stack cannot be reproduced from the
tree, and ruling 7 accepts the metric drift. One part is not drift: `Select.tsx`
renders U+25BE for its chevron, and Segoe UI does not carry that glyph, so on
Windows the stack has to reach a font that does. `"Segoe UI Symbol"` and
`"Noto Sans Symbols 2"` are appended for that glyph. No emoji families are
listed, because the app renders no emoji.
### Two review findings closed by decision, not by a change
Both were put to Mokhtar Mial on 2026-08-12 and both are closed. Neither is to
be re-opened by a later review reading the history and inferring an oversight.
`0f58966` carries a `README.md` edit that was already in the working tree and
is not part of the `srOnly` work its message describes. The content is intact
and only the attribution is wrong; rewriting published history to correct that
was declined.
Ruling 10 asks for one commit per file in ascending `className` count. S3 did
not hold to the order strictly. Bisectability does not depend on it and the
commits were already published, so the order stands as it is.
## Anti-requirements
- No router or query changes: TanStack Router and TanStack Query stay,
untouched. That decision is closed.
- No RAC beyond Dialog, AlertDialog, Tabs, Select — no RAC Button, TextField,
Table, Menu, DatePicker adopted as an app primitive. Native elements styled
with StyleX are the end state. The internal parts of the four adopted
components (Button, Popover, Label, ListBox, ListBoxItem, SelectValue inside
Select) are permitted, and only inside their own wrapper file — see the
recorded amendment above.
- No runtime style injection (`runtimeInjection: false` stays), no CSS-in-JS
at runtime, no styled-components-like patterns.
- No dynamic class-name assembly, no `clsx`/`classnames` dependency, no
Tailwind-compatibility shim.
- No theme toggle, no `data-theme`, no stored preference — media-query dark
mode only, as today.
- No redesign: layout structure is pinned by ruling 7. Drift in shades and
spacing is accepted; new page structures are not.
- No visual-regression harness, no screenshot tooling.
- No edits to `web/src/lib/types.ts`, `web/src/lib/contractSamples.gen.ts`,
`web/src/lib/api.ts`, or anything under `src/` (the Zig tree).
- No `vite-plugin-stylex` (unmaintained community plugin).
+106 -20
View File
@@ -47,7 +47,7 @@ const licenses = @import("licenses_files");
/// rolldown for the CommonJS interop helpers it prepends to any chunk pulling in /// rolldown for the CommonJS interop helpers it prepends to any chunk pulling in
/// a CommonJS module. Every other devDependency left `web/dist` untouched. /// a CommonJS module. Every other devDependency left `web/dist` untouched.
/// Anything added here must also be inventoried. /// Anything added here must also be inventoried.
const npm_generators = [_][]const u8{ "rolldown", "tailwindcss", "vite" }; const npm_generators = [_][]const u8{ "rolldown", "vite" };
/// Packages of the recorded runtime closure that put no byte in `web/dist`, so /// Packages of the recorded runtime closure that put no byte in `web/dist`, so
/// the inventory names them in a note rather than carrying their licence. The /// the inventory names them in a note rather than carrying their licence. The
@@ -57,7 +57,23 @@ const npm_generators = [_][]const u8{ "rolldown", "tailwindcss", "vite" };
/// This list is a claim about the build, and the build is what settles it: a /// This list is a claim about the build, and the build is what settles it: a
/// name here that turns up in the recorded `web/dist` bundle fails, so a /// name here that turns up in the recorded `web/dist` bundle fails, so a
/// package that starts shipping cannot stay on this list quietly. /// package that starts shipping cannot stay on this list quietly.
const npm_not_shipped = [_][]const u8{ "cookie-es", "isbot", "seroval", "seroval-plugins" }; const npm_not_shipped = [_][]const u8{
"cookie-es",
"isbot",
"seroval",
"seroval-plugins",
"css-mediaquery",
"invariant",
"js-tokens",
"loose-envify",
"@internationalized/date",
"@internationalized/number",
"@react-types/shared",
"@swc/helpers",
"aria-hidden",
"client-only",
"tslib",
};
/// Components the shipped artifacts contain that no dependency file mentions, /// Components the shipped artifacts contain that no dependency file mentions,
/// so no automatic check would ever notice their disappearance from the /// so no automatic check would ever notice their disappearance from the
@@ -71,7 +87,6 @@ const required_components = [_][]const u8{
"p256-m", "p256-m",
"React", "React",
"TanStack", "TanStack",
"Tailwind",
"Vite", "Vite",
"Rolldown", "Rolldown",
"Mozilla CA certificate bundle", "Mozilla CA certificate bundle",
@@ -103,15 +118,61 @@ const ZigDependencyVersion = struct {
}, },
}; };
/// The licence every *shipped* npm package carries today, and the licence of /// The licence each npm package of the recorded sets carries today. The identity
/// every npm text under `licenses/`. The identity file records a licence token /// file records a licence token per package; nothing used to read it, so a
/// per package; nothing used to read it, so a package that relicensed passed as /// package that relicensed passed as long as its version had not moved.
/// long as its version had not moved. A token other than this one needs its own ///
/// licence text and a decision about EUPL-1.2 compatibility, which is a human /// MIT is the default because almost every package here is MIT, so only the
/// review, not a paste. The `npm_not_shipped` packages are exempt — they /// exceptions are written down. A package whose recorded token stops matching
/// redistribute nothing, and one of them is Unlicense. /// its expectation fails — including the packages below, which is the point of
/// naming them rather than waving them through: a new licence on any of them is
/// a new decision, not a fact to absorb quietly.
///
/// Adding a name here is a human review, not a paste. A licence this project has
/// not taken before needs its own text under `licenses/`, an inventory entry
/// that says who accepted it and when, and whatever that licence's attribution
/// terms require. Apache-2.0 arrived that way: Mokhtar Mial accepted it inbound
/// on 2026-08-12, and `licenses/react-aria-apache-2.0.txt` carries the text
/// Section 4 asks for.
///
/// Packages that put no byte in `web/dist` are here too. They redistribute
/// nothing today, so their licence carries no obligation today — but "not
/// shipped" is a claim about the build that a future build can falsify, and
/// recording the licence now means the answer is already reviewed when it does.
const npm_expected_licence = "MIT"; const npm_expected_licence = "MIT";
const npm_licence_exceptions = [_]NpmLicence{
// Shipped: React Aria and the one @internationalized package it pulls into
// the bundle.
.{ .name = "react-aria-components", .licence = "Apache-2.0" },
.{ .name = "react-aria", .licence = "Apache-2.0" },
.{ .name = "react-stately", .licence = "Apache-2.0" },
.{ .name = "@internationalized/string", .licence = "Apache-2.0" },
// Not shipped: the rest of the React Aria closure.
.{ .name = "@internationalized/date", .licence = "Apache-2.0" },
.{ .name = "@internationalized/number", .licence = "Apache-2.0" },
.{ .name = "@react-types/shared", .licence = "Apache-2.0" },
.{ .name = "@swc/helpers", .licence = "Apache-2.0" },
.{ .name = "tslib", .licence = "0BSD" },
// Not shipped: reached only through @tanstack/router-core's server paths
// and @stylexjs/stylex's compiler.
.{ .name = "isbot", .licence = "Unlicense" },
.{ .name = "css-mediaquery", .licence = "BSD" },
};
const NpmLicence = struct {
name: []const u8,
licence: []const u8,
};
/// The reviewed licence for one package: its exception, or MIT.
fn expectedLicence(name: []const u8) []const u8 {
for (npm_licence_exceptions) |entry| {
if (std.mem.eql(u8, entry.name, name)) return entry.licence;
}
return npm_expected_licence;
}
/// The two licence texts that must be reproduced in full, pinned by content. /// The two licence texts that must be reproduced in full, pinned by content.
/// The marker-string probes below prove the right *document* is present; they /// The marker-string probes below prove the right *document* is present; they
/// cannot tell a complete Apache-2.0 from one with its middle sections deleted, /// cannot tell a complete Apache-2.0 from one with its middle sections deleted,
@@ -481,22 +542,21 @@ test "the CA bundle entry names the Alpine release the Dockerfile pins" {
} }
} }
test "every npm package in the recorded closure ships under the licence the inventory assumes" { test "every npm package in the recorded closure carries the licence the inventory expects" {
for ([_][]const u8{ npm_section, generator_section }) |header| { for ([_][]const u8{ npm_section, generator_section }) |header| {
const recorded = try recordedSection(header); const recorded = try recordedSection(header);
var lines = std.mem.tokenizeScalar(u8, recorded, '\n'); var lines = std.mem.tokenizeScalar(u8, recorded, '\n');
while (lines.next()) |line| { while (lines.next()) |line| {
const pkg = parseRecordedPackage(line) orelse return error.MalformedIdentityLine; const pkg = parseRecordedPackage(line) orelse return error.MalformedIdentityLine;
// A package that puts no byte in web/dist redistributes nothing, so const expected = expectedLicence(pkg.name);
// its licence carries no obligation. isbot is Unlicense and is one if (!std.mem.eql(u8, pkg.licence, expected)) {
// of these.
if (isNotShipped(pkg.name)) continue;
if (!std.mem.eql(u8, pkg.licence, npm_expected_licence)) {
std.debug.print( std.debug.print(
"npm package '{s}' is recorded under '{s}', not {s}. Every npm licence text under" ++ "npm package '{s}' is recorded under '{s}', but the reviewed expectation is" ++
" licenses/ is an {s} text, so this package needs one of its own and a" ++ " '{s}'. Work out what the new licence means for the shipped artifacts, give" ++
" decision about EUPL-1.2 compatibility.\n", " it a text under licenses/ and an inventory entry naming who accepted it if" ++
.{ pkg.name, pkg.licence, npm_expected_licence, npm_expected_licence }, " this project has not taken it before, then record the new expectation in" ++
" npm_licence_exceptions.\n",
.{ pkg.name, pkg.licence, expected },
); );
return error.NpmLicenceChanged; return error.NpmLicenceChanged;
} }
@@ -504,6 +564,32 @@ test "every npm package in the recorded closure ships under the licence the inve
} }
} }
test "every npm licence exception names a package the identity file records" {
const closure = try recordedSection(npm_section);
const generators = try recordedSection(generator_section);
for (npm_licence_exceptions) |entry| {
// A stale exception is how a package quietly regains the MIT default
// after it leaves the tree and comes back under a different licence.
if (recordsPackage(closure, entry.name) or recordsPackage(generators, entry.name)) continue;
std.debug.print(
"npm_licence_exceptions names '{s}', which licenses/dependency-identity.txt no longer" ++
" records. Drop the exception when the package leaves the closure.\n",
.{entry.name},
);
return error.StaleLicenceException;
}
}
/// Whether a recorded `<package> <version> <licence>` section names a package.
fn recordsPackage(section: []const u8, name: []const u8) bool {
var lines = std.mem.tokenizeScalar(u8, section, '\n');
while (lines.next()) |line| {
const pkg = parseRecordedPackage(line) orelse continue;
if (std.mem.eql(u8, pkg.name, name)) return true;
}
return false;
}
test "the licence texts that must be reproduced in full are unmodified" { test "the licence texts that must be reproduced in full are unmodified" {
for (pinned_texts) |pinned| { for (pinned_texts) |pinned| {
const body = textBody(pinned.file) orelse { const body = textBody(pinned.file) orelse {
+1111 -328
View File
File diff suppressed because it is too large Load Diff
+5 -4
View File
@@ -8,7 +8,7 @@
}, },
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vite build && node scripts/stamp-dist.mjs", "build": "vite build && node scripts/assert-css-layers.mjs && node scripts/stamp-dist.mjs",
"typecheck": "tsc -b", "typecheck": "tsc -b",
"lint": "oxlint src vite.config.ts", "lint": "oxlint src vite.config.ts",
"format": "prettier --write .", "format": "prettier --write .",
@@ -25,13 +25,15 @@
"trailingComma": "all" "trailingComma": "all"
}, },
"dependencies": { "dependencies": {
"@stylexjs/stylex": "0.19.0",
"@tanstack/react-query": "5.101.4", "@tanstack/react-query": "5.101.4",
"@tanstack/react-router": "1.170.18", "@tanstack/react-router": "1.170.18",
"react": "19.2.8", "react": "19.2.8",
"react-aria-components": "1.20.0",
"react-dom": "19.2.8" "react-dom": "19.2.8"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/vite": "4.3.3", "@stylexjs/unplugin": "0.19.0",
"@testing-library/dom": "10.4.1", "@testing-library/dom": "10.4.1",
"@testing-library/react": "16.3.2", "@testing-library/react": "16.3.2",
"@types/node": "26.1.1", "@types/node": "26.1.1",
@@ -41,8 +43,7 @@
"jsdom": "29.1.1", "jsdom": "29.1.1",
"oxlint": "1.75.0", "oxlint": "1.75.0",
"prettier": "3.9.6", "prettier": "3.9.6",
"tailwindcss": "4.3.3", "typescript": "7.0.2",
"typescript": "6.0.3",
"vite": "8.1.5", "vite": "8.1.5",
"vitest": "4.1.10" "vitest": "4.1.10"
} }
+118
View File
@@ -0,0 +1,118 @@
#!/usr/bin/env node
// Every rule in the built stylesheet must sit inside a cascade layer
// (milestone 23). Unlayered author CSS outranks every layer whatever its
// selector says, so a single unlayered rule silently beats the StyleX atomic
// rules it was written to sit under. That failure renders wrong and passes
// every other gate: no test asserts computed style, and the bundler is happy.
// It also checks the layer ORDER, which is the invariant that actually matters:
// a later layer beats an earlier one, so `reset` has to be declared first.
//
// What it does not catch, so nobody reads more into a pass than is there: an
// unlayered rule that sets only custom properties is allowed, because StyleX
// emits its token `:root` block exactly that way and this cannot tell that
// block from an override of it; a declaration value containing `@layer` or a
// brace inside a string blinds the stripper; and with several stylesheets it
// judges each alone, not their load order in the document.
//
// This check runs from web/ as part of `npm run build`.
import { readdirSync, readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
const distDir = join(dirname(dirname(fileURLToPath(import.meta.url))), "dist", "assets");
const sheets = readdirSync(distDir).filter((name) => name.endsWith(".css"));
if (sheets.length === 0) {
console.error("assert-css-layers: no stylesheet in dist/assets — did the build emit one?");
process.exit(1);
}
// At-rules that describe a resource or a name rather than styling an element.
// They carry no cascade priority against a layer, so being outside one is
// correct, and StyleX emits `@property` for its custom properties.
const unlayerable = String.raw`@(?:layer|property|keyframes|font-face|counter-style|charset|import)`;
/** Strip comments, then every balanced block and statement the rule above allows. */
function outsideLayers(css) {
let rest = css.replace(/\/\*[\s\S]*?\*\//g, "");
for (;;) {
const at = rest.search(new RegExp(`${unlayerable}[^{;]*\\{`));
if (at === -1) break;
let depth = 0;
let end = rest.indexOf("{", at);
for (let i = end; i < rest.length; i += 1) {
if (rest[i] === "{") depth += 1;
else if (rest[i] === "}") {
depth -= 1;
if (depth === 0) {
end = i;
break;
}
}
}
rest = rest.slice(0, at) + rest.slice(end + 1);
}
return rest.replace(new RegExp(`${unlayerable}[^;{}]*;`, "g"), "");
}
/**
* A rule that only sets custom properties styles nothing on its own — StyleX
* emits its token `:root` block that way, ahead of its layers, and a variable
* is consumed through `var()` rather than competing with a layered rule.
*/
function stylesSomething(body) {
return body
.split(";")
.map((declaration) => declaration.trim())
.some((declaration) => declaration.length > 0 && !declaration.startsWith("--"));
}
/**
* Layer names in the order their position is fixed, which is where each name is
* first mentioned — a later block under an already-named layer does not move it.
*/
function layerOrder(css) {
const seen = [];
for (const [, names] of css.replace(/\/\*[\s\S]*?\*\//g, "").matchAll(/@layer\s+([^{;]+)[{;]/g)) {
for (const name of names.split(",")) {
const trimmed = name.trim();
if (trimmed.length > 0 && !seen.includes(trimmed)) seen.push(trimmed);
}
}
return seen;
}
let failed = false;
for (const sheet of sheets) {
const css = readFileSync(join(distDir, sheet), "utf8");
// Order is the whole point: a later layer wins, so the reset has to be first.
const order = layerOrder(css);
if (order.length > 0 && order[0] !== "reset") {
console.error(
`assert-css-layers: ${sheet} declares layers in the order ${order.join(", ")}` +
`'reset' must come first or it outranks the StyleX rules written against it.`,
);
failed = true;
}
const leftover = outsideLayers(css);
for (const [, selector, body] of leftover.matchAll(/([^{}]+)\{([^{}]*)\}/g)) {
if (!stylesSomething(body)) continue;
console.error(
`assert-css-layers: ${sheet} styles elements outside every @layer:\n` +
` ${selector.trim().slice(0, 80)} { ${body.trim().slice(0, 60)} … }`,
);
failed = true;
break;
}
}
if (failed) {
console.error("Wrap it in a layer declared before StyleX's, as web/src/styles.css does.");
process.exit(1);
}
console.log(
`every rule in ${sheets.length === 1 ? "the stylesheet" : `${sheets.length} stylesheets`} sits inside a cascade layer`,
);
+59 -10
View File
@@ -1,8 +1,57 @@
import { useEffect, useState, type FormEvent } from "react"; import { useEffect, useState, type FormEvent } from "react";
import { useRouter, useSearch } from "@tanstack/react-router"; import { useRouter, useSearch } from "@tanstack/react-router";
import * as stylex from "@stylexjs/stylex";
import { ApiError } from "@/lib/api"; import { ApiError } from "@/lib/api";
import { useAuth } from "@/auth/store"; import { useAuth } from "@/auth/store";
import { inputClass, largePrimaryButtonClass } from "@/ui/classes"; import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
const styles = stylex.create({
/** Login renders outside AppShell, so it paints the page ground itself. */
page: {
display: "flex",
minHeight: "100dvh",
alignItems: "center",
justifyContent: "center",
backgroundColor: colors.surface,
color: colors.text,
padding: "1rem",
},
card: {
width: "100%",
maxWidth: "24rem",
},
heading: {
fontSize: "1.5rem",
lineHeight: "2rem",
fontWeight: 600,
},
probing: {
marginTop: "1rem",
color: colors.textMuted,
},
form: {
display: "flex",
flexDirection: "column",
gap: "1rem",
marginTop: "1.5rem",
},
fieldLabel: {
display: "block",
fontSize: "0.875rem",
lineHeight: "1.25rem",
fontWeight: 500,
},
submit: {
width: "100%",
},
error: {
marginTop: "1rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.danger,
},
});
export function safeRedirect(raw: string | undefined): string { export function safeRedirect(raw: string | undefined): string {
if (raw === undefined) return "/"; if (raw === undefined) return "/";
@@ -76,15 +125,15 @@ export default function LoginPage() {
} }
return ( return (
<main className="flex min-h-dvh items-center justify-center bg-zinc-50 p-4 text-zinc-900 dark:bg-zinc-950 dark:text-zinc-100"> <main {...stylex.props(styles.page)}>
<section className="w-full max-w-sm"> <section {...stylex.props(styles.card)}>
<h1 className="text-2xl font-semibold">nxdns</h1> <h1 {...stylex.props(styles.heading)}>nxdns</h1>
{authRequired !== true ? ( {authRequired !== true ? (
<p className="mt-4 text-zinc-500">Checking whether a password is required</p> <p {...stylex.props(styles.probing)}>Checking whether a password is required</p>
) : ( ) : (
<form onSubmit={onSubmit} className="mt-6 space-y-4"> <form onSubmit={onSubmit} {...stylex.props(styles.form)}>
<div> <div>
<label htmlFor="password" className="block text-sm font-medium"> <label htmlFor="password" {...stylex.props(styles.fieldLabel)}>
Password Password
</label> </label>
<input <input
@@ -95,20 +144,20 @@ export default function LoginPage() {
required required
value={password} value={password}
onChange={(event) => setPassword(event.target.value)} onChange={(event) => setPassword(event.target.value)}
className={inputClass} {...stylex.props(shared.input, shared.focusRing)}
/> />
</div> </div>
<button <button
type="submit" type="submit"
disabled={busy || lockedOut} disabled={busy || lockedOut}
className={`w-full ${largePrimaryButtonClass}`} {...stylex.props(shared.largePrimaryButton, styles.submit, shared.focusRing)}
> >
{busy ? "Logging in…" : "Log in"} {busy ? "Logging in…" : "Log in"}
</button> </button>
</form> </form>
)} )}
{error !== null && ( {error !== null && (
<p role="alert" className="mt-4 text-sm text-red-600 dark:text-red-400"> <p role="alert" {...stylex.props(styles.error)}>
{errorMessage(error, remaining)} {errorMessage(error, remaining)}
</p> </p>
)} )}
+54 -12
View File
@@ -1,10 +1,48 @@
import { useState, type FormEvent } from "react"; import { useState, type FormEvent } from "react";
import * as stylex from "@stylexjs/stylex";
import { ApiError } from "@/lib/api"; import { ApiError } from "@/lib/api";
import InlineError from "@/lib/InlineError"; import InlineError from "@/lib/InlineError";
import type { Blocklist, BlocklistInput } from "@/lib/types"; import type { Blocklist, BlocklistInput } from "@/lib/types";
import { buttonClass, focusRing, inputClass, primaryButtonClass } from "@/ui/classes"; import { styles as shared } from "@/ui/styles";
import { READ_ONLY_HINT } from "@/features/settings/authority"; import { READ_ONLY_HINT } from "@/features/settings/authority";
const styles = stylex.create({
form: {
display: "flex",
flexDirection: "column",
gap: "0.75rem",
marginTop: "1rem",
maxWidth: "36rem",
},
heading: {
fontSize: "1.125rem",
lineHeight: "1.75rem",
fontWeight: 500,
},
fieldLabel: {
display: "block",
fontSize: "0.875rem",
lineHeight: "1.25rem",
fontWeight: 500,
},
checkboxLabel: {
display: "flex",
alignItems: "center",
gap: "0.5rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
fontWeight: 500,
},
buttonRow: {
display: "flex",
alignItems: "center",
gap: "0.5rem",
},
cancel: {
fontWeight: 500,
},
});
/** /**
* Drops the rejection the page already renders inline below the form. Anything * Drops the rejection the page already renders inline below the form. Anything
* else is a bug in this component and must reach the console instead of dying * else is a bug in this component and must reach the console instead of dying
@@ -46,10 +84,10 @@ export default function BlocklistForm({ initial, busy, readOnly, error, onSubmit
} }
return ( return (
<form onSubmit={handleSubmit} className="mt-4 max-w-xl space-y-3"> <form onSubmit={handleSubmit} {...stylex.props(styles.form)}>
<h2 className="text-lg font-medium">{initial === undefined ? "Add source" : `Edit ${initial.name}`}</h2> <h2 {...stylex.props(styles.heading)}>{initial === undefined ? "Add source" : `Edit ${initial.name}`}</h2>
<div> <div>
<label htmlFor="blocklist-url" className="block text-sm font-medium"> <label htmlFor="blocklist-url" {...stylex.props(styles.fieldLabel)}>
URL URL
</label> </label>
<input <input
@@ -58,11 +96,11 @@ export default function BlocklistForm({ initial, busy, readOnly, error, onSubmit
required required
value={url} value={url}
onChange={(event) => setUrl(event.target.value)} onChange={(event) => setUrl(event.target.value)}
className={inputClass} {...stylex.props(shared.input, shared.focusRing)}
/> />
</div> </div>
<div> <div>
<label htmlFor="blocklist-name" className="block text-sm font-medium"> <label htmlFor="blocklist-name" {...stylex.props(styles.fieldLabel)}>
Name Name
</label> </label>
<input <input
@@ -71,29 +109,33 @@ export default function BlocklistForm({ initial, busy, readOnly, error, onSubmit
required required
value={name} value={name}
onChange={(event) => setName(event.target.value)} onChange={(event) => setName(event.target.value)}
className={inputClass} {...stylex.props(shared.input, shared.focusRing)}
/> />
</div> </div>
<label className="flex items-center gap-2 text-sm font-medium"> <label {...stylex.props(styles.checkboxLabel)}>
<input <input
type="checkbox" type="checkbox"
checked={enabled} checked={enabled}
onChange={(event) => setEnabled(event.target.checked)} onChange={(event) => setEnabled(event.target.checked)}
className={focusRing} {...stylex.props(shared.focusRing)}
/> />
Enabled Enabled
</label> </label>
<div className="flex items-center gap-2"> <div {...stylex.props(styles.buttonRow)}>
<button <button
type="submit" type="submit"
disabled={busy || readOnly} disabled={busy || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined} title={readOnly ? READ_ONLY_HINT : undefined}
className={primaryButtonClass} {...stylex.props(shared.primaryButton, shared.focusRing)}
> >
{initial === undefined ? "Add source" : "Save changes"} {initial === undefined ? "Add source" : "Save changes"}
</button> </button>
{onCancel !== undefined && ( {onCancel !== undefined && (
<button type="button" onClick={onCancel} className={`${buttonClass} font-medium`}> <button
type="button"
onClick={onCancel}
{...stylex.props(shared.button, styles.cancel, shared.focusRing)}
>
Cancel Cancel
</button> </button>
)} )}
@@ -41,14 +41,20 @@ const RESPONSES: Record<string, unknown> = {
}; };
let resolveUpdate: ((response: Response) => void) | null; let resolveUpdate: ((response: Response) => void) | null;
let deleted: string[];
beforeEach(() => { beforeEach(() => {
clearRefreshStatus(); clearRefreshStatus();
resolveUpdate = null; resolveUpdate = null;
deleted = [];
vi.stubGlobal( vi.stubGlobal(
"fetch", "fetch",
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input); const url = String(input);
if (init?.method === "DELETE") {
deleted.push(url);
return new Response(null, { status: 204 });
}
if (url === "/api/blocklists/update" && init?.method === "POST") { if (url === "/api/blocklists/update" && init?.method === "POST") {
return new Promise<Response>((resolve) => { return new Promise<Response>((resolve) => {
resolveUpdate = resolve; resolveUpdate = resolve;
@@ -227,3 +233,27 @@ test("the refresh snapshot outlives the query cache's gcTime", async () => {
vi.useRealTimers(); vi.useRealTimers();
} }
}); });
test("delete asks for confirmation, and cancelling sends no request", async () => {
renderBlocklistsRoute();
await screen.findByRole("heading", { name: "Blocklists" });
fireEvent.click(screen.getAllByRole("button", { name: "Delete" })[0]!);
const dialog = await screen.findByRole("alertdialog");
expect(dialog.textContent).toContain('Delete blocklist "StevenBlack"? Its domains stop being blocked.');
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
await waitFor(() => expect(screen.queryByRole("alertdialog")).toBeNull());
expect(deleted).toEqual([]);
});
test("confirming the delete dialog issues the DELETE for that source", async () => {
renderBlocklistsRoute();
await screen.findByRole("heading", { name: "Blocklists" });
fireEvent.click(screen.getAllByRole("button", { name: "Delete" })[1]!);
await screen.findByRole("alertdialog");
fireEvent.click(screen.getByRole("button", { name: "Delete" }));
await waitFor(() => expect(deleted).toEqual(["/api/blocklists/2"]));
});
+117 -49
View File
@@ -1,5 +1,6 @@
import { useState } from "react"; import { useState } from "react";
import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query"; import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query";
import * as stylex from "@stylexjs/stylex";
import { formatTime } from "@/lib/format"; import { formatTime } from "@/lib/format";
import InlineError from "@/lib/InlineError"; import InlineError from "@/lib/InlineError";
import { import {
@@ -13,21 +14,75 @@ import type { Blocklist, BlocklistInput } from "@/lib/types";
import BlocklistForm from "./BlocklistForm"; import BlocklistForm from "./BlocklistForm";
import { useRefreshStatus } from "./refreshStore"; import { useRefreshStatus } from "./refreshStore";
import SourceStatusSection from "./SourceStatusSection"; import SourceStatusSection from "./SourceStatusSection";
import { import ConfirmDialog from "@/ui/ConfirmDialog";
dangerLinkButtonClass, import { styles as shared } from "@/ui/styles";
focusRing, import { colors } from "@/ui/tokens.stylex";
linkButtonClass,
primaryButtonClass,
tableWrapClass,
tdClass,
thClass,
} from "@/ui/classes";
import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority"; import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority";
const styles = stylex.create({
header: {
display: "flex",
flexWrap: "wrap",
alignItems: "center",
justifyContent: "space-between",
gap: "0.75rem",
},
heading: {
fontSize: "1.5rem",
lineHeight: "2rem",
fontWeight: 600,
},
done: {
marginTop: "0.5rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.primaryOnSurface,
},
empty: {
marginTop: "1rem",
color: colors.textMuted,
},
table: {
width: "100%",
minWidth: "max-content",
borderCollapse: "collapse",
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
name: {
fontWeight: 500,
},
badge: {
marginLeft: "0.5rem",
borderRadius: "0.25rem",
backgroundColor: colors.border,
paddingInline: "0.375rem",
paddingBlock: "0.125rem",
fontSize: "0.75rem",
lineHeight: "1rem",
color: colors.text,
},
url: {
display: "block",
maxWidth: "18rem",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
},
actions: {
display: "flex",
gap: "0.75rem",
},
dimWhenDisabled: {
opacity: { default: 1, ":disabled": 0.5 },
},
});
export default function BlocklistsPage() { export default function BlocklistsPage() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { data: blocklists } = useSuspenseQuery(blocklistsQuery()); const { data: blocklists } = useSuspenseQuery(blocklistsQuery());
const [editing, setEditing] = useState<Blocklist | null>(null); const [editing, setEditing] = useState<Blocklist | null>(null);
const [pendingDelete, setPendingDelete] = useState<Blocklist | null>(null);
const create = useMutation(blocklistCreateMutation(queryClient)); const create = useMutation(blocklistCreateMutation(queryClient));
const save = useMutation(blocklistUpdateMutation(queryClient)); const save = useMutation(blocklistUpdateMutation(queryClient));
@@ -57,10 +112,10 @@ export default function BlocklistsPage() {
}); });
} }
function deleteBlocklist(b: Blocklist) { function confirmDelete() {
if (window.confirm(`Delete blocklist "${b.name}"? Its domains stop being blocked.`)) { if (pendingDelete === null) return;
remove.mutate(b.id); remove.mutate(pendingDelete.id);
} setPendingDelete(null);
} }
const formError = editing === null ? create.error : save.error; const formError = editing === null ? create.error : save.error;
@@ -68,60 +123,56 @@ export default function BlocklistsPage() {
return ( return (
<section> <section>
<div className="flex flex-wrap items-center justify-between gap-3"> <div {...stylex.props(styles.header)}>
<h1 className="text-2xl font-semibold">Blocklists</h1> <h1 {...stylex.props(styles.heading)}>Blocklists</h1>
<button <button
type="button" type="button"
onClick={() => updateNow.mutate()} onClick={() => updateNow.mutate()}
disabled={updateNow.isPending} disabled={updateNow.isPending}
className={primaryButtonClass} {...stylex.props(shared.primaryButton, shared.focusRing)}
> >
{updateNow.isPending ? "Updating…" : "Update now"} {updateNow.isPending ? "Updating…" : "Update now"}
</button> </button>
</div> </div>
{updateNow.isSuccess && !updateNow.isPending && ( {updateNow.isSuccess && !updateNow.isPending && (
<p className="mt-2 text-sm text-green-700 dark:text-green-400" role="status"> <p {...stylex.props(styles.done)} role="status">
Update completed; source status refreshed below. Update completed; source status refreshed below.
</p> </p>
)} )}
<InlineError error={updateNow.error} /> <InlineError error={updateNow.error} />
{blocklists.length === 0 ? ( {blocklists.length === 0 ? (
<p className="mt-4 text-zinc-500">No blocklist sources yet. Add one below.</p> <p {...stylex.props(styles.empty)}>No blocklist sources yet. Add one below.</p>
) : ( ) : (
<div className={tableWrapClass}> <div {...stylex.props(shared.tableWrap)}>
<table className="w-full min-w-max border-collapse text-sm"> <table {...stylex.props(styles.table)}>
<thead> <thead>
<tr> <tr>
<th className={thClass}>Name</th> <th {...stylex.props(shared.th)}>Name</th>
<th className={thClass}>URL</th> <th {...stylex.props(shared.th)}>URL</th>
<th className={thClass}>Enabled</th> <th {...stylex.props(shared.th)}>Enabled</th>
<th className={thClass}>Domains</th> <th {...stylex.props(shared.th)}>Domains</th>
<th className={thClass}>Wildcards</th> <th {...stylex.props(shared.th)}>Wildcards</th>
<th className={thClass}>Skipped regex</th> <th {...stylex.props(shared.th)}>Skipped regex</th>
<th className={thClass}>Last updated</th> <th {...stylex.props(shared.th)}>Last updated</th>
<th className={thClass}> <th {...stylex.props(shared.th)}>
<span className="sr-only">Actions</span> <span {...stylex.props(shared.srOnly)}>Actions</span>
</th> </th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{blocklists.map((b) => ( {blocklists.map((b) => (
<tr key={b.id}> <tr key={b.id}>
<td className={tdClass}> <td {...stylex.props(shared.td)}>
<span className="font-medium">{b.name}</span> <span {...stylex.props(styles.name)}>{b.name}</span>
{b.is_suggested && ( {b.is_suggested && <span {...stylex.props(styles.badge)}>Suggested</span>}
<span className="ml-2 rounded bg-zinc-200 px-1.5 py-0.5 text-xs text-zinc-700 dark:bg-zinc-800 dark:text-zinc-300">
Suggested
</span>
)}
</td> </td>
<td className={tdClass}> <td {...stylex.props(shared.td)}>
<span className="block max-w-72 truncate" title={b.url}> <span {...stylex.props(styles.url)} title={b.url}>
{b.url} {b.url}
</span> </span>
</td> </td>
<td className={tdClass}> <td {...stylex.props(shared.td)}>
<input <input
type="checkbox" type="checkbox"
aria-label={`${b.name} enabled`} aria-label={`${b.name} enabled`}
@@ -129,32 +180,36 @@ export default function BlocklistsPage() {
disabled={toggle.isPending || readOnly} disabled={toggle.isPending || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined} title={readOnly ? READ_ONLY_HINT : undefined}
onChange={() => toggleEnabled(b)} onChange={() => toggleEnabled(b)}
className={focusRing} {...stylex.props(shared.focusRing)}
/> />
</td> </td>
<td className={`${tdClass} tabular-nums`}>{b.domain_count}</td> <td {...stylex.props(shared.td, shared.tabularNums)}>{b.domain_count}</td>
<td className={`${tdClass} tabular-nums`}>{b.wildcard_count}</td> <td {...stylex.props(shared.td, shared.tabularNums)}>{b.wildcard_count}</td>
<td className={`${tdClass} tabular-nums`}>{b.skipped_regex_count}</td> <td {...stylex.props(shared.td, shared.tabularNums)}>{b.skipped_regex_count}</td>
<td className={tdClass}> <td {...stylex.props(shared.td)}>
{b.last_updated === null ? "never" : formatTime(b.last_updated)} {b.last_updated === null ? "never" : formatTime(b.last_updated)}
</td> </td>
<td className={tdClass}> <td {...stylex.props(shared.td)}>
<div className="flex gap-3"> <div {...stylex.props(styles.actions)}>
<button <button
type="button" type="button"
onClick={() => setEditing(b)} onClick={() => setEditing(b)}
disabled={readOnly} disabled={readOnly}
title={readOnly ? READ_ONLY_HINT : undefined} title={readOnly ? READ_ONLY_HINT : undefined}
className={`${linkButtonClass} disabled:opacity-50`} {...stylex.props(
shared.linkButton,
styles.dimWhenDisabled,
shared.focusRing,
)}
> >
Edit Edit
</button> </button>
<button <button
type="button" type="button"
onClick={() => deleteBlocklist(b)} onClick={() => setPendingDelete(b)}
disabled={remove.isPending || readOnly} disabled={remove.isPending || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined} title={readOnly ? READ_ONLY_HINT : undefined}
className={dangerLinkButtonClass} {...stylex.props(shared.dangerLinkButton, shared.focusRing)}
> >
Delete Delete
</button> </button>
@@ -179,6 +234,19 @@ export default function BlocklistsPage() {
/> />
<SourceStatusSection sources={sources} namesById={namesById} /> <SourceStatusSection sources={sources} namesById={namesById} />
<ConfirmDialog
isOpen={pendingDelete !== null}
title="Delete blocklist"
message={
pendingDelete === null
? ""
: `Delete blocklist "${pendingDelete.name}"? Its domains stop being blocked.`
}
confirmLabel="Delete"
onConfirm={confirmDelete}
onCancel={() => setPendingDelete(null)}
/>
</section> </section>
); );
} }
@@ -1,6 +1,8 @@
import * as stylex from "@stylexjs/stylex";
import { formatTime } from "@/lib/format"; import { formatTime } from "@/lib/format";
import type { SourceStatus } from "@/lib/types"; import type { SourceStatus } from "@/lib/types";
import { tdClass, thClass } from "@/ui/classes"; import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
function formatAttempt(unixSeconds: number): string { function formatAttempt(unixSeconds: number): string {
return unixSeconds === 0 ? "never" : formatTime(unixSeconds); return unixSeconds === 0 ? "never" : formatTime(unixSeconds);
@@ -11,61 +13,108 @@ interface SourceStatusSectionProps {
namesById: ReadonlyMap<number, string>; namesById: ReadonlyMap<number, string>;
} }
const styles = stylex.create({
section: {
marginTop: "2rem",
},
heading: {
fontSize: "1.125rem",
lineHeight: "1.75rem",
fontWeight: 500,
},
note: {
marginTop: "0.5rem",
color: colors.textMuted,
},
tableWrap: {
marginTop: "0.5rem",
overflowX: "auto",
},
table: {
width: "100%",
minWidth: "max-content",
borderCollapse: "collapse",
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
name: {
fontWeight: 500,
},
url: {
marginTop: "0.125rem",
display: "block",
maxWidth: "16rem",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
fontSize: "0.75rem",
lineHeight: "1rem",
color: colors.textMuted,
},
/** Green has no token: a loaded source is the only success state in the app. */
loaded: {
color: {
default: "oklch(52.7% 0.154 150.069)",
"@media (prefers-color-scheme: dark)": "oklch(79.2% 0.209 151.711)",
},
},
failed: {
color: colors.danger,
},
absent: {
color: colors.textMuted,
},
});
export default function SourceStatusSection({ sources, namesById }: SourceStatusSectionProps) { export default function SourceStatusSection({ sources, namesById }: SourceStatusSectionProps) {
return ( return (
<section className="mt-8"> <section {...stylex.props(styles.section)}>
<h2 className="text-lg font-medium">Source status</h2> <h2 {...stylex.props(styles.heading)}>Source status</h2>
{sources === null ? ( {sources === null ? (
<p className="mt-2 text-zinc-500"> <p {...stylex.props(styles.note)}>
No status snapshot yet run Update now to fetch status for every enabled source. No status snapshot yet run Update now to fetch status for every enabled source.
</p> </p>
) : sources.length === 0 ? ( ) : sources.length === 0 ? (
<p className="mt-2 text-zinc-500">The last update ran against no enabled sources.</p> <p {...stylex.props(styles.note)}>The last update ran against no enabled sources.</p>
) : ( ) : (
<div className="mt-2 overflow-x-auto"> <div {...stylex.props(styles.tableWrap)}>
<table className="w-full min-w-max border-collapse text-sm"> <table {...stylex.props(styles.table)}>
<thead> <thead>
<tr> <tr>
<th className={thClass}>Source</th> <th {...stylex.props(shared.th)}>Source</th>
<th className={thClass}>State</th> <th {...stylex.props(shared.th)}>State</th>
<th className={thClass}>Last attempt</th> <th {...stylex.props(shared.th)}>Last attempt</th>
<th className={thClass}>Last success</th> <th {...stylex.props(shared.th)}>Last success</th>
<th className={thClass}>Domains</th> <th {...stylex.props(shared.th)}>Domains</th>
<th className={thClass}>Wildcards</th> <th {...stylex.props(shared.th)}>Wildcards</th>
<th className={thClass}>Skipped regex</th> <th {...stylex.props(shared.th)}>Skipped regex</th>
<th className={thClass}>Last error</th> <th {...stylex.props(shared.th)}>Last error</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{sources.map((source) => ( {sources.map((source) => (
<tr key={source.id}> <tr key={source.id}>
<td className={tdClass}> <td {...stylex.props(shared.td)}>
<span className="font-medium">{namesById.get(source.id) ?? source.url}</span> <span {...stylex.props(styles.name)}>
<span className="mt-0.5 block max-w-64 truncate text-xs text-zinc-500"> {namesById.get(source.id) ?? source.url}
{source.url}
</span> </span>
<span {...stylex.props(styles.url)}>{source.url}</span>
</td> </td>
<td className={tdClass}> <td {...stylex.props(shared.td)}>
<span <span {...stylex.props(source.loaded ? styles.loaded : styles.failed)}>
className={
source.loaded
? "text-green-700 dark:text-green-400"
: "text-red-600 dark:text-red-400"
}
>
{source.state} {source.state}
</span> </span>
</td> </td>
<td className={tdClass}>{formatAttempt(source.last_attempt)}</td> <td {...stylex.props(shared.td)}>{formatAttempt(source.last_attempt)}</td>
<td className={tdClass}>{formatAttempt(source.last_success)}</td> <td {...stylex.props(shared.td)}>{formatAttempt(source.last_success)}</td>
<td className={`${tdClass} tabular-nums`}>{source.domains}</td> <td {...stylex.props(shared.td, shared.tabularNums)}>{source.domains}</td>
<td className={`${tdClass} tabular-nums`}>{source.wildcards}</td> <td {...stylex.props(shared.td, shared.tabularNums)}>{source.wildcards}</td>
<td className={`${tdClass} tabular-nums`}>{source.skipped_regex}</td> <td {...stylex.props(shared.td, shared.tabularNums)}>{source.skipped_regex}</td>
<td className={tdClass}> <td {...stylex.props(shared.td)}>
{source.last_error === "" ? ( {source.last_error === "" ? (
<span className="text-zinc-400"></span> <span {...stylex.props(styles.absent)}></span>
) : ( ) : (
<span className="text-red-600 dark:text-red-400">{source.last_error}</span> <span {...stylex.props(styles.failed)}>{source.last_error}</span>
)} )}
</td> </td>
</tr> </tr>
+76 -60
View File
@@ -1,9 +1,12 @@
import { useState } from "react"; import { useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQueryClient } from "@tanstack/react-query";
import * as stylex from "@stylexjs/stylex";
import { clientUpdateMutation } from "@/lib/queries"; import { clientUpdateMutation } from "@/lib/queries";
import type { Client, Group } from "@/lib/types"; import type { Client, Group } from "@/lib/types";
import InlineError from "@/lib/InlineError"; import InlineError from "@/lib/InlineError";
import { buttonClass, primaryButtonClass, smallInputClass } from "@/ui/classes"; import Dialog from "@/ui/Dialog";
import Select from "@/ui/Select";
import { styles as shared } from "@/ui/styles";
import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority"; import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority";
interface Props { interface Props {
@@ -12,7 +15,34 @@ interface Props {
onClose: () => void; onClose: () => void;
} }
const dialogInputClass = `mt-1 w-full ${smallInputClass}`; const styles = stylex.create({
heading: {
fontSize: "1.125rem",
lineHeight: "1.75rem",
fontWeight: 600,
},
form: {
display: "flex",
flexDirection: "column",
gap: "1rem",
marginTop: "1rem",
},
fieldLabel: {
display: "block",
fontSize: "0.875rem",
lineHeight: "1.25rem",
fontWeight: 500,
},
dialogInput: {
marginTop: "0.25rem",
width: "100%",
},
actions: {
display: "flex",
justifyContent: "flex-end",
gap: "0.5rem",
},
});
export default function ClientEditDialog({ client, groups, onClose }: Props) { export default function ClientEditDialog({ client, groups, onClose }: Props) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -22,64 +52,50 @@ export default function ClientEditDialog({ client, groups, onClose }: Props) {
const readOnly = useReadOnlyConfig(); const readOnly = useReadOnlyConfig();
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"> <Dialog label={`Edit client ${client.ip}`} isOpen onClose={onClose}>
<div <h2 {...stylex.props(styles.heading)}>Edit {client.ip}</h2>
role="dialog" <form
aria-modal="true" {...stylex.props(styles.form)}
aria-label={`Edit client ${client.ip}`} onSubmit={(event) => {
className="w-full max-w-md rounded-lg border border-zinc-200 bg-white p-6 shadow-lg dark:border-zinc-700 dark:bg-zinc-900" event.preventDefault();
mutation.mutate(
{ id: client.id, edit: { name: name.trim(), group_id: groupId } },
{ onSuccess: onClose },
);
}}
> >
<h2 className="text-lg font-semibold">Edit {client.ip}</h2> <label {...stylex.props(styles.fieldLabel)}>
<form Name
className="mt-4 space-y-4" <input
onSubmit={(event) => { type="text"
event.preventDefault(); value={name}
mutation.mutate( onChange={(event) => setName(event.target.value)}
{ id: client.id, edit: { name: name.trim(), group_id: groupId } }, autoFocus
{ onSuccess: onClose }, {...stylex.props(shared.smallInput, styles.dialogInput, shared.focusRing)}
); />
}} </label>
> <Select
<label className="block text-sm font-medium"> label="Group"
Name variant="compactField"
<input value={String(groupId)}
type="text" onChange={(value) => setGroupId(Number(value))}
value={name} options={groups.map((group) => ({ value: String(group.id), label: group.name }))}
onChange={(event) => setName(event.target.value)} />
className={dialogInputClass} <InlineError error={mutation.error} />
autoFocus <div {...stylex.props(styles.actions)}>
/> <button type="button" onClick={onClose} {...stylex.props(shared.button, shared.focusRing)}>
</label> Cancel
<label className="block text-sm font-medium"> </button>
Group <button
<select type="submit"
value={String(groupId)} disabled={mutation.isPending || readOnly}
onChange={(event) => setGroupId(Number(event.target.value))} title={readOnly ? READ_ONLY_HINT : undefined}
className={dialogInputClass} {...stylex.props(shared.primaryButton, shared.focusRing)}
> >
{groups.map((group) => ( Save
<option key={group.id} value={String(group.id)}> </button>
{group.name} </div>
</option> </form>
))} </Dialog>
</select>
</label>
<InlineError error={mutation.error} />
<div className="flex justify-end gap-2">
<button type="button" onClick={onClose} className={buttonClass}>
Cancel
</button>
<button
type="submit"
disabled={mutation.isPending || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
className={primaryButtonClass}
>
Save
</button>
</div>
</form>
</div>
</div>
); );
} }
+20 -5
View File
@@ -1,4 +1,4 @@
import { fireEvent, render, screen } from "@testing-library/react"; import { fireEvent, render, screen, within } from "@testing-library/react";
import { QueryClientProvider } from "@tanstack/react-query"; import { QueryClientProvider } from "@tanstack/react-query";
import { RouterProvider, createMemoryHistory } from "@tanstack/react-router"; import { RouterProvider, createMemoryHistory } from "@tanstack/react-router";
import { AuthProvider } from "@/auth/store"; import { AuthProvider } from "@/auth/store";
@@ -106,10 +106,25 @@ test("edit opens a dialog seeded with the client's name and group", async () =>
await renderClientsPage(BASE); await renderClientsPage(BASE);
fireEvent.click(screen.getAllByRole("button", { name: "Edit" })[0]!); fireEvent.click(screen.getAllByRole("button", { name: "Edit" })[0]!);
const dialog = screen.getByRole("dialog", { name: "Edit client 192.168.1.10" }); // The dialog portals out of the table, so every field query is scoped to it.
expect(dialog).toBeTruthy(); const dialog = within(await screen.findByRole("dialog", { name: "Edit client 192.168.1.10" }));
expect((screen.getByLabelText("Name") as HTMLInputElement).value).toBe("laptop"); expect((dialog.getByLabelText("Name") as HTMLInputElement).value).toBe("laptop");
expect((screen.getByLabelText("Group") as HTMLSelectElement).value).toBe("1"); // The RAC Select names its trigger with the value and then the label.
expect(dialog.getByRole("button", { name: /Group$/ }).textContent).toContain("default");
});
test("the group picker offers every group and reports the choice", async () => {
await renderClientsPage(BASE);
fireEvent.click(screen.getAllByRole("button", { name: "Edit" })[0]!);
const dialog = within(await screen.findByRole("dialog", { name: "Edit client 192.168.1.10" }));
fireEvent.click(dialog.getByRole("button", { name: /Group$/ }));
const options = await screen.findAllByRole("option");
expect(options.map((option) => option.textContent)).toEqual(["default", "kids"]);
fireEvent.click(screen.getByRole("option", { name: "kids" }));
expect(screen.getByRole("button", { name: /Group$/ }).textContent).toContain("kids");
}); });
test("prefix editor starts clean and dirties on add", async () => { test("prefix editor starts clean and dirties on add", async () => {
+124 -34
View File
@@ -1,16 +1,16 @@
import { useState } from "react"; import { useState } from "react";
import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query"; import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query";
import * as stylex from "@stylexjs/stylex";
import { clientDeleteMutation, clientPrefixesQuery, clientsQuery, groupsQuery } from "@/lib/queries"; import { clientDeleteMutation, clientPrefixesQuery, clientsQuery, groupsQuery } from "@/lib/queries";
import { formatTime } from "@/lib/format"; import { formatTime } from "@/lib/format";
import type { Client } from "@/lib/types"; import type { Client } from "@/lib/types";
import ClientEditDialog from "./ClientEditDialog"; import ClientEditDialog from "./ClientEditDialog";
import PrefixesEditor from "./PrefixesEditor"; import PrefixesEditor from "./PrefixesEditor";
import InlineError from "@/lib/InlineError"; import InlineError from "@/lib/InlineError";
import { smallButtonClass, tableWrapClass } from "@/ui/classes"; import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority"; import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority";
const cellClass = "px-3 py-2";
/** /**
* Deleting an observed row discards runtime state the file never declared, so * Deleting an observed row discards runtime state the file never declared, so
* it stays live under file authority; deleting a hand-edited row contradicts * it stays live under file authority; deleting a hand-edited row contradicts
@@ -18,6 +18,83 @@ const cellClass = "px-3 py-2";
*/ */
const DECLARED_CLIENT_NOTE = "This client is declared in the configuration file; remove it there and restart."; const DECLARED_CLIENT_NOTE = "This client is declared in the configuration file; remove it there and restart.";
const styles = stylex.create({
heading: {
fontSize: "1.5rem",
lineHeight: "2rem",
fontWeight: 600,
},
empty: {
marginTop: "1rem",
color: colors.textMuted,
},
table: {
width: "100%",
minWidth: "48rem",
textAlign: "left",
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
headRow: {
borderBottomWidth: 1,
borderBottomStyle: "solid",
borderBottomColor: colors.border,
color: colors.textMuted,
},
bodyRow: {
borderBottomWidth: 1,
borderBottomStyle: "solid",
borderBottomColor: colors.border,
},
cell: {
paddingInline: "0.75rem",
paddingBlock: "0.5rem",
},
right: {
textAlign: "right",
},
dash: {
color: colors.textMuted,
},
badge: {
marginLeft: "0.5rem",
borderRadius: "0.25rem",
backgroundColor: colors.primary,
paddingInline: "0.375rem",
paddingBlock: "0.125rem",
fontSize: "0.75rem",
lineHeight: "1rem",
fontWeight: 500,
color: colors.primaryText,
},
confirmGroup: {
display: "inline-flex",
flexWrap: "wrap",
alignItems: "center",
justifyContent: "flex-end",
gap: "0.5rem",
},
actionGroup: {
display: "inline-flex",
gap: "0.5rem",
},
note: {
fontSize: "0.75rem",
lineHeight: "1rem",
color: colors.textMuted,
},
dangerText: {
color: colors.danger,
},
dimWhenDisabled: {
opacity: { default: 1, ":disabled": 0.5 },
},
/**
* The accessible name of the actions column, kept out of the visual table
* without leaving the accessibility tree.
*/
});
export default function ClientsPage() { export default function ClientsPage() {
const { data: clients } = useSuspenseQuery(clientsQuery()); const { data: clients } = useSuspenseQuery(clientsQuery());
const { data: prefixes } = useSuspenseQuery(clientPrefixesQuery()); const { data: prefixes } = useSuspenseQuery(clientPrefixesQuery());
@@ -30,46 +107,46 @@ export default function ClientsPage() {
return ( return (
<section> <section>
<h1 className="text-2xl font-semibold">Clients</h1> <h1 {...stylex.props(styles.heading)}>Clients</h1>
{clients.length === 0 ? ( {clients.length === 0 ? (
<p className="mt-4 text-zinc-500"> <p {...stylex.props(styles.empty)}>
No clients yet. Rows appear automatically as devices on the network make DNS queries there is No clients yet. Rows appear automatically as devices on the network make DNS queries there is
nothing to create by hand. nothing to create by hand.
</p> </p>
) : ( ) : (
<div className={tableWrapClass}> <div {...stylex.props(shared.tableWrap)}>
<table className="w-full min-w-[48rem] text-left text-sm"> <table {...stylex.props(styles.table)}>
<thead> <thead>
<tr className="border-b border-zinc-200 text-zinc-500 dark:border-zinc-700"> <tr {...stylex.props(styles.headRow)}>
<th className={cellClass}>IP</th> <th {...stylex.props(styles.cell)}>IP</th>
<th className={cellClass}>Name</th> <th {...stylex.props(styles.cell)}>Name</th>
<th className={cellClass}>Group</th> <th {...stylex.props(styles.cell)}>Group</th>
<th className={cellClass}>First seen</th> <th {...stylex.props(styles.cell)}>First seen</th>
<th className={cellClass}>Last seen</th> <th {...stylex.props(styles.cell)}>Last seen</th>
<th className={cellClass}> <th {...stylex.props(styles.cell)}>
<span className="sr-only">Actions</span> <span {...stylex.props(shared.srOnly)}>Actions</span>
</th> </th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{clients.map((client) => ( {clients.map((client) => (
<tr key={client.id} className="border-b border-zinc-100 dark:border-zinc-800"> <tr key={client.id} {...stylex.props(styles.bodyRow)}>
<td className={`${cellClass} font-mono`}>{client.ip}</td> <td {...stylex.props(styles.cell, shared.mono)}>{client.ip}</td>
<td className={cellClass}> <td {...stylex.props(styles.cell)}>
{client.name === "" ? <span className="text-zinc-400"></span> : client.name} {client.name === "" ? (
{client.hand_edited && ( <span {...stylex.props(styles.dash)}></span>
<span className="ml-2 rounded bg-blue-100 px-1.5 py-0.5 text-xs font-medium text-blue-800 dark:bg-blue-900 dark:text-blue-200"> ) : (
edited client.name
</span>
)} )}
{client.hand_edited && <span {...stylex.props(styles.badge)}>edited</span>}
</td> </td>
<td className={cellClass}>{client.group}</td> <td {...stylex.props(styles.cell)}>{client.group}</td>
<td className={cellClass}>{formatTime(client.first_seen)}</td> <td {...stylex.props(styles.cell)}>{formatTime(client.first_seen)}</td>
<td className={cellClass}>{formatTime(client.last_seen)}</td> <td {...stylex.props(styles.cell)}>{formatTime(client.last_seen)}</td>
<td className={`${cellClass} text-right`}> <td {...stylex.props(styles.cell, styles.right)}>
{confirmingId === client.id ? ( {confirmingId === client.id ? (
<span className="inline-flex flex-wrap items-center justify-end gap-2"> <span {...stylex.props(styles.confirmGroup)}>
<span className="text-xs text-zinc-500"> <span {...stylex.props(styles.note)}>
Deleted clients re-materialize on their next DNS query. Deleted clients re-materialize on their next DNS query.
</span> </span>
<button <button
@@ -78,26 +155,34 @@ export default function ClientsPage() {
setConfirmingId(null); setConfirmingId(null);
deleteMutation.mutate(client.id); deleteMutation.mutate(client.id);
}} }}
className={`${smallButtonClass} text-red-700 dark:text-red-400`} {...stylex.props(
shared.smallButton,
styles.dangerText,
shared.focusRing,
)}
> >
Confirm delete Confirm delete
</button> </button>
<button <button
type="button" type="button"
onClick={() => setConfirmingId(null)} onClick={() => setConfirmingId(null)}
className={smallButtonClass} {...stylex.props(shared.smallButton, shared.focusRing)}
> >
Cancel Cancel
</button> </button>
</span> </span>
) : ( ) : (
<span className="inline-flex gap-2"> <span {...stylex.props(styles.actionGroup)}>
<button <button
type="button" type="button"
onClick={() => setEditing(client)} onClick={() => setEditing(client)}
disabled={readOnly} disabled={readOnly}
title={readOnly ? READ_ONLY_HINT : undefined} title={readOnly ? READ_ONLY_HINT : undefined}
className={`${smallButtonClass} disabled:opacity-50`} {...stylex.props(
shared.smallButton,
styles.dimWhenDisabled,
shared.focusRing,
)}
> >
Edit Edit
</button> </button>
@@ -110,7 +195,12 @@ export default function ClientsPage() {
? DECLARED_CLIENT_NOTE ? DECLARED_CLIENT_NOTE
: undefined : undefined
} }
className={`${smallButtonClass} text-red-700 disabled:opacity-50 dark:text-red-400`} {...stylex.props(
shared.smallButton,
styles.dangerText,
styles.dimWhenDisabled,
shared.focusRing,
)}
> >
Delete Delete
</button> </button>
+91 -26
View File
@@ -1,11 +1,14 @@
import { useReducer, useState } from "react"; import { useReducer, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQueryClient } from "@tanstack/react-query";
import * as stylex from "@stylexjs/stylex";
import { clientPrefixesPutMutation } from "@/lib/queries"; import { clientPrefixesPutMutation } from "@/lib/queries";
import type { ClientPrefix, Group } from "@/lib/types"; import type { ClientPrefix, Group } from "@/lib/types";
import { defaultGroupId } from "@/lib/defaultGroup"; import { defaultGroupId } from "@/lib/defaultGroup";
import { firstProblem, initPrefixEditor, isDirty, prefixEditorReducer, toInputs } from "./prefixEditor"; import { firstProblem, initPrefixEditor, isDirty, prefixEditorReducer, toInputs } from "./prefixEditor";
import InlineError from "@/lib/InlineError"; import InlineError from "@/lib/InlineError";
import { buttonClass, focusRing, primaryButtonClass, smallInputClass } from "@/ui/classes"; import Select from "@/ui/Select";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority"; import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority";
interface Props { interface Props {
@@ -13,6 +16,72 @@ interface Props {
groups: Group[]; groups: Group[];
} }
const styles = stylex.create({
section: {
marginTop: "2.5rem",
},
heading: {
fontSize: "1.25rem",
lineHeight: "1.75rem",
fontWeight: 600,
},
intro: {
marginTop: "0.25rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.textMuted,
},
empty: {
marginTop: "1rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.textMuted,
},
rows: {
display: "flex",
flexDirection: "column",
gap: "0.5rem",
marginTop: "1rem",
listStyleType: "none",
padding: 0,
},
row: {
display: "flex",
flexWrap: "wrap",
alignItems: "center",
gap: "0.5rem",
},
prefixInput: {
width: "13rem",
},
priorityInput: {
width: "5rem",
},
removeButton: {
borderRadius: "0.25rem",
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.borderStrong,
backgroundColor: "transparent",
paddingInline: "0.5rem",
paddingBlock: "0.375rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.danger,
},
validation: {
marginTop: "0.5rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.danger,
},
actions: {
display: "flex",
gap: "0.5rem",
marginTop: "1rem",
},
});
export default function PrefixesEditor({ prefixes, groups }: Props) { export default function PrefixesEditor({ prefixes, groups }: Props) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const mutation = useMutation(clientPrefixesPutMutation(queryClient)); const mutation = useMutation(clientPrefixesPutMutation(queryClient));
@@ -21,6 +90,7 @@ export default function PrefixesEditor({ prefixes, groups }: Props) {
const dirty = isDirty(state); const dirty = isDirty(state);
const fallbackGroupId = defaultGroupId(groups); const fallbackGroupId = defaultGroupId(groups);
const readOnly = useReadOnlyConfig(); const readOnly = useReadOnlyConfig();
const groupOptions = groups.map((group) => ({ value: String(group.id), label: group.name }));
const save = () => { const save = () => {
const problem = firstProblem(state.rows); const problem = firstProblem(state.rows);
@@ -32,18 +102,18 @@ export default function PrefixesEditor({ prefixes, groups }: Props) {
}; };
return ( return (
<section className="mt-10"> <section {...stylex.props(styles.section)}>
<h2 className="text-xl font-semibold">Client prefixes</h2> <h2 {...stylex.props(styles.heading)}>Client prefixes</h2>
<p className="mt-1 text-sm text-zinc-500"> <p {...stylex.props(styles.intro)}>
Prefixes assign a group to whole address ranges. The list is saved as a whole; the highest priority Prefixes assign a group to whole address ranges. The list is saved as a whole; the highest priority
match wins. match wins.
</p> </p>
{state.rows.length === 0 ? ( {state.rows.length === 0 ? (
<p className="mt-4 text-sm text-zinc-500">No prefixes configured.</p> <p {...stylex.props(styles.empty)}>No prefixes configured.</p>
) : ( ) : (
<ul className="mt-4 space-y-2"> <ul {...stylex.props(styles.rows)}>
{state.rows.map((row, index) => ( {state.rows.map((row, index) => (
<li key={index} className="flex flex-wrap items-center gap-2"> <li key={index} {...stylex.props(styles.row)}>
<input <input
type="text" type="text"
aria-label={`Prefix ${index + 1}`} aria-label={`Prefix ${index + 1}`}
@@ -52,22 +122,17 @@ export default function PrefixesEditor({ prefixes, groups }: Props) {
onChange={(event) => onChange={(event) =>
dispatch({ type: "edit", index, patch: { prefix: event.target.value } }) dispatch({ type: "edit", index, patch: { prefix: event.target.value } })
} }
className={`${smallInputClass} w-52`} {...stylex.props(shared.smallInput, styles.prefixInput, shared.focusRing)}
/> />
<select <Select
aria-label={`Group for prefix ${index + 1}`} aria-label={`Group for prefix ${index + 1}`}
variant="inline"
value={String(row.group_id)} value={String(row.group_id)}
onChange={(event) => onChange={(value) =>
dispatch({ type: "edit", index, patch: { group_id: Number(event.target.value) } }) dispatch({ type: "edit", index, patch: { group_id: Number(value) } })
} }
className={smallInputClass} options={groupOptions}
> />
{groups.map((group) => (
<option key={group.id} value={String(group.id)}>
{group.name}
</option>
))}
</select>
<input <input
type="text" type="text"
inputMode="numeric" inputMode="numeric"
@@ -77,12 +142,12 @@ export default function PrefixesEditor({ prefixes, groups }: Props) {
onChange={(event) => onChange={(event) =>
dispatch({ type: "edit", index, patch: { priority: event.target.value } }) dispatch({ type: "edit", index, patch: { priority: event.target.value } })
} }
className={`${smallInputClass} w-20`} {...stylex.props(shared.smallInput, styles.priorityInput, shared.focusRing)}
/> />
<button <button
type="button" type="button"
onClick={() => dispatch({ type: "remove", index })} onClick={() => dispatch({ type: "remove", index })}
className={`rounded border border-zinc-300 px-2 py-1.5 text-sm text-red-700 ${focusRing} dark:border-zinc-700 dark:text-red-400`} {...stylex.props(styles.removeButton, shared.focusRing)}
> >
Remove Remove
</button> </button>
@@ -91,16 +156,16 @@ export default function PrefixesEditor({ prefixes, groups }: Props) {
</ul> </ul>
)} )}
{validation !== null && ( {validation !== null && (
<p role="alert" className="mt-2 text-sm text-red-700 dark:text-red-400"> <p role="alert" {...stylex.props(styles.validation)}>
{validation} {validation}
</p> </p>
)} )}
<InlineError error={mutation.error} /> <InlineError error={mutation.error} />
<div className="mt-4 flex gap-2"> <div {...stylex.props(styles.actions)}>
<button <button
type="button" type="button"
onClick={() => dispatch({ type: "add", groupId: fallbackGroupId })} onClick={() => dispatch({ type: "add", groupId: fallbackGroupId })}
className={buttonClass} {...stylex.props(shared.button, shared.focusRing)}
> >
Add prefix Add prefix
</button> </button>
@@ -109,7 +174,7 @@ export default function PrefixesEditor({ prefixes, groups }: Props) {
onClick={save} onClick={save}
disabled={!dirty || mutation.isPending || readOnly} disabled={!dirty || mutation.isPending || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined} title={readOnly ? READ_ONLY_HINT : undefined}
className={primaryButtonClass} {...stylex.props(shared.primaryButton, shared.focusRing)}
> >
Save prefixes Save prefixes
</button> </button>
@@ -120,7 +185,7 @@ export default function PrefixesEditor({ prefixes, groups }: Props) {
setValidation(null); setValidation(null);
dispatch({ type: "reset", prefixes }); dispatch({ type: "reset", prefixes });
}} }}
className={buttonClass} {...stylex.props(shared.button, shared.focusRing)}
> >
Discard changes Discard changes
</button> </button>
+94 -14
View File
@@ -1,9 +1,11 @@
import { useState } from "react"; import { useState } from "react";
import { keepPreviousData, useQuery } from "@tanstack/react-query"; import { keepPreviousData, useQuery } from "@tanstack/react-query";
import * as stylex from "@stylexjs/stylex";
import { healthQuery, statsQuery, timeseriesQuery, upstreamHealthQuery } from "@/lib/queries"; import { healthQuery, statsQuery, timeseriesQuery, upstreamHealthQuery } from "@/lib/queries";
import type { Period } from "@/lib/types"; import type { Period } from "@/lib/types";
import InlineError from "@/lib/InlineError"; import InlineError from "@/lib/InlineError";
import { focusRing } from "@/ui/classes"; import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
import DiskCard from "./DiskCard"; import DiskCard from "./DiskCard";
import HealthBanners from "./HealthBanners"; import HealthBanners from "./HealthBanners";
import StatCards from "./StatCards"; import StatCards from "./StatCards";
@@ -12,20 +14,98 @@ import UpstreamHealthTable from "./UpstreamHealthTable";
const PERIODS: Period[] = ["1h", "24h", "7d", "30d"]; const PERIODS: Period[] = ["1h", "24h", "7d", "30d"];
const styles = stylex.create({
page: {
display: "flex",
flexDirection: "column",
gap: "1rem",
},
titleRow: {
display: "flex",
flexWrap: "wrap",
alignItems: "center",
justifyContent: "space-between",
gap: "0.75rem",
},
heading: {
fontSize: "1.5rem",
lineHeight: "2rem",
fontWeight: 600,
},
periodGroup: {
display: "flex",
gap: "0.25rem",
},
period: {
borderStyle: "none",
borderRadius: "0.25rem",
paddingInline: "0.625rem",
paddingBlock: "0.25rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
/** The pressed fill is heavier than `surfaceHover`, so a hover cannot mimic it. */
periodSelected: {
backgroundColor: {
default: "oklch(92% 0.004 286.32)",
"@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,
},
/** Dynamic: the caller sizes the placeholder to the widget it stands in for. */
skeletonHeight: (height: number) => ({ height }),
skeleton: {
borderRadius: "0.25rem",
backgroundColor: {
default: "oklch(92% 0.004 286.32)",
"@media (prefers-color-scheme: dark)": "oklch(27.4% 0.006 286.033)",
},
},
/** The chart takes two thirds beside the disk card from `lg`, one column below. */
panelGrid: {
display: "grid",
gap: "1rem",
gridTemplateColumns: {
default: "repeat(1, minmax(0, 1fr))",
"@media (min-width: 1024px)": "2fr 1fr",
},
},
panel: {
borderRadius: "0.25rem",
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.border,
backgroundColor: colors.surfaceRaised,
paddingInline: "1rem",
paddingBlock: "0.75rem",
},
panelHeading: {
marginBottom: "0.75rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
fontWeight: 600,
},
});
function PeriodPicker({ period, onChange }: { period: Period; onChange: (period: Period) => void }) { function PeriodPicker({ period, onChange }: { period: Period; onChange: (period: Period) => void }) {
return ( return (
<div role="group" aria-label="Period" className="flex gap-1"> <div role="group" aria-label="Period" {...stylex.props(styles.periodGroup)}>
{PERIODS.map((option) => ( {PERIODS.map((option) => (
<button <button
key={option} key={option}
type="button" type="button"
aria-pressed={option === period} aria-pressed={option === period}
onClick={() => onChange(option)} onClick={() => onChange(option)}
className={`rounded px-2.5 py-1 text-sm ${focusRing} ${ {...stylex.props(
option === period styles.period,
? "bg-zinc-200 font-medium text-zinc-900 dark:bg-zinc-700 dark:text-zinc-50" option === period ? styles.periodSelected : styles.periodIdle,
: "text-zinc-600 hover:bg-zinc-100 dark:text-zinc-400 dark:hover:bg-zinc-800" shared.focusRing,
}`} )}
> >
{option} {option}
</button> </button>
@@ -35,7 +115,7 @@ function PeriodPicker({ period, onChange }: { period: Period; onChange: (period:
} }
function Skeleton({ height }: { height: number }) { function Skeleton({ height }: { height: number }) {
return <div aria-hidden="true" className="animate-pulse rounded bg-zinc-200 dark:bg-zinc-800" style={{ height }} />; return <div aria-hidden="true" {...stylex.props(styles.skeleton, styles.skeletonHeight(height), shared.pulse)} />;
} }
export default function DashboardPage() { export default function DashboardPage() {
@@ -46,9 +126,9 @@ export default function DashboardPage() {
const upstreamHealth = useQuery(upstreamHealthQuery()); const upstreamHealth = useQuery(upstreamHealthQuery());
return ( return (
<section className="space-y-4"> <section {...stylex.props(styles.page)}>
<div className="flex flex-wrap items-center justify-between gap-3"> <div {...stylex.props(styles.titleRow)}>
<h1 className="text-2xl font-semibold">Dashboard</h1> <h1 {...stylex.props(styles.heading)}>Dashboard</h1>
<PeriodPicker period={period} onChange={setPeriod} /> <PeriodPicker period={period} onChange={setPeriod} />
</div> </div>
@@ -62,9 +142,9 @@ export default function DashboardPage() {
<StatCards stats={stats.data} /> <StatCards stats={stats.data} />
)} )}
<div className="grid gap-4 lg:grid-cols-[2fr_1fr]"> <div {...stylex.props(styles.panelGrid)}>
<section className="rounded border border-zinc-200 bg-white px-4 py-3 dark:border-zinc-800 dark:bg-zinc-900"> <section {...stylex.props(styles.panel)}>
<h2 className="mb-3 text-sm font-semibold">Queries over time</h2> <h2 {...stylex.props(styles.panelHeading)}>Queries over time</h2>
{timeseries.isError ? ( {timeseries.isError ? (
<InlineError error={timeseries.error} onRetry={() => void timeseries.refetch()} /> <InlineError error={timeseries.error} onRetry={() => void timeseries.refetch()} />
) : timeseries.data === undefined ? ( ) : timeseries.data === undefined ? (
+82 -20
View File
@@ -1,33 +1,95 @@
import * as stylex from "@stylexjs/stylex";
import { formatBytes } from "@/lib/format"; import { formatBytes } from "@/lib/format";
import type { Health } from "@/lib/types"; import type { Health } from "@/lib/types";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
const STATE_CLASSES: Record<Health["disk"]["state"], string> = { const DARK = "@media (prefers-color-scheme: dark)";
ok: "bg-emerald-100 text-emerald-800 dark:bg-emerald-950 dark:text-emerald-300",
warn: "bg-amber-100 text-amber-800 dark:bg-amber-950 dark:text-amber-300", const styles = stylex.create({
critical: "bg-red-100 text-red-800 dark:bg-red-950 dark:text-red-300", card: {
}; borderRadius: "0.25rem",
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.border,
backgroundColor: colors.surfaceRaised,
paddingInline: "1rem",
paddingBlock: "0.75rem",
},
heading: {
display: "flex",
alignItems: "center",
justifyContent: "space-between",
fontSize: "0.875rem",
lineHeight: "1.25rem",
fontWeight: 600,
},
badge: {
borderRadius: "0.25rem",
paddingInline: "0.5rem",
paddingBlock: "0.125rem",
fontSize: "0.75rem",
lineHeight: "1rem",
fontWeight: 500,
},
/**
* The badge fills are their own three-step scale, not the `danger`/`warn`
* banner tokens: they read as a tinted chip against a raised card, where a
* banner fill would be too heavy.
*/
ok: {
backgroundColor: { default: "oklch(95% 0.052 163.051)", [DARK]: "oklch(26.2% 0.051 172.552)" },
color: { default: "oklch(43.2% 0.095 166.913)", [DARK]: "oklch(84.5% 0.143 164.978)" },
},
warn: {
backgroundColor: { default: "oklch(96.2% 0.059 95.617)", [DARK]: "oklch(27.9% 0.077 45.635)" },
color: { default: "oklch(47.3% 0.137 46.201)", [DARK]: "oklch(87.9% 0.169 91.605)" },
},
critical: {
backgroundColor: { default: "oklch(93.6% 0.032 17.717)", [DARK]: "oklch(25.8% 0.092 26.042)" },
color: { default: "oklch(44.4% 0.177 26.899)", [DARK]: "oklch(80.8% 0.114 19.571)" },
},
list: {
display: "flex",
flexDirection: "column",
gap: "0.5rem",
marginTop: "0.75rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
row: {
display: "flex",
justifyContent: "space-between",
},
term: {
color: colors.textMuted,
},
});
function stateStyle(state: Health["disk"]["state"]) {
if (state === "critical") return styles.critical;
return state === "warn" ? styles.warn : styles.ok;
}
export default function DiskCard({ disk }: { disk: Health["disk"] }) { export default function DiskCard({ disk }: { disk: Health["disk"] }) {
return ( return (
<section className="rounded border border-zinc-200 bg-white px-4 py-3 dark:border-zinc-800 dark:bg-zinc-900"> <section {...stylex.props(styles.card)}>
<h2 className="flex items-center justify-between text-sm font-semibold"> <h2 {...stylex.props(styles.heading)}>
Disk Disk
<span className={`rounded px-2 py-0.5 text-xs font-medium ${STATE_CLASSES[disk.state]}`}> <span {...stylex.props(styles.badge, stateStyle(disk.state))}>{disk.state}</span>
{disk.state}
</span>
</h2> </h2>
<dl className="mt-3 space-y-2 text-sm"> <dl {...stylex.props(styles.list)}>
<div className="flex justify-between"> <div {...stylex.props(styles.row)}>
<dt className="text-zinc-500">Free</dt> <dt {...stylex.props(styles.term)}>Free</dt>
<dd className="tabular-nums">{formatBytes(disk.free_bytes)}</dd> <dd {...stylex.props(shared.tabularNums)}>{formatBytes(disk.free_bytes)}</dd>
</div> </div>
<div className="flex justify-between"> <div {...stylex.props(styles.row)}>
<dt className="text-zinc-500">Database</dt> <dt {...stylex.props(styles.term)}>Database</dt>
<dd className="tabular-nums">{formatBytes(disk.db_bytes)}</dd> <dd {...stylex.props(shared.tabularNums)}>{formatBytes(disk.db_bytes)}</dd>
</div> </div>
<div className="flex justify-between"> <div {...stylex.props(styles.row)}>
<dt className="text-zinc-500">Logs</dt> <dt {...stylex.props(styles.term)}>Logs</dt>
<dd className="tabular-nums">{formatBytes(disk.log_bytes)}</dd> <dd {...stylex.props(shared.tabularNums)}>{formatBytes(disk.log_bytes)}</dd>
</div> </div>
</dl> </dl>
</section> </section>
+31 -6
View File
@@ -1,13 +1,38 @@
import * as stylex from "@stylexjs/stylex";
import { formatBytes } from "@/lib/format"; import { formatBytes } from "@/lib/format";
import type { Health } from "@/lib/types"; import type { Health } from "@/lib/types";
import { colors } from "@/ui/tokens.stylex";
const styles = stylex.create({
stack: {
display: "flex",
flexDirection: "column",
gap: "0.5rem",
},
banner: {
borderRadius: "0.25rem",
borderWidth: 1,
borderStyle: "solid",
paddingInline: "1rem",
paddingBlock: "0.5rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
warn: {
borderColor: colors.warnBorder,
backgroundColor: colors.warnSurface,
color: colors.warnText,
},
critical: {
borderColor: colors.dangerBorder,
backgroundColor: colors.dangerSurface,
color: colors.dangerText,
},
});
function Banner({ tone, children }: { tone: "warn" | "critical"; children: React.ReactNode }) { function Banner({ tone, children }: { tone: "warn" | "critical"; children: React.ReactNode }) {
const classes =
tone === "critical"
? "border-red-300 bg-red-50 text-red-900 dark:border-red-900 dark:bg-red-950 dark:text-red-200"
: "border-amber-300 bg-amber-50 text-amber-900 dark:border-amber-900 dark:bg-amber-950 dark:text-amber-200";
return ( return (
<p role="alert" className={`rounded border px-4 py-2 text-sm ${classes}`}> <p role="alert" {...stylex.props(styles.banner, tone === "critical" ? styles.critical : styles.warn)}>
{children} {children}
</p> </p>
); );
@@ -39,5 +64,5 @@ export default function HealthBanners({ health }: { health: Health }) {
); );
} }
if (banners.length === 0) return null; if (banners.length === 0) return null;
return <div className="space-y-2">{banners}</div>; return <div {...stylex.props(styles.stack)}>{banners}</div>;
} }
+46 -5
View File
@@ -1,8 +1,49 @@
import * as stylex from "@stylexjs/stylex";
import { formatMicros } from "@/lib/format"; import { formatMicros } from "@/lib/format";
import type { StatsTotals } from "@/lib/types"; import type { StatsTotals } from "@/lib/types";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
const numberFormat = new Intl.NumberFormat(); const numberFormat = new Intl.NumberFormat();
const styles = stylex.create({
/** Two columns on a phone, three from `md`, five from `xl`, as before. */
grid: {
display: "grid",
gap: "0.75rem",
gridTemplateColumns: {
default: "repeat(2, minmax(0, 1fr))",
"@media (min-width: 768px)": "repeat(3, minmax(0, 1fr))",
"@media (min-width: 1280px)": "repeat(5, minmax(0, 1fr))",
},
},
card: {
borderRadius: "0.25rem",
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.border,
backgroundColor: colors.surfaceRaised,
paddingInline: "1rem",
paddingBlock: "0.75rem",
},
label: {
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.textMuted,
},
value: {
fontSize: "1.5rem",
lineHeight: "2rem",
fontWeight: 600,
},
detail: {
marginLeft: "0.5rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.textMuted,
},
});
function percentOf(part: number, total: number): string | null { function percentOf(part: number, total: number): string | null {
if (total === 0) return null; if (total === 0) return null;
return `${((part / total) * 100).toFixed(1)}%`; return `${((part / total) * 100).toFixed(1)}%`;
@@ -10,11 +51,11 @@ function percentOf(part: number, total: number): string | null {
function Card({ label, value, detail }: { label: string; value: string; detail?: string | null }) { function Card({ label, value, detail }: { label: string; value: string; detail?: string | null }) {
return ( return (
<div className="rounded border border-zinc-200 bg-white px-4 py-3 dark:border-zinc-800 dark:bg-zinc-900"> <div {...stylex.props(styles.card)}>
<dt className="text-sm text-zinc-500">{label}</dt> <dt {...stylex.props(styles.label)}>{label}</dt>
<dd> <dd>
<span className="text-2xl font-semibold tabular-nums">{value}</span> <span {...stylex.props(styles.value, shared.tabularNums)}>{value}</span>
{detail != null && <span className="ml-2 text-sm text-zinc-500 tabular-nums">{detail}</span>} {detail != null && <span {...stylex.props(styles.detail, shared.tabularNums)}>{detail}</span>}
</dd> </dd>
</div> </div>
); );
@@ -22,7 +63,7 @@ function Card({ label, value, detail }: { label: string; value: string; detail?:
export default function StatCards({ stats }: { stats: StatsTotals }) { export default function StatCards({ stats }: { stats: StatsTotals }) {
return ( return (
<dl className="grid grid-cols-2 gap-3 md:grid-cols-3 xl:grid-cols-5"> <dl {...stylex.props(styles.grid)}>
<Card label="Queries" value={numberFormat.format(stats.queries)} /> <Card label="Queries" value={numberFormat.format(stats.queries)} />
<Card <Card
label="Blocked" label="Blocked"
+131 -30
View File
@@ -1,6 +1,9 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import * as stylex from "@stylexjs/stylex";
import { formatTime } from "@/lib/format"; import { formatTime } from "@/lib/format";
import type { StatsTimeseries } from "@/lib/types"; import type { StatsTimeseries } from "@/lib/types";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
import { isEmptyTimeseries, layoutTimeseries, type BarLayout } from "./chartLayout"; import { isEmptyTimeseries, layoutTimeseries, type BarLayout } from "./chartLayout";
// Series colors validated for CVD separation and 3:1 surface contrast in both // Series colors validated for CVD separation and 3:1 surface contrast in both
@@ -14,6 +17,108 @@ const SERIES = [
const CHART_HEIGHT = 240; const CHART_HEIGHT = 240;
const FALLBACK_WIDTH = 640; const FALLBACK_WIDTH = 640;
const styles = stylex.create({
empty: {
display: "flex",
alignItems: "center",
justifyContent: "center",
height: CHART_HEIGHT,
borderRadius: "0.25rem",
borderWidth: 1,
borderStyle: "dashed",
borderColor: colors.borderStrong,
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.textMuted,
},
chartRoot: {
position: "relative",
},
tooltip: {
pointerEvents: "none",
position: "absolute",
top: "0.5rem",
zIndex: 10,
borderRadius: "0.25rem",
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.borderStrong,
backgroundColor: colors.surfaceRaised,
paddingInline: "0.75rem",
paddingBlock: "0.5rem",
fontSize: "0.75rem",
lineHeight: "1rem",
boxShadow: "0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)",
},
/** Dynamic: the tooltip flips to whichever side of the bar has room. */
tooltipLeft: (left: number) => ({ left, right: null }),
tooltipRight: (right: number) => ({ left: null, right }),
tooltipTitle: {
fontWeight: 500,
},
tooltipList: {
display: "flex",
flexDirection: "column",
gap: "0.125rem",
marginTop: "0.25rem",
},
tooltipRow: {
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: "1rem",
},
tooltipTerm: {
display: "flex",
alignItems: "center",
gap: "0.375rem",
color: colors.textMuted,
},
swatch: {
display: "inline-block",
borderRadius: "0.125rem",
},
/** Dynamic: the swatch takes the series colour the SVG bars are drawn in. */
swatchColor: (color: string) => ({ backgroundColor: color }),
swatchSmall: {
width: "0.5rem",
height: "0.5rem",
},
swatchLarge: {
width: "0.625rem",
height: "0.625rem",
},
gridLine: {
stroke: colors.border,
},
axisLine: {
stroke: colors.borderStrong,
},
axisLabel: {
fill: colors.textMuted,
fontSize: "10px",
},
/** The hairline separating touching segments is the page ground, not a colour. */
segment: {
stroke: colors.surface,
},
legend: {
marginTop: "0.5rem",
display: "flex",
flexWrap: "wrap",
columnGap: "1rem",
rowGap: "0.25rem",
fontSize: "0.75rem",
lineHeight: "1rem",
color: colors.textSecondary,
},
legendItem: {
display: "flex",
alignItems: "center",
gap: "0.375rem",
},
});
function useContainerWidth(): [React.RefObject<HTMLDivElement | null>, number] { function useContainerWidth(): [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);
@@ -46,28 +151,29 @@ function barSummary(bar: BarLayout): string {
function Tooltip({ bar, chartWidth }: { bar: BarLayout; chartWidth: number }) { function Tooltip({ bar, chartWidth }: { bar: BarLayout; chartWidth: number }) {
const centerX = bar.slot.x + bar.slot.width / 2; const centerX = bar.slot.x + bar.slot.width / 2;
const leftHalf = centerX < chartWidth / 2; const leftHalf = centerX < chartWidth / 2;
const side = leftHalf
? styles.tooltipLeft(Math.min(centerX + 8, chartWidth - 160))
: styles.tooltipRight(chartWidth - centerX + 8);
return ( return (
<div <div {...stylex.props(styles.tooltip, side)}>
className="pointer-events-none absolute top-2 z-10 rounded border border-zinc-200 bg-white px-3 py-2 text-xs shadow-sm dark:border-zinc-700 dark:bg-zinc-900" <div {...stylex.props(styles.tooltipTitle)}>{formatTime(bar.bucket.ts)}</div>
style={leftHalf ? { left: Math.min(centerX + 8, chartWidth - 160) } : { right: chartWidth - centerX + 8 }} <dl {...stylex.props(styles.tooltipList)}>
> <div {...stylex.props(styles.tooltipRow)}>
<div className="font-medium">{formatTime(bar.bucket.ts)}</div> <dt {...stylex.props(styles.tooltipTerm)}>Queries</dt>
<dl className="mt-1 space-y-0.5"> <dd {...stylex.props(shared.tabularNums)}>{bar.bucket.queries}</dd>
<div className="flex justify-between gap-4">
<dt className="text-zinc-500">Queries</dt>
<dd className="tabular-nums">{bar.bucket.queries}</dd>
</div> </div>
{SERIES.map((series) => ( {SERIES.map((series) => (
<div key={series.key} className="flex items-center justify-between gap-4"> <div key={series.key} {...stylex.props(styles.tooltipRow)}>
<dt className="flex items-center gap-1.5 text-zinc-500"> <dt {...stylex.props(styles.tooltipTerm)}>
<span <span
aria-hidden="true" aria-hidden="true"
className="inline-block size-2 rounded-xs" {...stylex.props(styles.swatch, styles.swatchSmall, styles.swatchColor(series.color))}
style={{ backgroundColor: series.color }}
/> />
{series.label} {series.label}
</dt> </dt>
<dd className="tabular-nums">{series.key === "other" ? bar.other : bar.bucket[series.key]}</dd> <dd {...stylex.props(shared.tabularNums)}>
{series.key === "other" ? bar.other : bar.bucket[series.key]}
</dd>
</div> </div>
))} ))}
</dl> </dl>
@@ -82,11 +188,7 @@ export default function TimeseriesChart({ data }: { data: StatsTimeseries }) {
if (data.buckets.length === 0 || isEmptyTimeseries(data.buckets)) { if (data.buckets.length === 0 || isEmptyTimeseries(data.buckets)) {
return ( return (
<div <div ref={containerRef} {...stylex.props(styles.empty)}>
ref={containerRef}
className="flex items-center justify-center rounded border border-dashed border-zinc-300 text-sm text-zinc-500 dark:border-zinc-700"
style={{ height: CHART_HEIGHT }}
>
No queries in this period. No queries in this period.
</div> </div>
); );
@@ -97,7 +199,7 @@ export default function TimeseriesChart({ data }: { data: StatsTimeseries }) {
const hoveredBar = hovered !== null ? layout.bars[hovered] : undefined; const hoveredBar = hovered !== null ? layout.bars[hovered] : undefined;
return ( return (
<div ref={containerRef} className="relative"> <div ref={containerRef} {...stylex.props(styles.chartRoot)}>
<svg <svg
role="img" role="img"
aria-label={`Queries over time, ${data.buckets.length} buckets: blocked, cached and other queries per bucket`} aria-label={`Queries over time, ${data.buckets.length} buckets: blocked, cached and other queries per bucket`}
@@ -113,14 +215,14 @@ export default function TimeseriesChart({ data }: { data: StatsTimeseries }) {
x2={layout.plot.x + layout.plot.width} x2={layout.plot.x + layout.plot.width}
y1={tick.y} y1={tick.y}
y2={tick.y} y2={tick.y}
className="stroke-zinc-200 dark:stroke-zinc-800" {...stylex.props(styles.gridLine)}
/> />
<text <text
x={layout.plot.x - 6} x={layout.plot.x - 6}
y={tick.y} y={tick.y}
textAnchor="end" textAnchor="end"
dominantBaseline="middle" dominantBaseline="middle"
className="fill-zinc-500 text-[10px] tabular-nums" {...stylex.props(styles.axisLabel, shared.tabularNums)}
> >
{compact.format(tick.value)} {compact.format(tick.value)}
</text> </text>
@@ -131,7 +233,7 @@ export default function TimeseriesChart({ data }: { data: StatsTimeseries }) {
x2={layout.plot.x + layout.plot.width} x2={layout.plot.x + layout.plot.width}
y1={baseline} y1={baseline}
y2={baseline} y2={baseline}
className="stroke-zinc-300 dark:stroke-zinc-700" {...stylex.props(styles.axisLine)}
/> />
{layout.xTicks.map((tick) => ( {layout.xTicks.map((tick) => (
<text <text
@@ -139,7 +241,7 @@ export default function TimeseriesChart({ data }: { data: StatsTimeseries }) {
x={tick.x} x={tick.x}
y={baseline + 14} y={baseline + 14}
textAnchor="middle" textAnchor="middle"
className="fill-zinc-500 text-[10px]" {...stylex.props(styles.axisLabel)}
> >
{formatTick(tick.ts, data.bucket_seconds)} {formatTick(tick.ts, data.bucket_seconds)}
</text> </text>
@@ -157,8 +259,8 @@ export default function TimeseriesChart({ data }: { data: StatsTimeseries }) {
width={rect.width} width={rect.width}
height={rect.height} height={rect.height}
fill={series.color} fill={series.color}
className="stroke-zinc-50 dark:stroke-zinc-950"
strokeWidth={rect.width > 3 ? 1 : 0} strokeWidth={rect.width > 3 ? 1 : 0}
{...stylex.props(styles.segment)}
/> />
); );
})} })}
@@ -179,19 +281,18 @@ export default function TimeseriesChart({ data }: { data: StatsTimeseries }) {
))} ))}
</svg> </svg>
{hoveredBar !== undefined && <Tooltip bar={hoveredBar} chartWidth={width} />} {hoveredBar !== undefined && <Tooltip bar={hoveredBar} chartWidth={width} />}
<ul className="mt-2 flex flex-wrap gap-x-4 gap-y-1 text-xs text-zinc-600 dark:text-zinc-400"> <ul {...stylex.props(styles.legend)}>
{SERIES.map((series) => ( {SERIES.map((series) => (
<li key={series.key} className="flex items-center gap-1.5"> <li key={series.key} {...stylex.props(styles.legendItem)}>
<span <span
aria-hidden="true" aria-hidden="true"
className="inline-block size-2.5 rounded-xs" {...stylex.props(styles.swatch, styles.swatchLarge, styles.swatchColor(series.color))}
style={{ backgroundColor: series.color }}
/> />
{series.label} {series.label}
</li> </li>
))} ))}
</ul> </ul>
<table className="sr-only"> <table {...stylex.props(shared.srOnly)}>
<caption>Queries per time bucket</caption> <caption>Queries per time bucket</caption>
<thead> <thead>
<tr> <tr>
@@ -1,64 +1,150 @@
import * as stylex from "@stylexjs/stylex";
import type { UpstreamHealth } from "@/lib/types"; import type { UpstreamHealth } from "@/lib/types";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
const styles = stylex.create({
card: {
borderRadius: "0.25rem",
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.border,
backgroundColor: colors.surfaceRaised,
},
heading: {
display: "flex",
alignItems: "baseline",
justifyContent: "space-between",
paddingInline: "1rem",
paddingTop: "0.75rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
fontWeight: 600,
},
count: {
fontSize: "0.75rem",
lineHeight: "1rem",
fontWeight: 400,
color: colors.textMuted,
},
empty: {
paddingInline: "1rem",
paddingBlock: "0.75rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.textMuted,
},
tableWrap: {
overflowX: "auto",
},
table: {
marginTop: "0.5rem",
width: "100%",
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
headRow: {
borderBottomWidth: 1,
borderBottomStyle: "solid",
borderBottomColor: colors.border,
textAlign: "left",
fontSize: "0.75rem",
lineHeight: "1rem",
color: colors.textMuted,
},
th: {
paddingInline: "1rem",
paddingBlock: "0.5rem",
fontWeight: 500,
},
thRight: {
textAlign: "right",
},
/** No hairline under the last row: the card border already closes the table. */
row: {
borderBottomWidth: { default: 1, ":last-child": 0 },
borderBottomStyle: "solid",
borderBottomColor: colors.border,
},
cell: {
paddingInline: "1rem",
paddingBlock: "0.5rem",
},
cellRight: {
textAlign: "right",
},
small: {
fontSize: "0.75rem",
lineHeight: "1rem",
},
muted: {
color: colors.textMuted,
},
bad: {
color: colors.danger,
},
});
function YesNo({ value, badValue }: { value: boolean; badValue: boolean }) { function YesNo({ value, badValue }: { value: boolean; badValue: boolean }) {
const bad = value === badValue; const bad = value === badValue;
return <span className={bad ? "text-red-700 dark:text-red-400" : ""}>{value ? "yes" : "no"}</span>; return <span {...stylex.props(bad && styles.bad)}>{value ? "yes" : "no"}</span>;
} }
export default function UpstreamHealthTable({ health }: { health: UpstreamHealth }) { export default function UpstreamHealthTable({ health }: { health: UpstreamHealth }) {
return ( return (
<section className="rounded border border-zinc-200 bg-white dark:border-zinc-800 dark:bg-zinc-900"> <section {...stylex.props(styles.card)}>
<h2 className="flex items-baseline justify-between px-4 pt-3 text-sm font-semibold"> <h2 {...stylex.props(styles.heading)}>
Upstreams Upstreams
<span className="text-xs font-normal text-zinc-500 tabular-nums"> <span {...stylex.props(styles.count, shared.tabularNums)}>
{health.available}/{health.total} available {health.available}/{health.total} available
</span> </span>
</h2> </h2>
{health.upstreams.length === 0 ? ( {health.upstreams.length === 0 ? (
<p className="px-4 py-3 text-sm text-zinc-500">No upstreams configured.</p> <p {...stylex.props(styles.empty)}>No upstreams configured.</p>
) : ( ) : (
<div className="overflow-x-auto"> <div {...stylex.props(styles.tableWrap)}>
<table className="mt-2 w-full text-sm"> <table {...stylex.props(styles.table)}>
<thead> <thead>
<tr className="border-b border-zinc-200 text-left text-xs text-zinc-500 dark:border-zinc-800"> <tr {...stylex.props(styles.headRow)}>
<th scope="col" className="px-4 py-2 font-medium"> <th scope="col" {...stylex.props(styles.th)}>
URL URL
</th> </th>
<th scope="col" className="px-4 py-2 font-medium"> <th scope="col" {...stylex.props(styles.th)}>
Enabled Enabled
</th> </th>
<th scope="col" className="px-4 py-2 font-medium"> <th scope="col" {...stylex.props(styles.th)}>
Available Available
</th> </th>
<th scope="col" className="px-4 py-2 text-right font-medium"> <th scope="col" {...stylex.props(styles.th, styles.thRight)}>
Failures Failures
</th> </th>
<th scope="col" className="px-4 py-2 text-right font-medium"> <th scope="col" {...stylex.props(styles.th, styles.thRight)}>
Success rate Success rate
</th> </th>
<th scope="col" className="px-4 py-2 font-medium"> <th scope="col" {...stylex.props(styles.th)}>
Last error Last error
</th> </th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{health.upstreams.map((upstream) => ( {health.upstreams.map((upstream) => (
<tr <tr key={upstream.url} {...stylex.props(styles.row)}>
key={upstream.url} <td {...stylex.props(styles.cell, styles.small, shared.mono)}>{upstream.url}</td>
className="border-b border-zinc-100 last:border-0 dark:border-zinc-800/50" <td {...stylex.props(styles.cell)}>
>
<td className="px-4 py-2 font-mono text-xs">{upstream.url}</td>
<td className="px-4 py-2">
<YesNo value={upstream.enabled} badValue={false} /> <YesNo value={upstream.enabled} badValue={false} />
</td> </td>
<td className="px-4 py-2"> <td {...stylex.props(styles.cell)}>
<YesNo value={upstream.available} badValue={false} /> <YesNo value={upstream.available} badValue={false} />
</td> </td>
<td className="px-4 py-2 text-right tabular-nums">{upstream.total_failures}</td> <td {...stylex.props(styles.cell, styles.cellRight, shared.tabularNums)}>
<td className="px-4 py-2 text-right tabular-nums"> {upstream.total_failures}
</td>
<td {...stylex.props(styles.cell, styles.cellRight, shared.tabularNums)}>
{(upstream.success_rate * 100).toFixed(1)}% {(upstream.success_rate * 100).toFixed(1)}%
</td> </td>
<td className="px-4 py-2 text-xs text-zinc-500">{upstream.last_error || "—"}</td> <td {...stylex.props(styles.cell, styles.small, styles.muted)}>
{upstream.last_error || "—"}
</td>
</tr> </tr>
))} ))}
</tbody> </tbody>
+45 -14
View File
@@ -1,10 +1,12 @@
import { useState } from "react"; import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import * as stylex from "@stylexjs/stylex";
import { groupSourcesPutMutation, groupSourcesQuery } from "@/lib/queries"; import { groupSourcesPutMutation, groupSourcesQuery } from "@/lib/queries";
import type { Blocklist } from "@/lib/types"; import type { Blocklist } from "@/lib/types";
import { sameSet, toggleSource } from "./sourceSet"; import { sameSet, toggleSource } from "./sourceSet";
import InlineError from "@/lib/InlineError"; import InlineError from "@/lib/InlineError";
import { buttonClass, focusRing, primaryButtonClass } from "@/ui/classes"; import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority"; import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority";
interface Props { interface Props {
@@ -12,6 +14,35 @@ interface Props {
blocklists: Blocklist[]; blocklists: Blocklist[];
} }
const styles = stylex.create({
note: {
marginTop: "0.75rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.textMuted,
},
root: {
marginTop: "0.75rem",
},
list: {
display: "flex",
flexDirection: "column",
gap: "0.25rem",
},
checkboxLabel: {
display: "inline-flex",
alignItems: "center",
gap: "0.5rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
buttonRow: {
marginTop: "0.75rem",
display: "flex",
gap: "0.5rem",
},
});
export default function GroupSourcesEditor({ groupId, blocklists }: Props) { export default function GroupSourcesEditor({ groupId, blocklists }: Props) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const sources = useQuery(groupSourcesQuery(groupId)); const sources = useQuery(groupSourcesQuery(groupId));
@@ -21,7 +52,7 @@ export default function GroupSourcesEditor({ groupId, blocklists }: Props) {
if (sources.isPending) { if (sources.isPending) {
return ( return (
<p role="status" className="mt-3 text-sm text-zinc-500"> <p role="status" {...stylex.props(styles.note)}>
Loading sources Loading sources
</p> </p>
); );
@@ -29,27 +60,23 @@ export default function GroupSourcesEditor({ groupId, blocklists }: Props) {
if (sources.isError) return <InlineError error={sources.error} />; if (sources.isError) return <InlineError error={sources.error} />;
if (blocklists.length === 0) { if (blocklists.length === 0) {
return ( return <p {...stylex.props(styles.note)}>No blocklist sources exist yet add them on the Blocklists page.</p>;
<p className="mt-3 text-sm text-zinc-500">
No blocklist sources exist yet add them on the Blocklists page.
</p>
);
} }
const current = selected ?? sources.data; const current = selected ?? sources.data;
const dirty = !sameSet(current, sources.data); const dirty = !sameSet(current, sources.data);
return ( return (
<div className="mt-3"> <div {...stylex.props(styles.root)}>
<ul className="space-y-1"> <ul {...stylex.props(styles.list)}>
{blocklists.map((blocklist) => ( {blocklists.map((blocklist) => (
<li key={blocklist.id}> <li key={blocklist.id}>
<label className="inline-flex items-center gap-2 text-sm"> <label {...stylex.props(styles.checkboxLabel)}>
<input <input
type="checkbox" type="checkbox"
checked={current.includes(blocklist.id)} checked={current.includes(blocklist.id)}
onChange={() => setSelected(toggleSource(current, blocklist.id))} onChange={() => setSelected(toggleSource(current, blocklist.id))}
className={focusRing} {...stylex.props(shared.focusRing)}
/> />
{blocklist.name} {blocklist.name}
</label> </label>
@@ -57,7 +84,7 @@ export default function GroupSourcesEditor({ groupId, blocklists }: Props) {
))} ))}
</ul> </ul>
<InlineError error={mutation.error} /> <InlineError error={mutation.error} />
<div className="mt-3 flex gap-2"> <div {...stylex.props(styles.buttonRow)}>
<button <button
type="button" type="button"
disabled={!dirty || mutation.isPending || readOnly} disabled={!dirty || mutation.isPending || readOnly}
@@ -65,12 +92,16 @@ export default function GroupSourcesEditor({ groupId, blocklists }: Props) {
onClick={() => onClick={() =>
mutation.mutate({ id: groupId, sourceIds: current }, { onSuccess: () => setSelected(null) }) mutation.mutate({ id: groupId, sourceIds: current }, { onSuccess: () => setSelected(null) })
} }
className={primaryButtonClass} {...stylex.props(shared.primaryButton, shared.focusRing)}
> >
Save sources Save sources
</button> </button>
{dirty && ( {dirty && (
<button type="button" onClick={() => setSelected(null)} className={buttonClass}> <button
type="button"
onClick={() => setSelected(null)}
{...stylex.props(shared.button, shared.focusRing)}
>
Discard Discard
</button> </button>
)} )}
+111 -24
View File
@@ -1,5 +1,6 @@
import { useState } from "react"; import { useState } from "react";
import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query"; import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query";
import * as stylex from "@stylexjs/stylex";
import { import {
blocklistsQuery, blocklistsQuery,
groupCreateMutation, groupCreateMutation,
@@ -10,13 +11,85 @@ import {
import type { Blocklist, Group } from "@/lib/types"; import type { Blocklist, Group } from "@/lib/types";
import GroupSourcesEditor from "./GroupSourcesEditor"; import GroupSourcesEditor from "./GroupSourcesEditor";
import InlineError from "@/lib/InlineError"; import InlineError from "@/lib/InlineError";
import { focusRing, primaryButtonClass, smallButtonClass, smallInputClass } from "@/ui/classes"; import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
import { DEFAULT_GROUP_ID } from "@/lib/defaultGroup"; import { DEFAULT_GROUP_ID } from "@/lib/defaultGroup";
import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority"; import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority";
const DEFAULT_GROUP_NOTE = "The default group cannot be renamed or deleted."; const DEFAULT_GROUP_NOTE = "The default group cannot be renamed or deleted.";
const groupButtonClass = `${smallButtonClass} disabled:opacity-50`; const styles = stylex.create({
heading: {
fontSize: "1.5rem",
lineHeight: "2rem",
fontWeight: 600,
},
createForm: {
marginTop: "1rem",
display: "flex",
flexWrap: "wrap",
alignItems: "center",
gap: "0.5rem",
},
fieldLabel: {
fontSize: "0.875rem",
lineHeight: "1.25rem",
fontWeight: 500,
},
list: {
display: "flex",
flexDirection: "column",
gap: "1rem",
marginTop: "1.5rem",
},
row: {
borderRadius: "0.25rem",
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.border,
padding: "1rem",
},
rowControls: {
display: "flex",
flexWrap: "wrap",
alignItems: "center",
gap: "0.75rem",
},
renameForm: {
display: "flex",
alignItems: "center",
gap: "0.5rem",
},
name: {
fontWeight: 500,
},
checkboxLabel: {
display: "inline-flex",
alignItems: "center",
gap: "0.5rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
actions: {
marginLeft: "auto",
display: "inline-flex",
flexWrap: "wrap",
alignItems: "center",
gap: "0.5rem",
},
groupButton: {
opacity: { default: 1, ":disabled": 0.5 },
},
destructive: {
color: colors.danger,
},
lockNote: {
marginTop: "0.5rem",
fontSize: "0.75rem",
lineHeight: "1rem",
color: colors.textMuted,
},
});
export default function GroupsPage() { export default function GroupsPage() {
const { data: groups } = useSuspenseQuery(groupsQuery()); const { data: groups } = useSuspenseQuery(groupsQuery());
@@ -28,9 +101,9 @@ export default function GroupsPage() {
return ( return (
<section> <section>
<h1 className="text-2xl font-semibold">Groups</h1> <h1 {...stylex.props(styles.heading)}>Groups</h1>
<form <form
className="mt-4 flex flex-wrap items-center gap-2" {...stylex.props(styles.createForm)}
onSubmit={(event) => { onSubmit={(event) => {
event.preventDefault(); event.preventDefault();
const name = newName.trim(); const name = newName.trim();
@@ -38,7 +111,7 @@ export default function GroupsPage() {
createMutation.mutate({ name }, { onSuccess: () => setNewName("") }); createMutation.mutate({ name }, { onSuccess: () => setNewName("") });
}} }}
> >
<label className="text-sm font-medium" htmlFor="new-group-name"> <label {...stylex.props(styles.fieldLabel)} htmlFor="new-group-name">
New group New group
</label> </label>
<input <input
@@ -47,19 +120,19 @@ export default function GroupsPage() {
value={newName} value={newName}
onChange={(event) => setNewName(event.target.value)} onChange={(event) => setNewName(event.target.value)}
disabled={readOnly} disabled={readOnly}
className={smallInputClass} {...stylex.props(shared.smallInput, shared.focusRing)}
/> />
<button <button
type="submit" type="submit"
disabled={createMutation.isPending || readOnly} disabled={createMutation.isPending || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined} title={readOnly ? READ_ONLY_HINT : undefined}
className={primaryButtonClass} {...stylex.props(shared.primaryButton, shared.focusRing)}
> >
Create Create
</button> </button>
</form> </form>
<InlineError error={createMutation.error} /> <InlineError error={createMutation.error} />
<ul className="mt-6 space-y-4"> <ul {...stylex.props(styles.list)}>
{groups.map((group) => ( {groups.map((group) => (
<GroupRow key={group.id} group={group} blocklists={blocklists} /> <GroupRow key={group.id} group={group} blocklists={blocklists} />
))} ))}
@@ -81,11 +154,11 @@ function GroupRow({ group, blocklists }: { group: Group; blocklists: Blocklist[]
const lockNote = isDefault ? DEFAULT_GROUP_NOTE : readOnly ? READ_ONLY_HINT : undefined; const lockNote = isDefault ? DEFAULT_GROUP_NOTE : readOnly ? READ_ONLY_HINT : undefined;
return ( return (
<li className="rounded border border-zinc-200 p-4 dark:border-zinc-700"> <li {...stylex.props(styles.row)}>
<div className="flex flex-wrap items-center gap-3"> <div {...stylex.props(styles.rowControls)}>
{renaming ? ( {renaming ? (
<form <form
className="flex items-center gap-2" {...stylex.props(styles.renameForm)}
onSubmit={(event) => { onSubmit={(event) => {
event.preventDefault(); event.preventDefault();
const trimmed = name.trim(); const trimmed = name.trim();
@@ -101,14 +174,14 @@ function GroupRow({ group, blocklists }: { group: Group; blocklists: Blocklist[]
aria-label={`New name for ${group.name}`} aria-label={`New name for ${group.name}`}
value={name} value={name}
onChange={(event) => setName(event.target.value)} onChange={(event) => setName(event.target.value)}
className={smallInputClass} {...stylex.props(shared.smallInput, shared.focusRing)}
autoFocus autoFocus
/> />
<button <button
type="submit" type="submit"
disabled={updateMutation.isPending || readOnly} disabled={updateMutation.isPending || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined} title={readOnly ? READ_ONLY_HINT : undefined}
className={groupButtonClass} {...stylex.props(shared.smallButton, styles.groupButton, shared.focusRing)}
> >
Save Save
</button> </button>
@@ -118,21 +191,21 @@ function GroupRow({ group, blocklists }: { group: Group; blocklists: Blocklist[]
setName(group.name); setName(group.name);
setRenaming(false); setRenaming(false);
}} }}
className={groupButtonClass} {...stylex.props(shared.smallButton, styles.groupButton, shared.focusRing)}
> >
Cancel Cancel
</button> </button>
</form> </form>
) : ( ) : (
<span className="font-medium">{group.name}</span> <span {...stylex.props(styles.name)}>{group.name}</span>
)} )}
<label className="inline-flex items-center gap-2 text-sm"> <label {...stylex.props(styles.checkboxLabel)}>
<input <input
type="checkbox" type="checkbox"
checked={group.safe_search} checked={group.safe_search}
disabled={updateMutation.isPending || readOnly} disabled={updateMutation.isPending || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined} title={readOnly ? READ_ONLY_HINT : undefined}
className={focusRing} {...stylex.props(shared.focusRing)}
onChange={(event) => onChange={(event) =>
updateMutation.mutate({ updateMutation.mutate({
id: group.id, id: group.id,
@@ -142,12 +215,12 @@ function GroupRow({ group, blocklists }: { group: Group; blocklists: Blocklist[]
/> />
Safe search Safe search
</label> </label>
<span className="ml-auto inline-flex flex-wrap items-center gap-2"> <span {...stylex.props(styles.actions)}>
<button <button
type="button" type="button"
aria-expanded={expanded} aria-expanded={expanded}
onClick={() => setExpanded((open) => !open)} onClick={() => setExpanded((open) => !open)}
className={groupButtonClass} {...stylex.props(shared.smallButton, styles.groupButton, shared.focusRing)}
> >
Sources Sources
</button> </button>
@@ -160,7 +233,7 @@ function GroupRow({ group, blocklists }: { group: Group; blocklists: Blocklist[]
setName(group.name); setName(group.name);
setRenaming(true); setRenaming(true);
}} }}
className={groupButtonClass} {...stylex.props(shared.smallButton, styles.groupButton, shared.focusRing)}
> >
Rename Rename
</button> </button>
@@ -173,11 +246,20 @@ function GroupRow({ group, blocklists }: { group: Group; blocklists: Blocklist[]
setConfirming(false); setConfirming(false);
deleteMutation.mutate(group.id); deleteMutation.mutate(group.id);
}} }}
className={`${groupButtonClass} text-red-700 dark:text-red-400`} {...stylex.props(
shared.smallButton,
styles.groupButton,
styles.destructive,
shared.focusRing,
)}
> >
Confirm delete Confirm delete
</button> </button>
<button type="button" onClick={() => setConfirming(false)} className={groupButtonClass}> <button
type="button"
onClick={() => setConfirming(false)}
{...stylex.props(shared.smallButton, styles.groupButton, shared.focusRing)}
>
Cancel Cancel
</button> </button>
</> </>
@@ -187,14 +269,19 @@ function GroupRow({ group, blocklists }: { group: Group; blocklists: Blocklist[]
disabled={isDefault || readOnly} disabled={isDefault || readOnly}
title={lockNote} title={lockNote}
onClick={() => setConfirming(true)} onClick={() => setConfirming(true)}
className={`${groupButtonClass} text-red-700 dark:text-red-400`} {...stylex.props(
shared.smallButton,
styles.groupButton,
styles.destructive,
shared.focusRing,
)}
> >
Delete Delete
</button> </button>
)} )}
</span> </span>
</div> </div>
{isDefault && <p className="mt-2 text-xs text-zinc-500">{DEFAULT_GROUP_NOTE}</p>} {isDefault && <p {...stylex.props(styles.lockNote)}>{DEFAULT_GROUP_NOTE}</p>}
<InlineError error={updateMutation.error ?? deleteMutation.error} /> <InlineError error={updateMutation.error ?? deleteMutation.error} />
{expanded && <GroupSourcesEditor groupId={group.id} blocklists={blocklists} />} {expanded && <GroupSourcesEditor groupId={group.id} blocklists={blocklists} />}
</li> </li>
+7 -1
View File
@@ -50,8 +50,14 @@ test("streams rows, flags blocked ones, and freezes the display", () => {
expect(screen.getByText("Blocked")).toBeTruthy(); expect(screen.getByText("Blocked")).toBeTruthy();
expect(screen.getByText("blocklist:stevenblack")).toBeTruthy(); expect(screen.getByText("blocklist:stevenblack")).toBeTruthy();
expect(screen.getByText("AAAA")).toBeTruthy(); expect(screen.getByText("AAAA")).toBeTruthy();
// StyleX compiles to opaque class names, so the check is structural: a blocked
// row carries every class a plain row does, plus the ones the flag adds.
const blockedRow = screen.getByText("ads.example").closest("tr"); const blockedRow = screen.getByText("ads.example").closest("tr");
expect(blockedRow?.className).toContain("bg-red-50"); const plainRow = screen.getByText("ok.example").closest("tr");
const blockedClasses = new Set(blockedRow?.className.split(" "));
const plainClasses = plainRow?.className.split(" ") ?? [];
expect(plainClasses.every((name) => blockedClasses.has(name))).toBe(true);
expect(blockedClasses.size).toBeGreaterThan(plainClasses.length);
const freeze = screen.getByRole("button", { name: "Freeze" }); const freeze = screen.getByRole("button", { name: "Freeze" });
fireEvent.click(freeze); fireEvent.click(freeze);
+175 -33
View File
@@ -1,20 +1,164 @@
import * as stylex from "@stylexjs/stylex";
import { QueryCells, QueryTableHead } from "@/features/queries/QueryLogPage"; import { QueryCells, QueryTableHead } from "@/features/queries/QueryLogPage";
import { RING_CAPACITY } from "./ringBuffer"; import { RING_CAPACITY } from "./ringBuffer";
import { useLiveQueries, type EventSourceFactory, type StreamStatus } from "./useLiveQueries"; import { useLiveQueries, type EventSourceFactory, type StreamStatus } from "./useLiveQueries";
import { buttonClass, retryButtonClass, tableWrapClass } from "@/ui/classes"; import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
const toolbarButtonClass = `${buttonClass} font-medium`; const DARK = "@media (prefers-color-scheme: dark)";
const styles = stylex.create({
toolbar: {
display: "flex",
flexWrap: "wrap",
alignItems: "center",
gap: "0.75rem",
},
heading: {
fontSize: "1.5rem",
lineHeight: "2rem",
fontWeight: 600,
},
toolbarButton: {
fontWeight: 500,
},
pill: {
borderRadius: "9999px",
paddingInline: "0.625rem",
paddingBlock: "0.125rem",
fontSize: "0.75rem",
lineHeight: "1rem",
fontWeight: 500,
},
/** Four stream states need four tints; only two of them map onto a token role. */
pillConnecting: {
backgroundColor: { default: "oklch(96.7% 0.001 286.375)", [DARK]: "oklch(27.4% 0.006 286.033)" },
color: { default: "oklch(37% 0.013 285.805)", [DARK]: "oklch(87.1% 0.006 286.286)" },
},
pillOpen: {
backgroundColor: { default: "oklch(96.2% 0.044 156.743)", [DARK]: "oklch(39.3% 0.095 152.535)" },
color: { default: "oklch(44.8% 0.119 151.328)", [DARK]: "oklch(92.5% 0.084 155.995)" },
},
pillRetrying: {
backgroundColor: { default: "oklch(96.2% 0.059 95.617)", [DARK]: "oklch(41.4% 0.112 45.904)" },
color: { default: "oklch(47.3% 0.137 46.201)", [DARK]: "oklch(92.4% 0.12 95.746)" },
},
pillCapped: {
backgroundColor: { default: "oklch(93.6% 0.032 17.717)", [DARK]: "oklch(39.6% 0.141 25.723)" },
color: { default: "oklch(44.4% 0.177 26.899)", [DARK]: "oklch(88.5% 0.062 18.334)" },
},
note: {
marginTop: "0.5rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.textMuted,
},
/** Informational, neither a warning nor a failure, so the blue ramp stands alone. */
resumed: {
marginTop: "0.75rem",
display: "flex",
alignItems: "center",
gap: "0.75rem",
borderRadius: "0.25rem",
borderWidth: 1,
borderStyle: "solid",
borderColor: { default: "oklch(80.9% 0.105 251.813)", [DARK]: "oklch(37.9% 0.146 265.522)" },
backgroundColor: { default: "oklch(97% 0.014 254.604)", [DARK]: "oklch(28.2% 0.091 267.935)" },
color: { default: "oklch(42.4% 0.199 265.638)", [DARK]: "oklch(88.2% 0.059 254.128)" },
paddingInline: "0.75rem",
paddingBlock: "0.5rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
dismiss: {
borderStyle: "none",
backgroundColor: "transparent",
padding: 0,
color: "inherit",
fontSize: "inherit",
fontWeight: 500,
textDecorationLine: "underline",
},
failureNote: {
marginTop: "0.75rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.dangerText,
},
cappedBox: {
marginTop: "1rem",
borderRadius: "0.25rem",
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.dangerBorder,
backgroundColor: colors.dangerSurface,
padding: "1rem",
},
cappedHeading: {
fontWeight: 600,
color: colors.dangerText,
},
cappedDetail: {
marginTop: "0.25rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.dangerText,
},
empty: {
marginTop: "1.5rem",
color: colors.textMuted,
},
tableWrap: {
marginTop: "1rem",
overflowX: "auto",
borderRadius: "0.25rem",
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.border,
},
table: {
width: "100%",
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
/** `divide-y`: a hairline between rows, so the first row carries none. */
row: {
borderTopWidth: { default: 1, ":first-child": 0 },
borderTopStyle: "solid",
borderTopColor: colors.border,
},
rowBlocked: {
backgroundColor: {
default: "oklch(97.1% 0.013 17.38)",
[DARK]: "oklch(25.8% 0.092 26.042 / 0.4)",
},
},
footnote: {
marginTop: "0.75rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.textMuted,
},
});
const PILL_LABELS: Record<StreamStatus, string> = {
connecting: "Connecting…",
open: "Live",
retrying: "Reconnecting…",
capped: "Disconnected",
};
function pillStyle(status: StreamStatus) {
if (status === "open") return styles.pillOpen;
if (status === "retrying") return styles.pillRetrying;
if (status === "capped") return styles.pillCapped;
return styles.pillConnecting;
}
function StatusPill({ status }: { status: StreamStatus }) { function StatusPill({ status }: { status: StreamStatus }) {
const styles: Record<StreamStatus, [string, string]> = { const label = PILL_LABELS[status];
connecting: ["Connecting…", "bg-zinc-100 text-zinc-700 dark:bg-zinc-800 dark:text-zinc-300"],
open: ["Live", "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200"],
retrying: ["Reconnecting…", "bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200"],
capped: ["Disconnected", "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200"],
};
const [label, tone] = styles[status];
return ( return (
<span role="status" aria-label={label} className={`rounded-full px-2.5 py-0.5 text-xs font-medium ${tone}`}> <span role="status" aria-label={label} {...stylex.props(styles.pill, pillStyle(status))}>
{label} {label}
</span> </span>
); );
@@ -27,58 +171,56 @@ export default function LiveLogPage({ createEventSource }: { createEventSource?:
return ( return (
<section> <section>
<div className="flex flex-wrap items-center gap-3"> <div {...stylex.props(styles.toolbar)}>
<h1 className="text-2xl font-semibold">Live</h1> <h1 {...stylex.props(styles.heading)}>Live</h1>
<StatusPill status={live.status} /> <StatusPill status={live.status} />
<button <button
type="button" type="button"
onClick={live.toggleFreeze} onClick={live.toggleFreeze}
aria-pressed={live.frozen} aria-pressed={live.frozen}
className={toolbarButtonClass} {...stylex.props(shared.button, styles.toolbarButton, shared.focusRing)}
> >
{live.frozen ? "Resume" : "Freeze"} {live.frozen ? "Resume" : "Freeze"}
</button> </button>
</div> </div>
{live.frozen && ( {live.frozen && (
<p className="mt-2 text-sm text-zinc-500" role="status"> <p {...stylex.props(styles.note)} role="status">
Display frozen new queries keep buffering ({live.liveCount} in buffer, newest {RING_CAPACITY}{" "} Display frozen new queries keep buffering ({live.liveCount} in buffer, newest {RING_CAPACITY}{" "}
kept). kept).
</p> </p>
)} )}
{live.missed !== null && ( {live.missed !== null && (
<div <div role="status" {...stylex.props(styles.resumed)}>
role="status"
className="mt-3 flex items-center gap-3 rounded border border-blue-300 bg-blue-50 px-3 py-2 text-sm text-blue-800 dark:border-blue-900 dark:bg-blue-950 dark:text-blue-200"
>
<span> <span>
Stream resumed {" "} Stream resumed {" "}
{live.missed === 0 ? "no queries missed" : `${live.missed} missed queries recovered`}. {live.missed === 0 ? "no queries missed" : `${live.missed} missed queries recovered`}.
</span> </span>
<button type="button" onClick={live.dismissMissed} className="font-medium underline"> <button
type="button"
onClick={live.dismissMissed}
{...stylex.props(styles.dismiss, shared.focusRing)}
>
Dismiss Dismiss
</button> </button>
</div> </div>
)} )}
{live.resyncFailed && ( {live.resyncFailed && (
<p role="alert" className="mt-3 text-sm text-red-700 dark:text-red-300"> <p role="alert" {...stylex.props(styles.failureNote)}>
Stream resumed, but re-syncing the gap failed some queries may be missing here. Stream resumed, but re-syncing the gap failed some queries may be missing here.
</p> </p>
)} )}
{live.status === "capped" && ( {live.status === "capped" && (
<div <div role="alert" {...stylex.props(styles.cappedBox)}>
role="alert" <h2 {...stylex.props(styles.cappedHeading)}>Live stream unavailable</h2>
className="mt-4 rounded border border-red-300 bg-red-50 p-4 dark:border-red-900 dark:bg-red-950" <p {...stylex.props(styles.cappedDetail)}>
>
<h2 className="font-semibold text-red-800 dark:text-red-200">Live stream unavailable</h2>
<p className="mt-1 text-sm text-red-700 dark:text-red-300">
The connection failed repeatedly possibly too many live viewers (the server caps streams per The connection failed repeatedly possibly too many live viewers (the server caps streams per
address), or the server is unreachable. address), or the server is unreachable.
</p> </p>
<button type="button" onClick={live.retry} className={retryButtonClass}> <button type="button" onClick={live.retry} {...stylex.props(shared.retryButton, shared.focusRing)}>
Retry Retry
</button> </button>
</div> </div>
@@ -86,25 +228,25 @@ export default function LiveLogPage({ createEventSource }: { createEventSource?:
{live.rows.length === 0 ? ( {live.rows.length === 0 ? (
live.status !== "capped" && ( live.status !== "capped" && (
<p className="mt-6 text-zinc-500"> <p {...stylex.props(styles.empty)}>
{live.status === "open" ? "Waiting for queries…" : "No queries received yet."} {live.status === "open" ? "Waiting for queries…" : "No queries received yet."}
</p> </p>
) )
) : ( ) : (
<> <>
<div className={`${tableWrapClass} rounded border border-zinc-200 dark:border-zinc-800`}> <div {...stylex.props(styles.tableWrap)}>
<table className="w-full text-sm"> <table {...stylex.props(styles.table)}>
<QueryTableHead /> <QueryTableHead />
<tbody className="divide-y divide-zinc-100 dark:divide-zinc-800"> <tbody>
{live.rows.map((row) => ( {live.rows.map((row) => (
<tr key={row.key} className={row.blocked ? "bg-red-50 dark:bg-red-950/40" : ""}> <tr key={row.key} {...stylex.props(styles.row, row.blocked && styles.rowBlocked)}>
<QueryCells row={row} /> <QueryCells row={row} />
</tr> </tr>
))} ))}
</tbody> </tbody>
</table> </table>
</div> </div>
<p className="mt-3 text-sm text-zinc-500"> <p {...stylex.props(styles.footnote)}>
Showing {live.rows.length} {live.rows.length === 1 ? "query" : "queries"} (newest first, last{" "} Showing {live.rows.length} {live.rows.length === 1 ? "query" : "queries"} (newest first, last{" "}
{RING_CAPACITY} kept). {RING_CAPACITY} kept).
</p> </p>
+87 -2
View File
@@ -1,4 +1,4 @@
import { fireEvent, render, screen } from "@testing-library/react"; import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { QueryClientProvider } from "@tanstack/react-query"; import { QueryClientProvider } from "@tanstack/react-query";
import { RouterProvider, createMemoryHistory } from "@tanstack/react-router"; import { RouterProvider, createMemoryHistory } from "@tanstack/react-router";
import { AuthProvider } from "@/auth/store"; import { AuthProvider } from "@/auth/store";
@@ -24,6 +24,12 @@ function createFetchMock() {
records = [...records, created]; records = [...records, created];
return json(created, 201); return json(created, 201);
} }
if (url.startsWith("/api/local-records/") && method === "DELETE") {
const id = Number(url.slice("/api/local-records/".length));
records = records.filter((record) => record.id !== id);
return new Response(null, { status: 204 });
}
if (url.startsWith("/api/forward-zones/") && method === "DELETE") return new Response(null, { status: 204 });
if (url === "/api/forward-zones" && method === "GET") { if (url === "/api/forward-zones" && method === "GET") {
return json({ forward_zones: [{ id: 7, zone: "lan.home", resolver: "udp://192.168.1.1:53" }] }); return json({ forward_zones: [{ id: 7, zone: "lan.home", resolver: "udp://192.168.1.1:53" }] });
} }
@@ -65,13 +71,34 @@ test("renders the records table and switches to the forward zones tab", async ()
expect(screen.getByText("udp://192.168.1.1:53")).toBeTruthy(); expect(screen.getByText("udp://192.168.1.1:53")).toBeTruthy();
}); });
test("the arrow keys move between tabs", async () => {
renderPage();
await screen.findByText("nas.lan.home");
const tablist = screen.getByRole("tablist", { name: "Local DNS" });
const records = screen.getByRole("tab", { name: "Records" });
expect(records.getAttribute("aria-selected")).toBe("true");
fireEvent.keyDown(tablist, { key: "ArrowRight" });
const zones = screen.getByRole("tab", { name: "Forward zones" });
expect(zones.getAttribute("aria-selected")).toBe("true");
expect(screen.getByRole("tab", { name: "Records" }).getAttribute("aria-selected")).toBe("false");
await screen.findByText("lan.home");
fireEvent.keyDown(tablist, { key: "ArrowLeft" });
expect(screen.getByRole("tab", { name: "Records" }).getAttribute("aria-selected")).toBe("true");
await screen.findByText("nas.lan.home");
});
test("creates a record: POST body per LocalRecordInput, list refreshes", async () => { test("creates a record: POST body per LocalRecordInput, list refreshes", async () => {
renderPage(); renderPage();
await screen.findByText("nas.lan.home"); await screen.findByText("nas.lan.home");
fireEvent.click(screen.getByRole("button", { name: "Add record" })); fireEvent.click(screen.getByRole("button", { name: "Add record" }));
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "printer.lan.home" } }); fireEvent.change(screen.getByLabelText("Name"), { target: { value: "printer.lan.home" } });
fireEvent.change(screen.getByLabelText("Type"), { target: { value: "AAAA" } }); // The record type is a RAC Select now: open the listbox, then pick.
fireEvent.click(screen.getByRole("button", { name: /Type$/ }));
fireEvent.click(await screen.findByRole("option", { name: "AAAA" }));
fireEvent.change(screen.getByLabelText("Value"), { target: { value: "fd00::11" } }); fireEvent.change(screen.getByLabelText("Value"), { target: { value: "fd00::11" } });
fireEvent.click(screen.getByRole("button", { name: "Save" })); fireEvent.click(screen.getByRole("button", { name: "Save" }));
@@ -83,3 +110,61 @@ test("creates a record: POST body per LocalRecordInput, list refreshes", async (
expect(post).toBeTruthy(); expect(post).toBeTruthy();
expect(JSON.parse(String(post?.[1]?.body))).toEqual({ name: "printer.lan.home", rtype: "AAAA", value: "fd00::11" }); expect(JSON.parse(String(post?.[1]?.body))).toEqual({ name: "printer.lan.home", rtype: "AAAA", value: "fd00::11" });
}); });
test("cancelling the record delete dialog sends no request", async () => {
renderPage();
await screen.findByText("nas.lan.home");
fireEvent.click(screen.getByRole("button", { name: "Delete" }));
const dialog = await screen.findByRole("alertdialog");
expect(dialog.textContent).toContain('Delete record "nas.lan.home"?');
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
await waitFor(() => expect(screen.queryByRole("alertdialog")).toBeNull());
expect(fetchMock.mock.calls.some(([, init]) => init?.method === "DELETE")).toBe(false);
expect(screen.getByText("nas.lan.home")).toBeTruthy();
});
test("confirming the record delete dialog issues the DELETE", async () => {
renderPage();
await screen.findByText("nas.lan.home");
fireEvent.click(screen.getByRole("button", { name: "Delete" }));
await screen.findByRole("alertdialog");
fireEvent.click(screen.getByRole("button", { name: "Delete" }));
await waitFor(() =>
expect(
fetchMock.mock.calls.some(
([input, init]) => init?.method === "DELETE" && String(input) === "/api/local-records/1",
),
).toBe(true),
);
await waitFor(() => expect(screen.queryByText("nas.lan.home")).toBeNull());
});
test("the forward zone delete dialog names the zone and confirms", async () => {
renderPage();
await screen.findByText("nas.lan.home");
fireEvent.click(screen.getByRole("tab", { name: "Forward zones" }));
await screen.findByText("lan.home");
fireEvent.click(screen.getByRole("button", { name: "Delete" }));
const dialog = await screen.findByRole("alertdialog");
expect(dialog.textContent).toContain('Delete forward zone "lan.home"?');
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
await waitFor(() => expect(screen.queryByRole("alertdialog")).toBeNull());
expect(fetchMock.mock.calls.some(([, init]) => init?.method === "DELETE")).toBe(false);
fireEvent.click(screen.getByRole("button", { name: "Delete" }));
await screen.findByRole("alertdialog");
fireEvent.click(screen.getByRole("button", { name: "Delete" }));
await waitFor(() =>
expect(
fetchMock.mock.calls.some(
([input, init]) => init?.method === "DELETE" && String(input) === "/api/forward-zones/7",
),
).toBe(true),
);
});
+17 -69
View File
@@ -1,79 +1,27 @@
import { useState } from "react"; import * as stylex from "@stylexjs/stylex";
import RecordsTab from "@/features/local/RecordsTab"; import RecordsTab from "@/features/local/RecordsTab";
import ZonesTab from "@/features/local/ZonesTab"; import ZonesTab from "@/features/local/ZonesTab";
import { focusRing } from "@/ui/classes"; import Tabs from "@/ui/Tabs";
type Tab = "records" | "zones"; const styles = stylex.create({
heading: {
function TabButton({ fontSize: "1.5rem",
id, lineHeight: "2rem",
controls, fontWeight: 600,
selected, },
onClick, });
children,
}: {
id: string;
controls: string;
selected: boolean;
onClick: () => void;
children: string;
}) {
return (
<button
type="button"
role="tab"
id={id}
aria-controls={controls}
aria-selected={selected}
onClick={onClick}
className={`-mb-px border-b-2 px-3 py-2 font-medium ${focusRing} ${
selected
? "border-blue-600 text-blue-600 dark:text-blue-400"
: "border-transparent text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300"
}`}
>
{children}
</button>
);
}
export default function LocalDnsPage() { export default function LocalDnsPage() {
const [tab, setTab] = useState<Tab>("records");
return ( return (
<section> <section>
<h1 className="text-2xl font-semibold">Local DNS</h1> <h1 {...stylex.props(styles.heading)}>Local DNS</h1>
<div <Tabs
role="tablist" label="Local DNS"
aria-label="Local DNS" tabs={[
className="mt-4 flex gap-2 border-b border-zinc-200 dark:border-zinc-800" { id: "records", label: "Records", content: <RecordsTab /> },
> { id: "zones", label: "Forward zones", content: <ZonesTab /> },
<TabButton ]}
id="tab-records" />
controls="panel-records"
selected={tab === "records"}
onClick={() => setTab("records")}
>
Records
</TabButton>
<TabButton
id="tab-zones"
controls="panel-zones"
selected={tab === "zones"}
onClick={() => setTab("zones")}
>
Forward zones
</TabButton>
</div>
{tab === "records" ? (
<div role="tabpanel" id="panel-records" aria-labelledby="tab-records">
<RecordsTab />
</div>
) : (
<div role="tabpanel" id="panel-zones" aria-labelledby="tab-zones">
<ZonesTab />
</div>
)}
</section> </section>
); );
} }
+146 -58
View File
@@ -1,5 +1,6 @@
import { useId, useState, type FormEvent } from "react"; import { useId, useState, type FormEvent } from "react";
import { useSuspenseQuery } from "@tanstack/react-query"; import { useSuspenseQuery } from "@tanstack/react-query";
import * as stylex from "@stylexjs/stylex";
import { import {
localRecordCreateMutation, localRecordCreateMutation,
localRecordDeleteMutation, localRecordDeleteMutation,
@@ -8,18 +9,86 @@ import {
} from "@/lib/queries"; } from "@/lib/queries";
import type { LocalRecord, LocalRecordInput, LocalRecordType } from "@/lib/types"; import type { LocalRecord, LocalRecordInput, LocalRecordType } from "@/lib/types";
import InlineError from "@/lib/InlineError"; import InlineError from "@/lib/InlineError";
import ConfirmDialog from "@/ui/ConfirmDialog";
import Select from "@/ui/Select";
import { useCrudForm } from "@/ui/useCrudForm"; import { useCrudForm } from "@/ui/useCrudForm";
import { import { styles as shared } from "@/ui/styles";
formCardClass, import { colors } from "@/ui/tokens.stylex";
inputClass,
largeButtonClass,
largePrimaryButtonClass,
rowButtonClass,
tableWrapClass,
} from "@/ui/classes";
import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority"; import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority";
const RTYPES: readonly LocalRecordType[] = ["A", "AAAA", "CNAME"]; const RTYPES: readonly LocalRecordType[] = ["A", "AAAA", "CNAME"];
const RTYPE_OPTIONS = RTYPES.map((rtype) => ({ value: rtype, label: rtype }));
const styles = stylex.create({
formHeading: {
fontWeight: 500,
},
fieldLabel: {
display: "block",
fontSize: "0.875rem",
lineHeight: "1.25rem",
fontWeight: 500,
},
buttonRow: {
display: "flex",
gap: "0.5rem",
},
toolbar: {
display: "flex",
alignItems: "center",
justifyContent: "space-between",
marginTop: "1rem",
},
intro: {
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.textMuted,
},
table: {
width: "100%",
textAlign: "left",
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
headRow: {
borderBottomWidth: 1,
borderBottomStyle: "solid",
borderBottomColor: colors.border,
color: colors.textMuted,
},
headCell: {
paddingBlock: "0.5rem",
paddingRight: "1rem",
fontWeight: 500,
},
headCellLast: {
paddingBlock: "0.5rem",
},
bodyRow: {
borderBottomWidth: 1,
borderBottomStyle: "solid",
borderBottomColor: colors.border,
},
cell: {
paddingBlock: "0.5rem",
paddingRight: "1rem",
},
emptyCell: {
paddingBlock: "1rem",
color: colors.textMuted,
},
actionCell: {
paddingBlock: "0.5rem",
textAlign: "right",
whiteSpace: "nowrap",
},
dangerText: {
color: colors.danger,
},
dimWhenDisabled: {
opacity: { default: 1, ":disabled": 0.5 },
},
});
function RecordForm({ function RecordForm({
initial, initial,
@@ -50,10 +119,12 @@ function RecordForm({
} }
return ( return (
<form onSubmit={submit} className={formCardClass}> <form onSubmit={submit} {...stylex.props(shared.formCard)}>
<h3 className="font-medium">{initial === undefined ? "New record" : `Edit ${initial.name}`}</h3> <h3 {...stylex.props(styles.formHeading)}>
{initial === undefined ? "New record" : `Edit ${initial.name}`}
</h3>
<div> <div>
<label htmlFor={`${id}-name`} className="block text-sm font-medium"> <label htmlFor={`${id}-name`} {...stylex.props(styles.fieldLabel)}>
Name Name
</label> </label>
<input <input
@@ -62,28 +133,19 @@ function RecordForm({
value={name} value={name}
onChange={(event) => setName(event.target.value)} onChange={(event) => setName(event.target.value)}
placeholder="nas.lan.home" placeholder="nas.lan.home"
className={inputClass} {...stylex.props(shared.input, shared.focusRing)}
/> />
</div> </div>
<div> <div>
<label htmlFor={`${id}-rtype`} className="block text-sm font-medium"> <Select
Type label="Type"
</label>
<select
id={`${id}-rtype`}
value={rtype} value={rtype}
onChange={(event) => setRtype(event.target.value as LocalRecordType)} onChange={(next) => setRtype(next as LocalRecordType)}
className={inputClass} options={RTYPE_OPTIONS}
> />
{RTYPES.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
</div> </div>
<div> <div>
<label htmlFor={`${id}-value`} className="block text-sm font-medium"> <label htmlFor={`${id}-value`} {...stylex.props(styles.fieldLabel)}>
Value Value
</label> </label>
<input <input
@@ -94,11 +156,11 @@ function RecordForm({
placeholder={ placeholder={
rtype === "CNAME" ? "target.example.com" : rtype === "AAAA" ? "fd00::10" : "192.168.1.10" rtype === "CNAME" ? "target.example.com" : rtype === "AAAA" ? "fd00::10" : "192.168.1.10"
} }
className={inputClass} {...stylex.props(shared.input, shared.focusRing)}
/> />
</div> </div>
<div> <div>
<label htmlFor={`${id}-ttl`} className="block text-sm font-medium"> <label htmlFor={`${id}-ttl`} {...stylex.props(styles.fieldLabel)}>
TTL (seconds) TTL (seconds)
</label> </label>
<input <input
@@ -108,19 +170,19 @@ function RecordForm({
value={ttl} value={ttl}
onChange={(event) => setTtl(event.target.value)} onChange={(event) => setTtl(event.target.value)}
placeholder="300" placeholder="300"
className={inputClass} {...stylex.props(shared.input, shared.focusRing)}
/> />
</div> </div>
<div className="flex gap-2"> <div {...stylex.props(styles.buttonRow)}>
<button <button
type="submit" type="submit"
disabled={busy || readOnly} disabled={busy || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined} title={readOnly ? READ_ONLY_HINT : undefined}
className={largePrimaryButtonClass} {...stylex.props(shared.largePrimaryButton, shared.focusRing)}
> >
{busy ? "Saving…" : "Save"} {busy ? "Saving…" : "Save"}
</button> </button>
<button type="button" onClick={onCancel} className={largeButtonClass}> <button type="button" onClick={onCancel} {...stylex.props(shared.largeButton, shared.focusRing)}>
Cancel Cancel
</button> </button>
</div> </div>
@@ -131,10 +193,19 @@ function RecordForm({
export default function RecordsTab() { export default function RecordsTab() {
const records = useSuspenseQuery(localRecordsQuery()).data; const records = useSuspenseQuery(localRecordsQuery()).data;
const { create, update, remove, form, openForm, closeForm, onSubmit, onDelete } = useCrudForm< const {
LocalRecord, create,
LocalRecordInput update,
>({ remove,
form,
openForm,
closeForm,
onSubmit,
onDelete,
pendingDelete,
confirmPendingDelete,
cancelPendingDelete,
} = useCrudForm<LocalRecord, LocalRecordInput>({
create: localRecordCreateMutation, create: localRecordCreateMutation,
update: localRecordUpdateMutation, update: localRecordUpdateMutation,
remove: localRecordDeleteMutation, remove: localRecordDeleteMutation,
@@ -144,14 +215,14 @@ export default function RecordsTab() {
return ( return (
<div> <div>
<div className="mt-4 flex items-center justify-between"> <div {...stylex.props(styles.toolbar)}>
<p className="text-sm text-zinc-500">Answers served directly for LAN names. Changes apply live.</p> <p {...stylex.props(styles.intro)}>Answers served directly for LAN names. Changes apply live.</p>
<button <button
type="button" type="button"
onClick={() => openForm({ mode: "create" })} onClick={() => openForm({ mode: "create" })}
disabled={readOnly} disabled={readOnly}
title={readOnly ? READ_ONLY_HINT : undefined} title={readOnly ? READ_ONLY_HINT : undefined}
className={largePrimaryButtonClass} {...stylex.props(shared.largePrimaryButton, shared.focusRing)}
> >
Add record Add record
</button> </button>
@@ -166,37 +237,37 @@ export default function RecordsTab() {
onCancel={closeForm} onCancel={closeForm}
/> />
)} )}
<div className={tableWrapClass}> <div {...stylex.props(shared.tableWrap)}>
<table className="w-full text-left text-sm"> <table {...stylex.props(styles.table)}>
<thead> <thead>
<tr className="border-b border-zinc-200 text-zinc-500 dark:border-zinc-800"> <tr {...stylex.props(styles.headRow)}>
<th scope="col" className="py-2 pr-4 font-medium"> <th scope="col" {...stylex.props(styles.headCell)}>
Name Name
</th> </th>
<th scope="col" className="py-2 pr-4 font-medium"> <th scope="col" {...stylex.props(styles.headCell)}>
Type Type
</th> </th>
<th scope="col" className="py-2 pr-4 font-medium"> <th scope="col" {...stylex.props(styles.headCell)}>
Value Value
</th> </th>
<th scope="col" className="py-2 pr-4 font-medium"> <th scope="col" {...stylex.props(styles.headCell)}>
TTL TTL
</th> </th>
<th scope="col" className="py-2"> <th scope="col" {...stylex.props(styles.headCellLast)}>
<span className="sr-only">Actions</span> <span {...stylex.props(shared.srOnly)}>Actions</span>
</th> </th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{records.length === 0 && ( {records.length === 0 && (
<tr> <tr>
<td colSpan={5} className="py-4 text-zinc-500"> <td colSpan={5} {...stylex.props(styles.emptyCell)}>
No local records yet. No local records yet.
</td> </td>
</tr> </tr>
)} )}
{records.map((record) => ( {records.map((record) => (
<tr key={record.id} className="border-b border-zinc-100 dark:border-zinc-900"> <tr key={record.id} {...stylex.props(styles.bodyRow)}>
{form?.mode === "edit" && form.entity.id === record.id ? ( {form?.mode === "edit" && form.entity.id === record.id ? (
<td colSpan={5}> <td colSpan={5}>
<RecordForm <RecordForm
@@ -210,17 +281,21 @@ export default function RecordsTab() {
</td> </td>
) : ( ) : (
<> <>
<td className="py-2 pr-4 font-mono">{record.name}</td> <td {...stylex.props(styles.cell, shared.mono)}>{record.name}</td>
<td className="py-2 pr-4">{record.rtype}</td> <td {...stylex.props(styles.cell)}>{record.rtype}</td>
<td className="py-2 pr-4 font-mono">{record.value}</td> <td {...stylex.props(styles.cell, shared.mono)}>{record.value}</td>
<td className="py-2 pr-4">{record.ttl}</td> <td {...stylex.props(styles.cell)}>{record.ttl}</td>
<td className="py-2 text-right whitespace-nowrap"> <td {...stylex.props(styles.actionCell)}>
<button <button
type="button" type="button"
onClick={() => openForm({ mode: "edit", entity: record })} onClick={() => openForm({ mode: "edit", entity: record })}
disabled={readOnly} disabled={readOnly}
title={readOnly ? READ_ONLY_HINT : undefined} title={readOnly ? READ_ONLY_HINT : undefined}
className={`${rowButtonClass} disabled:opacity-50`} {...stylex.props(
shared.rowButton,
styles.dimWhenDisabled,
shared.focusRing,
)}
> >
Edit Edit
</button> </button>
@@ -229,7 +304,12 @@ export default function RecordsTab() {
onClick={() => onDelete(record)} onClick={() => onDelete(record)}
disabled={remove.isPending || readOnly} disabled={remove.isPending || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined} title={readOnly ? READ_ONLY_HINT : undefined}
className={`${rowButtonClass} text-red-600 disabled:opacity-50 dark:text-red-400`} {...stylex.props(
shared.rowButton,
styles.dangerText,
styles.dimWhenDisabled,
shared.focusRing,
)}
> >
Delete Delete
</button> </button>
@@ -241,6 +321,14 @@ export default function RecordsTab() {
</tbody> </tbody>
</table> </table>
</div> </div>
<ConfirmDialog
isOpen={pendingDelete !== null}
title="Delete record"
message={pendingDelete?.message ?? ""}
confirmLabel="Delete"
onConfirm={confirmPendingDelete}
onCancel={cancelPendingDelete}
/>
</div> </div>
); );
} }
+133 -38
View File
@@ -1,5 +1,6 @@
import { useId, useState, type FormEvent } from "react"; import { useId, useState, type FormEvent } from "react";
import { useSuspenseQuery } from "@tanstack/react-query"; import { useSuspenseQuery } from "@tanstack/react-query";
import * as stylex from "@stylexjs/stylex";
import { import {
forwardZoneCreateMutation, forwardZoneCreateMutation,
forwardZoneDeleteMutation, forwardZoneDeleteMutation,
@@ -8,17 +9,83 @@ import {
} from "@/lib/queries"; } from "@/lib/queries";
import type { ForwardZone, ForwardZoneInput } from "@/lib/types"; import type { ForwardZone, ForwardZoneInput } from "@/lib/types";
import InlineError from "@/lib/InlineError"; import InlineError from "@/lib/InlineError";
import ConfirmDialog from "@/ui/ConfirmDialog";
import { useCrudForm } from "@/ui/useCrudForm"; import { useCrudForm } from "@/ui/useCrudForm";
import { import { styles as shared } from "@/ui/styles";
formCardClass, import { colors } from "@/ui/tokens.stylex";
inputClass,
largeButtonClass,
largePrimaryButtonClass,
rowButtonClass,
tableWrapClass,
} from "@/ui/classes";
import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority"; import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority";
const styles = stylex.create({
formHeading: {
fontWeight: 500,
},
fieldLabel: {
display: "block",
fontSize: "0.875rem",
lineHeight: "1.25rem",
fontWeight: 500,
},
buttonRow: {
display: "flex",
gap: "0.5rem",
},
toolbar: {
display: "flex",
alignItems: "center",
justifyContent: "space-between",
marginTop: "1rem",
},
intro: {
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.textMuted,
},
table: {
width: "100%",
textAlign: "left",
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
headRow: {
borderBottomWidth: 1,
borderBottomStyle: "solid",
borderBottomColor: colors.border,
color: colors.textMuted,
},
headCell: {
paddingBlock: "0.5rem",
paddingRight: "1rem",
fontWeight: 500,
},
headCellLast: {
paddingBlock: "0.5rem",
},
bodyRow: {
borderBottomWidth: 1,
borderBottomStyle: "solid",
borderBottomColor: colors.border,
},
cell: {
paddingBlock: "0.5rem",
paddingRight: "1rem",
},
emptyCell: {
paddingBlock: "1rem",
color: colors.textMuted,
},
actionCell: {
paddingBlock: "0.5rem",
textAlign: "right",
whiteSpace: "nowrap",
},
dangerText: {
color: colors.danger,
},
dimWhenDisabled: {
opacity: { default: 1, ":disabled": 0.5 },
},
});
function ZoneForm({ function ZoneForm({
initial, initial,
busy, busy,
@@ -44,10 +111,12 @@ function ZoneForm({
} }
return ( return (
<form onSubmit={submit} className={formCardClass}> <form onSubmit={submit} {...stylex.props(shared.formCard)}>
<h3 className="font-medium">{initial === undefined ? "New forward zone" : `Edit ${initial.zone}`}</h3> <h3 {...stylex.props(styles.formHeading)}>
{initial === undefined ? "New forward zone" : `Edit ${initial.zone}`}
</h3>
<div> <div>
<label htmlFor={`${id}-zone`} className="block text-sm font-medium"> <label htmlFor={`${id}-zone`} {...stylex.props(styles.fieldLabel)}>
Zone Zone
</label> </label>
<input <input
@@ -56,11 +125,11 @@ function ZoneForm({
value={zone} value={zone}
onChange={(event) => setZone(event.target.value)} onChange={(event) => setZone(event.target.value)}
placeholder="lan.home" placeholder="lan.home"
className={inputClass} {...stylex.props(shared.input, shared.focusRing)}
/> />
</div> </div>
<div> <div>
<label htmlFor={`${id}-resolver`} className="block text-sm font-medium"> <label htmlFor={`${id}-resolver`} {...stylex.props(styles.fieldLabel)}>
Resolver Resolver
</label> </label>
<input <input
@@ -69,19 +138,19 @@ function ZoneForm({
value={resolver} value={resolver}
onChange={(event) => setResolver(event.target.value)} onChange={(event) => setResolver(event.target.value)}
placeholder="udp://192.168.1.1:53" placeholder="udp://192.168.1.1:53"
className={inputClass} {...stylex.props(shared.input, shared.focusRing)}
/> />
</div> </div>
<div className="flex gap-2"> <div {...stylex.props(styles.buttonRow)}>
<button <button
type="submit" type="submit"
disabled={busy || readOnly} disabled={busy || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined} title={readOnly ? READ_ONLY_HINT : undefined}
className={largePrimaryButtonClass} {...stylex.props(shared.largePrimaryButton, shared.focusRing)}
> >
{busy ? "Saving…" : "Save"} {busy ? "Saving…" : "Save"}
</button> </button>
<button type="button" onClick={onCancel} className={largeButtonClass}> <button type="button" onClick={onCancel} {...stylex.props(shared.largeButton, shared.focusRing)}>
Cancel Cancel
</button> </button>
</div> </div>
@@ -92,10 +161,19 @@ function ZoneForm({
export default function ZonesTab() { export default function ZonesTab() {
const zones = useSuspenseQuery(forwardZonesQuery()).data; const zones = useSuspenseQuery(forwardZonesQuery()).data;
const { create, update, remove, form, openForm, closeForm, onSubmit, onDelete } = useCrudForm< const {
ForwardZone, create,
ForwardZoneInput update,
>({ remove,
form,
openForm,
closeForm,
onSubmit,
onDelete,
pendingDelete,
confirmPendingDelete,
cancelPendingDelete,
} = useCrudForm<ForwardZone, ForwardZoneInput>({
create: forwardZoneCreateMutation, create: forwardZoneCreateMutation,
update: forwardZoneUpdateMutation, update: forwardZoneUpdateMutation,
remove: forwardZoneDeleteMutation, remove: forwardZoneDeleteMutation,
@@ -105,8 +183,8 @@ export default function ZonesTab() {
return ( return (
<div> <div>
<div className="mt-4 flex items-center justify-between"> <div {...stylex.props(styles.toolbar)}>
<p className="text-sm text-zinc-500"> <p {...stylex.props(styles.intro)}>
Names under these zones go to their own resolver. Changes apply live. Names under these zones go to their own resolver. Changes apply live.
</p> </p>
<button <button
@@ -114,7 +192,7 @@ export default function ZonesTab() {
onClick={() => openForm({ mode: "create" })} onClick={() => openForm({ mode: "create" })}
disabled={readOnly} disabled={readOnly}
title={readOnly ? READ_ONLY_HINT : undefined} title={readOnly ? READ_ONLY_HINT : undefined}
className={largePrimaryButtonClass} {...stylex.props(shared.largePrimaryButton, shared.focusRing)}
> >
Add zone Add zone
</button> </button>
@@ -129,31 +207,31 @@ export default function ZonesTab() {
onCancel={closeForm} onCancel={closeForm}
/> />
)} )}
<div className={tableWrapClass}> <div {...stylex.props(shared.tableWrap)}>
<table className="w-full text-left text-sm"> <table {...stylex.props(styles.table)}>
<thead> <thead>
<tr className="border-b border-zinc-200 text-zinc-500 dark:border-zinc-800"> <tr {...stylex.props(styles.headRow)}>
<th scope="col" className="py-2 pr-4 font-medium"> <th scope="col" {...stylex.props(styles.headCell)}>
Zone Zone
</th> </th>
<th scope="col" className="py-2 pr-4 font-medium"> <th scope="col" {...stylex.props(styles.headCell)}>
Resolver Resolver
</th> </th>
<th scope="col" className="py-2"> <th scope="col" {...stylex.props(styles.headCellLast)}>
<span className="sr-only">Actions</span> <span {...stylex.props(shared.srOnly)}>Actions</span>
</th> </th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{zones.length === 0 && ( {zones.length === 0 && (
<tr> <tr>
<td colSpan={3} className="py-4 text-zinc-500"> <td colSpan={3} {...stylex.props(styles.emptyCell)}>
No forward zones yet. No forward zones yet.
</td> </td>
</tr> </tr>
)} )}
{zones.map((zone) => ( {zones.map((zone) => (
<tr key={zone.id} className="border-b border-zinc-100 dark:border-zinc-900"> <tr key={zone.id} {...stylex.props(styles.bodyRow)}>
{form?.mode === "edit" && form.entity.id === zone.id ? ( {form?.mode === "edit" && form.entity.id === zone.id ? (
<td colSpan={3}> <td colSpan={3}>
<ZoneForm <ZoneForm
@@ -167,15 +245,19 @@ export default function ZonesTab() {
</td> </td>
) : ( ) : (
<> <>
<td className="py-2 pr-4 font-mono">{zone.zone}</td> <td {...stylex.props(styles.cell, shared.mono)}>{zone.zone}</td>
<td className="py-2 pr-4 font-mono">{zone.resolver}</td> <td {...stylex.props(styles.cell, shared.mono)}>{zone.resolver}</td>
<td className="py-2 text-right whitespace-nowrap"> <td {...stylex.props(styles.actionCell)}>
<button <button
type="button" type="button"
onClick={() => openForm({ mode: "edit", entity: zone })} onClick={() => openForm({ mode: "edit", entity: zone })}
disabled={readOnly} disabled={readOnly}
title={readOnly ? READ_ONLY_HINT : undefined} title={readOnly ? READ_ONLY_HINT : undefined}
className={`${rowButtonClass} disabled:opacity-50`} {...stylex.props(
shared.rowButton,
styles.dimWhenDisabled,
shared.focusRing,
)}
> >
Edit Edit
</button> </button>
@@ -184,7 +266,12 @@ export default function ZonesTab() {
onClick={() => onDelete(zone)} onClick={() => onDelete(zone)}
disabled={remove.isPending || readOnly} disabled={remove.isPending || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined} title={readOnly ? READ_ONLY_HINT : undefined}
className={`${rowButtonClass} text-red-600 disabled:opacity-50 dark:text-red-400`} {...stylex.props(
shared.rowButton,
styles.dangerText,
styles.dimWhenDisabled,
shared.focusRing,
)}
> >
Delete Delete
</button> </button>
@@ -196,6 +283,14 @@ export default function ZonesTab() {
</tbody> </tbody>
</table> </table>
</div> </div>
<ConfirmDialog
isOpen={pendingDelete !== null}
title="Delete forward zone"
message={pendingDelete?.message ?? ""}
confirmLabel="Delete"
onConfirm={confirmPendingDelete}
onCancel={cancelPendingDelete}
/>
</div> </div>
); );
} }
+4 -2
View File
@@ -88,6 +88,8 @@ test("fetches nothing until submit, then renders the blocked verdict", async ()
test("defaults the group select to the default group (id 1)", async () => { test("defaults the group select to the default group (id 1)", async () => {
renderPage(); renderPage();
const select = (await screen.findByLabelText("Group")) as HTMLSelectElement; // A RAC Select names its trigger with the current value and then the label, so
expect(select.value).toBe("1"); // the selected group's name is the only thing the trigger shows.
const trigger = await screen.findByRole("button", { name: /Group$/ });
expect(trigger.textContent).toContain("default");
}); });
+161 -44
View File
@@ -1,10 +1,15 @@
import { useState, type FormEvent, type ReactNode } from "react"; import { useState, type FormEvent, type ReactNode } from "react";
import { useQuery, useSuspenseQuery } from "@tanstack/react-query"; import { useQuery, useSuspenseQuery } from "@tanstack/react-query";
import * as stylex from "@stylexjs/stylex";
import { ApiError } from "@/lib/api"; import { ApiError } from "@/lib/api";
import { groupsQuery, lookupQuery } from "@/lib/queries"; import { groupsQuery, lookupQuery } from "@/lib/queries";
import type { Group, LookupResult } from "@/lib/types"; import type { Group, LookupResult } from "@/lib/types";
import { defaultGroupId } from "@/lib/defaultGroup"; import { defaultGroupId } from "@/lib/defaultGroup";
import { inputClass, largePrimaryButtonClass } from "@/ui/classes"; import Select from "@/ui/Select";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
const DARK = "@media (prefers-color-scheme: dark)";
interface Submitted { interface Submitted {
domain: string; domain: string;
@@ -13,10 +18,123 @@ interface Submitted {
interface Verdict { interface Verdict {
label: string; label: string;
className: string; tone: "local" | "blocked" | "forwarded" | "allowed";
description: string; description: string;
} }
const styles = stylex.create({
heading: {
fontSize: "1.5rem",
lineHeight: "2rem",
fontWeight: 600,
},
intro: {
marginTop: "0.5rem",
color: colors.textMuted,
},
form: {
marginTop: "1.5rem",
display: "flex",
maxWidth: "42rem",
flexWrap: "wrap",
alignItems: "flex-end",
gap: "0.75rem",
},
domainField: {
minWidth: "14rem",
flexGrow: 1,
},
fieldLabel: {
display: "block",
fontSize: "0.875rem",
lineHeight: "1.25rem",
fontWeight: 500,
},
note: {
marginTop: "1.5rem",
color: colors.textMuted,
},
error: {
marginTop: "1.5rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.danger,
},
card: {
marginTop: "1.5rem",
borderRadius: "0.25rem",
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.border,
},
banner: {
borderStartStartRadius: "0.25rem",
borderStartEndRadius: "0.25rem",
paddingInline: "1rem",
paddingBlock: "0.75rem",
},
/** Four verdicts need four tints; only "blocked" maps onto a token role. */
local: {
backgroundColor: { default: "oklch(93.2% 0.032 255.585)", [DARK]: "oklch(28.2% 0.091 267.935)" },
color: { default: "oklch(42.4% 0.199 265.638)", [DARK]: "oklch(80.9% 0.105 251.813)" },
},
blocked: {
backgroundColor: { default: "oklch(93.6% 0.032 17.717)", [DARK]: "oklch(25.8% 0.092 26.042)" },
color: { default: "oklch(44.4% 0.177 26.899)", [DARK]: "oklch(80.8% 0.114 19.571)" },
},
forwarded: {
backgroundColor: { default: "oklch(96.2% 0.059 95.617)", [DARK]: "oklch(27.9% 0.077 45.635)" },
color: { default: "oklch(47.3% 0.137 46.201)", [DARK]: "oklch(87.9% 0.169 91.605)" },
},
allowed: {
backgroundColor: { default: "oklch(96.2% 0.044 156.743)", [DARK]: "oklch(26.6% 0.065 152.934)" },
color: { default: "oklch(44.8% 0.119 151.328)", [DARK]: "oklch(87.1% 0.15 154.449)" },
},
verdictLabel: {
fontSize: "1.125rem",
lineHeight: "1.75rem",
fontWeight: 600,
},
verdictDescription: {
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
details: {
paddingInline: "1rem",
paddingBlock: "0.5rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
/** `divide-y`: a hairline between rows, so the first row carries none. */
detailRow: {
display: "flex",
gap: "1rem",
paddingBlock: "0.5rem",
borderTopWidth: { default: 1, ":first-child": 0 },
borderTopStyle: "solid",
borderTopColor: colors.border,
},
detailTerm: {
width: "10rem",
flexShrink: 0,
color: colors.textMuted,
},
detailValue: {
minWidth: 0,
overflowWrap: "break-word",
},
sourceLink: {
color: colors.primaryOnSurface,
textDecorationLine: "underline",
},
});
function toneStyle(tone: Verdict["tone"]) {
if (tone === "local") return styles.local;
if (tone === "blocked") return styles.blocked;
return tone === "forwarded" ? styles.forwarded : styles.allowed;
}
/** /**
* Header priority follows the pipeline order the lookup handler documents * Header priority follows the pipeline order the lookup handler documents
* (PLAN §6): local records answer first, then the block decision, then * (PLAN §6): local records answer first, then the block decision, then
@@ -26,27 +144,27 @@ export function verdictOf(result: LookupResult): Verdict {
if (result.local_records) { if (result.local_records) {
return { return {
label: "Local answer", label: "Local answer",
className: "bg-blue-100 text-blue-800 dark:bg-blue-950 dark:text-blue-300", tone: "local",
description: "A local record answers this name directly.", description: "A local record answers this name directly.",
}; };
} }
if (result.blocked) { if (result.blocked) {
return { return {
label: "Blocked", label: "Blocked",
className: "bg-red-100 text-red-800 dark:bg-red-950 dark:text-red-300", tone: "blocked",
description: "Queries for this name get a blocked response.", description: "Queries for this name get a blocked response.",
}; };
} }
if (result.forward_zone !== null) { if (result.forward_zone !== null) {
return { return {
label: "Forwarded", label: "Forwarded",
className: "bg-amber-100 text-amber-800 dark:bg-amber-950 dark:text-amber-300", tone: "forwarded",
description: `Queries go to the resolver for zone ${result.forward_zone}.`, description: `Queries go to the resolver for zone ${result.forward_zone}.`,
}; };
} }
return { return {
label: "Allowed", label: "Allowed",
className: "bg-green-100 text-green-800 dark:bg-green-950 dark:text-green-300", tone: "allowed",
description: "Queries resolve through the upstream pool.", description: "Queries resolve through the upstream pool.",
}; };
} }
@@ -68,9 +186,9 @@ function errorMessage(error: unknown): string {
function DetailRow({ label, children }: { label: string; children: ReactNode }) { function DetailRow({ label, children }: { label: string; children: ReactNode }) {
return ( return (
<div className="flex gap-4 py-2"> <div {...stylex.props(styles.detailRow)}>
<dt className="w-40 shrink-0 text-zinc-500">{label}</dt> <dt {...stylex.props(styles.detailTerm)}>{label}</dt>
<dd className="min-w-0 break-words">{children}</dd> <dd {...stylex.props(styles.detailValue)}>{children}</dd>
</div> </div>
); );
} }
@@ -80,26 +198,30 @@ function VerdictCard({ result, groups }: { result: LookupResult; groups: Group[]
const groupName = groups.find((group) => group.id === result.group_id)?.name ?? `#${result.group_id}`; const groupName = groups.find((group) => group.id === result.group_id)?.name ?? `#${result.group_id}`;
return ( return (
<div className="mt-6 rounded border border-zinc-200 dark:border-zinc-800"> <div {...stylex.props(styles.card)}>
<div className={`rounded-t px-4 py-3 ${verdict.className}`}> <div {...stylex.props(styles.banner, toneStyle(verdict.tone))}>
<h2 className="text-lg font-semibold">{verdict.label}</h2> <h2 {...stylex.props(styles.verdictLabel)}>{verdict.label}</h2>
<p className="text-sm">{verdict.description}</p> <p {...stylex.props(styles.verdictDescription)}>{verdict.description}</p>
</div> </div>
<dl className="divide-y divide-zinc-100 px-4 py-2 text-sm dark:divide-zinc-900"> <dl {...stylex.props(styles.details)}>
<DetailRow label="Domain"> <DetailRow label="Domain">
<span className="font-mono">{result.domain}</span> <span {...stylex.props(shared.mono)}>{result.domain}</span>
</DetailRow> </DetailRow>
<DetailRow label="Group">{groupName}</DetailRow> <DetailRow label="Group">{groupName}</DetailRow>
<DetailRow label="Local record">{result.local_records ? "Yes" : "No"}</DetailRow> <DetailRow label="Local record">{result.local_records ? "Yes" : "No"}</DetailRow>
<DetailRow label="Forward zone"> <DetailRow label="Forward zone">
{result.forward_zone !== null ? <span className="font-mono">{result.forward_zone}</span> : "—"} {result.forward_zone !== null ? (
<span {...stylex.props(shared.mono)}>{result.forward_zone}</span>
) : (
"—"
)}
</DetailRow> </DetailRow>
<DetailRow label="Blocked">{result.blocked ? "Yes" : "No"}</DetailRow> <DetailRow label="Blocked">{result.blocked ? "Yes" : "No"}</DetailRow>
<DetailRow label="Reason"> <DetailRow label="Reason">
<span className="font-mono">{result.reason}</span> <span {...stylex.props(shared.mono)}>{result.reason}</span>
</DetailRow> </DetailRow>
<DetailRow label="Matched pattern"> <DetailRow label="Matched pattern">
{result.matched !== "" ? <span className="font-mono">{result.matched}</span> : "—"} {result.matched !== "" ? <span {...stylex.props(shared.mono)}>{result.matched}</span> : "—"}
</DetailRow> </DetailRow>
<DetailRow label="Blocklist source"> <DetailRow label="Blocklist source">
{result.source_url !== null ? ( {result.source_url !== null ? (
@@ -107,7 +229,7 @@ function VerdictCard({ result, groups }: { result: LookupResult; groups: Group[]
href={result.source_url} href={result.source_url}
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
className="text-blue-600 underline dark:text-blue-400" {...stylex.props(styles.sourceLink, shared.focusRing)}
> >
{result.source_url} {result.source_url}
</a> </a>
@@ -117,7 +239,7 @@ function VerdictCard({ result, groups }: { result: LookupResult; groups: Group[]
</DetailRow> </DetailRow>
<DetailRow label="Safe search rewrite"> <DetailRow label="Safe search rewrite">
{result.safe_search_rewrite !== null ? ( {result.safe_search_rewrite !== null ? (
<span className="font-mono">{result.safe_search_rewrite}</span> <span {...stylex.props(shared.mono)}>{result.safe_search_rewrite}</span>
) : ( ) : (
"—" "—"
)} )}
@@ -153,13 +275,13 @@ export default function LookupPage() {
return ( return (
<section> <section>
<h1 className="text-2xl font-semibold">Lookup</h1> <h1 {...stylex.props(styles.heading)}>Lookup</h1>
<p className="mt-2 text-zinc-500"> <p {...stylex.props(styles.intro)}>
What the pipeline would do with a domain: local records, forward zones, block decision, safe search. What the pipeline would do with a domain: local records, forward zones, block decision, safe search.
</p> </p>
<form onSubmit={onSubmit} className="mt-6 flex max-w-2xl flex-wrap items-end gap-3"> <form onSubmit={onSubmit} {...stylex.props(styles.form)}>
<div className="min-w-56 grow"> <div {...stylex.props(styles.domainField)}>
<label htmlFor="lookup-domain" className="block text-sm font-medium"> <label htmlFor="lookup-domain" {...stylex.props(styles.fieldLabel)}>
Domain Domain
</label> </label>
<input <input
@@ -168,33 +290,28 @@ export default function LookupPage() {
value={domain} value={domain}
onChange={(event) => setDomain(event.target.value)} onChange={(event) => setDomain(event.target.value)}
placeholder="ads.example.com" placeholder="ads.example.com"
className={inputClass} {...stylex.props(shared.input, shared.focusRing)}
/> />
</div> </div>
<div> <div>
<label htmlFor="lookup-group" className="block text-sm font-medium"> <Select
Group label="Group"
</label> value={String(groupId)}
<select onChange={(value) => setGroupId(Number(value))}
id="lookup-group" options={groups.map((group) => ({ value: String(group.id), label: group.name }))}
value={groupId} />
onChange={(event) => setGroupId(Number(event.target.value))}
className={inputClass}
>
{groups.map((group) => (
<option key={group.id} value={group.id}>
{group.name}
</option>
))}
</select>
</div> </div>
<button type="submit" className={largePrimaryButtonClass} disabled={lookup.isFetching}> <button
type="submit"
disabled={lookup.isFetching}
{...stylex.props(shared.largePrimaryButton, shared.focusRing)}
>
Look up Look up
</button> </button>
</form> </form>
{lookup.isFetching && <p className="mt-6 text-zinc-500">Looking up</p>} {lookup.isFetching && <p {...stylex.props(styles.note)}>Looking up</p>}
{!lookup.isFetching && lookup.isError && ( {!lookup.isFetching && lookup.isError && (
<p role="alert" className="mt-6 text-sm text-red-600 dark:text-red-400"> <p role="alert" {...stylex.props(styles.error)}>
{errorMessage(lookup.error)} {errorMessage(lookup.error)}
</p> </p>
)} )}
+78 -15
View File
@@ -1,8 +1,10 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import * as stylex from "@stylexjs/stylex";
import { pauseMutation, pauseQuery } from "@/lib/queries"; import { pauseMutation, pauseQuery } from "@/lib/queries";
import InlineError from "@/lib/InlineError"; import InlineError from "@/lib/InlineError";
import { buttonClass, insetFocusRing } from "@/ui/classes"; import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
const DURATIONS = [ const DURATIONS = [
{ label: "60 seconds", seconds: 60 }, { label: "60 seconds", seconds: 60 },
@@ -11,6 +13,72 @@ const DURATIONS = [
{ label: "Indefinitely", seconds: null }, { label: "Indefinitely", seconds: null },
] as const; ] as const;
const styles = stylex.create({
/**
* Disabled text darkens in light scheme and lightens in dark, the opposite
* direction from `textMuted`, so the token cannot express it.
*/
trigger: {
color: {
default: null,
":disabled": "oklch(70.5% 0.015 286.067)",
"@media (prefers-color-scheme: dark)": { default: null, ":disabled": "oklch(44.2% 0.017 285.786)" },
},
},
pausedRow: {
display: "flex",
flexDirection: "column",
alignItems: "flex-end",
},
pausedControls: {
display: "flex",
alignItems: "center",
gap: "0.5rem",
},
/**
* Amber as standalone text on the app ground, not inside a warning banner, so
* the `warn*` tokens tuned against `warnSurface` do not apply here.
*/
pausedLabel: {
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: {
default: "oklch(55.5% 0.163 48.998)",
"@media (prefers-color-scheme: dark)": "oklch(82.8% 0.189 84.429)",
},
},
anchor: {
position: "relative",
},
menu: {
position: "absolute",
right: 0,
top: "100%",
zIndex: 10,
marginTop: "0.25rem",
display: "flex",
width: "9rem",
flexDirection: "column",
borderRadius: "0.25rem",
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.border,
backgroundColor: colors.surfaceRaised,
paddingBlock: "0.25rem",
boxShadow: "0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)",
},
menuItem: {
borderStyle: "none",
backgroundColor: { default: "transparent", ":hover": colors.surfaceHover },
color: "inherit",
paddingInline: "0.75rem",
paddingBlock: "0.375rem",
textAlign: "left",
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
});
export function formatRemaining(totalSeconds: number): string { export function formatRemaining(totalSeconds: number): string {
const clamped = Math.max(0, totalSeconds); const clamped = Math.max(0, totalSeconds);
const hours = Math.floor(clamped / 3600); const hours = Math.floor(clamped / 3600);
@@ -35,8 +103,6 @@ function useNowSeconds(active: boolean): number {
return now; return now;
} }
const triggerButtonClass = `${buttonClass} disabled:text-zinc-400 dark:disabled:text-zinc-600`;
export default function PauseWidget() { export default function PauseWidget() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { data } = useQuery({ const { data } = useQuery({
@@ -52,7 +118,7 @@ export default function PauseWidget() {
if (data === undefined) { if (data === undefined) {
return ( return (
<button type="button" disabled className={triggerButtonClass}> <button type="button" disabled {...stylex.props(shared.button, styles.trigger, shared.focusRing)}>
Pause Pause
</button> </button>
); );
@@ -60,16 +126,16 @@ export default function PauseWidget() {
if (data.paused) { if (data.paused) {
return ( return (
<div className="flex flex-col items-end"> <div {...stylex.props(styles.pausedRow)}>
<div className="flex items-center gap-2"> <div {...stylex.props(styles.pausedControls)}>
<span className="text-sm text-amber-700 dark:text-amber-400"> <span {...stylex.props(styles.pausedLabel)}>
{data.until === null ? "Paused" : `Paused ${formatRemaining(data.until - now)}`} {data.until === null ? "Paused" : `Paused ${formatRemaining(data.until - now)}`}
</span> </span>
<button <button
type="button" type="button"
onClick={() => mutation.mutate({ paused: false })} onClick={() => mutation.mutate({ paused: false })}
disabled={mutation.isPending} disabled={mutation.isPending}
className={triggerButtonClass} {...stylex.props(shared.button, styles.trigger, shared.focusRing)}
> >
Resume Resume
</button> </button>
@@ -81,7 +147,7 @@ export default function PauseWidget() {
return ( return (
<div <div
className="relative" {...stylex.props(styles.anchor)}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === "Escape") setMenuOpen(false); if (e.key === "Escape") setMenuOpen(false);
}} }}
@@ -92,15 +158,12 @@ export default function PauseWidget() {
aria-controls="pause-menu" aria-controls="pause-menu"
onClick={() => setMenuOpen((open) => !open)} onClick={() => setMenuOpen((open) => !open)}
disabled={mutation.isPending} disabled={mutation.isPending}
className={triggerButtonClass} {...stylex.props(shared.button, styles.trigger, shared.focusRing)}
> >
Pause Pause
</button> </button>
{menuOpen && ( {menuOpen && (
<div <div id="pause-menu" {...stylex.props(styles.menu)}>
id="pause-menu"
className="absolute right-0 top-full z-10 mt-1 flex w-36 flex-col rounded border border-zinc-200 bg-white py-1 shadow dark:border-zinc-800 dark:bg-zinc-900"
>
{DURATIONS.map(({ label, seconds }) => ( {DURATIONS.map(({ label, seconds }) => (
<button <button
key={label} key={label}
@@ -111,7 +174,7 @@ export default function PauseWidget() {
seconds === null ? { paused: true } : { paused: true, duration_seconds: seconds }, seconds === null ? { paused: true } : { paused: true, duration_seconds: seconds },
); );
}} }}
className={`px-3 py-1.5 text-left text-sm hover:bg-zinc-100 ${insetFocusRing} dark:hover:bg-zinc-800`} {...stylex.props(styles.menuItem, shared.insetFocusRing)}
> >
{label} {label}
</button> </button>
+193 -63
View File
@@ -1,14 +1,146 @@
import { useState, type FormEvent } from "react"; import { useState, type FormEvent } from "react";
import { useInfiniteQuery } from "@tanstack/react-query"; import { useInfiniteQuery } from "@tanstack/react-query";
import * as stylex from "@stylexjs/stylex";
import * as api from "@/lib/api"; import * as api from "@/lib/api";
import { formatMicros, formatTime } from "@/lib/format"; import { formatMicros, formatTime } from "@/lib/format";
import { queriesInfiniteQuery } from "@/lib/queries"; import { queriesInfiniteQuery } from "@/lib/queries";
import type { QueriesFilter, QueryRow } from "@/lib/types"; import type { QueriesFilter, QueryRow } from "@/lib/types";
import { qtypeName } from "./qtype"; import { qtypeName } from "./qtype";
import { buttonClass, smallInputClass, tableWrapClass } from "@/ui/classes"; import Select from "@/ui/Select";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
const filterInputClass = `mt-1 w-full ${smallInputClass}`; const DARK = "@media (prefers-color-scheme: dark)";
const toolbarButtonClass = `${buttonClass} font-medium`;
const STATUS_OPTIONS = [
{ value: "any", label: "All" },
{ value: "blocked", label: "Blocked only" },
{ value: "allowed", label: "Allowed only" },
];
const styles = stylex.create({
heading: {
fontSize: "1.5rem",
lineHeight: "2rem",
fontWeight: 600,
},
/** One column on a phone, two from `sm`, five from `lg`, as before. */
filterGrid: {
marginTop: "1rem",
display: "grid",
gap: "0.75rem",
gridTemplateColumns: {
default: "repeat(1, minmax(0, 1fr))",
"@media (min-width: 640px)": "repeat(2, minmax(0, 1fr))",
"@media (min-width: 1024px)": "repeat(5, minmax(0, 1fr))",
},
},
filterLabel: {
display: "block",
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
filterInput: {
marginTop: "0.25rem",
width: "100%",
},
buttonRow: {
display: "flex",
alignItems: "flex-end",
gap: "0.5rem",
gridColumn: {
default: null,
"@media (min-width: 640px)": "span 2 / span 2",
"@media (min-width: 1024px)": "span 5 / span 5",
},
},
toolbarButton: {
fontWeight: 500,
},
note: {
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.textMuted,
},
empty: {
marginTop: "1.5rem",
color: colors.textMuted,
},
tableWrap: {
marginTop: "1rem",
overflowX: "auto",
borderRadius: "0.25rem",
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.border,
},
table: {
width: "100%",
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
/** The header tint is a shade off the ground in each scheme, not a token role. */
head: {
backgroundColor: { default: "oklch(98.5% 0 none)", [DARK]: "oklch(21% 0.006 285.885)" },
textAlign: "left",
},
th: {
paddingInline: "0.75rem",
paddingBlock: "0.5rem",
fontWeight: 500,
color: colors.textSecondary,
},
/** `divide-y`: a hairline between rows, so the first row carries none. */
row: {
borderTopWidth: { default: 1, ":first-child": 0 },
borderTopStyle: "solid",
borderTopColor: colors.border,
},
cell: {
paddingInline: "0.75rem",
paddingBlock: "0.5rem",
},
nowrap: {
whiteSpace: "nowrap",
},
breakAll: {
wordBreak: "break-all",
},
small: {
fontSize: "0.75rem",
lineHeight: "1rem",
},
muted: {
color: colors.textMuted,
},
blockedWrap: {
display: "inline-flex",
alignItems: "center",
gap: "0.375rem",
},
blockedBadge: {
borderRadius: "0.25rem",
paddingInline: "0.375rem",
paddingBlock: "0.125rem",
fontSize: "0.75rem",
lineHeight: "1rem",
fontWeight: 500,
backgroundColor: { default: "oklch(93.6% 0.032 17.717)", [DARK]: "oklch(39.6% 0.141 25.723)" },
color: { default: "oklch(44.4% 0.177 26.899)", [DARK]: "oklch(88.5% 0.062 18.334)" },
},
footer: {
marginTop: "0.75rem",
display: "flex",
alignItems: "center",
gap: "0.75rem",
},
moreError: {
marginTop: "0.5rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.dangerText,
},
});
function errorMessage(error: unknown): string { function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error); return error instanceof Error ? error.message : String(error);
@@ -21,13 +153,11 @@ function datetimeLocalToUnix(value: string): number | undefined {
} }
export function BlockedCell({ row }: { row: Pick<QueryRow, "blocked" | "block_reason"> }) { export function BlockedCell({ row }: { row: Pick<QueryRow, "blocked" | "block_reason"> }) {
if (!row.blocked) return <span className="text-zinc-400"></span>; if (!row.blocked) return <span {...stylex.props(styles.muted)}></span>;
return ( return (
<span className="inline-flex items-center gap-1.5"> <span {...stylex.props(styles.blockedWrap)}>
<span className="rounded bg-red-100 px-1.5 py-0.5 text-xs font-medium text-red-800 dark:bg-red-900 dark:text-red-200"> <span {...stylex.props(styles.blockedBadge)}>Blocked</span>
Blocked {row.block_reason !== "" && <span {...stylex.props(styles.small, styles.muted)}>{row.block_reason}</span>}
</span>
{row.block_reason !== "" && <span className="text-xs text-zinc-500">{row.block_reason}</span>}
</span> </span>
); );
} }
@@ -35,37 +165,38 @@ export function BlockedCell({ row }: { row: Pick<QueryRow, "blocked" | "block_re
export function QueryCells({ row }: { row: Omit<QueryRow, "id"> }) { export function QueryCells({ row }: { row: Omit<QueryRow, "id"> }) {
return ( return (
<> <>
<td className="px-3 py-2 whitespace-nowrap text-zinc-500">{formatTime(row.ts)}</td> <td {...stylex.props(styles.cell, styles.nowrap, styles.muted)}>{formatTime(row.ts)}</td>
<td className="px-3 py-2 font-mono text-xs break-all">{row.domain}</td> <td {...stylex.props(styles.cell, styles.small, styles.breakAll, shared.mono)}>{row.domain}</td>
<td className="px-3 py-2 font-mono text-xs whitespace-nowrap">{row.client_ip}</td> <td {...stylex.props(styles.cell, styles.small, styles.nowrap, shared.mono)}>{row.client_ip}</td>
<td className="px-3 py-2 whitespace-nowrap">{qtypeName(row.qtype)}</td> <td {...stylex.props(styles.cell, styles.nowrap)}>{qtypeName(row.qtype)}</td>
<td className="px-3 py-2"> <td {...stylex.props(styles.cell)}>
<BlockedCell row={row} /> <BlockedCell row={row} />
</td> </td>
<td className="px-3 py-2 whitespace-nowrap tabular-nums"> <td {...stylex.props(styles.cell, styles.nowrap, shared.tabularNums)}>
{row.response_time_us === null ? "—" : formatMicros(row.response_time_us)} {row.response_time_us === null ? "—" : formatMicros(row.response_time_us)}
</td> </td>
<td className="px-3 py-2 whitespace-nowrap"> <td {...stylex.props(styles.cell, styles.nowrap)}>
{row.cache_hit === null ? "—" : row.cache_hit ? "hit" : "miss"} {row.cache_hit === null ? "—" : row.cache_hit ? "hit" : "miss"}
</td> </td>
<td className="px-3 py-2 font-mono text-xs break-all">{row.upstream === "" ? "—" : row.upstream}</td> <td {...stylex.props(styles.cell, styles.small, styles.breakAll, shared.mono)}>
{row.upstream === "" ? "—" : row.upstream}
</td>
</> </>
); );
} }
export function QueryTableHead() { export function QueryTableHead() {
const th = "px-3 py-2 font-medium text-zinc-600 dark:text-zinc-400";
return ( return (
<thead className="bg-zinc-50 text-left dark:bg-zinc-900"> <thead {...stylex.props(styles.head)}>
<tr> <tr>
<th className={th}>Time</th> <th {...stylex.props(styles.th)}>Time</th>
<th className={th}>Domain</th> <th {...stylex.props(styles.th)}>Domain</th>
<th className={th}>Client</th> <th {...stylex.props(styles.th)}>Client</th>
<th className={th}>Type</th> <th {...stylex.props(styles.th)}>Type</th>
<th className={th}>Status</th> <th {...stylex.props(styles.th)}>Status</th>
<th className={th}>Response</th> <th {...stylex.props(styles.th)}>Response</th>
<th className={th}>Cache</th> <th {...stylex.props(styles.th)}>Cache</th>
<th className={th}>Upstream</th> <th {...stylex.props(styles.th)}>Upstream</th>
</tr> </tr>
</thead> </thead>
); );
@@ -124,66 +255,65 @@ export default function QueryLogPage() {
return ( return (
<section> <section>
<h1 className="text-2xl font-semibold">Query Log</h1> <h1 {...stylex.props(styles.heading)}>Query Log</h1>
<form onSubmit={applyFilters} className="mt-4 grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-5"> <form onSubmit={applyFilters} {...stylex.props(styles.filterGrid)}>
<label className="block text-sm"> <label {...stylex.props(styles.filterLabel)}>
Domain contains Domain contains
<input <input
type="text" type="text"
value={domain} value={domain}
onChange={(event) => setDomain(event.target.value)} onChange={(event) => setDomain(event.target.value)}
className={filterInputClass} {...stylex.props(shared.smallInput, styles.filterInput, shared.focusRing)}
/> />
</label> </label>
<label className="block text-sm"> <label {...stylex.props(styles.filterLabel)}>
Client (exact) Client (exact)
<input <input
type="text" type="text"
value={client} value={client}
onChange={(event) => setClient(event.target.value)} onChange={(event) => setClient(event.target.value)}
className={filterInputClass} {...stylex.props(shared.smallInput, styles.filterInput, shared.focusRing)}
/> />
</label> </label>
<label className="block text-sm"> <Select
Status variant="compactField"
<select label="Status"
value={blocked} value={blocked}
onChange={(event) => setBlocked(event.target.value)} onChange={setBlocked}
className={filterInputClass} options={STATUS_OPTIONS}
> />
<option value="any">All</option> <label {...stylex.props(styles.filterLabel)}>
<option value="blocked">Blocked only</option>
<option value="allowed">Allowed only</option>
</select>
</label>
<label className="block text-sm">
Since Since
<input <input
type="datetime-local" type="datetime-local"
value={since} value={since}
onChange={(event) => setSince(event.target.value)} onChange={(event) => setSince(event.target.value)}
className={filterInputClass} {...stylex.props(shared.smallInput, styles.filterInput, shared.focusRing)}
/> />
</label> </label>
<label className="block text-sm"> <label {...stylex.props(styles.filterLabel)}>
Until Until
<input <input
type="datetime-local" type="datetime-local"
value={until} value={until}
onChange={(event) => setUntil(event.target.value)} onChange={(event) => setUntil(event.target.value)}
className={filterInputClass} {...stylex.props(shared.smallInput, styles.filterInput, shared.focusRing)}
/> />
</label> </label>
<div className="flex items-end gap-2 sm:col-span-2 lg:col-span-5"> <div {...stylex.props(styles.buttonRow)}>
<button type="submit" className={toolbarButtonClass}> <button type="submit" {...stylex.props(shared.button, styles.toolbarButton, shared.focusRing)}>
Apply filters Apply filters
</button> </button>
<button type="button" onClick={clearFilters} className={toolbarButtonClass}> <button
type="button"
onClick={clearFilters}
{...stylex.props(shared.button, styles.toolbarButton, shared.focusRing)}
>
Clear Clear
</button> </button>
{base.isFetching && ( {base.isFetching && (
<span className="text-sm text-zinc-500" role="status"> <span {...stylex.props(styles.note)} role="status">
Loading Loading
</span> </span>
)} )}
@@ -191,29 +321,29 @@ export default function QueryLogPage() {
</form> </form>
{base.data === undefined ? ( {base.data === undefined ? (
<p className="mt-6 animate-pulse text-zinc-500" role="status"> <p {...stylex.props(styles.empty, shared.pulse)} role="status">
Loading query log Loading query log
</p> </p>
) : rows.length === 0 ? ( ) : rows.length === 0 ? (
<p className="mt-6 text-zinc-500"> <p {...stylex.props(styles.empty)}>
{filterActive ? "No queries match the current filters." : "No queries logged yet."} {filterActive ? "No queries match the current filters." : "No queries logged yet."}
</p> </p>
) : ( ) : (
<> <>
<div className={`${tableWrapClass} rounded border border-zinc-200 dark:border-zinc-800`}> <div {...stylex.props(styles.tableWrap)}>
<table className="w-full text-sm"> <table {...stylex.props(styles.table)}>
<QueryTableHead /> <QueryTableHead />
<tbody className="divide-y divide-zinc-100 dark:divide-zinc-800"> <tbody>
{rows.map((row) => ( {rows.map((row) => (
<tr key={row.id}> <tr key={row.id} {...stylex.props(styles.row)}>
<QueryCells row={row} /> <QueryCells row={row} />
</tr> </tr>
))} ))}
</tbody> </tbody>
</table> </table>
</div> </div>
<div className="mt-3 flex items-center gap-3"> <div {...stylex.props(styles.footer)}>
<p className="text-sm text-zinc-500"> <p {...stylex.props(styles.note)}>
Showing {rows.length} {rows.length === 1 ? "query" : "queries"} Showing {rows.length} {rows.length === 1 ? "query" : "queries"}
{hasMore ? "" : " — end of log"} {hasMore ? "" : " — end of log"}
</p> </p>
@@ -222,14 +352,14 @@ export default function QueryLogPage() {
type="button" type="button"
onClick={loadMore} onClick={loadMore}
disabled={base.isFetchingNextPage || base.isPlaceholderData} disabled={base.isFetchingNextPage || base.isPlaceholderData}
className={toolbarButtonClass} {...stylex.props(shared.button, styles.toolbarButton, shared.focusRing)}
> >
{base.isFetchingNextPage ? "Loading…" : "Load more"} {base.isFetchingNextPage ? "Loading…" : "Load more"}
</button> </button>
)} )}
</div> </div>
{moreError !== null && ( {moreError !== null && (
<p role="alert" className="mt-2 text-sm text-red-700 dark:text-red-300"> <p role="alert" {...stylex.props(styles.moreError)}>
Failed to load more: {moreError} Failed to load more: {moreError}
</p> </p>
)} )}
+60 -13
View File
@@ -1,4 +1,4 @@
import { fireEvent, render, screen, within } from "@testing-library/react"; import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { QueryClientProvider } from "@tanstack/react-query"; import { QueryClientProvider } from "@tanstack/react-query";
import { RouterProvider, createMemoryHistory } from "@tanstack/react-router"; import { RouterProvider, createMemoryHistory } from "@tanstack/react-router";
import { AuthProvider } from "@/auth/store"; import { AuthProvider } from "@/auth/store";
@@ -33,16 +33,26 @@ const RESPONSES: Record<string, unknown> = {
// The API orders groups by name, so the id-1 default is not always first. // The API orders groups by name, so the id-1 default is not always first.
let groups: { id: number; name: string; safe_search: boolean }[]; let groups: { id: number; name: string; safe_search: boolean }[];
let deleted: string[];
function deleteCalls(): string[] {
return deleted;
}
beforeEach(() => { beforeEach(() => {
groups = [ groups = [
{ id: 1, name: "Default", safe_search: false }, { id: 1, name: "Default", safe_search: false },
{ id: 2, name: "Kids", safe_search: true }, { id: 2, name: "Kids", safe_search: true },
]; ];
deleted = [];
vi.stubGlobal( vi.stubGlobal(
"fetch", "fetch",
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input); const url = String(input);
if (init?.method === "DELETE") {
deleted.push(url);
return new Response(null, { status: 204 });
}
if (url === "/api/rules" && init?.method === "POST") { if (url === "/api/rules" && init?.method === "POST") {
return new Response(JSON.stringify({ error: "rate limited" }), { return new Response(JSON.stringify({ error: "rate limited" }), {
status: 429, status: 429,
@@ -75,6 +85,25 @@ function renderRulesRoute() {
); );
} }
/**
* A RAC Select names its trigger with the current value and then the label, so
* the label alone is a suffix match. Opening it is the only way to read the
* options: there is no `<select>` carrying them any more.
*/
function trigger(label: string): HTMLElement {
return screen.getByRole("button", { name: new RegExp(`${label}$`) });
}
async function optionsOf(label: string): Promise<(string | null)[]> {
fireEvent.click(trigger(label));
const options = await screen.findAllByRole("option");
const labels = options.map((option) => option.textContent);
// Re-picking the current value closes the listbox and changes nothing.
fireEvent.click(options.find((option) => option.getAttribute("aria-selected") === "true") ?? options[0]!);
await waitFor(() => expect(screen.queryByRole("listbox")).toBeNull());
return labels;
}
test("renders the rule table and the create form with contract enums", async () => { test("renders the rule table and the create form with contract enums", async () => {
renderRulesRoute(); renderRulesRoute();
await screen.findByRole("heading", { name: "Rules" }); await screen.findByRole("heading", { name: "Rules" });
@@ -87,14 +116,9 @@ test("renders the rule table and the create form with contract enums", async ()
expect(table.getByText("Kids")).toBeTruthy(); expect(table.getByText("Kids")).toBeTruthy();
expect(screen.getAllByRole("button", { name: "Delete" })).toHaveLength(2); expect(screen.getAllByRole("button", { name: "Delete" })).toHaveLength(2);
const kindSelect = screen.getByLabelText("Kind") as HTMLSelectElement; expect(await optionsOf("Kind")).toEqual(["exact", "wildcard"]);
expect(Array.from(kindSelect.options).map((o) => o.value)).toEqual(["exact", "wildcard"]); expect(await optionsOf("Action")).toEqual(["allow", "block"]);
expect(await optionsOf("Group")).toEqual(["Default", "Kids"]);
const actionSelect = screen.getByLabelText("Action") as HTMLSelectElement;
expect(Array.from(actionSelect.options).map((o) => o.value)).toEqual(["allow", "block"]);
const groupSelect = screen.getByLabelText("Group") as HTMLSelectElement;
expect(Array.from(groupSelect.options).map((o) => o.textContent)).toEqual(["Default", "Kids"]);
}); });
test("rule create shows a countdown when rate limited with Retry-After", async () => { test("rule create shows a countdown when rate limited with Retry-After", async () => {
@@ -116,9 +140,8 @@ test("the group select preselects the id-1 default, not the alphabetically first
renderRulesRoute(); renderRulesRoute();
await screen.findByRole("heading", { name: "Rules" }); await screen.findByRole("heading", { name: "Rules" });
const groupSelect = screen.getByLabelText("Group") as HTMLSelectElement; expect(await optionsOf("Group")).toEqual(["Attic", "Default"]);
expect(Array.from(groupSelect.options).map((o) => o.textContent)).toEqual(["Attic", "Default"]); expect(trigger("Group").textContent).toContain("Default");
expect(groupSelect.value).toBe("1");
}); });
test("the group select falls back to the first group when the default is absent", async () => { test("the group select falls back to the first group when the default is absent", async () => {
@@ -129,5 +152,29 @@ test("the group select falls back to the first group when the default is absent"
renderRulesRoute(); renderRulesRoute();
await screen.findByRole("heading", { name: "Rules" }); await screen.findByRole("heading", { name: "Rules" });
expect((screen.getByLabelText("Group") as HTMLSelectElement).value).toBe("5"); expect(trigger("Group").textContent).toContain("Attic");
});
test("delete asks for confirmation, and cancelling sends no request", async () => {
renderRulesRoute();
await screen.findByRole("heading", { name: "Rules" });
fireEvent.click(screen.getAllByRole("button", { name: "Delete" })[0]!);
const dialog = await screen.findByRole("alertdialog");
expect(dialog.textContent).toContain('Delete the block rule for "ads.example.com"?');
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
await waitFor(() => expect(screen.queryByRole("alertdialog")).toBeNull());
expect(deleteCalls()).toEqual([]);
});
test("confirming the delete dialog issues the DELETE for that rule", async () => {
renderRulesRoute();
await screen.findByRole("heading", { name: "Rules" });
fireEvent.click(screen.getAllByRole("button", { name: "Delete" })[1]!);
await screen.findByRole("alertdialog");
fireEvent.click(screen.getByRole("button", { name: "Delete" }));
await waitFor(() => expect(deleteCalls()).toEqual(["/api/rules/2"]));
}); });
+144 -89
View File
@@ -1,13 +1,85 @@
import { useState, type FormEvent } from "react"; import { useState, type FormEvent } from "react";
import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query"; import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query";
import * as stylex from "@stylexjs/stylex";
import { formatTime } from "@/lib/format"; import { formatTime } from "@/lib/format";
import InlineError from "@/lib/InlineError"; import InlineError from "@/lib/InlineError";
import { groupsQuery, ruleCreateMutation, ruleDeleteMutation, rulesQuery } from "@/lib/queries"; import { groupsQuery, ruleCreateMutation, ruleDeleteMutation, rulesQuery } from "@/lib/queries";
import type { Rule, RuleAction, RuleKind } from "@/lib/types"; import type { Rule, RuleAction, RuleKind } from "@/lib/types";
import { defaultGroupId } from "@/lib/defaultGroup"; import { defaultGroupId } from "@/lib/defaultGroup";
import { dangerLinkButtonClass, inputClass, primaryButtonClass, tableWrapClass, tdClass, thClass } from "@/ui/classes"; import ConfirmDialog from "@/ui/ConfirmDialog";
import Select from "@/ui/Select";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority"; import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority";
const KIND_OPTIONS = [
{ value: "exact", label: "exact" },
{ value: "wildcard", label: "wildcard" },
];
const ACTION_OPTIONS = [
{ value: "allow", label: "allow" },
{ value: "block", label: "block" },
];
const styles = stylex.create({
heading: {
fontSize: "1.5rem",
lineHeight: "2rem",
fontWeight: 600,
},
empty: {
marginTop: "1rem",
color: colors.textMuted,
},
table: {
width: "100%",
minWidth: "max-content",
borderCollapse: "collapse",
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
pattern: {
fontWeight: 500,
},
allow: {
color: colors.primaryOnSurface,
},
block: {
color: colors.danger,
},
form: {
display: "flex",
flexDirection: "column",
gap: "0.75rem",
marginTop: "1.5rem",
maxWidth: "36rem",
},
formHeading: {
fontSize: "1.125rem",
lineHeight: "1.75rem",
fontWeight: 500,
},
fieldLabel: {
display: "block",
fontSize: "0.875rem",
lineHeight: "1.25rem",
fontWeight: 500,
},
/** One column on a phone, three from the `sm` breakpoint, as before. */
fieldGrid: {
display: "grid",
gap: "0.75rem",
gridTemplateColumns: {
default: "repeat(1, minmax(0, 1fr))",
"@media (min-width: 640px)": "repeat(3, minmax(0, 1fr))",
},
},
submitRow: {
display: "flex",
},
});
export default function RulesPage() { export default function RulesPage() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { data: rules } = useSuspenseQuery(rulesQuery()); const { data: rules } = useSuspenseQuery(rulesQuery());
@@ -20,6 +92,7 @@ export default function RulesPage() {
const [kind, setKind] = useState<RuleKind>("exact"); const [kind, setKind] = useState<RuleKind>("exact");
const [action, setAction] = useState<RuleAction>("block"); const [action, setAction] = useState<RuleAction>("block");
const [groupId, setGroupId] = useState(() => defaultGroupId(groups)); const [groupId, setGroupId] = useState(() => defaultGroupId(groups));
const [pendingDelete, setPendingDelete] = useState<Rule | null>(null);
const readOnly = useReadOnlyConfig(); const readOnly = useReadOnlyConfig();
function onSubmit(event: FormEvent<HTMLFormElement>) { function onSubmit(event: FormEvent<HTMLFormElement>) {
@@ -30,58 +103,52 @@ export default function RulesPage() {
); );
} }
function deleteRule(rule: Rule) { function confirmDelete() {
if (window.confirm(`Delete the ${rule.action} rule for "${rule.pattern}"?`)) { if (pendingDelete === null) return;
remove.mutate(rule.id); remove.mutate(pendingDelete.id);
} setPendingDelete(null);
} }
return ( return (
<section> <section>
<h1 className="text-2xl font-semibold">Rules</h1> <h1 {...stylex.props(styles.heading)}>Rules</h1>
{rules.length === 0 ? ( {rules.length === 0 ? (
<p className="mt-4 text-zinc-500">No allow or block rules yet. Create one below.</p> <p {...stylex.props(styles.empty)}>No allow or block rules yet. Create one below.</p>
) : ( ) : (
<div className={tableWrapClass}> <div {...stylex.props(shared.tableWrap)}>
<table className="w-full min-w-max border-collapse text-sm"> <table {...stylex.props(styles.table)}>
<thead> <thead>
<tr> <tr>
<th className={thClass}>Pattern</th> <th {...stylex.props(shared.th)}>Pattern</th>
<th className={thClass}>Kind</th> <th {...stylex.props(shared.th)}>Kind</th>
<th className={thClass}>Action</th> <th {...stylex.props(shared.th)}>Action</th>
<th className={thClass}>Group</th> <th {...stylex.props(shared.th)}>Group</th>
<th className={thClass}>Created</th> <th {...stylex.props(shared.th)}>Created</th>
<th className={thClass}> <th {...stylex.props(shared.th)}>
<span className="sr-only">Actions</span> <span {...stylex.props(shared.srOnly)}>Actions</span>
</th> </th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{rules.map((rule) => ( {rules.map((rule) => (
<tr key={rule.id}> <tr key={rule.id}>
<td className={`${tdClass} font-medium`}>{rule.pattern}</td> <td {...stylex.props(shared.td, styles.pattern)}>{rule.pattern}</td>
<td className={tdClass}>{rule.kind}</td> <td {...stylex.props(shared.td)}>{rule.kind}</td>
<td className={tdClass}> <td {...stylex.props(shared.td)}>
<span <span {...stylex.props(rule.action === "allow" ? styles.allow : styles.block)}>
className={
rule.action === "allow"
? "text-green-700 dark:text-green-400"
: "text-red-600 dark:text-red-400"
}
>
{rule.action} {rule.action}
</span> </span>
</td> </td>
<td className={tdClass}>{rule.group}</td> <td {...stylex.props(shared.td)}>{rule.group}</td>
<td className={tdClass}>{formatTime(rule.created_at)}</td> <td {...stylex.props(shared.td)}>{formatTime(rule.created_at)}</td>
<td className={tdClass}> <td {...stylex.props(shared.td)}>
<button <button
type="button" type="button"
onClick={() => deleteRule(rule)} onClick={() => setPendingDelete(rule)}
disabled={remove.isPending || readOnly} disabled={remove.isPending || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined} title={readOnly ? READ_ONLY_HINT : undefined}
className={dangerLinkButtonClass} {...stylex.props(shared.dangerLinkButton, shared.focusRing)}
> >
Delete Delete
</button> </button>
@@ -94,10 +161,10 @@ export default function RulesPage() {
)} )}
<InlineError error={remove.error} /> <InlineError error={remove.error} />
<form onSubmit={onSubmit} className="mt-6 max-w-xl space-y-3"> <form onSubmit={onSubmit} {...stylex.props(styles.form)}>
<h2 className="text-lg font-medium">Create rule</h2> <h2 {...stylex.props(styles.formHeading)}>Create rule</h2>
<div> <div>
<label htmlFor="rule-pattern" className="block text-sm font-medium"> <label htmlFor="rule-pattern" {...stylex.props(styles.fieldLabel)}>
Pattern Pattern
</label> </label>
<input <input
@@ -107,66 +174,54 @@ export default function RulesPage() {
value={pattern} value={pattern}
onChange={(event) => setPattern(event.target.value)} onChange={(event) => setPattern(event.target.value)}
placeholder="ads.example.com or *.example.com" placeholder="ads.example.com or *.example.com"
className={inputClass} {...stylex.props(shared.input, shared.focusRing)}
/> />
</div> </div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3"> <div {...stylex.props(styles.fieldGrid)}>
<div> <Select
<label htmlFor="rule-kind" className="block text-sm font-medium"> label="Kind"
Kind value={kind}
</label> onChange={(value) => setKind(value as RuleKind)}
<select options={KIND_OPTIONS}
id="rule-kind" />
value={kind} <Select
onChange={(event) => setKind(event.target.value as RuleKind)} label="Action"
className={inputClass} value={action}
> onChange={(value) => setAction(value as RuleAction)}
<option value="exact">exact</option> options={ACTION_OPTIONS}
<option value="wildcard">wildcard</option> />
</select> <Select
</div> label="Group"
<div> value={String(groupId)}
<label htmlFor="rule-action" className="block text-sm font-medium"> onChange={(value) => setGroupId(Number(value))}
Action options={groups.map((group) => ({ value: String(group.id), label: group.name }))}
</label> />
<select </div>
id="rule-action" <div {...stylex.props(styles.submitRow)}>
value={action} <button
onChange={(event) => setAction(event.target.value as RuleAction)} type="submit"
className={inputClass} disabled={create.isPending || readOnly}
> title={readOnly ? READ_ONLY_HINT : undefined}
<option value="allow">allow</option> {...stylex.props(shared.primaryButton, shared.focusRing)}
<option value="block">block</option> >
</select> {create.isPending ? "Creating…" : "Create rule"}
</div> </button>
<div>
<label htmlFor="rule-group" className="block text-sm font-medium">
Group
</label>
<select
id="rule-group"
value={groupId}
onChange={(event) => setGroupId(Number(event.target.value))}
className={inputClass}
>
{groups.map((group) => (
<option key={group.id} value={group.id}>
{group.name}
</option>
))}
</select>
</div>
</div> </div>
<button
type="submit"
disabled={create.isPending || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
className={primaryButtonClass}
>
{create.isPending ? "Creating…" : "Create rule"}
</button>
<InlineError error={create.error} /> <InlineError error={create.error} />
</form> </form>
<ConfirmDialog
isOpen={pendingDelete !== null}
title="Delete rule"
message={
pendingDelete === null
? ""
: `Delete the ${pendingDelete.action} rule for "${pendingDelete.pattern}"?`
}
confirmLabel="Delete"
onConfirm={confirmDelete}
onCancel={() => setPendingDelete(null)}
/>
</section> </section>
); );
} }
@@ -1,5 +1,22 @@
import * as stylex from "@stylexjs/stylex";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
import { useAuthority } from "./authority"; import { useAuthority } from "./authority";
const styles = stylex.create({
banner: {
borderBottomWidth: 1,
borderBottomStyle: "solid",
borderBottomColor: colors.warnBorder,
backgroundColor: colors.warnSurface,
color: colors.warnText,
paddingInline: "1rem",
paddingBlock: "0.5rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
});
/** /**
* File authority is a standing condition, not an event, so this banner has no * File authority is a standing condition, not an event, so this banner has no
* dismiss button: it stays up for as long as the process runs from a file. * dismiss button: it stays up for as long as the process runs from a file.
@@ -8,12 +25,9 @@ export default function ReadOnlyConfigBanner() {
const authority = useAuthority(); const authority = useAuthority();
if (authority?.mode !== "managed_file") return null; if (authority?.mode !== "managed_file") return null;
return ( return (
<div <div role="status" {...stylex.props(styles.banner)}>
role="status" Configuration is managed by <code {...stylex.props(shared.mono)}>{authority.path}</code>. Edit the file and
className="border-b border-amber-300 bg-amber-50 px-4 py-2 text-sm text-amber-900 dark:border-amber-800 dark:bg-amber-950 dark:text-amber-100" restart nxdns to change it; the server rejects edits made here.
>
Configuration is managed by <code className="font-mono">{authority.path}</code>. Edit the file and restart
nxdns to change it; the server rejects edits made here.
</div> </div>
); );
} }
+38 -11
View File
@@ -1,20 +1,47 @@
import * as stylex from "@stylexjs/stylex";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
import { dismissRestartBanner, useRestartBanner } from "./restartBanner"; import { dismissRestartBanner, useRestartBanner } from "./restartBanner";
import { focusRing } from "@/ui/classes";
const styles = stylex.create({
banner: {
display: "flex",
alignItems: "center",
gap: "0.75rem",
borderBottomWidth: 1,
borderBottomStyle: "solid",
borderBottomColor: colors.warnBorder,
backgroundColor: colors.warnSurface,
color: colors.warnText,
paddingInline: "1rem",
paddingBlock: "0.5rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
message: {
flex: 1,
},
dismiss: {
borderRadius: "0.25rem",
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.warnBorderStrong,
backgroundColor: "transparent",
color: "inherit",
paddingInline: "0.5rem",
paddingBlock: "0.25rem",
fontSize: "0.75rem",
lineHeight: "1rem",
},
});
export default function RestartBanner() { export default function RestartBanner() {
const raised = useRestartBanner(); const raised = useRestartBanner();
if (!raised) return null; if (!raised) return null;
return ( return (
<div <div role="status" {...stylex.props(styles.banner)}>
role="status" <span {...stylex.props(styles.message)}>Changes saved. Restart nxdns to apply.</span>
className="flex items-center gap-3 border-b border-amber-300 bg-amber-50 px-4 py-2 text-sm text-amber-900 dark:border-amber-800 dark:bg-amber-950 dark:text-amber-100" <button type="button" onClick={dismissRestartBanner} {...stylex.props(styles.dismiss, shared.focusRing)}>
>
<span className="flex-1">Changes saved. Restart nxdns to apply.</span>
<button
type="button"
onClick={dismissRestartBanner}
className={`rounded border border-amber-400 px-2 py-1 text-xs ${focusRing} dark:border-amber-700`}
>
Dismiss Dismiss
</button> </button>
</div> </div>
@@ -130,7 +130,11 @@ test("a changed field enables Save and the PUT body is exactly the diff", async
test("enum and boolean fields diff as their own types", async () => { test("enum and boolean fields diff as their own types", async () => {
await renderPage(); await renderPage();
const logging = screen.getByRole("group", { name: "Logging" }); const logging = screen.getByRole("group", { name: "Logging" });
fireEvent.change(within(logging).getByLabelText("level"), { target: { value: "debug" } }); // A RAC Select names its trigger with the current value and then the label, and
// carries the options only while the listbox is open.
fireEvent.click(within(logging).getByRole("button", { name: /level$/ }));
fireEvent.click(await screen.findByRole("option", { name: "debug" }));
await waitFor(() => expect(screen.queryByRole("listbox")).toBeNull());
fireEvent.click(within(logging).getByLabelText("hide_domains")); fireEvent.click(within(logging).getByLabelText("hide_domains"));
fireEvent.click(saveButton()); fireEvent.click(saveButton());
await waitFor(() => expect(putBodies).toHaveLength(1)); await waitFor(() => expect(putBodies).toHaveLength(1));
+154 -48
View File
@@ -1,12 +1,15 @@
import { useState, type FormEvent } from "react"; import { useState, type FormEvent } from "react";
import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query"; import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query";
import * as stylex from "@stylexjs/stylex";
import InlineError from "@/lib/InlineError"; import InlineError from "@/lib/InlineError";
import { settingsPutMutation, settingsQuery } from "@/lib/queries"; import { settingsPutMutation, settingsQuery } from "@/lib/queries";
import { buildSettingsPatch } from "@/lib/settingsDiff"; import { buildSettingsPatch } from "@/lib/settingsDiff";
import type { Settings, SettingsPatch } from "@/lib/types"; import type { Settings, SettingsPatch } from "@/lib/types";
import { raiseRestartBanner } from "./restartBanner"; import { raiseRestartBanner } from "./restartBanner";
import { READ_ONLY_HINT, useReadOnlyConfig } from "./authority"; import { READ_ONLY_HINT, useReadOnlyConfig } from "./authority";
import { focusRing } from "@/ui/classes"; import Select from "@/ui/Select";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
/** True when the patch touches anything besides the write-only `web.password` (ruling 11). */ /** True when the patch touches anything besides the write-only `web.password` (ruling 11). */
export function patchRequiresRestart(patch: SettingsPatch): boolean { export function patchRequiresRestart(patch: SettingsPatch): boolean {
@@ -139,8 +142,121 @@ const SECTIONS: readonly AnySectionDef[] = [
}), }),
]; ];
const LABEL_CLASS = "text-sm text-zinc-700 dark:text-zinc-300"; const DARK = "@media (prefers-color-scheme: dark)";
const fieldInputClass = `rounded border border-zinc-300 bg-white px-2 py-1 text-sm ${focusRing} dark:border-zinc-700 dark:bg-zinc-900`;
const styles = stylex.create({
heading: {
fontSize: "1.5rem",
lineHeight: "2rem",
fontWeight: 600,
},
intro: {
marginTop: "0.25rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.textMuted,
},
form: {
marginTop: "1rem",
maxWidth: "48rem",
},
/** A `fieldset` has a browser default border and padding; the layout wants neither. */
sections: {
display: "flex",
flexDirection: "column",
gap: "1.5rem",
borderStyle: "none",
margin: 0,
padding: 0,
},
section: {
borderRadius: "0.25rem",
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.border,
padding: "1rem",
},
legend: {
paddingInline: "0.25rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
fontWeight: 600,
},
/** One column on a phone, two from `sm`, as before. */
fieldGrid: {
display: "grid",
gap: "0.75rem",
gridTemplateColumns: {
default: "repeat(1, minmax(0, 1fr))",
"@media (min-width: 640px)": "repeat(2, minmax(0, 1fr))",
},
},
label: {
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: {
default: "oklch(37% 0.013 285.805)",
[DARK]: "oklch(87.1% 0.006 286.286)",
},
},
checkboxRow: {
display: "flex",
alignItems: "center",
gap: "0.5rem",
},
field: {
display: "flex",
flexDirection: "column",
gap: "0.25rem",
},
fieldInput: {
borderRadius: "0.25rem",
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.borderStrong,
backgroundColor: colors.surfaceRaised,
color: colors.text,
paddingInline: "0.5rem",
paddingBlock: "0.25rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
derived: {
color: colors.textMuted,
},
/** Both notices span the whole grid so the wrapped sentence stays readable. */
spanRow: {
gridColumn: { default: null, "@media (min-width: 640px)": "span 2 / span 2" },
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
passwordNotice: {
color: { default: "oklch(55.5% 0.163 48.998)", [DARK]: "oklch(82.8% 0.189 84.429)" },
},
mismatchNotice: {
color: colors.danger,
},
submitRow: {
display: "flex",
alignItems: "center",
gap: "0.75rem",
},
save: {
borderStyle: "none",
borderRadius: "0.25rem",
paddingInline: "1rem",
paddingBlock: "0.375rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
fontWeight: 500,
backgroundColor: {
default: colors.primary,
":disabled": "oklch(87.1% 0.006 286.286)",
[DARK]: { default: colors.primary, ":disabled": "oklch(27.4% 0.006 286.033)" },
},
color: { default: colors.primaryText, ":disabled": "oklch(55.2% 0.016 285.938)" },
},
});
function FieldRow({ function FieldRow({
section, section,
@@ -156,15 +272,15 @@ function FieldRow({
const id = `${section}.${def.key}`; const id = `${section}.${def.key}`;
if (def.kind === "boolean") { if (def.kind === "boolean") {
return ( return (
<div className="flex items-center gap-2"> <div {...stylex.props(styles.checkboxRow)}>
<input <input
id={id} id={id}
type="checkbox" type="checkbox"
checked={value as boolean} checked={value as boolean}
onChange={(e) => onChange(e.target.checked)} onChange={(e) => onChange(e.target.checked)}
className={focusRing} {...stylex.props(shared.focusRing)}
/> />
<label htmlFor={id} className={LABEL_CLASS}> <label htmlFor={id} {...stylex.props(styles.label)}>
{def.key} {def.key}
</label> </label>
</div> </div>
@@ -172,30 +288,20 @@ function FieldRow({
} }
if (Array.isArray(def.kind)) { if (Array.isArray(def.kind)) {
return ( return (
<div className="flex flex-col gap-1"> <Select
<label htmlFor={id} className={LABEL_CLASS}> variant="inline"
{def.key} label={def.key}
</label> value={value as string}
<select onChange={onChange}
id={id} options={def.kind.map((option) => ({ value: option, label: option }))}
value={value as string} />
onChange={(e) => onChange(e.target.value)}
className={fieldInputClass}
>
{def.kind.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
</div>
); );
} }
if (def.kind === "number") { if (def.kind === "number") {
const numeric = value as number; const numeric = value as number;
return ( return (
<div className="flex flex-col gap-1"> <div {...stylex.props(styles.field)}>
<label htmlFor={id} className={LABEL_CLASS}> <label htmlFor={id} {...stylex.props(styles.label)}>
{def.key} {def.key}
</label> </label>
<input <input
@@ -203,14 +309,14 @@ function FieldRow({
type="number" type="number"
value={Number.isNaN(numeric) ? "" : numeric} value={Number.isNaN(numeric) ? "" : numeric}
onChange={(e) => onChange(e.target.valueAsNumber)} onChange={(e) => onChange(e.target.valueAsNumber)}
className={fieldInputClass} {...stylex.props(styles.fieldInput, shared.focusRing)}
/> />
</div> </div>
); );
} }
return ( return (
<div className="flex flex-col gap-1"> <div {...stylex.props(styles.field)}>
<label htmlFor={id} className={LABEL_CLASS}> <label htmlFor={id} {...stylex.props(styles.label)}>
{def.key} {def.key}
</label> </label>
<input <input
@@ -218,7 +324,7 @@ function FieldRow({
type="text" type="text"
value={value as string} value={value as string}
onChange={(e) => onChange(e.target.value)} onChange={(e) => onChange(e.target.value)}
className={fieldInputClass} {...stylex.props(styles.fieldInput, shared.focusRing)}
/> />
</div> </div>
); );
@@ -270,16 +376,16 @@ export default function SettingsPage() {
return ( return (
<section> <section>
<h1 className="text-2xl font-semibold">Settings</h1> <h1 {...stylex.props(styles.heading)}>Settings</h1>
<p className="mt-1 text-sm text-zinc-500"> <p {...stylex.props(styles.intro)}>
Changes are validated as a whole; every setting requires a restart to take effect. Changes are validated as a whole; every setting requires a restart to take effect.
</p> </p>
<form onSubmit={handleSubmit} className="mt-4 max-w-3xl"> <form onSubmit={handleSubmit} {...stylex.props(styles.form)}>
<fieldset disabled={mutation.isPending || readOnly} className="space-y-6"> <fieldset disabled={mutation.isPending || readOnly} {...stylex.props(styles.sections)}>
{SECTIONS.map(({ section, title, fields }) => ( {SECTIONS.map(({ section, title, fields }) => (
<fieldset key={section} className="rounded border border-zinc-200 p-4 dark:border-zinc-800"> <fieldset key={section} {...stylex.props(styles.section)}>
<legend className="px-1 text-sm font-semibold">{title}</legend> <legend {...stylex.props(styles.legend)}>{title}</legend>
<div className="grid gap-3 sm:grid-cols-2"> <div {...stylex.props(styles.fieldGrid)}>
{(fields as readonly AnyFieldDef[]).map((def) => ( {(fields as readonly AnyFieldDef[]).map((def) => (
<FieldRow <FieldRow
key={def.key} key={def.key}
@@ -291,12 +397,12 @@ export default function SettingsPage() {
))} ))}
{section === "web" && ( {section === "web" && (
<> <>
<p className={LABEL_CLASS}> <p {...stylex.props(styles.label)}>
auth_enabled: {data.settings.web.auth_enabled ? "true" : "false"}{" "} auth_enabled: {data.settings.web.auth_enabled ? "true" : "false"}{" "}
<span className="text-zinc-500">(derived, read-only)</span> <span {...stylex.props(styles.derived)}>(derived, read-only)</span>
</p> </p>
<div className="flex flex-col gap-1"> <div {...stylex.props(styles.field)}>
<label htmlFor="web.password" className={LABEL_CLASS}> <label htmlFor="web.password" {...stylex.props(styles.label)}>
password password
</label> </label>
<input <input
@@ -305,11 +411,11 @@ export default function SettingsPage() {
autoComplete="new-password" autoComplete="new-password"
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
className={fieldInputClass} {...stylex.props(styles.fieldInput, shared.focusRing)}
/> />
</div> </div>
<div className="flex flex-col gap-1"> <div {...stylex.props(styles.field)}>
<label htmlFor="web.password_confirm" className={LABEL_CLASS}> <label htmlFor="web.password_confirm" {...stylex.props(styles.label)}>
confirm password confirm password
</label> </label>
<input <input
@@ -318,17 +424,17 @@ export default function SettingsPage() {
autoComplete="new-password" autoComplete="new-password"
value={confirm} value={confirm}
onChange={(e) => setConfirm(e.target.value)} onChange={(e) => setConfirm(e.target.value)}
className={fieldInputClass} {...stylex.props(styles.fieldInput, shared.focusRing)}
/> />
</div> </div>
{password !== "" && ( {password !== "" && (
<p className="text-sm text-amber-700 sm:col-span-2 dark:text-amber-400"> <p {...stylex.props(styles.spanRow, styles.passwordNotice)}>
Changing the password signs out every session; you will be asked to log Changing the password signs out every session; you will be asked to log
in again. in again.
</p> </p>
)} )}
{passwordsMismatch && ( {passwordsMismatch && (
<p className="text-sm text-red-700 sm:col-span-2 dark:text-red-400"> <p {...stylex.props(styles.spanRow, styles.mismatchNotice)}>
Passwords do not match. Passwords do not match.
</p> </p>
)} )}
@@ -337,12 +443,12 @@ export default function SettingsPage() {
</div> </div>
</fieldset> </fieldset>
))} ))}
<div className="flex items-center gap-3"> <div {...stylex.props(styles.submitRow)}>
<button <button
type="submit" type="submit"
disabled={saveDisabled} disabled={saveDisabled}
title={readOnly ? READ_ONLY_HINT : undefined} title={readOnly ? READ_ONLY_HINT : undefined}
className={`rounded bg-blue-600 px-4 py-1.5 text-sm font-medium text-white ${focusRing} disabled:bg-zinc-300 disabled:text-zinc-500 dark:disabled:bg-zinc-800`} {...stylex.props(styles.save, shared.focusRing)}
> >
{mutation.isPending ? "Saving…" : "Save"} {mutation.isPending ? "Saving…" : "Save"}
</button> </button>
+64 -15
View File
@@ -1,7 +1,9 @@
import { useState, type FormEvent } from "react"; import { useState, type FormEvent } from "react";
import * as stylex from "@stylexjs/stylex";
import InlineError from "@/lib/InlineError"; import InlineError from "@/lib/InlineError";
import type { Upstream, UpstreamInput } from "@/lib/types"; import type { Upstream, UpstreamInput } from "@/lib/types";
import { buttonClass, focusRing, inputClass, primaryButtonClass } from "@/ui/classes"; import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
import { READ_ONLY_HINT } from "@/features/settings/authority"; import { READ_ONLY_HINT } from "@/features/settings/authority";
const DEFAULT_PRIORITY = "100"; const DEFAULT_PRIORITY = "100";
@@ -16,6 +18,49 @@ interface UpstreamFormProps {
onCancel?: () => void; onCancel?: () => void;
} }
const styles = stylex.create({
form: {
display: "flex",
flexDirection: "column",
gap: "0.75rem",
marginTop: "1rem",
maxWidth: "36rem",
},
heading: {
fontSize: "1.125rem",
lineHeight: "1.75rem",
fontWeight: 500,
},
fieldLabel: {
display: "block",
fontSize: "0.875rem",
lineHeight: "1.25rem",
fontWeight: 500,
},
hint: {
marginTop: "0.25rem",
fontSize: "0.75rem",
lineHeight: "1rem",
color: colors.textMuted,
},
checkboxLabel: {
display: "flex",
alignItems: "center",
gap: "0.5rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
fontWeight: 500,
},
actions: {
display: "flex",
alignItems: "center",
gap: "0.5rem",
},
cancelButton: {
fontWeight: 500,
},
});
export default function UpstreamForm({ initial, busy, readOnly, error, onSubmit, onCancel }: UpstreamFormProps) { export default function UpstreamForm({ initial, busy, readOnly, error, onSubmit, onCancel }: UpstreamFormProps) {
const [url, setUrl] = useState(initial?.url ?? ""); const [url, setUrl] = useState(initial?.url ?? "");
const [priority, setPriority] = useState(initial === undefined ? DEFAULT_PRIORITY : String(initial.priority)); const [priority, setPriority] = useState(initial === undefined ? DEFAULT_PRIORITY : String(initial.priority));
@@ -45,10 +90,10 @@ export default function UpstreamForm({ initial, busy, readOnly, error, onSubmit,
} }
return ( return (
<form onSubmit={handleSubmit} className="mt-4 max-w-xl space-y-3"> <form onSubmit={handleSubmit} {...stylex.props(styles.form)}>
<h2 className="text-lg font-medium">{initial === undefined ? "Add upstream" : `Edit ${initial.url}`}</h2> <h2 {...stylex.props(styles.heading)}>{initial === undefined ? "Add upstream" : `Edit ${initial.url}`}</h2>
<div> <div>
<label htmlFor="upstream-url" className="block text-sm font-medium"> <label htmlFor="upstream-url" {...stylex.props(styles.fieldLabel)}>
URL URL
</label> </label>
<input <input
@@ -58,11 +103,11 @@ export default function UpstreamForm({ initial, busy, readOnly, error, onSubmit,
value={url} value={url}
onChange={(event) => setUrl(event.target.value)} onChange={(event) => setUrl(event.target.value)}
placeholder="udp://1.1.1.1:53" placeholder="udp://1.1.1.1:53"
className={inputClass} {...stylex.props(shared.input, shared.focusRing)}
/> />
</div> </div>
<div> <div>
<label htmlFor="upstream-priority" className="block text-sm font-medium"> <label htmlFor="upstream-priority" {...stylex.props(styles.fieldLabel)}>
Priority Priority
</label> </label>
<input <input
@@ -71,11 +116,11 @@ export default function UpstreamForm({ initial, busy, readOnly, error, onSubmit,
min={0} min={0}
value={priority} value={priority}
onChange={(event) => setPriority(event.target.value)} onChange={(event) => setPriority(event.target.value)}
className={inputClass} {...stylex.props(shared.input, shared.focusRing)}
/> />
</div> </div>
<div> <div>
<label htmlFor="upstream-tls-name" className="block text-sm font-medium"> <label htmlFor="upstream-tls-name" {...stylex.props(styles.fieldLabel)}>
TLS name TLS name
</label> </label>
<input <input
@@ -84,32 +129,36 @@ export default function UpstreamForm({ initial, busy, readOnly, error, onSubmit,
value={tlsName} value={tlsName}
onChange={(event) => setTlsName(event.target.value)} onChange={(event) => setTlsName(event.target.value)}
placeholder="one.one.one.one" placeholder="one.one.one.one"
className={inputClass} {...stylex.props(shared.input, shared.focusRing)}
/> />
<p className="mt-1 text-xs text-zinc-500"> <p {...stylex.props(styles.hint)}>
The SNI and certificate name for a <code>tls://</code> upstream. Leave empty for every other scheme. The SNI and certificate name for a <code>tls://</code> upstream. Leave empty for every other scheme.
</p> </p>
</div> </div>
<label className="flex items-center gap-2 text-sm font-medium"> <label {...stylex.props(styles.checkboxLabel)}>
<input <input
type="checkbox" type="checkbox"
checked={enabled} checked={enabled}
onChange={(event) => setEnabled(event.target.checked)} onChange={(event) => setEnabled(event.target.checked)}
className={focusRing} {...stylex.props(shared.focusRing)}
/> />
Enabled Enabled
</label> </label>
<div className="flex items-center gap-2"> <div {...stylex.props(styles.actions)}>
<button <button
type="submit" type="submit"
disabled={busy || readOnly} disabled={busy || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined} title={readOnly ? READ_ONLY_HINT : undefined}
className={primaryButtonClass} {...stylex.props(shared.primaryButton, shared.focusRing)}
> >
{initial === undefined ? "Add upstream" : "Save changes"} {initial === undefined ? "Add upstream" : "Save changes"}
</button> </button>
{onCancel !== undefined && ( {onCancel !== undefined && (
<button type="button" onClick={onCancel} className={`${buttonClass} font-medium`}> <button
type="button"
onClick={onCancel}
{...stylex.props(shared.button, styles.cancelButton, shared.focusRing)}
>
Cancel Cancel
</button> </button>
)} )}
@@ -134,15 +134,29 @@ test("toggling enabled resends the whole row", async () => {
await screen.findByRole("status"); await screen.findByRole("status");
}); });
test("delete asks for confirmation and skips the request when refused", async () => { /** The row Delete opens the dialog; the dialog's own Delete is the confirm. */
async function openDeleteDialog() {
fireEvent.click(screen.getAllByRole("button", { name: "Delete" })[0]!);
return await screen.findByRole("alertdialog");
}
test("delete asks for confirmation and skips the request when cancelled", async () => {
await renderUpstreamsRoute(); await renderUpstreamsRoute();
vi.spyOn(window, "confirm").mockReturnValue(false); const dialog = await openDeleteDialog();
fireEvent.click(screen.getAllByRole("button", { name: "Delete" })[0]!); expect(dialog.textContent).toContain('Delete upstream "udp://1.1.1.1:53"?');
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
await waitFor(() => expect(screen.queryByRole("alertdialog")).toBeNull());
expect(calls).toHaveLength(0); expect(calls).toHaveLength(0);
});
test("confirming the delete dialog issues the DELETE and raises the restart banner", async () => {
await renderUpstreamsRoute();
await openDeleteDialog();
fireEvent.click(screen.getByRole("button", { name: "Delete" }));
vi.spyOn(window, "confirm").mockReturnValue(true);
fireEvent.click(screen.getAllByRole("button", { name: "Delete" })[0]!);
await waitFor(() => expect(calls).toHaveLength(1)); await waitFor(() => expect(calls).toHaveLength(1));
expect(calls[0]!.method).toBe("DELETE"); expect(calls[0]!.method).toBe("DELETE");
expect(calls[0]!.url).toBe("/api/upstreams/1"); expect(calls[0]!.url).toBe("/api/upstreams/1");
@@ -185,14 +199,14 @@ test("a 409 on toggle renders the last-enabled conflict above the form", async (
test("a 409 on delete renders the last-enabled conflict", async () => { test("a 409 on delete renders the last-enabled conflict", async () => {
await renderUpstreamsRoute(); await renderUpstreamsRoute();
vi.spyOn(window, "confirm").mockReturnValue(true);
writeResponse = () => writeResponse = () =>
new Response(JSON.stringify({ error: "the last enabled upstream cannot be removed" }), { new Response(JSON.stringify({ error: "the last enabled upstream cannot be removed" }), {
status: 409, status: 409,
headers: { "content-type": "application/json" }, headers: { "content-type": "application/json" },
}); });
fireEvent.click(screen.getAllByRole("button", { name: "Delete" })[0]!); await openDeleteDialog();
fireEvent.click(screen.getByRole("button", { name: "Delete" }));
const alert = await screen.findByRole("alert"); const alert = await screen.findByRole("alert");
expect(alert.textContent).toBe("the last enabled upstream cannot be removed"); expect(alert.textContent).toBe("the last enabled upstream cannot be removed");
+89 -27
View File
@@ -1,17 +1,62 @@
import { useState } from "react"; import { useState } from "react";
import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query"; import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query";
import * as stylex from "@stylexjs/stylex";
import InlineError from "@/lib/InlineError"; import InlineError from "@/lib/InlineError";
import { upstreamCreateMutation, upstreamDeleteMutation, upstreamUpdateMutation, upstreamsQuery } from "@/lib/queries"; import { upstreamCreateMutation, upstreamDeleteMutation, upstreamUpdateMutation, upstreamsQuery } from "@/lib/queries";
import type { Upstream, UpstreamInput } from "@/lib/types"; import type { Upstream, UpstreamInput } from "@/lib/types";
import { raiseRestartBanner } from "../settings/restartBanner"; import { raiseRestartBanner } from "../settings/restartBanner";
import UpstreamForm from "./UpstreamForm"; import UpstreamForm from "./UpstreamForm";
import { dangerLinkButtonClass, focusRing, linkButtonClass, tableWrapClass, tdClass, thClass } from "@/ui/classes"; import ConfirmDialog from "@/ui/ConfirmDialog";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority"; import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority";
const styles = stylex.create({
heading: {
fontSize: "1.5rem",
lineHeight: "2rem",
fontWeight: 600,
},
intro: {
marginTop: "0.5rem",
maxWidth: "42rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.textMuted,
},
empty: {
marginTop: "1rem",
color: colors.textMuted,
},
table: {
width: "100%",
minWidth: "max-content",
borderCollapse: "collapse",
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
url: {
display: "block",
maxWidth: "18rem",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
fontWeight: 500,
},
actions: {
display: "flex",
gap: "0.75rem",
},
dimWhenDisabled: {
opacity: { default: 1, ":disabled": 0.5 },
},
});
export default function UpstreamsPage() { export default function UpstreamsPage() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { data: upstreams } = useSuspenseQuery(upstreamsQuery()); const { data: upstreams } = useSuspenseQuery(upstreamsQuery());
const [editing, setEditing] = useState<Upstream | null>(null); const [editing, setEditing] = useState<Upstream | null>(null);
const [pendingDelete, setPendingDelete] = useState<Upstream | null>(null);
const create = useMutation(upstreamCreateMutation(queryClient)); const create = useMutation(upstreamCreateMutation(queryClient));
const save = useMutation(upstreamUpdateMutation(queryClient)); const save = useMutation(upstreamUpdateMutation(queryClient));
@@ -39,10 +84,10 @@ export default function UpstreamsPage() {
); );
} }
function deleteUpstream(u: Upstream) { function confirmDelete() {
if (window.confirm(`Delete upstream "${u.url}"? Queries stop being forwarded to it.`)) { if (pendingDelete === null) return;
remove.mutate(u.id, { onSuccess: () => raiseRestartBanner() }); remove.mutate(pendingDelete.id, { onSuccess: () => raiseRestartBanner() });
} setPendingDelete(null);
} }
const formError = editing === null ? create.error : save.error; const formError = editing === null ? create.error : save.error;
@@ -50,38 +95,38 @@ export default function UpstreamsPage() {
return ( return (
<section> <section>
<h1 className="text-2xl font-semibold">Upstreams</h1> <h1 {...stylex.props(styles.heading)}>Upstreams</h1>
<p className="mt-2 max-w-2xl text-sm text-zinc-500"> <p {...stylex.props(styles.intro)}>
The pool builds its clients at startup, so an edit here takes effect at the next restart. The upstream The pool builds its clients at startup, so an edit here takes effect at the next restart. The upstream
health table on the Dashboard reflects the running pool, not this list. health table on the Dashboard reflects the running pool, not this list.
</p> </p>
{upstreams.length === 0 ? ( {upstreams.length === 0 ? (
<p className="mt-4 text-zinc-500">No upstreams yet. Add one below.</p> <p {...stylex.props(styles.empty)}>No upstreams yet. Add one below.</p>
) : ( ) : (
<div className={tableWrapClass}> <div {...stylex.props(shared.tableWrap)}>
<table className="w-full min-w-max border-collapse text-sm"> <table {...stylex.props(styles.table)}>
<thead> <thead>
<tr> <tr>
<th className={thClass}>URL</th> <th {...stylex.props(shared.th)}>URL</th>
<th className={thClass}>Priority</th> <th {...stylex.props(shared.th)}>Priority</th>
<th className={thClass}>Enabled</th> <th {...stylex.props(shared.th)}>Enabled</th>
<th className={thClass}>TLS name</th> <th {...stylex.props(shared.th)}>TLS name</th>
<th className={thClass}> <th {...stylex.props(shared.th)}>
<span className="sr-only">Actions</span> <span {...stylex.props(shared.srOnly)}>Actions</span>
</th> </th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{upstreams.map((u) => ( {upstreams.map((u) => (
<tr key={u.id}> <tr key={u.id}>
<td className={tdClass}> <td {...stylex.props(shared.td)}>
<span className="block max-w-72 truncate font-medium" title={u.url}> <span {...stylex.props(styles.url)} title={u.url}>
{u.url} {u.url}
</span> </span>
</td> </td>
<td className={`${tdClass} tabular-nums`}>{u.priority}</td> <td {...stylex.props(shared.td, shared.tabularNums)}>{u.priority}</td>
<td className={tdClass}> <td {...stylex.props(shared.td)}>
<input <input
type="checkbox" type="checkbox"
aria-label={`${u.url} enabled`} aria-label={`${u.url} enabled`}
@@ -89,27 +134,31 @@ export default function UpstreamsPage() {
disabled={toggle.isPending || readOnly} disabled={toggle.isPending || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined} title={readOnly ? READ_ONLY_HINT : undefined}
onChange={() => toggleEnabled(u)} onChange={() => toggleEnabled(u)}
className={focusRing} {...stylex.props(shared.focusRing)}
/> />
</td> </td>
<td className={tdClass}>{u.tls_name === "" ? "—" : u.tls_name}</td> <td {...stylex.props(shared.td)}>{u.tls_name === "" ? "—" : u.tls_name}</td>
<td className={tdClass}> <td {...stylex.props(shared.td)}>
<div className="flex gap-3"> <div {...stylex.props(styles.actions)}>
<button <button
type="button" type="button"
onClick={() => setEditing(u)} onClick={() => setEditing(u)}
disabled={readOnly} disabled={readOnly}
title={readOnly ? READ_ONLY_HINT : undefined} title={readOnly ? READ_ONLY_HINT : undefined}
className={`${linkButtonClass} disabled:opacity-50`} {...stylex.props(
shared.linkButton,
styles.dimWhenDisabled,
shared.focusRing,
)}
> >
Edit Edit
</button> </button>
<button <button
type="button" type="button"
onClick={() => deleteUpstream(u)} onClick={() => setPendingDelete(u)}
disabled={remove.isPending || readOnly} disabled={remove.isPending || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined} title={readOnly ? READ_ONLY_HINT : undefined}
className={dangerLinkButtonClass} {...stylex.props(shared.dangerLinkButton, shared.focusRing)}
> >
Delete Delete
</button> </button>
@@ -132,6 +181,19 @@ export default function UpstreamsPage() {
onSubmit={submitForm} onSubmit={submitForm}
onCancel={editing === null ? undefined : () => setEditing(null)} onCancel={editing === null ? undefined : () => setEditing(null)}
/> />
<ConfirmDialog
isOpen={pendingDelete !== null}
title="Delete upstream"
message={
pendingDelete === null
? ""
: `Delete upstream "${pendingDelete.url}"? Queries stop being forwarded to it.`
}
confirmLabel="Delete"
onConfirm={confirmDelete}
onCancel={() => setPendingDelete(null)}
/>
</section> </section>
); );
} }
+6 -1
View File
@@ -1,5 +1,7 @@
import { fireEvent, render, screen } from "@testing-library/react"; import { fireEvent, render, screen } from "@testing-library/react";
import * as stylex from "@stylexjs/stylex";
import { ApiError } from "@/lib/api"; import { ApiError } from "@/lib/api";
import { styles as shared } from "@/ui/styles";
import InlineError from "./InlineError"; import InlineError from "./InlineError";
test("no retry button without onRetry", () => { test("no retry button without onRetry", () => {
@@ -13,7 +15,10 @@ test("onRetry renders a focusable retry button that calls back", () => {
render(<InlineError error={new ApiError(500, "internal")} onRetry={onRetry} />); render(<InlineError error={new ApiError(500, "internal")} onRetry={onRetry} />);
const button = screen.getByRole("button", { name: "Retry" }); const button = screen.getByRole("button", { name: "Retry" });
expect(button.className).toContain("focus-visible:outline-2"); // The accessibility floor: StyleX compiles the ring to opaque class names, so
// the check is that every class `focusRing` produces landed on the button.
const ring = (stylex.props(shared.focusRing).className ?? "").split(" ");
expect(button.className.split(" ")).toEqual(expect.arrayContaining(ring));
fireEvent.click(button); fireEvent.click(button);
expect(onRetry).toHaveBeenCalledTimes(1); expect(onRetry).toHaveBeenCalledTimes(1);
}); });
+23 -3
View File
@@ -1,6 +1,26 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import * as stylex from "@stylexjs/stylex";
import { ApiError } from "@/lib/api"; import { ApiError } from "@/lib/api";
import { focusRing } from "@/ui/classes"; import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
const styles = stylex.create({
message: {
marginTop: "0.5rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.danger,
},
retry: {
borderStyle: "none",
backgroundColor: "transparent",
padding: 0,
color: "inherit",
fontSize: "inherit",
fontWeight: 500,
textDecorationLine: "underline",
},
});
/** /**
* Inline mutation error per ruling 17: 400/409 messages verbatim, 429 with * Inline mutation error per ruling 17: 400/409 messages verbatim, 429 with
@@ -36,12 +56,12 @@ export default function InlineError({ error, onRetry }: { error: unknown; onRetr
} }
return ( return (
<p role="alert" className="mt-2 text-sm text-red-600 dark:text-red-400"> <p role="alert" {...stylex.props(styles.message)}>
{message} {message}
{onRetry !== undefined && ( {onRetry !== undefined && (
<> <>
{" "} {" "}
<button type="button" onClick={onRetry} className={`font-medium underline ${focusRing}`}> <button type="button" onClick={onRetry} {...stylex.props(styles.retry, shared.focusRing)}>
Retry Retry
</button> </button>
</> </>
+40 -10
View File
@@ -1,4 +1,5 @@
import type { QueryClient } from "@tanstack/react-query"; import type { QueryClient } from "@tanstack/react-query";
import * as stylex from "@stylexjs/stylex";
import { import {
createRootRouteWithContext, createRootRouteWithContext,
createRoute, createRoute,
@@ -27,16 +28,44 @@ import {
upstreamHealthQuery, upstreamHealthQuery,
upstreamsQuery, upstreamsQuery,
} from "@/lib/queries"; } from "@/lib/queries";
import { retryButtonClass } from "@/ui/classes"; import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
export interface RouterContext { export interface RouterContext {
queryClient: QueryClient; queryClient: QueryClient;
} }
const styles = stylex.create({
pending: {
padding: "2rem",
textAlign: "center",
color: colors.textMuted,
},
errorBox: {
margin: "1rem",
borderRadius: "0.25rem",
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.dangerBorder,
backgroundColor: colors.dangerSurface,
padding: "1rem",
},
errorTitle: {
fontWeight: 600,
color: colors.dangerText,
},
errorDetail: {
marginTop: "0.25rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.dangerText,
},
});
function RoutePending() { function RoutePending() {
return ( return (
<div className="p-8 text-center text-zinc-500" role="status"> <div {...stylex.props(styles.pending)} role="status">
<span className="animate-pulse">Loading</span> <span {...stylex.props(shared.pulse)}>Loading</span>
</div> </div>
); );
} }
@@ -59,13 +88,14 @@ function RouteError({ error }: ErrorComponentProps) {
} }
} }
return ( return (
<div <div role="alert" {...stylex.props(styles.errorBox)}>
role="alert" <h2 {...stylex.props(styles.errorTitle)}>{title}</h2>
className="m-4 rounded border border-red-300 bg-red-50 p-4 dark:border-red-900 dark:bg-red-950" <p {...stylex.props(styles.errorDetail)}>{detail}</p>
> <button
<h2 className="font-semibold text-red-800 dark:text-red-200">{title}</h2> type="button"
<p className="mt-1 text-sm text-red-700 dark:text-red-300">{detail}</p> onClick={() => void router.invalidate()}
<button type="button" onClick={() => void router.invalidate()} className={retryButtonClass}> {...stylex.props(shared.retryButton, shared.focusRing)}
>
Retry Retry
</button> </button>
</div> </div>
+132 -23
View File
@@ -1,13 +1,19 @@
import { useState } from "react"; import { useState } from "react";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { Link, Outlet, useNavigate } from "@tanstack/react-router"; import { Link, Outlet, useNavigate } from "@tanstack/react-router";
import * as stylex from "@stylexjs/stylex";
import { useAuth } from "@/auth/store"; import { useAuth } from "@/auth/store";
import InlineError from "@/lib/InlineError"; import InlineError from "@/lib/InlineError";
import { versionQuery } from "@/lib/queries"; import { versionQuery } from "@/lib/queries";
import PauseWidget from "../features/pause/PauseWidget"; import PauseWidget from "../features/pause/PauseWidget";
import ReadOnlyConfigBanner from "../features/settings/ReadOnlyConfigBanner"; import ReadOnlyConfigBanner from "../features/settings/ReadOnlyConfigBanner";
import RestartBanner from "../features/settings/RestartBanner"; import RestartBanner from "../features/settings/RestartBanner";
import { buttonClass, focusRing } from "@/ui/classes"; import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
/** The one breakpoint the shell has: below it the sidebar becomes a drawer. */
const WIDE = "@media (min-width: 768px)";
const DARK = "@media (prefers-color-scheme: dark)";
const NAV_ITEMS = [ const NAV_ITEMS = [
{ to: "/", label: "Dashboard" }, { to: "/", label: "Dashboard" },
@@ -23,9 +29,115 @@ const NAV_ITEMS = [
{ to: "/settings", label: "Settings" }, { to: "/settings", label: "Settings" },
] as const; ] as const;
const styles = stylex.create({
navList: {
display: "flex",
flexDirection: "column",
gap: "0.25rem",
},
navLink: {
display: "block",
borderRadius: "0.25rem",
paddingInline: "0.75rem",
paddingBlock: "0.375rem",
textDecorationLine: "none",
},
/** The current page reads as a filled chip, heavier than the hover fill. */
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 },
},
versionFooter: {
paddingInline: "1rem",
paddingBlock: "0.75rem",
fontSize: "0.75rem",
lineHeight: "1rem",
color: colors.textMuted,
},
logoutRow: {
display: "flex",
alignItems: "center",
gap: "0.5rem",
},
shell: {
minHeight: "100dvh",
backgroundColor: colors.surface,
color: colors.text,
display: { default: "block", [WIDE]: "grid" },
gridTemplateColumns: { default: null, [WIDE]: "14rem 1fr" },
},
sidebar: {
display: { default: "none", [WIDE]: "flex" },
flexDirection: { default: null, [WIDE]: "column" },
borderRightWidth: 1,
borderRightStyle: "solid",
borderRightColor: colors.border,
},
brand: {
paddingInline: "1rem",
paddingBlock: "1rem",
fontSize: "1.125rem",
lineHeight: "1.75rem",
fontWeight: 600,
},
sidebarNav: {
flex: 1,
paddingInline: "0.5rem",
},
column: {
display: "flex",
minHeight: "100dvh",
flexDirection: "column",
},
header: {
display: "flex",
alignItems: "center",
gap: "0.75rem",
borderBottomWidth: 1,
borderBottomStyle: "solid",
borderBottomColor: colors.border,
paddingInline: "1rem",
paddingBlock: "0.5rem",
},
narrowOnly: {
display: { default: null, [WIDE]: "none" },
},
narrowBrand: {
fontSize: "1.125rem",
lineHeight: "1.75rem",
fontWeight: 600,
display: { default: null, [WIDE]: "none" },
},
headerRight: {
marginLeft: "auto",
display: "flex",
alignItems: "center",
gap: "0.75rem",
},
drawer: {
borderBottomWidth: 1,
borderBottomStyle: "solid",
borderBottomColor: colors.border,
display: { default: null, [WIDE]: "none" },
},
drawerNav: {
paddingInline: "0.5rem",
paddingBlock: "0.5rem",
},
main: {
flex: 1,
padding: "1rem",
},
});
function NavLinks({ onNavigate }: { onNavigate?: () => void }) { function NavLinks({ onNavigate }: { onNavigate?: () => void }) {
return ( return (
<ul className="space-y-1"> <ul {...stylex.props(styles.navList)}>
{NAV_ITEMS.map((item) => ( {NAV_ITEMS.map((item) => (
<li key={item.to}> <li key={item.to}>
<Link <Link
@@ -34,13 +146,10 @@ function NavLinks({ onNavigate }: { onNavigate?: () => void }) {
activeOptions={{ exact: item.to === "/" }} activeOptions={{ exact: item.to === "/" }}
activeProps={{ activeProps={{
"aria-current": "page", "aria-current": "page",
className: "bg-zinc-200 font-medium text-zinc-900 dark:bg-zinc-800 dark:text-zinc-50", className: stylex.props(styles.navActive).className,
}} }}
inactiveProps={{ inactiveProps={{ className: stylex.props(styles.navIdle).className }}
className: {...stylex.props(styles.navLink, shared.focusRing)}
"text-zinc-600 hover:bg-zinc-100 hover:text-zinc-900 dark:text-zinc-400 dark:hover:bg-zinc-900 dark:hover:text-zinc-100",
}}
className={`block rounded px-3 py-1.5 ${focusRing}`}
> >
{item.label} {item.label}
</Link> </Link>
@@ -53,7 +162,7 @@ function NavLinks({ onNavigate }: { onNavigate?: () => void }) {
function VersionFooter() { function VersionFooter() {
const { data } = useQuery(versionQuery()); const { data } = useQuery(versionQuery());
return ( return (
<footer className="px-4 py-3 text-xs text-zinc-500"> <footer {...stylex.props(styles.versionFooter)}>
{data === undefined ? "nxdns" : `nxdns v${data.version} (${data.git_commit.slice(0, 7)})`} {data === undefined ? "nxdns" : `nxdns v${data.version} (${data.git_commit.slice(0, 7)})`}
</footer> </footer>
); );
@@ -65,7 +174,7 @@ function LogoutButton() {
const [error, setError] = useState<unknown>(null); const [error, setError] = useState<unknown>(null);
if (authRequired !== true) return null; if (authRequired !== true) return null;
return ( return (
<div className="flex items-center gap-2"> <div {...stylex.props(styles.logoutRow)}>
<button <button
type="button" type="button"
onClick={() => { onClick={() => {
@@ -75,7 +184,7 @@ function LogoutButton() {
(logoutError: unknown) => setError(logoutError), (logoutError: unknown) => setError(logoutError),
); );
}} }}
className={buttonClass} {...stylex.props(shared.button, shared.focusRing)}
> >
Log out Log out
</button> </button>
@@ -87,27 +196,27 @@ function LogoutButton() {
export default function AppShell() { export default function AppShell() {
const [drawerOpen, setDrawerOpen] = useState(false); const [drawerOpen, setDrawerOpen] = useState(false);
return ( return (
<div className="min-h-dvh bg-zinc-50 text-zinc-900 md:grid md:grid-cols-[14rem_1fr] dark:bg-zinc-950 dark:text-zinc-100"> <div {...stylex.props(styles.shell)}>
<aside className="hidden border-r border-zinc-200 md:flex md:flex-col dark:border-zinc-800"> <aside {...stylex.props(styles.sidebar)}>
<div className="px-4 py-4 text-lg font-semibold">nxdns</div> <div {...stylex.props(styles.brand)}>nxdns</div>
<nav aria-label="Main" className="flex-1 px-2"> <nav aria-label="Main" {...stylex.props(styles.sidebarNav)}>
<NavLinks /> <NavLinks />
</nav> </nav>
<VersionFooter /> <VersionFooter />
</aside> </aside>
<div className="flex min-h-dvh flex-col"> <div {...stylex.props(styles.column)}>
<header className="flex items-center gap-3 border-b border-zinc-200 px-4 py-2 dark:border-zinc-800"> <header {...stylex.props(styles.header)}>
<button <button
type="button" type="button"
className={`${buttonClass} md:hidden`}
aria-expanded={drawerOpen} aria-expanded={drawerOpen}
aria-controls="mobile-nav" aria-controls="mobile-nav"
onClick={() => setDrawerOpen((open) => !open)} onClick={() => setDrawerOpen((open) => !open)}
{...stylex.props(shared.button, styles.narrowOnly, shared.focusRing)}
> >
Menu Menu
</button> </button>
<span className="text-lg font-semibold md:hidden">nxdns</span> <span {...stylex.props(styles.narrowBrand)}>nxdns</span>
<div className="ml-auto flex items-center gap-3"> <div {...stylex.props(styles.headerRight)}>
<PauseWidget /> <PauseWidget />
<LogoutButton /> <LogoutButton />
</div> </div>
@@ -115,14 +224,14 @@ export default function AppShell() {
<RestartBanner /> <RestartBanner />
<ReadOnlyConfigBanner /> <ReadOnlyConfigBanner />
{drawerOpen && ( {drawerOpen && (
<div id="mobile-nav" className="border-b border-zinc-200 md:hidden dark:border-zinc-800"> <div id="mobile-nav" {...stylex.props(styles.drawer)}>
<nav aria-label="Main" className="px-2 py-2"> <nav aria-label="Main" {...stylex.props(styles.drawerNav)}>
<NavLinks onNavigate={() => setDrawerOpen(false)} /> <NavLinks onNavigate={() => setDrawerOpen(false)} />
</nav> </nav>
<VersionFooter /> <VersionFooter />
</div> </div>
)} )}
<main className="flex-1 p-4"> <main {...stylex.props(styles.main)}>
<Outlet /> <Outlet />
</main> </main>
</div> </div>
+193 -1
View File
@@ -1 +1,193 @@
@import "tailwindcss"; /*
* The CSS entry (milestone 23, ruling 1). The StyleX plugin emits the compiled
* atomic rules into their own layers; what is left here is the element reset
* those rules are written against.
*
* The reset is not optional and not cosmetic. Tailwind's preflight used to
* supply it, so every StyleX style in `web/src` is written assuming border-box
* sizing, no default margins, unstyled lists and form controls that inherit
* their font. Deleting this block does not restore browser defaults it
* silently changes the meaning of every size and spacing value in the app.
* Rules are limited to what this app renders; it is not a general reset.
*
* The reset lives in its own cascade layer, declared here before StyleX emits
* its own. Layer order is priority order, and unlayered author CSS outranks
* every layer: leaving these rules unlayered silently beat every StyleX rule in
* the app, whatever the selector said.
*/
@layer reset {
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
/*
* This is not a reproduction of Tailwind's default stack, which is gone with
* the package; text metrics drift here, which ruling 7 accepts. The symbol
* families at the end are not decoration: `Select.tsx` renders U+25BE for its
* chevron, and Segoe UI does not carry that glyph, so on Windows the stack
* has to reach a font that does before it falls back to a substitute box.
*/
html {
line-height: 1.5;
-webkit-text-size-adjust: 100%;
font-family:
system-ui,
-apple-system,
"Segoe UI",
Roboto,
"Helvetica Neue",
Arial,
sans-serif,
"Segoe UI Symbol",
"Noto Sans Symbols 2";
}
/* Headings carry their scale from StyleX, not from the user agent. */
h1,
h2,
h3,
h4,
h5,
h6 {
font-size: inherit;
font-weight: inherit;
}
ul,
ol,
menu {
list-style: none;
}
/* Links opt into colour and underline; the shell's nav wants neither. */
a {
color: inherit;
text-decoration: inherit;
}
code,
kbd,
samp,
pre {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
font-size: 1em;
}
/* Without this the spinner stretches a number input taller than its row. */
::-webkit-inner-spin-button,
::-webkit-outer-spin-button {
height: auto;
}
/* Firefox draws a red glow on an invalid field; the form shows its own error. */
:-moz-ui-invalid {
box-shadow: none;
}
table {
border-collapse: collapse;
text-indent: 0;
border-color: inherit;
}
/*
* Form controls: inherit type and colour, drop the user-agent chrome, and keep
* `appearance: button` so iOS Safari honours a button's border radius.
*/
button,
input,
select,
optgroup,
textarea {
font: inherit;
letter-spacing: inherit;
color: inherit;
background-color: transparent;
border: 0 solid;
border-radius: 0;
opacity: 1;
}
button,
input[type="button"],
input[type="reset"],
input[type="submit"] {
appearance: button;
}
/* A checkbox keeps its native chrome; the rules above would erase it. */
input[type="checkbox"],
input[type="radio"] {
appearance: auto;
}
/*
* WebKit lays a date/time input out from its own pseudo-elements, and an
* empty one comes out shorter than a filled one without these. The query log
* filters are two `datetime-local` inputs sitting in a row of controls, so
* the height has to hold whether or not a value is set.
*/
::-webkit-date-and-time-value {
min-height: 1lh;
text-align: inherit;
}
::-webkit-datetime-edit {
display: inline-flex;
}
::-webkit-datetime-edit-fields-wrapper {
padding: 0;
}
::-webkit-datetime-edit,
::-webkit-datetime-edit-year-field,
::-webkit-datetime-edit-month-field,
::-webkit-datetime-edit-day-field,
::-webkit-datetime-edit-hour-field,
::-webkit-datetime-edit-minute-field,
::-webkit-datetime-edit-second-field,
::-webkit-datetime-edit-millisecond-field,
::-webkit-datetime-edit-meridiem-field {
padding-block: 0;
}
::placeholder {
opacity: 1;
}
/*
* Safari below 16.4 resolves `color-mix` against the wrong colour here and
* renders the placeholder invisible. The guard admits every engine that
* supports either a non-WebKit feature or one Safari only gained afterwards.
*/
@supports (not (-webkit-appearance: -apple-pay-button)) or (contain-intrinsic-size: 1px) {
::placeholder {
color: color-mix(in oklab, currentcolor 50%, transparent);
}
}
/* An inline SVG leaves a baseline gap under a full-width chart. */
svg,
img,
video,
canvas {
display: block;
vertical-align: middle;
}
img,
video {
max-width: 100%;
height: auto;
}
[hidden]:not([hidden="until-found"]) {
display: none !important;
}
}
+113
View File
@@ -0,0 +1,113 @@
/**
* The destructive-action confirmation (milestone 23, ruling 5), which replaces
* every `window.confirm` call in the app.
*
* `role="alertdialog"` rather than `dialog`: the message is the reason the
* dialog exists, so it is announced with the dialog instead of after it. The
* overlay is deliberately not dismissable a delete needs an explicit answer,
* and a stray outside click is not one. Escape still cancels.
*/
import * as stylex from "@stylexjs/stylex";
import { Dialog as AriaDialog, Heading, Modal, ModalOverlay } from "react-aria-components";
import { colors } from "./tokens.stylex";
import { styles as shared } from "./styles";
interface Props {
isOpen: boolean;
title: string;
/** The full sentence the operator reads before confirming; names the entity. */
message: string;
confirmLabel: string;
onConfirm: () => void;
onCancel: () => void;
}
const styles = stylex.create({
overlay: {
position: "fixed",
inset: 0,
zIndex: 50,
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: "1rem",
backgroundColor: "rgba(0, 0, 0, 0.4)",
},
panel: {
width: "100%",
maxWidth: "26rem",
borderRadius: "0.5rem",
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.dangerBorder,
backgroundColor: colors.surfaceRaised,
color: colors.text,
padding: "1.5rem",
boxShadow: "0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1)",
},
body: {
outlineStyle: "none",
},
title: {
fontSize: "1.125rem",
lineHeight: "1.75rem",
fontWeight: 600,
},
message: {
marginTop: "0.5rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.textMuted,
},
actions: {
display: "flex",
justifyContent: "flex-end",
gap: "0.5rem",
marginTop: "1.5rem",
},
dangerButton: {
borderRadius: "0.25rem",
borderStyle: "none",
backgroundColor: colors.danger,
color: colors.primaryText,
paddingInline: "0.75rem",
paddingBlock: "0.375rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
fontWeight: 500,
},
});
export default function ConfirmDialog({ isOpen, title, message, confirmLabel, onConfirm, onCancel }: Props) {
return (
<ModalOverlay
isOpen={isOpen}
onOpenChange={(open) => {
if (!open) onCancel();
}}
className={() => stylex.props(styles.overlay).className ?? ""}
>
<Modal className={() => stylex.props(styles.panel).className ?? ""}>
<AriaDialog role="alertdialog" {...stylex.props(styles.body)}>
<Heading slot="title" level={2} {...stylex.props(styles.title)}>
{title}
</Heading>
<p {...stylex.props(styles.message)}>{message}</p>
<div {...stylex.props(styles.actions)}>
<button type="button" onClick={onCancel} {...stylex.props(shared.button, shared.focusRing)}>
Cancel
</button>
<button
type="button"
onClick={onConfirm}
{...stylex.props(styles.dangerButton, shared.focusRing)}
>
{confirmLabel}
</button>
</div>
</AriaDialog>
</Modal>
</ModalOverlay>
);
}
+68
View File
@@ -0,0 +1,68 @@
/**
* The modal dialog (milestone 23, ruling 4).
*
* React Aria owns the focus trap, the Escape handler and the `aria-modal`
* wiring that the hand-rolled overlay only approximated. State is controlled by
* the caller because the trigger is a table row button, not a `DialogTrigger`.
*/
import type { ReactNode } from "react";
import * as stylex from "@stylexjs/stylex";
import { Dialog as AriaDialog, Modal, ModalOverlay } from "react-aria-components";
import { colors } from "./tokens.stylex";
interface Props {
/** The dialog's accessible name. */
label: string;
isOpen: boolean;
onClose: () => void;
children: ReactNode;
}
const styles = stylex.create({
overlay: {
position: "fixed",
inset: 0,
zIndex: 50,
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: "1rem",
backgroundColor: "rgba(0, 0, 0, 0.4)",
},
panel: {
width: "100%",
maxWidth: "28rem",
borderRadius: "0.5rem",
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.border,
backgroundColor: colors.surfaceRaised,
color: colors.text,
padding: "1.5rem",
boxShadow: "0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1)",
},
/** The panel already draws the boundary; the dialog's own ring would double it. */
body: {
outlineStyle: "none",
},
});
export default function Dialog({ label, isOpen, onClose, children }: Props) {
return (
<ModalOverlay
isOpen={isOpen}
onOpenChange={(open) => {
if (!open) onClose();
}}
isDismissable
className={() => stylex.props(styles.overlay).className ?? ""}
>
<Modal className={() => stylex.props(styles.panel).className ?? ""}>
<AriaDialog aria-label={label} {...stylex.props(styles.body)}>
{children}
</AriaDialog>
</Modal>
</ModalOverlay>
);
}
+148
View File
@@ -0,0 +1,148 @@
/**
* The single-choice picker (milestone 23, ruling 4), replacing every native
* `<select>` in the app.
*
* RAC composes a Select out of Label, Button, SelectValue, Popover, ListBox and
* ListBoxItem; those parts are the Select, not extra components adopted beyond
* ruling 4's scope, and nothing outside this file imports them.
*
* Keys are strings because a `<select>`'s value was a string. A call site that
* models an id converts on both edges.
*/
import * as stylex from "@stylexjs/stylex";
import { Button, Label, ListBox, ListBoxItem, Popover, Select as AriaSelect, SelectValue } from "react-aria-components";
import { colors } from "./tokens.stylex";
import { styles as shared } from "./styles";
export interface SelectOption {
value: string;
label: string;
}
interface Props {
options: readonly SelectOption[];
value: string;
onChange: (value: string) => void;
/** The visible label. Omit it only when `aria-label` names the control. */
label?: string;
"aria-label"?: string;
/**
* `field` matches a full-width form input, `compactField` the smaller one a
* dialog uses, `inline` a control sitting in a row of other controls.
*/
variant?: "field" | "compactField" | "inline";
}
const styles = stylex.create({
root: {
display: "block",
},
label: {
display: "block",
fontSize: "0.875rem",
lineHeight: "1.25rem",
fontWeight: 500,
},
trigger: {
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: "0.5rem",
textAlign: "left",
cursor: "pointer",
},
compact: {
marginTop: "0.25rem",
width: "100%",
},
/** Explicit, so RAC's default `react-aria-SelectValue` class does not land. */
value: {
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
},
chevron: {
color: colors.textMuted,
},
popover: {
width: "var(--trigger-width)",
maxHeight: "16rem",
overflowY: "auto",
borderRadius: "0.25rem",
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.borderStrong,
backgroundColor: colors.surfaceRaised,
color: colors.text,
boxShadow: "0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1)",
},
listBox: {
outlineStyle: "none",
paddingBlock: "0.25rem",
},
item: {
paddingInline: "0.75rem",
paddingBlock: "0.375rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
cursor: "pointer",
},
/**
* The inverted row is the listbox convention, and the ring is the milestone-9
* floor; an option gets both. RAC focuses the option's own DOM node, so the
* shared `:focus-visible` ring does apply here it is drawn inset because an
* option flush against a scrolling popover clips an outset one, and recoloured
* because the focus token is the same blue this row just painted behind it.
*/
itemFocused: {
backgroundColor: colors.primary,
color: colors.primaryText,
outlineColor: { default: null, ":focus-visible": colors.primaryText },
},
itemSelected: {
fontWeight: 600,
},
});
export default function Select({ options, value, onChange, label, "aria-label": ariaLabel, variant = "field" }: Props) {
const base = variant === "field" ? shared.input : shared.smallInput;
const block = variant === "compactField" ? styles.compact : null;
return (
<AriaSelect
aria-label={ariaLabel}
value={value}
onChange={(key) => onChange(String(key ?? ""))}
{...stylex.props(styles.root)}
>
{label !== undefined && <Label {...stylex.props(styles.label)}>{label}</Label>}
<Button className={() => stylex.props(base, block, styles.trigger, shared.focusRing).className ?? ""}>
<SelectValue className={() => stylex.props(styles.value).className ?? ""} />
<span aria-hidden="true" {...stylex.props(styles.chevron)}>
</span>
</Button>
<Popover className={() => stylex.props(styles.popover).className ?? ""}>
<ListBox {...stylex.props(styles.listBox)}>
{options.map((option) => (
<ListBoxItem
key={option.value}
id={option.value}
textValue={option.label}
className={({ isFocused, isSelected }) =>
stylex.props(
styles.item,
shared.insetFocusRing,
isSelected && styles.itemSelected,
isFocused && styles.itemFocused,
).className ?? ""
}
>
{option.label}
</ListBoxItem>
))}
</ListBox>
</Popover>
</AriaSelect>
);
}
+104
View File
@@ -0,0 +1,104 @@
/**
* The tab switcher (milestone 23, ruling 4).
*
* The hand-rolled version spelled the ARIA attributes by hand but had no
* keyboard navigation; React Aria brings arrow-key movement and roving
* tabindex with the same roles. State stays inside RAC no caller needs to
* read which tab is open.
*
* RAC exposes state as render-prop booleans, so every rule below is a
* boolean-guarded style object: StyleX cannot express `[data-selected]`.
*/
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";
export interface TabSpec {
id: string;
label: string;
content: ReactNode;
}
interface Props {
/** The tab list's accessible name. */
label: string;
tabs: readonly TabSpec[];
}
const styles = stylex.create({
list: {
display: "flex",
gap: "0.5rem",
marginTop: "1rem",
borderBottomWidth: 1,
borderBottomStyle: "solid",
borderBottomColor: colors.border,
},
tab: {
marginBottom: -1,
borderBottomWidth: 2,
borderBottomStyle: "solid",
borderBottomColor: "transparent",
paddingInline: "0.75rem",
paddingBlock: "0.5rem",
fontWeight: 500,
color: colors.textMuted,
cursor: "pointer",
},
tabSelected: {
borderBottomColor: colors.primary,
color: colors.primaryOnSurface,
},
tabHovered: {
color: colors.text,
},
/**
* The milestone-9 focus floor. A Tab is a `div` with a roving tabindex, so
* the ring is driven by RAC's `isFocusVisible` rather than `:focus-visible`.
*/
tabFocusVisible: {
outlineWidth: 2,
outlineStyle: "solid",
outlineColor: colors.focus,
outlineOffset: 2,
},
panel: {
outlineStyle: "none",
},
/** Explicit, so RAC's default `react-aria-Tabs` class does not land instead. */
root: {
display: "block",
},
});
export default function Tabs({ label, tabs }: Props) {
return (
<AriaTabs className={() => stylex.props(styles.root).className ?? ""}>
<TabList aria-label={label} className={() => stylex.props(styles.list).className ?? ""}>
{tabs.map((tab) => (
<Tab
key={tab.id}
id={tab.id}
className={({ isSelected, isHovered, isFocusVisible }) =>
stylex.props(
styles.tab,
isHovered && !isSelected && styles.tabHovered,
isSelected && styles.tabSelected,
isFocusVisible && styles.tabFocusVisible,
).className ?? ""
}
>
{tab.label}
</Tab>
))}
</TabList>
{tabs.map((tab) => (
<TabPanel key={tab.id} id={tab.id} className={() => stylex.props(styles.panel).className ?? ""}>
{tab.content}
</TabPanel>
))}
</AriaTabs>
);
}
-32
View File
@@ -1,32 +0,0 @@
/**
* The shared Tailwind class vocabulary (milestone 18, ruling 10).
*
* Every interactive element must carry `focusRing`; that is the milestone-9
* accessibility floor. Compose these constants for one-off variants
* (`` `${buttonClass} md:hidden` ``) instead of re-spelling the literal.
*/
export const focusRing = "focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600";
/** The ring drawn inside the element, for controls flush against a panel edge. */
export const insetFocusRing = "focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-blue-600";
export const inputClass = `mt-1 w-full rounded border border-zinc-300 bg-white px-3 py-2 ${focusRing} dark:border-zinc-700 dark:bg-zinc-900`;
export const smallInputClass = `rounded border border-zinc-300 bg-white px-2 py-1.5 text-sm ${focusRing} dark:border-zinc-700 dark:bg-zinc-900`;
export const buttonClass = `rounded border border-zinc-300 px-3 py-1.5 text-sm ${focusRing} dark:border-zinc-700`;
export const smallButtonClass = `rounded border border-zinc-300 px-2 py-1 text-sm ${focusRing} dark:border-zinc-700`;
export const largeButtonClass = `rounded border border-zinc-300 px-3 py-2 font-medium ${focusRing} dark:border-zinc-700`;
export const primaryButtonClass = `rounded bg-blue-600 px-3 py-1.5 text-sm font-medium text-white disabled:opacity-50 ${focusRing}`;
export const largePrimaryButtonClass = `rounded bg-blue-600 px-3 py-2 font-medium text-white disabled:opacity-50 ${focusRing}`;
export const rowButtonClass = `rounded px-2 py-1 text-sm text-blue-600 ${focusRing} dark:text-blue-400`;
export const linkButtonClass = `text-sm font-medium text-blue-600 ${focusRing} dark:text-blue-400`;
export const dangerLinkButtonClass = `text-sm font-medium text-red-600 disabled:opacity-50 ${focusRing} dark:text-red-400`;
export const retryButtonClass = `mt-3 rounded border border-red-300 px-3 py-1.5 text-sm font-medium text-red-800 ${focusRing} dark:border-red-800 dark:text-red-200`;
export const thClass = "border-b border-zinc-300 px-3 py-2 text-left font-medium dark:border-zinc-700";
export const tdClass = "border-b border-zinc-200 px-3 py-2 dark:border-zinc-800";
export const tableWrapClass = "mt-4 overflow-x-auto";
export const formCardClass = "mt-4 max-w-lg space-y-3 rounded border border-zinc-200 p-4 dark:border-zinc-800";
+29
View File
@@ -0,0 +1,29 @@
import { render, screen } from "@testing-library/react";
import * as stylex from "@stylexjs/stylex";
import { styles } from "./styles";
/**
* The StyleX compile-time transform must run in the vitest pipeline, not just
* in `vite build`. Without the plugin these tests do not fail an assertion
* importing the module throws `Unexpected 'stylex.defineVars' call at runtime`
* before a single case runs, which is the louder failure of the two
* (milestone 23, ruling 1).
*/
describe("the StyleX build integration", () => {
it("compiles stylex.props into a class name", () => {
render(
<button type="button" {...stylex.props(styles.button, styles.focusRing)}>
probe
</button>,
);
const probe = screen.getByRole("button", { name: "probe" });
expect(probe.className).not.toBe("");
});
it("keeps composed styles distinct from a single style", () => {
const one = stylex.props(styles.button).className;
const two = stylex.props(styles.button, styles.focusRing).className;
expect(one).toBeTruthy();
expect(two).not.toBe(one);
});
});
+226
View File
@@ -0,0 +1,226 @@
/**
* The shared style vocabulary (milestone 23, ruling 3; replaces the Tailwind
* class constants of milestone 18, ruling 10).
*
* Every interactive element must carry `focusRing` or `insetFocusRing`; that
* is the milestone-9 accessibility floor. Compose at the call site with
* `stylex.props(styles.button, extra)` instead of re-spelling a variant.
*/
import * as stylex from "@stylexjs/stylex";
import { colors } from "./tokens.stylex";
const FOCUS = ":focus-visible";
const DISABLED = ":disabled";
/** The half-fade loop a placeholder runs while its data is in flight. */
const pulseFrames = stylex.keyframes({
"50%": { opacity: 0.5 },
});
export const styles = stylex.create({
/** The ring drawn outside the element. */
focusRing: {
outlineWidth: { default: null, [FOCUS]: 2 },
outlineStyle: { default: null, [FOCUS]: "solid" },
outlineColor: { default: null, [FOCUS]: colors.focus },
outlineOffset: { default: null, [FOCUS]: 2 },
},
/** The ring drawn inside the element, for controls flush against a panel edge. */
insetFocusRing: {
outlineWidth: { default: null, [FOCUS]: 2 },
outlineStyle: { default: null, [FOCUS]: "solid" },
outlineColor: { default: null, [FOCUS]: colors.focus },
outlineOffset: { default: null, [FOCUS]: -2 },
},
input: {
marginTop: "0.25rem",
width: "100%",
borderRadius: "0.25rem",
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.borderStrong,
backgroundColor: colors.surfaceRaised,
color: colors.text,
paddingInline: "0.75rem",
paddingBlock: "0.5rem",
},
smallInput: {
borderRadius: "0.25rem",
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.borderStrong,
backgroundColor: colors.surfaceRaised,
color: colors.text,
paddingInline: "0.5rem",
paddingBlock: "0.375rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
button: {
borderRadius: "0.25rem",
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.borderStrong,
paddingInline: "0.75rem",
paddingBlock: "0.375rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
smallButton: {
borderRadius: "0.25rem",
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.borderStrong,
paddingInline: "0.5rem",
paddingBlock: "0.25rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
largeButton: {
borderRadius: "0.25rem",
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.borderStrong,
paddingInline: "0.75rem",
paddingBlock: "0.5rem",
fontWeight: 500,
},
primaryButton: {
borderRadius: "0.25rem",
borderStyle: "none",
backgroundColor: colors.primary,
color: colors.primaryText,
paddingInline: "0.75rem",
paddingBlock: "0.375rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
fontWeight: 500,
opacity: { default: 1, [DISABLED]: 0.5 },
},
largePrimaryButton: {
borderRadius: "0.25rem",
borderStyle: "none",
backgroundColor: colors.primary,
color: colors.primaryText,
paddingInline: "0.75rem",
paddingBlock: "0.5rem",
fontWeight: 500,
opacity: { default: 1, [DISABLED]: 0.5 },
},
rowButton: {
borderRadius: "0.25rem",
borderStyle: "none",
backgroundColor: "transparent",
paddingInline: "0.5rem",
paddingBlock: "0.25rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.primaryOnSurface,
},
linkButton: {
borderStyle: "none",
backgroundColor: "transparent",
padding: 0,
fontSize: "0.875rem",
lineHeight: "1.25rem",
fontWeight: 500,
color: colors.primaryOnSurface,
},
dangerLinkButton: {
borderStyle: "none",
backgroundColor: "transparent",
padding: 0,
fontSize: "0.875rem",
lineHeight: "1.25rem",
fontWeight: 500,
color: colors.danger,
opacity: { default: 1, [DISABLED]: 0.5 },
},
retryButton: {
marginTop: "0.75rem",
borderRadius: "0.25rem",
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.dangerBorder,
backgroundColor: "transparent",
paddingInline: "0.75rem",
paddingBlock: "0.375rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
fontWeight: 500,
color: colors.dangerText,
},
/** Marks an element as waiting on data. Carries no colour of its own. */
pulse: {
animationName: pulseFrames,
animationDuration: "2s",
animationTimingFunction: "cubic-bezier(0.4, 0, 0.6, 1)",
animationIterationCount: "infinite",
},
/** Digits of equal width, so a column of counts does not jitter as it updates. */
tabularNums: {
fontVariantNumeric: "tabular-nums",
},
/** For a value the operator reads character by character: a domain, an IP, a URL. */
mono: {
fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace",
},
/** Visible to a screen reader only; the element keeps its place in the a11y tree. */
srOnly: {
position: "absolute",
width: 1,
height: 1,
padding: 0,
margin: -1,
overflow: "hidden",
clipPath: "inset(50%)",
whiteSpace: "nowrap",
borderWidth: 0,
},
th: {
borderBottomWidth: 1,
borderBottomStyle: "solid",
borderBottomColor: colors.borderStrong,
paddingInline: "0.75rem",
paddingBlock: "0.5rem",
textAlign: "left",
fontWeight: 500,
},
td: {
borderBottomWidth: 1,
borderBottomStyle: "solid",
borderBottomColor: colors.border,
paddingInline: "0.75rem",
paddingBlock: "0.5rem",
},
tableWrap: {
marginTop: "1rem",
overflowX: "auto",
},
/**
* Not a faithful port: `space-y-3` set sibling margins on a block container,
* this is a flex formatting context. StyleX has no way to write the `> * + *`
* selector that `space-y` compiles to, so the rhythm can only come from
* `gap`. Child sizing and margin behaviour differ from the Tailwind original;
* both call sites are plain vertical form stacks, where they do not.
*/
formCard: {
display: "flex",
flexDirection: "column",
gap: "0.75rem",
marginTop: "1rem",
maxWidth: "32rem",
borderRadius: "0.25rem",
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.border,
padding: "1rem",
},
});
+64
View File
@@ -0,0 +1,64 @@
/**
* The design tokens (milestone 23, ruling 2).
*
* Values are the Tailwind 4 zinc/blue/red ramps the app rendered before the
* StyleX conversion, carried over verbatim so the palette does not shift.
* Each token holds its dark value under `prefers-color-scheme: dark` the
* same media strategy Tailwind 4 defaulted to. There is no theme toggle and
* no `data-theme` attribute; the scheme follows the operating system.
*
* Name tokens by role, never by shade: a call site asks for `border`, not for
* zinc-200.
*/
import * as stylex from "@stylexjs/stylex";
const DARK = "@media (prefers-color-scheme: dark)";
export const colors = stylex.defineVars({
/** The page ground. */
surface: { default: "oklch(98.5% 0 none)", [DARK]: "oklch(14.1% 0.005 285.823)" },
/** Cards, dialogs and inputs sitting on top of the ground. */
surfaceRaised: { default: "#fff", [DARK]: "oklch(21% 0.006 285.885)" },
/** The fill under a pointer on a nav item or a menu row. */
surfaceHover: { default: "oklch(96.7% 0.001 286.375)", [DARK]: "oklch(27.4% 0.006 286.033)" },
/** Hairlines between rows and around cards. */
border: { default: "oklch(92% 0.004 286.32)", [DARK]: "oklch(27.4% 0.006 286.033)" },
/** Control outlines, which need more contrast than a row divider. */
borderStrong: { default: "oklch(87.1% 0.006 286.286)", [DARK]: "oklch(37% 0.013 285.805)" },
text: { default: "oklch(21% 0.006 285.885)", [DARK]: "oklch(96.7% 0.001 286.375)" },
/**
* Secondary text. Most call sites were a flat zinc-500 before the conversion,
* with no dark override, which measured 4.12:1 on the dark ground and so
* failed WCAG AA; the dark value here is zinc-400, which measures 7.56:1.
*/
textMuted: { default: "oklch(55.2% 0.016 285.938)", [DARK]: "oklch(70.5% 0.015 286.067)" },
/**
* Secondary text that already adapted before the conversion: zinc-600 on the
* light ground, zinc-400 on the dark one. It is a separate token because
* `textMuted`'s lighter light value measures 4.62:1 against 7.40:1 here, and
* these call sites are small text table headers, an inactive nav item, a
* chart legend where that loss shows.
*/
textSecondary: { default: "oklch(44.2% 0.017 285.786)", [DARK]: "oklch(70.5% 0.015 286.067)" },
/** Primary actions. Identical in both schemes, as before the conversion. */
primary: { default: "oklch(54.6% 0.245 262.881)", [DARK]: "oklch(54.6% 0.245 262.881)" },
primaryText: { default: "#fff", [DARK]: "#fff" },
/** Primary as foreground: lightened in dark so it clears the ground. */
primaryOnSurface: { default: "oklch(54.6% 0.245 262.881)", [DARK]: "oklch(70.7% 0.165 254.624)" },
/**
* Warnings: a degraded condition the operator can still act on, as against
* `danger`, which is a failure or a destructive action. The amber ramp.
*/
warnSurface: { default: "oklch(98.7% 0.022 95.277)", [DARK]: "oklch(27.9% 0.077 45.635)" },
warnBorder: { default: "oklch(87.9% 0.169 91.605)", [DARK]: "oklch(47.3% 0.137 46.201)" },
/** A control's outline inside a warning banner, which the banner border would swallow. */
warnBorderStrong: { default: "oklch(82.8% 0.189 84.429)", [DARK]: "oklch(55.5% 0.163 48.998)" },
warnText: { default: "oklch(41.4% 0.112 45.904)", [DARK]: "oklch(96.2% 0.059 95.617)" },
danger: { default: "oklch(57.7% 0.245 27.325)", [DARK]: "oklch(70.4% 0.191 22.216)" },
dangerSurface: { default: "oklch(97.1% 0.013 17.38)", [DARK]: "oklch(25.8% 0.092 26.042)" },
dangerBorder: { default: "oklch(80.8% 0.114 19.571)", [DARK]: "oklch(44.4% 0.177 26.899)" },
dangerText: { default: "oklch(44.4% 0.177 26.899)", [DARK]: "oklch(88.5% 0.062 18.334)" },
/** 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)" },
});
+69 -8
View File
@@ -1,6 +1,7 @@
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { act, renderHook, waitFor } from "@testing-library/react"; import { act, fireEvent, render, renderHook, screen, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider, type QueryClient as QC } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider, type QueryClient as QC } from "@tanstack/react-query";
import ConfirmDialog from "./ConfirmDialog";
import { useCrudForm } from "./useCrudForm"; import { useCrudForm } from "./useCrudForm";
interface Row { interface Row {
@@ -41,6 +42,50 @@ function setup() {
return { hook, created, updated, removed }; return { hook, created, updated, removed };
} }
/**
* The delete path is only finished when a real dialog drives it, so this
* harness wires the hook's pending-delete state to the component operators
* actually see (ruling 5). `window.confirm` no longer exists to stub.
*/
function DeleteHarness({ onRemoved }: { onRemoved: (id: number) => void }) {
const { onDelete, pendingDelete, confirmPendingDelete, cancelPendingDelete } = useCrudForm<Row, string>({
create: (_qc: QC) => ({ mutationFn: async () => undefined }),
update: (_qc: QC) => ({ mutationFn: async () => undefined }),
remove: (_qc: QC) => ({
mutationFn: async (id: number) => {
onRemoved(id);
},
}),
confirmDelete: (row) => `Delete "${row.name}"?`,
});
return (
<>
<button type="button" onClick={() => onDelete({ id: 3, name: "gamma" })}>
Delete gamma
</button>
<ConfirmDialog
isOpen={pendingDelete !== null}
title="Delete row"
message={pendingDelete?.message ?? ""}
confirmLabel="Delete"
onConfirm={confirmPendingDelete}
onCancel={cancelPendingDelete}
/>
</>
);
}
function renderDeleteHarness() {
const removed: number[] = [];
const queryClient = new QueryClient({ defaultOptions: { mutations: { retry: false } } });
render(
<QueryClientProvider client={queryClient}>
<DeleteHarness onRemoved={(id) => removed.push(id)} />
</QueryClientProvider>,
);
return removed;
}
afterEach(() => vi.unstubAllGlobals()); afterEach(() => vi.unstubAllGlobals());
test("the create form submits through the create mutation and closes", async () => { test("the create form submits through the create mutation and closes", async () => {
@@ -96,18 +141,34 @@ test("openForm clears a stale create error so the reopened form starts clean", a
await waitFor(() => expect(hook.result.current.create.error).toBeNull()); await waitFor(() => expect(hook.result.current.create.error).toBeNull());
}); });
test("delete asks for confirmation and only removes when confirmed", async () => { test("onDelete opens the confirmation instead of removing the row", () => {
const { hook, removed } = setup(); const { hook, removed } = setup();
const confirm = vi.fn(() => false);
vi.stubGlobal("confirm", confirm);
act(() => hook.result.current.onDelete({ id: 3, name: "gamma" })); act(() => hook.result.current.onDelete({ id: 3, name: "gamma" }));
expect(confirm).toHaveBeenCalledWith('Delete "gamma"?');
expect(hook.result.current.pendingDelete).toEqual({ entity: { id: 3, name: "gamma" }, message: 'Delete "gamma"?' });
expect(removed).toEqual([]); expect(removed).toEqual([]);
});
confirm.mockReturnValue(true); test("cancelling the confirmation dialog leaves the row alone", async () => {
act(() => hook.result.current.onDelete({ id: 3, name: "gamma" })); const removed = renderDeleteHarness();
fireEvent.click(screen.getByRole("button", { name: "Delete gamma" }));
const dialog = await screen.findByRole("alertdialog");
expect(dialog.textContent).toContain('Delete "gamma"?');
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
await waitFor(() => expect(screen.queryByRole("alertdialog")).toBeNull());
expect(removed).toEqual([]);
});
test("confirming the dialog removes the row by id", async () => {
const removed = renderDeleteHarness();
fireEvent.click(screen.getByRole("button", { name: "Delete gamma" }));
await screen.findByRole("alertdialog");
fireEvent.click(screen.getByRole("button", { name: "Delete" }));
await waitFor(() => expect(removed).toEqual([3])); await waitFor(() => expect(removed).toEqual([3]));
expect(screen.queryByRole("alertdialog")).toBeNull();
}); });
test("closeForm closes whichever form is open", () => { test("closeForm closes whichever form is open", () => {
+36 -6
View File
@@ -4,20 +4,28 @@ import { useMutation, useQueryClient, type QueryClient, type UseMutationOptions
/** Which form is open: none, the create form, or the edit form for one row. */ /** Which form is open: none, the create form, or the edit form for one row. */
export type CrudFormState<Entity> = { mode: "create" } | { mode: "edit"; entity: Entity }; export type CrudFormState<Entity> = { mode: "create" } | { mode: "edit"; entity: Entity };
/** The row a confirmation dialog is open for, and the message it shows. */
export interface PendingDelete<Entity> {
entity: Entity;
message: string;
}
type MutationFactory<Variables> = (queryClient: QueryClient) => UseMutationOptions<unknown, Error, Variables>; type MutationFactory<Variables> = (queryClient: QueryClient) => UseMutationOptions<unknown, Error, Variables>;
export interface CrudFormSpec<Entity extends { id: number }, Input> { export interface CrudFormSpec<Entity extends { id: number }, Input> {
create: MutationFactory<Input>; create: MutationFactory<Input>;
update: MutationFactory<{ id: number; input: Input }>; update: MutationFactory<{ id: number; input: Input }>;
remove: MutationFactory<number>; remove: MutationFactory<number>;
/** The `window.confirm` text shown before a delete. */ /** The message the confirmation dialog shows before a delete. */
confirmDelete: (entity: Entity) => string; confirmDelete: (entity: Entity) => string;
} }
/** /**
* The create/edit/delete plumbing shared by the local DNS tabs (ruling 10): * The create/edit/delete plumbing shared by the local DNS tabs (ruling 10):
* three mutations, the open-form state, and the three handlers. The field JSX * three mutations, the open-form state, the pending-delete state and the
* and the table stay in the caller. * handlers. The field JSX, the table and the confirmation dialog stay in the
* caller: `onDelete` opens the confirmation instead of deciding anything, so
* the hook never reaches for `window.confirm` (ruling 5).
*/ */
export function useCrudForm<Entity extends { id: number }, Input>(spec: CrudFormSpec<Entity, Input>) { export function useCrudForm<Entity extends { id: number }, Input>(spec: CrudFormSpec<Entity, Input>) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -25,6 +33,7 @@ export function useCrudForm<Entity extends { id: number }, Input>(spec: CrudForm
const update = useMutation(spec.update(queryClient)); const update = useMutation(spec.update(queryClient));
const remove = useMutation(spec.remove(queryClient)); const remove = useMutation(spec.remove(queryClient));
const [form, setForm] = useState<CrudFormState<Entity> | null>(null); const [form, setForm] = useState<CrudFormState<Entity> | null>(null);
const [pendingDelete, setPendingDelete] = useState<PendingDelete<Entity> | null>(null);
function openForm(next: CrudFormState<Entity>) { function openForm(next: CrudFormState<Entity>) {
create.reset(); create.reset();
@@ -46,9 +55,30 @@ export function useCrudForm<Entity extends { id: number }, Input>(spec: CrudForm
} }
function onDelete(entity: Entity) { function onDelete(entity: Entity) {
if (!window.confirm(spec.confirmDelete(entity))) return; setPendingDelete({ entity, message: spec.confirmDelete(entity) });
remove.mutate(entity.id);
} }
return { create, update, remove, form, openForm, closeForm, onSubmit, onDelete }; function confirmPendingDelete() {
if (pendingDelete === null) return;
remove.mutate(pendingDelete.entity.id);
setPendingDelete(null);
}
function cancelPendingDelete() {
setPendingDelete(null);
}
return {
create,
update,
remove,
form,
openForm,
closeForm,
onSubmit,
onDelete,
pendingDelete,
confirmPendingDelete,
cancelPendingDelete,
};
} }
+35 -5
View File
@@ -1,11 +1,40 @@
/// <reference types="vitest/config" /> /// <reference types="vitest/config" />
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import tailwindcss from "@tailwindcss/vite"; import stylex from "@stylexjs/unplugin";
import react from "@vitejs/plugin-react"; import react from "@vitejs/plugin-react";
import { defineConfig } from "vite"; import { type Plugin, defineConfig } from "vite";
export default defineConfig({ /**
plugins: [react(), tailwindcss()], * StyleX's Vite plugin polls its CSS store every 150ms from `configureServer`
* and pushes an HMR event when it changes. It clears that timer from the close
* event of `server.httpServer`. Vitest runs the dev server in middleware mode,
* where `httpServer` is null, so the timer is never cleared: the run finishes,
* the event loop stays awake, and vitest kills the worker after ten seconds with
* "something prevents Vite server from exiting". A test needs StyleX's
* transform, which is a different hook, and never needs its HMR so drop this
* one hook under vitest and keep the plugin.
*
* Remove this once @stylexjs/unplugin clears the timer on `buildEnd` or guards
* the hook, and check that `npm test` still exits without the warning.
*/
function withoutDevServerPolling(plugin: Plugin | Plugin[]): Plugin[] {
return (Array.isArray(plugin) ? plugin : [plugin]).map(({ configureServer: _dropped, ...rest }) => rest);
}
function stylexPlugin(mode: string): Plugin | Plugin[] {
// StyleX resolves the `.stylex.ts` theme file itself, so it needs the `@`
// alias too — `resolve.alias` below is Vite's and the plugin cannot see it.
const configured = stylex.vite({
useCSSLayers: true,
dev: mode === "development",
runtimeInjection: false,
aliases: { "@/*": [fileURLToPath(new URL("./src/*", import.meta.url))] },
});
return process.env.VITEST === undefined ? configured : withoutDevServerPolling(configured);
}
export default defineConfig(({ mode }) => ({
plugins: [stylexPlugin(mode), react()],
resolve: { resolve: {
alias: { alias: {
"@": fileURLToPath(new URL("./src", import.meta.url)), "@": fileURLToPath(new URL("./src", import.meta.url)),
@@ -20,5 +49,6 @@ export default defineConfig({
test: { test: {
environment: "jsdom", environment: "jsdom",
globals: true, globals: true,
setupFiles: ["./vitest.setup.ts"],
}, },
}); }));
+49
View File
@@ -0,0 +1,49 @@
/**
* jsdom implements no `CSS` interface at all, and React Aria's collection code
* calls `CSS.escape` unguarded when it looks an item up by key
* (`react-aria/dist/private/selection/utils.mjs:22`). Without this, opening a
* RAC Select or moving between RAC Tabs throws in tests but works in a browser.
*
* The implementation is CSSOM's `serialize an identifier` algorithm
* (https://drafts.csswg.org/cssom/#serialize-an-identifier), not an
* approximation: a wrong escape would silently break the key lookups instead of
* failing loudly.
*/
function escapeIdentifier(value: string): string {
let result = "";
for (let index = 0; index < value.length; index += 1) {
const code = value.charCodeAt(index);
const char = value[index]!;
if (code === 0x0000) {
result += "";
} else if (
(code >= 0x0001 && code <= 0x001f) ||
code === 0x007f ||
(index === 0 && code >= 0x0030 && code <= 0x0039) ||
(index === 1 && code >= 0x0030 && code <= 0x0039 && value.charCodeAt(0) === 0x002d)
) {
result += `\\${code.toString(16)} `;
} else if (index === 0 && code === 0x002d && value.length === 1) {
result += `\\${char}`;
} else if (
code >= 0x0080 ||
code === 0x002d ||
code === 0x005f ||
(code >= 0x0030 && code <= 0x0039) ||
(code >= 0x0041 && code <= 0x005a) ||
(code >= 0x0061 && code <= 0x007a)
) {
result += char;
} else {
result += `\\${char}`;
}
}
return result;
}
if (globalThis.CSS === undefined) {
Object.defineProperty(globalThis, "CSS", { value: { escape: escapeIdentifier }, configurable: true });
} else if (typeof globalThis.CSS.escape !== "function") {
globalThis.CSS.escape = escapeIdentifier;
}