nimiq-branding-cli 1.2.0 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LINT.md +46 -1
- package/align/canonical.json +2 -1
- package/bin/nq.js +16 -2
- package/package.json +1 -1
- package/scripts/_browser.mjs +27 -0
- package/scripts/align.mjs +3 -0
- package/scripts/check.mjs +0 -0
- package/scripts/hooks.mjs +24 -1
- package/scripts/lint.mjs +185 -3
- package/scripts/verify.mjs +2 -2
- package/test/align.test.mjs +26 -2
- package/test/check.test.mjs +125 -0
package/LINT.md
CHANGED
|
@@ -36,6 +36,12 @@ These are the "make us not do AI things" set.
|
|
|
36
36
|
| borders on inputs | skill rule 1 | manual (inset box-shadow) |
|
|
37
37
|
| off-palette colors | skill rule 13 | manual |
|
|
38
38
|
| low-contrast blue/navy text on dark | skill rule 20 | manual → `#0CA6FE` / white |
|
|
39
|
+
| generic icon set (Lucide / Font Awesome / Material / Feather / Bootstrap…) | skill Icons | manual → `nq-icon` SVGs |
|
|
40
|
+
| duplicate gradient id with **differing** stops | skill rule 3 | manual → unique ids |
|
|
41
|
+
| pure-black surface (all channels ≤12, ≥4000px²) | skill rule 6 | manual → navy `#1F2348` |
|
|
42
|
+
| one-sided accent stripe on a card | skill rule 19 | manual → uniform border |
|
|
43
|
+
|
|
44
|
+
**Generic icon set** is a class-signature match (`lucide-`, `fa-solid`, `material-icons`, `bi-`, `ph-`, `data-lucide`, a `material-icons` ligature span, …) — verified zero hits in Nimiq's own components, so any hit is someone dropping in a foreign icon library. **Duplicate gradient id** only fires when two `<linearGradient>`/`<radialGradient>` defs share an id but have **different stops** — the real "later SVG paints the wrong fill" bug. nimiq.com repeats *identical* icon gradients under one id (Figma export) and renders fine, so raw id-collision is deliberately not flagged.
|
|
39
45
|
|
|
40
46
|
**Blue/navy on dark (rule 20)** is the headline a11y+brand check. A blue-family foreground (hue
|
|
41
47
|
195–245°) sitting on a dark surface (luminance < 0.12) below the AA floor — **4.5:1 normal, 3.0:1
|
|
@@ -73,6 +79,44 @@ to flag *"this is getting busy, is it justified?"* — the deterministic half of
|
|
|
73
79
|
| underlined links | a body `<a>` with `text-decoration: underline` (links are bold, no underline) |
|
|
74
80
|
| NIM address not in Fira Mono | a 4-char-grouped address rendered in a proportional font |
|
|
75
81
|
| gold-tinted UI icon | a non-logo icon tinted gold (gold = brand mark only) |
|
|
82
|
+
| feather/lucide-style stroke icon | an inlined SVG with `viewBox 0 0 24 24` + every path `stroke-width: 2` + `fill: none` (the generic-set default Nimiq never ships — its 24×24 `close` is a *filled* path) |
|
|
83
|
+
| green action/retry button | a button labelled retry/continue/submit/get-started filled green (green = success only, rule 5) |
|
|
84
|
+
| baked-color icon | an icon-role SVG (small, square, not identicon/flag/chart/logo, no gradient def) with a literal hex `fill`/`stroke` instead of `currentColor` |
|
|
85
|
+
| black-outline duotone icon | an icon-role SVG with a near-black layer *and* a colored layer (duotone = the same color at `opacity: 0.4`, never a black outline) |
|
|
86
|
+
| colored glow shadow | a `box-shadow`/`drop-shadow` whose color is saturated and bright (lum > 0.15) — Nimiq elevation is dark navy/black, so its real shadows are excluded |
|
|
87
|
+
| black modal overlay | a full-viewport `position: fixed` translucent scrim that's near-neutral black (navy `b≫r` excluded) — rule 11 |
|
|
88
|
+
| flat-fill navy/colored section | a full-width band (≥0.7vw, ≥220px) solid-filled in a brand color with **no** gradient — colored bands use the bottom-right radial (rule 7) |
|
|
89
|
+
|
|
90
|
+
**Accessibility**
|
|
91
|
+
|
|
92
|
+
| Check | Threshold |
|
|
93
|
+
|---|---|
|
|
94
|
+
| focus outline removed w/o `:focus-visible` | a stylesheet rule kills `outline` on a focusable/global selector and **no** `:focus-visible` rule anywhere restores a ring (cross-origin sheets are skipped, never guessed) |
|
|
95
|
+
| Mulish not loaded | a declared `Mulish` `@font-face` that didn't load (`document.fonts.check` false) → the page silently falls back to a system font |
|
|
96
|
+
|
|
97
|
+
**Headline typography & line breaks** (closes the "lint passes ≠ brand-correct" gap)
|
|
98
|
+
|
|
99
|
+
The deterministic checks above read color, spacing and shape — but a heading can satisfy all of
|
|
100
|
+
them and still read as generic SaaS through *weight* and *tracking*, the exact "faux-weight or
|
|
101
|
+
tightly-tracked black headlines" the skill blacklist names. These read the rendered type instead.
|
|
102
|
+
Measured on nimiq.com: every heading renders at weight **600/700**, `letter-spacing: 0`, and
|
|
103
|
+
`text-wrap: balance`; nothing on the page strands a single word. So:
|
|
104
|
+
|
|
105
|
+
| Check | Threshold | Why |
|
|
106
|
+
|---|---|---|
|
|
107
|
+
| faux-black headline weight | heading ≥ 24px at `font-weight ≥ 800` | nimiq.com tops out at 700; 800+ "black" is the generic tell |
|
|
108
|
+
| tight negative heading tracking | heading ≥ 24px with `letter-spacing ≤ -0.01em` | nimiq.com headings are `0`; negative tracking cramps the headline |
|
|
109
|
+
| wrapping heading w/o `text-wrap: balance` | a heading ≥ 20px that wraps to ≥ 2 lines without `balance`/`pretty` | how nimiq.com keeps every headline evenly split |
|
|
110
|
+
| orphaned last word | any text ≥ 20px that wraps with a **single word alone** on the last line | nimiq.com has **zero**; a stranded word (the circled "verified") is the defect |
|
|
111
|
+
| heading line-height off | heading ≥ 24px with `line-height/font-size ≤ 1.05` or `≥ 1.5` | nimiq.com headings render **1.25–1.3**; 1.0 cramps, 1.6 floats |
|
|
112
|
+
| cramped body line-height | body copy with `line-height/font-size < 1.35` | nimiq.com body is **1.5–1.56**; tighter hurts readability |
|
|
113
|
+
| non-brand font on text | a text element whose first font family isn't Mulish/Muli/Fira (≥3 uses) | nimiq.com is **Mulish** + Fira Mono for data; an Inter/Roboto headline reads instantly off |
|
|
114
|
+
|
|
115
|
+
`--fix` also **injects the `text-wrap` rule** (`h1–h4 { balance } p,li { pretty }`) once, before `</head>` — the mechanical cure for orphaned titles, applying the exact technique nimiq.com uses on every heading.
|
|
116
|
+
|
|
117
|
+
Weight/tracking are heading-scoped (buttons and bold body labels are legitimately heavy). The
|
|
118
|
+
orphan check is general (it caught a body banner, not a heading) but gated at ≥ 20px so calibrated
|
|
119
|
+
fine-print under 20px — which wasn't measured on the reference — is never flagged.
|
|
76
120
|
|
|
77
121
|
**Mobile (a second pass at 390px)**
|
|
78
122
|
|
|
@@ -122,7 +166,8 @@ same "measure reality" method that proved the no-em-dash rule. Verified outcome:
|
|
|
122
166
|
|
|
123
167
|
| Page | ERRORS | Note |
|
|
124
168
|
|---|---|---|
|
|
125
|
-
| nimiq.com home (reference) | **0** | the gate must never flag the brand's own site, even with the rule-20 contrast
|
|
169
|
+
| nimiq.com home (reference) | **0** | the gate must never flag the brand's own site, even with the rule-20 contrast, depth/motion + headline-typography checks |
|
|
170
|
+
| nimiq.com headline type | **0** | measured directly: weights 600/700, `letter-spacing: 0`, every heading `text-wrap: balance`, zero stranded words |
|
|
126
171
|
| nimiq.com/about | 0 | — |
|
|
127
172
|
| nimiq.tech | real findings | catches genuine em-dashes + a "Phase 2 · preview" colored eyebrow + 3 dense bands |
|
|
128
173
|
| positive control | **2** | a deliberate `#0582CA`/`#265DD7`-on-navy page errors at 3.63/2.61:1; `#0CA6FE` + white pass |
|
package/align/canonical.json
CHANGED
|
@@ -38,7 +38,8 @@
|
|
|
38
38
|
"settlementPackage": "nimiq-settlement",
|
|
39
39
|
"lightClient": {
|
|
40
40
|
"forbiddenImports": ["@nimiq/core/web"],
|
|
41
|
-
"forbiddenCalls": ["Client.create("
|
|
41
|
+
"forbiddenCalls": ["Client.create("],
|
|
42
|
+
"note": "forbiddenCalls is intentionally ONLY `Client.create(` (constructing the @nimiq/core light client). `waitForConsensusEstablished(` is NOT forbidden: the canonical rpc-block-scan client implements it as a NimiqClientLike interface method (RPC poll of isConsensusEstablished), so flagging it would punish the correct pattern.",
|
|
42
43
|
"reason": "The @nimiq/core light client never reaches consensus on our hosts (WASM addEventListener bug, hangs in Bun/Node through 2.7.0). Chain reads MUST use the rpc-block-scan path via the nimiq-settlement package; @nimiq/core is offline-crypto-only (key/tx signing)."
|
|
43
44
|
}
|
|
44
45
|
}
|
package/bin/nq.js
CHANGED
|
@@ -33,6 +33,12 @@ Usage:
|
|
|
33
33
|
--no-chain chainApp:false (skip settlement + styling parity)
|
|
34
34
|
--settlement mock|rpc|noop settlement client (default mock)
|
|
35
35
|
--deploy fly|none deploy kit (default fly)
|
|
36
|
+
nq check [path] Run the FULL per-project alignment gate in one shot:
|
|
37
|
+
align (--fail-on=settlement,styling) + 800-line file
|
|
38
|
+
guard + bun test (if present) + nq lint (if Playwright).
|
|
39
|
+
Prints a PASS/FAIL/SKIP summary; exits nonzero on any
|
|
40
|
+
FAIL (SKIP is fine). --json for machine output. This is
|
|
41
|
+
what a nightly loop / pre-push hook should run.
|
|
36
42
|
nq align [path] Grade an app's stack vs the canonical fleet baseline.
|
|
37
43
|
--all <dir> Grade every app dir under <dir>
|
|
38
44
|
--fix Safe autofixes only (write/repair nimiq-stack.json)
|
|
@@ -41,7 +47,8 @@ Usage:
|
|
|
41
47
|
SETTLEMENT is load-bearing: any @nimiq/core/web import or
|
|
42
48
|
Client.create( / waitForConsensusEstablished( in src HARD FAILS.
|
|
43
49
|
nq hooks install [repo] Install the drift hooks: git pre-commit (align --fail-on),
|
|
44
|
-
|
|
50
|
+
git pre-push (the fuller nq check gate), SessionStart banner,
|
|
51
|
+
weekly GH Action (--write drops the workflow)
|
|
45
52
|
nq verify <component|all> Render the html variant and diff against the reference PNG
|
|
46
53
|
nq lint <file.html|url> Render a page and enforce the brand rules + breathability.
|
|
47
54
|
--fix Auto-fix the safe text violations in a local file (dashes, title periods)
|
|
@@ -382,8 +389,10 @@ async function cmdHooks(sub, flags) {
|
|
|
382
389
|
}
|
|
383
390
|
if (sub === 'show' || !sub) {
|
|
384
391
|
console.log('# git pre-commit (.git/hooks/pre-commit):\n');
|
|
385
|
-
const { PRE_COMMIT } = await import(join(ROOT, 'scripts', 'hooks.mjs'));
|
|
392
|
+
const { PRE_COMMIT, PRE_PUSH } = await import(join(ROOT, 'scripts', 'hooks.mjs'));
|
|
386
393
|
console.log(PRE_COMMIT);
|
|
394
|
+
console.log('# git pre-push (.git/hooks/pre-push):\n');
|
|
395
|
+
console.log(PRE_PUSH);
|
|
387
396
|
console.log('# SessionStart advisory:\n');
|
|
388
397
|
console.log(SESSION_START);
|
|
389
398
|
console.log('# weekly GH Action (.github/workflows/stack-align.yml):\n');
|
|
@@ -422,6 +431,11 @@ try {
|
|
|
422
431
|
case 'new':
|
|
423
432
|
case 'new-component': await cmdNewComponent(rest[0], flags); break;
|
|
424
433
|
case 'new-app': await cmdNew(rest[0], flags); break;
|
|
434
|
+
case 'check': {
|
|
435
|
+
const { run } = await import(join(ROOT, 'scripts', 'check.mjs'));
|
|
436
|
+
await run(rest, flags);
|
|
437
|
+
break;
|
|
438
|
+
}
|
|
425
439
|
case 'align': {
|
|
426
440
|
const { run } = await import(join(ROOT, 'scripts', 'align.mjs'));
|
|
427
441
|
await run(rest, flags);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nimiq-branding-cli",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.0",
|
|
4
4
|
"description": "nq — pixel-verified Nimiq UI component registry + CLI. 39 components (Vue 3 + plain HTML) diffed against the real Nimiq apps, plus the team's real asset library. Unofficial community tool.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// Shared browser bootstrap for `nq lint` and `nq verify`.
|
|
2
|
+
// Playwright is intentionally NOT a runtime dependency (it pulls a ~hundreds-of-MB browser),
|
|
3
|
+
// so we load it lazily and, if it's missing, print a one-line fix instead of a raw stack
|
|
4
|
+
// trace. Exit code 2 = "setup needed", distinct from lint's 1 = violations / 0 = clean.
|
|
5
|
+
export async function launchChromium(cmd = 'this command') {
|
|
6
|
+
let chromium;
|
|
7
|
+
try {
|
|
8
|
+
({ chromium } = await import('playwright'));
|
|
9
|
+
} catch {
|
|
10
|
+
console.error(
|
|
11
|
+
`\nnq: ${cmd} needs Playwright (a headless browser) to render pages.\n` +
|
|
12
|
+
`It isn't bundled, to keep installs light. One-time setup:\n\n` +
|
|
13
|
+
` npm i -g playwright && npx playwright install chromium\n\n` +
|
|
14
|
+
`Then re-run. Only \`nq lint\` and \`nq verify\` need it — the rest of nq works without it.\n`,
|
|
15
|
+
);
|
|
16
|
+
process.exit(2);
|
|
17
|
+
}
|
|
18
|
+
try {
|
|
19
|
+
return await chromium.launch();
|
|
20
|
+
} catch (e) {
|
|
21
|
+
if (/Executable doesn't exist|playwright install|please run the following/i.test(String(e))) {
|
|
22
|
+
console.error(`\nnq: Playwright is installed but its browser isn't. Run:\n\n npx playwright install chromium\n`);
|
|
23
|
+
process.exit(2);
|
|
24
|
+
}
|
|
25
|
+
throw e;
|
|
26
|
+
}
|
|
27
|
+
}
|
package/scripts/align.mjs
CHANGED
|
@@ -70,6 +70,9 @@ async function scanLightClient(appDir, canonical) {
|
|
|
70
70
|
const full = join(dir, e.name);
|
|
71
71
|
if (e.isDirectory()) { await walk(full); continue; }
|
|
72
72
|
if (!CODE_EXT.test(e.name)) continue;
|
|
73
|
+
// Test/spec files legitimately reference the forbidden strings (fixtures, mocks, the
|
|
74
|
+
// detector's own cases) and are not shipped runtime code — never grade them.
|
|
75
|
+
if (/\.(test|spec)\./.test(e.name)) continue;
|
|
73
76
|
let text;
|
|
74
77
|
try { text = await readFile(full, 'utf8'); } catch { continue; }
|
|
75
78
|
const rel = full.slice(appDir.length + 1);
|
|
Binary file
|
package/scripts/hooks.mjs
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
//
|
|
3
3
|
// git pre-commit → `nq align --fail-on=settlement,styling` (blocks a commit that
|
|
4
4
|
// introduces the broken light-client path or off-brand styling)
|
|
5
|
+
// git pre-push → `nq check --fail-on=settlement,styling` (the fuller gate — align
|
|
6
|
+
// + 800-line guard + bun test + lint — run before code leaves the box)
|
|
5
7
|
// SessionStart → `nq align --quiet` advisory drift banner (one line)
|
|
6
8
|
// weekly GH Action → `nq align --all` → PR safe fixes / file a rolling drift issue,
|
|
7
9
|
// reusing the existing nq audit weekly machinery.
|
|
@@ -29,6 +31,19 @@ else
|
|
|
29
31
|
fi
|
|
30
32
|
`;
|
|
31
33
|
|
|
34
|
+
export const PRE_PUSH = `#!/usr/bin/env bash
|
|
35
|
+
# nq check pre-push gate — installed by \`nq hooks install\`.
|
|
36
|
+
# Runs the FULL per-project gate before code leaves the box: align
|
|
37
|
+
# (settlement/styling) + the 800-line file guard + bun test + nq lint.
|
|
38
|
+
# Blocks the push on any FAIL (SKIPs — e.g. no Playwright — are fine).
|
|
39
|
+
set -euo pipefail
|
|
40
|
+
if command -v nq >/dev/null 2>&1; then
|
|
41
|
+
nq check --fail-on=settlement,styling
|
|
42
|
+
else
|
|
43
|
+
npx -y github:Andjroo111/nimiq-branding-cli check --fail-on=settlement,styling
|
|
44
|
+
fi
|
|
45
|
+
`;
|
|
46
|
+
|
|
32
47
|
export const SESSION_START = `# nq align — SessionStart advisory drift banner.
|
|
33
48
|
# Add to .claude/settings.json hooks.SessionStart, or run at the top of a session.
|
|
34
49
|
nq align --quiet 2>/dev/null || true
|
|
@@ -124,8 +139,14 @@ export async function installHooks(target, opts = {}) {
|
|
|
124
139
|
await writeFile(hookPath, PRE_COMMIT);
|
|
125
140
|
await chmod(hookPath, 0o755);
|
|
126
141
|
out.wrote.push(hookPath);
|
|
142
|
+
|
|
143
|
+
// pre-push: the fuller `nq check` gate before code leaves the box
|
|
144
|
+
const pushPath = join(hookDir, 'pre-push');
|
|
145
|
+
await writeFile(pushPath, PRE_PUSH);
|
|
146
|
+
await chmod(pushPath, 0o755);
|
|
147
|
+
out.wrote.push(pushPath);
|
|
127
148
|
} else {
|
|
128
|
-
out.printed.push(`(no .git in ${repo} — skipped pre-commit; init git then re-run)`);
|
|
149
|
+
out.printed.push(`(no .git in ${repo} — skipped pre-commit + pre-push; init git then re-run)`);
|
|
129
150
|
}
|
|
130
151
|
|
|
131
152
|
// 2) weekly workflow (write only with --write)
|
|
@@ -146,6 +167,8 @@ export async function emitArtifacts() {
|
|
|
146
167
|
await mkdir(dir, { recursive: true });
|
|
147
168
|
await writeFile(join(dir, 'pre-commit'), PRE_COMMIT);
|
|
148
169
|
await chmod(join(dir, 'pre-commit'), 0o755);
|
|
170
|
+
await writeFile(join(dir, 'pre-push'), PRE_PUSH);
|
|
171
|
+
await chmod(join(dir, 'pre-push'), 0o755);
|
|
149
172
|
await writeFile(join(dir, 'session-start.sh'), SESSION_START);
|
|
150
173
|
await writeFile(join(dir, 'stack-align.yml'), WEEKLY_WORKFLOW);
|
|
151
174
|
return dir;
|
package/scripts/lint.mjs
CHANGED
|
@@ -85,12 +85,35 @@ function pageProbe({ SPACING_SCALE, ANCHORS, RADIUS_SCALE }) {
|
|
|
85
85
|
const visible = (el) => { const cs = getComputedStyle(el); if (cs.display === 'none' || cs.visibility === 'hidden' || +cs.opacity === 0) return false; const r = el.getBoundingClientRect(); return r.width > 1 && r.height > 1; };
|
|
86
86
|
const directText = (el) => [...el.childNodes].filter((n) => n.nodeType === 3).map((n) => n.textContent).join('').trim();
|
|
87
87
|
const matchAny = (el, sel) => { try { return el.closest(sel); } catch { return null; } };
|
|
88
|
+
// rendered line geometry of an element's own text — Range over each word, group by line-top.
|
|
89
|
+
// returns [lineCount, wordsOnLastLine] so callers can detect both wraps and orphaned words.
|
|
90
|
+
const lineInfo = (el) => {
|
|
91
|
+
const node = [...el.childNodes].find((n) => n.nodeType === 3 && n.textContent.trim());
|
|
92
|
+
if (!node) return [1, 1];
|
|
93
|
+
const words = node.textContent.trim().split(/\s+/);
|
|
94
|
+
if (words.length < 2) return [1, 1];
|
|
95
|
+
const range = document.createRange(); const tops = []; let idx = 0;
|
|
96
|
+
for (const w of words) {
|
|
97
|
+
const start = node.textContent.indexOf(w, idx); if (start < 0) continue;
|
|
98
|
+
range.setStart(node, start); range.setEnd(node, start + w.length);
|
|
99
|
+
const rr = range.getClientRects()[0]; if (rr) tops.push(Math.round(rr.top));
|
|
100
|
+
idx = start + w.length;
|
|
101
|
+
}
|
|
102
|
+
if (!tops.length) return [1, 1];
|
|
103
|
+
const uniq = [...new Set(tops)].sort((a, b) => a - b);
|
|
104
|
+
const last = uniq[uniq.length - 1];
|
|
105
|
+
return [uniq.length, tops.filter((t) => Math.abs(t - last) <= 2).length];
|
|
106
|
+
};
|
|
88
107
|
|
|
89
108
|
const o = {
|
|
90
109
|
dashes: [], titlePeriods: [], glass: [], inputBorders: [], uppercase: [], offPalette: {},
|
|
91
110
|
wideText: [], offScale: {}, onScaleN: 0, scaleN: 0, denseSections: [], fontSizes: {}, foldColors: new Set(),
|
|
92
111
|
blueOnDark: [], nonPill: [], flatColorBtn: [], wrongAnchor: [], wrongEase: [],
|
|
93
112
|
offRadius: {}, harshShadow: [], linkUnderline: [], addrNotMono: [], goldIcon: [],
|
|
113
|
+
fauxWeight: [], tightTrack: [], headingNoBalance: [], orphanLine: [],
|
|
114
|
+
tightLeadingH: [], tightLeadingBody: [], offFont: {},
|
|
115
|
+
blackBg: [], accentStripe: [], greenAction: [], dupGradIds: [], genericIcon: [], featherIcon: [],
|
|
116
|
+
bakedIcon: [], duotoneBlack: [], modalBlack: [], flatNavySection: [], glowShadow: [], fontsNotLoaded: false, noFocusRing: false,
|
|
94
117
|
};
|
|
95
118
|
|
|
96
119
|
for (const el of document.querySelectorAll('body *')) {
|
|
@@ -107,7 +130,37 @@ function pageProbe({ SPACING_SCALE, ANCHORS, RADIUS_SCALE }) {
|
|
|
107
130
|
if (txt && !inCode && /[—–]/.test(txt)) o.dashes.push(txt.slice(0, 70));
|
|
108
131
|
|
|
109
132
|
const isTitle = /^h[1-4]$/.test(tag) || el.matches('button,.nq-button,[class*="title" i],[class*="heading" i],[class*="headline" i]');
|
|
110
|
-
|
|
133
|
+
// a display title / CTA ends clean; exempt full sentences (comma/colon/semicolon = descriptive
|
|
134
|
+
// lead marked as a heading, which nimiq.com/about does and ends with a period legitimately).
|
|
135
|
+
if (isTitle && txt && /[a-z0-9)]\.$/i.test(txt) && !/\.\.\.$/.test(txt) && !/\.[a-z]?\.$/i.test(txt) && !/[,:;]/.test(txt)) o.titlePeriods.push(`<${tag}> ${txt.slice(0, 60)}`);
|
|
136
|
+
|
|
137
|
+
// HEADLINE TYPOGRAPHY — faux-black weight, tight negative tracking, orphaned wraps.
|
|
138
|
+
// Calibrated to nimiq.com: every heading renders at weight 600/700, letter-spacing 0,
|
|
139
|
+
// and text-wrap:balance. So weight ≥800, any meaningful negative tracking, or a multi-line
|
|
140
|
+
// heading that DIDN'T opt into balance/pretty is off-brand. Buttons excluded (legit bold).
|
|
141
|
+
const isHeadingEl = /^h[1-4]$/.test(tag) || el.matches('[class*="title" i],[class*="heading" i],[class*="headline" i]');
|
|
142
|
+
if (isHeadingEl && txt) {
|
|
143
|
+
const fs = px(cs.fontSize);
|
|
144
|
+
if (fs >= 24 && +cs.fontWeight >= 800) o.fauxWeight.push(`<${tag}> weight ${cs.fontWeight} @ ${fs}px "${txt.slice(0, 28)}"`);
|
|
145
|
+
if (fs >= 24 && cs.lineHeight !== 'normal') { const lhR = px(cs.lineHeight) / fs; if (lhR <= 1.05 || lhR >= 1.5) o.tightLeadingH.push(`<${tag}> line-height ${lhR.toFixed(2)} @ ${fs}px "${txt.slice(0, 22)}"`); }
|
|
146
|
+
if (fs >= 24 && cs.textTransform !== 'uppercase') {
|
|
147
|
+
const lsRaw = parseFloat(cs.letterSpacing);
|
|
148
|
+
if (!Number.isNaN(lsRaw) && lsRaw / fs <= -0.01) o.tightTrack.push(`<${tag}> ${(lsRaw / fs).toFixed(3)}em @ ${fs}px "${txt.slice(0, 24)}"`);
|
|
149
|
+
}
|
|
150
|
+
if (fs >= 20) {
|
|
151
|
+
const tw = cs.textWrap || cs.textWrapStyle || cs.textWrapMode || '';
|
|
152
|
+
const lc = lineInfo(el)[0];
|
|
153
|
+
if (!/balance|pretty/.test(tw) && lc >= 2) o.headingNoBalance.push(`<${tag}> ${lc} lines, no text-wrap:balance "${txt.slice(0, 24)}"`);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ORPHAN — any prominent text block (≥20px) that wraps and strands a single word on its last
|
|
158
|
+
// line. nimiq.com has ZERO of these (headings use text-wrap:balance; nothing orphans), so this
|
|
159
|
+
// is calibrated to 0 on the reference. Leaf elements only = measure the real text-painter.
|
|
160
|
+
if (txt && el.children.length === 0 && !inCode && px(cs.fontSize) >= 20) {
|
|
161
|
+
const [lc, lw] = lineInfo(el);
|
|
162
|
+
if (lc >= 2 && lw === 1) o.orphanLine.push(`<${tag}> ${lc} lines, last word alone "${txt.slice(0, 38)}"`);
|
|
163
|
+
}
|
|
111
164
|
|
|
112
165
|
// glassmorphism = a TRANSLUCENT surface with a backdrop blur (the frosted-glass card).
|
|
113
166
|
if (cs.backdropFilter && cs.backdropFilter !== 'none' && /blur\(/.test(cs.backdropFilter)) {
|
|
@@ -150,6 +203,12 @@ function pageProbe({ SPACING_SCALE, ANCHORS, RADIUS_SCALE }) {
|
|
|
150
203
|
const bgRgb = toRGB(cs.backgroundColor); const bgA = toRGBA(cs.backgroundColor)?.[3] ?? 0;
|
|
151
204
|
const brandFill = bgRgb && bgA > 0.5 && !gray(bgRgb) && nearest(bgRgb, ANCHORS).d < 60 && relLum(bgRgb) < 0.7 && nearest(bgRgb, ANCHORS).name !== 'white';
|
|
152
205
|
if (brandFill && cs.backgroundImage === 'none') o.flatColorBtn.push(`<${tag}> flat ${cs.backgroundColor} "${txt.slice(0, 20)}"`);
|
|
206
|
+
// green = success ONLY (rule 5): an action/retry button must not be green-filled
|
|
207
|
+
if (/\b(retry|try again|reload|continue|next|get started|submit|sign ?up|learn more)\b/i.test(txt) && !/\b(success|done|paid|complete|confirm)\b/i.test(txt)) {
|
|
208
|
+
const grads = (cs.backgroundImage.match(/rgba?\([^)]+\)|#[0-9a-fA-F]{3,8}/g) || []).map(toRGB).filter(Boolean);
|
|
209
|
+
const fill = (bgA > 0.5 ? bgRgb : null) || grads[0];
|
|
210
|
+
if (fill && (dist(fill, ANCHORS.green) < 55 || dist(fill, ANCHORS.greenGrad) < 55)) o.greenAction.push(`<${tag}> green "${txt.slice(0, 22)}"`);
|
|
211
|
+
}
|
|
153
212
|
if (/gradient/.test(cs.backgroundImage)) {
|
|
154
213
|
if (/linear-gradient/.test(cs.backgroundImage)) o.wrongAnchor.push(`<${tag}> linear-gradient (use radial)`);
|
|
155
214
|
else if (!/(bottom right|100% 100%|100%100%|at 100%)/.test(cs.backgroundImage)) o.wrongAnchor.push(`<${tag}> radial not bottom-right`);
|
|
@@ -172,6 +231,28 @@ function pageProbe({ SPACING_SCALE, ANCHORS, RADIUS_SCALE }) {
|
|
|
172
231
|
const m = cs.boxShadow.match(/rgba\(0,\s*0,\s*0,\s*([0-9.]+)\)/);
|
|
173
232
|
if (m && parseFloat(m[1]) > 0.22) o.harshShadow.push(`<${tag}> rgba(0,0,0,${m[1]})`);
|
|
174
233
|
}
|
|
234
|
+
// colored "glow" shadow (slop): Nimiq elevation is navy/black low-alpha, never a saturated glow.
|
|
235
|
+
// The luminance gate (>0.15) excludes the brand's dark navy shadow (lum ≈ 0.02) so only a bright
|
|
236
|
+
// gold/green/blue glow trips it.
|
|
237
|
+
for (const shadowProp of [cs.boxShadow, /drop-shadow/.test(cs.filter) ? cs.filter : '']) {
|
|
238
|
+
if (!shadowProp || shadowProp === 'none') continue;
|
|
239
|
+
for (const c of shadowProp.match(/rgba?\([^)]+\)|#[0-9a-fA-F]{3,8}/g) || []) {
|
|
240
|
+
const rgb = toRGB(c); if (rgb && !gray(rgb) && sat(rgb) > 0.4 && relLum(rgb) > 0.15 && nearest(rgb, ANCHORS).d < 80) { o.glowShadow.push(`<${tag}> ${c} glow`); break; }
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
// modal/overlay scrim must be navy, not black (rule 11): a full-viewport fixed translucent layer
|
|
244
|
+
// whose base is near-neutral black (r≈g≈b, all low) — navy (b≫r) is excluded.
|
|
245
|
+
if (cs.position === 'fixed') { const a = toRGBA(cs.backgroundColor); if (a && a[3] > 0.1 && a[3] < 0.95 && r.width >= innerWidth * 0.9 && r.height >= innerHeight * 0.9 && Math.max(a[0], a[1], a[2]) <= 40 && Math.abs(a[0] - a[2]) < 12) o.modalBlack.push(`<${tag}> overlay rgba(${a.slice(0, 3).map((x) => Math.round(x)).join(',')},${a[3].toFixed(2)})`); }
|
|
246
|
+
// pure-black surface (rule 6): Nimiq dark is navy #1F2348 / #171D2E, never #000 / near-black
|
|
247
|
+
if (!inCode) { const bgA2 = toRGBA(cs.backgroundColor); if (bgA2 && bgA2[3] >= 0.5 && r.width * r.height > 4000 && Math.max(bgA2[0], bgA2[1], bgA2[2]) <= 12) o.blackBg.push(`<${tag}> rgb(${bgA2.slice(0, 3).map((x) => Math.round(x)).join(',')})`); }
|
|
248
|
+
// one-sided vertical accent stripe on a card (rule 19): left/right edge ≥3px colored, opposite 0
|
|
249
|
+
if (r.width * r.height > 5000 && (toRGBA(cs.backgroundColor)?.[3] > 0.05 || cs.boxShadow !== 'none')) {
|
|
250
|
+
for (const [side, opp] of [['Left', 'Right'], ['Right', 'Left']]) {
|
|
251
|
+
if (px(cs[`border${side}Width`]) >= 3 && px(cs[`border${opp}Width`]) === 0) {
|
|
252
|
+
const bc = toRGB(cs[`border${side}Color`]); if (bc && !gray(bc) && nearest(bc, ANCHORS).d < 90) { o.accentStripe.push(`<${tag}> border-${side.toLowerCase()} ${px(cs[`border${side}Width`])}px`); break; }
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
175
256
|
// link styling — underlined body anchors (Nimiq links are bold, no underline)
|
|
176
257
|
if (tag === 'a' && txt && !matchAny(el, 'nav,header,footer') && cs.textDecorationLine.includes('underline')) o.linkUnderline.push(`"${txt.slice(0, 30)}"`);
|
|
177
258
|
// data formatting — NIM-address-looking text not in a mono font
|
|
@@ -183,8 +264,11 @@ function pageProbe({ SPACING_SCALE, ANCHORS, RADIUS_SCALE }) {
|
|
|
183
264
|
if ((tag === 'p' || el.matches('[class*="text" i],[class*="body" i],[class*="desc" i]')) && txt.length > 60 && el.children.length === 0) {
|
|
184
265
|
const fs = px(cs.fontSize) || 16; const ch = Math.round(r.width / (fs * 0.5));
|
|
185
266
|
if (ch > 88) o.wideText.push({ ch, snippet: txt.slice(0, 50) });
|
|
267
|
+
if (cs.lineHeight !== 'normal' && px(cs.lineHeight) / fs < 1.35) o.tightLeadingBody.push(`${(px(cs.lineHeight) / fs).toFixed(2)} @ ${fs}px "${txt.slice(0, 38)}"`);
|
|
186
268
|
}
|
|
187
269
|
if (txt) { const fs = px(cs.fontSize); if (fs) o.fontSizes[fs] = (o.fontSizes[fs] || 0) + 1; }
|
|
270
|
+
// font-family — Nimiq text is Mulish (+ Fira Mono for data). A named third-party face is off-brand.
|
|
271
|
+
if (txt && !inCode) { const fam = cs.fontFamily.split(',')[0].replace(/["']/g, '').trim().toLowerCase(); if (fam && !/^(mulish|muli|fira mono|fira code|fira sans|inherit)$/.test(fam)) o.offFont[fam] = (o.offFont[fam] || 0) + 1; }
|
|
188
272
|
if (r.top < 1024 && r.bottom > 0 && txt && !inCode) o.foldColors.add(cs.color);
|
|
189
273
|
}
|
|
190
274
|
|
|
@@ -194,19 +278,89 @@ function pageProbe({ SPACING_SCALE, ANCHORS, RADIUS_SCALE }) {
|
|
|
194
278
|
if (matchAny(el, '[class*="logo" i],[class*="brand" i],[class*="hex" i],[class*="pay" i],[class*="badge" i],[aria-label*="nimiq" i]')) continue;
|
|
195
279
|
const cs = getComputedStyle(el);
|
|
196
280
|
for (const c of [cs.color, cs.fill]) { const rgb = toRGB(c); if (rgb && dist(rgb, ANCHORS.gold) < 42) { o.goldIcon.push(`<${el.tagName.toLowerCase()}> gold-tinted icon`); break; } }
|
|
281
|
+
// Lucide/Feather/Tabler fingerprint: a 24×24 viewBox where EVERY path is 2px stroke + fill:none.
|
|
282
|
+
// Nimiq's own 24×24 icon (close.svg) is a FILLED path, so it can't match this composite.
|
|
283
|
+
if (el.tagName.toLowerCase() === 'svg' && el.getAttribute('viewBox') === '0 0 24 24' && cs.fill === 'none') {
|
|
284
|
+
const draw = [...el.querySelectorAll('path,line,polyline,polygon,circle,rect')];
|
|
285
|
+
if (draw.length >= 2 && draw.every((p) => getComputedStyle(p).strokeWidth === '2px')) o.featherIcon.push('<svg> 24×24 + uniform 2px stroke + fill:none (Lucide/Feather default)');
|
|
286
|
+
}
|
|
287
|
+
// baked-color + black-outline duotone — Nimiq mono icons paint via `currentColor`, never a
|
|
288
|
+
// literal hex (skill Icons). Icon-role = small square SVG, not an identicon/flag/chart/logo and
|
|
289
|
+
// with no gradient/image def (those are meant to be multi-colored).
|
|
290
|
+
if (el.tagName.toLowerCase() === 'svg') {
|
|
291
|
+
const ir = el.getBoundingClientRect();
|
|
292
|
+
const small = ir.width <= 130 && ir.height <= 130 && Math.abs(ir.width - ir.height) < Math.max(ir.width, ir.height, 1) * 0.5;
|
|
293
|
+
const decorative = matchAny(el, '[class*="identicon" i],[class*="flag" i],[class*="qr" i],[class*="chart" i],[class*="honeycomb" i],[class*="avatar" i]') || el.querySelector('linearGradient,radialGradient,image,pattern');
|
|
294
|
+
if (small && !decorative) {
|
|
295
|
+
const isLit = (v) => { v = (v || '').trim().toLowerCase(); return v && v !== 'none' && v !== 'currentcolor' && v !== 'inherit' && v !== 'transparent' && !/^url\(/.test(v) && !/^(#fff|#ffffff|white|rgb\(255,\s*255,\s*255\))$/.test(v); };
|
|
296
|
+
let baked = false, hasBlack = false, hasColor = false;
|
|
297
|
+
for (const p of [el, ...el.querySelectorAll('path,circle,rect,line,polyline,polygon,ellipse')]) {
|
|
298
|
+
for (const attr of ['fill', 'stroke']) {
|
|
299
|
+
const v = p.getAttribute && p.getAttribute(attr);
|
|
300
|
+
if (isLit(v)) { baked = true; const rgb = toRGB(v); if (rgb) { if (Math.max(...rgb) <= 40) hasBlack = true; else if (!gray(rgb)) hasColor = true; } }
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
if (baked) o.bakedIcon.push(`<svg ${(el.getAttribute('class') || '').slice(0, 22)}> baked fill (use currentColor)`);
|
|
304
|
+
if (hasBlack && hasColor) o.duotoneBlack.push('<svg> black layer on a colored icon (duotone = same color @0.4)');
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// generic / off-brand icon SETS (Lucide, Feather, Font Awesome, Material, Bootstrap, Tabler…).
|
|
310
|
+
// Nimiq ships its own SVGs under `nq-icon`; it never emits a library class (verified 0 in registry).
|
|
311
|
+
const ICON_LIB = /(^|\s)(lucide|feather|fa-(solid|regular|light|thin|brands|duotone)|fas|far|fal|fab|fad|bi-|mdi-|ph-|tabler|ti-|ion-|ionicon|remixicon|ri-|material-icons|material-symbols|octicon|glyphicon)(\b|-)/i;
|
|
312
|
+
const ICON_OK = /\b(nq-icon|flag-icon|flag-icons|fi-)\b/;
|
|
313
|
+
const seenGeneric = new Set();
|
|
314
|
+
for (const el of document.querySelectorAll('svg,i,span,[class*="icon" i],[data-lucide],[data-feather],[data-icon]')) {
|
|
315
|
+
if (!visible(el)) continue;
|
|
316
|
+
const cls = `${el.getAttribute('class') || ''}`;
|
|
317
|
+
const libClass = ICON_LIB.test(cls) && !ICON_OK.test(cls);
|
|
318
|
+
const libData = el.hasAttribute('data-lucide') || el.hasAttribute('data-feather');
|
|
319
|
+
const ligature = /material-icons|material-symbols/i.test(cls) && el.children.length === 0 && /^[a-z][a-z_]{1,}$/i.test((el.textContent || '').trim());
|
|
320
|
+
if (libClass || libData || ligature) { const key = cls || el.tagName; if (!seenGeneric.has(key)) { seenGeneric.add(key); o.genericIcon.push(`<${el.tagName.toLowerCase()} class="${cls.slice(0, 28)}">`); } }
|
|
197
321
|
}
|
|
198
322
|
|
|
323
|
+
// duplicate gradient ids — only a REAL bug when defs sharing one id have DIFFERENT stops (then a
|
|
324
|
+
// later SVG paints the first def's gradient). nimiq.com repeats IDENTICAL icon gradients under the
|
|
325
|
+
// same id (Figma export) and renders fine, so compare stop colors+offsets, not raw id counts.
|
|
326
|
+
const gradById = {};
|
|
327
|
+
for (const g of document.querySelectorAll('linearGradient[id],radialGradient[id]')) {
|
|
328
|
+
const sig = [...g.querySelectorAll('stop')].map((s) => `${getComputedStyle(s).stopColor || s.getAttribute('stop-color') || ''}@${s.getAttribute('offset') || ''}`).join(',');
|
|
329
|
+
(gradById[g.id] ??= new Set()).add(sig);
|
|
330
|
+
}
|
|
331
|
+
o.dupGradIds = Object.entries(gradById).filter(([, sigs]) => sigs.size > 1).map(([id, sigs]) => `#${id} (${sigs.size} differing defs)`);
|
|
332
|
+
|
|
199
333
|
// per-section text-ink ratio (full-width bands)
|
|
200
334
|
const vw = innerWidth;
|
|
201
335
|
const leaves = [...document.querySelectorAll('body *')].filter((el) => !matchAny(el, 'svg') && visible(el) && el.children.length === 0 && directText(el)).map((el) => el.getBoundingClientRect());
|
|
202
336
|
for (const el of document.querySelectorAll('section,main > div,body > div,[class*="section" i],[class*="band" i]')) {
|
|
203
337
|
if (matchAny(el, 'svg') || !visible(el)) continue;
|
|
204
338
|
const r = el.getBoundingClientRect(); if (r.width < vw * 0.7 || r.height < 220) continue;
|
|
339
|
+
// flat-fill navy/colored section (rule 7): a full-width band solid-filled in a brand color with
|
|
340
|
+
// NO gradient — Nimiq colored bands use the bottom-right radial. Grey/white cards are exempt.
|
|
341
|
+
const scs = getComputedStyle(el); const sbg = toRGB(scs.backgroundColor); const sbgA = toRGBA(scs.backgroundColor)?.[3] ?? 0;
|
|
342
|
+
if (sbgA > 0.85 && sbg && !gray(sbg) && relLum(sbg) < 0.5 && scs.backgroundImage === 'none') { const nb = nearest(sbg, ANCHORS); if (nb.d < 60 && nb.name !== 'white') o.flatNavySection.push(`<${el.tagName.toLowerCase()}> flat ${scs.backgroundColor}`); }
|
|
205
343
|
let ink = 0; for (const lr of leaves) { const cx = lr.left + lr.width / 2, cy = lr.top + lr.height / 2; if (cx >= r.left && cx <= r.right && cy >= r.top && cy <= r.bottom) ink += lr.width * lr.height; }
|
|
206
344
|
const pct = Math.round((ink / (r.width * r.height)) * 1000) / 10;
|
|
207
345
|
if (pct > 18) o.denseSections.push({ pct, tag: el.tagName.toLowerCase(), cls: (el.className || '').toString().trim().split(/\s+/)[0]?.slice(0, 24) || '', h: Math.round(r.height) });
|
|
208
346
|
}
|
|
209
347
|
o.foldColors = o.foldColors.size;
|
|
348
|
+
|
|
349
|
+
// Mulish must actually load (rule 9) — a declared family that 404s falls back to a system font.
|
|
350
|
+
o.fontsNotLoaded = !document.fonts.check('1em Mulish') && !document.fonts.check('1em Muli');
|
|
351
|
+
// focus ring removed without a :focus-visible restore (a11y). Scan accessible stylesheets only;
|
|
352
|
+
// cross-origin CDN sheets throw on .cssRules and are skipped (so we never guess).
|
|
353
|
+
let killsOutline = false, restoresFocus = false;
|
|
354
|
+
for (const sheet of document.styleSheets) {
|
|
355
|
+
let rules; try { rules = sheet.cssRules; } catch { continue; }
|
|
356
|
+
if (!rules) continue;
|
|
357
|
+
for (const rule of rules) {
|
|
358
|
+
const sel = rule.selectorText, st = rule.style; if (!sel || !st) continue;
|
|
359
|
+
if (/:focus(-visible)?\b/.test(sel) && ((st.outlineStyle && st.outlineStyle !== 'none') || parseFloat(st.outlineWidth) > 0 || (st.boxShadow && st.boxShadow !== 'none') || st.borderColor || st.border)) restoresFocus = true;
|
|
360
|
+
if (/(^|[\s,])(a|button|input|select|textarea|\*|\[tabindex\]|:focus)([\s,:>[]|$)/i.test(sel) && /outline\s*:\s*(none|0)/i.test(st.cssText)) killsOutline = true;
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
o.noFocusRing = killsOutline && !restoresFocus;
|
|
210
364
|
return o;
|
|
211
365
|
}
|
|
212
366
|
|
|
@@ -237,6 +391,13 @@ function fixSource(src) {
|
|
|
237
391
|
let out = src;
|
|
238
392
|
out = replaceText(out, (t) => t.replace(/\s*[—–]\s*/g, () => { fixes.push('dash→comma'); return ', '; }));
|
|
239
393
|
out = out.replace(/(<h[1-4][^>]*>)([\s\S]*?[a-z0-9)])\.(\s*<\/h[1-4]>)/gi, (m, a, b, c) => { fixes.push('title-period'); return a + b + c; });
|
|
394
|
+
// Inject the balance/pretty rule nimiq.com applies to EVERY heading — the mechanical cure for
|
|
395
|
+
// orphaned-title line breaks. Non-destructive (only affects wrapping), and only added once.
|
|
396
|
+
if (!/text-wrap\s*:\s*(balance|pretty)/i.test(out)) {
|
|
397
|
+
const block = '\n<style>/* nq --fix: nimiq balances headings + prettifies body so no line strands a lone word */\nh1,h2,h3,h4{text-wrap:balance}\np,li,blockquote,figcaption{text-wrap:pretty}</style>\n';
|
|
398
|
+
if (/<\/head>/i.test(out)) { out = out.replace(/<\/head>/i, block + '</head>'); fixes.push('text-wrap:balance'); }
|
|
399
|
+
else if (/<body[^>]*>/i.test(out)) { out = out.replace(/(<body[^>]*>)/i, `$1${block}`); fixes.push('text-wrap:balance'); }
|
|
400
|
+
}
|
|
240
401
|
return { out, fixes };
|
|
241
402
|
}
|
|
242
403
|
|
|
@@ -247,8 +408,8 @@ export async function lint(target, opts = {}) {
|
|
|
247
408
|
if (!isUrl && !existsSync(filePath)) throw new Error(`no such file: ${target}`);
|
|
248
409
|
const url = isUrl ? target : pathToFileURL(filePath).href;
|
|
249
410
|
|
|
250
|
-
const {
|
|
251
|
-
const browser = await
|
|
411
|
+
const { launchChromium } = await import('./_browser.mjs');
|
|
412
|
+
const browser = await launchChromium('nq lint');
|
|
252
413
|
const out = (s = '') => console.log(s);
|
|
253
414
|
let errorCount = 0, warnCount = 0;
|
|
254
415
|
try {
|
|
@@ -277,6 +438,10 @@ export async function lint(target, opts = {}) {
|
|
|
277
438
|
['borders on inputs', r.inputBorders.length, r.inputBorders[0], 'inset box-shadow'],
|
|
278
439
|
['off-palette colors', offPal.length, offPal[0] && `${offPal[0][0]} (≈${offPal[0][1].near}, Δ${offPal[0][1].d})`, 'manual'],
|
|
279
440
|
['low-contrast blue/navy text on dark', r.blueOnDark.length, r.blueOnDark[0] && `${r.blueOnDark[0].fg} on ${r.blueOnDark[0].bg} = ${r.blueOnDark[0].ratio}:1 "${r.blueOnDark[0].snippet}"`, 'white / #0CA6FE'],
|
|
441
|
+
['generic icon set (Lucide / FA / Material…)', r.genericIcon.length, r.genericIcon[0], 'use nq-icon SVGs'],
|
|
442
|
+
['duplicate gradient id (SVGs misrender)', r.dupGradIds.length, r.dupGradIds[0], 'unique ids'],
|
|
443
|
+
['pure-black surface (rule 6)', r.blackBg.length, r.blackBg[0], 'navy #1F2348'],
|
|
444
|
+
['one-sided accent stripe (rule 19)', r.accentStripe.length, r.accentStripe[0], 'uniform border'],
|
|
280
445
|
];
|
|
281
446
|
errorCount = errs.reduce((n, e) => n + e[1], 0);
|
|
282
447
|
|
|
@@ -284,11 +449,19 @@ export async function lint(target, opts = {}) {
|
|
|
284
449
|
const topOff = Object.entries(r.offScale).sort((a, b) => b[1] - a[1]).slice(0, 6);
|
|
285
450
|
const topRad = Object.entries(r.offRadius).sort((a, b) => b[1] - a[1]).slice(0, 5);
|
|
286
451
|
const sizes = Object.keys(r.fontSizes).map(Number).sort((a, b) => a - b);
|
|
452
|
+
const offFont = Object.entries(r.offFont).filter(([, n]) => n >= 3).sort((a, b) => b[1] - a[1]);
|
|
287
453
|
const warns = [
|
|
288
454
|
['body text wider than ~88ch', r.wideText.length, r.wideText.length && `worst ${Math.max(...r.wideText.map((w) => w.ch))}ch`],
|
|
289
455
|
['dense sections (>18% text ink)', r.denseSections.length, r.denseSections.slice(0, 4).map((d) => `${d.pct}% <${d.tag}.${d.cls}>`).join(' ')],
|
|
290
456
|
['off-scale spacing (snap to scale)', topOff.length, `${scalePct}% on-scale · ${topOff.map(([v, n]) => `${v}px×${n}`).join(' ')}`],
|
|
291
457
|
['type-scale sprawl', sizes.length > FONT_SPRAWL_WARN ? sizes.length : 0, `${sizes.length} sizes [${sizes.join(',')}]`],
|
|
458
|
+
['faux-black headline weight (≥800; nimiq ≤700)', r.fauxWeight.length, r.fauxWeight[0]],
|
|
459
|
+
['tight negative heading tracking (nimiq = 0)', r.tightTrack.length, r.tightTrack[0]],
|
|
460
|
+
['wrapping heading w/o text-wrap: balance', r.headingNoBalance.length, r.headingNoBalance[0]],
|
|
461
|
+
['orphaned last word (1-word final line)', r.orphanLine.length, r.orphanLine[0]],
|
|
462
|
+
['heading line-height off (nimiq 1.25–1.3)', r.tightLeadingH.length, r.tightLeadingH[0]],
|
|
463
|
+
['cramped body line-height (<1.35; nimiq 1.5)', r.tightLeadingBody.length, r.tightLeadingBody[0]],
|
|
464
|
+
['non-brand font on text (Mulish / Fira only)', offFont.length, offFont.map(([f, n]) => `${f}×${n}`).join(' ')],
|
|
292
465
|
['uppercase eyebrow (colored / long / pill)', r.uppercase.length, r.uppercase[0]],
|
|
293
466
|
['non-pill action buttons', r.nonPill.length, r.nonPill[0]],
|
|
294
467
|
['flat-fill colored button (needs gradient)', r.flatColorBtn.length, r.flatColorBtn[0]],
|
|
@@ -299,6 +472,15 @@ export async function lint(target, opts = {}) {
|
|
|
299
472
|
['underlined links (use bold, no underline)', r.linkUnderline.length, r.linkUnderline[0]],
|
|
300
473
|
['NIM address not in Fira Mono', r.addrNotMono.length, r.addrNotMono[0]],
|
|
301
474
|
['gold-tinted UI icon (gold = logo only)', r.goldIcon.length, r.goldIcon[0]],
|
|
475
|
+
['feather/lucide-style stroke icon (inlined)', r.featherIcon.length, r.featherIcon[0]],
|
|
476
|
+
['baked-color icon (use currentColor)', r.bakedIcon.length, r.bakedIcon[0]],
|
|
477
|
+
['black-outline duotone icon', r.duotoneBlack.length, r.duotoneBlack[0]],
|
|
478
|
+
['green action / retry button (green = success)', r.greenAction.length, r.greenAction[0]],
|
|
479
|
+
['colored glow shadow (navy elevation only)', r.glowShadow.length, r.glowShadow[0]],
|
|
480
|
+
['black modal overlay (use navy rgba)', r.modalBlack.length, r.modalBlack[0]],
|
|
481
|
+
['flat-fill navy/colored section (use radial)', r.flatNavySection.length, r.flatNavySection[0]],
|
|
482
|
+
['focus outline removed w/o :focus-visible', r.noFocusRing ? 1 : 0, r.noFocusRing ? 'add a :focus-visible ring' : ''],
|
|
483
|
+
['Mulish not loaded (system-font fallback)', r.fontsNotLoaded ? 1 : 0, r.fontsNotLoaded ? 'load Mulish' : ''],
|
|
302
484
|
['mobile horizontal overflow @390px', mob.overflowPx > 4 ? 1 : 0, `${mob.overflowPx}px`],
|
|
303
485
|
['mobile tap targets < 36px', mob.smallTargetN, mob.smallTargets[0]],
|
|
304
486
|
['text smaller than 12px @390px', mob.tinyText, `${mob.tinyText} element type(s)`],
|
package/scripts/verify.mjs
CHANGED
|
@@ -18,7 +18,7 @@ export async function verify(name) {
|
|
|
18
18
|
if (!existsSync(demo)) return { status: 'skip', reason: 'no html/demo.html' };
|
|
19
19
|
if (!existsSync(ref)) return { status: 'skip', reason: 'no reference.png' };
|
|
20
20
|
|
|
21
|
-
const {
|
|
21
|
+
const { launchChromium } = await import('./_browser.mjs');
|
|
22
22
|
const { PNG } = await import('pngjs');
|
|
23
23
|
const pixelmatch = (await import('pixelmatch')).default;
|
|
24
24
|
|
|
@@ -26,7 +26,7 @@ export async function verify(name) {
|
|
|
26
26
|
const viewport = v.viewport ?? { width: 800, height: 600 };
|
|
27
27
|
const threshold = v.maxDiffPct ?? 1.0; // percent of pixels allowed to differ
|
|
28
28
|
|
|
29
|
-
const browser = await
|
|
29
|
+
const browser = await launchChromium('nq verify');
|
|
30
30
|
try {
|
|
31
31
|
const page = await browser.newPage({ viewport, deviceScaleFactor: v.scale ?? 2 });
|
|
32
32
|
await page.goto('file://' + demo);
|
package/test/align.test.mjs
CHANGED
|
@@ -62,7 +62,7 @@ test('HARD FAIL: @nimiq/core/web import in src → settlement risky-fail', async
|
|
|
62
62
|
await rm(dir, { recursive: true, force: true });
|
|
63
63
|
});
|
|
64
64
|
|
|
65
|
-
test('HARD FAIL: Client.create(
|
|
65
|
+
test('HARD FAIL: Client.create( in src → settlement risky-fail (Client.create is the light-client tell)', async () => {
|
|
66
66
|
const dir = await tmpApp({
|
|
67
67
|
'nimiq-stack.json': canonicalManifest(),
|
|
68
68
|
'Dockerfile': 'x', 'fly.toml': 'x', '.github/workflows/ci.yml': 'x',
|
|
@@ -71,7 +71,31 @@ test('HARD FAIL: Client.create( and waitForConsensusEstablished( in src', async
|
|
|
71
71
|
const r = await alignApp(dir);
|
|
72
72
|
assert.equal(r.axes.settlement.verdict, RISKY);
|
|
73
73
|
assert.ok(r.axes.settlement.lines.some(l => l.includes('Client.create(')));
|
|
74
|
-
|
|
74
|
+
// waitForConsensusEstablished( is a legit NimiqClientLike interface / rpc-client method — NOT forbidden.
|
|
75
|
+
assert.ok(!r.axes.settlement.lines.some(l => l.includes('waitForConsensusEstablished(')), 'waitForConsensusEstablished must not be flagged');
|
|
76
|
+
await rm(dir, { recursive: true, force: true });
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('the canonical rpc-block-scan client (implements waitForConsensusEstablished) is NOT flagged', async () => {
|
|
80
|
+
const dir = await tmpApp({
|
|
81
|
+
'nimiq-stack.json': canonicalManifest(),
|
|
82
|
+
'Dockerfile': 'x', 'fly.toml': 'x', '.github/workflows/ci.yml': 'x',
|
|
83
|
+
'src/payments/nimiq-rpc-client.ts': 'export class RpcNimiqClient {\n async waitForConsensusEstablished() { /* RPC poll of isConsensusEstablished */ }\n}\n',
|
|
84
|
+
'src/payments/nimiq-provider.ts': 'export interface NimiqClientLike {\n waitForConsensusEstablished(): Promise<void>;\n}\n',
|
|
85
|
+
});
|
|
86
|
+
const r = await alignApp(dir);
|
|
87
|
+
assert.notEqual(r.axes.settlement.verdict, RISKY, 'the canonical rpc client + interface must stay clean');
|
|
88
|
+
await rm(dir, { recursive: true, force: true });
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test('forbidden light-client strings inside a *.test.* file are ignored', async () => {
|
|
92
|
+
const dir = await tmpApp({
|
|
93
|
+
'nimiq-stack.json': canonicalManifest(),
|
|
94
|
+
'Dockerfile': 'x', 'fly.toml': 'x', '.github/workflows/ci.yml': 'x',
|
|
95
|
+
'src/chain.test.ts': "import x from '@nimiq/core/web';\nconst c = Client.create(cfg);\n",
|
|
96
|
+
});
|
|
97
|
+
const r = await alignApp(dir);
|
|
98
|
+
assert.notEqual(r.axes.settlement.verdict, RISKY, 'test/spec files are excluded from the light-client scan');
|
|
75
99
|
await rm(dir, { recursive: true, force: true });
|
|
76
100
|
});
|
|
77
101
|
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// Tests for `nq check` — the per-project alignment gate, and the pre-push hook.
|
|
2
|
+
// node:test style, matching test/align.test.mjs.
|
|
3
|
+
import { test } from 'node:test';
|
|
4
|
+
import assert from 'node:assert/strict';
|
|
5
|
+
import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises';
|
|
6
|
+
import { existsSync, statSync } from 'node:fs';
|
|
7
|
+
import { tmpdir } from 'node:os';
|
|
8
|
+
import { join, resolve, dirname } from 'node:path';
|
|
9
|
+
import { fileURLToPath } from 'node:url';
|
|
10
|
+
|
|
11
|
+
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
12
|
+
const { check } = await import(join(ROOT, 'scripts', 'check.mjs'));
|
|
13
|
+
const hooks = await import(join(ROOT, 'scripts', 'hooks.mjs'));
|
|
14
|
+
|
|
15
|
+
async function tmpApp(files) {
|
|
16
|
+
const dir = await mkdtemp(join(tmpdir(), 'nq-check-'));
|
|
17
|
+
for (const [rel, content] of Object.entries(files)) {
|
|
18
|
+
const full = join(dir, rel);
|
|
19
|
+
await mkdir(dirname(full), { recursive: true });
|
|
20
|
+
await writeFile(full, typeof content === 'string' ? content : JSON.stringify(content, null, 2));
|
|
21
|
+
}
|
|
22
|
+
return dir;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// a clean, canonical chain app manifest (mirrors test/align.test.mjs)
|
|
26
|
+
const canonicalManifest = (over = {}) => ({
|
|
27
|
+
schemaVersion: 1, name: 'app', chainApp: true, exempt: false, exemptReason: null,
|
|
28
|
+
stack: { framework: 'vanilla-pwa', server: 'hono@^4.7', runtime: 'bun', build: 'none', packageManager: 'bun' },
|
|
29
|
+
styling: { source: 'nimiq-ui' },
|
|
30
|
+
settlement: { pattern: 'rpc-block-scan', lib: 'inline', coreRole: 'offline-crypto-only' },
|
|
31
|
+
deploy: { target: 'fly' },
|
|
32
|
+
config: { tsconfig: 'local-strict', lint: 'none', fileSizeGuard: 800, ci: true },
|
|
33
|
+
canonicalVersion: '0.1.0',
|
|
34
|
+
...over,
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test('a clean canonical app → check passes (exit 0), align PASS', async () => {
|
|
38
|
+
const dir = await tmpApp({
|
|
39
|
+
'nimiq-stack.json': canonicalManifest(),
|
|
40
|
+
'Dockerfile': 'FROM oven/bun:1\n', 'fly.toml': 'app="x"\n',
|
|
41
|
+
'.github/workflows/ci.yml': 'name: ci\n',
|
|
42
|
+
// no package.json test script + no index.html → those sections SKIP, never FAIL
|
|
43
|
+
'src/server.ts': 'export const x = 1;\n',
|
|
44
|
+
});
|
|
45
|
+
const r = await check(dir);
|
|
46
|
+
assert.equal(r.ok, true, JSON.stringify(r.sections, null, 2));
|
|
47
|
+
assert.equal(r.sections.align.verdict, 'pass');
|
|
48
|
+
assert.equal(r.sections.filesize.verdict, 'pass');
|
|
49
|
+
// no test script → SKIP, no index.html → SKIP (skips don't fail the gate)
|
|
50
|
+
assert.ok(['skip', 'pass'].includes(r.sections.test.verdict));
|
|
51
|
+
assert.equal(r.sections.lint.verdict, 'skip');
|
|
52
|
+
await rm(dir, { recursive: true, force: true });
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('a dir with @nimiq/core/web + Client.create + chainApp manifest → check FAILS', async () => {
|
|
56
|
+
const dir = await tmpApp({
|
|
57
|
+
'nimiq-stack.json': canonicalManifest(),
|
|
58
|
+
'Dockerfile': 'x', 'fly.toml': 'x', '.github/workflows/ci.yml': 'x',
|
|
59
|
+
'src/boot.ts': "import { Client } from '@nimiq/core/web';\nconst c = Client.create(cfg);\n",
|
|
60
|
+
});
|
|
61
|
+
const r = await check(dir);
|
|
62
|
+
assert.equal(r.ok, false);
|
|
63
|
+
assert.equal(r.sections.align.verdict, 'fail');
|
|
64
|
+
assert.ok(r.sections.align.lines.some(l => l.includes('@nimiq/core/web')));
|
|
65
|
+
await rm(dir, { recursive: true, force: true });
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('an oversized first-party src file → filesize section FAILS the gate', async () => {
|
|
69
|
+
const big = Array.from({ length: 850 }, (_, i) => `const l${i} = ${i};`).join('\n') + '\n';
|
|
70
|
+
const dir = await tmpApp({
|
|
71
|
+
'nimiq-stack.json': canonicalManifest(),
|
|
72
|
+
'Dockerfile': 'x', 'fly.toml': 'x', '.github/workflows/ci.yml': 'x',
|
|
73
|
+
'src/huge.ts': big,
|
|
74
|
+
});
|
|
75
|
+
const r = await check(dir);
|
|
76
|
+
assert.equal(r.sections.filesize.verdict, 'fail');
|
|
77
|
+
assert.ok(r.sections.filesize.lines.some(l => l.includes('src/huge.ts')));
|
|
78
|
+
assert.equal(r.ok, false);
|
|
79
|
+
await rm(dir, { recursive: true, force: true });
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('filesize honors per-repo fileSizeGuardExclude in the manifest', async () => {
|
|
83
|
+
const big = Array.from({ length: 850 }, (_, i) => `const l${i} = ${i};`).join('\n') + '\n';
|
|
84
|
+
const dir = await tmpApp({
|
|
85
|
+
'nimiq-stack.json': canonicalManifest({
|
|
86
|
+
config: { tsconfig: 'local-strict', lint: 'none', fileSizeGuard: 800, ci: true, fileSizeGuardExclude: ['src/generated.ts'] },
|
|
87
|
+
}),
|
|
88
|
+
'Dockerfile': 'x', 'fly.toml': 'x', '.github/workflows/ci.yml': 'x',
|
|
89
|
+
'src/generated.ts': big,
|
|
90
|
+
'src/app.ts': 'export const ok = 1;\n', // a real, in-scope file so the scan isn't empty
|
|
91
|
+
});
|
|
92
|
+
const r = await check(dir);
|
|
93
|
+
assert.equal(r.sections.filesize.verdict, 'pass', JSON.stringify(r.sections.filesize, null, 2));
|
|
94
|
+
await rm(dir, { recursive: true, force: true });
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test('lint section SKIPs (never FAILs) when there is no index.html', async () => {
|
|
98
|
+
const dir = await tmpApp({
|
|
99
|
+
'nimiq-stack.json': canonicalManifest(),
|
|
100
|
+
'Dockerfile': 'x', 'fly.toml': 'x', '.github/workflows/ci.yml': 'x',
|
|
101
|
+
'src/server.ts': 'export const x = 1;\n',
|
|
102
|
+
});
|
|
103
|
+
const r = await check(dir);
|
|
104
|
+
assert.equal(r.sections.lint.verdict, 'skip');
|
|
105
|
+
await rm(dir, { recursive: true, force: true });
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test('hooks install writes an executable pre-push that runs the check/align gate', async () => {
|
|
109
|
+
const dir = await mkdtemp(join(tmpdir(), 'nq-prepush-'));
|
|
110
|
+
const { execFileSync } = await import('node:child_process');
|
|
111
|
+
execFileSync('git', ['init', '-q'], { cwd: dir });
|
|
112
|
+
const r = await hooks.installHooks(dir);
|
|
113
|
+
const pushPath = join(dir, '.git', 'hooks', 'pre-push');
|
|
114
|
+
assert.ok(r.wrote.includes(pushPath), 'pre-push should be in wrote[]');
|
|
115
|
+
assert.ok(existsSync(pushPath));
|
|
116
|
+
assert.ok(statSync(pushPath).mode & 0o100, 'pre-push must be executable');
|
|
117
|
+
const { readFile } = await import('node:fs/promises');
|
|
118
|
+
const body = await readFile(pushPath, 'utf8');
|
|
119
|
+
assert.ok(/nq (check|align) --fail-on=settlement,styling/.test(body), 'pre-push must run the gate');
|
|
120
|
+
await rm(dir, { recursive: true, force: true });
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test('PRE_PUSH artifact contains the check/align gate command', () => {
|
|
124
|
+
assert.ok(/nq (check|align) --fail-on=settlement,styling/.test(hooks.PRE_PUSH));
|
|
125
|
+
});
|