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 <noreply@anthropic.com>
124 lines
5.0 KiB
JavaScript
124 lines
5.0 KiB
JavaScript
/**
|
||
* 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)
|
||
}
|