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 +21 -4
- package/package.json +1 -1
- package/scripts/lint.mjs +60 -8
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
|
-
**
|
|
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 (
|
|
138
|
-
| text smaller than 12px | any text element below 12px at
|
|
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
|
+
"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
|
-
//
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
const
|
|
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
|
-
['
|
|
554
|
-
['
|
|
555
|
-
['text smaller than 12px
|
|
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,
|
|
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)');
|