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 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.0",
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