nimiq-branding-cli 1.3.0 → 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
@@ -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
 
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.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/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());
@@ -462,6 +525,12 @@ export async function lint(target, opts = {}) {
462
525
  ['heading line-height off (nimiq 1.25–1.3)', r.tightLeadingH.length, r.tightLeadingH[0]],
463
526
  ['cramped body line-height (<1.35; nimiq 1.5)', r.tightLeadingBody.length, r.tightLeadingBody[0]],
464
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]],
465
534
  ['uppercase eyebrow (colored / long / pill)', r.uppercase.length, r.uppercase[0]],
466
535
  ['non-pill action buttons', r.nonPill.length, r.nonPill[0]],
467
536
  ['flat-fill colored button (needs gradient)', r.flatColorBtn.length, r.flatColorBtn[0]],