nimiq-branding-cli 1.3.2 → 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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nimiq-branding-cli",
3
- "version": "1.3.2",
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
 
@@ -563,6 +600,11 @@ export async function lint(target, opts = {}) {
563
600
  ['horizontal overflow at a breakpoint', overflowAt.length, overflowAt.map((s) => `${s.w}px:${s.overflowPx}px`).join(' ')],
564
601
  ['tap targets < 36px (any breakpoint)', tapWorst.smallTargetN, tapWorst.smallTargetN ? `@${tapWorst.w}px ${tapWorst.smallTargets[0] || ''}` : ''],
565
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]],
566
608
  ];
567
609
  warnCount = warns.reduce((n, w) => n + (w[1] ? 1 : 0), 0);
568
610