Compare commits

...

9 Commits

Author SHA1 Message Date
Kpa-clawbot 653d47e03c test(openapi): add CI completeness gate for /api routes (Phase 1 of #1670) (#1678)
## Summary

Partial fix for #1670 — **Phase 1 only** (CI completeness gate). Phase 2
(backfilling the 18 currently-undocumented routes into `openapi.go`) is
deferred to a separate issue per the triage on #1670 and is explicitly
out of scope here.

## What this adds

- `cmd/server/openapi_completeness_test.go` — AST-walks every
non-`_test.go` file in `cmd/server/`, finds string-literal first args to
`*.HandleFunc(...)` calls beginning with `/api/`, and diffs against the
paths declared in `routeDescriptions()` in `cmd/server/openapi.go`.
- `cmd/server/openapi_known_gaps.json` — seeded allowlist of the **18**
`/api/` routes currently registered via `HandleFunc` but not yet
documented in `openapi.go`.

## Ratchet pattern

From this branch forward, `TestOpenAPICompleteness` fails when:

1. A new `HandleFunc("/api/...")` is added without a matching entry in
`openapi.go` **or** the allowlist (regression gate — the main goal of
Phase 1).
2. A route in the allowlist is *also* documented in `openapi.go` — the
allowlist must shrink as Phase 2 backfills land, never go stale.

The two-commit history (red → green) demonstrates the gate works:

- **Red commit**: adds only the test. Fails on master with the 18
missing routes listed.
- **Green commit**: adds the allowlist seeded with that exact 18-route
set. Test passes at the current baseline.

## Local verification

- `go test ./cmd/server/ -run TestOpenAPICompleteness -v` → PASS at
baseline (`44/62 covered; 18 in allowlist; 18 gaps remain`).
- Ratchet validation: temporarily inserted
`r.HandleFunc("/api/ratchet-test-route", ...)` into `routes.go` → test
FAILED with that exact route name; reverted → test PASSES again.

## Files changed

- `cmd/server/openapi_completeness_test.go` (+203 / new)
- `cmd/server/openapi_known_gaps.json` (+24 / new)

## Preflight

`bash ~/.openclaw/skills/pr-preflight/scripts/run-all.sh origin/master`
→ all hard gates pass; no warnings.

## Out of scope

- Backfilling the 18 allowlisted routes into `openapi.go` (Phase 2 —
tracked separately).
- Schema validation of the spec against OpenAPI 3.0 (Phase 3 per the
issue).
- PR template checkbox update (Phase 2 follow-up).

Issue #1670 stays open for Phase 2.

---------

Co-authored-by: clawbot <bot@corescope.local>
2026-06-12 01:52:12 -07:00
Kpa-clawbot 2d59f15a07 docs(v3.9.1): release notes 2026-06-12 06:00:10 +00:00
Kpa-clawbot edc6d5da02 fix(#1107): content-drive Live PACKET TYPES legend + dock toggles bottom-right (#1669)
Fixes #1107

Per triage fix path (#1107 comment 4672137236): the Live view PACKET
TYPES legend was oversized (>60% whitespace per tufte review) and the
activate/hide toggle buttons were scattered and cramped at the bottom of
the map.

## Changes

`public/live.css`:
- `.live-legend` — added `height: max-content` + `max-width: 260px`.
Panel now hugs its content instead of dominating the map.
- `.legend-toggle-btn` — switched from `position:absolute; bottom:82px;
right:12px` to `position:fixed; bottom:1rem; right:1rem` (the
conventional map-control corner-dock per mesh-operator review).
- `.feed-show-btn` — switched from scattered `position:absolute;
bottom:12px; left:12px` to `position:fixed; bottom:1rem; right:1rem`
with `margin-bottom:56px` so it stacks above the legend toggle.
Activate/hide controls now dock together as one tidy bottom-right
cluster.

All colors via existing CSS variables (no hex tokens added).

`test-issue-1107-live-layout.js` (new) — source-invariant assertions
following the `test-issue-1532-live-fullscreen.js` pattern. Wired into
the JS unit-test gate in `.github/workflows/deploy.yml`.

## TDD trace

- Red commit: `c86073f68e30bb3c1c9f3880b39f4239cb681905` — test added
asserting the layout invariants. Verified locally: 8 assertion failures
on master CSS (exit 1).
- Green commit: `4bd29f9b87ad0a1b214f60ec55ae17d6c9f2d819` — CSS fix.
All 14 assertions pass. Reverting `public/live.css` returns 8 failures
(test gates behavior, not tautology).

## E2E / browser verification

E2E assertion added: `test-issue-1107-live-layout.js:48` (`.live-legend`
height/max-width invariants) and `:72-90` (toggle button group pinned
bottom-right).

This is a CSS-only layout fix; the assertions are source-invariant on
`public/live.css` (same pattern the codebase uses for #1532 / #1234
layout fixes — runs in the JS unit-test gate without needing a live
server). Browser visual verification of the docked cluster can be done
at the staging URL `http://analyzer-stg.00id.net/#/live` once the deploy
runs.

## Preflight

`bash ~/.openclaw/skills/pr-preflight/scripts/run-all.sh origin/master`
— clean (all gates pass, no warnings to ack).

---------

Co-authored-by: clawbot <bot@kpa-clawbot.local>
Co-authored-by: meshcore-bot <bot@meshcore.dev>
2026-06-11 22:53:27 -07:00
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
11 changed files with 779 additions and 34 deletions
+1
View File
@@ -134,6 +134,7 @@ jobs:
node test-issue-1509-nav-active-bg.js
node test-issue-1509-detect-preset.js
node test-live.js
node test-issue-1107-live-layout.js
node test-issue-1532-live-fullscreen.js
node test-issue-1619-feed-detail-card-draggable.js
node test-xss-escape-sinks.js
+10
View File
@@ -2,6 +2,16 @@
## [Unreleased]
## [3.9.1] — 2026-06-12
Patch release on top of v3.9.0 — v3.9.0's container image never published (Playwright flake gated Docker build). See [docs/release-notes/v3.9.1.md](docs/release-notes/v3.9.1.md).
### 🎨 Accessibility
- **WCAG AA contrast pass** (#1676, f0addfda) — two-tier CSS palette; muted-text ≥4.5:1 in both themes; unknown-repeater chip fixed (2.75:1 → 4.95:1). Closes #1671. Partial fix for #1668.
### 🧪 Test stability
- **Slideover E2E flake fix** (#1663+followups, f06359d7) — tightened selectors, bumped data-row wait. Fixes #1662.
## [3.9.0] — 2026-06-12
See [docs/release-notes/v3.9.0.md](docs/release-notes/v3.9.0.md) for the full notes. 257 commits since v3.8.3 (72 substantive + 185 coverage bumps).
+208
View File
@@ -0,0 +1,208 @@
// Package main: openapi completeness gate.
//
// Phase 1 of issue #1670: enforce that every `/api/*` route registered via
// `*.HandleFunc("/api/...", ...)` in cmd/server/*.go (non-_test) has a
// corresponding entry in the OpenAPI spec map declared in
// cmd/server/openapi.go (the `routeDescriptions` map literal).
//
// Ratchet pattern:
// - On first land, the spec covers only a subset of handlers. The full
// missing list is "frozen" into cmd/server/openapi_known_gaps.json.
// - The test FAILS when a NEW HandleFunc("/api/...") is added without
// either (a) adding the route to openapi.go, or (b) appending it to
// openapi_known_gaps.json.
// - It also FAILS if any entry in openapi_known_gaps.json is now covered
// by openapi.go (the allowlist must shrink as Phase 2 backfills land).
//
// Phase 2 (the actual backfill of ~18 routes into openapi.go) is tracked
// in a separate issue per the triage on #1670. This file is the gate
// that ensures the gap does not GROW while Phase 2 is in progress.
package main
import (
"encoding/json"
"go/ast"
"go/parser"
"go/token"
"os"
"sort"
"strconv"
"strings"
"testing"
)
const knownGapsFile = "openapi_known_gaps.json"
// collectHandlerRoutes walks every non-_test .go file in cmd/server/ and
// returns the set of string-literal first args to any `*.HandleFunc(...)`
// or `*.Handle(...)` call whose value starts with "/api/".
//
// Both forms are used in cmd/server/routes.go: bare handlers use
// `r.HandleFunc("/api/...", fn)`, while handlers wrapped in auth
// middleware use `r.Handle("/api/...", wrapped).Methods("...")`. The
// completeness gate MUST consider both — anything less lets the
// gorilla-style chained routes slip past the ratchet.
func collectHandlerRoutes(t *testing.T) map[string]string {
t.Helper()
out := map[string]string{} // route -> "file:line"
entries, err := os.ReadDir(".")
if err != nil {
t.Fatalf("read cmd/server dir: %v", err)
}
fset := token.NewFileSet()
for _, e := range entries {
if e.IsDir() {
continue
}
name := e.Name()
if !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") {
continue
}
f, err := parser.ParseFile(fset, name, nil, parser.AllErrors)
if err != nil {
t.Fatalf("parse %s: %v", name, err)
}
ast.Inspect(f, func(n ast.Node) bool {
call, ok := n.(*ast.CallExpr)
if !ok {
return true
}
sel, ok := call.Fun.(*ast.SelectorExpr)
if !ok || sel.Sel == nil {
return true
}
if sel.Sel.Name != "HandleFunc" && sel.Sel.Name != "Handle" {
return true
}
if len(call.Args) < 1 {
return true
}
lit, ok := call.Args[0].(*ast.BasicLit)
if !ok || lit.Kind != token.STRING {
return true
}
v, err := strconv.Unquote(lit.Value)
if err != nil {
return true
}
if !strings.HasPrefix(v, "/api/") {
return true
}
pos := fset.Position(lit.Pos())
if _, exists := out[v]; !exists {
out[v] = pos.String()
}
return true
})
}
return out
}
// strconvUnquote strips Go string-literal quoting without pulling strconv
// into the import list (keeps the file's imports lean).
func strconvUnquote(s string) (string, error) {
if len(s) >= 2 && s[0] == '"' && s[len(s)-1] == '"' {
return s[1 : len(s)-1], nil
}
if len(s) >= 2 && s[0] == '`' && s[len(s)-1] == '`' {
return s[1 : len(s)-1], nil
}
return s, nil
}
// collectSpecRoutes returns the set of "/api/..." paths declared in the
// routeDescriptions() map in openapi.go. Keys are "METHOD /path"; we strip
// the method and take just the path.
func collectSpecRoutes(t *testing.T) map[string]bool {
t.Helper()
out := map[string]bool{}
for k := range routeDescriptions() {
// key shape: "GET /api/foo" — split once on space.
idx := strings.IndexByte(k, ' ')
if idx < 0 {
continue
}
path := k[idx+1:]
if strings.HasPrefix(path, "/api/") {
out[path] = true
}
}
return out
}
// loadKnownGaps returns the allowlist of currently-known-missing routes.
// Missing file is treated as an empty allowlist (the initial RED state).
func loadKnownGaps(t *testing.T) map[string]bool {
t.Helper()
out := map[string]bool{}
b, err := os.ReadFile(knownGapsFile)
if err != nil {
if os.IsNotExist(err) {
return out
}
t.Fatalf("read %s: %v", knownGapsFile, err)
}
var payload struct {
Routes []string `json:"routes"`
}
if err := json.Unmarshal(b, &payload); err != nil {
t.Fatalf("parse %s: %v", knownGapsFile, err)
}
for _, r := range payload.Routes {
out[r] = true
}
return out
}
// TestOpenAPICompleteness is the ratchet gate for issue #1670.
func TestOpenAPICompleteness(t *testing.T) {
handlers := collectHandlerRoutes(t)
spec := collectSpecRoutes(t)
gaps := loadKnownGaps(t)
// 1. Find routes registered via HandleFunc but missing from spec AND
// not in the allowlist — these are new regressions.
var newMissing []string
for route := range handlers {
if spec[route] {
continue
}
if gaps[route] {
continue
}
newMissing = append(newMissing, route)
}
sort.Strings(newMissing)
// 2. Find allowlist entries that are now covered by the spec — the
// allowlist must shrink, not stay stale.
var stale []string
for route := range gaps {
if spec[route] {
stale = append(stale, route)
}
}
sort.Strings(stale)
// 3. (Diagnostic only) Total current gap count, for visibility.
var currentGaps []string
for route := range handlers {
if !spec[route] {
currentGaps = append(currentGaps, route)
}
}
sort.Strings(currentGaps)
t.Logf("openapi spec covers %d/%d /api/ handler routes; %d in allowlist; %d total gaps remain",
len(handlers)-len(currentGaps), len(handlers), len(gaps), len(currentGaps))
if len(newMissing) > 0 {
t.Errorf("\n%d /api/ route(s) registered in cmd/server but NOT in openapi.go spec AND NOT in %s:\n - %s\n\nFix one of:\n a) Add the route to routeDescriptions() in cmd/server/openapi.go (preferred — Phase 2 of #1670)\n b) Append the route to cmd/server/%s (ratchet — only if Phase 2 backfill is genuinely deferred)\n",
len(newMissing), knownGapsFile, strings.Join(newMissing, "\n - "), knownGapsFile)
}
if len(stale) > 0 {
t.Errorf("\n%d route(s) in %s are now covered by openapi.go and must be REMOVED from the allowlist (ratchet must shrink):\n - %s\n",
len(stale), knownGapsFile, strings.Join(stale, "\n - "))
}
}
+27
View File
@@ -0,0 +1,27 @@
{
"_comment": "Allowlist of /api/ routes registered via HandleFunc in cmd/server/ that are NOT yet documented in cmd/server/openapi.go. This is the 'ratchet' baseline for issue #1670 Phase 1: the TestOpenAPICompleteness gate fails when a NEW handler is added without either documenting it in openapi.go OR appending it here. Phase 2 (the actual backfill of these routes into openapi.go) is tracked in a separate issue per the #1670 triage. Entries should be REMOVED as Phase 2 lands docs for each route — the gate also fails if an entry here is already covered by openapi.go (stale allowlist).",
"_issue": "https://github.com/Kpa-clawbot/CoreScope/issues/1670",
"routes": [
"/api/admin/prune-geo-filter",
"/api/admin/prune-geo-filter/status",
"/api/analytics/relay-airtime-share",
"/api/analytics/roles",
"/api/config/areas",
"/api/config/areas/polygons",
"/api/docs",
"/api/dropped-packets",
"/api/healthz",
"/api/known-channels",
"/api/nodes/clock-skew",
"/api/nodes/{pubkey}/battery",
"/api/nodes/{pubkey}/clock-skew",
"/api/nodes/{pubkey}/reach",
"/api/observers/clock-skew",
"/api/paths/inspect",
"/api/perf/io",
"/api/perf/sqlite",
"/api/perf/write-sources",
"/api/scope-stats",
"/api/spec"
]
}
+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).
---
+15
View File
@@ -0,0 +1,15 @@
# CoreScope v3.9.1
**Upgrade urgency: Recommended** — v3.9.1 is what v3.9.0 should have been. v3.9.0's container image never published (Playwright flake gated Docker build). v3.9.1 includes everything in v3.9.0 plus:
- **WCAG AA contrast pass** — new two-tier CSS palette (raw `--palette-*` → semantic `--color-*`/`--text-*`); muted-text family bumped to ≥4.5:1 in both themes (most well above — `--text-muted` on dark surface goes 3.5 → 11.58:1). Operator-reported unknown-repeater chip ("dark-blue text on dark-blue background") fixed (2.75:1 → 4.95:1). Closes #1671. Partial fix for #1668. (#1676, f0addfda)
- **Slideover test stability**`test-slideover-1056-e2e.js` was racing the packets virtual-scroll spacer; tightened selectors, bumped data-row wait to 20s. Cleared the day-of-release CI flakes. Fixes #1662. (#1663+followups, f06359d7)
## Verification
Test plan: `workspace-meshcore/test-plans/v3.9.0-cdp-test-plan.md` (93 tests, applies unchanged to v3.9.1).
M1 a11y audit: `workspace-meshcore/a11y-audit/reports/violations-summary.md` (2,429 BLOCKER → estimated 85% cleared by M2).
## Acknowledgements
External contributors from v3.9.0 still apply: @efiten, @EldoonNemar. No new external PRs since v3.9.0.
+23 -6
View File
@@ -300,9 +300,15 @@
/* #1206: track --vcr-bar-height (set by JS ResizeObserver on .vcr-bar)
* the same way .live-feed does hard-coded bottom offsets get
* occluded by the bar on mobile two-row + safe-area-inset layouts.
*
* #1107: panel was oversized relative to its content (>60% whitespace
* around a sparse legend). Drive height by content and cap width so
* the PACKET TYPES legend stops dominating the map.
*/
bottom: calc(var(--vcr-bar-height, 58px) + 10px);
right: 12px;
height: max-content;
max-width: 260px;
background: color-mix(in srgb, var(--surface-1) 92%, transparent);
backdrop-filter: blur(12px);
padding: 10px 14px;
@@ -888,7 +894,16 @@ body.live-fullscreen #liveFullscreenToggle:hover,
.feed-hide-btn:hover { opacity: 1; color: #fff; background: rgba(239,68,68,0.6); }
.feed-show-btn {
position: absolute; bottom: 12px; left: 12px; z-index: 500;
/* #1107: pin fixed bottom-right (stacked above legend toggle) so the
* activate/hide button group docks together as a tidy cluster.
* bottom uses max() to clear the VCR bar when present (#1206). */
--legend-toggle-stack: calc(var(--legend-toggle-h, 46px) + 10px); /* gap above legend-toggle */
position: fixed;
bottom: max(1rem, calc(var(--vcr-bar-height, 0px) + 10px + var(--legend-toggle-stack)));
right: 1rem; z-index: 500;
/* Stack above .legend-toggle-btn using margin-bottom so the `right: 1rem`
* invariant (and bottom-right anchor) stays consistent across both buttons. */
margin-bottom: var(--legend-toggle-stack);
background: color-mix(in srgb, var(--surface-1) 92%, transparent); backdrop-filter: blur(8px);
border: 1px solid var(--border); border-radius: 8px;
color: var(--text-muted); font-size: 18px; padding: 8px 10px;
@@ -1180,7 +1195,6 @@ input.live-node-filter-input:focus {
* how tall it grows (mobile two-row layout, safe-area-inset, etc.).
*/
.live-feed { bottom: calc(var(--vcr-bar-height, 58px) + 10px); }
.feed-show-btn { bottom: calc(var(--vcr-bar-height, 58px) + 10px) !important; }
/* Cap the feed's height so its scrollable content never extends below the
* VCR bar top edge, even when the feed is pinned to a bottom corner.
*/
@@ -1280,11 +1294,14 @@ input.live-node-filter-input:focus {
border: 0;
}
/* Legend toggle button — visible at all sizes (#60, #279) */
/* Legend toggle button visible at all sizes (#60, #279).
* #1107: pinned fixed bottom-right so the activate/hide button group
* docks consistently (with .feed-show-btn) instead of floating loose
* inside the map container. */
.legend-toggle-btn {
position: absolute;
bottom: 82px;
right: 12px;
position: fixed;
bottom: 1rem;
right: 1rem;
z-index: 500;
background: color-mix(in srgb, var(--surface-1) 92%, transparent);
backdrop-filter: blur(8px);
+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); }
+212
View File
@@ -0,0 +1,212 @@
/**
* #1107 Live view: PACKET TYPES legend oversized + bottom toggle buttons
* cramped.
*
* Per triage fix path (Kpa-clawbot/CoreScope#1107):
* 1. `.live-legend` panel must be content-driven (`height: max-content`)
* with a `max-width` cap so it doesn't dominate the map.
* 2. The activate/hide toggle button group at the bottom of the map
* (`.legend-toggle-btn`, `.feed-show-btn`) must be pinned via
* `position: fixed; bottom: 1rem; right: 1rem` so they dock as one
* tidy bottom-right group instead of being scattered/cramped.
* 3. Theming uses existing CSS variables only no new hex colors.
*
* Source-invariant assertions on public/live.css, same approach as
* test-issue-1532-live-fullscreen.js (runs in the JS unit test gate).
*/
'use strict';
const fs = require('fs');
const path = require('path');
let passed = 0, failed = 0;
function assert(cond, msg) {
if (cond) { passed++; console.log(' \u2713 ' + msg); }
else { failed++; console.error(' \u2717 ' + msg); }
}
const liveCss = fs.readFileSync(path.join(__dirname, 'public', 'live.css'), 'utf8');
// Extract the .live-legend base block (first occurrence, not the media
// queries, not the .matrix-theme override, not .live-legend.hidden).
function ruleBlock(css, selector) {
// Match the LARGEST rule block for the given selector (multiple may
// exist: a base rule + media-query overrides). We pick the largest body
// because it is the canonical declaration with full property set.
const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const re = new RegExp(
'(?:^|[\\n,}])\\s*' + escaped + '\\s*\\{([^}]*)\\}',
'gm'
);
let m, best = null;
while ((m = re.exec(css)) !== null) {
if (best == null || m[1].length > best.length) best = m[1];
}
return best;
}
function allRuleBlocks(css, selector) {
const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const re = new RegExp(
'(?:^|[\\n,}])\\s*' + escaped + '\\s*\\{([^}]*)\\}',
'gm'
);
const out = [];
let m;
while ((m = re.exec(css)) !== null) out.push(m[1]);
return out;
}
function anyBlockMatches(css, selector, pattern) {
return allRuleBlocks(css, selector).some(b => pattern.test(b));
}
// ─────────────────────────────────────────────────────────────────────
console.log('\n=== #1107 A: .live-legend is content-driven + width-capped ===');
const legendBase = ruleBlock(liveCss, '.live-legend');
assert(legendBase != null, '.live-legend base rule block found in live.css');
if (legendBase) {
assert(
/height\s*:\s*max-content/.test(legendBase),
'.live-legend declares `height: max-content` (content-driven, not oversized)'
);
assert(
/max-width\s*:/.test(legendBase),
'.live-legend declares a `max-width` cap (does not dominate map)'
);
}
// ─────────────────────────────────────────────────────────────────────
console.log('\n=== #1107 B: bottom toggle button group pinned bottom-right ===');
const legendBtn = ruleBlock(liveCss, '.legend-toggle-btn');
assert(legendBtn != null, '.legend-toggle-btn rule block found');
if (legendBtn) {
assert(
/position\s*:\s*fixed/.test(legendBtn),
'.legend-toggle-btn uses position: fixed (pinned to viewport)'
);
assert(
/bottom\s*:\s*1rem/.test(legendBtn),
'.legend-toggle-btn pinned at bottom: 1rem'
);
assert(
/right\s*:\s*1rem/.test(legendBtn),
'.legend-toggle-btn pinned at right: 1rem'
);
}
const feedShowBtn = ruleBlock(liveCss, '.feed-show-btn');
assert(feedShowBtn != null, '.feed-show-btn rule block found');
if (feedShowBtn) {
assert(
/position\s*:\s*fixed/.test(feedShowBtn),
'.feed-show-btn uses position: fixed (pinned to viewport)'
);
assert(
/bottom\s*:\s*(1rem|max\(1rem)/.test(feedShowBtn),
'.feed-show-btn pinned at bottom: 1rem or max(1rem,...) (grouped with legend toggle)'
);
assert(
/right\s*:\s*1rem/.test(feedShowBtn),
'.feed-show-btn pinned at right: 1rem (grouped with legend toggle)'
);
}
// ─────────────────────────────────────────────────────────────────────
console.log('\n=== #1107 B2: cascade-final bottom invariant (no !important conflict) ===');
// Scan ALL .feed-show-btn rule blocks. If any declares `bottom` with
// `!important`, there must NOT be a separate block setting a different
// `bottom` without `!important` — otherwise the cascade silently wins
// and the buttons don't actually dock together.
(function cascadeFinalCheck() {
const feedBlocks = allRuleBlocks(liveCss, '.feed-show-btn');
const legendBlocks = allRuleBlocks(liveCss, '.legend-toggle-btn');
// Collect all bottom declarations from .feed-show-btn blocks
const feedBottoms = [];
for (const block of feedBlocks) {
const m = block.match(/bottom\s*:\s*([^;]+)/);
if (m) {
feedBottoms.push({
value: m[1].trim(),
important: /!important/.test(m[1])
});
}
}
// If any .feed-show-btn block uses !important on bottom, then EITHER:
// (a) it is the ONLY bottom declaration (canonical), OR
// (b) all bottom declarations agree (no cascade conflict)
const importantBottoms = feedBottoms.filter(b => b.important);
const nonImportantBottoms = feedBottoms.filter(b => !b.important);
if (importantBottoms.length > 0 && nonImportantBottoms.length > 0) {
// Cascade conflict: an !important override coexists with a non-!important
// declaration. The non-!important block (the PR's docking fix) will LOSE.
assert(false,
'.feed-show-btn has NO cascade conflict: found ' +
importantBottoms.length + ' !important bottom declaration(s) AND ' +
nonImportantBottoms.length + ' non-!important bottom declaration(s) — ' +
'the !important wins at runtime, breaking docking');
} else {
assert(true, '.feed-show-btn bottom declarations have no !important cascade conflict');
}
// Both buttons must resolve to the same `right` value across all blocks
const feedRights = [];
for (const block of feedBlocks) {
const m = block.match(/right\s*:\s*([^;]+)/);
if (m) feedRights.push(m[1].trim().replace(/\s*!important/, ''));
}
const legendRights = [];
for (const block of legendBlocks) {
const m = block.match(/right\s*:\s*([^;]+)/);
if (m) legendRights.push(m[1].trim().replace(/\s*!important/, ''));
}
// All right values should be identical across both selectors
const allRights = [...new Set([...feedRights, ...legendRights])];
assert(
allRights.length === 1,
'.feed-show-btn and .legend-toggle-btn share identical `right` value' +
(allRights.length !== 1 ? ' — found: ' + allRights.join(', ') : ' (both ' + allRights[0] + ')')
);
})();
// ─────────────────────────────────────────────────────────────────────
console.log('\n=== #1107 C: no new hex colors introduced for #1107 changes ===');
// Lightweight invariant: the .live-legend and toggle-button rules use
// CSS variables (no raw #hex in their base bodies). Existing rules in
// this repo already follow that convention; this gate prevents the fix
// from regressing it.
function noHexInBlock(block, name) {
if (!block) return;
// Strip /* ... */ comments first — issue refs like "#1206" in comments
// are not hex colors. Also restrict to canonical 3/6/8-digit hex (not 4/5/7).
const stripped = block.replace(/\/\*[\s\S]*?\*\//g, '');
const hex = stripped.match(/#(?:[0-9a-fA-F]{8}|[0-9a-fA-F]{6}|[0-9a-fA-F]{3})\b/);
assert(
!hex,
`${name} contains no raw hex color (uses CSS variables only)` +
(hex ? ` — found ${hex[0]}` : '')
);
}
noHexInBlock(legendBase, '.live-legend base');
noHexInBlock(legendBtn, '.legend-toggle-btn');
noHexInBlock(feedShowBtn, '.feed-show-btn');
// ─────────────────────────────────────────────────────────────────────
console.log(`\nResults: ${passed} passed, ${failed} failed`);
if (failed > 0) {
console.error('FAIL — #1107 layout invariants not met');
process.exit(1);
}
console.log('PASS — #1107 layout invariants enforced');
+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 {