Move the console onto ReUI PRO

Install ReUI (MCP, skill, @reui registry, Tailwind v4, shadcn base-nova) and move
the shared UI onto it: Button, Tag, Check, Input/Textarea, Segmented, Alert, Panel,
Dialog, Tooltip, Menu and Select (Popover), Toast (sonner), the Table frame
(DataGrid), the Dashboard KPI strip, the shnt Ops palette, Geist and the type scale.

Design only: behaviour, texts and roles are unchanged. Tests that asserted the old
DOM were updated; the tests now format numbers in English wherever they run.
docs/reui-migration-plan.md records the plan and its decisions.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
This commit is contained in:
DenozordecandClaude Sonnet 5.5 committed 2026-10-27 00:06:06 +07:00
commit f3531c64f9
680 files changed
+139836

No files matched your search

+51
View File
@@ -0,0 +1,51 @@
# Parity checks
## `check-parity-actions.sh`: the one that actually counts
Runs **the same action on both instances** and compares the state the server is
left in, not the bytes of the page. This is the check that makes the project's
governing constraint enforceable: since behaviour does not change, if the body
the new console sends differs by one parameter, the record left behind differs
and it shows up here.
```bash
docker compose up -d
./check-parity-actions.sh
```
It covers fourteen actions: seven creates (including the awkward ones: split TXT,
SVCB with flattened `svcParams`, CAA with its default values), three edits
(among them disabling a record, which upstream does as a `records/update`
resending the whole record), three deletes (including CNAME, which falls to the
`default` branch and sends no identity parameter at all) and the `options/set`
with all six empty lists travelling as the string `"false"`.
It normalises three things before comparing, and all three because they are not
parity: `lastModified`, the SOA serial and **the server's own name**, which is
embedded in the NS and SOA of every zone and is deliberately different in each
container.
## `check-parity.sh`: only for the preserved files
Compares the content of a path between the two instances. The console is ours,
so **for `/` it reports `DIFFERENT` and that is the point of the project**. It
still serves for what the build preserves untouched:
```bash
./check-parity.sh /robots.txt # IDENTICAL
./check-parity.sh /favicon.ico # IDENTICAL
```
## `parity-login.mjs`
Compares the alerts on the login screen between the new console (5380) and
upstream's (5381). Needs Playwright:
```bash
npm i -D playwright && npx playwright install chromium
node parity-login.mjs
```
It found two real divergences the first time it ran: the "×" button to dismiss
the alert was missing, upstream has it, so its absence was a behavioural
difference, and so was the space between the title and the text.
+142
View File
@@ -0,0 +1,142 @@
# Development harness
Two Technitium instances in Docker, to develop and verify the console redesign
without touching any real DNS server.
| Instance | Port | What it serves |
|---|---|---|
| `dev` | http://127.0.0.1:5380 | `dist/` mounted from the repo |
| `ref` | http://127.0.0.1:5381 | the `www` from the official image, untouched |
| `node2` | http://127.0.0.1:5382 | `dist/` too: the second node of the cluster |
User `admin`, password `technitium-ui-dev` on all three.
`node2` mounts the same build as `dev` on purpose: it is the only way to see a
screen **from the other side of a cluster**, and that is where half of `Cluster`'s
behaviour lives.
## The installer has its own bench
`sh dev/installer-probe.sh` measures `install.sh` against
`docs/2026-09-07-installer-contract.md`: thirty-one cases, one throwaway
container each off the same official image, exit code = cases failed. It does not
use the three containers below and does not touch them. C26 to C30 (2026-10-01)
also build the Docker init image from `dist/` and bring up a compose project of
their own, `installer-probe-<pid>`; they pull `busybox:stable` and `docker:cli` the
first time, and C30 mounts the Docker socket into `docker:cli`. `INSTALLER=<file>`
measures another `install.sh`: that is how the cases added on 2026-09-30 were
seen failing on the old script before they were seen passing on the new one.
The mode B cases need a server that honours
`DNS_SERVER_WEB_SERVICE_WWW_FOLDER_PATH`. **Since v15.5 (2026-09-19) the official
image does**, so they run against `technitium/dns-server:latest` with no build:
30 met, 1 not applicable (C15, which needs a server that ignores the variable).
They are not skipped by decree: the bench asks the image and believes the answer.
Before v15.5 the only server that honoured it was one built from the fork's
`feat/configurable-www-folder` branch, and later from `upstream/develop`. How that
was built (`TechnitiumLibrary` from its `develop` branch beside the server, five
`dotnet build` and a `dotnet publish` in `mcr.microsoft.com/dotnet/sdk:10.0`) is
in this file's history (`git log -p dev/README.md`); it is not needed any more.
## The cluster is DOWN: it was up on purpose, and it did its job
`dev` and `node2` formed a real two-node cluster: domain `cluster.test`, `dev`
primary and `node2` secondary from 2026-09-04 until Administration was built.
**It was not incidental.** `screens/admin/Cluster.tsx` is 1,629 lines, the largest
screen in the console, and without a cluster the harness only ever shows "Cluster
Not Initialized" and two buttons. Building against that would have meant building
only its empty state. With it, the screen was read **from both sides**: and that is how the
`Edit Node` rule turned out to depend on *who is looking*, not on the row:
```
isPrimary ? n.state === 'Self' : n.state === 'Self' || n.type === 'Primary'
```
### Taken down on 2026-09-04, and how
The condition it was kept for (**Administration built, recaptured and the gate
green**) was met: the section was built from both sides, `Edit Node` was checked on
the primary (one row) and on a secondary (two, its own and the primary's), and the
four `Force` dialogs were opened against a real cluster.
It came down **through the product's own path and without forcing**, which is also
the last check those two endpoints get:
```bash
# 1 · the secondary leaves, coordinating with the primary
T2=$(curl -s 'http://127.0.0.1:5382/api/user/login?user=admin&pass=technitium-ui-dev' | jq -r .token)
curl -s "http://127.0.0.1:5382/api/admin/cluster/secondary/leave?token=$T2&forceLeave=false&node="
# 2 · with no secondaries left, the primary deletes the cluster
TOK=$(curl -s 'http://127.0.0.1:5380/api/user/login?user=admin&pass=technitium-ui-dev' | jq -r .token)
curl -s "http://127.0.0.1:5380/api/admin/cluster/primary/delete?token=$TOK&forceDelete=false&node="
# 3 · and the zone the initialisation created, which upstream leaves behind
# ("no data loss except for the Cluster configuration")
curl -s "http://127.0.0.1:5380/api/zones/delete?token=$TOK&zone=cluster.test"
```
**Order matters and `force` is not needed**: unforced, `primary/delete` only works
once there are no secondaries, which is exactly why step 1 goes first. Doing it
this way is the only end-to-end exercise those two endpoints have had against a
real cluster.
The harness is back to its usual state: **six zones on `dev`, no cluster**, the
three containers up and the server domains back to `dev` and `node2`.
```bash
# how it was created, if it ever has to be rebuilt
TOK=$(curl -s 'http://127.0.0.1:5380/api/user/login?user=admin&pass=technitium-ui-dev' | jq -r .token)
curl -s -G http://127.0.0.1:5380/api/admin/cluster/init \
--data-urlencode "token=$TOK" \
--data-urlencode 'clusterDomain=cluster.test' \
--data-urlencode 'primaryNodeIpAddresses=172.23.0.4' # the docker IP of `dev`
T2=$(curl -s 'http://127.0.0.1:5382/api/user/login?user=admin&pass=technitium-ui-dev' | jq -r .token)
curl -s -X POST http://127.0.0.1:5382/api/admin/cluster/initJoin \
--data-urlencode "token=$T2" \
--data-urlencode 'secondaryNodeIpAddresses=172.23.0.3' \
--data-urlencode 'primaryNodeUrl=https://dev.cluster.test:53443/' \
--data-urlencode 'primaryNodeIpAddress=172.23.0.4' \
--data-urlencode 'ignoreCertificateErrors=true' \
--data-urlencode 'primaryNodeUsername=admin' \
--data-urlencode 'primaryNodePassword=technitium-ui-dev' \
--data-urlencode 'primaryNodeTotp='
```
Two things that cost a try each: the domain cannot be one that already exists as a
non-primary zone (`lab.test` was rejected) and **the join needs the primary's
DOMAIN NAME, not its IP**, even though it takes the IP as a separate parameter.
The IPs are the container ones and change if the network is recreated:
`docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' technitium-ui-dev`.
```bash
docker compose up -d # start
./check-parity.sh # compare dev's front page against ref
./check-parity.sh /api/ # compare another path
docker compose down -v # tear everything down, config included
```
`ref` is the reference truth: the project's constraint is that behaviour must not
change, so any non-visual divergence between the two is a bug.
Port 53 is not mapped (`systemd-resolved` holds it on WSL) and DHCP is not
enabled: only the web console is exercised here.
## Why the comparison normalises line endings
`check-parity.sh` strips `\r` before hashing, so it compares content and not
bytes. This is necessary, not cosmetic:
Upstream's `.gitattributes` declares `* text=auto`. Git stores **LF** in the
repository blobs and converts on checkout according to the platform. Our tree on
Linux has LF (`git status` sees it clean, it is byte-correct with respect to
the repository), while **the official Docker image ships CRLF**, because it was
built on Windows.
Without normalising, two files with identical content come out different by one
byte per line: in `index.html` that is 7,426 bytes of difference out of 619,718.
Found when running the first parity check.
+281
View File
@@ -0,0 +1,281 @@
/**
* The guard that keeps every colour in `src/theme/tokens.css`.
*
* `CONVENTIONS.md` has said "colours always by token, not one `#hex` outside
* `tokens.css`" from the start, and on 2026-10-01 there were 44 colour
* literals outside it, in 14 files: the hover border of a button, the veil behind
* a dialog, the shine on a ticked checkbox, eight fallbacks for the chart palette.
* None of them was wrong for the one theme the console had. Every one of them is
* a place a second theme cannot reach, which is why the rule stopped being a
* preference the day a light theme was decided. This is what measures it.
*
* It looks at the `.css`, `.ts` and `.tsx` files in `src/`, with their comments
* stripped (a comment that quotes a value is quoting, not painting), and finds:
*
* - `#hex`, 3, 4, 6 or 8 digits, and its URL-encoded form `%23hex`, which is how
* a colour hides inside an inline SVG in a `data:` URI.
* - The colour functions: `rgb()`, `rgba()`, `hsl()`, `hsla()`, `hwb()`, `lab()`,
* `lch()`, `oklab()`, `oklch()`, `color()`.
* - The CSS named colours (`white`, `black`, `red`…): in a stylesheet, as a word
* in a declaration's value; in TypeScript, as a whole string literal, which is
* how an inline style writes one.
*
* And inside `tokens.css` itself it checks the other half of the rule: a colour
* literal is only allowed in a THEME block (`:root, [data-theme='dark']` or
* `[data-theme='light']`). A colour in the plain `:root` would be one no theme
* can override. And the light block declares every property the dark block
* does: one it forgets falls back to `:root`, which carries dark, and paints a
* dark value on a light page without anything else noticing.
*
* `color-scheme` follows the same rule, because it is colour too: it decides the
* browser's own parts (scrollbars, date pickers, form controls), and an element
* that pins it draws them dark on a light page or the other way round. So a
* `color-scheme` declaration is only allowed in a theme block of `tokens.css`;
* anywhere else, a stylesheet declaration of it, or an inline style that sets it
* to a fixed string, is a finding. Setting it from the resolved theme
* (`root.style.colorScheme = resolved` in `theme/theme.ts`) is not pinning it, and
* `prefers-color-scheme` is a media query, not a declaration. There is no
* allow-list for it: an element that needs its own scheme needs a theme to say so.
*
* What it deliberately does NOT look at:
*
* - Tests (`*.test.*`, `src/test/`). A test may write a colour as data.
* - `color-mix()` and `transparent`: they compose tokens, they are not colours of
* their own. A `color-mix()` with a literal inside is still caught, by the
* literal.
* - Named colours inside a longer TypeScript string (`'1px solid white'`). None
* exists today; a parser would be needed to tell them from interface text.
*
* A literal that has to stay where it is goes in `ALLOWED`, by file AND literal,
* with its reason. An entry that no longer matches anything is a finding too: an
* allow-list that only grows stops meaning anything.
*
* Exit code is the number of findings, so it can sit in the gate.
*/
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), '..')
export const TOKENS = 'src/theme/tokens.css'
/* The selectors a colour token may be declared under, whitespace removed. */
export const THEME_BLOCKS = [":root,[data-theme='dark']", "[data-theme='light']"]
export const ALLOWED = [
{
file: 'src/screens/modals/Configure2FA.tsx',
literal: '#fff',
reason:
'The quiet zone around the QR code. A scanner needs dark modules on a light ground, '
+ 'so it is white in every theme: a token would invite a theme to change it.',
},
]
const NAMED = new Set(
(
'aliceblue antiquewhite aqua aquamarine azure beige bisque black blanchedalmond blue '
+ 'blueviolet brown burlywood cadetblue chartreuse chocolate coral cornflowerblue cornsilk '
+ 'crimson cyan darkblue darkcyan darkgoldenrod darkgray darkgreen darkgrey darkkhaki '
+ 'darkmagenta darkolivegreen darkorange darkorchid darkred darksalmon darkseagreen '
+ 'darkslateblue darkslategray darkslategrey darkturquoise darkviolet deeppink deepskyblue '
+ 'dimgray dimgrey dodgerblue firebrick floralwhite forestgreen fuchsia gainsboro ghostwhite '
+ 'gold goldenrod gray green greenyellow grey honeydew hotpink indianred indigo ivory khaki '
+ 'lavender lavenderblush lawngreen lemonchiffon lightblue lightcoral lightcyan '
+ 'lightgoldenrodyellow lightgray lightgreen lightgrey lightpink lightsalmon lightseagreen '
+ 'lightskyblue lightslategray lightslategrey lightsteelblue lightyellow lime limegreen linen '
+ 'magenta maroon mediumaquamarine mediumblue mediumorchid mediumpurple mediumseagreen '
+ 'mediumslateblue mediumspringgreen mediumturquoise mediumvioletred midnightblue mintcream '
+ 'mistyrose moccasin navajowhite navy oldlace olive olivedrab orange orangered orchid '
+ 'palegoldenrod palegreen paleturquoise palevioletred papayawhip peachpuff peru pink plum '
+ 'powderblue purple rebeccapurple red rosybrown royalblue saddlebrown salmon sandybrown '
+ 'seagreen seashell sienna silver skyblue slateblue slategray slategrey snow springgreen '
+ 'steelblue tan teal thistle tomato turquoise violet wheat white whitesmoke yellow yellowgreen'
).split(' '),
)
const HEX = /(?<![\w&%#-])#(?:[0-9a-f]{8}|[0-9a-f]{6}|[0-9a-f]{3,4})(?![\w-])/gi
const ENCODED = /%23(?:[0-9a-f]{8}|[0-9a-f]{6}|[0-9a-f]{3,4})(?![\w-])/gi
const FUNCTION = /(?<![\w.$-])(?:rgba?|hsla?|hwb|lab|lch|oklab|oklch|color)\([^)]*\)?/gi
const DECLARATION = /[a-z-]+\s*:\s*([^;{}]*)(?=[;}])/gi
const WORD = /(?<![\w-])[a-z]+(?![\w-])/gi
const STRING = /(['"])([a-z]+)\1/gi
const SCHEME_CSS = /(?<![\w-])color-scheme\s*:\s*[^;{}]*[^;{}\s]/gi
const SCHEME_TS = /(?<![\w$-])colorScheme\s*[:=]\s*(['"`])[^'"`]*\1|setProperty\(\s*(['"`])color-scheme\2/g
/*
Comments out, everything else in place: each comment character becomes a space
and each newline stays, so a finding keeps its line number. Strings are walked
so that a `//` inside one (`http://www.w3.org/2000/svg` in every inline SVG) is
not taken for a comment. `'` and `"` cannot span a line, so a quote inside a
regex literal can derail at most the rest of its own line.
*/
export function stripComments(text, kind) {
let out = ''
let i = 0
let quote = null
const blank = (s) => s.replace(/[^\n]/g, ' ')
while (i < text.length) {
const c = text[i]
if (quote) {
out += c
if (c === '\\') { out += text[i + 1] ?? ''; i += 2; continue }
if (c === quote || (c === '\n' && quote !== '`')) quote = null
i++
continue
}
if (c === '/' && text[i + 1] === '*') {
const end = text.indexOf('*/', i + 2)
const stop = end < 0 ? text.length : end + 2
out += blank(text.slice(i, stop))
i = stop
continue
}
if (kind === 'ts' && c === '/' && text[i + 1] === '/') {
const end = text.indexOf('\n', i)
const stop = end < 0 ? text.length : end
out += blank(text.slice(i, stop))
i = stop
continue
}
if (c === "'" || c === '"' || (kind === 'ts' && c === '`')) quote = c
out += c
i++
}
return out
}
const lineAt = (text, index) => text.slice(0, index).split('\n').length
/** The colour literals in a file's text, with their line. `kind` is `css` or `ts`. */
export function findColours(text, kind) {
const code = stripComments(text, kind)
const found = []
const add = (index, literal) => found.push({ line: lineAt(code, index), literal })
for (const re of [HEX, ENCODED, FUNCTION]) for (const m of code.matchAll(re)) add(m.index, m[0])
if (kind === 'css') {
for (const d of code.matchAll(DECLARATION)) {
const at = d.index + d[0].length - d[1].length
for (const w of d[1].matchAll(WORD)) if (NAMED.has(w[0].toLowerCase())) add(at + w.index, w[0])
}
} else {
for (const s of code.matchAll(STRING)) if (NAMED.has(s[2].toLowerCase())) add(s.index, s[0])
}
return found.sort((a, b) => a.line - b.line)
}
/** The `color-scheme` declarations in a file's text, with their line. */
export function findColourSchemes(text, kind) {
const code = stripComments(text, kind)
const re = kind === 'css' ? SCHEME_CSS : SCHEME_TS
return [...code.matchAll(re)].map((m) => ({ line: lineAt(code, m.index), literal: m[0] }))
}
/*
The top-level blocks of a stylesheet, with the selector each one hangs from.
`tokens.css` has no nesting except inside a block's own value, so counting
braces is enough.
*/
function blocks(code) {
const out = []
let depth = 0
let start = 0
for (let i = 0; i < code.length; i++) {
if (code[i] === '{') {
if (depth === 0) out.push({ selector: code.slice(start, i), from: i })
depth++
} else if (code[i] === '}') {
depth--
if (depth === 0) { out[out.length - 1].to = i; start = i + 1 }
}
}
return out.map((b) => ({ ...b, selector: b.selector.replace(/\s+/g, '') }))
}
/*
What each theme block declares, as `{ dark, light }`, each a `Map` from the
property (`--bg`, `color-scheme`) to its value as written. Shared with the tools
that measure a theme (`palette-distance.mjs`, `theme-contrast.mjs`), so the three
read the file the same way.
*/
export function themeTokens(text) {
const code = stripComments(text, 'css')
const out = {}
for (const b of blocks(code)) {
const theme = b.selector === THEME_BLOCKS[0] ? 'dark' : b.selector === THEME_BLOCKS[1] ? 'light' : null
if (!theme) continue
const decls = new Map()
for (const [, name, value] of code.slice(b.from + 1, b.to).matchAll(/(--[\w-]+|color-scheme)\s*:\s*([^;]+);/g))
decls.set(name, value.trim().replace(/\s+/g, ' '))
out[theme] = decls
}
return out
}
/*
Every property the dark block declares, the light block declares too. A token
light forgot is not an error anybody would see: `:root` carries the dark value,
so it quietly paints dark on a light page.
*/
export function findMissingInLight(text) {
const { dark = new Map(), light = new Map() } = themeTokens(text)
return [...dark.keys()].filter((name) => !light.has(name))
}
/** The colour literals and `color-scheme` declarations in `tokens.css` that are outside every theme block. */
export function findOutsideThemes(text) {
const code = stripComments(text, 'css')
const inside = blocks(code).filter((b) => THEME_BLOCKS.includes(b.selector))
return [...findColours(text, 'css'), ...findColourSchemes(text, 'css')]
.filter(({ line }) => !inside.some((b) => line >= lineAt(code, b.from) && line <= lineAt(code, b.to)))
.sort((a, b) => a.line - b.line)
}
function walk(dir) {
return fs.readdirSync(dir, { withFileTypes: true }).flatMap((e) => {
const p = path.join(dir, e.name)
/* `src/components/` holds the ReUI registry code exactly as shipped (shadcn add);
it is not ours to rewrite, so the token guard does not read it. */
if (e.isDirectory()) return e.name === 'test' || p.split(path.sep).slice(-2).join('/') === 'src/components' ? [] : walk(p)
return /\.(css|tsx?)$/.test(e.name) && !/\.test\./.test(e.name) ? [p] : []
})
}
/** Every finding under `root`, and the allow-list entries that matched nothing. */
export function check(root = ROOT, allowed = ALLOWED) {
const findings = []
const used = new Set()
for (const file of walk(path.join(root, 'src'))) {
const rel = path.relative(root, file).split(path.sep).join('/')
const text = fs.readFileSync(file, 'utf8')
if (rel === TOKENS) {
for (const f of findOutsideThemes(text))
findings.push(`${rel}:${f.line} ${f.literal} is outside every theme block`)
for (const name of findMissingInLight(text))
findings.push(`${rel} ${name} is declared for dark and not for light`)
continue
}
const kind = file.endsWith('.css') ? 'css' : 'ts'
for (const f of findColourSchemes(text, kind))
findings.push(`${rel}:${f.line} ${f.literal} pins color-scheme outside the theme blocks of ${TOKENS}`)
for (const f of findColours(text, kind)) {
const entry = allowed.find((a) => a.file === rel && a.literal.toLowerCase() === f.literal.toLowerCase())
if (entry) { used.add(entry); continue }
findings.push(`${rel}:${f.line} ${f.literal} is a colour outside ${TOKENS}`)
}
}
for (const a of allowed) if (!used.has(a)) findings.push(`${a.file} ${a.literal} is allowed but no longer found: remove the entry`)
return findings
}
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
const findings = check()
if (findings.length === 0) {
console.log(`\n Every colour is a token: no colour literal and no color-scheme in src/ outside the theme blocks of ${TOKENS} (${ALLOWED.length} allowed, each with its reason).\n`)
} else {
for (const f of findings) console.log(' ' + f)
console.log(`\n ${findings.length} findings. A colour, and color-scheme, belong in a theme block of ${TOKENS}; if a colour literal really cannot, ALLOWED in dev/check-colour-tokens.mjs takes it with its reason.\n`)
}
process.exit(Math.min(findings.length, 250))
}
+105
View File
@@ -0,0 +1,105 @@
import { describe, expect, it } from 'vitest'
import { ALLOWED, check, findColourSchemes, findColours, findMissingInLight, findOutsideThemes, stripComments, themeTokens } from './check-colour-tokens.mjs'
/*
The gate behind "every colour is a token". It lives in `dev/` for the reason
`relative-paths.test.mjs` gives (`src/` compiles without node's types), and it
runs inside `npm test`, so the rule is checked wherever the suite is.
*/
const literals = (text, kind) => findColours(text, kind).map((f) => f.literal)
describe('colour tokens', () => {
it('src/ has no colour literal outside the theme blocks of tokens.css', () => {
expect(check()).toEqual([])
})
it('every allowed literal says why', () => {
for (const a of ALLOWED) expect(a.reason.length).toBeGreaterThan(40)
})
it('finds a hex, a colour function and a named colour in a stylesheet', () => {
const css = '.a { color: #3a465b; background: rgba(5, 7, 10, 0.6); border-color: White; }'
expect(literals(css, 'css')).toEqual(['#3a465b', 'rgba(5, 7, 10, 0.6)', 'White'])
})
it('finds the colour baked into an inline SVG', () => {
const css = `.a { background-image: url("data:image/svg+xml,%3Csvg stroke='%238b95a7'%3E"); }`
expect(literals(css, 'css')).toEqual(['%238b95a7'])
})
it('does not take a property, a selector or a token for a colour', () => {
const css = '#root .tan { white-space: nowrap; color: var(--ink); background: color-mix(in srgb, var(--acc) 14%, transparent); }'
expect(literals(css, 'css')).toEqual([])
})
it('ignores a colour quoted in a comment, in either language', () => {
expect(literals('/* white over `#f5a524` gives 2.04:1 */ .a { color: var(--ink); }', 'css')).toEqual([])
expect(literals("// was '#94a3b8'\nconst a = 1 /* rgb(33,37,41) */", 'ts')).toEqual([])
})
it('finds a colour in an inline style, and a named one as a whole string', () => {
expect(literals("<img style={{ background: '#fff' }} />", 'ts')).toEqual(['#fff'])
expect(literals("const c = { color: 'black' }", 'ts')).toEqual(["'black'"])
})
it('keeps line numbers through a stripped comment and a URL in a string', () => {
const ts = "const u = 'http://www.w3.org/2000/svg'\n/* one\n two */\nconst c = '#123456'"
expect(stripComments(ts, 'ts').split('\n')).toHaveLength(4)
expect(findColours(ts, 'ts')).toEqual([{ line: 4, literal: '#123456' }])
})
it('in tokens.css, a colour is only allowed inside a theme block', () => {
const tokens = [
":root,\n[data-theme='dark'] {\n --bg: #111315;\n}",
"[data-theme='light'] {\n --bg: #ffffff;\n}",
':root {\n --s-1: 2px;\n --stray: #222222;\n}',
].join('\n')
expect(findOutsideThemes(tokens)).toEqual([{ line: 10, literal: '#222222' }])
})
it('finds a color-scheme pinned in a stylesheet or a fixed inline style', () => {
const css = '.ownRange input {\n color: var(--ink);\n color-scheme: dark;\n}'
expect(findColourSchemes(css, 'css')).toEqual([{ line: 3, literal: 'color-scheme: dark' }])
expect(findColourSchemes("<input style={{ colorScheme: 'light' }} />", 'ts')).toHaveLength(1)
expect(findColourSchemes("el.style.setProperty('color-scheme', 'dark')", 'ts')).toHaveLength(1)
})
it('does not take a media query, a comment or the resolved theme for a pinned color-scheme', () => {
expect(findColourSchemes('@media (prefers-color-scheme: dark) { .a { color: var(--ink); } }', 'css')).toEqual([])
expect(findColourSchemes('/* color-scheme: dark; */ .a { color: var(--ink); }', 'css')).toEqual([])
expect(findColourSchemes("root.style.colorScheme = resolved\nconst q = '(prefers-color-scheme: dark)'", 'ts')).toEqual([])
})
it('in tokens.css, color-scheme is only allowed inside a theme block', () => {
const tokens = [
":root,\n[data-theme='dark'] {\n color-scheme: dark;\n}",
"[data-theme='light'] {\n color-scheme: light;\n}",
':root {\n --s-1: 2px;\n color-scheme: dark;\n}',
].join('\n')
expect(findOutsideThemes(tokens)).toEqual([{ line: 10, literal: 'color-scheme: dark' }])
})
it('reads each theme block into its own map, comments out', () => {
const tokens = [
":root,\n[data-theme='dark'] {\n color-scheme: dark;\n /* --ink: #000000; */\n --ink: #e8eaec;\n --shadow: 0 1px 2px\n rgba(0, 0, 0, 0.4);\n}",
"[data-theme='light'] {\n color-scheme: light;\n --ink: #1a1d21;\n}",
':root {\n --s-1: 2px;\n}',
].join('\n')
const { dark, light } = themeTokens(tokens)
expect([...dark]).toEqual([['color-scheme', 'dark'], ['--ink', '#e8eaec'], ['--shadow', '0 1px 2px rgba(0, 0, 0, 0.4)']])
expect([...light]).toEqual([['color-scheme', 'light'], ['--ink', '#1a1d21']])
})
it('a token the dark block declares and the light one does not is a finding', () => {
const tokens = ":root,\n[data-theme='dark'] {\n --ink: #e8eaec;\n --mute: #9aa1a8;\n}\n[data-theme='light'] {\n --ink: #1a1d21;\n}"
expect(findMissingInLight(tokens)).toEqual(['--mute'])
})
it('an allowed literal that is no longer there is a finding', () => {
const stale = { file: 'src/ui/Button.module.css', literal: '#2f3846', reason: 'moved to a token' }
expect(check(undefined, [...ALLOWED, stale])).toEqual([
'src/ui/Button.module.css #2f3846 is allowed but no longer found: remove the entry',
])
})
})
+111
View File
@@ -0,0 +1,111 @@
/**
* The guard that keeps the em dash (U+2014) out of the repository. The owner's
* call, on 2026-10-01: in prose it reads as machine-written. A period, a colon, a
* comma or parentheses do the same job. On 2026-10-02 the rule grew from the
* Markdown and the GitHub-facing files to every text file in the repository: the
* code, its comments, the strings it prints and the titles of its tests.
*
* Only tracked files are read (`git ls-files`), so a local, untracked file (a
* note at the root, a symlink to an agent's instructions) is never part of the
* check. A tracked file deleted in the working tree is skipped too.
*
* Not read at all:
*
* - Binaries: a file with a NUL byte in it (fonts, images, the favicon).
* - `package-lock.json`, which npm writes.
* - `public/json/`, upstream's data files, shipped as they come.
*
* One use stays: the interface's "no value" placeholder is an em dash on purpose
* (the stock console draws the same). In the code under `src/` a dash is let
* through when it is the whole of a string literal (`'\u2014'`, in any of the three
* quotes) or the whole of a JSX text (`>\u2014<`, or alone on its line), which also
* covers a test asserting on it (`getByText('\u2014')`). Comments are not exempt:
* they are found and blanked first, so a comment that quotes the placeholder is a
* finding like any other. Anywhere else there is no exemption, so a tool that has
* to match the dash as data writes it as the escape, as `DASH` does below.
*
* Run by `npm test` through `check-dashes.test.mjs`, and on its own with
* `npm run lint:dashes`, which takes optional paths to narrow it
* (`npm run lint:dashes -- dev .github`).
*/
import { execFileSync } from 'node:child_process'
import { existsSync, readFileSync } from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { stripComments } from './check-colour-tokens.mjs'
export const DASH = '\u2014'
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
/** Tracked files that are never read. */
export const SKIPPED = [/^package-lock\.json$/, /^public\/json\//]
/** Where the placeholder may appear, and how each kind of file writes a comment. */
const PLACEHOLDER_DIR = 'src/'
const KINDS = { '.ts': 'ts', '.tsx': 'ts', '.js': 'ts', '.mjs': 'ts', '.css': 'css' }
const PLACEHOLDER = [
/(['"`])\u2014\1/g,
/>\u2014</g,
/^\s*\u2014\s*$/g,
]
/** The line with every placeholder use taken out. */
export function withoutPlaceholders(line) {
return PLACEHOLDER.reduce((l, re) => l.replace(re, ''), line)
}
const count = (s) => s.split(DASH).length - 1
/**
* The lines with an em dash. With `kind` ('ts' or 'css'), the text is code: a dash
* in a comment is always a finding, and one in the code only when it is not the
* placeholder.
*/
export function findDashes(text, { kind } = {}) {
const lines = text.split('\n')
const code = kind ? stripComments(text, kind).split('\n') : lines
const found = []
lines.forEach((line, i) => {
if (!line.includes(DASH)) return
const inComment = count(line) > count(code[i])
if (!kind || inComment || withoutPlaceholders(code[i]).includes(DASH))
found.push({ line: i + 1, text: line.trim() })
})
return found
}
/** The comment syntax of a file where the placeholder may appear; `undefined` elsewhere. */
export function kindOf(rel) {
if (!rel.startsWith(PLACEHOLDER_DIR)) return undefined
return KINDS[path.extname(rel)]
}
export function checkedFiles({ root = ROOT, paths = [] } = {}) {
return execFileSync('git', ['ls-files', '-z', '--', ...paths], { cwd: root, encoding: 'utf8' })
.split('\0')
.filter((rel) => rel && !SKIPPED.some((re) => re.test(rel)) && existsSync(path.join(root, rel)))
}
export function check({ root = ROOT, paths = [] } = {}) {
const findings = []
for (const rel of checkedFiles({ root, paths })) {
const bytes = readFileSync(path.join(root, rel))
if (bytes.includes(0)) continue
for (const f of findDashes(bytes.toString('utf8'), { kind: kindOf(rel) }))
findings.push(`${rel}:${f.line} ${f.text}`)
}
return findings
}
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
const paths = process.argv.slice(2)
const findings = check({ paths })
if (findings.length === 0) {
console.log(`\n No em dash in the ${checkedFiles({ paths }).length} tracked files, apart from the "no value" placeholder.\n`)
} else {
for (const f of findings) console.log(' ' + f)
console.log(`\n ${findings.length} lines with an em dash. Use a period, a colon, a comma or parentheses.\n`)
}
process.exit(Math.min(findings.length, 250))
}
+110
View File
@@ -0,0 +1,110 @@
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { execFileSync } from 'node:child_process'
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { dirname, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { DASH, check, checkedFiles, findDashes, withoutPlaceholders } from './check-dashes.mjs'
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
describe('em dashes', () => {
it('none in any tracked text file, apart from the "no value" placeholder', () => {
expect(check()).toEqual([])
})
it('looks at every tracked text file, code included, but not the lock file or upstream data', () => {
const files = checkedFiles()
for (const f of [
'README.md',
'install.sh',
'.github/workflows/release.yml',
'package.json',
'vite.config.ts',
'dev/check-dashes.mjs',
'src/main.tsx',
])
expect(files).toContain(f)
expect(files).not.toContain('package-lock.json')
expect(files.some((f) => f.startsWith('public/json/'))).toBe(false)
})
it('reads only tracked files, so it needs no file that is not in the repository', () => {
const files = checkedFiles()
expect(files.length).toBeGreaterThan(0)
for (const f of files) expect(existsSync(join(ROOT, f))).toBe(true)
})
it('lets the placeholder through as a whole literal or a whole JSX text, and nothing between words', () => {
const ok = [`'${DASH}'`, `"${DASH}"`, `\`${DASH}\``, `>${DASH}<`]
for (const p of ok) expect(withoutPlaceholders(`x ${p} y`)).not.toContain(DASH)
expect(withoutPlaceholders(` ${DASH}`)).not.toContain(DASH)
expect(withoutPlaceholders(`one ${DASH} two`)).toContain(DASH)
expect(withoutPlaceholders(`a${DASH}b`)).toContain(DASH)
expect(withoutPlaceholders(`'Version ${DASH}'`)).toContain(DASH)
expect(withoutPlaceholders(`'${DASH}"`)).toContain(DASH)
})
it('in code, catches a dash in a comment and lets the placeholder literal through', () => {
const ts = [
`// one ${DASH} two`,
`const v = x ?? '${DASH}'`,
`/* the '${DASH}' placeholder */`,
`return <span>${DASH}</span>`,
`expect(screen.getByText('${DASH}')).toBeInTheDocument()`,
`const s = 'a ${DASH} b'`,
`const t = '${DASH}' // and ${DASH} here`,
].join('\n')
expect(findDashes(ts, { kind: 'ts' }).map((f) => f.line)).toEqual([1, 3, 6, 7])
expect(findDashes(`.a { content: '${DASH}'; } /* x ${DASH} y */`, { kind: 'css' }).map((f) => f.line)).toEqual([1])
expect(findDashes(`.a { content: '${DASH}'; }`, { kind: 'css' })).toEqual([])
})
it('outside code there is no placeholder: every dash is a finding', () => {
expect(findDashes(`'${DASH}'`)).toEqual([{ line: 1, text: `'${DASH}'` }])
})
it('finds the line of each one, and not an en dash or a hyphen', () => {
expect(findDashes(`one\ntwo ${DASH} three\n9\u201310 and a-b`)).toEqual([{ line: 2, text: `two ${DASH} three` }])
})
describe('in a repository of its own', () => {
let root
const write = (rel, text) => {
mkdirSync(dirname(join(root, rel)), { recursive: true })
writeFileSync(join(root, rel), text)
}
beforeAll(() => {
root = mkdtempSync(join(tmpdir(), 'check-dashes-'))
execFileSync('git', ['init', '-q'], { cwd: root })
write('src/a.ts', `// a comment ${DASH} with a dash\nexport const none = '${DASH}'\n`)
write('src/b.tsx', `export const B = () => <td>${DASH}</td>\n`)
write('dev/tool.mjs', `const placeholder = '${DASH}'\n`)
write('README.md', `| a | ${DASH} |\n`)
write('package-lock.json', `{ "x": "${DASH}" }\n`)
write('public/json/list.json', `{ "x": "${DASH}" }\n`)
write('public/img.bin', Buffer.from([0, 1, 2, 0xe2, 0x80, 0x94]))
execFileSync('git', ['add', '.'], { cwd: root })
write('AGENTS.md', `untracked ${DASH} never read\n`)
})
afterAll(() => rmSync(root, { recursive: true, force: true }))
it('reports the comment in src, the literal outside src and the Markdown, and nothing else', () => {
expect(check({ root })).toEqual([
`README.md:1 | a | ${DASH} |`,
`dev/tool.mjs:1 const placeholder = '${DASH}'`,
`src/a.ts:1 // a comment ${DASH} with a dash`,
])
})
it('never reads an untracked file', () => {
expect(checkedFiles({ root })).not.toContain('AGENTS.md')
expect(check({ root }).some((f) => f.startsWith('AGENTS.md'))).toBe(false)
})
it('can be narrowed to some paths', () => {
expect(check({ root, paths: ['src'] })).toEqual([`src/a.ts:1 // a comment ${DASH} with a dash`])
})
})
})
+84
View File
@@ -0,0 +1,84 @@
/*
Which of the API endpoints the stock console calls does this one call too?
The README carried a figure for it ("114 of 114") that no script produced, and a
figure nobody can reproduce is an opinion. This produces it.
Upstream's side is every `"api/…"` literal in its `www/js/*.js` at the release
being matched (`UPSTREAM_REF`, default v15.6.2), read from the fork checked out
beside this repository (`../technitium-ui`).
This console's side is every string or template literal under `src/` that looks
like an API path. A template's `${…}` becomes a wildcard for ONE path segment,
which is how the three list screens share one component (`${kind}/list` serves
`allowed/list`, `blocked/list` and `cache/list`). A wildcard alone is not
proof, so a covered endpoint whose only match is a wildcard is printed as such:
look at those by eye.
node dev/check-endpoints.mjs
UPSTREAM_REF=v15.4.0 node dev/check-endpoints.mjs
Exit code is the number of upstream endpoints not covered.
*/
import { execFileSync } from 'node:child_process'
import { readdirSync, readFileSync, statSync } from 'node:fs'
import { dirname, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
const here = dirname(fileURLToPath(import.meta.url))
const SRC = resolve(here, '../src')
const FORK = resolve(here, '../../technitium-ui')
const REF = process.env.UPSTREAM_REF ?? 'v15.6.2'
const git = (...args) => execFileSync('git', ['-C', FORK, ...args], { encoding: 'utf8', maxBuffer: 1 << 26 })
const theirs = new Set()
for (const f of git('ls-tree', '--name-only', REF, 'DnsServerCore/www/js/').split('\n').filter(Boolean)) {
for (const m of git('show', `${REF}:${f}`).matchAll(/["']api\/([A-Za-z0-9/]+)/g)) {
theirs.add(m[1].replace(/\/$/, ''))
}
}
function files(dir) {
return readdirSync(dir).flatMap((n) => {
const p = join(dir, n)
if (statSync(p).isDirectory()) return files(p)
return /\.tsx?$/.test(n) && !/\.test\.|fixture/.test(n) ? [p] : []
})
}
/* Literal paths, and templates turned into one-segment wildcards. */
const exact = new Set()
const patterns = []
for (const f of files(SRC)) {
const text = readFileSync(f, 'utf8')
for (const m of text.matchAll(/(['"])((?:api\/)?[a-z][A-Za-z0-9]*(?:\/[A-Za-z0-9]+)+)\1/g)) {
exact.add(m[2].replace(/^api\//, ''))
}
for (const m of text.matchAll(/`((?:api\/)?(?:[A-Za-z0-9]+|\$\{[^}]+\})(?:\/(?:[A-Za-z0-9]+|\$\{[^}]+\}))+)[`?]/g)) {
const path = m[1].replace(/^api\//, '')
if (!path.includes('${')) {
exact.add(path)
continue
}
const rx = '^' + path.split('/').map((s) => (s.startsWith('${') ? '[A-Za-z0-9]+' : s)).join('/') + '$'
patterns.push({ rx: new RegExp(rx), source: path, file: f.slice(SRC.length + 1) })
}
}
const missing = []
const byWildcard = []
for (const e of [...theirs].sort()) {
if (exact.has(e)) continue
const p = patterns.find((q) => q.rx.test(e))
if (p) byWildcard.push(`${e} ← ${p.source} (${p.file})`)
else missing.push(e)
}
for (const w of byWildcard) console.log(` WILDCARD ${w}`)
for (const m of missing) console.log(` MISSING ${m}`)
console.log(
`\nENDPOINTS (${REF}): ${theirs.size - missing.length} of ${theirs.size} covered` +
(byWildcard.length ? `, ${byWildcard.length} of them only through a wildcard; check those by eye.` : '.'),
)
process.exit(Math.min(missing.length, 250))
+379
View File
@@ -0,0 +1,379 @@
/**
* The guard that keeps the repository in English.
*
* `CONVENTIONS.md` has said since the first day that "everything is in ENGLISH:
* the interface, the code, the comments and the tests", and the visual redesign
* broke it from the inside over three weeks: the interface stayed English and the
* code did not. By 2026-09-07 there were 60 files with Spanish comments, 19 with
* Spanish names and eighteen Spanish identifiers, and **nothing was measuring
* it**. This is what measures it.
*
* It started with `src/` and, since 2026-10-02, it reads every tracked text file:
* the Markdown at the root and in `docs/`, the tools in `dev/`, the workflows,
* the Docker files, the hooks, `install.sh` and the config files. It looks at:
*
* - **File names.** A path segment that is not a word this project writes in
* English. It is a blacklist of Spanish stems and not a dictionary: it cannot
* catch a name nobody has thought of yet.
* - **Comments** in code (`.ts`, `.tsx`, `.css`, `.js`, `.mjs`): any `/* *\/`,
* `//` or `{/* *\/}` that carries Spanish. Two signals: the characters
* `á é í ó ú ü ñ ¿ ¡`, which Spanish prose cannot avoid, and a list of
* accent-free words that are unambiguously Spanish: `porque`, `cuando`, `hay`,
* `del`… None of them is an English word, which is why the list can be short
* and still catch prose.
* - **Identifiers** in code: the same word list, outside comments and outside
* string literals, so a Spanish variable or a Spanish `describe()` is caught
* too.
* - **Every line** of every other text file (Markdown, shell, YAML, JSON,
* HTML, the Dockerfile, the hooks), read as prose: the same two signals, with
* only inline code (between backticks) taken out.
*
* What it deliberately does NOT look at:
*
* - Its own word lists, between the `language-data` markers below. They are
* the data that detects Spanish, and they would flag themselves.
* - Anything quoted inside a code comment. A comment that quotes a value is
* quoting data and not writing prose. Quoted text between a matched pair of
* backticks or `"` is stripped before the check.
* - Files that are not ours to write: `package-lock.json` (generated by npm)
* and `public/json/` (upstream's built-in lists, copied as they are). Binary
* files (images, fonts) are skipped.
*
* Exit code is the number of findings, so it can sit in the gate. Run by
* `npm test` through `check-language.test.mjs`, and on its own with
* `npm run lint:language`.
*/
import { execFileSync } from 'node:child_process'
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
/* language-data:start */
const ACCENTS = /[áéíóúüñ¿¡]/i
/* Every one of these is a word Spanish prose can hardly avoid and English never
uses. Short ones that ARE English (`no`, `es`, `la`, `se`) are left out on
purpose: they would flag English paragraphs and a guard that cries wolf gets
switched off. */
const WORDS = /\b(porque|cuando|pero|sino|para|hay|una|unos|unas|los|las|del|que|con|por|sin|entre|desde|cada|donde|como|aunque|mientras|siempre|nunca|tambien|entonces|segun|asi|solo|mas|esta|estan|habia|era|fue|tiene|hace|puede|debe|esto|este|eso|esa|ese|sus|nada|todo|toda|algo|otra|otro|misma|mismo|aqui|alli|ademas|ahora|antes|despues|dentro|fuera|arriba|abajo|encima|debajo)\b/i
const NAMES = /(ranura|caducado|crudo|senal|avisador|aviso|externo|boton|maestro|bloque|fallo|leyenda|interaccion|rotulo|pantalla|tarjeta|hueco|prueba|nodo|vacio|columna|fila|ayuda|derecha|izquierda)/i
/* Spanish that hides inside an identifier rather than in prose: `setTexto`,
`compacto`, `nombreRelativo`, `.caja`. The word list above does not see these
(they are not words on their own), so the stems go in a list of their own, and
it is checked against identifiers and class names, never against comments,
where an English sentence may legitimately quote one. */
const STEMS = /\b[A-Za-z]*(?:ranura|caducad|senal|avisad|externo|boton|maestro|bloque|fallo|leyenda|interaccion|rotulo|pantalla|tarjeta|hueco|vacio|columna|fila|ayuda|derecha|izquierda|texto|abiert|compacto|compacta|guardar|cargar|borrar|elegid|marcad|cerrad|nombre|caja|pastilla|salto|ultimo|arbol|secciones|orden(?![a-z])|prueba)[A-Za-z]*\b/
/*
Spanish that hides INSIDE an identifier: `esCache`, `pideTotp`, `firmaBase`.
Substring stems get this wrong in both directions (`firma` sits inside
`confirmation`, which is English, and `orden` misses `ordenar`), so the line is
broken into its camelCase pieces first and each piece is looked up WHOLE. That is
what catches `esCache` without flagging `confirmation`, and it is why `es` can be
on the list at all: as a whole word it is Spanish, as a substring it is in half
the English dictionary.
It runs alongside the stem list, not instead of it: the stems still catch the
inflections a whole-word lookup cannot see.
*/
/*
Two lists in one: the whole Spanish words of `WORDS` (which as camelCase pieces
are just as Spanish as they are in prose, and `setPorDesinstalar` proved it: `por`
is on that list and the regex missed it because there is no word boundary inside
an identifier) plus the nouns this codebase actually reached for.
It grows by finding, and that is the honest way to keep it: every entry below was
added because something got through. `por`, `desinstalar`, `hay`, `nota`, `tienda`
and `anotacion` are the batch of 2026-09-07, from Apps.
*/
const PIECES = new Set(
(
'porque cuando pero sino para hay una unos unas los las del que con por sin entre desde cada ' +
'donde como aunque mientras siempre nunca tambien entonces segun asi solo mas esta estan habia ' +
'era fue tiene hace puede debe esto este eso esa ese sus nada todo toda algo otra otro misma ' +
'mismo aqui alli ademas ahora antes despues dentro fuera arriba abajo encima debajo ' +
'es firma firmas firmar pedir pide piden confirmar desinstalar instalar texto nodo nodos arbol ' +
'caja cajas vacio vacia senal senial aviso avisos rotulo rotulos pantalla pantallas prueba ' +
'pruebas orden ultimo ultima salto hueco huecos fila filas columna columnas ayuda derecha ' +
'izquierda abierto abierta compacto compacta guardar cargar borrar marcado cerrado nombre ' +
'nombres pastilla boton maestro bloque fallo fallos leyenda caducado ranura externo nota notas ' +
'tienda anotacion reemplazar reemplaza tabla ficha fichas ' +
'fecha fechas hora horas dia dias mes meses tiempo valor valores clave claves llave lista listas ' +
'listar buscar busca filtro filtros filtrar crear editar mostrar ocultar enviar recibir contar ' +
'cuenta cuentas ancho anchos alto largo corto nuevo nueva viejo antiguo activo activa apagado ' +
'encendido cerrar abrir volver seguir contiene devuelve llama espera ' +
'carga cargas bloques cero dos tres cuatro cinco seis siete ocho nueve diez digito digitos ' +
'usuario usuarios conserva pegado golpe flotante foco hueco fuerte medio cajon curso ajustes enlaces seccion contenido iteraciones mirar estado ' +
/*
2026-09-30, the fifth hole. The gate was green and a census (every identifier
split into its camelCase pieces, each piece looked up in an English corpus and
the leftovers read by hand) found about 140 Spanish identifiers in 90 files:
`PropiedadesDnssec`, `EnElCromo`, `servidoresPrimariosObligatorios`, `setOlvido`,
`.testigo`, `data-testid="hueco"`, a dozen Spanish test descriptions. None of
them used a word on this list, and that is the whole lesson: a blacklist only
sees the words someone has already met. It cannot be completed by thinking
harder; the census is what finds the next batch, and its words come here.
These are the pieces of that sweep, renamed and now refused. Left out on
purpose, although they were Spanish where they appeared: `el` and `en`, which
this code uses in English as the usual shorthand for an element and for the
enablement rules, and the one-letter `a` / `o` of `aSlug` and `.oLogin`, which
as whole pieces are in every English identifier.
*/
'agente alta anadiendo apagada aplanar archivo automatico cambiar campo casilla casos catalogo ' +
'catalogos celda clases coincidencias conteo cromo crudas cruda cuerpo cuerpos curvas datos depende ' +
'dependiente descarte destino detalle dialogo dibujo disponibles ejemplo elemento elementos entero ' +
'entrada escapar eventos explicadas fallar fallido fijo forma formulario hacer hola huellas icono ' +
'ignorar inicial inicializar inicio instaladas interfaz inverso lanzar llamada llegar mandar manda ' +
'marca miembros modo muestra obligatorio obligatorios observador ocultas ocultos olvido otras ' +
'partido pestana pintadas pintar preparar previo previa primario primarios propiedades protocolo ' +
'protocolos punto raiz recuento refrescar respuesta resultado roto salida secciones sectores ' +
'selectores separados serie servidor servidores sobrescribir sujetos tamanos testigo tipo tira ' +
'todos tono unico usos visibles visto fin ir ya de al par fase grafica ninguna lecturas transporte ' +
'completos propaga tal cual vaciar importar exportar dominios unirse uno existente construir ' +
'piezas puras sesiones activas modales sobra esperaba falta'
).split(' '),
)
/* language-data:end */
/*
The description of a test is prose, and it was the one piece of prose this file
could not see: code lines have their single-quoted strings stripped (that is what
keeps a Spanish DATA literal from being reported), and a test description is a
single-quoted string like any other.
So it is read back on purpose, and only from the three functions that take one.
`CONVENTIONS.md` says the tests are in English too; this is what enforces it.
*/
const DESCRIPTION = /\b(?:it|test|describe)(?:\.\w+)?\(\s*(?:'([^']*)'|`([^`]*)`|"([^"]*)")/g
/*
Names that live inside a string and are still names: a test id and a `data-`
attribute. Stripping string literals is what keeps Spanish DATA quiet, and it also
hid `data-testid="hueco"` and a selector for `[data-activa="true"]` that matched
nothing because the attribute had been renamed to `data-active` everywhere else
(2026-09-30, the fifth hole). These are read back from the raw line.
*/
const NAMED_IN_STRING = /(?:data-testid=\{?\s*|ByTestId\(\s*)['"`]([^'"`]*)['"`]|\bdata-([a-z][a-z-]*)/g
/** Every camelCase piece of every identifier on the line, lower-cased. */
function pieces(line) {
const out = []
for (const id of line.match(/[A-Za-z][A-Za-z0-9]*/g) ?? []) {
for (const w of id.replace(/([a-z0-9])([A-Z])/g, '$1 $2').split(/\s+/)) {
if (w) out.push(w.toLowerCase())
}
}
return out
}
/* Proper names are not prose: `Adrián` carries an accent and appears in a dozen
decisions that are written in English around it. `Archivo` is the typeface the
interface is set in (`theme/base.css`), capitalised as its foundry writes it; the
lower-case `archivo` is Spanish and stays refused. */
/* language-data:start */
const NAMES_ALLOWED = /Adri[áa]n|\bArchivo\b/g
/* language-data:end */
function stripQuoted(s) {
return s
.replace(NAMES_ALLOWED, ' ')
.replace(/`[^`\n]*`/g, ' ')
.replace(/"[^"\n]*"/g, ' ')
.replace(/«[^»\n]*»/g, ' ')
}
/* Comments and code, told apart the cheap way: this is a linter for prose, not a
parser. A `//` inside a string literal is the known false positive, and the
quoted-text strip above is what keeps it quiet. */
function split(text) {
const comments = []
const code = []
let i = 0
const lines = text.split('\n')
while (i < lines.length) {
const l = lines[i]
if (l.includes('/*')) {
let j = i
while (j < lines.length && !lines[j].includes('*/')) j++
for (let k = i; k <= Math.min(j, lines.length - 1); k++) comments.push([k + 1, lines[k]])
i = j + 1
} else if (l.trim().startsWith('//') || l.trim().startsWith('*')) {
comments.push([i + 1, l]); i++
} else {
const at = l.indexOf('//')
if (at >= 0) { comments.push([i + 1, l.slice(at)]); code.push([i + 1, l.slice(0, at)]) }
else code.push([i + 1, l])
i++
}
}
return { comments, code }
}
/*
The files it reads: every tracked file, so a new folder is covered the day it is
committed. `git ls-files` and not a walk of the disk, so `node_modules/`, `dist/`
and an untracked local file are never read.
*/
const NOT_OURS = [
/^package-lock\.json$/,
/^public\/json\//,
/*
Responses captured from a real server and kept byte for byte as test fixtures:
the domains and records in them are data the server returned (a block list's
`t15-lista.com`, a base64 signature), not text anyone here wrote.
*/
/(^|\/)real-sample\.json$/,
/*
The ReUI registry code, exactly as `shadcn add` wrote it. It is vendored, so its
comments are not ours to reword (one of them trips the word lists).
*/
/^src\/components\//,
]
const BINARY = /\.(png|gif|ico|jpe?g|webp|woff2?|ttf|otf|zip|gz)$/i
const CODE = /\.(ts|tsx|css|js|mjs|cjs)$/
export function checkedFiles(root = ROOT) {
return execFileSync('git', ['ls-files', '-z'], { cwd: root, encoding: 'utf8' })
.split('\0')
.filter(Boolean)
.filter((f) => !NOT_OURS.some((re) => re.test(f)) && !BINARY.test(f))
}
/*
The lines of its own word lists, which are data and would flag themselves, and
the Spanish samples its test feeds it. Only those two files carry the markers,
and only the lines between them are dropped.
*/
function withoutLanguageData(text) {
const lines = text.split('\n')
let inside = false
return lines
.map((l) => {
if (l.includes('/* language-data:start */')) inside = true
const out = inside ? '' : l
if (l.includes('/* language-data:end */')) inside = false
return out
})
.join('\n')
}
/* A line of prose with its inline code and the allowed names taken out. Quoted
text stays: in a document, a quotation is still something a reader reads. */
function proseOf(line) {
return line.replace(NAMES_ALLOWED, ' ').replace(/`[^`\n]*`/g, ' ')
}
/*
In a document a word is only a word between word boundaries: `es2023` in a
tsconfig is a target and not the Spanish `es`. Code comments keep the looser
lookup they always had.
*/
const isSpanishProse = (clean, { whole = false } = {}) =>
ACCENTS.test(clean) ||
WORDS.test(clean) ||
(clean.toLowerCase().match(whole ? /\b[a-z]+\b/g : /[a-z]+/g) ?? []).some((w) => PIECES.has(w))
/*
`Math.sin` is the sine, not the Spanish `sin`: the colour tools in `dev/` compute
CIEDE2000 with it. The standard library's own members are taken out of a code
line before the word lists see it.
*/
const STDLIB = /\bMath\.\w+/g
/* The string literals of a code line: in `dev/` they are what a tool prints. */
const STRINGS = /'([^'\n]*)'|"([^"\n]*)"|`([^`\n]*)`/g
/** The findings of one file, given its path relative to the root and its text. */
export function checkText(rel, raw) {
const findings = []
const segments = rel.split('/')
if (segments.some((seg) => NAMES.test(seg))) findings.push(`${rel} file name is not English`)
const text = withoutLanguageData(raw)
if (!CODE.test(rel)) {
text.split('\n').forEach((line, i) => {
if (isSpanishProse(proseOf(line), { whole: true })) findings.push(`${rel}:${i + 1} Spanish in the text: ${line.trim().slice(0, 70)}`)
})
return findings
}
const { comments, code } = split(text)
for (const [n, line] of comments) {
const clean = stripQuoted(line)
/*
The comments were checked against WORDS only, and prose without an accent or
one of those connectives walked through: «Cuatro bloques.», «Tabla»,
«/* Fechas *\/», «cerrar no avisa» (2026-09-30, the fourth hole). The whole-
word PIECES lookup is applied here too; on the day it was added it flagged
nothing that was not Spanish.
*/
if (isSpanishProse(clean)) findings.push(`${rel}:${n} Spanish in a comment: ${line.trim().slice(0, 70)}`)
}
/*
Outside `src/` the string literals are read too. In `src/` a string is mostly
upstream's data and stays quiet; in a tool it is the message the tool prints,
and a Spanish one walked through for weeks (`dev/row-colours.mjs` reporting
"COLISIÓN", `dev/parity-login.mjs` reporting "PARIDAD DE MENSAJES").
*/
const readStrings = !rel.startsWith('src/')
for (const [n, line] of code) {
const clean = stripQuoted(line).replace(/'[^'\n]*'/g, ' ').replace(STDLIB, ' ')
if (readStrings)
for (const m of line.matchAll(STRINGS)) {
const str = (m[1] ?? m[2] ?? m[3] ?? '').replace(NAMES_ALLOWED, ' ')
if (isSpanishProse(str, { whole: true })) findings.push(`${rel}:${n} Spanish in a string: ${str.slice(0, 70)}`)
}
const spanishPiece = pieces(clean).find((w) => PIECES.has(w))
if (ACCENTS.test(clean) || WORDS.test(clean) || STEMS.test(clean) || spanishPiece != null)
findings.push(`${rel}:${n} Spanish in code: ${line.trim().slice(0, 70)}`)
for (const m of line.matchAll(DESCRIPTION)) {
const what = m[1] ?? m[2] ?? m[3] ?? ''
const piece = pieces(what).find((w) => PIECES.has(w))
if (ACCENTS.test(what) || WORDS.test(what) || piece != null)
findings.push(`${rel}:${n} Spanish in a test description: ${what.slice(0, 70)}`)
}
for (const m of line.matchAll(NAMED_IN_STRING)) {
const name = m[1] ?? m[2] ?? ''
const piece = pieces(name.replace(/-/g, ' ')).find((w) => PIECES.has(w))
if (ACCENTS.test(name) || piece != null)
findings.push(`${rel}:${n} Spanish in a test id or data attribute: ${name.slice(0, 70)}`)
}
}
return findings
}
export function check(root = ROOT) {
const findings = []
for (const rel of checkedFiles(root)) {
const file = path.join(root, rel)
if (!fs.existsSync(file)) continue
const buf = fs.readFileSync(file)
if (buf.includes(0)) continue
findings.push(...checkText(rel, buf.toString('utf8')))
}
return findings
}
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
const findings = check()
if (findings.length === 0) {
console.log(`\n The repository is in English: no Spanish in the ${checkedFiles().length} tracked text files.\n`)
} else {
const byFile = new Map()
for (const f of findings) {
const k = f.split(':')[0].split(' ')[0]
byFile.set(k, (byFile.get(k) ?? 0) + 1)
}
const sorted = [...byFile].sort((a, b) => b[1] - a[1])
for (const [f, n] of sorted) console.log(` ${String(n).padStart(4)} ${f}`)
console.log(`\n ${findings.length} findings in ${byFile.size} files\n`)
if (process.env.DETAIL) for (const f of findings) console.log(' ' + f)
}
process.exit(Math.min(findings.length, 250))
}
+105
View File
@@ -0,0 +1,105 @@
import { describe, expect, it } from 'vitest'
import { check, checkText, checkedFiles } from './check-language.mjs'
/*
The gate behind "everything is in English". It lives in `dev/` for the reason
`relative-paths.test.mjs` gives (`src/` compiles without node's types) and it runs
inside `npm test`, so the rule is checked wherever the suite is.
*/
/*
The Spanish this test feeds the checker. It is data, so it sits between the same
markers that keep the checker's own word lists out of the check.
*/
/* language-data:start */
const SAMPLE = {
prose: 'Esta consola es una alternativa para el servidor.',
quotedTitle: 'The section is called "Qué se refresca".',
devComment: '// Comprueba los endpoints que la consola usa',
shellComment: '# Copiado del repositorio vecino',
yamlComment: ' # sin esto no arranca',
printed: "console.log('== LÍNEA sobre el panel ==')",
identifier: 'const filaVacia = 1',
description: "it('una tabla vacía no aporta firma', () => {})",
words: 'const W = /porque|cuando/',
comment: '// porque sí',
fileName: 'dev/prueba.mjs',
/* And two lines that only look Spanish to a word list. */
sine: 'const dH = 2 * Math.sqrt(c) * Math.sin(h / 2)',
idn: 'An IDN such as `mañana.test` is shown decoded.',
}
/* language-data:end */
const START = '/* language-' + 'data:start */'
const END = '/* language-' + 'data:end */'
describe('language', () => {
it('no Spanish in any tracked text file', () => {
expect(check()).toEqual([])
})
it('reads the whole repository, not only src/', () => {
const files = checkedFiles()
for (const f of [
'README.md',
'CONVENTIONS.md',
'install.sh',
'.githooks/pre-commit',
'.github/workflows/release.yml',
'docker/Dockerfile',
'docs/2026-09-07-installer-contract.md',
'dev/uniformity.js',
'dev/compose.yaml',
'vite.config.ts',
'src/app/sections.ts',
])
expect(files).toContain(f)
})
it('leaves out only what is not ours to write, and binaries', () => {
const files = checkedFiles()
expect(files).not.toContain('package-lock.json')
expect(files.some((f) => f.startsWith('public/json/'))).toBe(false)
expect(files.some((f) => f.endsWith('real-sample.json'))).toBe(false)
expect(files.some((f) => /\.(png|gif|ico|woff2)$/.test(f))).toBe(false)
})
it('finds Spanish prose in a Markdown file', () => {
expect(checkText('README.md', `One line.\n${SAMPLE.prose}\n`)).toEqual([`README.md:2 Spanish in the text: ${SAMPLE.prose}`])
expect(checkText('docs/x.md', SAMPLE.quotedTitle)).toHaveLength(1)
})
it('finds Spanish in a comment of a dev tool, a shell script and a workflow', () => {
expect(checkText('dev/tool.mjs', `${SAMPLE.devComment}\nconst a = 1\n`)).toHaveLength(1)
expect(checkText('install.sh', `${SAMPLE.shellComment}\nset -eu\n`)).toHaveLength(1)
expect(checkText('.github/workflows/x.yml', `${SAMPLE.yamlComment}\n`)).toHaveLength(1)
})
it('reads what a dev tool prints, and leaves the strings of src/ alone', () => {
const line = `${SAMPLE.printed}\n`
expect(checkText('dev/tool.mjs', line)).toHaveLength(1)
expect(checkText('src/x.ts', line)).toEqual([])
})
it('finds a Spanish identifier and a Spanish test description in dev/', () => {
expect(checkText('dev/tool.mjs', `${SAMPLE.identifier}\n`)).toHaveLength(1)
expect(checkText('dev/tool.test.mjs', `${SAMPLE.description}\n`).length).toBeGreaterThan(0)
})
it('does not take English for Spanish', () => {
expect(checkText('README.md', 'Run `npm run lint:language` before the pull request.\n')).toEqual([])
expect(checkText('tsconfig.json', '{ "target": "es2023", "lib": ["ES2023"] }\n')).toEqual([])
expect(checkText('dev/colour.mjs', `${SAMPLE.sine}\n`)).toEqual([])
expect(checkText('CONVENTIONS.md', 'Amber is not offered (Adrián\'s decision).\n')).toEqual([])
expect(checkText('README.md', `${SAMPLE.idn}\n`)).toEqual([])
})
it('does not read the data between the markers, and only that', () => {
const data = `${START}\n${SAMPLE.words}\n${END}\n`
expect(checkText('dev/check-language.mjs', data)).toEqual([])
expect(checkText('dev/check-language.mjs', `${data}${SAMPLE.comment}\n`)).toHaveLength(1)
})
it('flags a Spanish file name anywhere in the tree', () => {
expect(checkText(SAMPLE.fileName, '')).toEqual([`${SAMPLE.fileName} file name is not English`])
})
})
+135
View File
@@ -0,0 +1,135 @@
#!/usr/bin/env bash
# Side-by-side comparison by ACTION, not by bytes of the page.
#
# The project's governing constraint is "design only, zero functionality", and
# that enables the check that actually counts: run the same action on both
# instances and verify the RESULTING SERVER STATE is the same. If the body the
# new console sends differs by one parameter, the record left behind differs and
# it shows up here.
#
# The bodies are the ones upstream builds, copied from its JS. The new console
# sends the same ones: that is what is being verified.
#
# Usage: ./check-parity-actions.sh
set -uo pipefail
DEV=http://127.0.0.1:5380
REF=http://127.0.0.1:5381
USERNAME=admin
PASSWORD=technitium-ui-dev
ZONE=paridad.test
failures=0
token() {
curl -s "$1/api/user/login?user=$USERNAME&pass=$PASSWORD&includeInfo=false" |
python3 -c 'import sys,json;print(json.load(sys.stdin)["token"])'
}
TD=$(token "$DEV")
TR=$(token "$REF")
# Leaves the zone identical on both before each comparison block.
reset_zone() {
for pair in "$DEV $TD" "$REF $TR"; do
set -- $pair
curl -s "$1/api/zones/delete?token=$2&zone=$ZONE" >/dev/null
curl -s "$1/api/zones/create?token=$2&zone=$ZONE&type=Primary" >/dev/null
done
}
# What is compared: the zone's records, minus whatever changes with the
# environment.
#
# Three things are normalised, all three for the same reason: they are not parity.
# · `lastModified` and the SOA serial change with the clock.
# · The SERVER NAME is embedded in the NS and the SOA of every zone, and the
# two containers deliberately have a different `DNS_SERVER_DOMAIN`
# (dev.technitium-ui.test / ref.technitium-ui.test). Without substituting it,
# all fourteen comparisons come out different over the hostname and not over
# the action.
fingerprint() {
curl -s "$1/api/zones/records/get?token=$2&domain=$ZONE&zone=$ZONE&listZone=true" |
python3 -c '
import sys, json, re
d = json.load(sys.stdin)["response"]
out = []
for r in sorted(d["records"], key=lambda x: (x["type"], x["name"])):
r.pop("lastModified", None)
if r["type"] == "SOA":
r["rData"].pop("serial", None)
out.append(r)
text = json.dumps(out, sort_keys=True)
print(re.sub(r"(dev|ref)\.technitium-ui\.test", "THIS-SERVER", text))'
}
# compare <name> <body-or-query> [method]
compare() {
local name=$1 path=$2 body=${3:-} method=${4:-GET}
for pair in "$DEV $TD" "$REF $TR"; do
set -- $pair
if [ "$method" = POST ]; then
curl -s -X POST "$1/api/$path?token=$2&node=" --data "$body" >/dev/null
else
curl -s "$1/api/$path?token=$2&$body" >/dev/null
fi
done
local d r
d=$(fingerprint "$DEV" "$TD")
r=$(fingerprint "$REF" "$TR")
if [ "$d" = "$r" ]; then
echo " OK $name"
else
echo " DIFFERENT $name"
diff <(echo "$r" | python3 -m json.tool) <(echo "$d" | python3 -m json.tool) | head -20
failures=$((failures + 1))
fi
}
echo "== record creates =="
reset_zone
compare "A with PTR" "zones/records/add" \
"zone=$ZONE&domain=www.$ZONE&type=A&ttl=3600&overwrite=false&comments=&expiryTtl=&ipAddress=10.0.0.1&ptr=false&createPtrZone=false&updateSvcbHints=false" POST
compare "MX" "zones/records/add" \
"zone=$ZONE&domain=$ZONE&type=MX&ttl=3600&overwrite=false&comments=&expiryTtl=&preference=10&exchange=mail.$ZONE" POST
compare "split TXT" "zones/records/add" \
"zone=$ZONE&domain=$ZONE&type=TXT&ttl=3600&overwrite=false&comments=note&expiryTtl=&text=v=spf1 -all&splitText=true" POST
compare "SRV" "zones/records/add" \
"zone=$ZONE&domain=_s._tcp.$ZONE&type=SRV&ttl=3600&overwrite=false&comments=&expiryTtl=&priority=1&weight=2&port=443&target=www.$ZONE" POST
compare "CAA with default values" "zones/records/add" \
"zone=$ZONE&domain=$ZONE&type=CAA&ttl=3600&overwrite=false&comments=&expiryTtl=&flags=0&tag=issue&value=letsencrypt.org" POST
compare "SVCB with params and automatic hint" "zones/records/add" \
"zone=$ZONE&domain=svc.$ZONE&type=SVCB&ttl=3600&overwrite=false&comments=&expiryTtl=&svcPriority=1&svcTargetName=www.$ZONE&svcParams=alpn|h2&autoIpv4Hint=true&autoIpv6Hint=false" POST
compare "CNAME" "zones/records/add" \
"zone=$ZONE&domain=alias.$ZONE&type=CNAME&ttl=3600&overwrite=false&comments=&expiryTtl=&cname=www.$ZONE" POST
echo "== edits =="
compare "edit the A: old and new" "zones/records/update" \
"zone=$ZONE&type=A&domain=www.$ZONE&newDomain=www.$ZONE&ttl=1800&disable=false&comments=&expiryTtl=&ipAddress=10.0.0.1&newIpAddress=10.0.0.2&ptr=false&createPtrZone=false&updateSvcbHints=false" POST
compare "disable the MX by resending it whole" "zones/records/update" \
"zone=$ZONE&type=MX&domain=$ZONE&ttl=3600&disable=true&comments=&expiryTtl=&preference=10&exchange=mail.$ZONE" POST
compare "edit the SOA" "zones/records/update" \
"zone=$ZONE&type=SOA&domain=$ZONE&newDomain=$ZONE&ttl=900&disable=false&comments=&expiryTtl=&primaryNameServer=ns1.$ZONE&responsiblePerson=hostadmin@$ZONE&serial=42&refresh=900&retry=300&expire=604800&minimum=900&useSerialDateScheme=false" POST
echo "== deletes =="
compare "delete the CNAME (no parameters: falls to default)" "zones/records/delete" \
"zone=$ZONE&domain=alias.$ZONE&type=CNAME" POST
compare "delete the SVCB by its flattened svcParams" "zones/records/delete" \
"zone=$ZONE&domain=svc.$ZONE&type=SVCB&svcPriority=1&svcTargetName=www.$ZONE&svcParams=alpn|h2" POST
compare "delete the SRV by its four fields" "zones/records/delete" \
"zone=$ZONE&domain=_s._tcp.$ZONE&type=SRV&priority=1&weight=2&port=443&target=www.$ZONE" POST
echo "== zone options =="
compare "options/set with the empty lists as \"false\"" "zones/options/set" \
"zone=$ZONE&catalog=&overrideCatalogQueryAccess=false&overrideCatalogZoneTransfer=false&overrideCatalogNotify=false&primaryNameServerAddresses=&primaryZoneTransferProtocol=Tcp&primaryZoneTransferTsigKeyName=&validateZone=false&queryAccess=Allow&queryAccessNetworkACL=&zoneTransfer=AllowOnlyZoneNameServers&zoneTransferNetworkACL=false&zoneTransferTsigKeyNames=false&notify=ZoneNameServers&notifyNameServers=false&notifySecondaryCatalogsNameServers=false&update=Deny&updateNetworkACL=false&updateSecurityPolicies=false"
echo
if [ "$failures" -eq 0 ]; then
echo "PARITY: every action leaves the same state"
exit 0
fi
echo "PARITY: $failures actions diverge"
exit 2
+429
View File
@@ -0,0 +1,429 @@
/*
Have we dropped any of upstream's destinations?
The review plan said "open the same screen in `technitium-ui-ref` and compare by
eye". Things slip past the eye, and they did, big ones: the About panel had lost
eight of its nine links, and the whole `body` footer, with the author's support
and donation addresses inside it.
This does it by list, and without a browser: upstream's console is a single
`index.html` with ALL its panels in the markup, so its destinations are read from
the HTML; ours are read from the application code. No new dependencies (putting
Playwright in `package.json` would show up in the pull request diff), and
therefore runnable anywhere with the reference console up.
node dev/check-parity-controls.mjs
REF=http://other:5381 node dev/check-parity-controls.mjs
What it does NOT answer, worth having in front of you before believing a green:
- **It searches ALL the code, not the screen in question.** If an explanation
appears in two dialogs and falls out of one, this stays green. Verified: the
DNSKEY TTL text was deliberately removed from "Sign Zone" and it said nothing,
because the same text lives in "DNSSEC Properties". It bites on what exists in
only one place, which is how things actually get lost: the About panel lost
eight links and none of them was anywhere else.
- Whether a destination present in both points to the same thing from the
equivalent screen.
- Whether a button that exists in both does the same thing. For behaviour there
is `check-parity-actions.sh`, which compares server state.
*/
import { readdirSync, readFileSync, statSync } from 'node:fs'
import { join } from 'node:path'
const REF = process.env.REF ?? 'http://127.0.0.1:5381'
const SOURCE = new URL('../src/', import.meta.url).pathname
/* Destinations upstream has and that must NOT be here, with their reason. Every
line is a decision, not an oversight: if it is not justified, it is a finding. */
const EXCUSED = new Map([
// Today this list is deliberately empty: it is left written so that whoever
// adds an excuse has to reason it out here.
])
function files(dir) {
const out = []
for (const n of readdirSync(dir)) {
const p = join(dir, n)
if (statSync(p).isDirectory()) out.push(...files(p))
else if (/\.(tsx?|css)$/.test(n)) out.push(p)
}
return out
}
const normalise = (u) => u.replace(/\/$/, '').replace(/^http:/, 'https:')
/** The four entities that appear in upstream's texts. */
const decode = (t) =>
t
.replace(/&amp;/g, '&')
.replace(/&quot;/g, '"')
.replace(/&#39;|&apos;/g, "'")
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
/*
The source WITHOUT comments.
A comment is not interface: nobody reads it on screen. While they were in Spanish
it made no difference, because they could not match an English sentence from
upstream; once translated to English it stopped making no difference: a comment
quoting the help text it explains would make a REALLY missing help text match,
and this check would go green for the opposite reason to the one it exists for.
They are stripped before comparing. String literals in the code stay, since that
is exactly where the text being searched for lives.
*/
const withoutComments = (t) =>
t.replaceAll(/\/\*[\s\S]*?\*\//g, ' ').replaceAll(/^\s*\/\/[^\n]*/gm, ' ')
const ours = files(SOURCE)
.filter((f) => !/\.test\./.test(f))
.map((f) => withoutComments(readFileSync(f, 'utf8')))
.join('\n')
const html = await fetch(`${REF}/`).then((r) => {
if (!r.ok) throw new Error(`the reference console answers ${r.status} at ${REF}`)
return r.text()
})
/* Upstream's destinations, with the screen they appear on. */
const theirs = new Map()
for (const m of html.matchAll(/href="((?:https?:|mailto:)[^"]+)"/g)) {
const url = normalise(m[1])
if (!theirs.has(url)) theirs.set(url, [])
/* The nearest container with an id, searching backwards. It is a HINT, not a
truth: upstream's markup does not close a modal before the next one starts,
so it serves to go and find it, not to cite it. */
const before = html.slice(0, m.index)
const near = [...before.matchAll(/id="(modal\w+|\w*TabPane\w+|footer)"/g)].pop()
theirs.get(url).push(near ? near[1] : '?')
}
/*
The application's text, reduced to words.
Comparing markup does not work here. JSX splits sentences (`Add{' '}<code>!</code>
character`) and many explanations travel as an ATTRIBUTE (`help="The duration for
which…"`), so stripping the tags to clean up takes with it the very text you came
looking for. Both produced false positives: the first accused the ACL text of
being lost, the second three more that had been in place for months.
Reducing both sides to bare words (no punctuation, no case, no symbols) removes
all of that difference. It adds noise (class names, attributes), but noise can
only produce a false NEGATIVE across eight consecutive words, and that does not
happen.
*/
const asWords = (t) =>
' ' +
t
.toLowerCase()
.replace(/&quot;|&#39;|&apos;/g, ' ')
.replace(/[^a-z0-9]+/g, ' ')
.trim() +
' '
/* JSX writes upstream's inline markup as markup too (`bind to <code>[::]</code>
local`), and the tag names would sit between the words as `code code`. Only
the attribute-free inline tags are dropped: a tag WITH attributes may be
carrying the very `help="…"` being looked for. Links are the exception: their
attributes are addresses, never explanations. */
const prose = asWords(
ours
.replace(/<\/?(?:code|b|i|em|strong|kbd|br\s*\/?)>/g, ' ')
.replace(/<\/?(?:Link|External|a)\b[^>]*>/g, ' ')
.replace(/\{' '\}/g, ' ')
// A bare JSX expression (`{hosts.doh}`) is a value filled at runtime, not
// prose; its name would otherwise sit between the words. Upstream's side
// drops its runtime labels the same way (see NOTES).
.replace(/\{[A-Za-z_$][\w.$]*\}/g, ' ')
// So is a template literal (`{`proxy_set_header ${realIpHeader} $remote_addr;`}`),
// where upstream has a `<code id="lbl…">` its script fills.
.replace(/\{`[^`]*`\}/g, ' '),
)
const missing = []
for (const [url] of theirs) {
if (EXCUSED.has(url)) continue
// the destination is searched for as-is and without the trailing slash: in the
// code it may be wrapped by the formatter, but a URL is never split.
const raw = url.replace(/^https:/, 'http:')
if (ours.includes(url) || ours.includes(raw) || ours.includes(`${url}/`)) continue
missing.push(url)
}
const total = theirs.size
for (const url of missing) {
console.log(` MISSING ${url} (upstream, near: ${[...new Set(theirs.get(url))].join(', ')})`)
}
console.log(
missing.length === 0
? `DESTINATION PARITY: all ${total} of upstream's are in the new console.`
: `DESTINATION PARITY: ${missing.length} of ${total} missing.`,
)
/*
And the help texts. Upstream does not mark them with a class of its own: they are
`div`s carrying the Bootstrap grid offset (`col-sm-offset-N col-sm-M`), which
turns out to be a reliable signature because nothing else goes there.
They are compared by a run of words from the middle, not by the whole sentence:
the beginning and the end are what gets reworded most while laying out, and what
matters is whether the explanation is there or not.
*/
const HELP = [...html.matchAll(/<div class="col-sm-offset-\d+ col-sm-\d+"[^>]*>([\s\S]*?)<\/div>/g)]
/* A `<span id="lbl…">` or `<code id="lbl…">` inside a note is a placeholder
`loadDnsSettings` overwrites on every load (main.js:1369-1372: the DoH, DoT,
DoQ and DoH(S) addresses). Its initial text is not contract, so it is not
compared. */
.map((m) => m[1].replace(/<(span|code) id="lbl\w+">[\s\S]*?<\/\1>/g, ' '))
.map((t) => t.replace(/<[^>]*>/g, ' ').replace(/&nbsp;/g, ' '))
.map((t) => decode(t).replace(/\s+/g, ' ').trim())
.filter((t) => t.length > 40)
const missingHelp = HELP.filter((t) => {
const words = asWords(t).trim().split(' ')
if (words.length < 12) return false
// three runs spread out: if NONE is there, the explanation is not there
const runs = [words.slice(2, 10), words.slice(6, 14), words.slice(-8)]
return !runs.some((p) => prose.includes(` ${p.join(' ')} `))
})
console.log('')
for (const t of missingHelp) console.log(` MISSING help: ${t.slice(0, 120)}…`)
console.log(
missingHelp.length === 0
? `HELP PARITY: all ${HELP.length} of upstream's texts are present.`
: `HELP PARITY: ${missingHelp.length} of ${HELP.length} missing.`,
)
/*
And each field's EXAMPLE, which is what upstream puts in `placeholder`.
Added after finding thirty-four of them lost, and some were not decoration: the
one on "Add Zone" read `example.com or 192.168.0.0/24 or 2001:db8::/64`, which is
how you discover that field takes a reverse zone in CIDR form, and the one on
"Certificate Association Data" carried the example hex and the PEM alternative.
Without them the field does not explain what it expects.
It compares by VALUE against the whole source, with the same limitation as the
other two checks and for the same reason: working out which component each field
ends up in would require understanding the JSX. That lets through the case where
the example is present but on another field (it happened with "confirm password",
which existed in "Add User" and was missing from "Change Password"), so a value
repeated across several fields has to be looked at by eye. What it does catch,
and what was needed, is the wholesale loss.
*/
const EXAMPLES = new Map()
for (const m of html.matchAll(/<(?:input|textarea)[^>]*>/g)) {
const ph = /placeholder="([^"]*)"/.exec(m[0])
if (!ph) continue
const id = /id="([^"]*)"/.exec(m[0])
/* Upstream's image was built on Windows and its multi-line examples carry
CRLF; our tree is LF. It is the same normalisation `dev/check-parity.sh`
does, and without it the four multi-line examples came out as lost over a
carriage return. */
const value = decode(ph[1]).replace(/\r\n/g, '\n')
if (value.trim().length < 1) continue
if (!EXAMPLES.has(value)) EXAMPLES.set(value, id ? id[1] : '?')
}
/* The forwarder example is rewritten by `zone.js:139-152` depending on the
protocol, and the same is done here in `forwarderExample`: the one in the HTML
is only the initial value. */
const DYNAMIC = new Set(['8.8.8.8'])
const missingExample = [...EXAMPLES].filter(
([value]) => !DYNAMIC.has(value) && !ours.includes(value.split('\n')[0]),
)
console.log('')
for (const [value, id] of missingExample) {
console.log(` MISSING example for ${id}: ${value.split('\n')[0].slice(0, 60)}`)
}
console.log(
missingExample.length === 0
? `EXAMPLE PARITY: all ${EXAMPLES.size} of upstream's are present.`
: `EXAMPLE PARITY: ${missingExample.length} of ${EXAMPLES.size} missing.`,
)
/*
The notes and the explanations that are NOT on the grid offset.
HELP above only reads `col-sm-offset-*` divs, and on 2026-09-30 it said "all 110
of upstream's texts are present" against a v15.5.1 whose new LDAP pane was not
here at all: its eleven "Note!"/"Warning!" paragraphs are `<p>`, and its field
explanations are `<div style="padding-top: 5px;">`. Neither shape was read. Both
are now, with the same three-runs comparison.
*/
const NOTES = [
...html.matchAll(/<p>([\s\S]*?)<\/p>/g),
...html.matchAll(/<div style="padding-top: 5px;[^"]*">([\s\S]*?)<\/div>/g),
// and the third shape, the Zone Options tabs': `<div><b>Note!</b> …</div>`
...html.matchAll(/<div>\s*(<b>(?:Note|Warning)!<\/b>[\s\S]*?)<\/div>/g),
]
/* A `<span id="lbl…">` or `<code id="lbl…">` inside a note is a placeholder
`loadDnsSettings` overwrites on every load (main.js:1369-1372: the DoH, DoT,
DoQ and DoH(S) addresses). Its initial text is not contract, so it is not
compared. */
.map((m) => m[1].replace(/<(span|code) id="lbl\w+">[\s\S]*?<\/\1>/g, ' '))
.map((t) => t.replace(/<[^>]*>/g, ' ').replace(/&nbsp;/g, ' '))
.map((t) => decode(t).replace(/\s+/g, ' ').trim())
.filter((t, i, all) => t.length > 40 && all.indexOf(t) === i)
/* Texts upstream has and this console deliberately does not, with the reason.
Each entry is a run of words, matched whole. */
const EXCUSED_NOTES = [
// deviation 1 in CONVENTIONS.md: Amber is not offered. No note names it today;
// the entry stays so that one which does is excused for that reason and no other.
'amber theme',
]
/* A run only proves its own note when no other upstream note shares it. v15.6.1
split the LDAP server note into "On Windows, the domain name used for LDAP
Server above ..." and "On Linux, the domain name used for LDAP Server above
...", and the Linux one passed on the run it shares with the Windows one while
it was not here at all. */
const noteWords = NOTES.map((t) => asWords(t))
const distinctive = (run) => noteWords.filter((w) => w.includes(` ${run} `)).length === 1
const missingNotes = NOTES.filter((t) => {
const words = asWords(t).trim().split(' ')
if (words.length < 12) return false
if (EXCUSED_NOTES.some((e) => asWords(t).includes(` ${e} `) && words.length < 40)) return false
const all = [words.slice(2, 10), words.slice(6, 14), words.slice(-8)].map((p) => p.join(' '))
const runs = all.filter(distinctive)
/* And its last eight words, always. A run proves the note is there, not that it
is whole: v15.6.2 added "and require a manual resync" to the end of the SOA
serial warning, and every run of the old text still matched. */
const end = words.slice(-8).join(' ')
return !(runs.length ? runs : all).some((p) => prose.includes(` ${p} `)) || !prose.includes(` ${end} `)
})
console.log('')
for (const t of missingNotes) console.log(` MISSING note: ${t.slice(0, 120)}…`)
console.log(
missingNotes.length === 0
? `NOTE PARITY: all ${NOTES.length} of upstream's notes and explanations are present.`
: `NOTE PARITY: ${missingNotes.length} of ${NOTES.length} missing.`,
)
/*
And the short texts of a control: a radio's option, a modal's title, an entry of
the account menu.
None of the passes above reads them (they are neither links, nor help, nor
examples, nor notes), so a whole dialog could go missing and every one of them
stay green. It is exactly what happened to `Change Theme`: absent by decision
until 2026-10-01, and nothing here could have told whether it came back whole.
They are compared as whole runs of words, against our source without comments.
*/
const accountMenu = html.slice(html.indexOf('<ul class="dropdown-menu">'))
const CONTROL_TEXTS = [
...[...html.matchAll(/<input type="radio"[^>]*>\s*([^<]+?)\s*<\/label>/g)].map((m) => ['radio', m[1]]),
...[...html.matchAll(/<h4 class="modal-title"[^>]*>([^<]+)<\/h4>/g)].map((m) => ['modal title', m[1]]),
...[...accountMenu.slice(0, accountMenu.indexOf('</ul>')).matchAll(/>([^<>]+)<\/a>/g)].map((m) => ['account menu', m[1]]),
]
.map(([kind, t]) => [kind, decode(t).replace(/\s+/g, ' ').trim()])
.filter(([, t], i, all) => t.length > 0 && all.findIndex(([, u]) => u === t) === i)
/* Upstream's control texts this console deliberately does not have, with the reason. */
const EXCUSED_TEXTS = new Map([
['Amber Theme', 'deviation 1 in CONVENTIONS.md: Amber is not offered in Change Theme'],
])
const missingText = CONTROL_TEXTS.filter(([, t]) => !EXCUSED_TEXTS.has(t) && !prose.includes(asWords(t)))
console.log('')
for (const [kind, t] of missingText) console.log(` MISSING ${kind}: ${t}`)
for (const [t, why] of EXCUSED_TEXTS) console.log(` EXCUSED ${t} (${why})`)
console.log(
missingText.length === 0
? `CONTROL TEXT PARITY: all ${CONTROL_TEXTS.length - EXCUSED_TEXTS.size} of upstream's radio options, modal titles and account-menu entries are present; ${EXCUSED_TEXTS.size} excused (EXCUSED_TEXTS).`
: `CONTROL TEXT PARITY: ${missingText.length} of ${CONTROL_TEXTS.length} missing.`,
)
/*
And the other direction, which none of the checks above can see: a field this
console still draws after upstream REMOVED it. v15.5 dropped Auto Prefetch; its
two fields stayed here, still required, and Settings could not be saved at all
against the new server, with every parity check green, because every check
asked "is upstream's text here?" and never "is our text still upstream's?".
Every `label="…"` in the source must exist, word for word, somewhere in
upstream's page.
*/
/* Upstream builds part of its interface from JavaScript (row menus, the Apps
buttons, the DHCP and Logs tab strips), so its scripts are read too. */
const scripts = await Promise.all(
[...html.matchAll(/<script src="(js\/[^"?]+)/g)].map((m) =>
fetch(`${REF}/${m[1]}`).then((r) => (r.ok ? r.text() : '')),
),
)
const theirWords = asWords([html, decode(html), ...scripts].join(' '))
const OUR_LABELS = new Set()
/* Where each label is written, for the Blocking exception below. */
const LABEL_FILES = new Map()
for (const f of files(SOURCE).filter((f) => !/\.test\.|fixture/.test(f))) {
for (const m of withoutComments(readFileSync(f, 'utf8')).matchAll(/(?<![-\w])label="([^"{}]{3,})"/g)) {
OUR_LABELS.add(m[1])
LABEL_FILES.set(m[1], [...new Set([...(LABEL_FILES.get(m[1]) ?? []), f])])
}
}
/* Accessible names this console gives to controls upstream leaves unnamed: a
menu button's `aria-label` and a tab strip's. They are read by a screen
reader, never drawn, so they are not interface text upstream could own. */
const ACCESSIBLE_NAMES = new Set([
'Blocking options',
'Blocking sections',
'Zone actions',
'DNSSEC actions',
'DHCP sections',
'Logs sections',
'Settings sections',
])
/*
The Blocking section is the deliberate exception that adds a screen
(CONVENTIONS.md, "Deliberate deviations"), so it has words upstream does not.
They are named here one by one, each with where it is written and where its file
declares it as ours, and each passes ONLY while every file that writes it is
under `screens/blocking/`. Anywhere else in the console it is still a stray, and
a label that is not in this list is a stray even inside the section.
*/
const BLOCKING = join(SOURCE, 'screens/blocking/')
const BLOCKING_OWN_LABELS = new Map([
['Block List Domains', 'Overview figure, blocking/Overview.tsx:361; declared ours at Overview.tsx:35'],
['Your Rules', 'Overview figure, blocking/Overview.tsx:365; declared ours at Overview.tsx:35'],
['Tree', "the Tree view's Blocked/Allowed selector, blocking/Rules.tsx:329; declared ours at Rules.tsx:37-38"],
['More lists', "Quick Add's group of the console's own catalogue, blocking/BlockLists.tsx:508; declared ours at BlockLists.tsx:48-49"],
['Listed as', 'Check a domain, the list entry that matched, blocking/DomainCheck.tsx:107; declared ours at DomainCheck.tsx:15'],
['Rule', 'Check a domain, the rule that matched, blocking/DomainCheck.tsx:117; declared ours at DomainCheck.tsx:15'],
['Add to group', 'the group a new list goes into, blocking/BlockLists.tsx:666; declared ours at BlockLists.tsx:48'],
])
const onlyInBlocking = (label) => (LABEL_FILES.get(label) ?? []).every((f) => f.startsWith(BLOCKING))
const labels = [...OUR_LABELS]
const upstreamLabels = labels.filter((l) => theirWords.includes(asWords(l)))
const accessible = labels.filter((l) => !upstreamLabels.includes(l) && ACCESSIBLE_NAMES.has(l))
const blockingOwn = labels.filter(
(l) => !upstreamLabels.includes(l) && !accessible.includes(l) && BLOCKING_OWN_LABELS.has(l) && onlyInBlocking(l),
)
const strayLabels = labels.filter((l) => !upstreamLabels.includes(l) && !accessible.includes(l) && !blockingOwn.includes(l))
console.log('')
for (const l of blockingOwn) console.log(` BLOCKING OWN label: ${l} (${BLOCKING_OWN_LABELS.get(l)})`)
for (const l of strayLabels) console.log(` NOT UPSTREAM label: ${l}`)
console.log(
strayLabels.length === 0
? `LABEL PARITY: ${upstreamLabels.length} of our ${labels.length} labels exist upstream; ` +
`${accessible.length} are accessible names upstream leaves unnamed (ACCESSIBLE_NAMES) and ` +
`${blockingOwn.length} are the Blocking section's own (BLOCKING_OWN_LABELS).`
: `LABEL PARITY: ${strayLabels.length} of ${labels.length} labels are not upstream's.`,
)
process.exit(
missing.length + missingHelp.length + missingExample.length + missingNotes.length + missingText.length + strayLabels.length === 0
? 0
: 1,
)
+126
View File
@@ -0,0 +1,126 @@
/*
Which of upstream's sortable columns are still sortable here.
## Why this file exists
`check-parity-controls.mjs` counts three things (destinations, help texts and
examples), and it has been green throughout. A sortable column header is none of
the three, so it walks straight past it, and that blind spot has now cost twice:
· About (2026-09-04): the three links of the update modal were `href="#"` filled
in by JavaScript, so "all 28 destinations present" was true and useless.
· Apps (2026-09-07): upstream's `Store Apps` header is a sort link and this
console had dropped it. Found by reading upstream by hand, which is not a
method.
A green tool is not a contract. This one turns the remaining half of that lesson
into something that fails on its own.
## How it measures
Upstream declares every sortable column as `sortTable('<tbodyId>', <n>)` inside
the `<a>` of its `<th>`, so the census is exact and comes from the source rather
than from a list somebody typed. Ours are `<Th field=…>`, plus the handful of
sort affordances that are not table headers.
The comparison is per TABLE and by count, not by column name: upstream's labels
live in the HTML and ours in TSX, and matching them by string would be a second
inventory to keep in sync, which is the thing that rots.
Every gap must be DECLARED below with its reason. An undeclared gap is a finding;
a declared one that has closed is also a finding, because a reason nobody needs
any more is a reason nobody rereads.
Exit code is the number of findings.
*/
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const HERE = path.dirname(fileURLToPath(import.meta.url))
const ROOT = path.resolve(HERE, '..')
// UPSTREAM_HTML supplies an explicit snapshot when the sibling checkout is absent.
const UPSTREAM = process.env.UPSTREAM_HTML
? path.resolve(process.env.UPSTREAM_HTML)
: path.resolve(ROOT, '../technitium-ui/DnsServerCore/www/index.html')
/**
* Upstream table id -> where it lives here, how many of its columns we made
* sortable, and the reason for any gap.
*
* `ours` counts the sort affordances the surface really offers, whether they are
* `<Th field>` or not: the store's is a button over a list, and it counts.
*/
const MAP = [
{ id: 'tableZonesBody', file: 'src/screens/zones/ZoneList.tsx', ours: 7,
gap: 'The `#` column. Sorting by the row number the sort itself has just handed out leads nowhere, and it sorts as text: 1, 10, 11, 2.' },
{ id: 'tableEditZoneBody', file: 'src/screens/zones/ZoneRecords.tsx', ours: 4,
gap: 'The `#` column, for the same reason. Documented at the head of the file.' },
{ id: 'tableDnssecPropertiesPrivateKeysBody', file: 'src/screens/zones/modals/DnssecProperties.tsx', ours: 6 },
{ id: 'tbodyAdminUsers', file: 'src/screens/admin/Users.tsx', ours: 7 },
{ id: 'tbodyAdminGroups', file: 'src/screens/admin/Groups.tsx', ours: 2 },
{ id: 'tbodyAdminSessions', file: 'src/screens/admin/Sessions.tsx', ours: 5 },
{ id: 'tbodyAdminCluster', file: 'src/screens/admin/Cluster.tsx', ours: 8 },
{ id: 'tbodyAdminPermissions', file: 'src/screens/admin/Permissions.tsx', ours: 1 },
{ id: 'tbodyUserDetailsActiveSessions', file: 'src/screens/admin/UserDetails.tsx', ours: 4 },
{ id: 'tbodyMyProfileActiveSessions', file: 'src/screens/modals/MyProfile.tsx', ours: 4 },
{ id: 'tbodyMyProfileMemberOf', file: 'src/screens/modals/MyProfile.tsx', ours: 1 },
{ id: 'tableDhcpScopesBody', file: 'src/screens/dhcp/Scopes.tsx', ours: 4 },
{ id: 'tableDhcpLeasesBody', file: 'src/screens/dhcp/Leases.tsx', ours: 7 },
{ id: 'tableStoreAppsBody', file: 'src/screens/apps/StoreApps.tsx', ours: 1 },
{ id: 'tableAppsBody', file: 'src/screens/apps/Apps.tsx', ours: 1 },
{ id: 'tbodyEditPermissionsUser', file: 'src/screens/admin/Permissions.tsx', ours: 1 },
{ id: 'tbodyEditPermissionsGroup', file: 'src/screens/admin/Permissions.tsx', ours: 1 },
]
if (!fs.existsSync(UPSTREAM)) {
console.error(`\n SORT PARITY FAILED: upstream not found at ${UPSTREAM}.\n` +
' Check out technitium-ui or set UPSTREAM_HTML to its www/index.html snapshot.\n')
process.exit(1)
}
const html = fs.readFileSync(UPSTREAM, 'utf8')
const upstream = new Map()
for (const m of html.matchAll(/sortTable\('([^']+)',\s*(\d+)\)/g)) {
const [, id, col] = m
if (!upstream.has(id)) upstream.set(id, new Set())
upstream.get(id).add(Number(col))
}
const findings = []
const seen = new Set()
for (const row of MAP) {
seen.add(row.id)
const theirs = upstream.get(row.id)?.size
if (theirs == null) {
findings.push(`${row.id}: declared here but upstream no longer sorts it`)
continue
}
const gap = theirs - row.ours
if (gap > 0 && row.gap == null) {
findings.push(`${row.id} (${row.file}): upstream sorts ${theirs} columns, this console ${row.ours}, ${gap} undeclared`)
}
if (gap <= 0 && row.gap != null) {
findings.push(`${row.id}: the gap is declared and no longer exists: remove the reason`)
}
}
for (const id of upstream.keys()) {
if (!seen.has(id)) findings.push(`${id}: upstream sorts it and this file does not say where it lives here`)
}
const theirTotal = [...upstream.values()].reduce((n, s) => n + s.size, 0)
const ourTotal = MAP.reduce((n, r) => n + r.ours, 0)
if (findings.length === 0) {
console.log(
`\n SORT PARITY: ${ourTotal} of upstream's ${theirTotal} sortable columns, across ${MAP.length} tables.\n` +
` The ${theirTotal - ourTotal} missing are declared, with a reason each.\n`,
)
} else {
console.log('')
for (const f of findings) console.log(` ${f}`)
console.log(`\n ${findings.length} findings\n`)
}
process.exit(Math.min(findings.length, 250))
+65
View File
@@ -0,0 +1,65 @@
// @vitest-environment node
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { spawnSync } from 'node:child_process'
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
const SCRIPT = fileURLToPath(new URL('./check-parity-sort.mjs', import.meta.url))
// Independent stock table census. Zones and records each include their declared
// row-number gap; an added stock column must make the executable gate fail.
const COLUMNS = [
['tableZonesBody', 8], ['tableEditZoneBody', 5],
['tableDnssecPropertiesPrivateKeysBody', 6], ['tbodyAdminUsers', 7],
['tbodyAdminGroups', 2], ['tbodyAdminSessions', 5], ['tbodyAdminCluster', 8],
['tbodyAdminPermissions', 1], ['tbodyUserDetailsActiveSessions', 4],
['tbodyMyProfileActiveSessions', 4], ['tbodyMyProfileMemberOf', 1],
['tableDhcpScopesBody', 4], ['tableDhcpLeasesBody', 7],
['tableStoreAppsBody', 1], ['tableAppsBody', 1],
['tbodyEditPermissionsUser', 1], ['tbodyEditPermissionsGroup', 1],
]
describe('sort parity gate', () => {
let root
let valid
beforeAll(() => {
root = mkdtempSync(join(tmpdir(), 'sort-parity-'))
valid = join(root, 'index.html')
writeFileSync(valid, COLUMNS.flatMap(([id, count]) =>
Array.from({ length: count }, (_, column) => `<a onclick="sortTable('${id}', ${column})">Sort</a>`),
).join('\n'))
})
afterAll(() => rmSync(root, { recursive: true, force: true }))
const run = (snapshot) => spawnSync(process.execPath, [SCRIPT], {
env: { ...process.env, UPSTREAM_HTML: snapshot }, encoding: 'utf8', timeout: 10_000,
})
it('fails instead of reporting success when the requested snapshot is missing', () => {
const outcome = run(join(root, 'missing.html'))
expect(outcome.status).toBe(1)
expect(outcome.stderr).toContain('SORT PARITY FAILED: upstream not found')
expect(outcome.stderr).toContain('UPSTREAM_HTML')
expect(outcome.stdout).not.toContain('SORT PARITY:')
})
it('accepts a valid explicitly supplied stock snapshot', () => {
const outcome = run(valid)
expect(outcome.status).toBe(0)
expect(outcome.stdout).toContain('SORT PARITY: 64 of upstream\'s 66 sortable columns')
})
it('fails for a stock sortable column without a declared counterpart', () => {
const snapshot = join(root, 'extra.html')
writeFileSync(snapshot, COLUMNS.flatMap(([id, count]) =>
Array.from({ length: count + (id === 'tableAppsBody' ? 1 : 0) }, (_, column) =>
`sortTable('${id}', ${column})`),
).join('\n'))
const outcome = run(snapshot)
expect(outcome.status).toBe(1)
expect(outcome.stdout).toContain('tableAppsBody')
expect(outcome.stdout).toContain('1 undeclared')
})
})
+40
View File
@@ -0,0 +1,40 @@
#!/usr/bin/env bash
# Compares the dev console (5380) against the reference one (5381).
# Usage: ./check-parity.sh [path] (default: /)
#
# It compares CONTENT, not bytes: line endings are normalised before hashing.
# This is needed because upstream's .gitattributes uses `* text=auto`, so the
# repository stores LF and the checkout converts according to the platform: our
# tree on Linux has LF, and the official Docker image ships CRLF because it was
# built on Windows. Without normalising, two identical files would come out
# different by 7,426 extra bytes (one per line, in the case of index.html).
set -euo pipefail
PATH_="${1:-/}"
DEV="http://127.0.0.1:5380${PATH_}"
REF="http://127.0.0.1:5381${PATH_}"
for url in "$DEV" "$REF"; do
if ! curl -sfo /dev/null "$url"; then
echo "FAILED: $url does not answer"
exit 1
fi
done
normalised_hash() {
curl -s "$1" | tr -d '\r' | sha256sum | cut -d' ' -f1
}
d=$(normalised_hash "$DEV")
r=$(normalised_hash "$REF")
echo "dev $d"
echo "ref $r"
if [ "$d" = "$r" ]; then
echo "IDENTICAL"
exit 0
else
echo "DIFFERENT"
exit 2
fi
+22
View File
@@ -0,0 +1,22 @@
#!/usr/bin/env bash
# The server honours X-Forwarded-Prefix and mounts a PathBase
# (DnsWebService.cs:1943-1945). This check simulates that proxy and verifies the
# bundle is requested relatively, not absolutely.
set -euo pipefail
PREFIX="/dns"
BASE="http://127.0.0.1:5380"
html=$(curl -s -H "X-Forwarded-Prefix: ${PREFIX}" -H "X-Forwarded-Proto: https" "${BASE}/")
paths=$(echo "$html" | grep -oE '(src|href)="[^"]+"' | sed -E 's/^(src|href)="//; s/"$//')
echo "Emitted paths:"
echo "$paths" | sed 's/^/ /'
if echo "$paths" | grep -q '^/'; then
echo "FAILED: there are absolute paths. Behind a proxy at ${PREFIX} the browser would request them outside the prefix and they would 404."
exit 1
fi
echo "OK: every path is relative; they survive a prefixed proxy."
+96
View File
@@ -0,0 +1,96 @@
/*
Every `<Th field="x">` has to name a key that exists in its `Keys` object.
## What this catches, and how it got through everything else
`useSort` looks the field up and gives up quietly when it is not there:
function toggle(field) {
const read = keys[field]
if (read == null) return // <- the column simply does not sort
...
So a header whose `field` does not match a key is a header you can click for ever.
Nothing else in this project sees it: `field` is a string, so TypeScript has
nothing to check; the column renders, so no test that looks for the header fails;
and no gate reads the pair.
It was found on 2026-09-07 in the Edit Permissions modal, where `field="nombre"`
had been left behind by the English sweep of 2026-08-31 (the sweep renamed the
`Keys` object's key and not the string inside the attribute, because a string in
double quotes is where you put data, not identifiers). The column had been dead
for a week with every gate green.
## How it measures
Per file: the keys declared in any `Keys<...> = { ... }` object or passed inline to
`useSort`, against every `field="..."` in the same file. Both sides are read from
the source, so there is no list to keep in sync.
Files with no `field=` are not looked at. Exit code is the number of findings.
*/
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const SRC = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../src')
function walk(dir, out = []) {
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
const p = path.join(dir, e.name)
if (e.isDirectory()) walk(p, out)
else if (/\.tsx?$/.test(e.name)) out.push(p)
}
return out
}
const findings = []
let checked = 0
let columns = 0
for (const file of walk(SRC)) {
const text = fs.readFileSync(file, 'utf8')
const fields = [...text.matchAll(/<Th[^>]*?\bfield="([^"]+)"/g)].map((m) => m[1])
if (fields.length === 0) continue
checked++
columns += fields.length
/* The keys of every `Keys` object in the file, however it is written: a named
`const KEYS: Keys<Row> = { … }`, or the object handed straight to `useSort`.
Reading them by brace-matching keeps this a linter and not a parser. */
const keys = new Set()
for (const m of text.matchAll(/(?:Keys<[^>]*>\s*=|useSort(?:<[^>]*>)?\s*\()\s*\{/g)) {
let i = m.index + m[0].length - 1
let depth = 0
let end = i
for (; end < text.length; end++) {
if (text[end] === '{') depth++
else if (text[end] === '}') { depth--; if (depth === 0) break }
}
const body = text.slice(i + 1, end)
for (const k of body.matchAll(/(?:^|[,{])\s*(?:'([^']+)'|"([^"]+)"|([A-Za-z_$][\w$]*))\s*:/g)) {
keys.add(k[1] ?? k[2] ?? k[3])
}
}
if (keys.size === 0) {
findings.push(`${path.relative(SRC, file)}: ${fields.length} sortable columns and no Keys object in sight`)
continue
}
for (const f of new Set(fields)) {
if (!keys.has(f)) {
findings.push(
`${path.relative(SRC, file)}: field="${f}" names no key, so that column does not sort. Keys: ${[...keys].join(', ')}`,
)
}
}
}
if (findings.length === 0) {
console.log(`\n SORT FIELDS: the ${columns} sortable columns of ${checked} files all name a key that exists.\n`)
} else {
console.log('')
for (const f of findings) console.log(` ${f}`)
console.log(`\n ${findings.length} findings\n`)
}
process.exit(Math.min(findings.length, 250))
+123
View File
@@ -0,0 +1,123 @@
/**
* The guard against names cut letter by letter.
*
* Three declarations let the browser end a line inside a word wherever the edge
* falls: `word-break: break-all`, `word-break: break-word` and `overflow-wrap:
* anywhere`. The last two also tell a table, a flex row or a grid track sized by
* its content that the word could break after ANY letter, so the column shrinks to
* a few characters and every name in it comes out cut: `0.30.172.in-addr.arpa` in
* four lines in Zones, `raw.githubuser` / `content.com` in Blocking › Lists. On
* 2026-10-15 there were 23 of them in 17 stylesheets, found two or three at a time
* by looking at screenshots. This is what finds the rest.
*
* What to write instead:
* - a DNS name goes through `ui/DomainName`, which breaks only after its dots;
* - a URL through `ui/UrlText`, which breaks after its separators;
* - other text through `overflow-wrap: break-word`, which ends lines between words
* and breaks a word only when it alone does not fit, without shrinking the column;
* - or one line cut with an ellipsis and the whole value in the text and `title`
* (`Clip` in Query Logs).
*
* What genuinely has no words (a public key, a signature, a hex string, raw output)
* goes in `ALLOWED`, by file AND selector, with its reason. An entry that no longer
* matches anything is a finding too, so the list cannot only grow.
*
* It reads every `.css` file under `src/`, with comments stripped. Exit code is the
* number of findings, so it can sit in the gate.
*/
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), '..')
export const ALLOWED = [
{
file: 'src/ui/record.module.css',
selector: '.key',
reason: 'A public key or a hex string is one token with no words; it breaks by characters by design.',
},
{
file: 'src/screens/lists/Lists.module.css',
selector: '.key',
reason: 'The same key as ui/record, composed: a DNSKEY or an RRSIG would otherwise walk out of its cell.',
},
{
file: 'src/screens/lists/Lists.module.css',
selector: '.signatures',
reason: 'Preformatted DNSSEC and glue records: base64 signatures with no words, inside a box of fixed width.',
},
{
file: 'src/screens/zones/Zones.module.css',
selector: '.output',
reason: 'Preformatted text the server returns (a record comment), in a scrolling box of fixed width.',
},
{
file: 'src/screens/zones/Zones.module.css',
selector: '.val',
reason: 'A record value can be one token of hundreds of characters (a DKIM TXT, a hex SSHFP) '
+ 'and has to fit its table cell. Domain-valued records would read better through DomainName; not done yet.',
},
{
file: 'src/ui/kv.module.css',
selector: '.kv dd',
reason: 'Key/value lists hold DNSSEC keys and hex digests inside table cells; without it they push the table out.',
},
{
file: 'src/ui/SectionHeader.module.css',
selector: '.title',
reason: 'The h1 sits in a column of definite width (`min-width: 0`), so it only breaks a word that '
+ 'overflows it and never sizes anything; a zone name longer than the screen is the one case.',
},
]
const RULE = /(?:word-break\s*:\s*(break-all|break-word)|overflow-wrap\s*:\s*(anywhere))/g
export function stripComments(css) {
return css.replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, ' '))
}
/** Each breaking declaration with the selector of the rule that holds it. */
export function findBreaks(css) {
const text = stripComments(css)
const out = []
for (const m of text.matchAll(RULE)) {
const open = text.lastIndexOf('{', m.index)
const close = text.lastIndexOf('}', open)
const selector = text.slice(close + 1, open).trim().replace(/\s+/g, ' ')
out.push({ selector, value: m[1] ?? m[2], line: text.slice(0, m.index).split('\n').length })
}
return out
}
function cssFiles(dir) {
return fs.readdirSync(dir, { withFileTypes: true }).flatMap((e) => {
const p = path.join(dir, e.name)
if (e.isDirectory()) return cssFiles(p)
return e.name.endsWith('.css') ? [p] : []
})
}
export function check() {
const findings = []
const used = new Set()
for (const abs of cssFiles(path.join(ROOT, 'src'))) {
const file = path.relative(ROOT, abs).split(path.sep).join('/')
for (const b of findBreaks(fs.readFileSync(abs, 'utf8'))) {
const allowed = ALLOWED.findIndex((a) => a.file === file && a.selector === b.selector)
if (allowed >= 0) used.add(allowed)
else findings.push(`${file}:${b.line} ${b.selector} { ${b.value} }`)
}
}
ALLOWED.forEach((a, i) => {
if (!used.has(i)) findings.push(`ALLOWED entry matches nothing: ${a.file} ${a.selector}`)
})
return findings
}
if (process.argv[1] === fileURLToPath(import.meta.url)) {
const findings = check()
for (const f of findings) console.log(f)
console.log(findings.length === 0 ? 'Word breaks: none outside the allowed list.' : `${findings.length} finding(s).`)
process.exit(findings.length)
}
+28
View File
@@ -0,0 +1,28 @@
import { describe, expect, it } from 'vitest'
import { ALLOWED, check, findBreaks } from './check-word-breaks.mjs'
/*
The gate behind "a name is never cut letter by letter". It runs inside `npm test`,
as the colour gate does, so the rule is checked wherever the suite is.
*/
describe('word breaks', () => {
it('src/ breaks inside words only where the allowed list says why', () => {
expect(check()).toEqual([])
})
it('every allowed entry says why', () => {
for (const a of ALLOWED) expect(a.reason.length).toBeGreaterThan(40)
})
it('finds the three declarations, with the selector that holds each', () => {
const css = '.a { word-break: break-all; }\ntd .b, .c { color: var(--ink); overflow-wrap: anywhere; }\n.d{word-break:break-word}'
expect(findBreaks(css).map((b) => [b.selector, b.value])).toEqual([
['.a', 'break-all'], ['td .b, .c', 'anywhere'], ['.d', 'break-word'],
])
})
it('lets through the wrap that keeps words whole, and a rule quoted in a comment', () => {
expect(findBreaks('/* was `word-break: break-all` */ .a { overflow-wrap: break-word; word-break: normal; }')).toEqual([])
})
})
+57
View File
@@ -0,0 +1,57 @@
# technitium-console development harness. This is NOT a production compose: for
# running the console in Docker, see README.md, "Docker".
#
# dev -> official image + our www/ mounted on top -> 127.0.0.1:5380
# ref -> official image untouched (upstream console) -> 127.0.0.1:5381
# node2 -> official image + our www/, a second node -> 127.0.0.1:5382
#
# No port 53 (systemd-resolved holds it on WSL) and no DHCP: only the web
# console is exercised here.
services:
dev:
container_name: technitium-ui-dev
image: docker.io/technitium/dns-server:latest
ports:
- "127.0.0.1:5380:5380/tcp"
environment:
- DNS_SERVER_DOMAIN=dev.technitium-ui.test
- DNS_SERVER_ADMIN_PASSWORD=technitium-ui-dev
volumes:
- ../dist:/opt/technitium/dns/www:ro
- dev-config:/etc/dns
restart: unless-stopped
ref:
container_name: technitium-ui-ref
image: docker.io/technitium/dns-server:latest
ports:
- "127.0.0.1:5381:5380/tcp"
environment:
- DNS_SERVER_DOMAIN=ref.technitium-ui.test
- DNS_SERVER_ADMIN_PASSWORD=technitium-ui-dev
volumes:
- ref-config:/etc/dns
restart: unless-stopped
# Only for reviewing the 9 Cluster dialogs, which without a second node cannot
# even be opened. `ref` is NOT used for this: joining it to a cluster syncs its
# config with the primary and destroys its value as the upstream reference.
node2:
container_name: technitium-ui-node2
image: docker.io/technitium/dns-server:latest
ports:
- "127.0.0.1:5382:5380/tcp"
environment:
- DNS_SERVER_DOMAIN=node2.technitium-ui.test
- DNS_SERVER_ADMIN_PASSWORD=technitium-ui-dev
volumes:
# With the new console too: the "Promote To Primary" and "Edit Node"
# dialogs of the primary ONLY exist from a secondary's console.
- ../dist:/opt/technitium/dns/www:ro
- node2-config:/etc/dns
restart: unless-stopped
volumes:
dev-config:
ref-config:
node2-config:
+315
View File
@@ -0,0 +1,315 @@
/*
CSS rules written and never applied.
This is the defect no test sees and no screenshot gives away: a class declared in
a module that no component references. It happened with `.v`, `.p` and `.k` on
the Dashboard tiles, which had gone unapplied since early in the redesign.
It is static on purpose. Sweeping the DOM would produce false positives for
everything living in a state the session never reached (a closed dialog, an empty
table, a hover).
## Why it resolves the import instead of just searching for the name
The first version treated any class as alive if its name appeared in ANY file
under `src/`. Its comment claimed the only possible error was staying silent
about a dead class, never inventing one, and that was true, but it stayed silent
about many: `.fail` was declared in four modules and used in one, and the other
three copies passed because the word existed in Settings. Unifying the panel-form
kit surfaced half a dozen leftovers that way.
Now it resolves which module each `styles` in each file points at, following the
two hops this codebase has: the direct import and the re-export
(`export { default as settingsStyles } from './Settings.module.css'`, used by the
nine Settings sub-tabs). Whatever cannot be resolved is not judged.
node dev/css-dead.mjs
*/
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'
import { join, dirname, resolve } from 'node:path'
const ROOT = join(import.meta.dirname, '..', 'src')
function files(dir, filter, acc = []) {
for (const e of readdirSync(dir)) {
const p = join(dir, e)
if (statSync(p).isDirectory()) files(p, filter, acc)
else if (filter.test(e)) acc.push(p)
}
return acc
}
const modules = files(ROOT, /\.module\.css$/)
const sources = files(ROOT, /\.tsx?$/).filter((f) => !/\.test\./.test(f))
const code = new Map(sources.map((f) => [f, readFileSync(f, 'utf8')]))
/** Resolves a relative import to a real file under `src/`. */
function resolveImport(from, rel) {
const base = resolve(dirname(from), rel)
for (const cand of [base, `${base}.tsx`, `${base}.ts`, join(base, 'index.tsx')]) {
if (existsSync(cand) && statSync(cand).isFile()) return cand
}
return null
}
/*
Step 1: which module each file brings in under its own name, and under what name
it re-exports it. `alias` is what that file writes; `exposes` is the name another
file can ask for it by.
*/
const alias = new Map() // file -> Map(identifier -> module)
const exposes = new Map() // file -> Map(exported name -> module)
for (const [f, src] of code) {
const a = new Map()
const e = new Map()
for (const m of src.matchAll(/import\s+(\w+)\s+from\s+['"]([^'"]*\.module\.css)['"]/g)) {
a.set(m[1], resolve(dirname(f), m[2]))
}
for (const m of src.matchAll(
/export\s*\{\s*default\s+as\s+(\w+)\s*\}\s*from\s*['"]([^'"]*\.module\.css)['"]/g,
)) {
e.set(m[1], resolve(dirname(f), m[2]))
}
for (const m of src.matchAll(/export\s*\{\s*(\w+)\s+as\s+(\w+)\s*\}/g)) {
if (a.has(m[1])) e.set(m[2], a.get(m[1]))
}
alias.set(f, a)
exposes.set(f, e)
}
/* Step 2: the indirect hop, as in `import { settingsStyles as styles } from '../parts'`. */
for (const [f, src] of code) {
for (const m of src.matchAll(/import\s*\{([^}]*)\}\s*from\s*['"](\.[^'"]*)['"]/g)) {
const target = resolveImport(f, m[2])
if (!target || !exposes.has(target)) continue
for (const piece of m[1].split(',')) {
const [, name, local] = /^\s*(\w+)(?:\s+as\s+(\w+))?\s*$/.exec(piece.trim()) ?? []
if (name && exposes.get(target).has(name)) {
alias.get(f).set(local ?? name, exposes.get(target).get(name))
}
}
}
}
/*
A module consumed with brackets (`styles[variant]`) can use any class, so it is
not judged. You have to look at the identifier EACH file imports its module
under: the Dashboard calls a data object `s` and does `s[m.k]`, which has nothing
to do with its styles.
*/
const dynamic = new Set()
for (const [f, src] of code) {
for (const [id, mod] of alias.get(f)) {
if (new RegExp(`\\b${id}\\[`).test(src)) dynamic.add(mod)
}
}
/* Step 3: which classes each module names, now properly attributed. */
const named = new Map(modules.map((m) => [m, new Set()]))
const consumers = new Map(modules.map((m) => [m, new Set()]))
for (const [f, src] of code) {
for (const [id, mod] of alias.get(f)) {
if (!named.has(mod)) continue
consumers.get(mod).add(f)
for (const m of src.matchAll(new RegExp(`\\b${id}\\.(\\w+)`, 'g'))) {
named.get(mod).add(m[1])
}
}
}
/*
Four places in a CSS file look like they declare a class and declare none: the
comments (`.zt` lived in one), the contents of `url()` (where `www.w3.org` reads
as `.w3`), the `:global()` blocks, and the path of a `composes: … from '…'`,
which ends in `.module.css` and would read as two classes, `.module` and `.css`.
They are stripped before looking.
*/
const strip = (css) =>
css
.replaceAll(/\/\*[\s\S]*?\*\//g, '')
.replaceAll(/url\((?:[^()]|\([^()]*\))*\)/g, '')
.replaceAll(/:global\([^)]*\)/g, '')
.replaceAll(/(['"])(?:(?!\1).)*\1/g, "''")
/* And a class is also alive if another module inherits it with `composes: x from '…'`. */
const composed = new Map(modules.map((m) => [m, new Set()]))
for (const mod of modules) {
// No `strip` here: that removes strings, and the `from` path is one.
const css = readFileSync(mod, 'utf8').replaceAll(/\/\*[\s\S]*?\*\//g, '')
for (const m of css.matchAll(/composes:\s*([\w\s-]+?)\s+from\s*['"]([^'"]+)['"]/g)) {
const target = resolve(dirname(mod), m[2])
if (!composed.has(target)) continue
for (const c of m[1].trim().split(/\s+/)) composed.get(target).add(c)
}
}
let dead = 0
let total = 0
const unjudged = new Set()
for (const mod of modules) {
const css = readFileSync(mod, 'utf8')
const declared = new Set()
for (const m of strip(css).matchAll(/\.(-?[A-Za-z_][\w-]*)/g)) declared.add(m[1])
total += declared.size
if (dynamic.has(mod)) {
unjudged.add(`${mod.slice(ROOT.length + 1)} (bracket access: not judged)`)
continue
}
if (consumers.get(mod).size === 0 && composed.get(mod).size === 0) {
unjudged.add(`${mod.slice(ROOT.length + 1)} (no resolved consumer: not judged)`)
continue
}
const alive = new Set([...named.get(mod), ...composed.get(mod)])
const unused = [...declared].filter((c) => !alive.has(c))
if (unused.length) {
dead += unused.length
console.log(`\n${mod.slice(ROOT.length + 1)}: ${consumers.get(mod).size} consumer(s)`)
for (const c of unused) {
const line = css.split('\n').findIndex((l) => l.includes(`.${c}`)) + 1
console.log(` .${c} (line ${line})`)
}
}
}
/*
And the other direction, which is the one that bites: `styles.foo` where the
module declares no `.foo`.
A CSS module is a plain object at runtime, so a name that is not there is
`undefined`, React drops the attribute and the element renders with no class at
all. Nothing fails: TypeScript sees `Record<string, string>`, the element is in
the DOM so every query for it passes, and the only symptom is a box that lost its
box.
It is the same shape of defect as `<Th field="nombre">` against `{ name: … }`,
found on 2026-09-07 after a week alive: an identifier that travels as a STRING
between two files, with nothing checking the pair.
Modules read with brackets are skipped for the same reason as above: `styles[k]`
can name anything.
*/
const missing = []
for (const [f, src] of code) {
for (const [id, mod] of alias.get(f)) {
if (!named.has(mod) || dynamic.has(mod)) continue
const declared = new Set()
for (const m of strip(readFileSync(mod, 'utf8')).matchAll(/\.(-?[A-Za-z_][\w-]*)/g)) declared.add(m[1])
/* Without stripping the strings, the import path itself matches: `text` in
`from './text.module.css'` reads as `text.module`. */
const prose = src.replaceAll(/(['"`])(?:(?!\1).)*\1/g, "''")
for (const m of prose.matchAll(new RegExp(`\\b${id}\\.(\\w+)`, 'g'))) {
if (!declared.has(m[1])) {
const line = prose.slice(0, m.index).split('\n').length
missing.push(`${f.slice(ROOT.length + 1)}:${line} ${id}.${m[1]}: ${mod.slice(ROOT.length + 1)} declares no .${m[1]}`)
}
}
}
}
/*
And the modules read with BRACKETS, which until now were simply "not judged".
`styles[tone]` can name anything, so the reverse walk above cannot judge them,
but the set of things it can name is not unknown: it is the string-literal union
of the variable's own type, declared a few lines up in the same file. So each
literal of that union is checked against the module.
They are the five most-used primitives in the console (`Alert`, `Button`,
`Dialog`, `Tag` and the three list screens), and a tone with no class is a warning
that silently loses its colour, on every screen at once. "Not judged" was the
wrong answer for exactly the files that could do the most damage.
A literal the code EXCLUDES from the lookup is not required: `Button` writes
`variant !== 'secondary' && styles[variant]` and `Tag` writes
`tone === 'neutral' ? '' : styles[tone]`, and neither declares a class for the one
it steps around. Those are found by looking for a comparison of the same variable
against the literal.
*/
const bracketFindings = []
for (const [f, src] of code) {
for (const [id, mod] of alias.get(f)) {
if (!dynamic.has(mod)) continue
const declared = new Set()
for (const m of strip(readFileSync(mod, 'utf8')).matchAll(/\.(-?[A-Za-z_][\w-]*)/g)) declared.add(m[1])
for (const use of src.matchAll(new RegExp(`\\b${id}\\[(\\w+)\\]`, 'g'))) {
const variable = use[1]
/* The union as the file declares it: `size?: 'compact' | 'form' | …` or
`type ButtonVariant = 'primary' | …` assigned to that variable. */
const decl =
new RegExp(`\\b${variable}\\??\\s*:\\s*((?:'[^']+'\\s*\\|\\s*)+'[^']+')`).exec(src) ??
new RegExp(`\\b${variable}\\??\\s*:\\s*(\\w+)`).exec(src)
if (!decl) continue
let union = decl[1]
if (!union.includes("'")) {
const named = new RegExp(`type\\s+${union}\\s*=\\s*((?:'[^']+'\\s*\\|\\s*)+'[^']+')`).exec(src)
if (!named) continue
union = named[1]
}
const literals = [...union.matchAll(/'([^']+)'/g)].map((m) => m[1])
for (const lit of literals) {
if (declared.has(lit)) continue
const stepped = new RegExp(`${variable}\\s*[!=]==\\s*'${lit}'`).test(src)
if (stepped) continue
bracketFindings.push(
`${f.slice(ROOT.length + 1)} ${id}[${variable}] can be '${lit}' and ${mod.slice(ROOT.length + 1)} declares no .${lit}`,
)
}
}
}
}
if (bracketFindings.length) {
console.log('\nRead with brackets, and one of its values has no class:')
for (const m of bracketFindings) console.log(` ${m}`)
}
if (missing.length) {
console.log('\nNamed but never declared; these elements render with no class:')
for (const m of missing) console.log(` ${m}`)
}
console.log(`\n${dead} classes declared and never named, out of ${total} in ${modules.length} modules`)
if (unjudged.size) {
console.log('\nNot judged for dead classes (bracket access); their values ARE checked above:')
for (const d of unjudged) console.log(` ${d}`)
}
/*
Every `composes: X from './y.module.css'` has to resolve, and until 2026-09-07
nothing checked it. A target that does not exist is ignored in silence: the build
passes, the tests pass (jsdom stubs CSS modules), and the class simply arrives
without the declarations it was composing.
It happened that day. Renaming `rotulo.module.css` sent four `composes` to
`text.module.css`, which has no `smallCaps`, and the Matrix headers lost their
small caps. Four gates were green over it; the only thing that saw it was the
uniformity sweep, and only when the signature was compared string by string
instead of by count.
*/
const broken = []
for (const file of modules) {
const css = readFileSync(file, 'utf8')
for (const m of css.matchAll(/composes:\s*([^;]+?)\s+from\s+['"]([^'"]+)['"]/g)) {
const target = resolve(dirname(file), m[2])
const rel = file.slice(ROOT.length + 1)
if (!existsSync(target)) { broken.push(`${rel}: composes from ${m[2]}, which does not exist`); continue }
const source = readFileSync(target, 'utf8')
for (const name of m[1].trim().split(/\s+/)) {
if (!new RegExp(`\\.${name}\\b`).test(source)) {
broken.push(`${rel}: composes \`${name}\` from ${m[2]}, which does not declare it`)
}
}
}
}
console.log(
broken.length === 0
? `every composes resolves, out of ${modules.length} modules`
: '\nComposes that do not resolve:',
)
for (const b of broken) console.log(' ' + b)
process.exitCode = broken.length + missing.length + bracketFindings.length
+90
View File
@@ -0,0 +1,90 @@
/*
The same thing, written in two modules.
`css-dead.mjs` answers whether a rule is unused. This one answers something
different: whether the SAME rule is written more than once. That is the defect
that produces drift (two copies that start identical and stop being identical),
and no screen-by-screen review catches it, because on each screen, taken alone,
everything looks right.
Measured before fixing it, it found 27 groups, and out of those came, among
others:
· eleven `<textarea>` elements painting their own box by hand, with radius 6
instead of 8 and without the inset shadow every other field carries;
· the inline `<code>` in five modules and the intro paragraph in five, one of
them at 1.65 line-height against 1.6 in the other four;
· the `<details>` chevron hand-encoded as an SVG in a `data:` URI, in two
modules, with a different stroke width from every other icon;
· the count under a table sitting at four different distances.
node dev/css-duplicated.mjs
## How to read it
It groups by rule BODY, ignoring `composes`, and only shows bodies that appear in
more than one module. That leaves out what is already shared (a class that
composes from another repeats nothing) and leaves in three things that are NOT
defects, so look before you touch:
· **Applying the same token.** Two components with `background: var(--acc)`
duplicate nothing: the token IS the single source, and `composes` cannot even
reach a pseudo-class or an attribute selector, which is where nearly all of
these come from. Real example: the highlight on a menu option and the one on
a dropdown option.
· **Generic layout coincidences.** `display:flex; flex-direction:column;
gap:var(--s-9)` shows up in a column on About, another on the Dashboard and
the Login form. They are not the same thing; there are just few ways to stack
three elements.
· **The same responsive collapse.** `grid-template-columns: minmax(0,1fr)`
inside a media query means "one column when narrow", and every grid in the
console says it.
The minimum body length is there so the output is not drowned in single-property
rules, which are almost always one of the three cases above.
*/
import { readFileSync, readdirSync, statSync } from 'node:fs'
import { join } from 'node:path'
const ROOT = join(import.meta.dirname, '..', 'src')
const MINIMUM = Number(process.env.MINIMO ?? 28)
function files(dir, acc = []) {
for (const e of readdirSync(dir)) {
const p = join(dir, e)
if (statSync(p).isDirectory()) files(p, acc)
else if (e.endsWith('.module.css')) acc.push(p)
}
return acc
}
const groups = new Map()
for (const mod of files(ROOT)) {
const css = readFileSync(mod, 'utf8').replaceAll(/\/\*[\s\S]*?\*\//g, '')
for (const m of css.matchAll(/([^{}]+)\{([^{}]+)\}/g)) {
const selector = m[1].trim().split('\n').at(-1).trim()
/* Normalised: no spaces and sorted, so two identical rules written with
different formatting, or with the properties in another order, match. */
const body = m[2]
.split(';')
.map((d) => d.trim().replaceAll(' ', ''))
.filter((d) => d && !d.startsWith('composes'))
.sort()
.join(' ')
if (body.length < MINIMUM) continue
if (!groups.has(body)) groups.set(body, [])
groups.get(body).push({ mod: mod.slice(ROOT.length + 1), selector })
}
}
const repeated = [...groups]
.filter(([, places]) => new Set(places.map((s) => s.mod)).size > 1)
.sort((a, b) => b[1].length - a[1].length)
for (const [body, places] of repeated) {
console.log(`\n[${places.length}] ${body}`)
for (const s of places) console.log(` ${s.mod} ${s.selector}`)
}
console.log(`\n${repeated.length} rule bodies written in more than one module`)
+24
View File
@@ -0,0 +1,24 @@
#!/usr/bin/env bash
# Regenerates the dialog inventory of the new console. The figures in the review
# plan come from here, not from anyone's memory.
#
# dev/dialog-inventory.sh table per file
# dev/dialog-inventory.sh titulos plus the title of each dialog
set -euo pipefail
cd "$(dirname "$0")/../src"
tot_d=0; tot_c=0
printf '%-3s %-3s %s\n' D C FILE
while read -r f; do
d=$(grep -c '<Dialog' "$f" || true)
c=$(grep -c '<Confirm' "$f" || true)
tot_d=$((tot_d + d)); tot_c=$((tot_c + c))
printf '%-3s %-3s %s\n' "$d" "$c" "${f#./}"
if [ "${1:-}" = titulos ]; then
grep -o 'title={\?[^}]*}\?' "$f" | grep -v '<Alert' | sed 's/^/ /' || true
fi
done < <(grep -rl '<Dialog\|<Confirm' --include='*.tsx' --exclude='*test*' . | sort)
echo
echo "Dialogs (<Dialog>): $tot_d in $(grep -rl '<Dialog' --include='*.tsx' --exclude='*test*' . | wc -l) files"
echo "Confirmations (<Confirm>): $tot_c in $(grep -rl '<Confirm' --include='*.tsx' --exclude='*test*' . | wc -l) files"
File diff suppressed because it is too large. Load diff
+153
View File
@@ -0,0 +1,153 @@
"""Offline installer regressions; run as root in a bounded, network-free sandbox.
Only dummy paths beneath the selected scratch root are used. No host service,
Docker socket, credentials or production endpoint is used by this harness.
"""
import os
from pathlib import Path
import subprocess
import sys
import tempfile
SOURCE = Path(sys.argv[1] if len(sys.argv) > 1 else "install.sh").read_text()
BASE = Path(tempfile.mkdtemp(prefix="installer-security-", dir=os.environ["TMPDIR"]))
BASE.chmod(0o755)
# Source the native functions, omitting discovery and the real install entrypoint.
functions = SOURCE[SOURCE.index("resolve_path()") : SOURCE.index("# What a console looks like")]
functions = functions[:functions.index("# --into-volume")] + functions[functions.index("refuse_links()") :]
publishing = SOURCE[SOURCE.index("CUSTOM_GLOB=") : SOURCE.index("verify_checksum()")]
preamble = "set -eu\nBACKUP_SUFFIX=.original\nINTO_VOLUME=\nMARKER=.technitium-console\nDOCKER_DOCS=fixture\ndie() { echo \"$*\" >&2; exit 1; }\n"
LIBRARY = BASE / "functions.sh"
LIBRARY.write_text(preamble + functions + publishing)
def shell(body, ok=True):
result = subprocess.run(["/bin/sh", "-c", '. "' + str(LIBRARY) + '"\n' + body],
text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
timeout=10, env={"PATH": "/usr/bin:/bin", "TMPDIR": str(BASE)})
if (result.returncode == 0) != ok:
raise AssertionError(result.stdout)
return result.stdout
def tree(name):
path = BASE / name
path.mkdir(mode=0o755)
return path
# Valid publication retains custom lists and removes only the old snapshot.
src, dst, work = tree("source"), tree("console"), tree("work")
(src / "assets").mkdir()
(src / "assets/new.js").write_text("new")
(src / "index.html").write_text("new page")
(dst / "json").mkdir()
(dst / "json/keep-custom.json").write_text("custom")
(dst / "old.js").write_text("old")
shell(f'snapshot "{dst}" "{work}"; publish "{src}" "{dst}" "{work}"')
assert (dst / "index.html").read_text() == "new page"
assert (dst / "json/keep-custom.json").read_text() == "custom"
assert not (dst / "old.js").exists()
print("PASS ordinary publication and custom-list preservation")
# A literal newline pathname must not be reinterpreted as ../victim.
victim = BASE / "victim"
victim.write_text("untouched")
malformed = dst / "prefix\n.."
malformed.mkdir()
(malformed / "victim").write_text("dummy")
shell(f'snapshot "{dst}" "{work}"', ok=False)
assert victim.read_text() == "untouched"
print("PASS newline sweep escape rejected before snapshot")
# A service-writable top level or ancestor is rejected before root publication.
unsafe = tree("service-owned")
os.chown(unsafe, 65534, 65534)
shell(f'secure_tree "{unsafe}"', ok=False)
child = unsafe / "child"
child.mkdir()
shell(f'secure_tree "{child}"', ok=False)
print("PASS service-owned tree and ancestor rejected")
# A namespace its group can write is unsafe when that group is not root's.
writable = tree("group-writable")
writable.chmod(0o775)
os.chown(writable, 0, 65534)
shell(f'secure_tree "{writable}"', ok=False)
print("PASS namespace writable by a non-root group rejected")
# Technitium's own tarball ships every file 0664, and tar run as root keeps
# that mode. Root's group writing root's files gives no other account a way in.
stock_tree = tree("stock-tarball")
stock_tree.chmod(0o775)
(stock_tree / "favicon.ico").write_text("icon")
(stock_tree / "favicon.ico").chmod(0o664)
shell(f'secure_tree "{stock_tree}"')
other_writable = tree("other-writable")
(other_writable / "favicon.ico").write_text("icon")
(other_writable / "favicon.ico").chmod(0o666)
shell(f'secure_tree "{other_writable}"', ok=False)
print("PASS root group may write, other accounts may not")
# On an object with a POSIX access control list the group bits are the list's
# mask, so root:root 0775 can be a named account that setfacl let write.
def access_acl(named_uid, named_perm):
import struct
every = 0xFFFFFFFF # ACL_UNDEFINED_ID: the owner, group and other entries
entries = [(0x01, 7, every), (0x02, named_perm, named_uid), (0x04, 5, every),
(0x10, named_perm | 5, every), (0x20, 5, every)]
return struct.pack("<I", 2) + b"".join(struct.pack("<HHI", *e) for e in entries)
acl_root = tree("acl-writable")
os.setxattr(acl_root, "system.posix_acl_access", access_acl(65534, 7))
shell(f'secure_tree "{acl_root}"', ok=False)
acl_file = tree("acl-writable-file")
acl_file.chmod(0o775)
(acl_file / "favicon.ico").write_text("icon")
(acl_file / "favicon.ico").chmod(0o664)
os.setxattr(acl_file / "favicon.ico", "system.posix_acl_access", access_acl(65534, 6))
shell(f'secure_tree "{acl_file}"', ok=False)
acl_parent = tree("acl-writable-parent")
os.setxattr(acl_parent, "system.posix_acl_access", access_acl(65534, 7))
(acl_parent / "console").mkdir()
shell(f'secure_tree "{acl_parent / "console"}"', ok=False)
acl_read = tree("acl-read-only")
os.setxattr(acl_read, "system.posix_acl_access", access_acl(65534, 5))
shell(f'secure_tree "{acl_read}"')
print("PASS access control list that lets another account write rejected")
# Root-only directories cannot make service-controlled state contents trusted.
state = tree("state")
state_file = state / "install.state"
state_file.write_text("webroot=dummy\n")
os.chown(state_file, 65534, 65534)
shell(f'secure_tree "{state}"', ok=False)
os.chown(state_file, 0, 65534)
state_file.chmod(0o664)
shell(f'secure_tree "{state}"', ok=False)
print("PASS service-owned and group-writable state files rejected")
# A source link must never turn root-private bytes into a public custom list.
private, stock, output = tree("private"), tree("stock"), tree("output")
private.chmod(0o700)
secret = private / "dummy"
secret.write_text("private dummy")
secret.chmod(0o644)
(stock / "json").mkdir()
(stock / "json/leak-custom.json").symlink_to(secret)
shell(f'carry_custom_lists "{stock}" "{output}"', ok=False)
assert not (output / "json/leak-custom.json").exists()
print("PASS custom-list source symlink rejected")
# Hard links and devices are not accepted as ordinary console files.
hard = tree("hard-linked")
os.link(victim, hard / "file")
shell(f'secure_tree "{hard}"', ok=False)
print("PASS hard-linked file rejected")
# Direct calls still cannot use reconstructed traversal components.
shell(f'copy_one "{src}" "{output}" ../victim', ok=False)
assert victim.read_text() == "untouched"
print("PASS copy destination traversal rejected")
+87
View File
@@ -0,0 +1,87 @@
/*
The two guards of the master-switch signal that are checked against the SOURCE
CODE, not against a render.
They live here and not next to `master.test.tsx` for a mechanical reason: they
read files, and everything under `src/` compiles with `types: ["vite/client"]`
(without node's types), so a `node:fs` in there passes the suite and **breaks the
build**. That is exactly what happened: 1039 tests green and `tsc -b` red with four
errors. It is the same hole as the time `grep -cE "^error"` said "build 0" while
the build was failing, and that is why the gate is read by its exit code.
What they check is not a render matter either: jsdom does not apply CSS modules and
there is no screen on which to see either of the two things.
*/
import { describe, expect, it } from 'vitest'
import { readFileSync, readdirSync } from 'node:fs'
import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
const here = dirname(fileURLToPath(import.meta.url))
const src = resolve(here, '../src')
/*
The rule mark must not take grid space.
With `border-left: 2px solid var(--acc)` the five dependent rows of
`/settings/general/` measured `210px 540px 360px` against the `210px 542px 360px`
of the other 34: the border eats two pixels of the box and **shifts the control**.
In a dense form that is a column that stops being aligned, and `dev/uniformity.js`
caught it as a third `grid-panel` signature that existed only on that route. A
state signal must not move the content it marks.
*/
describe('the master rule mark does not shift the grid', () => {
const css = readFileSync(resolve(src, 'ui/Form.module.css'), 'utf8')
const rule = /\.dependent\s*\{([^}]*)\}/.exec(css)?.[1] ?? ''
it('the rule exists and paints the amber mark', () => {
expect(rule).toMatch(/var\(--acc(-ink)?\)/)
})
it('and paints it with an inset shadow, not with a border that takes box space', () => {
expect(rule).toMatch(/box-shadow:\s*inset/)
expect(rule).not.toMatch(/border/)
expect(rule).not.toMatch(/padding/)
})
})
/*
The amber pill and the `warn` pill must never meet on one screen.
Measured on 2026-09-03 with the function in `dev/palette-distance.mjs`: against
`warn`, the `acc` tone gives ΔE00 **8.8** on the text, **8.9** on the border and
**0.0** on the background. That same tool's collision threshold is 10, so by the
project's own criterion **they are the same colour**. And it has to be: the pill
goes with the amber mark of the row and the two are ONE signal; giving it another
amber would split it.
That this costs nothing depends on a fact, not on luck: today the two families do
not meet (the seven `warn` pills live in cluster, sessions, apps and zones; the
`acc` one only in the `Settings` forms). If a screen ever painted both, the user
would see two identical pills meaning "you can" and "careful".
*/
describe('the amber of "you can" never meets the amber of "careful"', () => {
const files = (pattern) => {
const found = []
const walk = (dir) => {
for (const e of readdirSync(dir, { withFileTypes: true })) {
const p = resolve(dir, e.name)
if (e.isDirectory()) walk(p)
else if (e.name.endsWith('.tsx') && !e.name.includes('.test.')) {
if (pattern.test(readFileSync(p, 'utf8'))) found.push(p.slice(src.length + 1))
}
}
}
walk(src)
return found.sort()
}
it('no file paints both', () => {
const withAcc = files(/dependsOn=|tone="acc"/)
const withWarn = files(/tone="warn"/)
expect(withAcc.length).toBeGreaterThan(0)
expect(withWarn.length).toBeGreaterThan(0)
expect(withAcc.filter((f) => withWarn.includes(f))).toEqual([])
})
})
+351
View File
@@ -0,0 +1,351 @@
#!/usr/bin/env node
/*
How far apart the chart colours actually are.
A series palette is not a matter of taste at the point where two series land in
the same doughnut: either the eye separates them or it does not. This measures
it, so the decision between the design drawing's palette and the code's is made
on numbers and not on which one was written last.
What it measures, for each palette:
· CIEDE2000 between every pair of series that SHARE A CHART. Pairs that never
appear together do not compete and are not counted.
· The same, through protanopia and deuteranopia (Machado 2009, severity 1.0).
A DNS console is read by whoever administers the server, and a palette that
only works for trichromats is a palette that works for most people.
· Every series against the interface's own text and line tokens. A series the
exact colour of `--mute` is a series that reads as a label.
· WCAG 1.4.11 contrast against the panel it is drawn on: a 2 px line and a 9 px
legend chip are non-text graphical objects and want 3:1.
Thresholds, stated so they can be argued with:
ΔE00 < 10 collision: the two read as the same colour at line and chip size
ΔE00 < 15 at risk: separable side by side, not separable across a chart
Every theme is measured, not only the dark one: the code's palette is read from
each theme block of `tokens.css` and reported against that theme's own panel and
text tokens. Light is held to two more rules, because dark is the palette these
thresholds were argued on and light has to answer to it pair by pair:
· no series under ΔE00 10 from a text or line token of its theme;
· no pair that shares a chart separates LESS in light than in dark, under any
of the three visions, unless it is still at 15 or more. A pair that was at
risk in dark may stay at risk in light, never sink further.
Exit code: 1 when a theme of the code fails its rules (every theme: each series
at 3:1 on its panel; light: also the two above), 0 otherwise. The drawn palette
and the proposal are history, printed for comparison and never gated.
Run: node dev/palette-distance.mjs
*/
import { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { dirname, join } from 'node:path'
import { themeTokens } from './check-colour-tokens.mjs'
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
// ── The two palettes under comparison ────────────────────────────────────────
/*
The code's, read from the file so this cannot go stale: one palette per theme
block, with the six-digit hex tokens of that block and nothing else.
*/
export function codePalettes(css = readFileSync(join(ROOT, 'src/theme/tokens.css'), 'utf8')) {
const out = {}
for (const [theme, decls] of Object.entries(themeTokens(css))) {
out[theme] = {}
for (const [name, value] of decls)
if (/^#[0-9a-f]{6}$/i.test(value)) out[theme][name.slice(2)] = value.toLowerCase()
}
return out
}
/*
The palette of the Dashboard design drawing, transcribed by hand on 2026-09-02.
Transcribed and not read: the drawing is not a file in this repository.
*/
const DRAWN = {
'ch-total': '#60a5fa',
'ch-ok': '#34d399',
'ch-fail': '#f87171',
'ch-nx': '#a3e635',
'ch-refuse': '#22d3ee',
'ch-auth': '#38bdf8',
'ch-rec': '#fb923c',
'ch-cache': '#f472b6',
'ch-block': '#c084fc',
'ch-drop': '#868e96',
'ch-clients': '#f5a524',
/* The open cycle, taken from the two doughnuts the drawing paints with it. */
'ch-1': '#60a5fa',
'ch-2': '#38bdf8',
'ch-3': '#2dd4bf',
'ch-4': '#f472b6',
'ch-5': '#9aa1a8',
}
/*
The proposal this measurement argues for: the drawing's hues, which measure better
where it counts, with the two exact identities corrected back to the code's.
*/
const PROPOSED = {
...DRAWN,
/* `#868e96` IS `--faint`, to the digit: a series cannot be the text colour. */
'ch-drop': '#94a3b8',
/* Eight and not five: the cycle feeds an unbounded set (StatsManager.cs:2544
truncates nothing), so it runs out sooner than the doughnut does. */
'ch-1': '#38bdf8', 'ch-2': '#34d399', 'ch-3': '#a78bfa', 'ch-4': '#fb923c',
'ch-5': '#2dd4bf', 'ch-6': '#f472b6', 'ch-7': '#f87171', 'ch-8': '#facc15',
}
/*
Which series share a chart. Read from the server, not guessed:
WebServiceDashboardApi.cs:465-535 for the line, StatsManager.cs:2467 for the
response doughnut. The two remaining doughnuts are open sets and get the cycle.
*/
export const CHARTS = {
'main line chart': ['ch-total', 'ch-ok', 'ch-fail', 'ch-nx', 'ch-refuse', 'ch-auth', 'ch-rec', 'ch-cache', 'ch-block', 'ch-drop', 'ch-clients'],
'Query Response Types': ['ch-auth', 'ch-rec', 'ch-cache', 'ch-block', 'ch-drop'],
'Blocking stacked bars': ['ch-ok', 'ch-block'],
'open cycle (Query / Protocol Types)': ['ch-1', 'ch-2', 'ch-3', 'ch-4', 'ch-5', 'ch-6', 'ch-7', 'ch-8'],
}
/* The interface tokens a series must not be mistaken for. */
export const UI = ['ink', 'mute', 'faint', 'line']
// ── Colour ───────────────────────────────────────────────────────────────────
const srgb = (hex) => [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16) / 255)
const toLinear = (c) => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4)
const fromLinear = (c) => (c <= 0.0031308 ? c * 12.92 : 1.055 * c ** (1 / 2.4) - 0.055)
function lab(hex) {
const [r, g, b] = srgb(hex).map(toLinear)
const X = (0.4124564 * r + 0.3575761 * g + 0.1804375 * b) / 0.95047
const Y = 0.2126729 * r + 0.7151522 * g + 0.072175 * b
const Z = (0.0193339 * r + 0.119192 * g + 0.9503041 * b) / 1.08883
const f = (t) => (t > 216 / 24389 ? Math.cbrt(t) : (24389 / 27) * t / 116 + 16 / 116)
const [fx, fy, fz] = [f(X), f(Y), f(Z)]
return [116 * fy - 16, 500 * (fx - fy), 200 * (fy - fz)]
}
export const luminance = (hex) => {
const [r, g, b] = srgb(hex).map(toLinear)
return 0.2126 * r + 0.7152 * g + 0.0722 * b
}
export const contrast = (a, b) => {
const [x, y] = [luminance(a), luminance(b)].sort((m, n) => n - m)
return (x + 0.05) / (y + 0.05)
}
/* CIEDE2000. Sharma, Wu & Dalal's formulation. */
export function deltaE00(hexA, hexB) {
const [L1, a1, b1] = lab(hexA)
const [L2, a2, b2] = lab(hexB)
const rad = Math.PI / 180
const C1 = Math.hypot(a1, b1)
const C2 = Math.hypot(a2, b2)
const Cb = (C1 + C2) / 2
const G = 0.5 * (1 - Math.sqrt(Cb ** 7 / (Cb ** 7 + 25 ** 7)))
const ap1 = (1 + G) * a1
const ap2 = (1 + G) * a2
const Cp1 = Math.hypot(ap1, b1)
const Cp2 = Math.hypot(ap2, b2)
const hp = (b, ap) => {
if (b === 0 && ap === 0) return 0
const h = Math.atan2(b, ap) / rad
return h < 0 ? h + 360 : h
}
const hp1 = hp(b1, ap1)
const hp2 = hp(b2, ap2)
const dL = L2 - L1
const dC = Cp2 - Cp1
let dh = 0
if (Cp1 * Cp2 !== 0) {
dh = hp2 - hp1
if (dh > 180) dh -= 360
else if (dh < -180) dh += 360
}
const dH = 2 * Math.sqrt(Cp1 * Cp2) * Math.sin((dh * rad) / 2)
const Lb = (L1 + L2) / 2
const Cpb = (Cp1 + Cp2) / 2
let hpb
if (Cp1 * Cp2 === 0) hpb = hp1 + hp2
else if (Math.abs(hp1 - hp2) <= 180) hpb = (hp1 + hp2) / 2
else hpb = hp1 + hp2 < 360 ? (hp1 + hp2 + 360) / 2 : (hp1 + hp2 - 360) / 2
const T =
1 -
0.17 * Math.cos((hpb - 30) * rad) +
0.24 * Math.cos(2 * hpb * rad) +
0.32 * Math.cos((3 * hpb + 6) * rad) -
0.2 * Math.cos((4 * hpb - 63) * rad)
const dTheta = 30 * Math.exp(-(((hpb - 275) / 25) ** 2))
const Rc = 2 * Math.sqrt(Cpb ** 7 / (Cpb ** 7 + 25 ** 7))
const Sl = 1 + (0.015 * (Lb - 50) ** 2) / Math.sqrt(20 + (Lb - 50) ** 2)
const Sc = 1 + 0.045 * Cpb
const Sh = 1 + 0.015 * Cpb * T
const Rt = -Math.sin(2 * dTheta * rad) * Rc
return Math.sqrt((dL / Sl) ** 2 + (dC / Sc) ** 2 + (dH / Sh) ** 2 + Rt * (dC / Sc) * (dH / Sh))
}
/* Machado, Oliveira & Fernandes 2009, severity 1.0, on linear RGB. */
const CVD = {
protanopia: [
[0.152286, 1.052583, -0.204868],
[0.114503, 0.786281, 0.099216],
[-0.003882, -0.048116, 1.051998],
],
deuteranopia: [
[0.367322, 0.860646, -0.227968],
[0.280085, 0.672501, 0.047413],
[-0.01182, 0.04294, 0.968881],
],
}
export function simulate(hex, kind) {
if (!kind) return hex
const m = CVD[kind]
const lin = srgb(hex).map(toLinear)
const out = m.map((row) => row.reduce((s, k, i) => s + k * lin[i], 0))
return (
'#' +
out
.map((c) => Math.round(Math.min(1, Math.max(0, fromLinear(c))) * 255).toString(16).padStart(2, '0'))
.join('')
)
}
// ── The report ───────────────────────────────────────────────────────────────
const NAME = {
'ch-total': 'Total', 'ch-ok': 'No Error', 'ch-fail': 'Server Failure',
'ch-nx': 'NX Domain', 'ch-refuse': 'Refused', 'ch-auth': 'Authoritative',
'ch-rec': 'Recursive', 'ch-cache': 'Cached', 'ch-block': 'Blocked',
'ch-drop': 'Dropped', 'ch-clients': 'Clients',
}
const label = (k) => NAME[k] ?? k
export function pairs(palette, keys, vision) {
const present = keys.filter((k) => palette[k])
const out = []
for (let i = 0; i < present.length; i++)
for (let j = i + 1; j < present.length; j++) {
const [a, b] = [present[i], present[j]]
out.push({ a, b, d: deltaE00(simulate(palette[a], vision), simulate(palette[b], vision)) })
}
return out.sort((x, y) => x.d - y.d)
}
export const VISIONS = [null, 'deuteranopia', 'protanopia']
function report(title, palette, ui) {
console.log(`\n${'='.repeat(72)}\n${title}\n${'='.repeat(72)}`)
for (const [chart, keys] of Object.entries(CHARTS)) {
console.log(`\n── ${chart} ──`)
for (const vision of VISIONS) {
const p = pairs(palette, keys, vision)
if (!p.length) continue
const bad = p.filter((x) => x.d < 15)
const head = vision ?? 'normal vision'
if (!bad.length) {
console.log(` ${head.padEnd(14)} worst pair ΔE00 ${p[0].d.toFixed(1)} ${label(p[0].a)} / ${label(p[0].b)}: clear`)
} else {
console.log(` ${head}`)
for (const x of bad)
console.log(
` ΔE00 ${x.d.toFixed(1).padStart(5)} ${x.d < 10 ? 'COLLISION' : 'at risk '} ${label(x.a)} ${palette[x.a]} / ${label(x.b)} ${palette[x.b]}`,
)
}
}
}
console.log('\n── a series the colour of the interface ──')
const clashes = uiClashes(palette, ui)
for (const c of clashes)
console.log(` ΔE00 ${c.d.toFixed(1).padStart(5)} ${c.d < 1 ? 'IDENTICAL' : 'COLLISION'} ${label(c.k)} ${palette[c.k]} / --${c.t} ${ui[c.t]}`)
if (!clashes.length) console.log(' none under ΔE00 10')
console.log(`\n── contrast on the panel (WCAG 1.4.11 wants 3:1) ──`)
const thin = thinSeries(palette, ui.pan)
if (!thin.length) console.log(` every series ≥ 3:1 on --pan ${ui.pan}`)
else for (const k of thin) console.log(` ${contrast(palette[k], ui.pan).toFixed(2)}:1 ${label(k)} ${palette[k]}`)
}
const seriesOf = (palette) => [...new Set(Object.values(CHARTS).flat())].filter((k) => palette[k])
/** The series under ΔE00 10 from a text or line token of the same theme. */
export function uiClashes(palette, ui) {
const out = []
for (const k of seriesOf(palette))
for (const t of UI) {
if (!ui[t]) continue
const d = deltaE00(palette[k], ui[t])
if (d < 10) out.push({ k, t, d })
}
return out
}
/** The series under 3:1 against the panel they are drawn on. */
export function thinSeries(palette, pan) {
return seriesOf(palette).filter((k) => contrast(palette[k], pan) < 3)
}
/*
Every pair, chart and vision where `light` separates less than `dark` AND is
under 15. Pairs that do not share a chart are not compared, as everywhere else.
*/
export function regressions(dark, light) {
const out = []
for (const [chart, keys] of Object.entries(CHARTS))
for (const vision of VISIONS) {
const before = new Map(pairs(dark, keys, vision).map((x) => [`${x.a}|${x.b}`, x.d]))
for (const x of pairs(light, keys, vision)) {
const was = before.get(`${x.a}|${x.b}`)
if (x.d < 15 && was != null && x.d < was) out.push({ chart, vision, a: x.a, b: x.b, dark: was, light: x.d })
}
}
return out
}
const uiOf = (p) => ({ ink: p.ink, mute: p.mute, faint: p.faint, line: p.line, pan: p.pan })
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
const { dark, light } = codePalettes()
report('CODE, dark: src/theme/tokens.css', dark, uiOf(dark))
report('CODE, light: src/theme/tokens.css', light, uiOf(light))
report('DRAWN: the Dashboard design drawing', DRAWN, uiOf(dark))
report('PROPOSED: the drawing with the two identities corrected', PROPOSED, uiOf(dark))
console.log(`\n${'='.repeat(72)}\nWhere the two disagree\n${'='.repeat(72)}`)
for (const k of Object.keys(NAME)) {
if (dark[k] !== DRAWN[k]) console.log(` ${label(k).padEnd(15)} code ${dark[k]} drawn ${DRAWN[k]}`)
}
console.log(`\n${'='.repeat(72)}\nLight against dark, pair by pair\n${'='.repeat(72)}`)
const worse = regressions(dark, light)
if (!worse.length) console.log('\n no pair under 15 separates less in light than in dark')
for (const r of worse)
console.log(` ${r.chart} · ${r.vision ?? 'normal'}: ${label(r.a)} / ${label(r.b)} dark ${r.dark.toFixed(1)} → light ${r.light.toFixed(1)}`)
const failures = [
...seriesOf(dark).filter((k) => !light[k]).map((k) => `light: ${label(k)} is not declared`),
...thinSeries(dark, dark.pan).map((k) => `dark: ${label(k)} under 3:1 on its panel`),
...thinSeries(light, light.pan).map((k) => `light: ${label(k)} under 3:1 on its panel`),
...uiClashes(light, uiOf(light)).map((c) => `light: ${label(c.k)} is ΔE00 ${c.d.toFixed(1)} from --${c.t}`),
...worse.map((r) => `light: ${label(r.a)} / ${label(r.b)} separates less than in dark (${r.chart}, ${r.vision ?? 'normal'})`),
]
console.log(`\n${'='.repeat(72)}\nVerdict\n${'='.repeat(72)}`)
if (!failures.length) console.log('\n dark and light both pass\n')
else {
for (const f of failures) console.log(` FAIL ${f}`)
console.log('')
process.exitCode = 1
}
}
+43
View File
@@ -0,0 +1,43 @@
import { chromium } from 'playwright'
const b = await chromium.launch()
async function ours(p) {
await p.goto('http://127.0.0.1:5380/', { waitUntil: 'networkidle' })
const r = {}
await p.getByRole('button', { name: 'Login' }).click()
await p.waitForTimeout(400)
r.noUser = (await p.locator('[role=alert]').innerText()).replace(/\s+/g, ' ').trim()
await p.getByLabel('Username').fill('admin')
await p.getByRole('button', { name: 'Login' }).click()
await p.waitForTimeout(400)
r.noPass = (await p.locator('[role=alert]').innerText()).replace(/\s+/g, ' ').trim()
await p.getByLabel('Password').fill('wrong-password')
await p.getByRole('button', { name: 'Login' }).click()
await p.waitForTimeout(2000)
r.wrong = (await p.locator('[role=alert]').innerText()).replace(/\s+/g, ' ').trim()
return r
}
async function stock(p) {
await p.goto('http://127.0.0.1:5381/', { waitUntil: 'networkidle' })
const r = {}
const alert = async () => (await p.locator('#divAlert, .alert').first().innerText()).replace(/\s+/g,' ').trim()
await p.click('#btnLogin'); await p.waitForTimeout(400); r.noUser = await alert()
await p.fill('#txtUser', 'admin'); await p.click('#btnLogin'); await p.waitForTimeout(400); r.noPass = await alert()
await p.fill('#txtPass', 'wrong-password'); await p.click('#btnLogin'); await p.waitForTimeout(2000); r.wrong = await alert()
return r
}
const p1 = await b.newPage(); const n = await ours(p1)
const p2 = await b.newPage(); const s = await stock(p2)
let ok = true
for (const k of ['noUser', 'noPass', 'wrong']) {
const same = n[k] === s[k]
if (!same) ok = false
console.log(`${same ? 'SAME ' : 'DIFFERENT'} ${k}`)
console.log(` ours: ${n[k]}`)
console.log(` stock: ${s[k]}`)
}
console.log(ok ? '\n=> MESSAGE PARITY: OK' : '\n=> THERE ARE DIVERGENCES')
await b.close()
+88
View File
@@ -0,0 +1,88 @@
/*
The raw-output rules that live only in the CSS.
jsdom does not apply CSS modules, so no render test can see `white-space` or
`max-height`, and those are the two decisions this primitive exists to take. It
lives in `dev/` and not under `src/` for the same reason as
`master-switch-signal.test.mjs`: reading files needs `node:fs`, and everything
under `src/` compiles without node's types. A `node:fs` there passes the suite and
breaks the build.
*/
import { describe, expect, it } from 'vitest'
import { readFileSync } from 'node:fs'
import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
const css = readFileSync(
resolve(dirname(fileURLToPath(import.meta.url)), '../src/ui/Raw.module.css'),
'utf8',
)
const rule = (n) => /* that class's declaration block */ new RegExp(`\\.${n}\\s*\\{([^}]*)\\}`).exec(css)?.[1] ?? ''
describe('raw output does not wrap', () => {
const text = rule('text')
/*
THE decision of this pattern. The two places where it was written before used
`pre-wrap` with `word-break`, and that breaks the one thing that makes a long
JSON readable: the indentation. The price is a horizontal scroll bar.
*/
it('it uses `pre` and not `pre-wrap`', () => {
expect(text).toMatch(/white-space:\s*pre\s*;/)
expect(text).not.toMatch(/pre-wrap/)
expect(text).not.toMatch(/word-break/)
})
it('it scrolls on both axes and does not drag the page', () => {
expect(text).toMatch(/overflow:\s*auto/)
expect(text).toMatch(/overscroll-behavior:\s*contain/)
})
/* The panel does not grow with the content: measured, the viewer once loaded
1,073,928 characters. */
it('has a height cap, set through a variable so each use can fix its own', () => {
expect(text).toMatch(/max-height:\s*var\(--raw-height/)
})
/* The border goes on the box: on the element that scrolls, it would move with
the horizontal scroll. */
it('the border lives on the box and not on the text', () => {
expect(rule('box')).toMatch(/border:/)
expect(text).not.toMatch(/border:/)
})
})
/*
The two modifiers View Logs asks for. Neither may leak into the default above, and
neither may cut a word letter by letter (`npm run lint:breaks`).
*/
describe('raw output, when the caller asks it to wrap or to fill', () => {
it('`wrap` soft-wraps with a hanging indent read from `--raw-hang`', () => {
const wrap = rule('wrap')
expect(wrap).toMatch(/white-space:\s*pre-wrap/)
expect(wrap).toMatch(/text-indent:\s*var\(--raw-hang[^;]*\)\s*hanging\s+each-line/)
})
/* `break-word` cuts only a piece wider than the whole box; `anywhere` and
`break-all` would end lines inside words wherever the edge falls. */
it('`wrap` breaks between words, never anywhere', () => {
const wrap = rule('wrap')
expect(wrap).toMatch(/overflow-wrap:\s*break-word/)
expect(wrap).not.toMatch(/anywhere|break-all|word-break/)
})
it('on a phone the indent drops to two characters', () => {
expect(css).toMatch(/@media \(max-width: 560px\)\s*\{\s*\.wrap\s*\{\s*text-indent:\s*2ch hanging each-line;/)
})
/* Out of flow, so the file adds nothing to the page's height; the box's
`min-height` is the floor. */
it('`fill` lays the text out of flow over a box with a floor', () => {
expect(rule('fill')).toMatch(/min-height:\s*var\(--raw-height/)
expect(rule('fill')).toMatch(/flex:\s*1 1 auto/)
const inner = /\.fill\s*>\s*\.text\s*\{([^}]*)\}/.exec(css)?.[1] ?? ''
expect(inner).toMatch(/position:\s*absolute/)
expect(inner).toMatch(/inset:\s*0/)
expect(inner).toMatch(/max-height:\s*none/)
})
})
+37
View File
@@ -0,0 +1,37 @@
import { describe, expect, it } from 'vitest'
import { readdirSync, readFileSync, statSync } from 'node:fs'
import { dirname, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
/*
Upstream is one page, so a relative path in its markup always resolves against
the root. This console has real routes: a relative `sso/login` link drawn at
`/dashboard/` goes to `/dashboard/sso/login`. It happened twice on 2026-09-30
(the SSO Redirect URI and the SSO login link, the second one found on the home
server as a 404), so every path in the markup has to hang from the root
(`publicUrl`, `urlApi`), and this is what keeps a literal one from coming back.
It lives in `dev/` and not beside the code for the reason
`master-switch-signal.test.mjs` gives: `src/` compiles without node's types, and
a `node:fs` in there passes the suite and breaks `tsc -b`.
*/
const SRC = resolve(dirname(fileURLToPath(import.meta.url)), '../src') + '/'
function files(dir) {
return readdirSync(dir).flatMap((n) => {
const p = join(dir, n)
if (statSync(p).isDirectory()) return files(p)
return /\.tsx$/.test(n) && !/\.test\./.test(n) ? [p] : []
})
}
const RELATIVE = /\b(?:href|src|action)=["'`](?!https?:|mailto:|data:|#|\/)[^"'`{}\s]+["'`]/g
describe('paths in the markup', () => {
it('no literal href, src or action is relative to the route it is drawn at', () => {
const found = files(SRC).flatMap((f) =>
[...readFileSync(f, 'utf8').matchAll(RELATIVE)].map((m) => `${f.slice(SRC.length)}: ${m[0]}`),
)
expect(found).toEqual([])
})
})
+213
View File
@@ -0,0 +1,213 @@
/*
The seven Query Logs row colours, measured where they are really used.
It exists because `dev/palette-distance.mjs` measures the SERIES palette (lines
and chips at full colour) and the row colour code is something else: the same
token laid as a translucent background behind text. Measuring both proved it:
with the seven series tokens, the chip separates (0 of 21 pairs below the
threshold) and **the row does not** (5 of 21). Measuring one surface and deciding
about the other is the mistake this file prevents.
Since 2026-10-15 that background is no longer the whole row: it is the pill
around the Response Type (`.tone` in screens/logs/Logs.module.css), at the same
30 % over the same panel, so the three checks below measure it unchanged.
The three checks, and all three have to pass at once:
· ΔE00 between pairs as a BACKGROUND, at the real opacity, over the panel.
· Contrast of the cell text over the lightest background: AA asks for 4.5:1.
· Contrast of the LINE over the panel: WCAG 1.4.11 asks for 3:1 for a non-text
graphical object, and it is the one that sank the first proposed tweak:
darkening the cyan fixed deuteranopia and left the line at 2.35:1.
node dev/row-colours.mjs
Measured on 2026-09-03. The value that passes all three is `tok('ch-refuse')`
(teal-600): the HUE has to move as well as the lightness, because no cyan passes
all three.
*/
import { readFileSync } from 'node:fs'
const srgb = (hex) => [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16) / 255)
const toLinear = (c) => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4)
const fromLinear = (c) => (c <= 0.0031308 ? c * 12.92 : 1.055 * c ** (1 / 2.4) - 0.055)
function lab(hex) {
const [r, g, b] = srgb(hex).map(toLinear)
const X = (0.4124564 * r + 0.3575761 * g + 0.1804375 * b) / 0.95047
const Y = 0.2126729 * r + 0.7151522 * g + 0.072175 * b
const Z = (0.0193339 * r + 0.119192 * g + 0.9503041 * b) / 1.08883
const f = (t) => (t > 216 / 24389 ? Math.cbrt(t) : (24389 / 27) * t / 116 + 16 / 116)
const [fx, fy, fz] = [f(X), f(Y), f(Z)]
return [116 * fy - 16, 500 * (fx - fy), 200 * (fy - fz)]
}
const luminance = (hex) => {
const [r, g, b] = srgb(hex).map(toLinear)
return 0.2126 * r + 0.7152 * g + 0.0722 * b
}
const contrast = (a, b) => {
const [x, y] = [luminance(a), luminance(b)].sort((m, n) => n - m)
return (x + 0.05) / (y + 0.05)
}
/* CIEDE2000. Sharma, Wu & Dalal's formulation. */
function deltaE00(hexA, hexB) {
const [L1, a1, b1] = lab(hexA)
const [L2, a2, b2] = lab(hexB)
const rad = Math.PI / 180
const C1 = Math.hypot(a1, b1)
const C2 = Math.hypot(a2, b2)
const Cb = (C1 + C2) / 2
const G = 0.5 * (1 - Math.sqrt(Cb ** 7 / (Cb ** 7 + 25 ** 7)))
const ap1 = (1 + G) * a1
const ap2 = (1 + G) * a2
const Cp1 = Math.hypot(ap1, b1)
const Cp2 = Math.hypot(ap2, b2)
const hp = (b, ap) => {
if (b === 0 && ap === 0) return 0
const h = Math.atan2(b, ap) / rad
return h < 0 ? h + 360 : h
}
const hp1 = hp(b1, ap1)
const hp2 = hp(b2, ap2)
const dL = L2 - L1
const dC = Cp2 - Cp1
let dh = 0
if (Cp1 * Cp2 !== 0) {
dh = hp2 - hp1
if (dh > 180) dh -= 360
else if (dh < -180) dh += 360
}
const dH = 2 * Math.sqrt(Cp1 * Cp2) * Math.sin((dh * rad) / 2)
const Lb = (L1 + L2) / 2
const Cpb = (Cp1 + Cp2) / 2
let hpb
if (Cp1 * Cp2 === 0) hpb = hp1 + hp2
else if (Math.abs(hp1 - hp2) <= 180) hpb = (hp1 + hp2) / 2
else hpb = hp1 + hp2 < 360 ? (hp1 + hp2 + 360) / 2 : (hp1 + hp2 - 360) / 2
const T =
1 -
0.17 * Math.cos((hpb - 30) * rad) +
0.24 * Math.cos(2 * hpb * rad) +
0.32 * Math.cos((3 * hpb + 6) * rad) -
0.2 * Math.cos((4 * hpb - 63) * rad)
const dTheta = 30 * Math.exp(-(((hpb - 275) / 25) ** 2))
const Rc = 2 * Math.sqrt(Cpb ** 7 / (Cpb ** 7 + 25 ** 7))
const Sl = 1 + (0.015 * (Lb - 50) ** 2) / Math.sqrt(20 + (Lb - 50) ** 2)
const Sc = 1 + 0.045 * Cpb
const Sh = 1 + 0.015 * Cpb * T
const Rt = -Math.sin(2 * dTheta * rad) * Rc
return Math.sqrt((dL / Sl) ** 2 + (dC / Sc) ** 2 + (dH / Sh) ** 2 + Rt * (dC / Sc) * (dH / Sh))
}
/* Machado, Oliveira & Fernandes 2009, severity 1.0, on linear RGB. */
const CVD = {
protanopia: [
[0.152286, 1.052583, -0.204868],
[0.114503, 0.786281, 0.099216],
[-0.003882, -0.048116, 1.051998],
],
deuteranopia: [
[0.367322, 0.860646, -0.227968],
[0.280085, 0.672501, 0.047413],
[-0.01182, 0.04294, 0.968881],
],
}
function simulate(hex, kind) {
if (!kind) return hex
const m = CVD[kind]
const lin = srgb(hex).map(toLinear)
const out = m.map((row) => row.reduce((s, k, i) => s + k * lin[i], 0))
return (
'#' +
out
.map((c) => Math.round(Math.min(1, Math.max(0, fromLinear(c))) * 255).toString(16).padStart(2, '0'))
.join('')
)
}
// ── The report ───────────────────────────────────────────────────────────────
const NAME = {
'ch-total': 'Total', 'ch-ok': 'No Error', 'ch-fail': 'Server Failure',
'ch-nx': 'NX Domain', 'ch-refuse': 'Refused', 'ch-auth': 'Authoritative',
'ch-rec': 'Recursive', 'ch-cache': 'Cached', 'ch-block': 'Blocked',
'ch-drop': 'Dropped', 'ch-clients': 'Clients',
}
const label = (k) => NAME[k] ?? k
/*
The seven are READ from `tokens.css`, not written here.
Written by hand, this tool would say one thing and the product would paint another
as soon as someone touched a token, and that is exactly what happened during the
`--ch-refuse` tweak: the first version carried the value by hand and kept
reporting the old one after it changed. A drift tool that can drift is no use.
*/
const CSS = readFileSync(new URL('../src/theme/tokens.css', import.meta.url), 'utf8')
const tok = (n) => {
const m = new RegExp(`--${n}:\\s*(#[0-9a-fA-F]{6})`).exec(CSS)
if (m == null) throw new Error(`token --${n} is missing from tokens.css`)
return m[1].toLowerCase()
}
const PAN = tok('pan')
const INK = tok('ink')
const mix = (h, a) => '#' + [1, 3, 5].map((i) =>
Math.round(parseInt(h.slice(i, i + 2), 16) * a + parseInt(PAN.slice(i, i + 2), 16) * (1 - a))
.toString(16).padStart(2, '0')).join('')
const lum = (h) => {
const c = [1, 3, 5].map((i) => parseInt(h.slice(i, i + 2), 16) / 255)
.map((v) => (v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4))
return 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2]
}
const ratio = (a, b) => { const [x, y] = [lum(a), lum(b)].sort((p, q) => q - p); return (x + 0.05) / (y + 0.05) }
/* The seven of the Query Logs colour code, and the four series `Refused` shares
the Dashboard line chart with. */
const ROW = {
'Server Failure': tok('ch-fail'), 'Blocked': tok('ch-block'), 'NX Domain': tok('ch-nx'),
'Refused': tok('ch-refuse'), 'Authoritative': tok('ch-auth'), 'Recursive': tok('ch-rec'),
'Cached': tok('ch-cache'),
}
const SERIES = {
...ROW, 'Total': tok('ch-total'), 'No Error': tok('ch-ok'),
'Dropped': tok('ch-drop'), 'Clients': tok('ch-clients'),
}
const ALPHA = 0.30
const ks = Object.keys(ROW)
console.log('tokens read from tokens.css:', JSON.stringify(ROW))
console.log(`\n== ROW BACKGROUND at ${ALPHA * 100} % over ${PAN} ==`)
const pairs = []
for (let i = 0; i < ks.length; i++) for (let j = i + 1; j < ks.length; j++)
pairs.push([deltaE00(mix(ROW[ks[i]], ALPHA), mix(ROW[ks[j]], ALPHA)), ks[i], ks[j]])
pairs.sort((a, b) => a[0] - b[0])
const bad = pairs.filter((p) => p[0] < 10)
for (const [d, a, b] of pairs.slice(0, 6))
console.log(` ${d.toFixed(1).padStart(5)} ${d < 10 ? 'COLLISION' : 'at risk '} ${a} / ${b}`)
console.log(` --> ${bad.length} of ${pairs.length} below the collision threshold (10)`)
const worstText = Math.min(...ks.map((k) => ratio(INK, mix(ROW[k], ALPHA))))
console.log(`\n== Cell TEXT over the lightest background ==`)
console.log(` ${worstText.toFixed(1)}:1 ${worstText >= 4.5 ? 'ok (AA)' : 'BELOW 4.5'}`)
console.log(`\n== LINE over the panel: WCAG 1.4.11 asks for 3:1 ==`)
for (const [n, h] of Object.entries(ROW)) {
const c = ratio(h, PAN)
console.log(` ${n.padEnd(15)} ${c.toFixed(2).padStart(5)}:1 ${c >= 3 ? 'ok' : 'BELOW'}`)
}
console.log(`\n== THE ELEVEN SERIES, by vision, worst pair of each with Refused ==`)
for (const v of ['normal', 'protanopia', 'deuteranopia']) {
const r = Object.entries(SERIES).filter(([n]) => n !== 'Refused').map(([n, h]) => {
const A = v === 'normal' ? ROW['Refused'] : simulate(ROW['Refused'], v)
const B = v === 'normal' ? h : simulate(h, v)
return [deltaE00(A, B), n]
}).sort((a, b) => a[0] - b[0])
const [d, n] = r[0]
console.log(` ${v.padEnd(13)} worst: ${n} ${d.toFixed(1)} ${d < 10 ? 'COLLISION' : d < 15 ? 'at risk' : 'separates'}`)
}
+190
View File
@@ -0,0 +1,190 @@
#!/usr/bin/env node
/*
Every text colour against every surface it is drawn on, per theme.
A palette is not legible in general: a token is legible on a surface. `--faint`
can be 5:1 on the panel and 4.2:1 on the same panel under a hovered row, and the
second is where it is read the moment the pointer is on it. So what is measured
here is not a list of tokens but a list of PAIRS, each one a place the console
really puts that colour on that ground. The pairs were read from the stylesheets
(`grep var(--token)` in `src/`), and each says where.
A surface may be a token, or a token under a translucent one (the hover veils,
the Query Logs row colours), composed the way the browser composes them: in sRGB,
source over. `color-mix(in srgb, X 30%, transparent)` is X at alpha 0.3.
Thresholds, WCAG 2.2:
4.5 text (1.4.3). Every text here is under 18.66 px bold, so none is "large".
3 a non-text object a user needs to see (1.4.11): a field's border, the focus
ring, a checkbox's box, a switch's knob, a tick on its fill.
What it does not measure: the chart series (`dev/palette-distance.mjs` does, as
pairs that share a chart) and decorative dividers (`--line`, `--line2`), which
1.4.11 does not ask anything of: a panel is still a panel without its border.
Exit code: 1 when a pair in the LIGHT theme is under its threshold. Dark is
measured and printed with the same list, and it is not gated, on purpose: its
values are the ones shipped since 2026-08-25 and the light theme was designed
under a "dark stays pixel-identical" rule (2026-10-01). What it fails is listed
as a fact about dark, not excused.
Run: node dev/theme-contrast.mjs
*/
import { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { dirname, join } from 'node:path'
import { themeTokens } from './check-colour-tokens.mjs'
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
// ── Colour ───────────────────────────────────────────────────────────────────
const toLinear = (c) => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4)
/** `#rrggbb` or `rgb[a](r, g, b[, a])` → [r, g, b, a], channels 0-255. */
export function parse(value) {
const hex = /^#([0-9a-f]{6})$/i.exec(value)
if (hex) return [0, 2, 4].map((i) => parseInt(hex[1].slice(i, i + 2), 16)).concat(1)
const fn = /^rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)\s*(?:,\s*([\d.]+)\s*)?\)$/i.exec(value)
if (fn) return [Number(fn[1]), Number(fn[2]), Number(fn[3]), fn[4] == null ? 1 : Number(fn[4])]
return null
}
/** `top` over an opaque `ground`, in sRGB: what the browser paints. */
export const over = (top, ground) => [0, 1, 2].map((i) => top[i] * top[3] + ground[i] * (1 - top[3])).concat(1)
const luminance = ([r, g, b]) => {
const [R, G, B] = [r, g, b].map((c) => toLinear(c / 255))
return 0.2126 * R + 0.7152 * G + 0.0722 * B
}
export const contrast = (a, b) => {
const [x, y] = [luminance(a), luminance(b)].sort((m, n) => n - m)
return (x + 0.05) / (y + 0.05)
}
const hex = (c) => '#' + c.slice(0, 3).map((v) => Math.round(v).toString(16).padStart(2, '0')).join('')
// ── What is measured ─────────────────────────────────────────────────────────
/*
The surfaces. A name is a token; `a + b` is token `b` painted over surface `a`;
`a + [email protected]` is `b` at that alpha, which is how `color-mix(..., transparent)` and
the row colours are written.
*/
const PLAIN = ['bg', 'pan', 'pan2', 'field']
const HOVERED = ['pan + hover', 'pan + hover-strong', 'pan2 + hover-strong']
const EVERYWHERE = [...PLAIN, ...HOVERED]
/*
The `stroke` written inside a `--glyph-*` data URI, which is the only place that
colour exists. `%23` is `#`.
*/
const GLYPH = { 'glyph-chevron': 'glyph-chevron', 'glyph-tick': 'glyph-tick', 'glyph-tick-ok': 'glyph-tick-ok', 'glyph-dash': 'glyph-dash' }
export const PAIRS = [
// Text: the three steps of ink, on every ground of the console.
{ fg: 'ink', on: [...EVERYWHERE, 'field-focus', 'acc-bg', 'dan-fill'], min: 4.5, where: 'all body text; the own-session row (`acc-bg`); Confirm armed (`dan-fill`)' },
{ fg: 'mute', on: [...EVERYWHERE, 'acc-bg'], min: 4.5, where: 'secondary text, labels, the small button, row data' },
{ fg: 'faint', on: [...EVERYWHERE, 'acc-bg'], min: 4.5, where: 'placeholders, TTL suffixes, timestamps, disabled row actions' },
// Amber.
{ fg: 'on-acc', on: ['acc'], min: 4.5, where: 'primary button, active rail entry, current page, Segmented' },
{ fg: 'acc-ink', on: [...EVERYWHERE, 'acc-bg', 'pan + [email protected]'], min: 4.5, where: 'links, current sub-tab, sorted arrow, `acc` pill, Versions mark' },
// State tones, as pill or notice (`ui/tones`) and as loose text.
{ fg: 'ok', on: ['ok-bg', 'pan', 'pan2'], min: 4.5, where: '`ok` pill and notice; Blocking status mark' },
{ fg: 'warn', on: ['warn-bg', 'pan', 'pan + hover'], min: 4.5, where: '`warn` pill and notice; the Dashboard\'s limited rows' },
{ fg: 'dan', on: ['dan-bg', 'pan', 'pan2', 'pan + hover-strong'], min: 4.5, where: '`dan` pill and notice, validation text, Delete in a row or a menu' },
{ fg: 'info', on: ['info-bg', 'pan'], min: 4.5, where: '`info` pill and notice' },
{ fg: 'on-dan', on: ['dan-fill'], min: 4.5, where: 'the destructive button, filled' },
{ fg: 'chip-fg', on: ['chip-bg'], min: 4.5, where: 'record type and app class chips' },
// The rule kind in Blocking › Rules and Lists: `Allow` takes `--ok`, `Block` a series colour.
{ fg: 'ok', on: ['pan + hover'], min: 4.5, where: '`Allow` in Blocking › Rules and Block Lists, under the row hover' },
{ fg: 'ch-block', on: ['pan', 'pan + hover'], min: 4.5, where: '`Block` in Blocking › Rules and Block Lists' },
// Query Logs: the cell text on its row colour, 30 % over the panel.
...['ch-fail', 'ch-block', 'ch-nx', 'ch-refuse', 'ch-auth', 'ch-rec', 'ch-cache'].map((k) => ({
fg: 'ink', on: [`pan + ${k}@0.3`], min: 4.5, where: `Query Logs row (${k})`,
})),
...['ch-fail', 'ch-block', 'ch-nx', 'ch-refuse', 'ch-auth', 'ch-rec', 'ch-cache'].map((k) => ({
fg: 'faint-on-tint', on: [`pan + ${k}@0.3`], min: 4.5, where: `Query Logs row, its response time (${k})`,
})),
// The login cover (`screens/LoginCover`) draws all its text on `field`, the deepest surface.
...['ink', 'mute', 'faint', 'acc-ink'].map((fg) => ({
fg, on: ['field'], min: 4.5, where: `the login cover (${fg})`,
})),
// Non-text: what a user has to see to operate a control.
{ fg: 'faint-on-tint', on: ['pan', 'pan + hover'], min: 4.5, where: 'Query Logs, the response time on an uncoloured or hovered row' },
{ fg: 'line-field', on: ['bg', 'pan', 'field', 'pan2'], min: 3, where: 'a field\'s border at rest: it is what tells a field from the panel' },
{ fg: 'line-ctrl', on: ['pan', 'field', 'pan2'], min: 3, where: 'a checkbox\'s and a radio\'s box at rest; a field under the pointer' },
{ fg: 'acc-ink', on: ['bg', 'pan', 'pan2', 'field'], min: 3, where: 'the focus ring; a focused field\'s border; a ticked box\'s border' },
{ fg: 'dan', on: ['pan', 'field'], min: 3, where: 'the border of a field that failed validation' },
{ fg: 'mute', on: ['field'], min: 3, where: 'the switch\'s knob, off' },
{ fg: 'on-acc', on: ['acc'], min: 3, where: 'the switch\'s knob, on; the tick and the dash' },
{ fg: 'glyph-tick-ok', on: ['ok'], min: 3, where: 'a granted permission\'s tick, on its green' },
{ fg: 'ok', on: ['pan'], min: 3, where: 'a granted permission\'s box and pip' },
{ fg: 'glyph-chevron', on: ['field'], min: 3, where: 'the dropdown\'s chevron' },
{ fg: 'ch-1', on: ['pan', 'pan2'], min: 3, where: 'the Dashboard\'s week by hour: its busiest cell on the panel and beside an empty cell (Heatmap.module.css)' },
]
// ── Resolution ───────────────────────────────────────────────────────────────
function resolver(decls) {
const value = (name) => {
let v = decls.get(`--${name}`)
if (v == null) throw new Error(`--${name} is not declared`)
if (GLYPH[name]) {
const m = /stroke='%23([0-9a-f]{6})'/i.exec(v)
if (!m) throw new Error(`--${name} has no stroke colour`)
v = `#${m[1]}`
}
const ref = /^var\(--([\w-]+)\)$/.exec(v)
if (ref) return value(ref[1])
const c = parse(v)
if (!c) throw new Error(`--${name}: cannot read ${v}`)
return c
}
const surface = (spec) => {
const [base, ...layers] = spec.split('+').map((s) => s.trim())
let ground = value(base)
if (ground[3] !== 1) throw new Error(`${base} is not opaque`)
for (const layer of layers) {
const [name, alpha] = layer.split('@')
const top = value(name)
ground = over(alpha == null ? top : [...top.slice(0, 3), Number(alpha)], ground)
}
return ground
}
return { value, surface }
}
/** Every pair of `PAIRS` measured in one theme. */
export function measure(decls) {
const { value, surface } = resolver(decls)
return PAIRS.flatMap((p) => p.on.map((on) => {
const ratio = contrast(value(p.fg), surface(on))
return { ...p, on, ratio, pass: ratio >= p.min, fgHex: hex(value(p.fg)), onHex: hex(surface(on)) }
}))
}
// ── The report ───────────────────────────────────────────────────────────────
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
const themes = themeTokens(readFileSync(join(ROOT, 'src/theme/tokens.css'), 'utf8'))
let lightFails = 0
for (const theme of ['light', 'dark']) {
const rows = measure(themes[theme])
console.log(`\n${'='.repeat(78)}\n${theme.toUpperCase()}${theme === 'dark' ? ': measured, not gated' : ''}\n${'='.repeat(78)}`)
for (const p of PAIRS) {
const mine = rows.filter((r) => r.fg === p.fg && r.where === p.where)
const worst = mine.reduce((a, b) => (b.ratio < a.ratio ? b : a))
console.log(`\n --${p.fg} ${mine[0].fgHex} ≥ ${p.min} · ${p.where}`)
console.log(' ' + mine.map((r) => `${r.on} ${r.ratio.toFixed(2)}${r.pass ? '' : ' ✗'}`).join(' · '))
if (!worst.pass) console.log(` UNDER ${p.min}: ${worst.on} ${worst.onHex} gives ${worst.ratio.toFixed(2)}:1`)
}
const fails = rows.filter((r) => !r.pass)
console.log(`\n ${theme}: ${rows.length - fails.length} of ${rows.length} pairs meet their threshold`)
if (theme === 'light') lightFails = fails.length
}
console.log('')
if (lightFails) process.exitCode = 1
}
+33
View File
@@ -0,0 +1,33 @@
import { readFileSync } from 'node:fs'
import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
import { themeTokens } from './check-colour-tokens.mjs'
import { codePalettes, regressions, thinSeries, uiClashes } from './palette-distance.mjs'
import { measure } from './theme-contrast.mjs'
/*
The light theme's two measurements, inside `npm test`: a value tuned by hand in
`tokens.css` that breaks a pair breaks the suite, not only the script nobody ran.
Dark is measured by the same scripts and not gated here; see their headers.
*/
const here = dirname(fileURLToPath(import.meta.url))
const themes = themeTokens(readFileSync(resolve(here, '../src/theme/tokens.css'), 'utf8'))
describe('the light theme, measured', () => {
it('every text and control colour meets its threshold on every surface it is drawn on', () => {
const under = measure(themes.light).filter((r) => !r.pass).map((r) => `--${r.fg} on ${r.on}: ${r.ratio.toFixed(2)}`)
expect(under).toEqual([])
})
it('every chart series holds 3:1 on the panel and is not the colour of the interface', () => {
const { light } = codePalettes()
expect(thinSeries(light, light.pan)).toEqual([])
expect(uiClashes(light, { ink: light.ink, mute: light.mute, faint: light.faint, line: light.line })).toEqual([])
})
it('no pair of series separates less in light than in dark, unless both are past 15', () => {
const { dark, light } = codePalettes()
expect(regressions(dark, light)).toEqual([])
})
})
+454
View File
@@ -0,0 +1,454 @@
/*
Does the same thing look the same on every screen?
The other tools in `dev/` look at one screen and answer whether it is right. This
one looks at ALL of them and answers something different: whether the same object
(a panel, a table header, a count) is painted the same everywhere.
It was needed because the console was written screen by screen, and each one
solved on its own what had already been solved next door. Measured before fixing
it: the bordered container was defined SEVEN times with four different looks, the
small-caps label FOURTEEN times with three sizes and four letter-spacings, and
the count footer EIGHT times with four treatments. None of that is visible to a
screen-by-screen review, because on each screen, taken alone, everything looks
right.
screenSignatures() groups each family by look and says how many there are
## How to run it, and why it changed
It used to say "paste it into the console". It now EXPORTS, which means it can no
longer be pasted raw, and that is on purpose, for two reasons found on the same
day:
· **Fidelity.** Taking a baseline meant getting these lines into the page.
Transcribing them into a `browser_evaluate` would have measured a
hand-copy of the tool instead of the tool. Serving the file untouched and
importing it is the only way the photo is of THIS code.
· **Testability.** A tool whose defects only show up in a browser is a tool
nobody regression-tests. `_sinFilas_` survived the August translation for
weeks precisely because of that.
Serve it and import it: await import('/__uniformity.js')
Or in a test: import { screenSignatures } from './uniformity.js'
It still registers itself on `window` when it runs in a browser, so the call
sites that expect `screenSignatures()` to be there keep working.
The CONTROL families (text field, textarea, dropdown, checkbox, radio, alert)
were added later, and they are the ones that caught the last batch: the textarea
was painted by hand on four screens, with radius 6 instead of 8 and without the
inset shadow every other field carries. None of the earlier families saw it,
because a textarea, alone on its screen, looks right.
What it returns are GROUPS, not a verdict: two signatures can both be right (the
data table and the editable one are two objects on purpose), and a single one can
be wrong if it is ugly. What must not happen is that there are five without
anyone having decided so.
And you have to know how to read it. A signature with a placeholder where the
cell padding goes is not a different density: it is a table no cell sample could
be taken from, so the same table appears twice. That no longer happens: an empty table now contributes no
signature at all (see the table family below), and the data table and the editable
one are measured as two families, `table` and `table-editable`.
Import it in the browser console, or pass it to `browser_evaluate`, screen by
screen, and accumulate the results with `merge()`.
*/
const css = (e) => getComputedStyle(e)
/** The visual signature of every family present on the current screen. */
export function screenSignatures() {
/*
The roots, plural: `<main>` **and every open dialog**.
`Dialog` mounts through `RadixDialog.Portal`, that is outside `main`, so for a
long time this tool **did not see a single piece of dialog content**, while the
whole modal system was being decided. The alert family gave one signature and
looked healthy while the alerts inside modals could drift without anything
saying so.
With no dialog open this returns exactly what it returned before, which was the
condition for not invalidating a baseline already taken.
*/
const roots = [
document.querySelector('main'),
...document.querySelectorAll('[role=dialog], [role=alertdialog]'),
].filter(Boolean)
if (roots.length === 0) roots.push(document.body)
/* A composite `root`: it behaves like a node for what this file asks of it,
which is searching inside. `querySelector` returns the first hit across all
the roots, so the screen title is still the one in `main`, because `main`
goes first in the list. */
const root = {
querySelectorAll: (sel) => roots.flatMap((r) => [...r.querySelectorAll(sel)]),
querySelector: (sel) => {
for (const r of roots) {
const hit = r.querySelector(sel)
if (hit) return hit
}
return null
},
}
const out = {}
const note = (family, signature) => {
out[family] = out[family] ?? new Set()
out[family].add(signature)
}
/* The bordered container: panel, block or form fieldset. */
for (const c of root.querySelectorAll('[class*="_panel_"], [class*="_block_"]')) {
const s = css(c)
const head = c.querySelector('[class*="_ph_"], [class*="_blockTitle_"]')
/* The title is not always an `h2`: in Permissions it is a `span` inside an
`h4`, and in a `fieldset` it is the `legend` itself. */
const t =
head?.tagName === 'LEGEND' ? head : (head?.querySelector('h2, h3') ?? head?.firstElementChild)
note(
'panel',
[
s.boxShadow === 'none' ? 'no-shadow' : 'shadow',
s.borderRadius,
head ? css(head).backgroundColor : 'no-header',
t ? `${css(t).fontSize}/${css(t).fontWeight}/${css(t).textTransform}/${css(t).letterSpacing}` : '-',
].join(' | '),
)
}
/* The table: header and cell density. */
for (const t of root.querySelectorAll('table')) {
const th = t.querySelector('thead th')
/* The sample cell, skipping the "there is nothing" row: its padding is its
own, taller on purpose, and it came out as one more density. */
/* `_noRows_`, and it used to say `_sinFilas_`. The August translation renamed
the class and this filter was not updated with it, so any EMPTY table
(`/dhcp/leases/` on a fresh harness) handed its "No Lease Found" cell over as
the density sample and came out as a fourth table signature that does not
exist. Found while taking a baseline, which is exactly what a baseline is
for. */
const td = [...t.querySelectorAll('tbody td')].find((c) => !/_noRows_/.test(c.className))
if (!th) continue
/*
An EMPTY table says nothing about cell density, and a placeholder where the
cell padding goes was not "nothing": it was a third signature of its own, and it is what `/dhcp/leases/`
handed over on a fresh harness for months. The `_noRows_` filter above was
already right; what was missing was giving up when the filter leaves nothing.
*/
if (!td) continue
/*
And the data table and the EDITABLE table are two objects, not one look of the
same one. `ui/EditableTable` says so itself: "It is a different piece from the
data table and it must be: that one is a screen's main object, with its panel
and its border; this one lives INSIDE a panel, up against its fields".
Measuring them together is the mistake this file warns about elsewhere: a
family that holds two objects cannot tell you that one of them has changed.
Apart, each has one signature and either can drift on its own.
*/
const family = /_editable_/.test(t.className) ? 'table-editable' : 'table'
note(
family,
`th ${css(th).fontSize}/${css(th).fontWeight}/${css(th).letterSpacing}/${css(th).backgroundColor}` +
` · td ${css(td).padding}`,
)
}
/* The actions column: what is left over between the last control and the edge. */
for (const t of root.querySelectorAll('table')) {
const row = t.querySelector('tbody tr')
const last = row ? [...row.querySelectorAll('td')].pop() : null
const group = last?.querySelector('[class*="_actions_"]')
if (!group) continue
note('actions', `${Math.round(t.getBoundingClientRect().right - group.getBoundingClientRect().right)}px from the edge`)
}
/* The count that goes with a table. Its TEXT changes on purpose (the three
vocabularies are upstream literals); its look does not. */
for (const n of root.querySelectorAll('div, span, b')) {
if (n.children.length > 0) continue
/* With the colon and the number: without them, "Total Queries" (the label of
a Dashboard tile) passed for a count and showed up as a separate signature
that did not exist. */
if (!/^(Total [A-Za-z ]+: ?\d|\d+ zones|\d+-\d+ \()/.test((n.textContent || '').trim())) continue
note('count', `${css(n).fontSize}/${css(n).fontWeight}/${css(n).color}`)
}
/*
The form controls. This family was not here, and it was the one that was
missing: the textarea was painted by hand in Settings, DHCP, Administration and
the lists screens (radius 6 instead of 8, one step less in size, without the
inset shadow every other field carries), and neither the screenshots nor the
other families said so, because on each screen, alone, the textarea looked
right.
The signature does not include WIDTH: that one really is per-field (upstream
pins numeric fields at 80-100 px and leaves text fields wide). It includes the
box.
*/
const box = (e) => {
const s = css(e)
return [
s.borderRadius,
s.padding,
s.fontSize,
s.borderWidth,
s.boxShadow === 'none' ? 'no-inset' : 'inset',
].join(' | ')
}
for (const e of root.querySelectorAll('input[type=text], input[type=number], input[type=password], input:not([type])')) {
note('field-text', box(e))
}
for (const e of root.querySelectorAll('textarea')) note('field-area', box(e))
/*
And two NEW families for the control widths, instead of putting the measure
into the ones above.
`box()` leaves the width out on purpose and for a reason: a text field's width
is its own (upstream pins the numeric ones at 80-100 and leaves the text ones
wide), so putting it there would turn every field into its own signature and
bury any drift under the noise. But what the design system decided
(`--ctrl-num` for the default numeric width and `--area-min` for the minimum
height of a textarea) shows up in no existing signature.
So they go apart and narrow: **the width of numeric fields** and **the height
of textareas**. What is measured is what is RENDERED and not what is declared,
which is the lesson that already cost two corrections: the alerts' cap lived on
the container and the fill was decided by the order of the bundle.
The explicit widths the console already has (80, 125, 200, 38, 28) will come out
as signatures of their own, and that is right: they are per-field decisions,
not drift. What is watched is that the DEFAULT is a single one.
*/
/*
The row grid, with **panel and modal measured apart**.
It is not a cosmetic distinction: `ui/Form.module.css` declares it (inside a
modal there is less room and fewer rows: no divider and no 210 px) and it is
tied to the `modal` prop. An earlier audit went as far as calling it drift
**from a grep of loose px values, without opening the file**, and that was
corrected on 2026-09-03.
Measuring them together would repeat that mistake at the level of the
instrument: 210 and 180 would come out as two signatures of one family and look
like an inconsistency. They go apart, and **each must have a single signature**.
What is measured is the RESOLVED `grid-template-columns`, which is what decides
where the help lands: whether the third column fits shows here, and so does
whether it degrades with the width.
*/
for (const e of root.querySelectorAll('[class*="_row_"], [class*="_mrow_"]')) {
const cls = [...e.classList].join(' ')
/* `_mrow_` contains `row`, so the modal is asked about first. */
const where = /_mrow_/.test(cls) ? 'modal' : 'panel'
if (!/_m?row_/.test(cls)) continue
/*
And it has to be the row of `ui/Form`, not just any `.row`.
Measured: `[class*="_row_"]` also caught the `.row` of Cache, Allowed, Blocked
and View Logs, which are not grids, and the family came out with a third
signature, `none`, which is no form grid at all. A family that measures two
different objects cannot tell you whether one of them has changed.
What identifies it is its label: every `Form` row (`Row` and `GroupRow`, modal
or not) paints a direct child `_rowLabel_`/`_mrowLabel_`. That is more stable
than the module hash, which changes with every build.
*/
const ours = [...e.children].some((h) => /_m?rowLabel_/.test([...h.classList].join(' ')))
if (!ours) continue
note(`grid-${where}`, css(e).gridTemplateColumns)
}
/*
A number field in a FORM and one in a table CELL are two objects: the first has
the width its label's grid gives it, the second fills its column. Together they
gave 104 px and 151 px in Settings and looked like drift for months; apart, each
is one width.
*/
for (const e of root.querySelectorAll('input[type=number]')) {
const where = e.closest('td') ? 'cell' : 'form'
note(`field-num-width-${where}`, `${Math.round(e.getBoundingClientRect().width)}px`)
}
/*
The height of a textarea is a FUNCTION of its `rows`: 3 rows gave 66 px and 5
gave 98, and measuring the height reported that as drift when it is somebody
having asked for five rows instead of three.
So what is measured is the FORMULA and not the result (the line and the frame
around it), and `rows` drops out. Both Settings textareas turn out to be
16 px per line inside 18 px of frame, which is one look and not two: that is
checked here rather than declared as an allowed exception.
*/
for (const e of root.querySelectorAll('textarea')) {
const c = css(e)
const frame =
parseFloat(c.paddingTop) + parseFloat(c.paddingBottom) +
parseFloat(c.borderTopWidth) + parseFloat(c.borderBottomWidth)
note('field-area-height', `line ${c.lineHeight} + frame ${Math.round(frame)}px`)
}
for (const e of root.querySelectorAll('select')) note('field-list', box(e))
/*
The checkbox or radio row.
A setting and a row selection are NOT the same family, even though both are an
`input[type=checkbox]` inside a `label`: the setting changes how the server
behaves and stays put, the selection lasts one click. They are measured apart
because otherwise the two legitimate exceptions of the table checkbox (40 px in
the data cell, 0 in the header one, both deliberate and documented in
`ui/Table.module.css`) come out as two more signatures and bury any real drift
in the setting checkboxes under the noise.
*/
for (const e of root.querySelectorAll('input[type=checkbox], input[type=radio]')) {
const row = e.closest('label')
if (!row) continue
const s = css(row)
const inTable = row.closest('td, th') != null
/*
And inside a table the header cell and the data cell are two places, not two
looks of one: `ui/Table.module.css` gives the data one a 40 px hit target and
the header one none on purpose. Told apart, each is a single look and a real
drift in either surfaces on its own.
*/
const cell = row.closest('th') ? 'row-checkbox-th' : 'row-checkbox-td'
note(
inTable ? cell : e.type === 'radio' ? 'radio' : 'settings-checkbox',
`${s.minHeight} | ${s.gap} | ${s.fontSize} | ${s.color}`,
)
}
/*
The "Note!"/"Warning!" alert: same block and same inset inside its panel.
What is measured is the RESULT (how many pixels from the panel its edge sits)
and not the parent's `margin-left`, which was the first attempt and gave a
false difference: in Settings the gap comes from a margin on the alerts
wrapper, and in About from padding on the panel body, i.e. the same place by
two mechanisms.
*/
/*
And they are split BY TYPE, not by the literal title.
The alerts used to be a single family, and so it could not detect the decision
that had to be taken: whether `Warning!` goes filled and `Note!` outlined.
Measured together, two different treatments are two signatures of the same
family and cannot be told apart from drift. Measured apart, **each must end up
with ONE signature**, and that can be checked.
The discriminator is the TYPE class (`_info_`, `_warning_`) and not the text:
the treatment is applied by type, so the measure has to look along the same
axis as the decision. `Note!` is the `info`; `Warning!` is the `warning`.
And the signature includes what it did not look at before: **the fill**
(coloured background against transparent, which is the whole decision) and
**whether it carries an icon**. With the old signature, `borderRadius | inset`,
an alert could go from filled to outlined without any family moving.
*/
for (const e of root.querySelectorAll('[role=note], [class*="_alert"]')) {
const classes = [...e.classList].join(' ')
const type = ['info', 'warning', 'success', 'danger'].find((t) => classes.includes(`_${t}_`)) ?? 'no-type'
const panel = e.closest('[class*="_panel_"], [class*="_block_"]')
const inset = panel
? `${Math.round(e.getBoundingClientRect().left - panel.getBoundingClientRect().left)}px from the panel`
: 'loose on the page'
const s = css(e)
/* Transparent or unpainted counts as OUTLINED; anything else, as filled.
`rgba(0, 0, 0, 0)` is what an undeclared background returns. */
const fill = /^(transparent|rgba\(0, 0, 0, 0\))$/.test(s.backgroundColor) ? 'outlined' : 'filled'
const icon = e.querySelector('svg') ? 'with-icon' : 'no-icon'
/*
And the WIDTH CAP, for the same reason the fill was added: a six-hundred-
character alert reads badly if the line is too long, that is the decision
`--notice-max` records, and the signature did not see it. What is measured is
the width REALLY PAINTED and not the alert's own `max-width`, and the first
version got exactly that wrong: the cap is declared on the CONTAINER
(`.notices`), so the alert still says `max-width: none` and the signature
would have seen nothing. It is the same mistake already made with the fill,
and that is why it is fixed by looking at the result instead of the
declaration.
It depends on the window width, yes; that is why the sweep is always taken at
1440.
*/
note(
`notice-${type}`,
`${fill} | ${icon} | width ${Math.round(e.getBoundingClientRect().width)}px | ${s.borderRadius} | ${inset}`,
)
}
/* The screen title. */
const h1 = root.querySelector('h1')
if (h1) note('title', `${css(h1).fontSize}/${css(h1).fontWeight}/${css(h1).letterSpacing}`)
/*
And the gap under that title, which had also drifted apart: six screens had it
at 38 px and twelve at 24, because those six put the header inside a `flex`
container with `gap`, and in flex the gap ADDS to the child's margin. It is
measured against the next sibling and not against "the first bordered box",
which was the first attempt: that `find()` picked different elements on
different screens and gave two false measurements in a row.
*/
const header = h1?.closest('[class*="_hrow_"]')
const next = header?.nextElementSibling
if (header && next) {
note(
'gap-under-the-title',
`${Math.round(next.getBoundingClientRect().top - header.getBoundingClientRect().bottom)}px`,
)
}
return Object.fromEntries(Object.entries(out).map(([k, v]) => [k, [...v]]))
}
/*
How many looks each family is ALLOWED to have, and why.
Without this the report was a list of fifteen families to read and judge, four of
them permanently split for reasons nobody reread, and a tool that always shows
four problems teaches you to skim it. That is not hypothetical: the panel lost its
shadow on three screens on 2026-09-07 and the only thing that said so was a
fifth split appearing among the four.
So the number is declared. One look unless stated, and a family that gains one is
a finding whatever its number was.
*/
const EXPECTED = {
/*
Empty, and that is the point: every family that used to need an exception here
was a family measuring two objects at once, and each has been split into the two
it was really holding. An entry in this map is a debt, not a feature: it says
"these two looks are both right and nobody will ever tell them apart again".
*/
}
/** Accumulates the signatures of several screens into a single report. */
export function merge(reports) {
const total = {}
for (const { route, signatures } of reports) {
for (const [family, list] of Object.entries(signatures)) {
total[family] = total[family] ?? {}
for (const f of list) {
total[family][f] = total[family][f] ?? []
total[family][f].push(route)
}
}
}
return Object.entries(total).map(([family, signatures]) => {
const many = Object.keys(signatures).length
const allowed = EXPECTED[family] ?? 1
return {
family,
count: many,
expected: allowed,
/* A family with FEWER looks than declared is a finding too: the reason
above has stopped being true and nobody will notice by reading it. */
finding: many === allowed ? null : many > allowed ? 'gained a look' : 'lost one, the reason is stale',
signatures: Object.entries(signatures).map(([f, routes]) => `${f} → ${routes.join(', ')}`),
}
})
}
/* In a browser it also hangs itself on `window`, so `screenSignatures()` works
as a bare call once the module has been imported. */
if (typeof window !== 'undefined') Object.assign(window, { screenSignatures, merge })
+313
View File
@@ -0,0 +1,313 @@
/*
Regression tests for the uniformity tool.
It had none, and that is why `_sinFilas_` survived the August translation to
English for weeks: the class the filter looked for stopped existing, and nothing
said so until a baseline was taken and `/dhcp/leases/` (an empty table on a
fresh harness) handed over its "No Lease Found" cell as the density sample
and produced a FOURTH table signature that does not exist.
A tool that measures drift and is not itself measured is a tool that will drift.
*/
import { describe, expect, it, beforeEach } from 'vitest'
import { screenSignatures, merge } from './uniformity.js'
/** A table like the console's: a header, and whatever rows are given. */
function table(rows) {
const main = document.createElement('main')
main.innerHTML = `
<table>
<thead><tr><th style="font-size:10.5px;font-weight:600">Zone</th></tr></thead>
<tbody>${rows}</tbody>
</table>`
document.body.replaceChildren(main)
return main
}
const WITH_ROWS = '<tr><td class="_cell_abc" style="padding:9px 10px">home.test</td></tr>'
/* The empty row. The class carries its hash suffix exactly as CSS modules emit
it, because that is what the filter has to survive. */
const NO_ROWS = '<tr><td class="_noRows_1arwr" style="padding:24px 10px">No Lease Found</td></tr>'
describe('the empty row is not a density sample', () => {
beforeEach(() => document.body.replaceChildren())
/*
THE ONE THIS FILE EXISTS FOR, and its contract changed on 2026-09-07: an empty
table used to sign with a placeholder where the cell padding goes, and that is
not "nothing": it is a signature of its own, and `/dhcp/leases/` on a fresh
harness handed it over as a third table look for months. A table with no data cell now contributes NO signature at all.
The other half of the guard stays the same: if the `_noRows_` filter ever stops
recognising the class, the padding of the empty row leaks in and invents one.
*/
it('an empty table contributes no signature, not even one with a placeholder cell', () => {
table(NO_ROWS)
expect(screenSignatures().table).toBeUndefined()
})
it('a table with rows does contribute its own', () => {
table(WITH_ROWS)
expect(screenSignatures().table[0]).toContain('td 9px 10px')
})
/* The mix, which is the case of `/dhcp/leases/` when it DOES have leases: the
good cell sits behind the empty one and must not lose to it. */
it('with both, the data cell wins and not the empty one', () => {
table(NO_ROWS + WITH_ROWS)
const [signature] = screenSignatures().table
expect(signature).toContain('td 9px 10px')
expect(signature).not.toContain('24px')
})
/*
And the test that really closes the hole: the class is read from the CODE, not
from this constant. If someone renames it again (as happened in the
translation), this fails here and not in a baseline taken six weeks later.
*/
it('the class the filter skips is the one the code uses today', async () => {
/* From the project root and not from `import.meta.url`: under jsdom that URL
is not `file:` and `fileURLToPath` throws. */
const { readFileSync } = await import('node:fs')
const { join } = await import('node:path')
const root = process.cwd()
const tableSource = readFileSync(join(root, 'src/ui/Table.tsx'), 'utf8')
const tool = readFileSync(join(root, 'dev/uniformity.js'), 'utf8')
const cls = tableSource.match(/className=\{styles\.(\w*[Nn]o[Rr]ows\w*)\}/)?.[1]
expect(cls, 'Table.tsx no longer paints a recognisable empty-row class').toBeTruthy()
/*
Against the FILTER LINE, not against the whole file. The first version of this
test looked at all of `uniformity.js` and passed even with the filter broken,
because the right name appeared in a comment. A test that a comment can satisfy
proves nothing, and the only reason it was noticed is that it was tested in
the negative.
*/
const filter = tool.match(/const td = .*\n?.*!\/(\S+?)\/\.test/)?.[1]
?? tool.split('\n').find((l) => l.includes('.find((c) =>'))
expect(
filter,
`the empty-row filter does not skip the class Table.tsx uses today ("${cls}")`,
).toContain(`_${cls}_`)
})
})
describe('merge groups by family without losing which route each signature comes from', () => {
it('joins the same signature from two routes and names them', () => {
const report = merge([
{ route: '/zones/', signatures: { table: ['A'] } },
{ route: '/cache/', signatures: { table: ['A'] } },
{ route: '/dhcp/leases/', signatures: { table: ['B'] } },
])
const t = report.find((f) => f.family === 'table')
expect(t.count).toBe(2)
expect(t.signatures).toContain('A → /zones/, /cache/')
expect(t.signatures).toContain('B → /dhcp/leases/')
})
})
/*
Portals, and alerts split by type.
Two holes at once. `Dialog` mounts through a portal, outside `<main>`, so for a
long time this tool **did not see a single piece of dialog content**, while the
whole modal system was being decided. And the alert family measured
`borderRadius | inset`, so an alert could go from filled to outlined **without any
family moving**: exactly the decision that had to be taken was invisible to the
guard meant to protect it.
*/
function alert({ type, background, icon = false, inDialog = false }) {
const main = document.createElement('main')
const box = `<div class="_alert_x _${type}_y" style="background:${background};border-radius:8px">
${icon ? '<svg aria-hidden="true"></svg>' : ''}<b>${type}!</b> text
</div>`
if (inDialog) {
main.innerHTML = ''
const d = document.createElement('div')
d.setAttribute('role', 'dialog')
d.innerHTML = box
document.body.replaceChildren(main, d)
} else {
main.innerHTML = box
document.body.replaceChildren(main)
}
}
describe('the guard sees portals and splits alerts by type', () => {
beforeEach(() => document.body.replaceChildren())
/*
THE INVARIANT. Widening the tool must not move what was already measured: the
baseline was taken with no dialog open, and it has to stay valid.
*/
it('with no dialog open it only looks at <main>, as before', () => {
const main = document.createElement('main')
main.innerHTML = '<div class="_alert_x _info_y" style="background:#123">a</div>'
const outside = document.createElement('div')
outside.innerHTML = '<div class="_alert_x _warning_y" style="background:#456">b</div>'
document.body.replaceChildren(main, outside)
const f = screenSignatures()
expect(Object.keys(f)).toContain('notice-info')
expect(Object.keys(f)).not.toContain('notice-warning')
})
it('an alert inside a dialog IS measured now', () => {
alert({ type: 'warning', background: '#456', inDialog: true })
expect(screenSignatures()['notice-warning']).toHaveLength(1)
})
it('info and warning land in different families, not in one', () => {
const main = document.createElement('main')
main.innerHTML =
'<div class="_alert_x _info_y" style="background:#123">a</div>' +
'<div class="_alert_x _warning_y" style="background:#456">b</div>'
document.body.replaceChildren(main)
const f = screenSignatures()
expect(f['notice-info']).toHaveLength(1)
expect(f['notice-warning']).toHaveLength(1)
expect(f.notice).toBeUndefined()
})
/* The measure that was missing: with the old signature an alert could go from
filled to outlined without anything moving. */
it('tells filled from outlined', () => {
alert({ type: 'info', background: '#123' })
expect(screenSignatures()['notice-info'][0]).toContain('filled')
alert({ type: 'info', background: 'transparent' })
expect(screenSignatures()['notice-info'][0]).toContain('outlined')
})
/* The width cap: the third thing the signature did not see. Without this,
`--notice-max` could be applied (or stop being applied) without anything
moving. */
it('the signature includes the painted width', () => {
const main = document.createElement('main')
main.innerHTML = '<div class="_alert_x _info_y" style="background:#123;max-width:880px">a</div>'
document.body.replaceChildren(main)
/* jsdom does no layout, so the width comes out as 0: what this test pins is
that the signature CARRIES the width, not how much it measures. The number
is checked in the sweep, against the real page and at 1440. */
expect(screenSignatures()['notice-info'][0]).toMatch(/width \d+px/)
main.innerHTML = '<div class="_alert_x _info_y" style="background:#123">a</div>'
document.body.replaceChildren(main)
expect(screenSignatures()['notice-info'][0]).toMatch(/width \d+px/)
})
it('and detects the icon', () => {
alert({ type: 'warning', background: '#456', icon: true })
expect(screenSignatures()['notice-warning'][0]).toContain('with-icon')
alert({ type: 'warning', background: '#456', icon: false })
expect(screenSignatures()['notice-warning'][0]).toContain('no-icon')
})
})
/*
The two control-width families. They measure what is RENDERED (not what is
declared) because that is the lesson that already cost two corrections in a single
day: the alerts' cap lived on the container, and the fill was decided by the order
of the bundle. Looking at the declaration would have come out green both times.
*/
describe('control widths', () => {
beforeEach(() => document.body.replaceChildren())
const withFields = (html) => {
const main = document.createElement('main')
main.innerHTML = html
document.body.replaceChildren(main)
}
/* The numeric field of a FORM and that of a CELL are two objects: the first has
the width its label's grid gives it and the second fills its column. Together
they gave 104 px and 151 px in Settings and looked like drift. */
it('separates the form numeric field from the cell one, and does not measure text fields', () => {
withFields('<input type="number"><input type="text">')
const f = screenSignatures()
expect(f['field-num-width-form']).toHaveLength(1)
expect(f['field-num-width-form'][0]).toMatch(/^\d+px$/)
expect(f['field-num-width-cell']).toBeUndefined()
})
it('the numeric field inside a cell lands in the other family', () => {
withFields('<table><tbody><tr><td><input type="number"></td></tr></tbody></table>')
const f = screenSignatures()
expect(f['field-num-width-cell']).toHaveLength(1)
expect(f['field-num-width-form']).toBeUndefined()
})
/* A textarea's height is `rows` times the line plus the frame, so measuring the
height turned somebody asking for five rows instead of three into drift. What
is measured is the formula, and `rows` drops out. */
it('measures the textarea formula and not its height', () => {
withFields('<textarea rows="3"></textarea><textarea rows="5"></textarea>')
const f = screenSignatures()['field-area-height']
expect(f).toHaveLength(1)
expect(f[0]).toMatch(/^line .* \+ frame \d+px$/)
})
})
/*
The row grid, panel and modal APART.
`Form` tells the two apart on purpose (210 px in the dense form, 180 in the tighter
space of the dialog) and it says so in `ui/Form.module.css`. An earlier audit
called it drift from a grep, without opening the file, and it had to be corrected.
Measuring them together would have built that confusion into the instrument.
*/
describe('the row grid is measured by context', () => {
beforeEach(() => document.body.replaceChildren())
it('separates the panel row from the modal one', () => {
const main = document.createElement('main')
main.innerHTML =
'<div class="_row_a" style="grid-template-columns:210px 1fr">' +
'<span class="_rowLabel_a"></span></div>' +
'<div class="_mrow_b" style="grid-template-columns:180px 1fr">' +
'<span class="_mrowLabel_b"></span></div>'
document.body.replaceChildren(main)
const f = screenSignatures()
expect(f['grid-panel']).toHaveLength(1)
expect(f['grid-modal']).toHaveLength(1)
})
/* `_mrow_` contains the substring `row`: if the question were asked the other way
round, every modal row would count as a panel row and the two families would
mix. */
it('does not mistake a modal row for a panel row', () => {
const main = document.createElement('main')
main.innerHTML =
'<div class="_mrow_b" style="grid-template-columns:180px 1fr">' +
'<span class="_mrowLabel_b"></span></div>'
document.body.replaceChildren(main)
const f = screenSignatures()
expect(f['grid-modal']).toHaveLength(1)
expect(f['grid-panel']).toBeUndefined()
})
/*
And the `.row` of ANOTHER module is not a form row.
Measured in the console: Cache, Allowed, Blocked and View Logs have their own
`.row`, which is not a grid, and the family came out with a `none` signature
that is no form's grid. What tells the two apart is the label, which every
`Form` row paints as a direct child.
*/
it("ignores another module's `.row`, which is not a form row", () => {
const main = document.createElement('main')
main.innerHTML =
'<div class="_row_a" style="grid-template-columns:210px 1fr">' +
'<span class="_rowLabel_a"></span></div>' +
'<div class="_row_zzz" style="display:flex"></div>'
document.body.replaceChildren(main)
const f = screenSignatures()
expect(f['grid-panel']).toEqual(['210px 1fr'])
})
})
+52
View File
@@ -0,0 +1,52 @@
/**
* The update check (`update-check/`), served from this machine for the harness.
*
* Cloudflare runs it in production. Here it runs under Node, with the same
* handler, so that the harness's DNS server can be pointed at it:
* `http://<docker gateway>:8853/dns-query`.
*
* `UPDATE_CHECK_FAKE='v=1 latest=9.9.9 server=15.5'` answers that record instead
* of reading GitHub, to see the console's notice without a real release.
*
* Run it with `node --experimental-strip-types dev/update-check-serve.mjs`.
*/
import { createServer } from 'node:http'
import { handle, lookup } from '../update-check/src/handler.ts'
const PORT = 8853
function fakeRelease(text) {
const fields = Object.fromEntries(text.split(' ').map((pair) => pair.split('=')))
return { latest: fields.latest, server: fields.server }
}
const memory = new Map()
const store = {
async get(key) {
const hit = memory.get(key)
return hit && hit.until > Date.now() ? hit.value : null
},
async put(key, value, ttlSeconds) {
memory.set(key, { value, until: Date.now() + ttlSeconds * 1000 })
},
}
const fake = process.env.UPDATE_CHECK_FAKE
const find = fake ? async () => fakeRelease(fake) : () => lookup(fetch, store)
createServer((req, res) => {
const chunks = []
req.on('data', (chunk) => chunks.push(chunk))
req.on('end', async () => {
const url = new URL(req.url ?? '/', `http://${req.headers.host}`)
if (url.pathname !== '/dns-query') {
res.writeHead(404).end()
return
}
const body = req.method === 'POST' ? Buffer.concat(chunks) : undefined
const response = await handle(new Request(url, { method: req.method, headers: req.headers, body }), find)
res.writeHead(response.status, Object.fromEntries(response.headers))
res.end(Buffer.from(await response.arrayBuffer()))
console.log(new Date().toISOString(), req.method, response.status)
})
}).listen(PORT, '0.0.0.0', () => console.log(`update check on :${PORT}${fake ? ` (fake: ${fake})` : ''}`))