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:
commit
f3531c64f9
680 files changed
+139836
No files matched your search
@@ -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
@@ -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.
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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',
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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`])
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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))
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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`])
|
||||
})
|
||||
})
|
||||
@@ -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¬ify=ZoneNameServers¬ifyNameServers=false¬ifySecondaryCatalogsNameServers=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
|
||||
@@ -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(/&/g, '&')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'|'/g, "'")
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/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(/"|'|'/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(/ /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(/ /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,
|
||||
)
|
||||
@@ -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))
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
@@ -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."
|
||||
@@ -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))
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
@@ -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:
|
||||
@@ -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
|
||||
@@ -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`)
|
||||
@@ -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
@@ -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")
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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/)
|
||||
})
|
||||
})
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
@@ -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'}`)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
@@ -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 })
|
||||
@@ -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'])
|
||||
})
|
||||
})
|
||||
@@ -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})` : ''}`))
|
||||
Reference in new issue
Block a user