nimiq-branding-cli 1.3.1 → 1.4.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
@@ -105,6 +105,18 @@ to flag *"this is getting busy, is it justified?"* — the deterministic half of
105
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
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
107
 
108
+ **Media & page-quality** (the deterministic asymptote — last verifiable batch)
109
+
110
+ | Check | Threshold |
111
+ |---|---|
112
+ | no `prefers-reduced-motion` | an `infinite` animation is present but **no** `@media (prefers-reduced-motion: reduce)` rule anywhere (nimiq.com ships the rule, so it's clean) |
113
+ | missing viewport meta | a full page (has `<head><title>`) without `<meta name="viewport">` — silently breaks all mobile |
114
+ | clipped / truncated text | a text element ellipsis/overflow-truncated (`scrollWidth > clientWidth`) with **no** `title` fallback to read it |
115
+ | distorted image | an `<img>` whose displayed aspect ratio differs from its natural ratio by > 8% without `object-fit: cover/contain`, scoped to non-hero images (< 70% viewport wide) so full-bleed `fill` backgrounds aren't flagged |
116
+ | content image missing alt | a **raster** (`jpg/png/webp/gif/avif`) `<img>` with no `alt` attribute (SVG illustrations + `alt=""` decoratives are exempt) |
117
+
118
+ > **Deliberately not built:** adjacent tap-target spacing — nimiq.com's own layout has 12–14 closely-spaced controls (incl. non-nav) that can't be cleanly separated from real crowding, so it would be noise, not signal. Beyond this batch the remaining gaps are either niche, un-calibratable against the reference, or genuinely judgment (the optional vision pass).
119
+
108
120
  **Headline typography & line breaks** (closes the "lint passes ≠ brand-correct" gap)
109
121
 
110
122
  The deterministic checks above read color, spacing and shape — but a heading can satisfy all of
@@ -129,13 +141,18 @@ Weight/tracking are heading-scoped (buttons and bold body labels are legitimatel
129
141
  orphan check is general (it caught a body banner, not a heading) but gated at ≥ 20px so calibrated
130
142
  fine-print under 20px — which wasn't measured on the reference — is never flagged.
131
143
 
132
- **Mobile (a second pass at 390px)**
144
+ **Responsive sweep (360 · 414 · 768 · 1024 · 1280px)**
145
+
146
+ The desktop pass renders at 1440; this sweep re-measures at five more widths so a layout that's
147
+ clean at phone *and* desktop but **breaks in the tablet / small-laptop no-man's-land** is caught —
148
+ the single most common responsive bug a fixed 390+1440 check misses. Measured: nimiq.com has **zero
149
+ horizontal overflow at every width** 320→1440, so overflow-at-a-breakpoint is calibrated to 0.
133
150
 
134
151
  | Check | Threshold |
135
152
  |---|---|
136
- | horizontal overflow | `scrollWidth > viewport` by > 4px |
137
- | tap targets too small | a button / input / link-button under 30px (inline text links are exempt) |
138
- | text smaller than 12px | any text element below 12px at mobile width |
153
+ | horizontal overflow at a breakpoint | `scrollWidth > viewport` by > 4px at **any** swept width (reports which widths and by how much) |
154
+ | tap targets too small | a button / input / link-button under 30px at any width (reports the worst breakpoint) |
155
+ | text smaller than 12px | any text element below 12px at any width (reports the worst breakpoint) |
139
156
 
140
157
  The curated spacing scale (from `assets/css/modern/spacing.css`, desktop max of each step):
141
158
  `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.1",
3
+ "version": "1.4.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/lint.mjs CHANGED
@@ -132,6 +132,7 @@ function pageProbe({ SPACING_SCALE, ANCHORS, RADIUS_SCALE }) {
132
132
  blackBg: [], accentStripe: [], greenAction: [], dupGradIds: [], genericIcon: [], featherIcon: [],
133
133
  bakedIcon: [], duotoneBlack: [], modalBlack: [], flatNavySection: [], glowShadow: [], fontsNotLoaded: false, noFocusRing: false,
134
134
  lowContrast: [], h1Count: 0, unconstrained: [], amountColor: [], addrStructure: [], pulseDot: [],
135
+ noReducedMotion: false, hasInfiniteAnim: false, noViewport: false, clippedText: [], distortedImg: [], noAltImg: [],
135
136
  };
136
137
 
137
138
  for (const el of document.querySelectorAll('body *')) {
@@ -246,6 +247,13 @@ function pageProbe({ SPACING_SCALE, ANCHORS, RADIUS_SCALE }) {
246
247
  else if (el.children.length === 0) o.addrStructure.push('address as one flat string (use the 3×3 grid)');
247
248
  }
248
249
 
250
+ // any infinite animation on the page → feeds the reduced-motion check at the end of the probe
251
+ if (cs.animationName && cs.animationName !== 'none' && /(^|,)\s*infinite\s*(,|$)/.test(cs.animationIterationCount)) o.hasInfiniteAnim = true;
252
+ // clipped / truncated text — ellipsis (or overflow:hidden + nowrap) cutting text off, no title fallback
253
+ if (txt && el.children.length === 0) {
254
+ const ell = cs.textOverflow === 'ellipsis' || (cs.overflowX !== 'visible' && cs.whiteSpace === 'nowrap');
255
+ if (ell && el.scrollWidth > el.clientWidth + 2 && !el.title) o.clippedText.push(`<${tag}> "${txt.slice(0, 24)}" cut off (no title)`);
256
+ }
249
257
  // pulsing "live" dot (slop): a small round element on an infinite animation that isn't a spinner.
250
258
  if (cs.animationName && cs.animationName !== 'none' && /(^|,)\s*infinite\s*(,|$)/.test(cs.animationIterationCount) && r.width <= 28 && r.height <= 28) {
251
259
  const round = cs.borderRadius.includes('50%') || px(cs.borderRadius) >= Math.min(r.width, r.height) / 2 - 1;
@@ -393,6 +401,22 @@ function pageProbe({ SPACING_SCALE, ANCHORS, RADIUS_SCALE }) {
393
401
  // jumps h1→h4 for styling, so a no-skip rule would flag the reference.)
394
402
  o.h1Count = [...document.querySelectorAll('h1')].filter((e) => visible(e)).length;
395
403
 
404
+ // images — distortion (displayed ratio ≠ natural, not object-fit-preserved, not a full-bleed hero)
405
+ // and missing alt on raster CONTENT images (SVG illustrations + alt="" decoratives are exempt).
406
+ for (const img of document.querySelectorAll('img')) {
407
+ if (!visible(img)) continue;
408
+ const ir = img.getBoundingClientRect();
409
+ const src = img.currentSrc || img.getAttribute('src') || '';
410
+ if (img.naturalWidth && img.naturalHeight && ir.width < innerWidth * 0.7) {
411
+ const of = getComputedStyle(img).objectFit;
412
+ if (!/cover|contain|scale-down/.test(of)) {
413
+ const skew = Math.abs((ir.width / ir.height) - (img.naturalWidth / img.naturalHeight)) / (img.naturalWidth / img.naturalHeight);
414
+ if (skew > 0.08) o.distortedImg.push(`${src.split('/').pop().slice(0, 20)} ${Math.round(skew * 100)}% skew (${of})`);
415
+ }
416
+ }
417
+ if (!img.hasAttribute('alt') && /\.(jpe?g|png|webp|gif|avif)(\?|$)/i.test(src)) o.noAltImg.push(`${src.split('/').pop().slice(0, 24)} ${Math.round(ir.width)}×${Math.round(ir.height)}`);
418
+ }
419
+
396
420
  // per-section text-ink ratio (full-width bands)
397
421
  const vw = innerWidth;
398
422
  const leaves = [...document.querySelectorAll('body *')].filter((el) => !matchAny(el, 'svg') && visible(el) && el.children.length === 0 && directText(el)).map((el) => el.getBoundingClientRect());
@@ -424,6 +448,19 @@ function pageProbe({ SPACING_SCALE, ANCHORS, RADIUS_SCALE }) {
424
448
  }
425
449
  }
426
450
  o.noFocusRing = killsOutline && !restoresFocus;
451
+
452
+ // reduced-motion: infinite animation present but NO @media (prefers-reduced-motion) rule anywhere
453
+ // (cross-origin sheets throw on .cssRules and are skipped). nimiq.com ships the rule → not flagged.
454
+ let reducedMotionRule = false;
455
+ for (const sheet of document.styleSheets) {
456
+ let rules; try { rules = sheet.cssRules; } catch { continue; }
457
+ if (!rules) continue;
458
+ for (const rule of rules) { if (/prefers-reduced-motion/i.test(rule.cssText || '')) { reducedMotionRule = true; break; } }
459
+ if (reducedMotionRule) break;
460
+ }
461
+ o.noReducedMotion = o.hasInfiniteAnim && !reducedMotionRule;
462
+ // viewport meta — only on a full page (has a <head><title>), so component fragments aren't flagged
463
+ o.noViewport = !!document.querySelector('head > title') && !document.querySelector('meta[name="viewport"]');
427
464
  return o;
428
465
  }
429
466
 
@@ -483,10 +520,20 @@ export async function lint(target, opts = {}) {
483
520
  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 {}
484
521
  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)); });
485
522
  const r = await page.evaluate(pageProbe, { SPACING_SCALE, ANCHORS, RADIUS_SCALE });
486
- // second pass at mobile width (no reload resize + re-measure)
487
- await page.setViewportSize({ width: 390, height: 844 });
488
- await page.waitForTimeout(500);
489
- const mob = await page.evaluate(mobileProbe);
523
+ // responsive sweep overflow / tap-targets / tiny-text across standard breakpoints. The 1440
524
+ // desktop pass above already covered the wide end; overflow at ANY intermediate width is the
525
+ // "no-man's-land" bug a fixed 390+1440 check misses. nimiq.com is overflow-clean at every width.
526
+ const SWEEP = [360, 414, 768, 1024, 1280];
527
+ const sweep = [];
528
+ for (const w of SWEEP) {
529
+ await page.setViewportSize({ width: w, height: 900 });
530
+ await page.waitForTimeout(350);
531
+ 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)); });
532
+ sweep.push({ w, ...(await page.evaluate(mobileProbe)) });
533
+ }
534
+ const overflowAt = sweep.filter((s) => s.overflowPx > 4);
535
+ const tapWorst = sweep.reduce((a, s) => (s.smallTargetN > a.smallTargetN ? s : a), sweep[0]);
536
+ const tinyWorst = sweep.reduce((a, s) => (s.tinyText > a.tinyText ? s : a), sweep[0]);
490
537
 
491
538
  // social-icon exemption (run in Node — SOCIAL anchors aren't in the page context)
492
539
  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; };
@@ -550,13 +597,18 @@ export async function lint(target, opts = {}) {
550
597
  ['flat-fill navy/colored section (use radial)', r.flatNavySection.length, r.flatNavySection[0]],
551
598
  ['focus outline removed w/o :focus-visible', r.noFocusRing ? 1 : 0, r.noFocusRing ? 'add a :focus-visible ring' : ''],
552
599
  ['Mulish not loaded (system-font fallback)', r.fontsNotLoaded ? 1 : 0, r.fontsNotLoaded ? 'load Mulish' : ''],
553
- ['mobile horizontal overflow @390px', mob.overflowPx > 4 ? 1 : 0, `${mob.overflowPx}px`],
554
- ['mobile tap targets < 36px', mob.smallTargetN, mob.smallTargets[0]],
555
- ['text smaller than 12px @390px', mob.tinyText, `${mob.tinyText} element type(s)`],
600
+ ['horizontal overflow at a breakpoint', overflowAt.length, overflowAt.map((s) => `${s.w}px:${s.overflowPx}px`).join(' ')],
601
+ ['tap targets < 36px (any breakpoint)', tapWorst.smallTargetN, tapWorst.smallTargetN ? `@${tapWorst.w}px ${tapWorst.smallTargets[0] || ''}` : ''],
602
+ ['text smaller than 12px (any breakpoint)', tinyWorst.tinyText, tinyWorst.tinyText ? `@${tinyWorst.w}px, ${tinyWorst.tinyText} type(s)` : ''],
603
+ ['no prefers-reduced-motion (infinite anim)', r.noReducedMotion ? 1 : 0, r.noReducedMotion ? 'add @media (prefers-reduced-motion: reduce)' : ''],
604
+ ['missing viewport meta tag', r.noViewport ? 1 : 0, r.noViewport ? 'add <meta name="viewport" content="width=device-width…">' : ''],
605
+ ['clipped / truncated text (no title)', r.clippedText.length, r.clippedText[0]],
606
+ ['distorted image (wrong aspect ratio)', r.distortedImg.length, r.distortedImg[0]],
607
+ ['content image missing alt (raster)', r.noAltImg.length, r.noAltImg[0]],
556
608
  ];
557
609
  warnCount = warns.reduce((n, w) => n + (w[1] ? 1 : 0), 0);
558
610
 
559
- 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 }; }
611
+ 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 }; }
560
612
 
561
613
  out(`\n══════ nq lint — ${target} ══════\n`);
562
614
  out('ERRORS (off-brand / a11y — must fix to pass)');