nimiq-branding-cli 1.3.2 → 1.4.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 +24 -0
- package/package.json +1 -1
- package/scripts/lint.mjs +98 -0
package/LINT.md
CHANGED
|
@@ -105,6 +105,30 @@ 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
|
+
**Layout integrity** (catches the "things are shifted" regression class — content overlapped or cut off)
|
|
119
|
+
|
|
120
|
+
These came from a real app regression `nq lint` initially missed: fixed footers/bars overlapping
|
|
121
|
+
content, cards clipping their own rows, square steppers. All three are calibrated to nimiq.com = 0
|
|
122
|
+
and were proven to fire on the real buggy screens.
|
|
123
|
+
|
|
124
|
+
| Check | Threshold |
|
|
125
|
+
|---|---|
|
|
126
|
+
| interactive element hidden behind a bar | a labelled control (or any form input) ≥ 2 of 5 sample points covered, via `elementFromPoint`, by a **positioned** (fixed/sticky/absolute) non-dialog element — i.e. a footer/bar you then can't click. Unlabelled `<a>`/`<button>` (stretched click-layers) and modals/cookie/overlays are exempt |
|
|
127
|
+
| container clips its own content | an `overflow: hidden/clip` box where a **text-bearing descendant** is pushed past the clip edge (visible text actually cut off). Decorative (non-text) overflow + `ellipsis`/`line-clamp` truncation are exempt, so nimiq's hero-bleed / hover-card clips don't fire |
|
|
128
|
+
| small square button | a small (24–80px), roughly-square interactive button **with a visible surface** (fill/border) whose radius isn't a circle/pill — a stepper/icon button that should be round. Bare text links (no surface) are exempt |
|
|
129
|
+
|
|
130
|
+
> **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).
|
|
131
|
+
|
|
108
132
|
**Headline typography & line breaks** (closes the "lint passes ≠ brand-correct" gap)
|
|
109
133
|
|
|
110
134
|
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
|
+
"version": "1.4.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
|
@@ -132,6 +132,8 @@ 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: [],
|
|
136
|
+
occluded: [], clippedBox: [], squareBtn: [],
|
|
135
137
|
};
|
|
136
138
|
|
|
137
139
|
for (const el of document.querySelectorAll('body *')) {
|
|
@@ -246,6 +248,13 @@ function pageProbe({ SPACING_SCALE, ANCHORS, RADIUS_SCALE }) {
|
|
|
246
248
|
else if (el.children.length === 0) o.addrStructure.push('address as one flat string (use the 3×3 grid)');
|
|
247
249
|
}
|
|
248
250
|
|
|
251
|
+
// any infinite animation on the page → feeds the reduced-motion check at the end of the probe
|
|
252
|
+
if (cs.animationName && cs.animationName !== 'none' && /(^|,)\s*infinite\s*(,|$)/.test(cs.animationIterationCount)) o.hasInfiniteAnim = true;
|
|
253
|
+
// clipped / truncated text — ellipsis (or overflow:hidden + nowrap) cutting text off, no title fallback
|
|
254
|
+
if (txt && el.children.length === 0) {
|
|
255
|
+
const ell = cs.textOverflow === 'ellipsis' || (cs.overflowX !== 'visible' && cs.whiteSpace === 'nowrap');
|
|
256
|
+
if (ell && el.scrollWidth > el.clientWidth + 2 && !el.title) o.clippedText.push(`<${tag}> "${txt.slice(0, 24)}" cut off (no title)`);
|
|
257
|
+
}
|
|
249
258
|
// pulsing "live" dot (slop): a small round element on an infinite animation that isn't a spinner.
|
|
250
259
|
if (cs.animationName && cs.animationName !== 'none' && /(^|,)\s*infinite\s*(,|$)/.test(cs.animationIterationCount) && r.width <= 28 && r.height <= 28) {
|
|
251
260
|
const round = cs.borderRadius.includes('50%') || px(cs.borderRadius) >= Math.min(r.width, r.height) / 2 - 1;
|
|
@@ -393,6 +402,74 @@ function pageProbe({ SPACING_SCALE, ANCHORS, RADIUS_SCALE }) {
|
|
|
393
402
|
// jumps h1→h4 for styling, so a no-skip rule would flag the reference.)
|
|
394
403
|
o.h1Count = [...document.querySelectorAll('h1')].filter((e) => visible(e)).length;
|
|
395
404
|
|
|
405
|
+
// images — distortion (displayed ratio ≠ natural, not object-fit-preserved, not a full-bleed hero)
|
|
406
|
+
// and missing alt on raster CONTENT images (SVG illustrations + alt="" decoratives are exempt).
|
|
407
|
+
for (const img of document.querySelectorAll('img')) {
|
|
408
|
+
if (!visible(img)) continue;
|
|
409
|
+
const ir = img.getBoundingClientRect();
|
|
410
|
+
const src = img.currentSrc || img.getAttribute('src') || '';
|
|
411
|
+
if (img.naturalWidth && img.naturalHeight && ir.width < innerWidth * 0.7) {
|
|
412
|
+
const of = getComputedStyle(img).objectFit;
|
|
413
|
+
if (!/cover|contain|scale-down/.test(of)) {
|
|
414
|
+
const skew = Math.abs((ir.width / ir.height) - (img.naturalWidth / img.naturalHeight)) / (img.naturalWidth / img.naturalHeight);
|
|
415
|
+
if (skew > 0.08) o.distortedImg.push(`${src.split('/').pop().slice(0, 20)} ${Math.round(skew * 100)}% skew (${of})`);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
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)}`);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// OCCLUSION — an interactive element whose own area is covered by a positioned (fixed/sticky/
|
|
422
|
+
// absolute) non-dialog element: a footer/bar overlapping a field or button you then can't click.
|
|
423
|
+
// (Modals/cookie/overlays are exempt — they're meant to sit on top until dismissed.)
|
|
424
|
+
const inDialog = (el) => !!matchAny(el, '[role="dialog"],[aria-modal="true"],[class*="modal" i],[class*="overlay" i],[class*="dialog" i],[class*="cookie" i],[class*="consent" i],[class*="popup" i],[class*="drawer" i],[class*="toast" i],[class*="menu" i]');
|
|
425
|
+
const positioned = (el) => { let e = el; for (let i = 0; e && i < 6; i++) { const p = getComputedStyle(e).position; if (p === 'fixed' || p === 'sticky' || p === 'absolute') return e; e = e.parentElement; } return null; };
|
|
426
|
+
for (const el of document.querySelectorAll('button,[role="button"],a[href],input,select,textarea,label.nq-button')) {
|
|
427
|
+
if (!visible(el) || inDialog(el)) continue;
|
|
428
|
+
const oTag = el.tagName.toLowerCase();
|
|
429
|
+
const isFormCtrl = oTag === 'input' || oTag === 'select' || oTag === 'textarea';
|
|
430
|
+
const oLabel = (el.textContent || '').trim() || el.getAttribute('aria-label') || el.getAttribute('title') || el.getAttribute('placeholder') || '';
|
|
431
|
+
if (!isFormCtrl && !oLabel) continue; // a bare unlabeled <a>/<button> is a stretched click-layer, not a control
|
|
432
|
+
const r = el.getBoundingClientRect();
|
|
433
|
+
if (r.width < 24 || r.height < 16 || r.bottom < 0 || r.top > innerHeight) continue;
|
|
434
|
+
const pts = [[0.5, 0.5], [0.25, 0.4], [0.75, 0.4], [0.25, 0.72], [0.75, 0.72]].map(([fx, fy]) => [Math.min(innerWidth - 1, r.left + r.width * fx), Math.min(innerHeight - 1, Math.max(0, r.top + r.height * fy))]);
|
|
435
|
+
let occ = 0, by = null;
|
|
436
|
+
for (const [x, y] of pts) { const hit = document.elementFromPoint(x, y); if (hit && hit !== el && !el.contains(hit) && !hit.contains(el) && positioned(hit) && !inDialog(hit)) { occ++; by = hit; } }
|
|
437
|
+
if (occ >= 2 && by) o.occluded.push(`<${oTag}> "${oLabel.slice(0, 18)}" hidden behind <${by.tagName.toLowerCase()}.${(by.className || '').toString().trim().split(/\s+/)[0]?.slice(0, 14) || ''}>`);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// CONTAINER CLIPPING — overflow:hidden/clip where a TEXT-bearing descendant is pushed past the
|
|
441
|
+
// clip edge (so visible text is actually cut off). Decorative (non-text) overflow + ellipsis/
|
|
442
|
+
// line-clamp truncation are exempt, so nimiq's hero-bleed / hover-card clips don't false-positive.
|
|
443
|
+
for (const el of document.querySelectorAll('body *')) {
|
|
444
|
+
if (!visible(el)) continue;
|
|
445
|
+
const cs = getComputedStyle(el);
|
|
446
|
+
const clipsY = cs.overflowY === 'hidden' || cs.overflowY === 'clip';
|
|
447
|
+
const clipsX = cs.overflowX === 'hidden' || cs.overflowX === 'clip';
|
|
448
|
+
if ((!clipsY && !clipsX) || cs.textOverflow === 'ellipsis' || (cs.webkitLineClamp && cs.webkitLineClamp !== 'none')) continue;
|
|
449
|
+
const box = el.getBoundingClientRect(); if (box.height < 10) continue;
|
|
450
|
+
let cut = null;
|
|
451
|
+
for (const d of el.querySelectorAll('*')) {
|
|
452
|
+
if (![...d.childNodes].some((n) => n.nodeType === 3 && n.textContent.trim().length > 1)) continue;
|
|
453
|
+
const dr = d.getBoundingClientRect(); if (dr.width < 2 || dr.height < 2) continue;
|
|
454
|
+
if (clipsY && (dr.top >= box.bottom + 1 || (dr.bottom > box.bottom + 10 && dr.top < box.bottom - 4))) { cut = d; break; }
|
|
455
|
+
if (clipsX && (dr.left >= box.right + 1 || (dr.right > box.right + 10 && dr.left < box.right - 4))) { cut = d; break; }
|
|
456
|
+
}
|
|
457
|
+
if (cut) o.clippedBox.push(`<${el.tagName.toLowerCase()}.${(el.className || '').toString().trim().split(/\s+/)[0]?.slice(0, 16) || ''}> cuts off "${cut.textContent.trim().replace(/\s+/g, ' ').slice(0, 22)}"`);
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// SMALL SQUARE INTERACTIVE BUTTON — a stepper/icon button should be a circle/pill, not a square.
|
|
461
|
+
// (The non-pill check above only catches wide text buttons; this catches the small square ones.)
|
|
462
|
+
for (const el of document.querySelectorAll('button,[role="button"],a.nq-button,.nq-button-s')) {
|
|
463
|
+
if (!visible(el)) continue;
|
|
464
|
+
const r = el.getBoundingClientRect(); const mn = Math.min(r.width, r.height), mx = Math.max(r.width, r.height);
|
|
465
|
+
if (mn < 24 || mn > 80 || (mx - mn) > mn * 0.3) continue;
|
|
466
|
+
const cs = getComputedStyle(el);
|
|
467
|
+
// only a button with a visible SURFACE (fill / border) has a shape to round — a bare text link doesn't
|
|
468
|
+
if (!((toRGBA(cs.backgroundColor)?.[3] > 0.05) || (cs.borderStyle !== 'none' && px(cs.borderTopWidth) > 0) || cs.backgroundImage !== 'none')) continue;
|
|
469
|
+
const rad = px(cs.borderTopLeftRadius);
|
|
470
|
+
if (rad < 500 && rad < mn / 2 - 2) o.squareBtn.push(`<${el.tagName.toLowerCase()}> ${Math.round(r.width)}×${Math.round(r.height)} r=${rad}px "${(el.textContent || '').trim().slice(0, 12)}"`);
|
|
471
|
+
}
|
|
472
|
+
|
|
396
473
|
// per-section text-ink ratio (full-width bands)
|
|
397
474
|
const vw = innerWidth;
|
|
398
475
|
const leaves = [...document.querySelectorAll('body *')].filter((el) => !matchAny(el, 'svg') && visible(el) && el.children.length === 0 && directText(el)).map((el) => el.getBoundingClientRect());
|
|
@@ -424,6 +501,19 @@ function pageProbe({ SPACING_SCALE, ANCHORS, RADIUS_SCALE }) {
|
|
|
424
501
|
}
|
|
425
502
|
}
|
|
426
503
|
o.noFocusRing = killsOutline && !restoresFocus;
|
|
504
|
+
|
|
505
|
+
// reduced-motion: infinite animation present but NO @media (prefers-reduced-motion) rule anywhere
|
|
506
|
+
// (cross-origin sheets throw on .cssRules and are skipped). nimiq.com ships the rule → not flagged.
|
|
507
|
+
let reducedMotionRule = false;
|
|
508
|
+
for (const sheet of document.styleSheets) {
|
|
509
|
+
let rules; try { rules = sheet.cssRules; } catch { continue; }
|
|
510
|
+
if (!rules) continue;
|
|
511
|
+
for (const rule of rules) { if (/prefers-reduced-motion/i.test(rule.cssText || '')) { reducedMotionRule = true; break; } }
|
|
512
|
+
if (reducedMotionRule) break;
|
|
513
|
+
}
|
|
514
|
+
o.noReducedMotion = o.hasInfiniteAnim && !reducedMotionRule;
|
|
515
|
+
// viewport meta — only on a full page (has a <head><title>), so component fragments aren't flagged
|
|
516
|
+
o.noViewport = !!document.querySelector('head > title') && !document.querySelector('meta[name="viewport"]');
|
|
427
517
|
return o;
|
|
428
518
|
}
|
|
429
519
|
|
|
@@ -563,6 +653,14 @@ export async function lint(target, opts = {}) {
|
|
|
563
653
|
['horizontal overflow at a breakpoint', overflowAt.length, overflowAt.map((s) => `${s.w}px:${s.overflowPx}px`).join(' ')],
|
|
564
654
|
['tap targets < 36px (any breakpoint)', tapWorst.smallTargetN, tapWorst.smallTargetN ? `@${tapWorst.w}px ${tapWorst.smallTargets[0] || ''}` : ''],
|
|
565
655
|
['text smaller than 12px (any breakpoint)', tinyWorst.tinyText, tinyWorst.tinyText ? `@${tinyWorst.w}px, ${tinyWorst.tinyText} type(s)` : ''],
|
|
656
|
+
['no prefers-reduced-motion (infinite anim)', r.noReducedMotion ? 1 : 0, r.noReducedMotion ? 'add @media (prefers-reduced-motion: reduce)' : ''],
|
|
657
|
+
['missing viewport meta tag', r.noViewport ? 1 : 0, r.noViewport ? 'add <meta name="viewport" content="width=device-width…">' : ''],
|
|
658
|
+
['clipped / truncated text (no title)', r.clippedText.length, r.clippedText[0]],
|
|
659
|
+
['distorted image (wrong aspect ratio)', r.distortedImg.length, r.distortedImg[0]],
|
|
660
|
+
['content image missing alt (raster)', r.noAltImg.length, r.noAltImg[0]],
|
|
661
|
+
['interactive element hidden behind a bar', r.occluded.length, r.occluded[0]],
|
|
662
|
+
['container clips its own content', r.clippedBox.length, r.clippedBox[0]],
|
|
663
|
+
['small square button (use circle / pill)', r.squareBtn.length, r.squareBtn[0]],
|
|
566
664
|
];
|
|
567
665
|
warnCount = warns.reduce((n, w) => n + (w[1] ? 1 : 0), 0);
|
|
568
666
|
|