nimiq-branding-cli 1.3.0 → 1.3.2

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
@@ -93,6 +93,17 @@ to flag *"this is getting busy, is it justified?"* — the deterministic half of
93
93
  |---|---|
94
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
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 |
96
107
 
97
108
  **Headline typography & line breaks** (closes the "lint passes ≠ brand-correct" gap)
98
109
 
@@ -118,13 +129,18 @@ Weight/tracking are heading-scoped (buttons and bold body labels are legitimatel
118
129
  orphan check is general (it caught a body banner, not a heading) but gated at ≥ 20px so calibrated
119
130
  fine-print under 20px — which wasn't measured on the reference — is never flagged.
120
131
 
121
- **Mobile (a second pass at 390px)**
132
+ **Responsive sweep (360 · 414 · 768 · 1024 · 1280px)**
133
+
134
+ The desktop pass renders at 1440; this sweep re-measures at five more widths so a layout that's
135
+ clean at phone *and* desktop but **breaks in the tablet / small-laptop no-man's-land** is caught —
136
+ the single most common responsive bug a fixed 390+1440 check misses. Measured: nimiq.com has **zero
137
+ horizontal overflow at every width** 320→1440, so overflow-at-a-breakpoint is calibrated to 0.
122
138
 
123
139
  | Check | Threshold |
124
140
  |---|---|
125
- | horizontal overflow | `scrollWidth > viewport` by > 4px |
126
- | tap targets too small | a button / input / link-button under 30px (inline text links are exempt) |
127
- | text smaller than 12px | any text element below 12px at mobile width |
141
+ | horizontal overflow at a breakpoint | `scrollWidth > viewport` by > 4px at **any** swept width (reports which widths and by how much) |
142
+ | tap targets too small | a button / input / link-button under 30px at any width (reports the worst breakpoint) |
143
+ | text smaller than 12px | any text element below 12px at any width (reports the worst breakpoint) |
128
144
 
129
145
  The curated spacing scale (from `assets/css/modern/spacing.css`, desktop max of each step):
130
146
  `8 · 12 · 16 · 24 · 32 · 40 · 48 · 72 · 80 · 96 · 144 · 200`. Sub-8px is treated as optical
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nimiq-branding-cli",
3
- "version": "1.3.0",
3
+ "version": "1.3.2",
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/lint.mjs CHANGED
@@ -82,6 +82,23 @@ 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; } };
@@ -114,6 +131,7 @@ function pageProbe({ SPACING_SCALE, ANCHORS, RADIUS_SCALE }) {
114
131
  tightLeadingH: [], tightLeadingBody: [], offFont: {},
115
132
  blackBg: [], accentStripe: [], greenAction: [], dupGradIds: [], genericIcon: [], featherIcon: [],
116
133
  bakedIcon: [], duotoneBlack: [], modalBlack: [], flatNavySection: [], glowShadow: [], fontsNotLoaded: false, noFocusRing: false,
134
+ lowContrast: [], h1Count: 0, unconstrained: [], amountColor: [], addrStructure: [], pulseDot: [],
117
135
  };
118
136
 
119
137
  for (const el of document.querySelectorAll('body *')) {
@@ -195,6 +213,45 @@ function pageProbe({ SPACING_SCALE, ANCHORS, RADIUS_SCALE }) {
195
213
  }
196
214
  }
197
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
+
198
255
  // BUTTON shape + fill + gradient anchor
199
256
  if (isBtn && txt && r.width >= 80 && r.height >= 26 && !el.matches('.nq-button-s')) {
200
257
  const rad = px(cs.borderRadius);
@@ -265,6 +322,8 @@ function pageProbe({ SPACING_SCALE, ANCHORS, RADIUS_SCALE }) {
265
322
  const fs = px(cs.fontSize) || 16; const ch = Math.round(r.width / (fs * 0.5));
266
323
  if (ch > 88) o.wideText.push({ ch, snippet: txt.slice(0, 50) });
267
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) });
268
327
  }
269
328
  if (txt) { const fs = px(cs.fontSize); if (fs) o.fontSizes[fs] = (o.fontSizes[fs] || 0) + 1; }
270
329
  // font-family — Nimiq text is Mulish (+ Fira Mono for data). A named third-party face is off-brand.
@@ -330,6 +389,10 @@ function pageProbe({ SPACING_SCALE, ANCHORS, RADIUS_SCALE }) {
330
389
  }
331
390
  o.dupGradIds = Object.entries(gradById).filter(([, sigs]) => sigs.size > 1).map(([id, sigs]) => `#${id} (${sigs.size} differing defs)`);
332
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;
395
+
333
396
  // per-section text-ink ratio (full-width bands)
334
397
  const vw = innerWidth;
335
398
  const leaves = [...document.querySelectorAll('body *')].filter((el) => !matchAny(el, 'svg') && visible(el) && el.children.length === 0 && directText(el)).map((el) => el.getBoundingClientRect());
@@ -420,10 +483,20 @@ export async function lint(target, opts = {}) {
420
483
  try { const en = page.locator('button.flag-btn', { hasText: 'English' }).first(); if (await en.count()) { await en.click({ timeout: 3000 }); await page.waitForLoadState('networkidle', { timeout: 12000 }).catch(() => {}); await page.waitForTimeout(1000); } } catch {}
421
484
  await page.evaluate(async () => { const s = innerHeight * 0.8; for (let y = 0; y < document.body.scrollHeight; y += s) { scrollTo(0, y); await new Promise((r) => setTimeout(r, 200)); } scrollTo(0, 0); await new Promise((r) => setTimeout(r, 350)); });
422
485
  const r = await page.evaluate(pageProbe, { SPACING_SCALE, ANCHORS, RADIUS_SCALE });
423
- // second pass at mobile width (no reload resize + re-measure)
424
- await page.setViewportSize({ width: 390, height: 844 });
425
- await page.waitForTimeout(500);
426
- const mob = await page.evaluate(mobileProbe);
486
+ // responsive sweep overflow / tap-targets / tiny-text across standard breakpoints. The 1440
487
+ // desktop pass above already covered the wide end; overflow at ANY intermediate width is the
488
+ // "no-man's-land" bug a fixed 390+1440 check misses. nimiq.com is overflow-clean at every width.
489
+ const SWEEP = [360, 414, 768, 1024, 1280];
490
+ const sweep = [];
491
+ for (const w of SWEEP) {
492
+ await page.setViewportSize({ width: w, height: 900 });
493
+ await page.waitForTimeout(350);
494
+ await page.evaluate(async () => { const s = innerHeight * 0.8; for (let y = 0; y < document.body.scrollHeight; y += s) { scrollTo(0, y); await new Promise((r) => setTimeout(r, 80)); } scrollTo(0, 0); await new Promise((r) => setTimeout(r, 120)); });
495
+ sweep.push({ w, ...(await page.evaluate(mobileProbe)) });
496
+ }
497
+ const overflowAt = sweep.filter((s) => s.overflowPx > 4);
498
+ const tapWorst = sweep.reduce((a, s) => (s.smallTargetN > a.smallTargetN ? s : a), sweep[0]);
499
+ const tinyWorst = sweep.reduce((a, s) => (s.tinyText > a.tinyText ? s : a), sweep[0]);
427
500
 
428
501
  // social-icon exemption (run in Node — SOCIAL anchors aren't in the page context)
429
502
  const socialName = (rgb) => { let best = Infinity, name = null; for (const [k, v] of Object.entries(SOCIAL)) { const d = Math.hypot(rgb[0] - v[0], rgb[1] - v[1], rgb[2] - v[2]); if (d < best) { best = d; name = k; } } return best <= SOCIAL_DELTA ? name : null; };
@@ -462,6 +535,12 @@ export async function lint(target, opts = {}) {
462
535
  ['heading line-height off (nimiq 1.25–1.3)', r.tightLeadingH.length, r.tightLeadingH[0]],
463
536
  ['cramped body line-height (<1.35; nimiq 1.5)', r.tightLeadingBody.length, r.tightLeadingBody[0]],
464
537
  ['non-brand font on text (Mulish / Fira only)', offFont.length, offFont.map(([f, n]) => `${f}×${n}`).join(' ')],
538
+ ['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`],
539
+ ['multiple <h1> on the page', r.h1Count > 1 ? r.h1Count : 0, r.h1Count > 1 ? `${r.h1Count} h1 elements` : ''],
540
+ ['unconstrained text column (set max-width)', r.unconstrained.length, r.unconstrained[0] && `${r.unconstrained[0].w}px (${r.unconstrained[0].ch}ch)`],
541
+ ['NIM amount wrong semantic color', r.amountColor.length, r.amountColor[0]],
542
+ ['address not uppercase / flat (use 3×3 grid)', r.addrStructure.length, r.addrStructure[0]],
543
+ ['pulsing "live" dot animation', r.pulseDot.length, r.pulseDot[0]],
465
544
  ['uppercase eyebrow (colored / long / pill)', r.uppercase.length, r.uppercase[0]],
466
545
  ['non-pill action buttons', r.nonPill.length, r.nonPill[0]],
467
546
  ['flat-fill colored button (needs gradient)', r.flatColorBtn.length, r.flatColorBtn[0]],
@@ -481,13 +560,13 @@ export async function lint(target, opts = {}) {
481
560
  ['flat-fill navy/colored section (use radial)', r.flatNavySection.length, r.flatNavySection[0]],
482
561
  ['focus outline removed w/o :focus-visible', r.noFocusRing ? 1 : 0, r.noFocusRing ? 'add a :focus-visible ring' : ''],
483
562
  ['Mulish not loaded (system-font fallback)', r.fontsNotLoaded ? 1 : 0, r.fontsNotLoaded ? 'load Mulish' : ''],
484
- ['mobile horizontal overflow @390px', mob.overflowPx > 4 ? 1 : 0, `${mob.overflowPx}px`],
485
- ['mobile tap targets < 36px', mob.smallTargetN, mob.smallTargets[0]],
486
- ['text smaller than 12px @390px', mob.tinyText, `${mob.tinyText} element type(s)`],
563
+ ['horizontal overflow at a breakpoint', overflowAt.length, overflowAt.map((s) => `${s.w}px:${s.overflowPx}px`).join(' ')],
564
+ ['tap targets < 36px (any breakpoint)', tapWorst.smallTargetN, tapWorst.smallTargetN ? `@${tapWorst.w}px ${tapWorst.smallTargets[0] || ''}` : ''],
565
+ ['text smaller than 12px (any breakpoint)', tinyWorst.tinyText, tinyWorst.tinyText ? `@${tinyWorst.w}px, ${tinyWorst.tinyText} type(s)` : ''],
487
566
  ];
488
567
  warnCount = warns.reduce((n, w) => n + (w[1] ? 1 : 0), 0);
489
568
 
490
- if (opts.json) { out(JSON.stringify({ url, errorCount, warnCount, raw: r, mobile: mob, exemptSocial: exemptSocial.map(([c, v]) => ({ color: c, icon: v.social })) }, null, 2)); return { errorCount, warnCount }; }
569
+ if (opts.json) { out(JSON.stringify({ url, errorCount, warnCount, raw: r, responsive: sweep, exemptSocial: exemptSocial.map(([c, v]) => ({ color: c, icon: v.social })) }, null, 2)); return { errorCount, warnCount }; }
491
570
 
492
571
  out(`\n══════ nq lint — ${target} ══════\n`);
493
572
  out('ERRORS (off-brand / a11y — must fix to pass)');