mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-06-12 11:11:45 +00:00
Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 653d47e03c | |||
| 2d59f15a07 | |||
| edc6d5da02 | |||
| f0addfdabf | |||
| f06359d739 | |||
| b0996047ef | |||
| ef13b22291 | |||
| bb3fd21f9f | |||
| e3a3f93f7b |
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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 - "))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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).
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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); }
|
||||
|
||||
@@ -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');
|
||||
@@ -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)`);
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user