nimiq-branding-cli 1.2.1 → 1.3.0

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