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 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 + depth/motion checks |
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 |
@@ -38,7 +38,8 @@
38
38
  "settlementPackage": "nimiq-settlement",
39
39
  "lightClient": {
40
40
  "forbiddenImports": ["@nimiq/core/web"],
41
- "forbiddenCalls": ["Client.create(", "waitForConsensusEstablished("],
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
- SessionStart banner, weekly GH Action (--write drops the workflow)
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.2.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
- if (isTitle && txt && /[a-z0-9)]\.$/i.test(txt) && !/\.\.\.$/.test(txt) && !/\.[a-z]?\.$/i.test(txt)) o.titlePeriods.push(`<${tag}> ${txt.slice(0, 60)}`);
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)`],
@@ -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( and waitForConsensusEstablished( in src', async () => {
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
- assert.ok(r.axes.settlement.lines.some(l => l.includes('waitForConsensusEstablished(')));
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
+ });