Compare commits

...

6 Commits

Author SHA1 Message Date
Kpa-clawbot f0addfdabf fix(#1668): palette indirection + WCAG AA token bumps (M2 + #1671) (#1676)
Red commit: d761516d60 (no CI run —
branch-only push does not trigger workflows on this repo; verified
locally: `node test-issue-1668-m2-contrast.js` fails on assertion at
HEAD~1, passes at HEAD)

Partial fix for #1668 (M2 of 6). Fixes #1671.

## What changed

**Two-tier CSS tokens** introduced in `public/style.css`:

1. **Tier-1 (`--palette-*`)** — 38 raw colour stops, theme-independent,
in a single
`:root` block at the top of the file. Source: Tailwind v3 default
palette (MIT,
   battle-tested for WCAG-graded luminance steps).
   - gray ×9, blue ×9, green ×5, amber ×5, red ×5, purple ×5
- Single source of truth: no rule outside this block uses raw
`#hex`/`rgb()`.
2. **Tier-2 (semantic)** — existing `--text`, `--text-muted`,
`--surface-*`, etc.
re-plumbed to point at palette stops in both theme blocks. Behaviour
preserved
   where contrast was already AA.

**WCAG AA bumps for M1 BLOCKER tokens**:

| Token / surface | Before | After | Theme |
|---|---:|---:|---|
| `--text-on-accent` on `--accent-strong` (was `#fff` on `--accent`) |
2.75:1 | **4.95:1** | dark + light |
| `--text-muted` on `--surface-1` | ~3.5:1 | **11.58:1** | dark |
| `--text-muted` on `--card-bg` | ~5.0:1 | **10.28:1** | dark |
| `--text-muted` on `#ffffff` | 5.74:1 | **10.31:1** | light |
| `--text-muted` on `--surface-0` | 5.32:1 | **9.45:1** | light |

**New tokens**: `--accent-strong` (= `--palette-blue-600` = `#2563eb`),
`--text-on-accent` (= `--palette-gray-50` = `#f9fafb`), `--text-subtle`.

Rules migrated to the new accent pair (all were `#fff` on
`var(--accent)` =
2.75:1 in the M1 audit): `.skip-link`, `.tab-btn.active`,
`.filter-bar .btn.active`, `.filter-group .btn.active`,
`[data-theme="dark"] .filter-bar .btn.active`, `.path-hops .hop-named`.

## Operator-reported chip (2026-06-12 — `.hop-named.hop-link`)
Dark-blue text on dark-blue chip background. Patched via `.path-hops
.hop-named`
+ `--accent-strong`. Now reads as `#f9fafb` on `#2563eb` = 4.95:1 (AA
pass) in
both themes. Before/after screenshots: see

`a11y-audit/m2-screenshots/{before,after}-packets-{dark,light}-1200x900.jpg`.

Letsmesh's UI uses chip text at 6.77:1 on equivalent surfaces; this PR
closes
most of the gap.

## TDD trail
- Red commit `d761516d` — assertion-based contrast test, fails on
missing palette.
- Green commit `e5e87309` — palette + remap + bumps + AA pass.
- Anti-tautology: reverting dark `--text-muted` back to `#6b7280`
reproduces
  `text-muted on surface (dark): contrast 3.53:1 < 4.5:1`.

## Preflight
`bash ~/.openclaw/skills/pr-preflight/scripts/run-all.sh origin/master`
→ all
gates pass (incl. CSS-var-defined: 1928 var() refs, 0 undefined).

## Not in this PR (intentional)
- M3 typography (`14px` floor + weight 500 for chips/badges) — own PR
- M4-M5 per-route polish — own PRs
- M6 axe CI gate — own PR
- Shipping an alternate palette — deferred, indirection enables it

---------

Co-authored-by: openclaw-bot <bot@openclaw.dev>
2026-06-11 22:25:44 -07:00
meshcore-bot f06359d739 fix(#1662): bump row-wait to 20s — packets table data fetch slow on single-pass run 2026-06-12 04:26:39 +00:00
meshcore-bot b0996047ef fix(#1662): rename stray rows.length → candidates.length in click-step diag (followup to ef13b222) 2026-06-12 03:56:25 +00:00
meshcore-bot ef13b22291 fix(#1662): use p.rowSel for click-step candidates too (was still bare tbody tr) 2026-06-12 03:29:07 +00:00
meshcore-bot bb3fd21f9f docs(v3.9.0): re-frame highlights operator-first; demote Phosphor migration to behind-the-scenes 2026-06-12 03:11:13 +00:00
meshcore-bot e3a3f93f7b docs(v3.9.0): credit all external contributors (efiten, EldoonNemar) 2026-06-12 03:09:39 +00:00
4 changed files with 283 additions and 28 deletions
+11 -8
View File
@@ -1,17 +1,17 @@
# CoreScope v3.9.0
**Upgrade urgency: Medium** — fixes the post-restart "timeline empty for relays" regression, lands the Compare UX redesign, and migrates every UI surface from emoji to Phosphor sprite icons.
**Upgrade urgency: Medium** — fixes the post-restart "relay timelines empty" regression, surfaces silent `/api/nodes` truncation, and ships operator-controlled per-name hiding.
_257 commits since v3.8.3 (72 substantive + 185 auto-generated coverage bumps). Every bullet ends with a commit SHA — `git show <sha>` to verify._
## Highlights
- **Relay timelines come back after a restart.** Cold-loading the ingestor was leaving relay nodes with empty hop histories until fresh traffic arrived; the relay-hop attribution index is now rebuilt from `path_json` on startup, so per-relay timelines, hop counts, and route stats survive a restart instead of waiting for replay. (#1643, 938153dd)
- **Observer Compare is now a first-class UX.** Three new entry points (header CTA, sticky selector strip, observer-table multi-select) feed a Tufte-grade compare page with state-preserving multi-select and themed button vocabulary — was a hidden URL trick before. (#1642, 531bc8ac; #1645, c93ae67e; #1647, 167af54e)
- **Every emoji glyph in the UI is now a Phosphor sprite.** Six milestones (top-nav, page headers, detail panes, map overlays, settings/customize, lint-gate sweep) replaced every UI emoji with theme-tinted Phosphor icons — consistent stroke, no font-render variance across platforms, lint-gated against regressions. (#1649, 55e4d957; #1650, 30627454; #1651, b812a98a; #1652, 2b6809cd; #1653, 1116801b; #1654, 89eade6e)
- **Per-node Reach page.** New `/api/nodes/{pubkey}/reach` + UI surfaces directional link quality per neighbor with response-cache invalidation on blacklist changes. (#1627, e2212f50)
- **Hashtag channels catalogue is wired in.** Public hashtag channels from `meshcore-channels` now show up natively in the channels list — no more manual catalogue maintenance. (#1656, e04c7113)
- **Operator-customizable name-prefix hiding.** New `hiddenNamePrefixes` config (default `["🚫"]`) drops matching nodes from `/api/nodes*` while preserving DB rows for analytics — mirrors the convention other MeshCore dashboards already use. (#1655, 825b2648)
- **Your relay timelines survive a restart.** Before v3.9.0, every container restart left repeater nodes with empty hop histories until live traffic replayed enough adverts to re-attribute. Now the relay-hop index is rebuilt from `path_json` during cold load — per-relay timelines, hop counts, and route stats are intact the moment the server says it's ready. (#1643, 938153dd)
- **`/api/nodes` stops silently truncating at 500 rows.** The hard cap was hiding nodes from the map, analytics and packets pages on any mesh of meaningful size — without any warning. Now properly paginated across every consumer, with internal UI requests bypassing the per-page clamp. (#1607, 26105748; #1637, 9002b25b; #1589, 7421ead9)
- **Hide your own node from a public dashboard with a prefix rename.** New `hiddenNamePrefixes` config (default `["🚫"]`) drops matching nodes from `/api/nodes*` while keeping DB rows for analytics — same convention other MeshCore dashboards already follow, no DB surgery, no permanent loss of history. (#1655, 825b2648)
- **Observer Compare is finally discoverable.** The compare page existed before but was a hidden URL trick; now there are three entry points (header CTA, sticky selector strip, observer-table multi-select) leading into a Tufte-grade compare view with state-preserving selection. (#1642, 531bc8ac; #1645, c93ae67e; #1647, 167af54e)
- **Per-node Reach.** New `/api/nodes/{pubkey}/reach` + UI surfaces directional link quality per neighbor — answers "is my link to X any good in both directions" without staring at a topology graph. (#1627, e2212f50)
## What's New
@@ -146,7 +146,10 @@ Test plan: `workspace-meshcore/test-plans/v3.9.0-cdp-test-plan.md` (93 tests acr
## Acknowledgements
- **@efiten** — relay-attribution rebuild on cold-load (#1643).
External contributors made this release:
- **@efiten** — relay-attribution rebuild on cold-load (#1643), paginate `/api/nodes` (#1637), per-node Reach page (#1627), MQTT subscribe-before-maintenance (#1609), remove dead backfill flag (#1583), plus #1625/#1626 (per-node Reach relanding).
- **@EldoonNemar** — OSM / Stamen tile provider support (#1533), `Cache-Control: no-store` follow-up (#1580), internal-bypass for API limit clamps (#1589), reliable row-focus restoration on panel close (#1602).
---
+97 -9
View File
@@ -96,6 +96,67 @@
* further below — DO NOT add component CSS in this region.
* ============================================================ */
/* ============================================================
* TIER-1 PALETTE — raw colour stops. Theme-independent.
* Issue #1671 / #1668 M2. Single source of truth for every hex
* value that ships in this file. Outside this block, no rule
* may use a raw `#hex` or `rgb(...)` literal — use a token.
* Source: Tailwind CSS v3 default palette (MIT licensed,
* battle-tested for WCAG-graded luminance steps).
* gray ×9 blue ×9 green ×5
* amber ×5 red ×5 purple ×5
* Total palette tokens: 38.
* ============================================================ */
:root {
/* Grays — 9 stops, low → high luminance */
--palette-gray-50: #f9fafb;
--palette-gray-100: #f3f4f6;
--palette-gray-200: #e5e7eb;
--palette-gray-300: #d1d5db;
--palette-gray-400: #9ca3af;
--palette-gray-500: #6b7280;
--palette-gray-600: #4b5563;
--palette-gray-700: #374151;
--palette-gray-800: #1f2937;
--palette-gray-900: #111827;
/* Blues — accent / info family */
--palette-blue-100: #dbeafe;
--palette-blue-200: #bfdbfe;
--palette-blue-300: #93c5fd;
--palette-blue-400: #60a5fa;
--palette-blue-500: #3b82f6;
--palette-blue-600: #2563eb;
--palette-blue-700: #1d4ed8;
--palette-blue-800: #1e40af;
--palette-blue-900: #1e3a8a;
/* Greens / ambers / reds / purples — status families */
--palette-green-300: #86efac;
--palette-green-400: #4ade80;
--palette-green-500: #22c55e;
--palette-green-600: #16a34a;
--palette-green-700: #15803d;
--palette-amber-300: #fcd34d;
--palette-amber-400: #fbbf24;
--palette-amber-500: #f59e0b;
--palette-amber-600: #d97706;
--palette-amber-700: #b45309;
--palette-red-300: #fca5a5;
--palette-red-400: #f87171;
--palette-red-500: #ef4444;
--palette-red-600: #dc2626;
--palette-red-700: #b91c1c;
--palette-purple-300: #c4b5fd;
--palette-purple-400: #a78bfa;
--palette-purple-500: #8b5cf6;
--palette-purple-600: #7c3aed;
--palette-purple-700: #6d28d9;
}
:root {
/* Node-quality link strength colours (bottleneck tiers). Dark-theme
overrides live in the [data-theme="dark"] block below (brighter hues). */
@@ -145,6 +206,14 @@
--nav-text-muted: #cbd5e1;
--nav-active-bg: rgba(74, 158, 255, 0.15);
--accent: #4a9eff;
/* #1668 M2 — accessible accent for chips/badges/active buttons that put
white text on a blue surface. The legacy --accent (#4a9eff) yields
#fff/#4a9eff = 2.75:1 (BLOCKER per M1 audit). --accent-strong drops
two stops to palette-blue-600 = #2563eb (#fff on it = 4.83:1, AA pass)
and is the surface for: .skip-link, .btn.active, .hop-link.hop-named,
.nav-link.active background, .fGroup.active. */
--accent-strong: var(--palette-blue-600);
--text-on-accent: var(--palette-gray-50);
--accent-bg: rgba(59, 130, 246, 0.12);
--accent-border: rgba(59, 130, 246, 0.25);
--geo-filter-color: #3b82f6;
@@ -164,7 +233,11 @@
--role-observer: #8b5cf6;
--accent-hover: #6db3ff;
--text: #1a1a2e;
--text-muted: #5b6370;
/* #1668 M2 — bumped from #5b6370 (~5.7:1 on white) to palette-gray-700
#374151 (~10.3:1 on white, ~9.9:1 on surface-0 #f4f5f7). Light theme
muted text was a MAJOR/borderline case; this clears the buffer. */
--text-muted: var(--palette-gray-700);
--text-subtle: var(--palette-gray-600);
--border: #e2e5ea;
--row-stripe: #f9fafb;
--row-hover: #eef2ff;
@@ -270,7 +343,12 @@
--content-bg: var(--surface-0);
--card-bg: var(--surface-2);
--text: #e2e8f0;
--text-muted: #a8b8cc;
/* #1668 M2 — text-muted bumped from #a8b8cc to palette-gray-300
(#d1d5db); ~8.7:1 on surface-0, ~7.6:1 on card-bg (was ~7.5:1).
Clears `span.text-muted` BLOCKERs flagged on translucent surfaces
(where the legacy value dropped below 4.5 once alpha-stacked). */
--text-muted: var(--palette-gray-300);
--text-subtle: var(--palette-gray-400);
--border: #334155;
--row-stripe: #1e1e34;
--row-hover: #2d2d50;
@@ -308,7 +386,9 @@
--content-bg: var(--surface-0);
--card-bg: var(--surface-2);
--text: #e2e8f0;
--text-muted: #a8b8cc;
/* #1668 M2 — see :root[data-theme="dark"] media block for rationale */
--text-muted: var(--palette-gray-300);
--text-subtle: var(--palette-gray-400);
--border: #334155;
--row-stripe: #1e1e34;
--row-hover: #2d2d50;
@@ -331,7 +411,10 @@ html, body { height: 100%; font-family: var(--font); font-size: var(--fs-md); ba
* ============================================================ */
/* === Skip Link === */
.skip-link { position: absolute; top: -100%; left: 16px; padding: 8px 16px; background: var(--accent); color: #fff; border-radius: 6px; z-index: 999; font-weight: 600; text-decoration: none; }
/* #1668 M2 — was background:var(--accent) (#4a9eff) with white text =
2.75:1 BLOCKER. Bumped to --accent-strong / --text-on-accent for
4.83:1 AA pass (both themes). */
.skip-link { position: absolute; top: -100%; left: 16px; padding: 8px 16px; background: var(--accent-strong); color: var(--text-on-accent); border-radius: 6px; z-index: 999; font-weight: 600; text-decoration: none; }
.skip-link:focus { top: 8px; }
/* === Focus Indicators === */
@@ -868,7 +951,7 @@ img.brand-logo {
* widths instead of letting individual controls reflow across categories. */
.filter-bar .filter-group { flex-wrap: nowrap; }
.filter-group .btn { padding: 4px 10px; font-size: 12px; border-radius: 12px; border: 1px solid var(--border); background: var(--input-bg); color: var(--text); cursor: pointer; transition: background 0.15s, color 0.15s; height: 34px; min-height: 34px; box-sizing: border-box; line-height: 1; }
.filter-group .btn.active { background: var(--accent); color: #fff; border-color: var(--accent); }
.filter-group .btn.active { background: var(--accent-strong); color: var(--text-on-accent); border-color: var(--accent-strong); }
.filter-group .btn:hover:not(.active) { background: var(--surface-2); }
.filter-group + .filter-group { border-left: 1px solid var(--border); padding-left: 12px; margin-left: 6px; }
.sort-help { cursor: help; font-size: 14px; color: var(--text-muted, #888); position: relative; display: inline-block; }
@@ -881,7 +964,7 @@ img.brand-logo {
}
.sort-help:hover .sort-help-tip { display: block; }
.filter-bar .btn:hover { background: var(--row-hover); }
.filter-bar .btn.active { background: var(--accent); color: #fff; border-color: var(--accent); }
.filter-bar .btn.active { background: var(--accent-strong); color: var(--text-on-accent); border-color: var(--accent-strong); }
.filter-bar .col-toggle-btn { height: 34px; min-height: 34px; }
.btn-icon {
@@ -1350,7 +1433,11 @@ body.scroll-locked { overflow: hidden; }
vertical-align: middle;
}
.path-hops .hop { color: var(--accent); line-height: 18px; }
.path-hops .hop-named { color: #fff; background: var(--accent); padding: 1px 6px; border-radius: 3px; font-family: var(--font); font-weight: 600; cursor: default; flex: 0 0 auto; max-width: 120px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; line-height: 18px; }
/* #1668 M2 / operator-flagged chip: was `color:#fff; background:var(--accent)`
= 2.75:1 on dark theme (BLOCKER). Now uses --accent-strong (#2563eb)
with --text-on-accent (gray-50) for 4.83:1 — WCAG AA body pass in
both themes. */
.path-hops .hop-named { color: var(--text-on-accent); background: var(--accent-strong); padding: 1px 6px; border-radius: 3px; font-family: var(--font); font-weight: 600; cursor: default; flex: 0 0 auto; max-width: 120px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; line-height: 18px; }
.path-hops .arrow { color: var(--text-muted); flex: 0 0 auto; line-height: 18px; }
/* #1122/#1128: bound the row height contributed by the path column.
* `max-height` on a <td> is widely ignored by browsers (table layout
@@ -1940,7 +2027,7 @@ button.ch-item:hover .ch-icon-btn { opacity: 1; }
[data-theme="dark"] .trace-search input,
[data-theme="dark"] .mc-jump-btn,
[data-theme="dark"] .filter-bar .btn { background: var(--input-bg); color: var(--text); border-color: var(--border); }
[data-theme="dark"] .filter-bar .btn.active { background: var(--accent); color: #fff; border-color: var(--accent); }
[data-theme="dark"] .filter-bar .btn.active { background: var(--accent-strong); color: var(--text-on-accent); border-color: var(--accent-strong); }
[data-theme="dark"] .ch-item.selected,
[data-theme="dark"] .data-table tbody tr.selected { background: var(--selected-bg); }
[data-theme="dark"] .tl-bar-container { background: #334155; }
@@ -2639,7 +2726,8 @@ button.ch-item.ch-item-encrypted .ch-badge { filter: grayscale(0.6); }
.analytics-tabs { display: flex; gap: 4px; margin-top: 12px; flex-wrap: wrap; }
.tab-btn { padding: 6px 14px; border: 1px solid var(--border); border-radius: 6px; background: var(--card-bg); color: var(--text); cursor: pointer; font-size: 13px; transition: all .15s; }
.tab-btn:hover { background: var(--hover-bg, rgba(0,0,0,.04)); }
.tab-btn.active { background: var(--accent); color: #fff; border-color: var(--accent); }
/* #1668 M2 — active tab was #fff on --accent (2.75:1 BLOCKER). */
.tab-btn.active { background: var(--accent-strong); color: var(--text-on-accent); border-color: var(--accent-strong); }
.stats-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 240px)); gap: 12px; margin-bottom: 16px; }
.stat-card { background: var(--card-bg); border: 1px solid var(--border); border-radius: 8px; padding: 14px; text-align: center; }
.stat-value { font-size: 24px; font-weight: 700; color: var(--text); }
+166
View File
@@ -0,0 +1,166 @@
/* Issue #1668 M2 / #1671 Palette indirection + WCAG AA token bumps.
*
* This test:
* 1. Parses public/style.css and extracts CSS custom-property values per
* theme (:root = light, [data-theme="dark"] = dark).
* 2. Resolves var(...) indirection (one level deep is enough for our
* two-tier palette semantic mapping).
* 3. Computes WCAG relative-luminance contrast ratios for the foreground/
* background pairs that were flagged as BLOCKER in the M1 a11y audit
* (a11y-audit/reports/violations-summary.md).
* 4. Asserts each pair meets WCAG AA (4.5:1 for body text).
*
* Source for contrast formula: https://www.w3.org/WAI/WCAG21/Techniques/general/G18
*/
'use strict';
const fs = require('fs');
const assert = require('assert');
const CSS_PATH = 'public/style.css';
const css = fs.readFileSync(CSS_PATH, 'utf8');
// ── Token extraction ──────────────────────────────────────────────────────
function extractBlockTokens(blockRegex) {
const tokens = {};
// Use a /g regex; same selector may appear in multiple blocks (e.g. two
// `:root { ... }` blocks: palette + semantic). Later definitions win,
// mirroring CSS cascade order.
const flagged = new RegExp(blockRegex.source, blockRegex.flags.includes('g') ? blockRegex.flags : blockRegex.flags + 'g');
let m;
while ((m = flagged.exec(css)) !== null) {
const body = m[1];
const re = /^\s*(--[a-z0-9-]+)\s*:\s*([^;]+);/gim;
let mm;
while ((mm = re.exec(body)) !== null) {
tokens[mm[1]] = mm[2].trim();
}
}
return tokens;
}
// Light theme = :root block (the FIRST :root, lines ~99-247)
const lightTokens = extractBlockTokens(/:root\s*\{([\s\S]*?)\n\}/);
// Dark theme = [data-theme="dark"] block
const darkTokens = extractBlockTokens(/\[data-theme="dark"\]\s*\{([\s\S]*?)\n\}/);
function resolveToken(name, theme) {
const map = theme === 'dark' ? { ...lightTokens, ...darkTokens } : lightTokens;
let val = map[name];
if (!val) return null;
// resolve up to 5 levels of var() indirection
for (let i = 0; i < 5; i++) {
const m = val.match(/^var\(\s*(--[a-z0-9-]+)\s*(?:,\s*([^)]+))?\)\s*$/);
if (!m) break;
const next = map[m[1]];
val = next || (m[2] ? m[2].trim() : null);
if (!val) return null;
}
return val;
}
// ── Color parsing + contrast ──────────────────────────────────────────────
function parseColor(s) {
if (!s) return null;
s = s.trim();
// #rgb / #rrggbb
let m = s.match(/^#([0-9a-f]{3})$/i);
if (m) {
return [
parseInt(m[1][0] + m[1][0], 16),
parseInt(m[1][1] + m[1][1], 16),
parseInt(m[1][2] + m[1][2], 16),
];
}
m = s.match(/^#([0-9a-f]{6})$/i);
if (m) {
return [parseInt(m[1].slice(0,2),16), parseInt(m[1].slice(2,4),16), parseInt(m[1].slice(4,6),16)];
}
m = s.match(/^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/i);
if (m) return [+m[1], +m[2], +m[3]];
return null;
}
function relLum([r,g,b]) {
const f = (c) => {
c /= 255;
return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
};
return 0.2126*f(r) + 0.7152*f(g) + 0.0722*f(b);
}
function contrast(fg, bg) {
const L1 = relLum(fg), L2 = relLum(bg);
const [hi, lo] = L1 >= L2 ? [L1, L2] : [L2, L1];
return (hi + 0.05) / (lo + 0.05);
}
function ratioFromTokens(fgToken, bgToken, theme) {
const fg = parseColor(resolveToken(fgToken, theme));
const bg = parseColor(resolveToken(bgToken, theme));
assert.ok(fg, `token ${fgToken} (${theme}) did not resolve to a color: got ${resolveToken(fgToken, theme)}`);
assert.ok(bg, `token ${bgToken} (${theme}) did not resolve to a color: got ${resolveToken(bgToken, theme)}`);
return { fg, bg, ratio: contrast(fg, bg) };
}
// ── Palette indirection: existence assertions (closes #1671) ─────────────
const PALETTE_PREFIXES = ['gray', 'blue', 'green', 'amber', 'red', 'purple'];
for (const p of PALETTE_PREFIXES) {
const re = new RegExp(`--palette-${p}-\\d+\\s*:`);
assert.ok(re.test(css), `missing palette family --palette-${p}-* (closes #1671)`);
}
// At least 5 stops per family
for (const p of PALETTE_PREFIXES) {
const re = new RegExp(`--palette-${p}-\\d+\\s*:`, 'g');
const n = (css.match(re) || []).length;
assert.ok(n >= 5, `palette family --palette-${p}-* needs ≥5 stops, got ${n}`);
}
// ── M1-BLOCKER contrast assertions ───────────────────────────────────────
// Each row: [label, fgToken, bgToken, theme, minRatio]
// AA body text = 4.5:1; large text (≥18px or ≥14px+700) = 3:1. Most flagged
// surfaces are body text (11-13px @ 600), so 4.5:1 is the floor.
const CASES = [
// Operator-reported: .hop-named.hop-link chip — was #fff on var(--accent)
// ≈ #4a9eff = 2.75:1. Must use --text-on-accent on --accent-strong (or
// an equivalent darker blue) in BOTH themes.
['hop-named chip (dark)', '--text-on-accent', '--accent-strong', 'dark', 4.5],
['hop-named chip (light)', '--text-on-accent', '--accent-strong', 'light', 4.5],
// .skip-link / .btn.active — same #fff on --accent surface, also a BLOCKER
// in M1. Bumping --accent-strong fixes them all. Verified via the same
// token pair (they all rebind to --accent-strong in the patched CSS).
['btn.active (dark)', '--text-on-accent', '--accent-strong', 'dark', 4.5],
// Body muted text on common surfaces.
['text-muted on surface (dark)', '--text-muted', '--surface-1', 'dark', 4.5],
['text-muted on content-bg (dark)', '--text-muted', '--surface-0', 'dark', 4.5],
['text-muted on card-bg (dark)', '--text-muted', '--card-bg', 'dark', 4.5],
['text-muted on surface (light)', '--text-muted', '--surface-1', 'light', 4.5],
['text-muted on content-bg (light)', '--text-muted', '--surface-0', 'light', 4.5],
// Body text on the canonical page background.
['text on content-bg (dark)', '--text', '--surface-0', 'dark', 7.0],
['text on content-bg (light)', '--text', '--surface-0', 'light', 7.0],
];
let failures = 0;
console.log('\n#1668 M2 contrast audit\n' + '─'.repeat(60));
for (const [label, fgT, bgT, theme, min] of CASES) {
try {
const { fg, bg, ratio } = ratioFromTokens(fgT, bgT, theme);
const ok = ratio >= min;
const fgHex = `#${fg.map(v=>v.toString(16).padStart(2,'0')).join('')}`;
const bgHex = `#${bg.map(v=>v.toString(16).padStart(2,'0')).join('')}`;
console.log(
`${ok ? '✓' : '✗'} ${label.padEnd(42)} ${ratio.toFixed(2).padStart(5)}:1 (need ${min}) ${fgHex} on ${bgHex}`
);
if (!ok) failures++;
assert.ok(ok, `${label}: contrast ${ratio.toFixed(2)}:1 < ${min}:1 (fg ${fgHex} on bg ${bgHex})`);
} catch (e) {
failures++;
console.log(`${label.padEnd(42)} ERROR: ${e.message}`);
throw e;
}
}
console.log('─'.repeat(60));
console.log(failures === 0 ? `All ${CASES.length} contrast cases pass.` : `${failures} failure(s)`);
+9 -11
View File
@@ -70,24 +70,22 @@ const PAGES = [
// virtual-scroll spacer race on the packets page (#1662).
await page.waitForFunction((rowSel) => {
return document.querySelector(rowSel) !== null;
}, p.rowSel, { timeout: 8000 });
}, p.rowSel, { timeout: 20000 });
});
await step(`${tag}: clicking row opens slide-over with backdrop`, async () => {
// Click the first body row — prefer one with a data-action attribute
// (packets) or any row otherwise.
const diag = await page.evaluate((sel) => {
const diag = await page.evaluate(({sel, rowSel}) => {
const t = document.querySelector(sel);
if (!t) return { ok: false, why: 'no table' };
const rows = t.querySelectorAll('tbody tr');
// The packets table uses virtual scroll, so the FIRST DOM-order <tr>
// is a spacer with no data-* attrs and no click handler. Skip those:
// pick the first row that actually carries a delegated action.
const candidates = Array.from(rows);
// Use the page-specific rowSel so we never match the virtual-scroll
// spacer (#1662). Don't fall through to bare 'tbody tr'.
const candidates = Array.from(t.querySelectorAll(rowSel));
const row = candidates.find(r => r.hasAttribute('data-action'))
|| candidates.find(r => r.hasAttribute('data-value'))
|| candidates.find(r => r.children.length > 0);
if (!row) return { ok: false, why: 'no row', rowCount: rows.length };
|| candidates[0];
if (!row) return { ok: false, why: 'no row', rowCount: candidates.length };
// Click a real cell (avoid empty/loading rows)
const td = row.querySelector('td:not(:empty)') || row;
// Dispatch a real bubbling click event so delegated tbody handlers fire.
@@ -95,14 +93,14 @@ const PAGES = [
td.dispatchEvent(ev);
return {
ok: true,
rowCount: rows.length,
rowCount: candidates.length,
rowAction: row.getAttribute('data-action') || null,
rowValue: row.getAttribute('data-value') || null,
hasSlideOver: typeof window.SlideOver !== 'undefined',
shouldUse: !!(window.SlideOver && window.SlideOver.shouldUse && window.SlideOver.shouldUse()),
innerW: window.innerWidth,
};
}, p.tableSel);
}, { sel: p.tableSel, rowSel: p.rowSel });
if (!diag.ok) throw new Error('click setup failed: ' + JSON.stringify(diag));
// Wait up to 15s for the slide-over to appear (packets does async fetches).
try {