nimiq-branding-cli 1.2.1 → 1.3.1
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 +57 -1
- package/align/canonical.json +2 -1
- package/bin/nq.js +16 -2
- package/package.json +1 -1
- package/scripts/align.mjs +3 -0
- package/scripts/check.mjs +0 -0
- package/scripts/hooks.mjs +24 -1
- package/scripts/lint.mjs +252 -1
- 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,55 @@ 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
|
+
| **text contrast below WCAG AA** | text below `4.5:1` (normal) / `3.0:1` (large) against its *determinable* background. Background is read by walking ancestors; if none is found (a hero's gradient lives on a separate layer) the element is **skipped**, and ratios < 1.6:1 are treated as undetectable-bg, not flagged. **Honest caveat:** nimiq.com itself ships sub-AA secondary grey (≈`2.37:1`), so this is a **warning that the reference also trips** — it surfaces a real a11y debt rather than pretending the brand is perfect. |
|
|
97
|
+
| multiple `<h1>` per page | more than one visible `<h1>`. Level *skips* (h2→h4) are intentionally **not** checked — nimiq.com/about jumps h1→h4 for styling, so a no-skip rule would flag the reference |
|
|
98
|
+
|
|
99
|
+
**Layout, data & motion**
|
|
100
|
+
|
|
101
|
+
| Check | Threshold |
|
|
102
|
+
|---|---|
|
|
103
|
+
| unconstrained text column | a body paragraph with `max-width: none` rendered wider than 760px — nimiq's wide copy always caps its measure |
|
|
104
|
+
| NIM amount wrong semantic color | a `+N NIM`/`%` that isn't green (incoming/up), or a `−N`/`−%` that isn't navy/red (outgoing/down) — skill Addresses. Only fires on wallet/POS surfaces |
|
|
105
|
+
| address not uppercase / flat | a display NIM address (≥20px) rendered lowercase or as one flat string instead of the uppercase 3×3 grid |
|
|
106
|
+
| pulsing "live" dot animation | a small (≤28px) round element on an `infinite` animation that isn't a spinner/loader — the fake-"live" glow the slop blacklist names |
|
|
107
|
+
|
|
108
|
+
**Headline typography & line breaks** (closes the "lint passes ≠ brand-correct" gap)
|
|
109
|
+
|
|
110
|
+
The deterministic checks above read color, spacing and shape — but a heading can satisfy all of
|
|
111
|
+
them and still read as generic SaaS through *weight* and *tracking*, the exact "faux-weight or
|
|
112
|
+
tightly-tracked black headlines" the skill blacklist names. These read the rendered type instead.
|
|
113
|
+
Measured on nimiq.com: every heading renders at weight **600/700**, `letter-spacing: 0`, and
|
|
114
|
+
`text-wrap: balance`; nothing on the page strands a single word. So:
|
|
115
|
+
|
|
116
|
+
| Check | Threshold | Why |
|
|
117
|
+
|---|---|---|
|
|
118
|
+
| faux-black headline weight | heading ≥ 24px at `font-weight ≥ 800` | nimiq.com tops out at 700; 800+ "black" is the generic tell |
|
|
119
|
+
| tight negative heading tracking | heading ≥ 24px with `letter-spacing ≤ -0.01em` | nimiq.com headings are `0`; negative tracking cramps the headline |
|
|
120
|
+
| 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 |
|
|
121
|
+
| 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 |
|
|
122
|
+
| 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 |
|
|
123
|
+
| cramped body line-height | body copy with `line-height/font-size < 1.35` | nimiq.com body is **1.5–1.56**; tighter hurts readability |
|
|
124
|
+
| 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 |
|
|
125
|
+
|
|
126
|
+
`--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.
|
|
127
|
+
|
|
128
|
+
Weight/tracking are heading-scoped (buttons and bold body labels are legitimately heavy). The
|
|
129
|
+
orphan check is general (it caught a body banner, not a heading) but gated at ≥ 20px so calibrated
|
|
130
|
+
fine-print under 20px — which wasn't measured on the reference — is never flagged.
|
|
76
131
|
|
|
77
132
|
**Mobile (a second pass at 390px)**
|
|
78
133
|
|
|
@@ -122,7 +177,8 @@ same "measure reality" method that proved the no-em-dash rule. Verified outcome:
|
|
|
122
177
|
|
|
123
178
|
| Page | ERRORS | Note |
|
|
124
179
|
|---|---|---|
|
|
125
|
-
| nimiq.com home (reference) | **0** | the gate must never flag the brand's own site, even with the rule-20 contrast
|
|
180
|
+
| 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 |
|
|
181
|
+
| nimiq.com headline type | **0** | measured directly: weights 600/700, `letter-spacing: 0`, every heading `text-wrap: balance`, zero stranded words |
|
|
126
182
|
| nimiq.com/about | 0 | — |
|
|
127
183
|
| nimiq.tech | real findings | catches genuine em-dashes + a "Phase 2 · preview" colored eyebrow + 3 dense bands |
|
|
128
184
|
| 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.1",
|
|
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": {
|
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
|
@@ -82,15 +82,56 @@ function pageProbe({ SPACING_SCALE, ANCHORS, RADIUS_SCALE }) {
|
|
|
82
82
|
}
|
|
83
83
|
return [255, 255, 255];
|
|
84
84
|
};
|
|
85
|
+
// like effBg but reports whether a real background was actually found. Heroes paint their
|
|
86
|
+
// gradient on a separate absolutely-positioned layer, so the text's ancestors are transparent
|
|
87
|
+
// and we'd wrongly default to white — `found:false` lets the contrast check skip those.
|
|
88
|
+
const effBgF = (el) => {
|
|
89
|
+
let e = el;
|
|
90
|
+
while (e && e.nodeType === 1) {
|
|
91
|
+
const cs = getComputedStyle(e);
|
|
92
|
+
const bc = toRGBA(cs.backgroundColor);
|
|
93
|
+
if (bc && bc[3] >= 0.5) return { rgb: bc.slice(0, 3), found: true };
|
|
94
|
+
if (cs.backgroundImage && cs.backgroundImage !== 'none') {
|
|
95
|
+
const cols = (cs.backgroundImage.match(/rgba?\([^)]+\)|#[0-9a-fA-F]{3,8}/g) || []).map(toRGB).filter(Boolean);
|
|
96
|
+
if (cols.length) return { rgb: cols.reduce((d, c) => relLum(c) < relLum(d) ? c : d), found: true };
|
|
97
|
+
}
|
|
98
|
+
e = e.parentElement;
|
|
99
|
+
}
|
|
100
|
+
return { rgb: [255, 255, 255], found: false };
|
|
101
|
+
};
|
|
85
102
|
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
103
|
const directText = (el) => [...el.childNodes].filter((n) => n.nodeType === 3).map((n) => n.textContent).join('').trim();
|
|
87
104
|
const matchAny = (el, sel) => { try { return el.closest(sel); } catch { return null; } };
|
|
105
|
+
// rendered line geometry of an element's own text — Range over each word, group by line-top.
|
|
106
|
+
// returns [lineCount, wordsOnLastLine] so callers can detect both wraps and orphaned words.
|
|
107
|
+
const lineInfo = (el) => {
|
|
108
|
+
const node = [...el.childNodes].find((n) => n.nodeType === 3 && n.textContent.trim());
|
|
109
|
+
if (!node) return [1, 1];
|
|
110
|
+
const words = node.textContent.trim().split(/\s+/);
|
|
111
|
+
if (words.length < 2) return [1, 1];
|
|
112
|
+
const range = document.createRange(); const tops = []; let idx = 0;
|
|
113
|
+
for (const w of words) {
|
|
114
|
+
const start = node.textContent.indexOf(w, idx); if (start < 0) continue;
|
|
115
|
+
range.setStart(node, start); range.setEnd(node, start + w.length);
|
|
116
|
+
const rr = range.getClientRects()[0]; if (rr) tops.push(Math.round(rr.top));
|
|
117
|
+
idx = start + w.length;
|
|
118
|
+
}
|
|
119
|
+
if (!tops.length) return [1, 1];
|
|
120
|
+
const uniq = [...new Set(tops)].sort((a, b) => a - b);
|
|
121
|
+
const last = uniq[uniq.length - 1];
|
|
122
|
+
return [uniq.length, tops.filter((t) => Math.abs(t - last) <= 2).length];
|
|
123
|
+
};
|
|
88
124
|
|
|
89
125
|
const o = {
|
|
90
126
|
dashes: [], titlePeriods: [], glass: [], inputBorders: [], uppercase: [], offPalette: {},
|
|
91
127
|
wideText: [], offScale: {}, onScaleN: 0, scaleN: 0, denseSections: [], fontSizes: {}, foldColors: new Set(),
|
|
92
128
|
blueOnDark: [], nonPill: [], flatColorBtn: [], wrongAnchor: [], wrongEase: [],
|
|
93
129
|
offRadius: {}, harshShadow: [], linkUnderline: [], addrNotMono: [], goldIcon: [],
|
|
130
|
+
fauxWeight: [], tightTrack: [], headingNoBalance: [], orphanLine: [],
|
|
131
|
+
tightLeadingH: [], tightLeadingBody: [], offFont: {},
|
|
132
|
+
blackBg: [], accentStripe: [], greenAction: [], dupGradIds: [], genericIcon: [], featherIcon: [],
|
|
133
|
+
bakedIcon: [], duotoneBlack: [], modalBlack: [], flatNavySection: [], glowShadow: [], fontsNotLoaded: false, noFocusRing: false,
|
|
134
|
+
lowContrast: [], h1Count: 0, unconstrained: [], amountColor: [], addrStructure: [], pulseDot: [],
|
|
94
135
|
};
|
|
95
136
|
|
|
96
137
|
for (const el of document.querySelectorAll('body *')) {
|
|
@@ -107,7 +148,37 @@ function pageProbe({ SPACING_SCALE, ANCHORS, RADIUS_SCALE }) {
|
|
|
107
148
|
if (txt && !inCode && /[—–]/.test(txt)) o.dashes.push(txt.slice(0, 70));
|
|
108
149
|
|
|
109
150
|
const isTitle = /^h[1-4]$/.test(tag) || el.matches('button,.nq-button,[class*="title" i],[class*="heading" i],[class*="headline" i]');
|
|
110
|
-
|
|
151
|
+
// a display title / CTA ends clean; exempt full sentences (comma/colon/semicolon = descriptive
|
|
152
|
+
// lead marked as a heading, which nimiq.com/about does and ends with a period legitimately).
|
|
153
|
+
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)}`);
|
|
154
|
+
|
|
155
|
+
// HEADLINE TYPOGRAPHY — faux-black weight, tight negative tracking, orphaned wraps.
|
|
156
|
+
// Calibrated to nimiq.com: every heading renders at weight 600/700, letter-spacing 0,
|
|
157
|
+
// and text-wrap:balance. So weight ≥800, any meaningful negative tracking, or a multi-line
|
|
158
|
+
// heading that DIDN'T opt into balance/pretty is off-brand. Buttons excluded (legit bold).
|
|
159
|
+
const isHeadingEl = /^h[1-4]$/.test(tag) || el.matches('[class*="title" i],[class*="heading" i],[class*="headline" i]');
|
|
160
|
+
if (isHeadingEl && txt) {
|
|
161
|
+
const fs = px(cs.fontSize);
|
|
162
|
+
if (fs >= 24 && +cs.fontWeight >= 800) o.fauxWeight.push(`<${tag}> weight ${cs.fontWeight} @ ${fs}px "${txt.slice(0, 28)}"`);
|
|
163
|
+
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)}"`); }
|
|
164
|
+
if (fs >= 24 && cs.textTransform !== 'uppercase') {
|
|
165
|
+
const lsRaw = parseFloat(cs.letterSpacing);
|
|
166
|
+
if (!Number.isNaN(lsRaw) && lsRaw / fs <= -0.01) o.tightTrack.push(`<${tag}> ${(lsRaw / fs).toFixed(3)}em @ ${fs}px "${txt.slice(0, 24)}"`);
|
|
167
|
+
}
|
|
168
|
+
if (fs >= 20) {
|
|
169
|
+
const tw = cs.textWrap || cs.textWrapStyle || cs.textWrapMode || '';
|
|
170
|
+
const lc = lineInfo(el)[0];
|
|
171
|
+
if (!/balance|pretty/.test(tw) && lc >= 2) o.headingNoBalance.push(`<${tag}> ${lc} lines, no text-wrap:balance "${txt.slice(0, 24)}"`);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// ORPHAN — any prominent text block (≥20px) that wraps and strands a single word on its last
|
|
176
|
+
// line. nimiq.com has ZERO of these (headings use text-wrap:balance; nothing orphans), so this
|
|
177
|
+
// is calibrated to 0 on the reference. Leaf elements only = measure the real text-painter.
|
|
178
|
+
if (txt && el.children.length === 0 && !inCode && px(cs.fontSize) >= 20) {
|
|
179
|
+
const [lc, lw] = lineInfo(el);
|
|
180
|
+
if (lc >= 2 && lw === 1) o.orphanLine.push(`<${tag}> ${lc} lines, last word alone "${txt.slice(0, 38)}"`);
|
|
181
|
+
}
|
|
111
182
|
|
|
112
183
|
// glassmorphism = a TRANSLUCENT surface with a backdrop blur (the frosted-glass card).
|
|
113
184
|
if (cs.backdropFilter && cs.backdropFilter !== 'none' && /blur\(/.test(cs.backdropFilter)) {
|
|
@@ -142,6 +213,45 @@ function pageProbe({ SPACING_SCALE, ANCHORS, RADIUS_SCALE }) {
|
|
|
142
213
|
}
|
|
143
214
|
}
|
|
144
215
|
|
|
216
|
+
// GENERAL TEXT CONTRAST (WARNING, WCAG AA) — only where the background is actually determinable
|
|
217
|
+
// (effBgF.found), skipping ratio<1.6 = an undetectable layered-hero bg, not a real defect.
|
|
218
|
+
// NOTE: nimiq.com itself ships sub-AA secondary grey (≈2.37:1), so this WARNS, never gates.
|
|
219
|
+
if (txt && !inCode && fg && el.children.length === 0 && (toRGBA(cs.color)?.[3] ?? 1) > 0.3) {
|
|
220
|
+
const cb = effBgF(el);
|
|
221
|
+
if (cb.found) {
|
|
222
|
+
const fsc = px(cs.fontSize), large = fsc >= 24 || (fsc >= 18.66 && +cs.fontWeight >= 700);
|
|
223
|
+
const ratio = contrast(fg, cb.rgb);
|
|
224
|
+
if (ratio >= 1.6 && ratio < (large ? 3.0 : 4.5)) o.lowContrast.push({ ratio, fg: `rgb(${fg.join(',')})`, bg: `rgb(${cb.rgb.map((x) => Math.round(x)).join(',')})`, fs: fsc, snippet: txt.slice(0, 26) });
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// NIM amount / %-change semantic color (WARNING): incoming +N green; outgoing −N NIM navy;
|
|
229
|
+
// %-change green up / red down (skill Addresses). Only fires on wallet/POS surfaces.
|
|
230
|
+
if (txt && !inCode && fg) {
|
|
231
|
+
const m = txt.trim().match(/^([+\-−])\s?[\d][\d., ]*\s?(NIM|%)$/i);
|
|
232
|
+
if (m) {
|
|
233
|
+
const up = m[1] === '+', pct = /%$/.test(txt.trim());
|
|
234
|
+
const greenish = dist(fg, ANCHORS.green) < 60 || (hue(fg) >= 140 && hue(fg) <= 178 && sat(fg) > 0.2);
|
|
235
|
+
const reddish = dist(fg, ANCHORS.red) < 70 || (hue(fg) <= 18 && sat(fg) > 0.35);
|
|
236
|
+
const navyish = gray(fg) || dist(fg, ANCHORS.navy) < 70;
|
|
237
|
+
const ok = up ? greenish : (pct ? reddish : (navyish || reddish));
|
|
238
|
+
if (!ok) o.amountColor.push(`"${txt.slice(0, 18)}" ${up ? 'increase not green' : pct ? 'decrease not red' : 'outgoing not navy/red'}`);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// live address structure (WARNING): a display NIM address (≥20px) must be uppercase + chunked
|
|
243
|
+
// into the 3×3 grid, not one flat lowercased string (skill Addresses).
|
|
244
|
+
if (txt && /^NQ[0-9A-Z]{2}(\s?[0-9A-Z]{4}){8}$/i.test(txt.replace(/\s+/g, ' ').trim()) && px(cs.fontSize) >= 20) {
|
|
245
|
+
if (txt !== txt.toUpperCase()) o.addrStructure.push(`address not uppercase "${txt.slice(0, 22)}…"`);
|
|
246
|
+
else if (el.children.length === 0) o.addrStructure.push('address as one flat string (use the 3×3 grid)');
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// pulsing "live" dot (slop): a small round element on an infinite animation that isn't a spinner.
|
|
250
|
+
if (cs.animationName && cs.animationName !== 'none' && /(^|,)\s*infinite\s*(,|$)/.test(cs.animationIterationCount) && r.width <= 28 && r.height <= 28) {
|
|
251
|
+
const round = cs.borderRadius.includes('50%') || px(cs.borderRadius) >= Math.min(r.width, r.height) / 2 - 1;
|
|
252
|
+
if (round && !matchAny(el, '[class*="spin" i],[class*="load" i],[class*="progress" i],[class*="skeleton" i]')) o.pulseDot.push(`<${tag}> infinite pulse on a ${Math.round(r.width)}×${Math.round(r.height)} dot`);
|
|
253
|
+
}
|
|
254
|
+
|
|
145
255
|
// BUTTON shape + fill + gradient anchor
|
|
146
256
|
if (isBtn && txt && r.width >= 80 && r.height >= 26 && !el.matches('.nq-button-s')) {
|
|
147
257
|
const rad = px(cs.borderRadius);
|
|
@@ -150,6 +260,12 @@ function pageProbe({ SPACING_SCALE, ANCHORS, RADIUS_SCALE }) {
|
|
|
150
260
|
const bgRgb = toRGB(cs.backgroundColor); const bgA = toRGBA(cs.backgroundColor)?.[3] ?? 0;
|
|
151
261
|
const brandFill = bgRgb && bgA > 0.5 && !gray(bgRgb) && nearest(bgRgb, ANCHORS).d < 60 && relLum(bgRgb) < 0.7 && nearest(bgRgb, ANCHORS).name !== 'white';
|
|
152
262
|
if (brandFill && cs.backgroundImage === 'none') o.flatColorBtn.push(`<${tag}> flat ${cs.backgroundColor} "${txt.slice(0, 20)}"`);
|
|
263
|
+
// green = success ONLY (rule 5): an action/retry button must not be green-filled
|
|
264
|
+
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)) {
|
|
265
|
+
const grads = (cs.backgroundImage.match(/rgba?\([^)]+\)|#[0-9a-fA-F]{3,8}/g) || []).map(toRGB).filter(Boolean);
|
|
266
|
+
const fill = (bgA > 0.5 ? bgRgb : null) || grads[0];
|
|
267
|
+
if (fill && (dist(fill, ANCHORS.green) < 55 || dist(fill, ANCHORS.greenGrad) < 55)) o.greenAction.push(`<${tag}> green "${txt.slice(0, 22)}"`);
|
|
268
|
+
}
|
|
153
269
|
if (/gradient/.test(cs.backgroundImage)) {
|
|
154
270
|
if (/linear-gradient/.test(cs.backgroundImage)) o.wrongAnchor.push(`<${tag}> linear-gradient (use radial)`);
|
|
155
271
|
else if (!/(bottom right|100% 100%|100%100%|at 100%)/.test(cs.backgroundImage)) o.wrongAnchor.push(`<${tag}> radial not bottom-right`);
|
|
@@ -172,6 +288,28 @@ function pageProbe({ SPACING_SCALE, ANCHORS, RADIUS_SCALE }) {
|
|
|
172
288
|
const m = cs.boxShadow.match(/rgba\(0,\s*0,\s*0,\s*([0-9.]+)\)/);
|
|
173
289
|
if (m && parseFloat(m[1]) > 0.22) o.harshShadow.push(`<${tag}> rgba(0,0,0,${m[1]})`);
|
|
174
290
|
}
|
|
291
|
+
// colored "glow" shadow (slop): Nimiq elevation is navy/black low-alpha, never a saturated glow.
|
|
292
|
+
// The luminance gate (>0.15) excludes the brand's dark navy shadow (lum ≈ 0.02) so only a bright
|
|
293
|
+
// gold/green/blue glow trips it.
|
|
294
|
+
for (const shadowProp of [cs.boxShadow, /drop-shadow/.test(cs.filter) ? cs.filter : '']) {
|
|
295
|
+
if (!shadowProp || shadowProp === 'none') continue;
|
|
296
|
+
for (const c of shadowProp.match(/rgba?\([^)]+\)|#[0-9a-fA-F]{3,8}/g) || []) {
|
|
297
|
+
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; }
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
// modal/overlay scrim must be navy, not black (rule 11): a full-viewport fixed translucent layer
|
|
301
|
+
// whose base is near-neutral black (r≈g≈b, all low) — navy (b≫r) is excluded.
|
|
302
|
+
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)})`); }
|
|
303
|
+
// pure-black surface (rule 6): Nimiq dark is navy #1F2348 / #171D2E, never #000 / near-black
|
|
304
|
+
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(',')})`); }
|
|
305
|
+
// one-sided vertical accent stripe on a card (rule 19): left/right edge ≥3px colored, opposite 0
|
|
306
|
+
if (r.width * r.height > 5000 && (toRGBA(cs.backgroundColor)?.[3] > 0.05 || cs.boxShadow !== 'none')) {
|
|
307
|
+
for (const [side, opp] of [['Left', 'Right'], ['Right', 'Left']]) {
|
|
308
|
+
if (px(cs[`border${side}Width`]) >= 3 && px(cs[`border${opp}Width`]) === 0) {
|
|
309
|
+
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; }
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
}
|
|
175
313
|
// link styling — underlined body anchors (Nimiq links are bold, no underline)
|
|
176
314
|
if (tag === 'a' && txt && !matchAny(el, 'nav,header,footer') && cs.textDecorationLine.includes('underline')) o.linkUnderline.push(`"${txt.slice(0, 30)}"`);
|
|
177
315
|
// data formatting — NIM-address-looking text not in a mono font
|
|
@@ -183,8 +321,13 @@ function pageProbe({ SPACING_SCALE, ANCHORS, RADIUS_SCALE }) {
|
|
|
183
321
|
if ((tag === 'p' || el.matches('[class*="text" i],[class*="body" i],[class*="desc" i]')) && txt.length > 60 && el.children.length === 0) {
|
|
184
322
|
const fs = px(cs.fontSize) || 16; const ch = Math.round(r.width / (fs * 0.5));
|
|
185
323
|
if (ch > 88) o.wideText.push({ ch, snippet: txt.slice(0, 50) });
|
|
324
|
+
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)}"`);
|
|
325
|
+
// unconstrained text column (rule: cap the measure) — nimiq's wide copy always sets a max-width
|
|
326
|
+
if (cs.maxWidth === 'none' && r.width > 760) o.unconstrained.push({ w: Math.round(r.width), ch, snippet: txt.slice(0, 40) });
|
|
186
327
|
}
|
|
187
328
|
if (txt) { const fs = px(cs.fontSize); if (fs) o.fontSizes[fs] = (o.fontSizes[fs] || 0) + 1; }
|
|
329
|
+
// font-family — Nimiq text is Mulish (+ Fira Mono for data). A named third-party face is off-brand.
|
|
330
|
+
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
331
|
if (r.top < 1024 && r.bottom > 0 && txt && !inCode) o.foldColors.add(cs.color);
|
|
189
332
|
}
|
|
190
333
|
|
|
@@ -194,7 +337,61 @@ function pageProbe({ SPACING_SCALE, ANCHORS, RADIUS_SCALE }) {
|
|
|
194
337
|
if (matchAny(el, '[class*="logo" i],[class*="brand" i],[class*="hex" i],[class*="pay" i],[class*="badge" i],[aria-label*="nimiq" i]')) continue;
|
|
195
338
|
const cs = getComputedStyle(el);
|
|
196
339
|
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; } }
|
|
340
|
+
// Lucide/Feather/Tabler fingerprint: a 24×24 viewBox where EVERY path is 2px stroke + fill:none.
|
|
341
|
+
// Nimiq's own 24×24 icon (close.svg) is a FILLED path, so it can't match this composite.
|
|
342
|
+
if (el.tagName.toLowerCase() === 'svg' && el.getAttribute('viewBox') === '0 0 24 24' && cs.fill === 'none') {
|
|
343
|
+
const draw = [...el.querySelectorAll('path,line,polyline,polygon,circle,rect')];
|
|
344
|
+
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)');
|
|
345
|
+
}
|
|
346
|
+
// baked-color + black-outline duotone — Nimiq mono icons paint via `currentColor`, never a
|
|
347
|
+
// literal hex (skill Icons). Icon-role = small square SVG, not an identicon/flag/chart/logo and
|
|
348
|
+
// with no gradient/image def (those are meant to be multi-colored).
|
|
349
|
+
if (el.tagName.toLowerCase() === 'svg') {
|
|
350
|
+
const ir = el.getBoundingClientRect();
|
|
351
|
+
const small = ir.width <= 130 && ir.height <= 130 && Math.abs(ir.width - ir.height) < Math.max(ir.width, ir.height, 1) * 0.5;
|
|
352
|
+
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');
|
|
353
|
+
if (small && !decorative) {
|
|
354
|
+
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); };
|
|
355
|
+
let baked = false, hasBlack = false, hasColor = false;
|
|
356
|
+
for (const p of [el, ...el.querySelectorAll('path,circle,rect,line,polyline,polygon,ellipse')]) {
|
|
357
|
+
for (const attr of ['fill', 'stroke']) {
|
|
358
|
+
const v = p.getAttribute && p.getAttribute(attr);
|
|
359
|
+
if (isLit(v)) { baked = true; const rgb = toRGB(v); if (rgb) { if (Math.max(...rgb) <= 40) hasBlack = true; else if (!gray(rgb)) hasColor = true; } }
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
if (baked) o.bakedIcon.push(`<svg ${(el.getAttribute('class') || '').slice(0, 22)}> baked fill (use currentColor)`);
|
|
363
|
+
if (hasBlack && hasColor) o.duotoneBlack.push('<svg> black layer on a colored icon (duotone = same color @0.4)');
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// generic / off-brand icon SETS (Lucide, Feather, Font Awesome, Material, Bootstrap, Tabler…).
|
|
369
|
+
// Nimiq ships its own SVGs under `nq-icon`; it never emits a library class (verified 0 in registry).
|
|
370
|
+
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;
|
|
371
|
+
const ICON_OK = /\b(nq-icon|flag-icon|flag-icons|fi-)\b/;
|
|
372
|
+
const seenGeneric = new Set();
|
|
373
|
+
for (const el of document.querySelectorAll('svg,i,span,[class*="icon" i],[data-lucide],[data-feather],[data-icon]')) {
|
|
374
|
+
if (!visible(el)) continue;
|
|
375
|
+
const cls = `${el.getAttribute('class') || ''}`;
|
|
376
|
+
const libClass = ICON_LIB.test(cls) && !ICON_OK.test(cls);
|
|
377
|
+
const libData = el.hasAttribute('data-lucide') || el.hasAttribute('data-feather');
|
|
378
|
+
const ligature = /material-icons|material-symbols/i.test(cls) && el.children.length === 0 && /^[a-z][a-z_]{1,}$/i.test((el.textContent || '').trim());
|
|
379
|
+
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)}">`); } }
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// duplicate gradient ids — only a REAL bug when defs sharing one id have DIFFERENT stops (then a
|
|
383
|
+
// later SVG paints the first def's gradient). nimiq.com repeats IDENTICAL icon gradients under the
|
|
384
|
+
// same id (Figma export) and renders fine, so compare stop colors+offsets, not raw id counts.
|
|
385
|
+
const gradById = {};
|
|
386
|
+
for (const g of document.querySelectorAll('linearGradient[id],radialGradient[id]')) {
|
|
387
|
+
const sig = [...g.querySelectorAll('stop')].map((s) => `${getComputedStyle(s).stopColor || s.getAttribute('stop-color') || ''}@${s.getAttribute('offset') || ''}`).join(',');
|
|
388
|
+
(gradById[g.id] ??= new Set()).add(sig);
|
|
197
389
|
}
|
|
390
|
+
o.dupGradIds = Object.entries(gradById).filter(([, sigs]) => sigs.size > 1).map(([id, sigs]) => `#${id} (${sigs.size} differing defs)`);
|
|
391
|
+
|
|
392
|
+
// heading hierarchy — exactly one <h1> per page. (Level SKIPS are NOT checked: nimiq.com/about
|
|
393
|
+
// jumps h1→h4 for styling, so a no-skip rule would flag the reference.)
|
|
394
|
+
o.h1Count = [...document.querySelectorAll('h1')].filter((e) => visible(e)).length;
|
|
198
395
|
|
|
199
396
|
// per-section text-ink ratio (full-width bands)
|
|
200
397
|
const vw = innerWidth;
|
|
@@ -202,11 +399,31 @@ function pageProbe({ SPACING_SCALE, ANCHORS, RADIUS_SCALE }) {
|
|
|
202
399
|
for (const el of document.querySelectorAll('section,main > div,body > div,[class*="section" i],[class*="band" i]')) {
|
|
203
400
|
if (matchAny(el, 'svg') || !visible(el)) continue;
|
|
204
401
|
const r = el.getBoundingClientRect(); if (r.width < vw * 0.7 || r.height < 220) continue;
|
|
402
|
+
// flat-fill navy/colored section (rule 7): a full-width band solid-filled in a brand color with
|
|
403
|
+
// NO gradient — Nimiq colored bands use the bottom-right radial. Grey/white cards are exempt.
|
|
404
|
+
const scs = getComputedStyle(el); const sbg = toRGB(scs.backgroundColor); const sbgA = toRGBA(scs.backgroundColor)?.[3] ?? 0;
|
|
405
|
+
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
406
|
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
407
|
const pct = Math.round((ink / (r.width * r.height)) * 1000) / 10;
|
|
207
408
|
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
409
|
}
|
|
209
410
|
o.foldColors = o.foldColors.size;
|
|
411
|
+
|
|
412
|
+
// Mulish must actually load (rule 9) — a declared family that 404s falls back to a system font.
|
|
413
|
+
o.fontsNotLoaded = !document.fonts.check('1em Mulish') && !document.fonts.check('1em Muli');
|
|
414
|
+
// focus ring removed without a :focus-visible restore (a11y). Scan accessible stylesheets only;
|
|
415
|
+
// cross-origin CDN sheets throw on .cssRules and are skipped (so we never guess).
|
|
416
|
+
let killsOutline = false, restoresFocus = false;
|
|
417
|
+
for (const sheet of document.styleSheets) {
|
|
418
|
+
let rules; try { rules = sheet.cssRules; } catch { continue; }
|
|
419
|
+
if (!rules) continue;
|
|
420
|
+
for (const rule of rules) {
|
|
421
|
+
const sel = rule.selectorText, st = rule.style; if (!sel || !st) continue;
|
|
422
|
+
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;
|
|
423
|
+
if (/(^|[\s,])(a|button|input|select|textarea|\*|\[tabindex\]|:focus)([\s,:>[]|$)/i.test(sel) && /outline\s*:\s*(none|0)/i.test(st.cssText)) killsOutline = true;
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
o.noFocusRing = killsOutline && !restoresFocus;
|
|
210
427
|
return o;
|
|
211
428
|
}
|
|
212
429
|
|
|
@@ -237,6 +454,13 @@ function fixSource(src) {
|
|
|
237
454
|
let out = src;
|
|
238
455
|
out = replaceText(out, (t) => t.replace(/\s*[—–]\s*/g, () => { fixes.push('dash→comma'); return ', '; }));
|
|
239
456
|
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; });
|
|
457
|
+
// Inject the balance/pretty rule nimiq.com applies to EVERY heading — the mechanical cure for
|
|
458
|
+
// orphaned-title line breaks. Non-destructive (only affects wrapping), and only added once.
|
|
459
|
+
if (!/text-wrap\s*:\s*(balance|pretty)/i.test(out)) {
|
|
460
|
+
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';
|
|
461
|
+
if (/<\/head>/i.test(out)) { out = out.replace(/<\/head>/i, block + '</head>'); fixes.push('text-wrap:balance'); }
|
|
462
|
+
else if (/<body[^>]*>/i.test(out)) { out = out.replace(/(<body[^>]*>)/i, `$1${block}`); fixes.push('text-wrap:balance'); }
|
|
463
|
+
}
|
|
240
464
|
return { out, fixes };
|
|
241
465
|
}
|
|
242
466
|
|
|
@@ -277,6 +501,10 @@ export async function lint(target, opts = {}) {
|
|
|
277
501
|
['borders on inputs', r.inputBorders.length, r.inputBorders[0], 'inset box-shadow'],
|
|
278
502
|
['off-palette colors', offPal.length, offPal[0] && `${offPal[0][0]} (≈${offPal[0][1].near}, Δ${offPal[0][1].d})`, 'manual'],
|
|
279
503
|
['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'],
|
|
504
|
+
['generic icon set (Lucide / FA / Material…)', r.genericIcon.length, r.genericIcon[0], 'use nq-icon SVGs'],
|
|
505
|
+
['duplicate gradient id (SVGs misrender)', r.dupGradIds.length, r.dupGradIds[0], 'unique ids'],
|
|
506
|
+
['pure-black surface (rule 6)', r.blackBg.length, r.blackBg[0], 'navy #1F2348'],
|
|
507
|
+
['one-sided accent stripe (rule 19)', r.accentStripe.length, r.accentStripe[0], 'uniform border'],
|
|
280
508
|
];
|
|
281
509
|
errorCount = errs.reduce((n, e) => n + e[1], 0);
|
|
282
510
|
|
|
@@ -284,11 +512,25 @@ export async function lint(target, opts = {}) {
|
|
|
284
512
|
const topOff = Object.entries(r.offScale).sort((a, b) => b[1] - a[1]).slice(0, 6);
|
|
285
513
|
const topRad = Object.entries(r.offRadius).sort((a, b) => b[1] - a[1]).slice(0, 5);
|
|
286
514
|
const sizes = Object.keys(r.fontSizes).map(Number).sort((a, b) => a - b);
|
|
515
|
+
const offFont = Object.entries(r.offFont).filter(([, n]) => n >= 3).sort((a, b) => b[1] - a[1]);
|
|
287
516
|
const warns = [
|
|
288
517
|
['body text wider than ~88ch', r.wideText.length, r.wideText.length && `worst ${Math.max(...r.wideText.map((w) => w.ch))}ch`],
|
|
289
518
|
['dense sections (>18% text ink)', r.denseSections.length, r.denseSections.slice(0, 4).map((d) => `${d.pct}% <${d.tag}.${d.cls}>`).join(' ')],
|
|
290
519
|
['off-scale spacing (snap to scale)', topOff.length, `${scalePct}% on-scale · ${topOff.map(([v, n]) => `${v}px×${n}`).join(' ')}`],
|
|
291
520
|
['type-scale sprawl', sizes.length > FONT_SPRAWL_WARN ? sizes.length : 0, `${sizes.length} sizes [${sizes.join(',')}]`],
|
|
521
|
+
['faux-black headline weight (≥800; nimiq ≤700)', r.fauxWeight.length, r.fauxWeight[0]],
|
|
522
|
+
['tight negative heading tracking (nimiq = 0)', r.tightTrack.length, r.tightTrack[0]],
|
|
523
|
+
['wrapping heading w/o text-wrap: balance', r.headingNoBalance.length, r.headingNoBalance[0]],
|
|
524
|
+
['orphaned last word (1-word final line)', r.orphanLine.length, r.orphanLine[0]],
|
|
525
|
+
['heading line-height off (nimiq 1.25–1.3)', r.tightLeadingH.length, r.tightLeadingH[0]],
|
|
526
|
+
['cramped body line-height (<1.35; nimiq 1.5)', r.tightLeadingBody.length, r.tightLeadingBody[0]],
|
|
527
|
+
['non-brand font on text (Mulish / Fira only)', offFont.length, offFont.map(([f, n]) => `${f}×${n}`).join(' ')],
|
|
528
|
+
['text contrast below WCAG AA', r.lowContrast.length, r.lowContrast.length && `worst ${Math.min(...r.lowContrast.map((c) => c.ratio))}:1 — ${r.lowContrast[0].fg} on ${r.lowContrast[0].bg} @ ${r.lowContrast[0].fs}px`],
|
|
529
|
+
['multiple <h1> on the page', r.h1Count > 1 ? r.h1Count : 0, r.h1Count > 1 ? `${r.h1Count} h1 elements` : ''],
|
|
530
|
+
['unconstrained text column (set max-width)', r.unconstrained.length, r.unconstrained[0] && `${r.unconstrained[0].w}px (${r.unconstrained[0].ch}ch)`],
|
|
531
|
+
['NIM amount wrong semantic color', r.amountColor.length, r.amountColor[0]],
|
|
532
|
+
['address not uppercase / flat (use 3×3 grid)', r.addrStructure.length, r.addrStructure[0]],
|
|
533
|
+
['pulsing "live" dot animation', r.pulseDot.length, r.pulseDot[0]],
|
|
292
534
|
['uppercase eyebrow (colored / long / pill)', r.uppercase.length, r.uppercase[0]],
|
|
293
535
|
['non-pill action buttons', r.nonPill.length, r.nonPill[0]],
|
|
294
536
|
['flat-fill colored button (needs gradient)', r.flatColorBtn.length, r.flatColorBtn[0]],
|
|
@@ -299,6 +541,15 @@ export async function lint(target, opts = {}) {
|
|
|
299
541
|
['underlined links (use bold, no underline)', r.linkUnderline.length, r.linkUnderline[0]],
|
|
300
542
|
['NIM address not in Fira Mono', r.addrNotMono.length, r.addrNotMono[0]],
|
|
301
543
|
['gold-tinted UI icon (gold = logo only)', r.goldIcon.length, r.goldIcon[0]],
|
|
544
|
+
['feather/lucide-style stroke icon (inlined)', r.featherIcon.length, r.featherIcon[0]],
|
|
545
|
+
['baked-color icon (use currentColor)', r.bakedIcon.length, r.bakedIcon[0]],
|
|
546
|
+
['black-outline duotone icon', r.duotoneBlack.length, r.duotoneBlack[0]],
|
|
547
|
+
['green action / retry button (green = success)', r.greenAction.length, r.greenAction[0]],
|
|
548
|
+
['colored glow shadow (navy elevation only)', r.glowShadow.length, r.glowShadow[0]],
|
|
549
|
+
['black modal overlay (use navy rgba)', r.modalBlack.length, r.modalBlack[0]],
|
|
550
|
+
['flat-fill navy/colored section (use radial)', r.flatNavySection.length, r.flatNavySection[0]],
|
|
551
|
+
['focus outline removed w/o :focus-visible', r.noFocusRing ? 1 : 0, r.noFocusRing ? 'add a :focus-visible ring' : ''],
|
|
552
|
+
['Mulish not loaded (system-font fallback)', r.fontsNotLoaded ? 1 : 0, r.fontsNotLoaded ? 'load Mulish' : ''],
|
|
302
553
|
['mobile horizontal overflow @390px', mob.overflowPx > 4 ? 1 : 0, `${mob.overflowPx}px`],
|
|
303
554
|
['mobile tap targets < 36px', mob.smallTargetN, mob.smallTargets[0]],
|
|
304
555
|
['text smaller than 12px @390px', mob.tinyText, `${mob.tinyText} element type(s)`],
|
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
|
+
});
|