nimiq-branding-cli 1.3.0 → 1.3.2
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 +20 -4
- package/package.json +1 -1
- package/scripts/lint.mjs +87 -8
package/LINT.md
CHANGED
|
@@ -93,6 +93,17 @@ to flag *"this is getting busy, is it justified?"* — the deterministic half of
|
|
|
93
93
|
|---|---|
|
|
94
94
|
| focus outline removed w/o `:focus-visible` | a stylesheet rule kills `outline` on a focusable/global selector and **no** `:focus-visible` rule anywhere restores a ring (cross-origin sheets are skipped, never guessed) |
|
|
95
95
|
| Mulish not loaded | a declared `Mulish` `@font-face` that didn't load (`document.fonts.check` false) → the page silently falls back to a system font |
|
|
96
|
+
| **text contrast below WCAG AA** | text below `4.5:1` (normal) / `3.0:1` (large) against its *determinable* background. Background is read by walking ancestors; if none is found (a hero's gradient lives on a separate layer) the element is **skipped**, and ratios < 1.6:1 are treated as undetectable-bg, not flagged. **Honest caveat:** nimiq.com itself ships sub-AA secondary grey (≈`2.37:1`), so this is a **warning that the reference also trips** — it surfaces a real a11y debt rather than pretending the brand is perfect. |
|
|
97
|
+
| multiple `<h1>` per page | more than one visible `<h1>`. Level *skips* (h2→h4) are intentionally **not** checked — nimiq.com/about jumps h1→h4 for styling, so a no-skip rule would flag the reference |
|
|
98
|
+
|
|
99
|
+
**Layout, data & motion**
|
|
100
|
+
|
|
101
|
+
| Check | Threshold |
|
|
102
|
+
|---|---|
|
|
103
|
+
| unconstrained text column | a body paragraph with `max-width: none` rendered wider than 760px — nimiq's wide copy always caps its measure |
|
|
104
|
+
| NIM amount wrong semantic color | a `+N NIM`/`%` that isn't green (incoming/up), or a `−N`/`−%` that isn't navy/red (outgoing/down) — skill Addresses. Only fires on wallet/POS surfaces |
|
|
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
|
+
| 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 |
|
|
96
107
|
|
|
97
108
|
**Headline typography & line breaks** (closes the "lint passes ≠ brand-correct" gap)
|
|
98
109
|
|
|
@@ -118,13 +129,18 @@ Weight/tracking are heading-scoped (buttons and bold body labels are legitimatel
|
|
|
118
129
|
orphan check is general (it caught a body banner, not a heading) but gated at ≥ 20px so calibrated
|
|
119
130
|
fine-print under 20px — which wasn't measured on the reference — is never flagged.
|
|
120
131
|
|
|
121
|
-
**
|
|
132
|
+
**Responsive sweep (360 · 414 · 768 · 1024 · 1280px)**
|
|
133
|
+
|
|
134
|
+
The desktop pass renders at 1440; this sweep re-measures at five more widths so a layout that's
|
|
135
|
+
clean at phone *and* desktop but **breaks in the tablet / small-laptop no-man's-land** is caught —
|
|
136
|
+
the single most common responsive bug a fixed 390+1440 check misses. Measured: nimiq.com has **zero
|
|
137
|
+
horizontal overflow at every width** 320→1440, so overflow-at-a-breakpoint is calibrated to 0.
|
|
122
138
|
|
|
123
139
|
| Check | Threshold |
|
|
124
140
|
|---|---|
|
|
125
|
-
| horizontal overflow | `scrollWidth > viewport` by > 4px |
|
|
126
|
-
| tap targets too small | a button / input / link-button under 30px (
|
|
127
|
-
| text smaller than 12px | any text element below 12px at
|
|
141
|
+
| horizontal overflow at a breakpoint | `scrollWidth > viewport` by > 4px at **any** swept width (reports which widths and by how much) |
|
|
142
|
+
| tap targets too small | a button / input / link-button under 30px at any width (reports the worst breakpoint) |
|
|
143
|
+
| text smaller than 12px | any text element below 12px at any width (reports the worst breakpoint) |
|
|
128
144
|
|
|
129
145
|
The curated spacing scale (from `assets/css/modern/spacing.css`, desktop max of each step):
|
|
130
146
|
`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.
|
|
3
|
+
"version": "1.3.2",
|
|
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
|
@@ -82,6 +82,23 @@ function pageProbe({ SPACING_SCALE, ANCHORS, RADIUS_SCALE }) {
|
|
|
82
82
|
}
|
|
83
83
|
return [255, 255, 255];
|
|
84
84
|
};
|
|
85
|
+
// like effBg but reports whether a real background was actually found. Heroes paint their
|
|
86
|
+
// gradient on a separate absolutely-positioned layer, so the text's ancestors are transparent
|
|
87
|
+
// and we'd wrongly default to white — `found:false` lets the contrast check skip those.
|
|
88
|
+
const effBgF = (el) => {
|
|
89
|
+
let e = el;
|
|
90
|
+
while (e && e.nodeType === 1) {
|
|
91
|
+
const cs = getComputedStyle(e);
|
|
92
|
+
const bc = toRGBA(cs.backgroundColor);
|
|
93
|
+
if (bc && bc[3] >= 0.5) return { rgb: bc.slice(0, 3), found: true };
|
|
94
|
+
if (cs.backgroundImage && cs.backgroundImage !== 'none') {
|
|
95
|
+
const cols = (cs.backgroundImage.match(/rgba?\([^)]+\)|#[0-9a-fA-F]{3,8}/g) || []).map(toRGB).filter(Boolean);
|
|
96
|
+
if (cols.length) return { rgb: cols.reduce((d, c) => relLum(c) < relLum(d) ? c : d), found: true };
|
|
97
|
+
}
|
|
98
|
+
e = e.parentElement;
|
|
99
|
+
}
|
|
100
|
+
return { rgb: [255, 255, 255], found: false };
|
|
101
|
+
};
|
|
85
102
|
const visible = (el) => { const cs = getComputedStyle(el); if (cs.display === 'none' || cs.visibility === 'hidden' || +cs.opacity === 0) return false; const r = el.getBoundingClientRect(); return r.width > 1 && r.height > 1; };
|
|
86
103
|
const directText = (el) => [...el.childNodes].filter((n) => n.nodeType === 3).map((n) => n.textContent).join('').trim();
|
|
87
104
|
const matchAny = (el, sel) => { try { return el.closest(sel); } catch { return null; } };
|
|
@@ -114,6 +131,7 @@ function pageProbe({ SPACING_SCALE, ANCHORS, RADIUS_SCALE }) {
|
|
|
114
131
|
tightLeadingH: [], tightLeadingBody: [], offFont: {},
|
|
115
132
|
blackBg: [], accentStripe: [], greenAction: [], dupGradIds: [], genericIcon: [], featherIcon: [],
|
|
116
133
|
bakedIcon: [], duotoneBlack: [], modalBlack: [], flatNavySection: [], glowShadow: [], fontsNotLoaded: false, noFocusRing: false,
|
|
134
|
+
lowContrast: [], h1Count: 0, unconstrained: [], amountColor: [], addrStructure: [], pulseDot: [],
|
|
117
135
|
};
|
|
118
136
|
|
|
119
137
|
for (const el of document.querySelectorAll('body *')) {
|
|
@@ -195,6 +213,45 @@ function pageProbe({ SPACING_SCALE, ANCHORS, RADIUS_SCALE }) {
|
|
|
195
213
|
}
|
|
196
214
|
}
|
|
197
215
|
|
|
216
|
+
// GENERAL TEXT CONTRAST (WARNING, WCAG AA) — only where the background is actually determinable
|
|
217
|
+
// (effBgF.found), skipping ratio<1.6 = an undetectable layered-hero bg, not a real defect.
|
|
218
|
+
// NOTE: nimiq.com itself ships sub-AA secondary grey (≈2.37:1), so this WARNS, never gates.
|
|
219
|
+
if (txt && !inCode && fg && el.children.length === 0 && (toRGBA(cs.color)?.[3] ?? 1) > 0.3) {
|
|
220
|
+
const cb = effBgF(el);
|
|
221
|
+
if (cb.found) {
|
|
222
|
+
const fsc = px(cs.fontSize), large = fsc >= 24 || (fsc >= 18.66 && +cs.fontWeight >= 700);
|
|
223
|
+
const ratio = contrast(fg, cb.rgb);
|
|
224
|
+
if (ratio >= 1.6 && ratio < (large ? 3.0 : 4.5)) o.lowContrast.push({ ratio, fg: `rgb(${fg.join(',')})`, bg: `rgb(${cb.rgb.map((x) => Math.round(x)).join(',')})`, fs: fsc, snippet: txt.slice(0, 26) });
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// NIM amount / %-change semantic color (WARNING): incoming +N green; outgoing −N NIM navy;
|
|
229
|
+
// %-change green up / red down (skill Addresses). Only fires on wallet/POS surfaces.
|
|
230
|
+
if (txt && !inCode && fg) {
|
|
231
|
+
const m = txt.trim().match(/^([+\-−])\s?[\d][\d., ]*\s?(NIM|%)$/i);
|
|
232
|
+
if (m) {
|
|
233
|
+
const up = m[1] === '+', pct = /%$/.test(txt.trim());
|
|
234
|
+
const greenish = dist(fg, ANCHORS.green) < 60 || (hue(fg) >= 140 && hue(fg) <= 178 && sat(fg) > 0.2);
|
|
235
|
+
const reddish = dist(fg, ANCHORS.red) < 70 || (hue(fg) <= 18 && sat(fg) > 0.35);
|
|
236
|
+
const navyish = gray(fg) || dist(fg, ANCHORS.navy) < 70;
|
|
237
|
+
const ok = up ? greenish : (pct ? reddish : (navyish || reddish));
|
|
238
|
+
if (!ok) o.amountColor.push(`"${txt.slice(0, 18)}" ${up ? 'increase not green' : pct ? 'decrease not red' : 'outgoing not navy/red'}`);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// live address structure (WARNING): a display NIM address (≥20px) must be uppercase + chunked
|
|
243
|
+
// into the 3×3 grid, not one flat lowercased string (skill Addresses).
|
|
244
|
+
if (txt && /^NQ[0-9A-Z]{2}(\s?[0-9A-Z]{4}){8}$/i.test(txt.replace(/\s+/g, ' ').trim()) && px(cs.fontSize) >= 20) {
|
|
245
|
+
if (txt !== txt.toUpperCase()) o.addrStructure.push(`address not uppercase "${txt.slice(0, 22)}…"`);
|
|
246
|
+
else if (el.children.length === 0) o.addrStructure.push('address as one flat string (use the 3×3 grid)');
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// pulsing "live" dot (slop): a small round element on an infinite animation that isn't a spinner.
|
|
250
|
+
if (cs.animationName && cs.animationName !== 'none' && /(^|,)\s*infinite\s*(,|$)/.test(cs.animationIterationCount) && r.width <= 28 && r.height <= 28) {
|
|
251
|
+
const round = cs.borderRadius.includes('50%') || px(cs.borderRadius) >= Math.min(r.width, r.height) / 2 - 1;
|
|
252
|
+
if (round && !matchAny(el, '[class*="spin" i],[class*="load" i],[class*="progress" i],[class*="skeleton" i]')) o.pulseDot.push(`<${tag}> infinite pulse on a ${Math.round(r.width)}×${Math.round(r.height)} dot`);
|
|
253
|
+
}
|
|
254
|
+
|
|
198
255
|
// BUTTON shape + fill + gradient anchor
|
|
199
256
|
if (isBtn && txt && r.width >= 80 && r.height >= 26 && !el.matches('.nq-button-s')) {
|
|
200
257
|
const rad = px(cs.borderRadius);
|
|
@@ -265,6 +322,8 @@ function pageProbe({ SPACING_SCALE, ANCHORS, RADIUS_SCALE }) {
|
|
|
265
322
|
const fs = px(cs.fontSize) || 16; const ch = Math.round(r.width / (fs * 0.5));
|
|
266
323
|
if (ch > 88) o.wideText.push({ ch, snippet: txt.slice(0, 50) });
|
|
267
324
|
if (cs.lineHeight !== 'normal' && px(cs.lineHeight) / fs < 1.35) o.tightLeadingBody.push(`${(px(cs.lineHeight) / fs).toFixed(2)} @ ${fs}px "${txt.slice(0, 38)}"`);
|
|
325
|
+
// unconstrained text column (rule: cap the measure) — nimiq's wide copy always sets a max-width
|
|
326
|
+
if (cs.maxWidth === 'none' && r.width > 760) o.unconstrained.push({ w: Math.round(r.width), ch, snippet: txt.slice(0, 40) });
|
|
268
327
|
}
|
|
269
328
|
if (txt) { const fs = px(cs.fontSize); if (fs) o.fontSizes[fs] = (o.fontSizes[fs] || 0) + 1; }
|
|
270
329
|
// font-family — Nimiq text is Mulish (+ Fira Mono for data). A named third-party face is off-brand.
|
|
@@ -330,6 +389,10 @@ function pageProbe({ SPACING_SCALE, ANCHORS, RADIUS_SCALE }) {
|
|
|
330
389
|
}
|
|
331
390
|
o.dupGradIds = Object.entries(gradById).filter(([, sigs]) => sigs.size > 1).map(([id, sigs]) => `#${id} (${sigs.size} differing defs)`);
|
|
332
391
|
|
|
392
|
+
// heading hierarchy — exactly one <h1> per page. (Level SKIPS are NOT checked: nimiq.com/about
|
|
393
|
+
// jumps h1→h4 for styling, so a no-skip rule would flag the reference.)
|
|
394
|
+
o.h1Count = [...document.querySelectorAll('h1')].filter((e) => visible(e)).length;
|
|
395
|
+
|
|
333
396
|
// per-section text-ink ratio (full-width bands)
|
|
334
397
|
const vw = innerWidth;
|
|
335
398
|
const leaves = [...document.querySelectorAll('body *')].filter((el) => !matchAny(el, 'svg') && visible(el) && el.children.length === 0 && directText(el)).map((el) => el.getBoundingClientRect());
|
|
@@ -420,10 +483,20 @@ export async function lint(target, opts = {}) {
|
|
|
420
483
|
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 {}
|
|
421
484
|
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)); });
|
|
422
485
|
const r = await page.evaluate(pageProbe, { SPACING_SCALE, ANCHORS, RADIUS_SCALE });
|
|
423
|
-
//
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
const
|
|
486
|
+
// responsive sweep — overflow / tap-targets / tiny-text across standard breakpoints. The 1440
|
|
487
|
+
// desktop pass above already covered the wide end; overflow at ANY intermediate width is the
|
|
488
|
+
// "no-man's-land" bug a fixed 390+1440 check misses. nimiq.com is overflow-clean at every width.
|
|
489
|
+
const SWEEP = [360, 414, 768, 1024, 1280];
|
|
490
|
+
const sweep = [];
|
|
491
|
+
for (const w of SWEEP) {
|
|
492
|
+
await page.setViewportSize({ width: w, height: 900 });
|
|
493
|
+
await page.waitForTimeout(350);
|
|
494
|
+
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)); });
|
|
495
|
+
sweep.push({ w, ...(await page.evaluate(mobileProbe)) });
|
|
496
|
+
}
|
|
497
|
+
const overflowAt = sweep.filter((s) => s.overflowPx > 4);
|
|
498
|
+
const tapWorst = sweep.reduce((a, s) => (s.smallTargetN > a.smallTargetN ? s : a), sweep[0]);
|
|
499
|
+
const tinyWorst = sweep.reduce((a, s) => (s.tinyText > a.tinyText ? s : a), sweep[0]);
|
|
427
500
|
|
|
428
501
|
// social-icon exemption (run in Node — SOCIAL anchors aren't in the page context)
|
|
429
502
|
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; };
|
|
@@ -462,6 +535,12 @@ export async function lint(target, opts = {}) {
|
|
|
462
535
|
['heading line-height off (nimiq 1.25–1.3)', r.tightLeadingH.length, r.tightLeadingH[0]],
|
|
463
536
|
['cramped body line-height (<1.35; nimiq 1.5)', r.tightLeadingBody.length, r.tightLeadingBody[0]],
|
|
464
537
|
['non-brand font on text (Mulish / Fira only)', offFont.length, offFont.map(([f, n]) => `${f}×${n}`).join(' ')],
|
|
538
|
+
['text contrast below WCAG AA', r.lowContrast.length, r.lowContrast.length && `worst ${Math.min(...r.lowContrast.map((c) => c.ratio))}:1 — ${r.lowContrast[0].fg} on ${r.lowContrast[0].bg} @ ${r.lowContrast[0].fs}px`],
|
|
539
|
+
['multiple <h1> on the page', r.h1Count > 1 ? r.h1Count : 0, r.h1Count > 1 ? `${r.h1Count} h1 elements` : ''],
|
|
540
|
+
['unconstrained text column (set max-width)', r.unconstrained.length, r.unconstrained[0] && `${r.unconstrained[0].w}px (${r.unconstrained[0].ch}ch)`],
|
|
541
|
+
['NIM amount wrong semantic color', r.amountColor.length, r.amountColor[0]],
|
|
542
|
+
['address not uppercase / flat (use 3×3 grid)', r.addrStructure.length, r.addrStructure[0]],
|
|
543
|
+
['pulsing "live" dot animation', r.pulseDot.length, r.pulseDot[0]],
|
|
465
544
|
['uppercase eyebrow (colored / long / pill)', r.uppercase.length, r.uppercase[0]],
|
|
466
545
|
['non-pill action buttons', r.nonPill.length, r.nonPill[0]],
|
|
467
546
|
['flat-fill colored button (needs gradient)', r.flatColorBtn.length, r.flatColorBtn[0]],
|
|
@@ -481,13 +560,13 @@ export async function lint(target, opts = {}) {
|
|
|
481
560
|
['flat-fill navy/colored section (use radial)', r.flatNavySection.length, r.flatNavySection[0]],
|
|
482
561
|
['focus outline removed w/o :focus-visible', r.noFocusRing ? 1 : 0, r.noFocusRing ? 'add a :focus-visible ring' : ''],
|
|
483
562
|
['Mulish not loaded (system-font fallback)', r.fontsNotLoaded ? 1 : 0, r.fontsNotLoaded ? 'load Mulish' : ''],
|
|
484
|
-
['
|
|
485
|
-
['
|
|
486
|
-
['text smaller than 12px
|
|
563
|
+
['horizontal overflow at a breakpoint', overflowAt.length, overflowAt.map((s) => `${s.w}px:${s.overflowPx}px`).join(' ')],
|
|
564
|
+
['tap targets < 36px (any breakpoint)', tapWorst.smallTargetN, tapWorst.smallTargetN ? `@${tapWorst.w}px ${tapWorst.smallTargets[0] || ''}` : ''],
|
|
565
|
+
['text smaller than 12px (any breakpoint)', tinyWorst.tinyText, tinyWorst.tinyText ? `@${tinyWorst.w}px, ${tinyWorst.tinyText} type(s)` : ''],
|
|
487
566
|
];
|
|
488
567
|
warnCount = warns.reduce((n, w) => n + (w[1] ? 1 : 0), 0);
|
|
489
568
|
|
|
490
|
-
if (opts.json) { out(JSON.stringify({ url, errorCount, warnCount, raw: r,
|
|
569
|
+
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 }; }
|
|
491
570
|
|
|
492
571
|
out(`\n══════ nq lint — ${target} ══════\n`);
|
|
493
572
|
out('ERRORS (off-brand / a11y — must fix to pass)');
|