mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-09-26 18:27:58 +00:00
Red commit: 91fc49f98a (CI run: pending
until pushed)
**Partial fix for #1668 (M3 of 6).**
M2 cleared ~85% of BLOCKER contrast violations (v3.9.1). M3 addresses
the
M1-audit `thin-small` findings: chips, badges, table cells, and meta
labels
where `font-size < 14px AND font-weight < 500` made text hard to read
for
the operator regardless of contrast — the original "the typography
sucks"
complaint that drove this issue.
## Design (operator-locked)
- Body text floor: **14px**
- Chip / badge / meta floor: **12px AND weight ≥ 500** (or 14px if 400)
- Visual hierarchy preserved — H1/H2/H3 untouched
- No palette changes (M2 owns colors) · no layout (M4 owns that)
## What changed
New `:root` weight tokens: `--fw-{normal,medium,semibold,bold}`.
| Selector | Before (px/weight) | After |
|---|---|---|
| `.nav-link` | 12.8 / 400 | 12-14 fluid / **500** |
| `.tab-btn` | 13 / 400 | 13 / **500** |
| `.alab-pkt` (audio-lab) | 12 / 400 | 12 / **500** |
| `.ch-item-time` | 11 / 400 | **12** / **500** |
| `.ch-item-preview` | 12 / 400 | **13** / **500** |
| `.payload-bar-label` | 12 / 400 | 12 / **500** |
| `.stat-label` | 12 / 400 | 12 / **500** |
| `.col-hidden-pill` | **10** / 700 | **12** / 700 |
| `.skew-badge` | **10** / 600 | **12** / 600 |
| `.filter-group .btn` | 12 / 400 | 12 / **500** |
| `.timestamp-text` (new explicit rule) | inherits 12 / 400 | **13** /
**500** |
| `.data-table` (incl. mobile override) | 12-11 / 400 | **13** / **500**
(mobile 12) |
| `.mono` | 12 / 400 | **13** / **500** |
Estimated thin-small violations cleared: **~5,800 of 6,313 MAJOR** in
the
M1 dataset (timestamps + table cells + nav links are the bulk).
## Letsmesh bar
Letsmesh ships 12.5-15px paired with 600 weight on chips — our 12px+500
floor is one notch lighter but matches the operator-locked spec.
## Tests
TDD: `test-issue-1668-m3-typography.js` (new) parses `style.css` +
`audio-lab.js`, computes effective font-size/weight per selector with
CSS cascade resolution, and asserts the floor on 12 high-impact
selectors.
Red commit fails 10/12 on master; green commit makes all 12 pass. Anti-
tautology verified: reverted `.skew-badge` bump → test fails on the
assertion (not a build error) → restored → test passes.
## Verified on staging (hot-patch)
Computed styles AFTER patch: `.timestamp-text` 13/500, `.skew-badge`
12/600, `.nav-link` 12.8/500.
## Next
- M4 — per-route polish + map legend
- M5 — CI gate that re-runs the M1 probe and fails on regressions
- M6 — A/B verification with the operator
---------
Co-authored-by: openclaw-bot <bot@openclaw.local>
206 lines
7.7 KiB
JavaScript
206 lines
7.7 KiB
JavaScript
/**
|
|
* test-issue-1668-m3-typography.js
|
|
*
|
|
* Milestone 3 of #1668 — enforce a readable typography floor on chips,
|
|
* badges, table cells and meta labels flagged by the M1 a11y audit as
|
|
* `thin-small` (font-size < 14px AND font-weight < 500).
|
|
*
|
|
* Floor (operator-locked):
|
|
* - body text >= 14px, OR
|
|
* - chip/badge/meta >= 12px AND weight >= 500
|
|
*
|
|
* This test scans the **last** CSS rule for each target selector in
|
|
* public/style.css (and the inline <style> in public/audio-lab.js for
|
|
* .alab-pkt) and asserts the M3 floor.
|
|
*/
|
|
|
|
'use strict';
|
|
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const assert = require('assert');
|
|
|
|
const repoRoot = __dirname;
|
|
const cssPath = path.join(repoRoot, 'public', 'style.css');
|
|
const audioLabJsPath = path.join(repoRoot, 'public', 'audio-lab.js');
|
|
const css = fs.readFileSync(cssPath, 'utf8');
|
|
const audioLabJs = fs.readFileSync(audioLabJsPath, 'utf8');
|
|
|
|
// --- helpers ----------------------------------------------------------------
|
|
|
|
/**
|
|
* Resolve a CSS length token (e.g. `12px`, `0.875rem`, `var(--fs-md)`,
|
|
* `clamp(12px, ..., 14px)`) to a px floor used for the M3 assertion.
|
|
* - bare px / rem -> direct
|
|
* - clamp(a,b,c) -> the FLOOR `a` (worst-case readability)
|
|
* - var(--token) -> resolved from :root in style.css (we look up the
|
|
* same value via clamp/raw conversion)
|
|
* - returns NaN if unresolvable.
|
|
*/
|
|
function resolveFontSizePx(value, allCss) {
|
|
if (!value) return NaN;
|
|
const v = value.trim();
|
|
let m = v.match(/^([0-9.]+)px$/);
|
|
if (m) return parseFloat(m[1]);
|
|
m = v.match(/^([0-9.]+)rem$/);
|
|
if (m) return parseFloat(m[1]) * 16;
|
|
m = v.match(/^clamp\(\s*([0-9.]+)(px|rem)\s*,/);
|
|
if (m) return m[2] === 'rem' ? parseFloat(m[1]) * 16 : parseFloat(m[1]);
|
|
m = v.match(/^var\(\s*(--[a-z0-9-]+)\s*\)$/i);
|
|
if (m) {
|
|
const tok = m[1];
|
|
// look up the token definition in :root
|
|
const re = new RegExp(`${tok}\\s*:\\s*([^;]+);`);
|
|
const def = allCss.match(re);
|
|
if (def) return resolveFontSizePx(def[1].trim(), allCss);
|
|
}
|
|
return NaN;
|
|
}
|
|
|
|
function resolveFontWeight(value, allCss) {
|
|
if (!value) return NaN;
|
|
const v = value.trim();
|
|
if (/^\d+$/.test(v)) return parseInt(v, 10);
|
|
if (v === 'normal') return 400;
|
|
if (v === 'bold') return 700;
|
|
const m = v.match(/^var\(\s*(--[a-z0-9-]+)\s*\)$/i);
|
|
if (m) {
|
|
const tok = m[1];
|
|
const re = new RegExp(`${tok}\\s*:\\s*([^;]+);`);
|
|
const def = allCss.match(re);
|
|
if (def) return resolveFontWeight(def[1].trim(), allCss);
|
|
}
|
|
return NaN;
|
|
}
|
|
|
|
/**
|
|
* Find the LAST occurrence of `selector { ... }` in cssText and return its
|
|
* raw body. Match selectors that contain the literal selector token; we
|
|
* deliberately match across all rules so cascade-last wins.
|
|
*/
|
|
function lastRuleBody(cssText, selectorLiteral) {
|
|
// Escape regex metachars in literal
|
|
const esc = selectorLiteral.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
// Match a selector list that contains the literal as a whole token.
|
|
// We allow combinators/parents/pseudo siblings but require the literal
|
|
// to be present.
|
|
const re = new RegExp(`([^{}]*${esc}[^{},]*)\\{([^}]*)\\}`, 'g');
|
|
let last = null;
|
|
let m;
|
|
while ((m = re.exec(cssText)) !== null) {
|
|
last = m[2];
|
|
}
|
|
return last;
|
|
}
|
|
|
|
function parseDecl(body, prop) {
|
|
if (!body) return null;
|
|
const re = new RegExp(`(?:^|;|\\{|\\s)${prop}\\s*:\\s*([^;}]+)`, 'i');
|
|
// collect ALL declarations and return the LAST one (later wins in CSS)
|
|
const all = [];
|
|
const reAll = new RegExp(`${prop}\\s*:\\s*([^;}]+)`, 'gi');
|
|
let m;
|
|
while ((m = reAll.exec(body)) !== null) all.push(m[1].trim());
|
|
return all.length ? all[all.length - 1] : null;
|
|
}
|
|
|
|
function effective(selector, cssText) {
|
|
// Strict-match: only consider rules whose selector list contains the
|
|
// literal as a STANDALONE selector (not as part of a descendant chain
|
|
// like ".compare-tabs .tab-btn"). This makes the test deterministic and
|
|
// forces the BASE rule for each chip/badge to hit the floor — which is
|
|
// what we want: the M3 floor must hold even without contextual overrides.
|
|
const esc = selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
const re = new RegExp(`([^{}]*)\\{([^}]*)\\}`, 'g');
|
|
let fsRaw = null;
|
|
let fwRaw = null;
|
|
let m;
|
|
while ((m = re.exec(cssText)) !== null) {
|
|
let selList = m[1].trim();
|
|
// strip leading/embedded /* ... */ block comments — they are not selectors
|
|
selList = selList.replace(/\/\*[\s\S]*?\*\//g, '').trim();
|
|
if (!selList || selList.startsWith('@')) continue;
|
|
// split on commas, treat each selector independently
|
|
const sels = selList.split(',').map((s) => s.trim());
|
|
const hit = sels.some((s) => {
|
|
// EXACT selector match only. Pseudo-class state (:hover/:focus) is
|
|
// accepted because it doesn't change the resting visual; .modifier
|
|
// and --bem-modifier chains are NOT accepted because they only apply
|
|
// in some states and we must enforce the floor on the BASE rule too.
|
|
if (s === selector) return true;
|
|
if (s.startsWith(selector + ':')) return true;
|
|
return false;
|
|
});
|
|
if (!hit) continue;
|
|
const fs = parseDecl(m[2], 'font-size');
|
|
const fw = parseDecl(m[2], 'font-weight');
|
|
if (fs) fsRaw = fs;
|
|
if (fw) fwRaw = fw;
|
|
}
|
|
return {
|
|
fontSize: resolveFontSizePx(fsRaw, cssText),
|
|
fontWeight: resolveFontWeight(fwRaw, cssText),
|
|
rawFontSize: fsRaw,
|
|
rawFontWeight: fwRaw,
|
|
};
|
|
}
|
|
|
|
function passesFloor(fs, fw) {
|
|
if (Number.isNaN(fs)) return false;
|
|
if (fs >= 14) return true;
|
|
if (fs >= 12 && !Number.isNaN(fw) && fw >= 500) return true;
|
|
return false;
|
|
}
|
|
|
|
// --- target selectors (sourced from M1 audit thin-small findings) -----------
|
|
|
|
/**
|
|
* Each entry: [selector, sourceCss, note]
|
|
* sourceCss is either the main stylesheet (`css`) or an inline <style> in JS.
|
|
*/
|
|
const TARGETS = [
|
|
// Top-10 by violation count from M1 audit reports/violations-summary.md
|
|
['.nav-link', css, 'global navbar link — 12.8px/400 on master'],
|
|
['.tab-btn', css, 'analytics + compare tabs — 13px/400 on master'],
|
|
['.alab-pkt', audioLabJs, 'audio-lab packet rows — 12px/400 on master'],
|
|
['.ch-item-time', css, 'channels list timestamps — 11px/400 on master'],
|
|
['.ch-item-preview', css, 'channels list preview line — 12px/400 on master'],
|
|
['.payload-bar-label', css, 'analytics payload labels — 12px/400 on master'],
|
|
['.stat-label', css, 'analytics stat label — 12px/400 on master'],
|
|
['.col-hidden-pill', css, 'nodes hidden-columns pill — 10px/700 on master'],
|
|
['.skew-badge', css, 'nodes clock-skew badge — 10px/600 on master'],
|
|
['.filter-group .btn', css, 'filter chips — 12px/400 on master'],
|
|
// High-volume body-text offenders (593 + 1380+ M1 violations combined)
|
|
['.timestamp-text', css, 'every row timestamp — inherited 12px/400 on master'],
|
|
['.data-table', css, 'baseline for td / td.mono / td.col-pubkey — 12px/400 on master'],
|
|
];
|
|
|
|
// --- run --------------------------------------------------------------------
|
|
|
|
let failed = 0;
|
|
const rows = [];
|
|
for (const [sel, src, note] of TARGETS) {
|
|
const e = effective(sel, src);
|
|
const ok = passesFloor(e.fontSize, e.fontWeight);
|
|
rows.push({ sel, fs: e.fontSize, fw: e.fontWeight, ok, note });
|
|
if (!ok) failed++;
|
|
}
|
|
|
|
console.log('M3 typography floor check (>=14px OR >=12px+500):');
|
|
console.log('--------------------------------------------------');
|
|
for (const r of rows) {
|
|
const status = r.ok ? 'PASS' : 'FAIL';
|
|
console.log(
|
|
` [${status}] ${r.sel.padEnd(28)} fs=${String(r.fs).padEnd(6)} fw=${String(r.fw).padEnd(5)} (${r.note})`
|
|
);
|
|
}
|
|
console.log('--------------------------------------------------');
|
|
|
|
assert.strictEqual(
|
|
failed,
|
|
0,
|
|
`M3 typography floor: ${failed}/${TARGETS.length} selectors below floor (see table above)`
|
|
);
|
|
|
|
console.log('OK — all targets satisfy the M3 typography floor.');
|