nimiq-branding-cli 1.4.0 → 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 +12 -0
- package/package.json +1 -1
- package/scripts/lint.mjs +56 -0
package/LINT.md
CHANGED
|
@@ -115,6 +115,18 @@ to flag *"this is getting busy, is it justified?"* — the deterministic half of
|
|
|
115
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
116
|
| content image missing alt | a **raster** (`jpg/png/webp/gif/avif`) `<img>` with no `alt` attribute (SVG illustrations + `alt=""` decoratives are exempt) |
|
|
117
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
|
+
|
|
118
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).
|
|
119
131
|
|
|
120
132
|
**Headline typography & line breaks** (closes the "lint passes ≠ brand-correct" gap)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nimiq-branding-cli",
|
|
3
|
-
"version": "1.4.
|
|
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
|
@@ -133,6 +133,7 @@ function pageProbe({ SPACING_SCALE, ANCHORS, RADIUS_SCALE }) {
|
|
|
133
133
|
bakedIcon: [], duotoneBlack: [], modalBlack: [], flatNavySection: [], glowShadow: [], fontsNotLoaded: false, noFocusRing: false,
|
|
134
134
|
lowContrast: [], h1Count: 0, unconstrained: [], amountColor: [], addrStructure: [], pulseDot: [],
|
|
135
135
|
noReducedMotion: false, hasInfiniteAnim: false, noViewport: false, clippedText: [], distortedImg: [], noAltImg: [],
|
|
136
|
+
occluded: [], clippedBox: [], squareBtn: [],
|
|
136
137
|
};
|
|
137
138
|
|
|
138
139
|
for (const el of document.querySelectorAll('body *')) {
|
|
@@ -417,6 +418,58 @@ function pageProbe({ SPACING_SCALE, ANCHORS, RADIUS_SCALE }) {
|
|
|
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)}`);
|
|
418
419
|
}
|
|
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
|
+
|
|
420
473
|
// per-section text-ink ratio (full-width bands)
|
|
421
474
|
const vw = innerWidth;
|
|
422
475
|
const leaves = [...document.querySelectorAll('body *')].filter((el) => !matchAny(el, 'svg') && visible(el) && el.children.length === 0 && directText(el)).map((el) => el.getBoundingClientRect());
|
|
@@ -605,6 +658,9 @@ export async function lint(target, opts = {}) {
|
|
|
605
658
|
['clipped / truncated text (no title)', r.clippedText.length, r.clippedText[0]],
|
|
606
659
|
['distorted image (wrong aspect ratio)', r.distortedImg.length, r.distortedImg[0]],
|
|
607
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]],
|
|
608
664
|
];
|
|
609
665
|
warnCount = warns.reduce((n, w) => n + (w[1] ? 1 : 0), 0);
|
|
610
666
|
|