diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index de554551..e813b7df 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -249,6 +249,7 @@ jobs: node test-issue-1360-pill-letter-count.js node test-issue-1364-pill-no-clamp.js node test-issue-1375-scope-stats-fetch.js + node test-scope-audit-styles-linked.js node test-issue-1361-cb-presets.js node test-issue-1380-cb-sim-overlay.js node test-issue-1380-cb-reset-button.js diff --git a/public/index.html b/public/index.html index aeeaa5f6..2a5f05d9 100644 --- a/public/index.html +++ b/public/index.html @@ -43,6 +43,7 @@ + diff --git a/public/node-scopes.css b/public/node-scopes.css new file mode 100644 index 00000000..4af607d2 --- /dev/null +++ b/public/node-scopes.css @@ -0,0 +1,69 @@ +/* Node detail — Scopes section. Reuses .analytics-stats / .analytics-stat-card / + .analytics-time-range from the analytics page (same tokens node-reach.css + reuses); these are the scopes-specific bits only. No hex/rgb literals — + --text-muted / --border / --status-* are defined globally. --section-bg is + NOT: it exists only in the dark-theme blocks (style.css), never in light + :root, so every use below MUST keep the var(--section-bg, var(--card-bg)) + fallback — the fallback is load-bearing, not decorative. */ + +.ns-head { display: flex; flex-wrap: wrap; align-items: baseline; justify-content: space-between; gap: 8px; margin-bottom: 8px; } +.ns-head h4 { margin: 0; } +.ns-window { margin: 0; } + +.ns-declared-meta { font-size: 11px; color: var(--text-muted); margin: 0 0 10px; } +/* Flattened from `.ns-declared-meta .ns-truncated` when this file was ported: + the Scope Audit renders the marker in its Declared-age cell, with no + .ns-declared-meta ancestor, so the scoped form styled nothing there. The + standalone rule covers both callers. */ +.ns-truncated { color: var(--status-amber-text); font-weight: 600; } +.ns-declared-meta .ns-never-asked { color: var(--text-muted); font-style: italic; } + +.ns-empty { + color: var(--text-muted); font-size: 13px; padding: 14px 12px; + border: 1px dashed var(--border); border-radius: 6px; margin: 6px 0 4px; +} +.ns-empty.ns-empty-config { color: var(--status-amber-text); border-color: var(--status-amber-text); } + +.ns-table { border-collapse: collapse; width: 100%; font-size: 12px; margin: 4px 0 10px; } +.ns-table th, .ns-table td { border: 0; border-bottom: 1px solid var(--border); padding: 5px 8px; } +.ns-table thead th { border-bottom: 2px solid var(--border); background: none; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: .3px; color: var(--text-muted); text-align: left; } +/* The rule above is (0,1,2) and outranks the global th.sort-active at (0,1,1) + whatever the source order, so without this the sorted column label and its + caret (fill: currentColor) both render muted. Measured on a deployment: + rgb(74,158,255) before this file loads, rgb(91,99,112) after. Table-scoped, + the same way .data-table and .analytics-table already solve it. */ +.ns-table thead th.sort-active { color: var(--link-color); } +.ns-n { text-align: right; font-variant-numeric: tabular-nums; } +.ns-scope-name { font-family: var(--mono); font-weight: 600; } + +.ns-decl { + display: inline-block; padding: 2px 7px; border-radius: var(--badge-radius, 4px); + font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: .3px; + white-space: nowrap; +} +.ns-decl-yes { background: color-mix(in srgb, var(--status-green) 16%, transparent); color: var(--status-green-text); } +.ns-decl-quiet { background: color-mix(in srgb, var(--status-yellow) 18%, transparent); color: var(--status-amber-text); } +.ns-decl-warn { background: color-mix(in srgb, var(--status-red) 16%, transparent); color: var(--danger); } +/* The border is load-bearing here and not on its siblings: --section-bg is + defined only in the dark blocks, so in the light theme this falls back to + --card-bg, which is white on a near-white page ground. Without the outline + the "no flood" badge is invisible — the same failure this file is fixing. + An inset shadow rather than a border so the chip keeps the exact box metrics + of .ns-decl-yes/-quiet/-warn beside it in the same column. */ +.ns-decl-unknown { background: var(--section-bg, var(--card-bg)); box-shadow: inset 0 0 0 1px var(--border); color: var(--text-muted); font-weight: 600; text-transform: none; } + +.ns-routes { font-size: 11px; color: var(--text-muted); margin: 0 0 14px; } +.ns-routes b { color: var(--text); font-variant-numeric: tabular-nums; } + +.ns-links { font-size: 12px; margin-top: 4px; } +.ns-links a { color: var(--link-color); } + +/* The fork this file comes from hides the third column of .ns-table at 480px, + where .ns-table is the per-node Scopes panel's four-column table and column + three is "Last seen". That rule is deliberately NOT ported: the only + .ns-table in this repository is the Scope Audit's, whose third column is + Config — the badge column this file exists to make visible. Measured at + 430px with the rule in place: the Config header and every badge under it + computed to display:none. The audit table already scrolls horizontally + (.sa-table-wrap + .sa-table min-width), so it needs no column hiding. When + the panel lands here, it should bring a rule scoped to its own table. */ diff --git a/public/scope-audit.css b/public/scope-audit.css index dfb12d7e..15a281fa 100644 --- a/public/scope-audit.css +++ b/public/scope-audit.css @@ -36,7 +36,10 @@ } .sa-chip-declared { background: var(--section-bg, var(--card-bg)); color: var(--text); border: 1px solid var(--border); } .sa-chip-undeclared { background: color-mix(in srgb, var(--status-yellow) 18%, transparent); color: var(--status-amber-text); } -.sa-chip-wildcard { background: var(--section-bg, var(--card-bg)); color: var(--text-muted); font-weight: 700; } +/* Same outline, same reason as .ns-decl-unknown: --section-bg is dark-theme + only, so in light this falls back to --card-bg, which is white on a + near-white page ground and leaves the wildcard chip unpainted. */ +.sa-chip-wildcard { background: var(--section-bg, var(--card-bg)); box-shadow: inset 0 0 0 1px var(--border); color: var(--text-muted); font-weight: 700; } .sa-chip-ambiguous { background: var(--section-bg, var(--card-bg)); color: var(--text-muted); border: 1px dashed var(--border); font-family: inherit; font-style: italic; } .sa-count { font-size: 11px; margin-top: 6px; } @@ -48,9 +51,10 @@ .sa-summary { display: flex; flex-wrap: wrap; gap: 10px 16px; font-size: 12px; color: var(--text-muted); margin: 2px 0 10px; } .sa-summary-item { display: inline-flex; align-items: center; gap: 5px; } -@media (max-width: 640px) { - .sa-table th:nth-child(6), .sa-table td:nth-child(6) { display: none; } -} +/* A 640px rule hiding the table's sixth column used to live here. The table + has five (scope-audit.js), so it hid nothing, and the wrapper already scrolls + horizontally. Removed rather than repointed: hiding a column of this table is + what made the Config badge disappear on phones when node-scopes.css landed. */ /* Observed forwarding, the green half of the merged Scopes column. Pairs with .sa-chip-unobserved below, which stays neutral. Only the observed side is diff --git a/test-all.sh b/test-all.sh index b782da68..8804b361 100755 --- a/test-all.sh +++ b/test-all.sh @@ -15,6 +15,7 @@ node test-aging.js node test-issue-1065-gesture-hints-gates.js node test-frontend-helpers.js node test-issue-2001-map-scope-state.js +node test-scope-audit-styles-linked.js node test-fetch-all-nodes-pagination.js node test-my-repeaters-dashboard.js node test-repeater-metric-scatter.js diff --git a/test-scope-audit-styles-linked.js b/test-scope-audit-styles-linked.js new file mode 100644 index 00000000..9841681f --- /dev/null +++ b/test-scope-audit-styles-linked.js @@ -0,0 +1,198 @@ +/* Every class the Scope Audit page paints with must have a rule that can + * actually apply to the element the page renders, in a stylesheet index.html + * links. + * + * The page shipped referencing .ns-decl / .ns-empty / .ns-table, whose rules + * live in node-scopes.css — a file that was never added, and never linked. The + * page still rendered: correct data, correct DOM, no console error, and every + * badge as unstyled text. Nothing in the suite noticed, because nothing + * compared the classes the page writes against the CSS the page loads. + * + * "Can actually apply" is the part a substring check gets wrong. `.ns-truncated` + * was mentioned in exactly one rule, `.ns-declared-meta .ns-truncated`, and this + * page renders that span with no such ancestor — styled on paper, unstyled on + * screen, which is the same bug one level down. + * + * Deliberately not a whitelist of known-good class names: that would need + * updating every time the page grows a class, which is exactly the moment this + * check has to fire. + */ +'use strict'; +const fs = require('fs'); +const assert = require('assert'); + +let passed = 0, failed = 0; +function test(name, fn) { + try { fn(); passed++; console.log(` ✅ ${name}`); } + catch (e) { failed++; console.log(` ❌ ${name}: ${e.message}`); } +} + +// linkedStylesheets returns the local stylesheet paths index.html loads, in +// link order. Vendor CSS is included: it is loaded too, and excluding it would +// be a guess about where a rule is allowed to live. +function linkedStylesheets(html) { + const out = []; + const re = /]+rel=["']stylesheet["'][^>]*>/g; + let m; + while ((m = re.exec(html))) { + const href = /href=["']([^"']+)["']/.exec(m[0]); + if (!href) continue; + const path = href[1].split('?')[0]; + if (/^https?:/.test(path)) continue; // remote sheets are not ours to check + out.push('public/' + path); + } + return out; +} + +// classesUsedBy pulls class names out of the class="..." literals a page +// script writes, plus the ' class-name' fragments it concatenates. Only the +// page's own namespaces are checked; shared vocabulary (btn, active, +// text-muted) belongs to style.css and is not this page's to own. +function classesUsedBy(js, prefixes) { + const found = new Set(); + const re = /class="([^"]*)"|'\s*([a-z][a-z0-9-]*)'/g; + let m; + while ((m = re.exec(js))) { + const chunk = m[1] || m[2] || ''; + for (const raw of chunk.split(/[\s'"+]+/)) { + const name = raw.trim(); + if (!name) continue; + if (prefixes.some(p => name.startsWith(p))) found.add(name); + } + } + return [...found].sort(); +} + +function classRe(className) { + return new RegExp('\\.' + className.replace(/-/g, '\\-') + '(?![a-zA-Z0-9_-])'); +} + +// selectorsFor returns every selector that targets className, comments +// stripped: a class named only inside a comment has no rule. +function selectorsFor(css, className) { + // At-rule preludes are dropped rather than skipped, so the rules INSIDE a + // @media or @supports block are parsed like any other. Skipping them left + // the checker blind to the rule that hid this page's Config column below + // 480px: re-adding that rule kept the suite green. + const clean = css + .replace(/\/\*[\s\S]*?\*\//g, '') + .replace(/@(?:media|supports|layer|container)[^{]*\{/g, ''); + const re = classRe(className); + const out = []; + for (const chunk of clean.split('}')) { + const brace = chunk.indexOf('{'); + if (brace === -1) continue; + for (const sel of chunk.slice(0, brace).split(',')) { + const t = sel.trim(); + if (t && !t.startsWith('@') && re.test(t)) out.push(t); + } + } + return out; +} + +// requiredAncestors lists the classes a selector needs on an ANCESTOR before +// its own compound can match. ".a .b" requires a; ".a.b" requires nothing, +// since both sit on one element. +function requiredAncestors(selector, className) { + const parts = selector.split(/\s*[>+~]\s*|\s+/).filter(Boolean); + const own = parts.findIndex(p => classRe(className).test(p)); + if (own <= 0) return []; + const need = []; + for (const p of parts.slice(0, own)) { + for (const m of p.match(/\.[a-zA-Z][a-zA-Z0-9_-]*/g) || []) need.push(m.slice(1)); + } + return need; +} + +// A class passes when some rule targets it with no ancestor requirement, or +// when every ancestor that rule requires is a class this page also emits. +function ruleApplies(css, className, emitted) { + const sels = selectorsFor(css, className); + if (!sels.length) return false; + return sels.some(sel => requiredAncestors(sel, className).every(a => emitted.has(a))); +} + +console.log('\n=== scope-audit: every class it paints with is in a linked stylesheet ==='); + +const html = fs.readFileSync('public/index.html', 'utf8'); +const sheets = linkedStylesheets(html); +const css = sheets.map(p => (fs.existsSync(p) ? fs.readFileSync(p, 'utf8') : '')).join('\n'); +const js = fs.readFileSync('public/scope-audit.js', 'utf8'); + +// classAttrClasses reads only class="..." literals, so the ancestor set holds +// classes that are really rendered. An earlier version accepted every +// single-quoted lowercase token in the file, which let 'active', 'true', +// 'number' and 'function' in and made the ancestor gate accept anything. +function classAttrClasses(src) { + const out = new Set(); + const re = /class="([^"]*)"/g; + let m; + while ((m = re.exec(src))) { + for (const raw of m[1].split(/[\s'"+]+/)) { + const t = raw.trim(); + if (/^[a-z][a-z0-9-]*$/.test(t)) out.add(t); + } + } + return out; +} + +// Checked classes are this page's own namespaces. The filter is defensive: +// nothing in scope-audit.js builds a class name by concatenation today, but a +// token like 'ns-decl-' would report as an unstyled class and say nothing. +const used = classesUsedBy(js, ['ns-', 'sa-']).filter(c => /^[a-z][a-z0-9-]*[a-z0-9]$/.test(c)); + +// The ancestor set: classes this page renders in markup, plus the page's own +// namespace tokens it assembles in variables, plus the app shell around it. +// This answers "does the page render this class at all", not "is it an ancestor +// of that element" — a rule like `.active .ns-decl` would pass. That is the +// limit of a static check; the case it exists for is an ancestor the page never +// renders under any arrangement. +const emitted = new Set([...classAttrClasses(js), ...classAttrClasses(html), ...used]); + +test('index.html links the stylesheets it names', () => { + const missing = sheets.filter(p => !fs.existsSync(p)); + assert.deepStrictEqual(missing, [], 'linked but absent from the tree: ' + missing.join(', ')); +}); + +test('scope-audit.js uses a non-trivial number of its own classes', () => { + assert.ok(used.length >= 10, 'expected the page to use its own vocabulary, found: ' + used.join(', ')); +}); + +test('every ns-* / sa-* class it writes has a rule that can apply', () => { + const unstyled = used.filter(c => !ruleApplies(css, c, emitted)); + assert.deepStrictEqual(unstyled, [], 'classes with no applicable rule in any linked stylesheet: ' + unstyled.join(', ')); +}); + +// The regression that started the follow-up commit: a stylesheet hiding a +// column of the table this page renders. The page's own CSS gives the table a +// min-width and a horizontally scrolling wrapper, so hiding columns is never +// the intended answer here, and the column that gets hidden is whichever one +// the fork's panel happened to put third. +test('no linked stylesheet hides a column of the audit table', () => { + const clean = css.replace(/\/\*[\s\S]*?\*\//g, '').replace(/@(?:media|supports|layer|container)[^{]*\{/g, ''); + const offenders = []; + for (const chunk of clean.split('}')) { + const brace = chunk.indexOf('{'); + if (brace === -1) continue; + const head = chunk.slice(0, brace); + const body = chunk.slice(brace + 1); + if (!/\.(ns|sa)-table/.test(head)) continue; + if (!/nth-child|nth-of-type/.test(head)) continue; + if (!/display\s*:\s*none/.test(body)) continue; + offenders.push(head.trim().replace(/\s+/g, ' ')); + } + assert.deepStrictEqual(offenders, [], 'column-hiding rules on the audit table: ' + offenders.join(' | ')); +}); + +// The checker itself, so a broken checker cannot pass the tree. +test('the checker rejects a rule gated on an ancestor the page never renders', () => { + const emit = new Set(['sa-page', 'ns-truncated']); + assert.strictEqual(ruleApplies('.ns-truncated { color: red }', 'ns-truncated', emit), true); + assert.strictEqual(ruleApplies('.sa-page .ns-truncated { color: red }', 'ns-truncated', emit), true); + assert.strictEqual(ruleApplies('.never-rendered .ns-truncated { color: red }', 'ns-truncated', emit), false); + assert.strictEqual(ruleApplies('/* .ns-truncated lives elsewhere */', 'ns-truncated', emit), false); + assert.strictEqual(ruleApplies('.ns-truncated-other { color: red }', 'ns-truncated', emit), false); +}); + +console.log(`\n${passed} passed, ${failed} failed`); +process.exit(failed === 0 ? 0 : 1);