Merge remote-tracking branch 'origin/main' into outside-reviews-end-with-a-verdict-line

This commit is contained in:
garrytan committed 2026-11-07 20:18:05 +00:00
commit f8914e9c2e
34 files changed
+2542 -13

No files matched your search

+30 -5
View File
@@ -2,23 +2,32 @@
// gstack-design-lint — the free, deterministic design lint behind /design-html
// and /design-review. Static tier over .html .css .jsx .tsx .vue .svelte .astro
// (Tailwind class strings included); --render measures the page in gstack's own
// browser (Aside first, the browse daemon otherwise).
// browser (Aside first, the browse daemon otherwise). --scope app is the
// render-only app profile: five spacing rules inside one --within section.
//
// Usage: gstack-design-lint <file-or-dir ...> [--render] [--url <http://…>]
// [--scope page|component] [--job <job>] [--json] [--rules]
// gstack-design-lint --scope app (--url <http://…> ... | --state-file <states.json>) --within <selector>
// [--viewport WxH ...] [--wait-for <selector>] [--waivers <file>] [--json] [--verbose]
//
// Prints one "LINT: <blocks|ask|polish|waived> <rule-id> <file>:<line> <evidence> | fix: <fix>"
// line per finding, then "LINT_SUMMARY: blocks=N ask=N waived=N polish=N render=<ran|skipped: reason>".
// Exit 2 on blocks, 1 on unanswered asks, 0 otherwise; 3 on a usage error.
// docs/design-lint.md is the contract.
// Exit 2 on blocks, 1 on unanswered asks, 0 otherwise; 3 on a usage error; 4 when
// --url was given and no browser measured it. Under --scope app: 0 clean, 1 open,
// 4 incomplete, 3 usage, and a LINT_APP verdict line. docs/design-lint.md is the contract.
import { formatResult, lint, type LintOptions } from '../lib/design-lint/index';
import { appMain, renderUnavailable, type AppCliArgs } from '../lib/design-lint/app-cli';
import { ruleTable } from '../lib/design-lint/report';
const USAGE = 'usage: gstack-design-lint <file-or-dir ...> [--render] [--url <http://…>] [--scope page|component] [--job <job>] [--json] [--rules]';
const USAGE = 'usage: gstack-design-lint <file-or-dir ...> [--render] [--url <http://…>] [--scope page|component] [--job <job>] [--json] [--rules]\n'
+ ' gstack-design-lint --scope app (--url <http://…> ... | --state-file <states.json>) --within <selector> [--viewport WxH ...] [--wait-for <selector>] [--waivers <file>] [--json] [--verbose]';
const args = process.argv.slice(2);
const opts: LintOptions & { json: boolean; rules: boolean } = { render: false, urls: [], scope: 'page', job: null, json: false, rules: false };
const app: AppCliArgs = { paths: [], urls: [], viewports: [], verbose: false, json: false };
let appScope = false;
const appFlags: string[] = [];
const paths: string[] = [];
const take = (flag: string): string => {
const v = args[++i];
@@ -30,10 +39,16 @@ for (; i < args.length; i++) {
const a = args[i];
if (a === '--render') opts.render = true;
else if (a === '--url') { opts.urls!.push(take(a)); opts.render = true; }
else if (a === '--scope') { const s = take(a); if (s !== 'page' && s !== 'component') { console.error('--scope must be page or component'); process.exit(3); } opts.scope = s; }
else if (a === '--scope') { const s = take(a); if (s === 'app') appScope = true; else if (s !== 'page' && s !== 'component') { console.error('--scope must be page, component or app'); process.exit(3); } else opts.scope = s; }
else if (a === '--job') opts.job = take(a).trim().toLowerCase();
else if (a === '--json') opts.json = true;
else if (a === '--rules') opts.rules = true;
else if (a === '--verbose') app.verbose = true;
else if (a === '--within') { appFlags.push(a); app.within = take(a); }
else if (a === '--viewport') { appFlags.push(a); app.viewports.push(take(a)); }
else if (a === '--wait-for') { appFlags.push(a); app.waitFor = take(a); }
else if (a === '--waivers') { appFlags.push(a); app.waivers = take(a); }
else if (a === '--state-file') { appFlags.push(a); app.stateFile = take(a); }
else if (a === '--help' || a === '-h') { console.log(USAGE); process.exit(0); }
else if (a.startsWith('--')) { console.error(`unknown flag ${a}`); console.error(USAGE); process.exit(3); }
else paths.push(a);
@@ -42,6 +57,14 @@ if (opts.rules) {
for (const r of ruleTable()) console.log(`${r.tier.padEnd(6)} ${r.id.padEnd(26)} ${r.gate.padEnd(7)} ${r.title}`);
process.exit(0);
}
if (appScope) {
const code = await appMain({ ...app, paths, urls: opts.urls!, json: opts.json }, { out: (s) => console.log(s), err: (s) => console.error(s) });
process.exit(code);
}
if (appFlags.length) {
console.error(`gstack-design-lint: APP_ONLY: ${appFlags[0]} only with --scope app | fix: add --scope app, or drop ${appFlags[0]}`);
process.exit(3);
}
if (!paths.length && !opts.urls!.length) { console.error(USAGE); process.exit(3); }
if (opts.urls!.some((u) => !/^https?:\/\//.test(u))) { console.error('--url takes an http or https address'); process.exit(3); }
@@ -52,4 +75,6 @@ if (!result.files.length && !opts.urls!.length) {
}
if (opts.json) console.log(JSON.stringify({ summary: result.summary, files: result.files.length, findings: result.findings }, null, 2));
else console.log(formatResult(result));
const missing = renderUnavailable(opts.urls!, result.summary.render);
if (missing) { console.error(missing); process.exit(4); }
process.exit(result.exitCode);
+5
View File
@@ -0,0 +1,5 @@
---
bump: minor
---
- **The design lint can now check one section of a running app screen for crowded rows, help text jammed under a list, notes as loud as names, and small tap targets.** It is render-only and opt-in: `--url <url>` (one state each) or `--state-file <states.json>`, a required `--within <selector>` matched through open shadow roots, `--viewport WxH` (1440x900 and 375x812 by default), `--wait-for`, `--waivers <file>` and `--verbose`. Five new rules at ask report only under `--scope app`: `row-crowding`, `copy-flush-under-list`, `note-in-row-weight`, `small-hit-area` and `section-overflow`, each finding with its state, viewport, a shadow-piercing locator, the measured px and the threshold. On gstack's replica of the qm#3186 Sharing panel it reports the four defect rules; on the fixed copy, the negative controls and a 12-page false-positive corpus it reports none. A `LINT_APP: verdict=<clean|open|incomplete> code=…` line follows `LINT_SUMMARY`, and the exit is 0 clean, 1 open, 4 incomplete (no browser, unreachable, timeout, not ready, state mismatch, no match, closed shadow root, probe error, missing stylesheet), 3 usage. URLs are redacted in every output and probe failures write a private trace file. See docs/design-lint.md "App profile (--scope app)".
- **Behavior change for every caller: `gstack-design-lint --url` with no browser now exits 4.** It prints `gstack-design-lint: RENDER_UNAVAILABLE: nothing was measured at <url> (<reason>) | fix: gstack-browser-ensure, then rerun` on stderr instead of passing with exit 0. `--render` over files without a browser is unchanged. `--within`, `--viewport`, `--wait-for`, `--waivers` and `--state-file` without `--scope app` exit 3, and the usage text gains the app line. Page and component output and exit codes are otherwise byte-identical (test/design-lint-scope-guard.test.ts).
+1 -1
View File
@@ -75,7 +75,7 @@ gstack/
├── freeze/ # /freeze skill; bin/check-freeze.sh (PreToolUse edit-boundary hook; sources careful/bin/hook-extract.sh, fails closed)
├── guard/, unfreeze/ # /guard (careful + freeze in one), /unfreeze
├── gstack-upgrade/ # /gstack-upgrade skill + migrations/ (run after ./setup during an upgrade)
├── bin/ # CLI utilities (gstack-render.ts = render a local HTML file through Aside or the engine, gstack-design-detect.ts = probe/scan through a user-installed impeccable engine; gstack-design-md.ts = open DESIGN.md check/convert/tokens/mark; gstack-design-lint = the free deterministic design lint over HTML/CSS/components, --render measures in the browser (docs/design-lint.md); gstack-repo-mode, gstack-slug, gstack-config, gstack-wtree, gstack-evidence, gstack-issue-guard, gstack-relink, gstack-memorable, etc.)
├── bin/ # CLI utilities (gstack-render.ts = render a local HTML file through Aside or the engine, gstack-design-detect.ts = probe/scan through a user-installed impeccable engine; gstack-design-md.ts = open DESIGN.md check/convert/tokens/mark; gstack-design-lint = the free deterministic design lint over HTML/CSS/components, --render measures in the browser, --scope app measures one app section's spacing (docs/design-lint.md); gstack-repo-mode, gstack-slug, gstack-config, gstack-wtree, gstack-evidence, gstack-issue-guard, gstack-relink, gstack-memorable, etc.)
├── document-release/ # /document-release skill (every-ship pre-verification audit; standalone doc updates + Diataxis coverage map)
├── document-generate/ # /document-generate skill (Diataxis doc generator: tutorial/how-to/reference/explanation)
├── cso/ # /cso skill (OWASP Top 10 + STRIDE security audit)
+158 -3
View File
@@ -21,8 +21,12 @@ every finalized page and /design-review on each audited page.
```
gstack-design-lint <file-or-dir ...> [--render] [--url <http://…>] [--scope page|component] [--job <job>] [--json] [--rules]
gstack-design-lint --scope app (--url <http://…> ... | --state-file <states.json>) --within <selector> [--viewport WxH ...] [--wait-for <selector>] [--waivers <file>] [--json] [--verbose]
```
The second form is the render-only app profile; see
[App profile](#app-profile---scope-app).
- Reads `.html`, `.css`, `.jsx`, `.tsx`, `.vue`, `.svelte` and `.astro`.
Tailwind class strings in components are read too, including `cn()`,
`clsx()`, `cva()` and their kin. `node_modules`, dot folders and build
@@ -63,7 +67,11 @@ gstack-design-lint <file-or-dir ...> [--render] [--url <http://…>] [--scope pa
`globals.css` is not reported missing on every component.
- `--render` measures every HTML page found; `--url` measures a running dev
server (and implies `--render`). When no browser is available the render
tier is skipped and the summary says why.
tier is skipped and the summary says why. With `--url`, under any scope,
a skipped render also prints `gstack-design-lint: RENDER_UNAVAILABLE:
nothing was measured at <url> (<reason>) | fix: gstack-browser-ensure, then
rerun` on stderr and exits **4**: a URL nobody measured is never a pass.
`--render` over files without a browser keeps the skip and its exit code.
- `--scope component` skips the page-only rules (the nav, the footer, the
eyebrow, section gaps, the wireframe count) when linting a component alone.
- `--job <job>` names what the visitor came to do (`book`, `buy`,
@@ -87,8 +95,10 @@ separator (it is printed as ` / fix: `), so splitting a line on its first
`--json`.
Exit **2** when anything blocks, **1** when an ask is unanswered, **0**
otherwise (**3** on a usage error). Findings sort blocks, ask, waived, polish,
then by rule, file and line.
otherwise (**3** on a usage error, **4** when `--url` was given and no browser
measured it). Findings sort blocks, ask, waived, polish, then by rule, file
and line. `--scope app` has its own verdict and exit codes: see
[App profile](#app-profile---scope-app).
## Tiers
@@ -300,6 +310,151 @@ than three waivers, polish), `render` (a page that did not render, blocks:
an unrendered page is never a pass) and `internal-error` (a page or stylesheet the lint could not read, or a rule family that
threw on a page, ask: the page is not a pass until the lint has read it).
## App profile (--scope app)
```
gstack-design-lint --scope app (--url <url> ... | --state-file <states.json>) --within <selector>
[--viewport WxH ...] [--wait-for <selector>] [--waivers <file>] [--json] [--verbose]
```
The page and component profiles grade a whole page against hallmark's gates.
The app profile measures one section of an app screen while it is being
built, for the spacing defects qm#3186 shipped (two-line rows crowding the next
row, help copy flush under a list, a status note at full weight inside a row)
and small hit areas. It is render-only (no static tier, and paths are a usage
error), reports only its five rules, and the page and component profiles never
report them. Its scheduler (`lib/design-lint/render-app.ts`) and probes
(`lib/design-lint/render-probe-app.ts`, installed on
`window.__gstackLintApp`) are separate from the page tier's, which is
unchanged.
- **States.** Each `--url` is one state, named by its path and query
(redacted); a later URL whose name is taken gets `~2`, `~3` after it, and
the same URL given twice exits 3 (`DUPLICATE_STATE`).
`--state-file` names them instead:
`{"version":1,"states":[{"name":"owner","url":"http://127.0.0.1:5300/p?as=owner","expect":"<selector>","within":"<selector>"}]}`.
`name`, `expect` and `within` are optional; an unknown key, a missing or
non-http `url`, a bad selector or a repeated name exits 3. A URL off
loopback is allowed and noted (`LINT_APP_NOTE:`). `expect` proves the state
rendered: it must match in the composed tree after readiness, or the state
is incomplete with `STATE_MISMATCH`.
- **`--within`** (required) is matched through the light DOM and every open
shadow root, slotted content included; a state's own `within` overrides it.
A malformed selector exits 3 before any page opens. A selector that matches
nothing is `WITHIN_NO_MATCH`, naming the trees searched.
- **Readiness.** The page is opened and the engine waits up to 15 s for the
`--wait-for` selector, else the `--within` selector, through open shadow
roots. Then the probe polls up to 5 s until the section holds a list of two
or more rows; an explicit `--wait-for` skips that list wait (use it for a
section with no list). A section that never gets its list is `NOT_READY`; an
explicit `--wait-for` that never matches is `NOT_READY` too.
- **Viewports.** 1440x900 and 375x812 by default; `--viewport WxH` (repeatable)
replaces them. Width 320 to 3840, height 480 to 2400, `^\d{3,4}x\d{3,4}$`;
anything else exits 3. One page load per state, one measurement per
viewport.
- `--within`, `--viewport`, `--wait-for`, `--waivers` and `--state-file`
without `--scope app` exit 3 (`APP_ONLY: <flag> only with --scope app`).
`--verbose` is accepted under every scope; under `--scope app` it prints a
failed probe's stack.
### What it measures
The thresholds are fixed in this release; DESIGN.md does not override them.
Every rule is at `ask`, but the tier does not decide the verdict: any app
finding a waiver did not answer makes the section open.
| Rule | Tier | The tell (measured px, threshold) | The fix |
|---|---|---|---|
| `row-crowding` | ask | in a list of rows, a row whose text spans two or more lines sits under 8px from a drawn row edge (its own border or surface, the neighbouring row's separator, or the list's edge), or under 16px from the next row's text | 8px or more inside the row, 16px or more between rows' text |
| `copy-flush-under-list` | ask | the first text block after a list (later siblings of the list container inside the section, before the next heading) starts under 12px below it | 12px or more under the list |
| `note-in-row-weight` | ask | in a list of rows, a note with no icon and no surface of its own, OKLCH lightness within 0.15 of the name's and size 0.9 or more of it; or, at 1200px and wider, a note of three lines or more | mute the note, or give it an icon or a callout |
| `small-hit-area` | ask | at 480px and narrower, a control inside a list row under 32px in either dimension (an input's label counts toward its box; a link inside a run of text is exempt) | a 32px target that keeps the glyph its size |
| `section-overflow` | ask | an element inside the section whose right edge passes the viewport (outside any scroller), or a non-scrolling element whose content is wider than its box | wrap or shrink it, or give it a scroller |
How it reads the page, with no test hooks (no `data-testid`, no attribute at
all): a **list** is two or more adjacent siblings of one tag and class list,
widths within 10%, stacked (each top at or below the previous bottom less
1px), each holding visible text; tables, anything inside `nav` or a
navigation, grid, table, menu, listbox, tablist or tree role, headings,
paragraphs, sections and forms are never rows, and siblings side by side are a
grid, not a list. A list is a **list of rows** when it or a row draws a
border, background or shadow, or when every row is a flex or grid box that sets
two or more boxes side by side (an avatar, the text, a control);
`row-crowding` and `note-in-row-weight` read lists of rows only (an unboxed
bullet list is prose). With no drawn edge, `row-crowding` measures only the
gap to the next row's text. Measurements within 0.05px of a threshold count as
meeting it, so layout's subpixel rounding never fires a rule. A row's **text runs** are its
visible text nodes in rendered order through the composed tree, leaving out
text inside controls; positions are line boxes, so a gap is the padding and
margins the CSS set. The **name** is the run on the row's first line (the
leftmost when two share it). A **note** is a text block whose first line starts
below the name's line; a role label beside the name is never a note.
**Controls** are `button`, `a[href]`, `input`, `select` and the button,
menuitem, switch and tab roles.
### Verdict, output and exit
Each finding is a `LINT:` line whose evidence reads
`[<state> <W>px] <measured sentence> at <locator>`; the locator pierces
shadow roots (`qm-sharing >>> section.panel > div.rows:nth-of-type(1) > div.row:nth-of-type(2) "Registry token Pushes…"`).
Then one `LINT_APP_STATE:` line per state and viewport, `LINT_SUMMARY`, and
the verdict:
```
LINT_APP: verdict=<clean|open|incomplete> code=<OK|OPEN|CODE> states=<measured>/<planned> viewports=<list> within=<sel> matched=<n> open=<n> waived=<n>
```
`states` counts states measured at every viewport; `matched` is the fewest
elements `--within` matched at any state and viewport. Exit **0** clean (every
planned state and viewport measured, nothing open), **1** open (complete
coverage, open findings), **4** incomplete (any state or viewport not
measured: incomplete wins over open, and partial coverage never prints clean),
**3** usage only. `--json` prints `{ summary, files, findings, app }`, where
`app` is `{ verdict, code, codes, within, matched, viewports, states: [{ name,
url, viewport, status, code, matched, detail?, trace? }], open, waived, notes }`
and each app finding carries `app: { state, viewport, locator, measured,
threshold }`, never truncated. Findings that differ only in their row or
viewport stay separate.
When several codes apply, `code` is the first in this order and `codes` lists
them all:
| Code | Means | Fix |
|---|---|---|
| <a id="app-code-no_browser"></a>`NO_BROWSER` | no browser could open the page (also `RENDER_UNAVAILABLE` on stderr) | gstack-browser-ensure, then rerun |
| <a id="app-code-unreachable"></a>`UNREACHABLE` | the URL did not answer 2xx, or the page did not render | start the server or fix the URL, then rerun |
| <a id="app-code-timeout"></a>`TIMEOUT` | the render ran past its 120 s budget | make the page load faster or measure fewer viewports, then rerun |
| <a id="app-code-not_ready"></a>`NOT_READY` | the section never held a list, or `--wait-for` never matched | pass `--wait-for` with a selector that appears once the data is in, or make the data load |
| <a id="app-code-state_mismatch"></a>`STATE_MISMATCH` | the state's `expect` selector matched nothing | make the URL load the state it names, or fix `expect` |
| <a id="app-code-within_no_match"></a>`WITHIN_NO_MATCH` | `--within` matched nothing in the document or any open shadow root | pass the custom element's tag or a selector inside the light DOM |
| <a id="app-code-closed_shadow"></a>`CLOSED_SHADOW` | a custom element in the section (or, when nothing matched, on the page) renders a closed shadow root: no shadow root, no light DOM, 40px or taller | attach the shadow root with mode "open" in development, or pass a `--within` outside it |
| <a id="app-code-probe_error"></a>`PROBE_ERROR` | a probe threw or answered in a shape the lint does not know | read the trace file; a page script that redefines DOM APIs breaks the probes |
| <a id="app-code-asset_missing"></a>`ASSET_MISSING` | a linked stylesheet did not load (a font that did not load is only a `LINT_APP_NOTE`) | fix the stylesheet path or wait for the server, then rerun |
Each `LINT_APP_STATE` line for an incomplete state names its code, the
detail, the fix and its anchor here. Nothing is retried: fix the named cause
and rerun. A probe that throws writes `{probe, state, viewport, stack, url}`
to a private file (mode 0600, in a 0700 `gstack-design-lint-traces`
directory under the system temp directory) and names it on stdout and
stderr; `--verbose` also prints the stack. When the trace cannot be
written, the state is still `PROBE_ERROR` and stderr says why.
### Waivers and redaction
URL targets have no source comment to carry a waiver, so `--waivers <file>`
does: `{"version":1,"waivers":[{"rule":"small-hit-area","locator":"<part of
the finding's locator>","reason":"<why, about this page>"}]}`. The rule must
be an app rule and the reason 8 characters or more; an unknown rule or key
exits 3. A finding whose rule matches and whose locator contains the waiver's
`locator` prints at `waived` with its reason; a waiver that matched nothing
prints as a `waiver` finding (unused), which does not change the verdict.
Every URL a person sees (text, JSON, stderr, traces, state names) has its
userinfo dropped and the value of any query or `#fragment` parameter whose
name contains token, key, secret, auth, session, sig, signature, password,
pass, code, jwt or bearer replaced with `REDACTED`; the page is opened at the
real URL.
## Token names the rules read
Several rules key on hallmark's token names, which are also the names
+80
View File
@@ -0,0 +1,80 @@
/**
* The --scope app half of bin/gstack-design-lint: validates every input before
* a browser starts (exit 3 with a code and a fix line), runs the app
* scheduler and prints its contract. Kept apart from the bin so the contract
* tests can inject a runner instead of a browser.
*/
import * as fs from 'node:fs';
import { AppUsageError, checkSelector, parseStateFile, parseViewport, parseWaiverFile, redactUrl, stateNameOf, type Viewport } from './app-input';
import { formatAppLines, lintApp, type AppOptions, type AppState } from './render-app';
import { formatFinding, formatSummary } from './report';
import { ruleMeta } from './rules/ids';
export interface AppCliArgs {
paths: string[];
urls: string[];
within?: string;
viewports: string[];
waitFor?: string;
waivers?: string;
stateFile?: string;
verbose: boolean;
json: boolean;
}
export interface Io { out: (s: string) => void; err: (s: string) => void }
export type AppDeps = Pick<AppOptions, 'engine' | 'run' | 'reach' | 'traceDir' | 'readyMs' | 'sectionWaitMs'>;
export const usageLine = (e: AppUsageError): string => `gstack-design-lint: ${e.code}: ${e.message} | fix: ${e.fix}`;
/** The stderr line for a run that was asked to measure URLs and measured nothing because no browser was available; null otherwise. */
export function renderUnavailable(urls: string[], status: string): string | null {
if (!urls.length || !/^skipped: no browser available/.test(status)) return null;
return `gstack-design-lint: RENDER_UNAVAILABLE: nothing was measured at ${urls.map(redactUrl).join(', ')} (${status.slice('skipped: '.length)}) | fix: gstack-browser-ensure, then rerun`;
}
const read = (flag: string, file: string, code: string): string => {
try { return fs.readFileSync(file, 'utf8'); } catch (e) { throw new AppUsageError(code, `${flag} ${file} could not be read: ${(e as Error).message}`, `pass a readable JSON file to ${flag}`); }
};
/** Every input settled before work: states, within, viewports, wait-for and waivers. */
export function planApp(a: AppCliArgs): { states: AppState[]; notes: string[]; within: string; viewports: Viewport[]; waivers: AppOptions['waivers'] } {
if (a.paths.length) throw new AppUsageError('APP_PATHS', `--scope app measures URLs, not files (got ${a.paths[0]})`, 'pass --url <http://…> or --state-file <states.json>, or lint files without --scope app');
if (!a.within) throw new AppUsageError('NO_WITHIN', '--scope app needs --within <selector>', 'pass --within with the selector of the section you are building');
checkSelector('--within', a.within);
if (a.waitFor !== undefined) checkSelector('--wait-for', a.waitFor);
if (a.urls.length && a.stateFile) throw new AppUsageError('BOTH_TARGETS', '--url and --state-file are two ways to name the states; pass one', 'put every URL in the state file, or drop it');
if (!a.urls.length && !a.stateFile) throw new AppUsageError('NO_TARGET', '--scope app needs --url <url> or --state-file <states.json>', 'pass the URL of each state to measure');
const bad = a.urls.find((u) => !/^https?:\/\/[^/]/.test(u));
if (bad) throw new AppUsageError('BAD_URL', `--url ${redactUrl(bad)} is not an http or https address`, 'pass the dev server URL, like http://127.0.0.1:5300/p');
const viewports = a.viewports.map(parseViewport);
const twice = a.urls.find((u, i) => a.urls.indexOf(u) !== i);
if (twice) throw new AppUsageError('DUPLICATE_STATE', `--url ${redactUrl(twice)} is given twice`, 'measure each URL once');
const used = new Set<string>();
const parsed = a.stateFile ? parseStateFile(read('--state-file', a.stateFile, 'BAD_STATE_FILE')) : {
states: a.urls.map((url) => { const base = stateNameOf(url); let name = base; for (let n = 2; used.has(name); n++) name = `${base}~${n}`; used.add(name); return { name, url }; }),
notes: [],
};
const waivers = a.waivers ? parseWaiverFile(read('--waivers', a.waivers, 'BAD_WAIVERS'), (id) => !!ruleMeta(id)?.app) : [];
return { states: parsed.states, notes: parsed.notes, within: a.within, viewports, waivers };
}
export async function appMain(a: AppCliArgs, io: Io, deps: AppDeps = {}): Promise<number> {
let plan: ReturnType<typeof planApp>;
try { plan = planApp(a); } catch (e) {
if (!(e instanceof AppUsageError)) throw e;
io.err(usageLine(e));
return 3;
}
const r = await lintApp({ states: plan.states, within: plan.within, viewports: plan.viewports, waitFor: a.waitFor, waivers: plan.waivers, ...deps });
r.app.notes.unshift(...plan.notes);
for (const line of r.stderr) io.err(line);
if (a.verbose) for (const c of r.app.states) if (c.trace) { try { io.err(`gstack-design-lint: trace ${c.trace}:\n${fs.readFileSync(c.trace, 'utf8')}`); } catch (e) { io.err(`gstack-design-lint: trace ${c.trace} could not be read: ${(e as Error).message}`); } }
const missing = renderUnavailable(plan.states.map((s) => s.url), r.summary.render);
if (missing) io.err(missing);
if (a.json) { io.out(JSON.stringify({ summary: r.summary, files: 0, findings: r.findings, app: r.app }, null, 2)); return r.exitCode; }
const lines = formatAppLines(r.app);
io.out([...r.findings.map(formatFinding), ...r.app.notes.map((n) => `LINT_APP_NOTE: ${n}`), ...lines.states, formatSummary(r.summary), lines.verdict].join('\n'));
return r.exitCode;
}
+193
View File
@@ -0,0 +1,193 @@
/**
* What `gstack-design-lint --scope app` reads before any page opens: the
* viewports, the --within selector, a --state-file and a --waivers file, plus
* the one redaction every URL goes through on its way to a person (text,
* JSON, stderr, traces). Every input error is an AppUsageError: exit 3, a
* code, and a fix line, raised before a browser starts.
*/
export class AppUsageError extends Error {
constructor(readonly code: string, message: string, readonly fix: string) {
super(message);
}
}
export interface Viewport { width: number; height: number }
export const DEFAULT_VIEWPORTS: Viewport[] = [{ width: 1440, height: 900 }, { width: 375, height: 812 }];
export const viewportLabel = (v: Viewport): string => `${v.width}x${v.height}`;
export function parseViewport(spec: string): Viewport {
const m = /^(\d{3,4})x(\d{3,4})$/.exec(spec);
const width = m ? Number(m[1]) : NaN;
const height = m ? Number(m[2]) : NaN;
if (!m || width < 320 || width > 3840 || height < 480 || height > 2400) {
throw new AppUsageError('BAD_VIEWPORT', `--viewport ${JSON.stringify(spec)} is not <width>x<height> with width 320 to 3840 and height 480 to 2400`, 'pass a size like 1440x900 or 375x812');
}
return { width, height };
}
const SECRET_PARAM = /token|key|secret|auth|session|sig|signature|password|pass|code|jwt|bearer/i;
/** Parameters with every secret-looking value replaced with REDACTED, or null when none looks secret. */
function redactParams(text: string): string | null {
const params = new URLSearchParams(text);
if (![...params.keys()].some((k) => SECRET_PARAM.test(k))) return null;
return new URLSearchParams([...params.entries()].map(([k, v]) => [k, SECRET_PARAM.test(k) ? 'REDACTED' : v])).toString();
}
/** A URL fit to print: userinfo dropped, secret-looking query and fragment parameter values replaced with REDACTED. Navigation always uses the real URL. */
export function redactUrl(raw: string): string {
let u: URL;
try { u = new URL(raw); } catch { return raw.replace(/\/\/[^/@\s]*@/, '//'); }
u.username = '';
u.password = '';
const search = redactParams(u.search.slice(1));
if (search !== null) u.search = search;
const hash = u.hash.includes('=') ? redactParams(u.hash.slice(1)) : null;
if (hash !== null) u.hash = hash;
return u.toString();
}
/** Every http(s) URL inside free text (an error message, a stack) redacted. */
export const redactText = (s: string): string => s.replace(/https?:\/\/[^\s"'<>)]+/g, (u) => redactUrl(u));
/** A state's name when only its URL is given: its path and query, redacted. */
export function stateNameOf(url: string): string {
try {
const u = new URL(redactUrl(url));
return u.pathname + u.search;
} catch {
return redactUrl(url);
}
}
const QUOTED = /"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'/g;
/**
* Why a selector is malformed, or null, without a browser: balanced brackets,
* parentheses and quotes, a name after every . # and :, and a compound on
* both sides of every combinator and comma. Conservative: it can accept a
* selector the browser refuses (the probe then reports PROBE_ERROR), never
* the other way round.
*/
export function selectorError(sel: string): string | null {
const s = sel.trim();
if (!s) return 'the selector is empty';
if (s.length > 1000) return 'the selector is over 1000 characters';
if (/[{};]/.test(s.replace(QUOTED, ''))) return 'braces and semicolons belong to CSS rules, not a selector';
const stack: string[] = [];
let compound = false;
for (let i = 0; i < s.length; i++) {
const c = s[i];
if (c === '\\') { i++; compound = true; continue; }
if (c === '"' || c === "'") {
if (!stack.length) return `a quote outside [] or () at ${i + 1}`;
let end = i + 1;
while (end < s.length && s[end] !== c) end += s[end] === '\\' ? 2 : 1;
if (end >= s.length) return 'an unclosed quote';
i = end;
continue;
}
if (c === '[' || c === '(') { stack.push(c); compound = true; continue; }
if (c === ']' || c === ')') {
if (stack.pop() !== (c === ']' ? '[' : '(')) return `an unbalanced ${c} at ${i + 1}`;
continue;
}
if (stack.length || /\s/.test(c)) continue;
if (c === ',' || c === '>' || c === '+' || c === '~') {
if (!compound) return `nothing before ${c} at ${i + 1}`;
compound = false;
continue;
}
if (!/[\w\-.#:*|\u00a0-\uffff]/.test(c)) return `${c} cannot appear in a selector (at ${i + 1})`;
if ((c === '.' || c === '#') && !/[\w\-\\\u00a0-\uffff]/.test(s[i + 1] ?? '')) return `a ${c} with no name after it (at ${i + 1})`;
if (c === ':' && !/[\w\-:]/.test(s[i + 1] ?? '')) return `a : with no pseudo-class after it (at ${i + 1})`;
compound = true;
}
if (stack.length) return `an unclosed ${stack[stack.length - 1]}`;
if (!compound) return 'the selector ends in a combinator or a comma';
return null;
}
export function checkSelector(flag: string, sel: string): void {
const why = selectorError(sel);
if (why) throw new AppUsageError('BAD_SELECTOR', `${flag} ${JSON.stringify(sel)} is not a valid selector: ${why}`, "pass a CSS selector the page's querySelectorAll accepts, like main .access or my-panel");
}
export interface AppStateInput {
name: string;
url: string;
expect?: string;
within?: string;
}
const isLoopback = (url: string): boolean => {
try { return /^(127\.\d+\.\d+\.\d+|localhost|\[::1\])$/.test(new URL(url).hostname); } catch { return false; }
};
const STATE_KEYS = new Set(['name', 'url', 'expect', 'within']);
type Bad = (why: string) => AppUsageError;
/** The versioned top level shared by --state-file and --waivers: an object with exactly version 1 and one list. */
function versioned(text: string, list: string, bad: Bad): unknown[] {
let doc: unknown;
try { doc = JSON.parse(text); } catch (e) { throw bad(`not JSON (${(e as Error).message})`); }
if (!doc || typeof doc !== 'object' || Array.isArray(doc)) throw bad('the top level is not an object');
const top = doc as Record<string, unknown>;
const extra = Object.keys(top).find((k) => k !== 'version' && k !== list);
if (extra) throw bad(`unknown key ${extra}`);
if (top.version !== 1) throw bad('version must be 1');
if (!Array.isArray(top[list])) throw bad(`${list} must be a list`);
return top[list] as unknown[];
}
function entry(raw: unknown, where: string, keys: Set<string>, bad: Bad): Record<string, unknown> {
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) throw bad(`${where} is not an object`);
const unknown = Object.keys(raw).find((k) => !keys.has(k));
if (unknown) throw bad(`${where} has unknown key ${unknown}`);
return raw as Record<string, unknown>;
}
/** A --state-file: `{"version":1,"states":[{"name","url","expect"?,"within"?}]}`. Returns the states and notes (a non-loopback URL is allowed, and noted). */
export function parseStateFile(text: string): { states: AppStateInput[]; notes: string[] } {
const bad: Bad = (why) => new AppUsageError('BAD_STATE_FILE', `--state-file: ${why}`, 'write {"version":1,"states":[{"name":"owner","url":"http://127.0.0.1:5300/p?as=owner","expect":"<selector that proves the state>"}]}');
const list = versioned(text, 'states', bad);
if (!list.length) throw bad('states is empty');
const notes: string[] = [];
const names = new Set<string>();
const states = list.map((raw, i): AppStateInput => {
const st = entry(raw, `states[${i}]`, STATE_KEYS, bad);
if (typeof st.url !== 'string' || !st.url) throw bad(`states[${i}] has no url`);
if (!/^https?:\/\/[^/]/.test(st.url)) throw bad(`states[${i}].url is not an http or https address`);
for (const k of ['name', 'expect', 'within']) if (st[k] !== undefined && (typeof st[k] !== 'string' || !st[k])) throw bad(`states[${i}].${k} must be a non-empty string`);
const name = typeof st.name === 'string' ? st.name : stateNameOf(st.url);
if (names.has(name)) throw bad(`two states are named ${name}`);
names.add(name);
if (typeof st.expect === 'string') checkSelector(`states[${i}].expect`, st.expect);
if (typeof st.within === 'string') checkSelector(`states[${i}].within`, st.within);
if (!isLoopback(st.url)) notes.push(`state ${name} is not on a loopback address (${redactUrl(st.url)})`);
return { name, url: st.url, ...(typeof st.expect === 'string' ? { expect: st.expect } : {}), ...(typeof st.within === 'string' ? { within: st.within } : {}) };
});
return { states, notes };
}
export interface AppWaiver { rule: string; locator: string; reason: string }
export const APP_WAIVER_MIN_REASON = 8;
const WAIVER_KEYS = new Set(['rule', 'locator', 'reason']);
/** A --waivers file: `{"version":1,"waivers":[{"rule","locator","reason"}]}`; the rule must be an app rule, the locator a non-empty substring to match, the reason 8 characters or more. */
export function parseWaiverFile(text: string, isAppRule: (id: string) => boolean): AppWaiver[] {
const bad: Bad = (why) => new AppUsageError('BAD_WAIVERS', `--waivers: ${why}`, 'write {"version":1,"waivers":[{"rule":"small-hit-area","locator":"<part of the finding\'s locator>","reason":"<why, about this page>"}]}');
return versioned(text, 'waivers', bad).map((raw, i): AppWaiver => {
const w = entry(raw, `waivers[${i}]`, WAIVER_KEYS, bad);
if (typeof w.rule !== 'string' || !isAppRule(w.rule)) throw bad(`waivers[${i}].rule ${JSON.stringify(w.rule)} is not an app rule`);
if (typeof w.locator !== 'string' || !w.locator.trim()) throw bad(`waivers[${i}].locator must be a non-empty string`);
if (typeof w.reason !== 'string' || w.reason.trim().length < APP_WAIVER_MIN_REASON) throw bad(`waivers[${i}].reason must say why, about this page, in ${APP_WAIVER_MIN_REASON} characters or more`);
return { rule: w.rule, locator: w.locator, reason: w.reason.trim() };
});
}
+260
View File
@@ -0,0 +1,260 @@
/**
* The app profile's scheduler (gstack-design-lint --scope app): render-only,
* one page load per state, one measurement per viewport, confined to the
* --within section. Separate from render.ts on purpose: the page and
* component profiles' plan, probes and output do not change.
*
* The verdict is the machine signal, never the tier: clean when every
* planned state x viewport was measured and no app finding is open, open when
* coverage is complete and one is, incomplete (exit 4) when any state x
* viewport was not measured, whatever else it found. docs/design-lint.md
* "App profile (--scope app)" is the contract.
*/
import { randomBytes } from 'node:crypto';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { pickEngine, render, renderWithBrowse, type EngineChoice, type RenderStep } from '../aside-render';
import { DEFAULT_VIEWPORTS, redactText, redactUrl, viewportLabel, type AppWaiver, type Viewport } from './app-input';
import { appInstallScript, appProbeScript, composedWaitExpression, type AppProbeFinding, type AppProbeResult, type AppSetupReport } from './render-probe-app';
import type { Runner } from './render';
import { finalize, oneLine, summarize, type Finding, type Summary } from './report';
import { ruleMeta } from './rules/ids';
export const APP_CODES = ['NO_BROWSER', 'UNREACHABLE', 'TIMEOUT', 'NOT_READY', 'STATE_MISMATCH', 'WITHIN_NO_MATCH', 'CLOSED_SHADOW', 'PROBE_ERROR', 'ASSET_MISSING'] as const;
export type AppCode = typeof APP_CODES[number];
/** One line each: what to do about a state that was not measured. Anchors: docs/design-lint.md#app-code-<code in lower case>. */
export const APP_CODE_FIX: Record<AppCode, string> = {
NO_BROWSER: 'gstack-browser-ensure, then rerun',
UNREACHABLE: 'start the server or fix the URL, then rerun',
TIMEOUT: 'the page took longer than the render budget; make it load faster or measure fewer viewports, then rerun',
NOT_READY: 'pass --wait-for <selector> that appears once the section has its data, or make the data load',
STATE_MISMATCH: "make the URL load the state it names, or fix the state's expect selector",
WITHIN_NO_MATCH: "pass the custom element's tag or a selector inside the light DOM",
CLOSED_SHADOW: 'attach the shadow root with mode "open" in development, or pass a --within outside it',
PROBE_ERROR: 'read the trace file; a page script that redefines DOM APIs breaks the probes',
ASSET_MISSING: 'fix the stylesheet path or wait for the server to serve it, then rerun',
};
export const appCodeAnchor = (code: AppCode): string => `docs/design-lint.md#app-code-${code.toLowerCase()}`;
export interface AppState { name: string; url: string; expect?: string; within?: string }
export interface AppOptions {
states: AppState[];
within: string;
viewports?: Viewport[];
/** An explicit --wait-for: the readiness selector, and no list is required before measuring. */
waitFor?: string;
waivers?: AppWaiver[];
engine?: () => EngineChoice;
run?: Runner;
/** Replaces the reachability check (tests inject one); null means reachable. */
reach?: (url: string) => Promise<string | null>;
traceDir?: string;
/** How long the probe waits for a list after the section matched; 5000 by default. */
readyMs?: number;
/** How long the engine waits for the section (or --wait-for) to match; 15000 by default. */
sectionWaitMs?: number;
}
export interface AppCoverage { name: string; url: string; viewport: string; status: 'measured' | 'incomplete'; code: AppCode | 'OK'; matched: number; detail?: string; trace?: string }
export interface AppVerdict {
verdict: 'clean' | 'open' | 'incomplete';
code: string;
codes: AppCode[];
within: string;
matched: number;
viewports: string[];
states: AppCoverage[];
open: number;
waived: number;
notes: string[];
}
export interface AppResult { findings: Finding[]; summary: Summary; app: AppVerdict; exitCode: 0 | 1 | 4; stderr: string[] }
const SECTION_WAIT_MS = 15_000;
const READY_MS = 5_000;
const RENDER_TIMEOUT_MS = 120_000;
const MAX_LOCATOR = 1000;
export const defaultTraceDir = (): string => path.join(os.tmpdir(), 'gstack-design-lint-traces');
function writeTrace(dir: string, rec: { probe: string; state: string; viewport: string; stack: string; url: string }): string {
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
const file = path.join(dir, `trace-${Date.now()}-${randomBytes(4).toString('hex')}.json`);
fs.writeFileSync(file, JSON.stringify(rec, null, 2) + '\n', { mode: 0o600 });
fs.chmodSync(file, 0o600);
return file;
}
async function reachable(url: string): Promise<string | null> {
try {
const res = await fetch(url, { signal: AbortSignal.timeout(10_000), redirect: 'follow' });
return res.ok || res.status === 304 ? null : `HTTP ${res.status}`;
} catch (e) {
return (e as Error).message;
}
}
const codeOfRunError = (e: string): AppCode => (/^no browser available/i.test(e) ? 'NO_BROWSER' : /waitFor/.test(e) ? 'NOT_READY' : /timed? ?out|timeout/i.test(e) ? 'TIMEOUT' : 'UNREACHABLE');
const INCOMPLETE_FROM_PROBE = new Set<string>(['NOT_READY', 'STATE_MISMATCH', 'WITHIN_NO_MATCH', 'CLOSED_SHADOW']);
type Parsed = { value: unknown } | { error: string; stack?: string };
function parseEval(text: string | undefined): Parsed {
if (text === undefined) return { error: 'no result came back' };
try {
const v = JSON.parse(text) as unknown;
if (v && typeof v === 'object' && 'error' in v && typeof (v as { error: unknown }).error === 'string') {
const e = v as { error: string; stack?: unknown };
return { error: e.error, ...(typeof e.stack === 'string' ? { stack: e.stack } : {}) };
}
return v === null ? { error: 'empty result' } : { value: v };
} catch (e) {
return { error: (e as Error).message };
}
}
const isFinding = (f: unknown): f is AppProbeFinding => {
if (!f || typeof f !== 'object') return false;
const x = f as Record<string, unknown>;
return typeof x.rule === 'string' && !!ruleMeta(x.rule)?.app && typeof x.sentence === 'string' && x.sentence.length <= 400
&& typeof x.locator === 'string' && x.locator.length <= MAX_LOCATOR && typeof x.measured === 'number' && Number.isFinite(x.measured) && typeof x.threshold === 'number';
};
/** A probe answer of the shape glAppMeasure returns, or why not. */
function probeShape(v: unknown): { ok: AppProbeResult } | { bad: string } {
if (!v || typeof v !== 'object') return { bad: 'the answer is not an object' };
const r = v as AppProbeResult;
if (r.status !== 'measured' && r.status !== 'incomplete') return { bad: 'the answer has no status' };
if (typeof r.matched !== 'number' || !Array.isArray(r.findings)) return { bad: 'the answer has no match count or finding list' };
if (r.status === 'incomplete' && !INCOMPLETE_FROM_PROBE.has(String(r.code))) return { bad: `the answer names an unknown code (${String(r.code).slice(0, 40)})` };
if (!r.findings.every(isFinding)) return { bad: 'the page handed back a measurement the lint does not know; its scripts may be tampering with the probes' };
return { ok: r };
}
const field = (s: string): string => oneLine(redactText(s)).replace(/ \| fix: /g, ' / fix: ');
interface StateRun { coverage: AppCoverage[]; findings: Finding[]; notes: string[]; stderr: string[] }
/** One state: reachability, one render with the install, setup and one measurement per viewport, then each answer settled. */
async function lintState(state: AppState, opts: AppOptions, run: Runner, viewports: Viewport[]): Promise<StateRun> {
const out: StateRun = { coverage: [], findings: [], notes: [], stderr: [] };
const shown = redactUrl(state.url);
const within = state.within ?? opts.within;
const all = (code: AppCode, detail: string, trace?: string) => {
for (const v of viewports) out.coverage.push({ name: state.name, url: shown, viewport: viewportLabel(v), status: 'incomplete', code, matched: 0, detail: field(detail), ...(trace ? { trace } : {}) });
};
const trace = (probe: string, viewport: string, stack: string): string | undefined => {
let file: string;
try { file = writeTrace(opts.traceDir ?? defaultTraceDir(), { probe, state: state.name, viewport, stack: redactText(stack), url: shown }); } catch (e) {
out.stderr.push(`gstack-design-lint: PROBE_ERROR: the ${probe} probe threw at ${state.name} ${viewport}; no trace written (${field((e as Error).message)})`);
return undefined;
}
out.stderr.push(`gstack-design-lint: PROBE_ERROR: the ${probe} probe threw at ${state.name} ${viewport}; trace ${file}`);
return file;
};
if (/^https?:\/\//.test(state.url)) {
const err = await (opts.reach ?? reachable)(state.url);
if (err) { all('UNREACHABLE', `could not reach ${shown}: ${err}`); return out; }
}
const first = viewports[0];
const sized = (expression: string, v: Viewport, maxInline = 400_000): RenderStep => ({ kind: 'eval', expression, width: v.width, height: v.height, deviceScaleFactor: 1, mobile: false, maxInline });
const args = (v: Viewport) => JSON.stringify({ within, expect: state.expect ?? null, explicitWait: !!opts.waitFor, readyMs: opts.readyMs ?? READY_MS });
const steps: RenderStep[] = [sized(appInstallScript(), first, 10), sized(appProbeScript('P.setup(P)'), first), ...viewports.map((v) => sized(appProbeScript(`P.measure(P, ${args(v)})`), v))];
const sectionWait = opts.sectionWaitMs ?? SECTION_WAIT_MS;
const waitFor = { expression: composedWaitExpression(opts.waitFor ?? within, opts.waitFor ? null : sectionWait), timeoutMs: sectionWait + (opts.waitFor ? 0 : 1_000) };
const res = await run({ file: state.url, waitFor, steps, timeoutMs: RENDER_TIMEOUT_MS });
if (!res.ok) {
const err = res.error ?? 'unknown error';
const code = codeOfRunError(err);
all(code, code === 'NOT_READY' ? `${opts.waitFor ? `the --wait-for selector ${opts.waitFor}` : `the section ${within}`} did not match within ${sectionWait / 1000}s` : `the page did not render: ${err.slice(0, 300)}`);
return out;
}
const setup = parseEval(res.evals[1]);
if ('error' in setup) { all('PROBE_ERROR', `the setup probe failed: ${setup.error}`, trace('setup', viewportLabel(first), setup.stack ?? setup.error)); return out; }
const report = setup.value as AppSetupReport;
if (!Array.isArray(report?.lostSheets) || !Array.isArray(report?.lostFonts)) { all('PROBE_ERROR', 'the setup answer has no list of lost assets'); return out; }
if (report.lostFonts.length) out.notes.push(field(`${state.name}: fonts did not load (${report.lostFonts.slice(0, 3).join(', ')}); measured with a fallback face`));
if (report.lostSheets.length) { all('ASSET_MISSING', `stylesheets did not load: ${report.lostSheets.slice(0, 3).join(', ')}`); return out; }
viewports.forEach((v, i) => {
const label = viewportLabel(v);
const cov: AppCoverage = { name: state.name, url: shown, viewport: label, status: 'incomplete', code: 'PROBE_ERROR', matched: 0 };
out.coverage.push(cov);
const p = parseEval(res.evals[2 + i]);
if ('error' in p) { cov.detail = field(`the measure probe failed: ${p.error}`); cov.trace = trace('measure', label, p.stack ?? p.error); return; }
const shape = probeShape(p.value);
if ('bad' in shape) { cov.detail = field(shape.bad); return; }
const r = shape.ok;
cov.matched = r.matched;
if (r.status === 'incomplete') { cov.code = r.code as AppCode; cov.detail = field(r.detail ?? ''); return; }
cov.status = 'measured';
cov.code = 'OK';
for (const f of r.findings) {
const meta = ruleMeta(f.rule)!;
const locator = field(f.locator);
out.findings.push({ rule: f.rule, tier: meta.tier, grade: meta.tier, file: oneLine(shown), line: 1, evidence: field(`[${state.name} ${v.width}px] ${f.sentence} at ${locator}`), fix: meta.fix, source: 'render',
app: { state: state.name, viewport: label, locator, measured: f.measured, threshold: f.threshold } });
}
});
return out;
}
/** Waivers by rule and locator substring: a match prints at waived with its reason; a waiver that matched nothing is a waiver finding. */
function applyAppWaivers(findings: Finding[], waivers: AppWaiver[]): Finding[] {
const used = new Set<number>();
const out = findings.map((f) => {
const i = waivers.findIndex((w) => w.rule === f.rule && !!f.app && f.app.locator.includes(w.locator));
if (i < 0) return f;
used.add(i);
return { ...f, grade: 'waived' as const, waiver: oneLine(waivers[i].reason) };
});
waivers.forEach((w, i) => {
if (used.has(i)) return;
out.push({ rule: 'waiver', tier: 'ask', grade: 'ask', file: '--waivers', line: i + 1, evidence: field(`waiver on ${w.rule} at "${w.locator}" matched no finding (unused)`), fix: 'remove the waiver, or correct its locator to part of the finding\'s', source: 'render' });
});
return out;
}
export async function lintApp(opts: AppOptions): Promise<AppResult> {
const viewports = opts.viewports?.length ? opts.viewports : DEFAULT_VIEWPORTS;
let run = opts.run;
let status = 'ran';
const runs: StateRun[] = [];
if (!run) {
const choice = (opts.engine ?? pickEngine)();
if (choice.engine) run = (spec) => (choice.engine === 'browse' ? renderWithBrowse(spec, choice.bin) : render(spec));
else {
status = `skipped: ${choice.error}`;
runs.push({ findings: [], notes: [], stderr: [], coverage: opts.states.flatMap((s) => viewports.map((v): AppCoverage => ({ name: s.name, url: redactUrl(s.url), viewport: viewportLabel(v), status: 'incomplete', code: 'NO_BROWSER', matched: 0, detail: field(choice.error) }))) });
}
}
if (run) for (const s of opts.states) runs.push(await lintState(s, opts, run, viewports));
const coverage = runs.flatMap((r) => r.coverage);
const findings = finalize(applyAppWaivers(runs.flatMap((r) => r.findings), opts.waivers ?? []));
const codes = APP_CODES.filter((c) => coverage.some((x) => x.code === c));
const open = findings.filter((f) => ruleMeta(f.rule)?.app && f.grade !== 'waived').length;
const waived = findings.filter((f) => ruleMeta(f.rule)?.app && f.grade === 'waived').length;
const verdict: AppVerdict['verdict'] = codes.length || !coverage.length ? 'incomplete' : open ? 'open' : 'clean';
const app: AppVerdict = {
verdict, code: verdict === 'incomplete' ? codes[0] ?? 'NO_BROWSER' : verdict === 'open' ? 'OPEN' : 'OK', codes,
within: opts.within, matched: coverage.length ? Math.min(...coverage.map((c) => c.matched)) : 0,
viewports: viewports.map(viewportLabel), states: coverage, open, waived, notes: runs.flatMap((r) => r.notes),
};
return { findings, summary: summarize(findings, status), app, exitCode: verdict === 'clean' ? 0 : verdict === 'open' ? 1 : 4, stderr: runs.flatMap((r) => r.stderr) };
}
/** The LINT line's companions under --scope app: one LINT_APP_STATE line per state x viewport before LINT_SUMMARY, the LINT_APP verdict after it. */
export function formatAppLines(app: AppVerdict): { states: string[]; verdict: string } {
const states = app.states.map((c) => oneLine(`LINT_APP_STATE: state=${c.name} viewport=${c.viewport} status=${c.status} code=${c.code} matched=${c.matched}`
+ (c.status === 'incomplete' ? ` ${c.detail ?? ''}${c.trace ? ` (trace: ${c.trace})` : ''} | fix: ${APP_CODE_FIX[c.code as AppCode]} (${appCodeAnchor(c.code as AppCode)})` : '')));
const names = [...new Set(app.states.map((c) => c.name))];
const measured = names.filter((n) => app.states.every((c) => c.name !== n || c.status === 'measured')).length;
const verdict = oneLine(`LINT_APP: verdict=${app.verdict} code=${app.code} states=${measured}/${names.length} viewports=${app.viewports.join(',')} within=${app.within} matched=${app.matched} open=${app.open} waived=${app.waived}`);
return { states, verdict };
}
+397
View File
@@ -0,0 +1,397 @@
/**
* The app profile's in-page measurements (gstack-design-lint --scope app).
* Installed on window.__gstackLintApp by their own install step, apart from
* the page probes in render-probe.ts, which stay as they are. Each function
* is serialised with Function.prototype.toString and sees only its arguments,
* the helpers object and the DOM.
*
* Everything walks the composed tree: light DOM, every open shadow root and
* slotted content in the order it renders. Nothing reads a test hook
* (data-testid or any other attribute): a list is found by shape (siblings of
* one tag and class list, similar widths, stacked, each holding text), a
* row's name by position (the first run on its first line), a note by
* position (a text block starting below the name's line).
*
* Text positions are line boxes: a text rect widened by its half-leading
* (Chrome floors the half above the glyphs and gives the rest below), so a gap
* measured to a row edge is the padding and margins the CSS set.
*/
export interface AppProbeFinding { rule: string; sentence: string; locator: string; measured: number; threshold: number }
export interface AppProbeResult {
status: 'measured' | 'incomplete';
/** Set when incomplete: NOT_READY, STATE_MISMATCH, WITHIN_NO_MATCH or CLOSED_SHADOW. */
code?: string;
detail?: string;
/** Elements the --within selector matched in the composed tree. */
matched: number;
/** Trees searched: the document plus every open shadow root. */
roots: number;
lists: number;
findings: AppProbeFinding[];
}
export interface AppSetupReport { lostSheets: string[]; lostFonts: string[] }
export interface AppMeasureArgs { within: string; expect: string | null; explicitWait: boolean; readyMs: number }
interface Run { node: Text; el: Element; block: Element; rects: DOMRect[]; top: number; bottom: number; left: number; lineTop: number; lineBottom: number }
interface AppList { container: Element; rows: Element[]; last: Element; whole: boolean }
/** Composed-tree helpers, built once by the install step. */
export function glAppHelpers() {
const kids = (n: Node): Node[] => {
if ((n as Element).tagName === 'SLOT') { const a = (n as HTMLSlotElement).assignedNodes({ flatten: true }); return a.length ? a : Array.from(n.childNodes); }
const sr = (n as Element).shadowRoot;
return Array.from(sr ? sr.childNodes : n.childNodes);
};
const ekids = (n: Node): Element[] => kids(n).filter((c) => c.nodeType === 1) as Element[];
/** The element children that render as boxes: a display: contents child (a slot, a wrapper) is replaced by its own. */
const boxKids = (n: Node): Element[] => ekids(n).flatMap((k) => (getComputedStyle(k).display === 'contents' ? boxKids(k) : [k]));
const cparent = (n: Node): Element | null => {
const slot = (n as Element).assignedSlot;
if (slot) return slot;
const p = n.parentNode;
if (!p) return null;
return p.nodeType === 11 ? ((p as ShadowRoot).host || null) : (p as Element).nodeType === 1 ? (p as Element) : null;
};
const inside = (el: Node, root: Element): boolean => { for (let e: Node | null = el; e; e = cparent(e)) if (e === root) return true; return false; };
/** Every element under root in the composed tree, root first. */
const desc = (root: Element): Element[] => { const out: Element[] = []; const go = (e: Element) => { out.push(e); for (const k of ekids(e)) go(k); }; go(root); return out; };
const roots = (): Array<Document | ShadowRoot> => {
const out: Array<Document | ShadowRoot> = [document];
for (let i = 0; i < out.length; i++) for (const e of out[i].querySelectorAll('*')) if (e.shadowRoot) out.push(e.shadowRoot);
return out;
};
const qAll = (sel: string): Element[] => { const out: Element[] = []; for (const r of roots()) for (const e of r.querySelectorAll(sel)) out.push(e); return out; };
const shown = (el: Element): boolean => { const cs = getComputedStyle(el); return cs.display !== 'none' && cs.visibility !== 'hidden' && parseFloat(cs.opacity) !== 0; };
const vis = (el: Element): boolean => { if (!shown(el)) return false; const r = el.getBoundingClientRect(); return r.width >= 1 && r.height >= 1; };
const CONTROL = 'button, a[href], select, input, textarea, [role=button], [role=menuitem], [role=menuitemradio], [role=menuitemcheckbox], [role=tab], [role=switch], [role=option]';
const isControl = (el: Element): boolean => el.matches(CONTROL);
const blockOf = (el: Element, stop: Element): Element => {
let e: Element = el;
while (e !== stop) { const d = getComputedStyle(e).display; if (d !== 'inline' && d !== 'contents') break; const p = cparent(e); if (!p) break; e = p; }
return e;
};
/** Visible text runs under root in rendered order, text inside controls left out. */
const runsOf = (root: Element): Run[] => {
const out: Run[] = [];
const go = (n: Node, ctl: boolean): void => {
for (const c of kids(n)) {
if (c.nodeType === 3) {
if (ctl || !(c.textContent || '').trim()) continue;
const el = cparent(c);
if (!el) continue;
const rg = document.createRange(); rg.selectNodeContents(c);
const rects = Array.from(rg.getClientRects()).filter((q) => q.width > 0 && q.height > 0);
if (!rects.length) continue;
const lh = parseFloat(getComputedStyle(el).lineHeight);
const above = (q: DOMRect) => (Number.isFinite(lh) ? Math.floor((lh - q.height) / 2) : 0);
const below = (q: DOMRect) => (Number.isFinite(lh) ? lh - q.height - above(q) : 0);
out.push({ node: c as Text, el, block: blockOf(el, root), rects, top: rects[0].top, bottom: Math.max(...rects.map((q) => q.bottom)), left: rects[0].left,
lineTop: Math.min(...rects.map((q) => q.top - above(q))), lineBottom: Math.max(...rects.map((q) => q.bottom + below(q))) });
} else if (c.nodeType === 1) {
const e = c as Element;
if (/^(SCRIPT|STYLE|TEMPLATE|NOSCRIPT)$/.test(e.tagName) || !shown(e)) continue;
go(e, ctl || isControl(e));
}
}
};
go(root, isControl(root));
return out;
};
const lineCount = (runs: Run[]): number => {
const tops = runs.flatMap((r) => r.rects.map((q) => q.top + q.height / 2)).sort((a, b) => a - b);
let n = 0, last = -Infinity;
for (const t of tops) { if (t - last > 4) n++; last = t; }
return n;
};
const cv = document.createElement('canvas'); cv.width = cv.height = 1;
const cx = cv.getContext('2d', { willReadFrequently: true }) as CanvasRenderingContext2D;
const rgb = (s: string): number[] => { cx.clearRect(0, 0, 1, 1); cx.fillStyle = 'rgba(0, 0, 0, 0)'; cx.fillStyle = s; cx.fillRect(0, 0, 1, 1); const d = cx.getImageData(0, 0, 1, 1).data; return [d[0] / 255, d[1] / 255, d[2] / 255, d[3] / 255]; };
const surface = (el: Element): boolean => {
const cs = getComputedStyle(el);
if (rgb(cs.backgroundColor)[3] > 0.02 || cs.backgroundImage !== 'none' || cs.boxShadow !== 'none') return true;
return (['Top', 'Right', 'Bottom', 'Left'] as const).some((s) => parseFloat(cs.getPropertyValue('border-' + s.toLowerCase() + '-width')) > 0 && !/none|hidden/.test(cs.getPropertyValue('border-' + s.toLowerCase() + '-style')) && rgb(cs.getPropertyValue('border-' + s.toLowerCase() + '-color'))[3] > 0.02);
};
/** The px of a drawn edge on one side: its border width, 0 when only a background or shadow draws it, null when nothing does. */
const drawn = (el: Element, side: 'top' | 'bottom'): number | null => {
const cs = getComputedStyle(el);
const w = parseFloat(cs.getPropertyValue('border-' + side + '-width'));
if (w > 0 && !/none|hidden/.test(cs.getPropertyValue('border-' + side + '-style')) && rgb(cs.getPropertyValue('border-' + side + '-color'))[3] > 0.02) return w;
return rgb(cs.backgroundColor)[3] > 0.02 || cs.backgroundImage !== 'none' || cs.boxShadow !== 'none' ? 0 : null;
};
const bgOf = (el: Element): number[] => {
for (let n: Element | null = el; n; n = cparent(n)) { const c = rgb(getComputedStyle(n).backgroundColor); if (c[3] >= 0.9) return c; }
return [1, 1, 1, 1];
};
const lightness = (el: Element, stop: Element): number => {
const a = rgb(getComputedStyle(el).color);
let op = 1;
for (let n: Element | null = el; n && n !== stop; n = cparent(n)) op *= parseFloat(getComputedStyle(n).opacity);
const bg = bgOf(el);
const c = [0, 1, 2].map((i) => a[i] * a[3] * op + bg[i] * (1 - a[3] * op));
const lin = c.map((v) => (v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4));
const l = Math.cbrt(0.4122214708 * lin[0] + 0.5363325363 * lin[1] + 0.0514459929 * lin[2]);
const m = Math.cbrt(0.2119034982 * lin[0] + 0.6806995451 * lin[1] + 0.1073969566 * lin[2]);
const s = Math.cbrt(0.0883024619 * lin[0] + 0.2817188376 * lin[1] + 0.6299787005 * lin[2]);
return 0.2104542553 * l + 0.793617785 * m - 0.0040720403 * s;
};
const seg = (e: Element): string => {
const cls = (e.getAttribute('class') || '').trim().split(/\s+/).filter(Boolean).slice(0, 2);
const p = e.parentElement || (e.parentNode as ShadowRoot | null);
const same = p ? Array.from(p.children).filter((x) => x.tagName === e.tagName) : [];
return e.tagName.toLowerCase() + (cls.length ? '.' + cls.join('.') : '') + (same.length > 1 ? ':nth-of-type(' + (same.indexOf(e) + 1) + ')' : '');
};
/** A shadow-piercing path to el: each host, then the steps inside el's own tree (up to eight), then its opening text, or a control's label. */
const locOf = (el: Element): string => {
const path: string[] = [];
for (let e: Element | null = el; e && path.length < 8; e = e.parentElement) { if (e === document.body || e === document.documentElement) break; path.unshift(seg(e)); }
const hosts: string[] = [];
for (let r = el.getRootNode(); r.nodeType === 11; r = (r as ShadowRoot).host.getRootNode()) hosts.unshift(seg((r as ShadowRoot).host));
const own = runsOf(el).map((r) => r.node.textContent || '').join(' ');
const words = (own.trim() ? own : el.getAttribute('aria-label') || el.textContent || '').replace(/\s+/g, ' ').trim().slice(0, 40);
return [...hosts, path.join(' > ') || seg(el)].join(' >>> ') + (words ? ' "' + words + '"' : '');
};
const px = (n: number): number => Math.round(n * 100) / 100;
/** m is under the threshold by more than layout's subpixel noise (0.05px). */
const under = (m: number, t: number): boolean => m < t - 0.05;
return { kids, ekids, boxKids, cparent, inside, desc, roots, qAll, shown, vis, isControl, runsOf, lineCount, rgb, surface, drawn, lightness, locOf, px, under };
}
export type AppHelpers = ReturnType<typeof glAppHelpers>;
/** The page before measuring: transitions off, running animations finished, fonts settled; which stylesheets and fonts did not load. */
export async function glAppSetup(P: AppProbes): Promise<AppSetupReport> {
const quiet = document.createElement('style');
quiet.textContent = '* { transition: none !important } html, body { scroll-behavior: auto !important }';
document.head.appendChild(quiet);
for (const a of document.getAnimations()) { try { a.finish(); } catch { /* an infinite animation keeps running */ } }
if (document.fonts && document.fonts.status !== 'loaded') await Promise.race([document.fonts.ready, new Promise((r) => setTimeout(r, 3000))]);
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
const lostSheets: string[] = [];
for (const root of P.H.roots()) for (const l of root.querySelectorAll('link[rel~="stylesheet"][href]')) if (!(l as HTMLLinkElement).sheet) lostSheets.push((l.getAttribute('href') || '').split('?')[0].split('/').pop() || 'stylesheet');
for (const e of performance.getEntriesByType('resource') as PerformanceResourceTiming[]) if ((e.initiatorType === 'link' || e.initiatorType === 'css') && /\.css(\?|$)/.test(e.name) && e.responseStatus >= 400) lostSheets.push(e.name.split('?')[0].split('/').pop() || 'stylesheet');
const lostFonts: string[] = [];
if (document.fonts) for (const f of document.fonts) if (f.status === 'error') lostFonts.push(f.family.replace(/^["']|["']$/g, ''));
return { lostSheets: Array.from(new Set(lostSheets)), lostFonts: Array.from(new Set(lostFonts)) };
}
/** Every list under root: two or more adjacent siblings of one tag and class list (never headings, paragraphs, sections or forms), widths within 10%, stacked top to bottom, each holding text; never a table, a nav, a menu-like role or a grid of columns. */
export function glAppLists(P: AppProbes, root: Element): AppList[] {
const { boxKids, cparent, vis, runsOf } = P.H;
const out: AppList[] = [];
const banned = (e: Element): boolean => {
for (let n: Element | null = e; n; n = cparent(n)) if (/^(TABLE|THEAD|TBODY|TFOOT|TR|TD|TH|NAV)$/.test(n.tagName) || /^(navigation|grid|table|menu|menubar|listbox|tablist|tree)$/.test(n.getAttribute('role') || '')) return true;
return false;
};
const walk = (parent: Element): void => {
const kids = boxKids(parent).filter((k) => vis(k));
const keyOf = (k: Element) => k.tagName + '|' + (k.getAttribute('class') || '').trim().split(/\s+/).filter(Boolean).sort().join(' ');
const groups: Element[][] = [];
for (const k of kids) {
const g = groups[groups.length - 1];
if (g && keyOf(g[0]) === keyOf(k)) g.push(k); else groups.push([k]);
}
for (const g of groups) {
if (g.length < 2 || /^(H[1-6]|P|SECTION|MAIN|HEADER|FOOTER|ASIDE|FORM|FIELDSET)$/.test(g[0].tagName) || banned(g[0])) continue;
const boxes = g.map((e) => e.getBoundingClientRect());
const widths = boxes.map((b) => b.width);
if (Math.max(...widths) > Math.min(...widths) * 1.1) continue;
const order = g.map((e, i) => ({ e, b: boxes[i] })).sort((a, b) => a.b.top - b.b.top);
if (order.some((x, i) => i > 0 && x.b.top < order[i - 1].b.bottom - 1)) continue;
if (order.some((x) => !runsOf(x.e).length)) continue;
const rows = order.map((x) => x.e);
out.push({ container: parent, rows, last: rows[rows.length - 1], whole: kids.every((k) => rows.includes(k)) });
}
for (const k of kids) walk(k);
};
walk(root);
return out;
}
/** row-crowding and note-in-row-weight over a list of rows (bounded: a row or the list draws an edge; or laid out: every row sets two or more boxes side by side), small-hit-area over any list's. */
export function glAppRows(P: AppProbes, list: AppList, vw: number): AppProbeFinding[] {
const { runsOf, lineCount, surface, drawn, lightness, locOf, px, under, cparent, vis, desc, boxKids } = P.H;
const out: AppProbeFinding[] = [];
const rows = list.rows;
const texts = rows.map((r) => runsOf(r));
const boxes = rows.map((r) => r.getBoundingClientRect());
const cb = list.container.getBoundingClientRect();
const sideBySide = (r: Element): boolean => {
if (!/flex|grid/.test(getComputedStyle(r).display)) return false;
const bs = boxKids(r).filter((k) => vis(k)).map((k) => k.getBoundingClientRect());
return bs.some((a, i) => bs.some((b, j) => j > i && a.top < b.bottom && b.top < a.bottom && (a.right <= b.left || b.right <= a.left)));
};
const bounded = surface(list.container) || rows.some((r) => surface(r)) || rows.every(sideBySide);
const inner = (i: number, side: 'top' | 'bottom'): number | null => {
const own = drawn(rows[i], side);
if (own != null) return side === 'top' ? boxes[i].top + own : boxes[i].bottom - own;
const j = side === 'top' ? i - 1 : i + 1;
if (rows[j] && drawn(rows[j], side === 'top' ? 'bottom' : 'top') != null) return side === 'top' ? boxes[j].bottom : boxes[j].top;
const c = !rows[j] && list.whole ? drawn(list.container, side) : null;
return c == null ? null : side === 'top' ? cb.top + c : cb.bottom - c;
};
rows.forEach((row, i) => {
const runs = texts[i];
const lines = lineCount(runs);
if (bounded && lines >= 2) {
const topEdge = inner(i, 'top');
const bottomEdge = inner(i, 'bottom');
const top = topEdge == null ? Infinity : Math.min(...runs.map((r) => r.lineTop)) - topEdge;
const bottom = bottomEdge == null ? Infinity : bottomEdge - Math.max(...runs.map((r) => r.lineBottom));
const next = texts[i + 1];
const gap = px(next && next.length ? Math.min(...next.map((r) => r.lineTop)) - Math.max(...runs.map((r) => r.lineBottom)) : Infinity);
const edge = px(Math.min(top, bottom));
if (under(edge, 8)) out.push({ rule: 'row-crowding', sentence: 'a ' + lines + '-line row leaves ' + edge + 'px between its text and its ' + (top <= bottom ? 'top' : 'bottom') + ' edge (under 8px)', locator: locOf(row), measured: edge, threshold: 8 });
else if (under(gap, 16)) out.push({ rule: 'row-crowding', sentence: 'a ' + lines + '-line row leaves ' + gap + 'px between its last text line and the next row\'s text (under 16px)', locator: locOf(row), measured: gap, threshold: 16 });
}
if (bounded && runs.length) {
const first = Math.min(...runs.map((r) => r.top));
const name = runs.filter((r) => r.top <= first + 1).sort((a, b) => a.left - b.left)[0];
const blocks = new Map<Element, typeof runs>();
for (const r of runs) if (r.block !== name.block) blocks.set(r.block, [...(blocks.get(r.block) || []), r]);
for (const [block, own] of blocks) {
if (Math.min(...own.map((r) => r.top)) < name.bottom - 1) continue;
let callout = false;
for (let e: Element | null = block; e && e !== row; e = cparent(e)) if (surface(e)) { callout = true; break; }
const ICON = 'svg, img, [aria-hidden="true"]';
const iconed = !!block.querySelector(ICON) || [block.previousElementSibling, block.nextElementSibling].some((s) => !!s && vis(s) && (s.matches(ICON) || (!(s.textContent || '').trim() && !!s.querySelector('svg, img'))));
if (callout || iconed) continue;
const dl = Math.abs(lightness(own[0].el, row) - lightness(name.el, row));
const ratio = parseFloat(getComputedStyle(own[0].el).fontSize) / parseFloat(getComputedStyle(name.el).fontSize);
const noteLines = lineCount(own);
if (dl < 0.15 && ratio >= 0.9) out.push({ rule: 'note-in-row-weight', sentence: 'a note in a row reads at the name\'s weight: lightness ' + dl.toFixed(3) + ' from the name\'s (under 0.15) at ' + ratio.toFixed(2) + ' of its size (0.9 or more)', locator: locOf(block), measured: Math.round(dl * 1000) / 1000, threshold: 0.15 });
else if (vw >= 1200 && noteLines >= 3) out.push({ rule: 'note-in-row-weight', sentence: 'a note in a row runs ' + noteLines + ' lines at ' + vw + 'px (3 or more)', locator: locOf(block), measured: noteLines, threshold: 3 });
}
}
if (vw <= 480) {
const controls = desc(row).filter((c) => c !== row && c.matches('button, a[href], input:not([type=hidden]), select, [role=button], [role^=menuitem], [role=switch], [role=tab]') && vis(c));
for (const c of controls) {
if (c.tagName === 'A' && getComputedStyle(c).display === 'inline') {
const host = cparent(c);
if (host && runsOf(host).some((r) => !c.contains(r.node))) continue;
}
let b = c.getBoundingClientRect();
const labels = (c as HTMLInputElement).labels;
if (labels && labels.length) {
const all = [b, ...Array.from(labels).map((l) => l.getBoundingClientRect())];
const l = Math.min(...all.map((q) => q.left)), t = Math.min(...all.map((q) => q.top));
b = new DOMRect(l, t, Math.max(...all.map((q) => q.right)) - l, Math.max(...all.map((q) => q.bottom)) - t);
}
if (under(b.width, 32) || under(b.height, 32)) out.push({ rule: 'small-hit-area', sentence: 'a row control is ' + px(b.width) + 'x' + px(b.height) + 'px at ' + vw + 'px (under 32px)', locator: locOf(c), measured: px(Math.min(b.width, b.height)), threshold: 32 });
}
}
});
return out;
}
/** copy-flush-under-list: the first text block after the list, before the next heading, inside the measured section. */
export function glAppAfter(P: AppProbes, list: AppList, root: Element): AppProbeFinding[] {
const { boxKids, cparent, vis, runsOf, locOf, px, under } = P.H;
const HEADING = /^H[1-6]$/;
const isHeading = (e: Element) => HEADING.test(e.tagName) || e.getAttribute('role') === 'heading';
let from: Element = list.whole ? list.container : list.last;
for (let depth = 0; depth < 3; depth++) {
let parent = cparent(from);
while (parent && getComputedStyle(parent).display === 'contents' && parent !== root) parent = cparent(parent);
if (!parent) return [];
const sibs = boxKids(parent);
for (const s of sibs.slice(sibs.indexOf(from) + 1)) {
if (!vis(s)) continue;
if (isHeading(s)) return [];
const runs = runsOf(s);
if (!runs.length) continue;
const first = runs.slice().sort((a, b) => a.lineTop - b.lineTop)[0];
for (let e: Element | null = first.el; e && e !== s; e = cparent(e)) if (isHeading(e)) return [];
const gap = px(first.lineTop - from.getBoundingClientRect().bottom);
return under(gap, 12) ? [{ rule: 'copy-flush-under-list', sentence: 'the text under the list starts ' + gap + 'px below it (under 12px)', locator: locOf(first.block), measured: gap, threshold: 12 }] : [];
}
if (parent === root || isHeading(parent)) return [];
from = parent;
}
return [];
}
/** section-overflow: inside root, an element past the viewport's right edge, or a non-scrolling block wider inside than out. The innermost three. */
export function glAppOverflow(P: AppProbes, root: Element, vw: number): AppProbeFinding[] {
const { ekids, vis, locOf, px } = P.H;
const hits: Array<{ el: Element; sentence: string; m: number }> = [];
const go = (el: Element, clipped: boolean): void => {
if (el.tagName === 'svg' || /^(SCRIPT|STYLE|TEMPLATE)$/.test(el.tagName)) return;
const cs = getComputedStyle(el);
if (cs.display === 'contents') { for (const k of ekids(el)) go(k, clipped); return; }
if (!vis(el) || cs.position === 'fixed') return;
const b = el.getBoundingClientRect();
if (!clipped && b.width > 1 && b.height > 1 && b.right > vw + 1) hits.push({ el, sentence: el.tagName.toLowerCase() + ' runs ' + px(b.right - vw) + 'px past the ' + vw + 'px viewport', m: px(b.right - vw) });
else if (cs.overflowX === 'visible' && cs.display !== 'inline' && el.clientWidth > 0 && el.scrollWidth > el.clientWidth + 1) hits.push({ el, sentence: el.tagName.toLowerCase() + ' overflows its own box by ' + (el.scrollWidth - el.clientWidth) + 'px', m: el.scrollWidth - el.clientWidth });
for (const k of ekids(el)) go(k, clipped || cs.overflowX !== 'visible');
};
go(root, false);
const inner = hits.filter((h) => !hits.some((o) => o !== h && P.H.inside(o.el, h.el)));
return inner.slice(0, 3).map((h) => ({ rule: 'section-overflow', sentence: h.sentence + ' (over 1px)', locator: locOf(h.el), measured: h.m, threshold: 1 }));
}
/** One state at one viewport: wait for the section and a list (or the caller's --wait-for), check the state's expect selector, then every app rule. */
export async function glAppMeasure(P: AppProbes, a: AppMeasureArgs): Promise<AppProbeResult> {
const { qAll, inside, roots, desc } = P.H;
const t0 = Date.now();
const outermost = (els: Element[]) => els.filter((e) => !els.some((o) => o !== e && inside(e, o)));
let matches: Element[] = [];
let lists: AppList[] = [];
let expectOk = true;
let host: Element | undefined;
const closed = (scope: Element[]): Element | undefined =>
scope.flatMap((s) => desc(s)).find((e) => e.tagName.includes('-') && !e.shadowRoot && !e.children.length && !(e.textContent || '').trim() && e.getBoundingClientRect().height >= 40);
for (;;) {
matches = outermost(qAll(a.within));
lists = matches.flatMap((m) => P.lists(P, m));
expectOk = !a.expect || qAll(a.expect).length > 0;
host = matches.length && !lists.length ? closed(matches) : undefined;
if (host || (matches.length && (lists.length || a.explicitWait) && expectOk) || Date.now() - t0 > a.readyMs) break;
await new Promise((r) => setTimeout(r, 150));
}
const matched = qAll(a.within).length;
const nRoots = roots().length;
const base = { matched, roots: nRoots, lists: lists.length, findings: [] as AppProbeFinding[] };
if (!matches.length) {
const host = closed([document.body]);
if (host) return { ...base, status: 'incomplete', code: 'CLOSED_SHADOW', detail: '<' + host.tagName.toLowerCase() + '> renders a closed shadow root the lint cannot read, and nothing matched ' + a.within + ' in the document or its ' + (nRoots - 1) + ' open shadow roots' };
return { ...base, status: 'incomplete', code: 'WITHIN_NO_MATCH', detail: 'nothing matched ' + a.within + ' in the document or its ' + (nRoots - 1) + ' open shadow roots' };
}
if (host) return { ...base, status: 'incomplete', code: 'CLOSED_SHADOW', detail: '<' + host.tagName.toLowerCase() + '> inside ' + a.within + ' renders a closed shadow root the lint cannot read' };
if (!lists.length && !a.explicitWait) return { ...base, status: 'incomplete', code: 'NOT_READY', detail: 'no list of two or more rows appeared inside ' + a.within + ' within ' + Math.round(a.readyMs / 1000) + 's' };
if (!expectOk) return { ...base, status: 'incomplete', code: 'STATE_MISMATCH', detail: 'the state\'s expect selector ' + a.expect + ' matched nothing after readiness' };
const vw = innerWidth;
const findings: AppProbeFinding[] = [];
for (const l of lists) findings.push(...P.rows(P, l, vw), ...P.after(P, l, matches.find((m) => inside(l.container, m)) || l.container));
for (const m of matches) findings.push(...P.overflow(P, m, vw));
return { ...base, status: 'measured', findings };
}
const APP_FNS = { setup: glAppSetup, lists: glAppLists, rows: glAppRows, after: glAppAfter, overflow: glAppOverflow, measure: glAppMeasure };
export interface AppProbes extends AppFns { H: AppHelpers }
type AppFns = typeof APP_FNS;
export const APP_PROBE_GLOBAL = 'window.__gstackLintApp';
/** The JS that installs the app probes, as one expression. */
export function appInstallScript(): string {
const entries = Object.entries(APP_FNS).map(([k, fn]) => k + ': (' + fn.toString() + ')');
return '(' + APP_PROBE_GLOBAL + ' = { H: (' + glAppHelpers.toString() + ')(), ' + entries.join(', ') + ' }, 1)';
}
/** One app probe call as an eval expression: its JSON, or {error, stack} when it throws (the stack feeds the trace file). Written without the word await (see render-probe.ts probeScript). */
export function appProbeScript(call: string): string {
return '(() => { const P = ' + APP_PROBE_GLOBAL + '; return Promise.resolve().then(() => (' + call + ')).then((v) => JSON.stringify(v), (e) => JSON.stringify({ error: String((e && e.message) || e).slice(0, 300), stack: String((e && e.stack) || e).slice(0, 4000) })); })()';
}
/** The readiness expression handed to the engine's waitFor: a selector matched through every open shadow root. With soft, it also turns true after ms, so a selector that never matches reaches the probe and reports WITHIN_NO_MATCH instead of failing the render. */
export function composedWaitExpression(selector: string, soft: number | null): string {
const find = '(() => { const s = ' + JSON.stringify(selector) + '; const rs = [document]; try { for (let i = 0; i < rs.length; i++) { if (rs[i].querySelector(s)) return true; for (const e of rs[i].querySelectorAll("*")) if (e.shadowRoot) rs.push(e.shadowRoot); } } catch (e) { return ' + (soft != null) + '; } return false; })()';
if (soft == null) return find;
return '(() => { const w = window; w.__gstackLintAppWait = w.__gstackLintAppWait || Date.now(); return ' + find + ' || Date.now() - w.__gstackLintAppWait > ' + soft + '; })()';
}
+13 -2
View File
@@ -26,6 +26,16 @@ export interface Finding {
source: Source;
/** Set when a waiver answered the finding: the reason it recorded. */
waiver?: string;
/** An app-profile finding's measurement (--scope app): kept whole, never truncated. */
app?: AppMeasure;
}
export interface AppMeasure {
state: string;
viewport: string;
locator: string;
measured: number;
threshold: number;
}
export interface ReportOptions {
@@ -47,13 +57,14 @@ export const relPath = (file: string, cwd = process.cwd()): string =>
export class Reporter {
readonly findings: Finding[] = [];
constructor(readonly scope: 'page' | 'component' = 'page', readonly cwd = process.cwd()) {}
constructor(readonly scope: 'page' | 'component' | 'app' = 'page', readonly cwd = process.cwd()) {}
/** Records one finding and returns it; null when the rule is page-only and the scope is component. */
report(rule: string, file: string, line: number | null | undefined, evidence: string, opts: ReportOptions = {}): Finding | null {
const meta = ruleMeta(rule);
if (!meta) throw new Error(`design-lint: unknown rule id ${rule}`);
if (this.scope === 'component' && meta.pageOnly) return null;
if (meta.app && this.scope !== 'app') return null;
const tier: Tier = opts.suspected && meta.tier === 'blocks' ? 'ask' : meta.tier;
const finding: Finding = {
rule, tier, grade: tier,
@@ -76,7 +87,7 @@ const GRADE_RANK: Record<Grade, number> = { blocks: 0, ask: 1, waived: 2, polish
export function finalize(findings: Finding[]): Finding[] {
const seen = new Set<string>();
const unique = findings.filter((f) => {
const k = `${f.rule}|${f.grade}|${f.file}|${f.line}|${f.evidence}`;
const k = `${f.rule}|${f.grade}|${f.file}|${f.line}|${f.evidence}` + (f.app ? `|${f.app.state}|${f.app.viewport}|${f.app.locator}` : '');
if (seen.has(k)) return false;
seen.add(k);
return true;
+8
View File
@@ -31,6 +31,8 @@ export interface RuleMeta {
pageOnly?: boolean;
/** The render tier reports this rule; the static tier never does. */
render?: boolean;
/** An app-profile rule: only `--scope app` reports it, and `--scope app` reports nothing else (render-app.ts). */
app?: boolean;
}
export const RULES: Record<string, RuleMeta> = {
@@ -170,6 +172,12 @@ export const RULES: Record<string, RuleMeta> = {
'missing-skip-link': { tier: 'ask', gate: 'craft', title: 'no skip link reaching <main>', fix: 'add a "Skip to content" link first in <body> whose #id exists on <main>', pageOnly: true },
'missing-aria-current': { tier: 'ask', gate: 'craft', title: 'route navigation without aria-current', fix: 'mark the current nav link aria-current="page"', pageOnly: true },
'empty-mount-node': { tier: 'ask', gate: 'craft', title: 'an empty mount node: the body has no text and a script renders it, so the lint read none of its content', fix: 'lint the rendered page: save document.documentElement.outerHTML to a file and lint that', pageOnly: true },
// ── App profile (--scope app only; render-app.ts and render-probe-app.ts, GST-1741) ──
'row-crowding': { tier: 'ask', gate: 'app', title: 'a list row whose text spans two or more lines sits under 8px from its row edge, or under 16px from the next row\'s text', fix: 'pad the row 8px or more top and bottom and leave 16px or more between one row\'s text and the next', render: true, app: true },
'copy-flush-under-list': { tier: 'ask', gate: 'app', title: 'the first text block after a list starts under 12px below the list', fix: 'leave 12px or more between the list and the copy under it', render: true, app: true },
'note-in-row-weight': { tier: 'ask', gate: 'app', title: 'a note inside a row at the name\'s weight (no icon, no surface of its own, lightness within 0.15 and size 0.9 or more of the name\'s), or a note of three lines or more at 1200px and wider', fix: 'mute the note (a lighter ink and a smaller size than the name), or give it an icon or a callout of its own', render: true, app: true },
'small-hit-area': { tier: 'ask', gate: 'app', title: 'a control inside a list row under 32px in either dimension at a phone width (480px or less)', fix: 'make the target 32px or more (padding with a negative margin, or an inset ::before) and keep the visible glyph its size', render: true, app: true },
'section-overflow': { tier: 'ask', gate: 'app', title: 'an element inside the measured section runs past the viewport, or a non-scrolling element overflows its own box', fix: 'let it wrap or shrink (min-width: 0, overflow-wrap: anywhere), or give it a scrolling container', render: true, app: true },
// ── The lint's own bookkeeping ──
'waiver': { tier: 'ask', gate: 'waiver', title: 'a waiver the lint refused', fix: 'fix the finding, or waive it with the rule id and a reason about this page' },
'house-style': { tier: 'polish', gate: 'waiver', title: 'more than three rules waived on one page', fix: 'lock the choices into DESIGN.md so later pages inherit them instead of re-arguing them' },
+386
View File
@@ -0,0 +1,386 @@
/**
* The app profile's contract without a browser (GST-1741 PR 1): usage errors
* before any render, state-file and waiver validation, the verdict and exit
* precedence, URL redaction, private trace files, RENDER_UNAVAILABLE for a
* --url nobody measured, and the scope guard that keeps app rules out of the
* page and component profiles. A fake runner stands in for the browser; the
* measured behavior is test/design-lint-app-profile.test.ts.
*/
import { afterAll, describe, expect, test } from 'bun:test';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import type { RenderResult, RenderSpec } from '../lib/aside-render';
import { parseStateFile, parseViewport, parseWaiverFile, redactText, redactUrl, selectorError, stateNameOf } from '../lib/design-lint/app-input';
import { appMain, renderUnavailable, type AppCliArgs, type AppDeps } from '../lib/design-lint/app-cli';
import { lint } from '../lib/design-lint/index';
import { lintApp, APP_CODES } from '../lib/design-lint/render-app';
import { appInstallScript, composedWaitExpression } from '../lib/design-lint/render-probe-app';
import { installScript } from '../lib/design-lint/render-probe';
import { finalize, Reporter, type Finding } from '../lib/design-lint/report';
import { RULES } from '../lib/design-lint/rules/ids';
const ROOT = path.resolve(import.meta.dir, '..');
const BIN = path.join(ROOT, 'bin', 'gstack-design-lint');
const tmp: string[] = [];
afterAll(() => { for (const d of tmp) fs.rmSync(d, { recursive: true, force: true }); });
const tmpDir = (): string => { const d = fs.mkdtempSync(path.join(os.tmpdir(), 'design-lint-app-')); tmp.push(d); return d; };
function runBin(args: string[]) {
const r = Bun.spawnSync([BIN, ...args], { cwd: ROOT, stdout: 'pipe', stderr: 'pipe', timeout: 60_000 });
return { code: r.exitCode, out: r.stdout.toString(), err: r.stderr.toString() };
}
const APP_IDS = Object.entries(RULES).filter(([, m]) => m.app).map(([id]) => id);
const URL1 = 'http://127.0.0.1:5300/settings?as=owner';
interface Fake { setup?: unknown; measure?: (width: number, call: string) => unknown; fail?: string }
const ok = (findings: unknown[] = [], extra: Record<string, unknown> = {}) => ({ status: 'measured', matched: 1, roots: 1, lists: 1, findings, ...extra });
const f = (rule: string, locator: string, measured = 4, threshold = 8) => ({ rule, sentence: 'a 2-line row leaves ' + measured + 'px between its text and its top edge (under ' + threshold + 'px)', locator, measured, threshold });
/** A runner that answers every step the scheduler plans, and records each spec it was handed. */
function fakeRun(fake: Fake, specs: RenderSpec[] = []) {
return async (spec: RenderSpec): Promise<RenderResult> => {
specs.push(spec);
if (fake.fail) return { ok: false, outputs: [], evals: {}, stdout: '', error: fake.fail };
const evals: Record<number, string> = {};
spec.steps.forEach((s, i) => {
if (s.kind !== 'eval') return;
if (i === 0) evals[i] = '1';
else if (s.expression.includes('P.setup(P)')) evals[i] = JSON.stringify(fake.setup ?? { lostSheets: [], lostFonts: [] });
else evals[i] = JSON.stringify(fake.measure ? fake.measure(s.width ?? 0, s.expression) : ok());
});
return { ok: true, outputs: [], evals, stdout: '' };
};
}
const args = (over: Partial<AppCliArgs> = {}): AppCliArgs => ({ paths: [], urls: [URL1], within: '.sharing', viewports: [], verbose: false, json: false, ...over });
async function app(over: Partial<AppCliArgs>, deps: AppDeps) {
const out: string[] = [];
const err: string[] = [];
const code = await appMain(args(over), { out: (s) => out.push(s), err: (s) => err.push(s) }, { reach: async () => null, traceDir: tmpDir(), ...deps });
return { code, out: out.join('\n'), err: err.join('\n') };
}
const neverRun = async (): Promise<RenderResult> => { throw new Error('the runner must not be called for a usage error'); };
describe('app profile contract: usage errors exit 3 before any render (T24, T30)', () => {
test('the bin refuses paths, a missing --within, a bad selector, a bad viewport and both or neither target', () => {
for (const [argv, code] of [
[['--scope', 'app', 'x.html', '--within', 'main'], 'APP_PATHS'],
[['--scope', 'app', '--url', URL1], 'NO_WITHIN'],
[['--scope', 'app', '--url', URL1, '--within', '.rows['], 'BAD_SELECTOR'],
[['--scope', 'app', '--url', URL1, '--within', 'main', '--wait-for', 'a >'], 'BAD_SELECTOR'],
[['--scope', 'app', '--url', URL1, '--within', 'main', '--viewport', '100x900'], 'BAD_VIEWPORT'],
[['--scope', 'app', '--url', URL1, '--within', 'main', '--viewport', '1440x900x'], 'BAD_VIEWPORT'],
[['--scope', 'app', '--url', URL1, '--within', 'main', '--viewport', '4000x900'], 'BAD_VIEWPORT'],
[['--scope', 'app', '--within', 'main'], 'NO_TARGET'],
[['--scope', 'app', '--url', 'ftp://x.test/p', '--within', 'main'], 'BAD_URL'],
[['--scope', 'app', '--url', URL1, '--state-file', 'x.json', '--within', 'main'], 'BOTH_TARGETS'],
[['--scope', 'app', '--state-file', '/nowhere/states.json', '--within', 'main'], 'BAD_STATE_FILE'],
] as Array<[string[], string]>) {
const r = runBin(argv);
expect([argv.join(' '), r.code, r.out]).toEqual([argv.join(' '), 3, '']);
expect(r.err).toMatch(new RegExp('^gstack-design-lint: ' + code + ': .+ \\| fix: .+$', 'm'));
}
});
test('every app-only flag without --scope app exits 3 naming the flag; --verbose works anywhere', () => {
for (const flag of ['--within', '--viewport', '--wait-for', '--waivers', '--state-file']) {
const r = runBin(['test/fixtures/design-lint/clean', flag, 'x']);
expect([r.code, r.err.trim()]).toEqual([3, 'gstack-design-lint: APP_ONLY: ' + flag + ' only with --scope app | fix: add --scope app, or drop ' + flag]);
}
const plain = runBin(['test/fixtures/design-lint/clean']);
const verbose = runBin(['test/fixtures/design-lint/clean', '--verbose']);
expect([verbose.code, verbose.out]).toEqual([plain.code, plain.out]);
const usage = runBin(['--help']);
expect(usage.out).toContain('gstack-design-lint --scope app (--url <http://…> ... | --state-file <states.json>) --within <selector>');
});
test('an invalid selector never reaches the runner', async () => {
const r = await app({ within: 'div > > p' }, { run: neverRun });
expect(r.code).toBe(3);
expect(r.err).toContain('BAD_SELECTOR');
});
test('selector and viewport grammar', () => {
for (const ok of ['.sharing', 'qm-sharing', 'main .rows > .row:nth-of-type(2)', '[data-x="a]b"]', 'a, b', ':is(.a, .b) li', 'x-y\\:z', '#main']) expect([ok, selectorError(ok)]).toEqual([ok, null]);
for (const bad of ['', '.rows[', 'div >', '> div', 'a,,b', 'a {}', 'p:', '.', '#', '(a', 'a)', 'a !b', '"q"']) expect([bad, selectorError(bad)]).not.toEqual([bad, null]);
expect(parseViewport('375x812')).toEqual({ width: 375, height: 812 });
expect(parseViewport('3840x2400')).toEqual({ width: 3840, height: 2400 });
for (const bad of ['319x900', '1440x479', '1440x2401', '14400x900', '1440X900', ' 1440x900', '1440x900.5']) expect(() => parseViewport(bad)).toThrow(/--viewport/);
});
});
describe('app profile contract: state files and waivers (T12, T23, T30, T36)', () => {
test('a state file is validated whole: unknown keys, no url, a bad version or selector, a duplicate name; a non-loopback URL is noted', () => {
const good = parseStateFile(JSON.stringify({ version: 1, states: [{ name: 'owner', url: URL1, expect: '.owner-note' }, { url: 'https://staging.example.test/p?as=reader&token=abc' }] }));
expect(good.states.map((s) => s.name)).toEqual(['owner', '/p?as=reader&token=REDACTED']);
expect(good.notes).toEqual(['state /p?as=reader&token=REDACTED is not on a loopback address (https://staging.example.test/p?as=reader&token=REDACTED)']);
for (const [doc, why] of [
['{', 'not JSON'],
[{ version: 1, states: [], extra: 1 }, 'unknown key extra'],
[{ version: 2, states: [{ url: URL1 }] }, 'version must be 1'],
[{ version: 1, states: [] }, 'states is empty'],
[{ version: 1, states: [{ name: 'a' }] }, 'states[0] has no url'],
[{ version: 1, states: [{ url: 'file:///x.html' }] }, 'not an http or https address'],
[{ version: 1, states: [{ url: URL1, wait: 1 }] }, 'states[0] has unknown key wait'],
[{ version: 1, states: [{ url: URL1, expect: 'a >' }] }, 'not a valid selector'],
[{ version: 1, states: [{ url: URL1, name: 'a' }, { url: URL1, name: 'a' }] }, 'two states are named a'],
] as Array<[unknown, string]>) {
expect(() => parseStateFile(typeof doc === 'string' ? doc : JSON.stringify(doc))).toThrow(why);
}
});
test('a bad state file through the bin exits 3 with its reason', () => {
const dir = tmpDir();
fs.writeFileSync(path.join(dir, 's.json'), JSON.stringify({ version: 1, states: [{ url: URL1, color: 'red' }] }));
const r = runBin(['--scope', 'app', '--state-file', path.join(dir, 's.json'), '--within', 'main']);
expect([r.code, r.err.trim()]).toEqual([3, expect.stringMatching(/^gstack-design-lint: BAD_STATE_FILE: --state-file: states\[0\] has unknown key color \| fix: /)]);
});
test('a waiver file names an app rule, a locator and a reason of 8 characters or more; anything else exits 3', async () => {
const isApp = (id: string) => !!RULES[id]?.app;
expect(parseWaiverFile(JSON.stringify({ version: 1, waivers: [{ rule: 'small-hit-area', locator: 'button.x', reason: 'the glyph sits in a 44px label target' }] }), isApp)).toHaveLength(1);
for (const [w, why] of [
[{ rule: 'low-contrast', locator: 'a', reason: 'long enough reason' }, 'is not an app rule'],
[{ rule: 'nope', locator: 'a', reason: 'long enough reason' }, 'is not an app rule'],
[{ rule: 'small-hit-area', locator: 'a', reason: 'short' }, '8 characters or more'],
[{ rule: 'small-hit-area', locator: ' ', reason: 'long enough reason' }, 'locator must be'],
[{ rule: 'small-hit-area', locator: 'a', reason: 'long enough reason', page: 'x' }, 'unknown key page'],
] as Array<[unknown, string]>) expect(() => parseWaiverFile(JSON.stringify({ version: 1, waivers: [w] }), isApp)).toThrow(why);
const dir = tmpDir();
fs.writeFileSync(path.join(dir, 'w.json'), JSON.stringify({ version: 1, waivers: [{ rule: 'row-crowding', locator: 'a', reason: 'tiny' }] }));
const r = await app({ waivers: path.join(dir, 'w.json') }, { run: neverRun });
expect([r.code, r.err]).toEqual([3, expect.stringContaining('BAD_WAIVERS')]);
});
test('a matched waiver prints at waived with its reason and leaves the verdict clean; an unused one is a waiver finding (T6, T23, T28, T35)', async () => {
const dir = tmpDir();
const file = path.join(dir, 'w.json');
fs.writeFileSync(file, JSON.stringify({ version: 1, waivers: [
{ rule: 'small-hit-area', locator: 'button.remove', reason: 'the remove glyph sits inside a 44px label target' },
{ rule: 'row-crowding', locator: 'div.never', reason: 'kept to show an unused waiver' },
] }));
const run = fakeRun({ measure: (w) => ok(w === 375 ? [f('small-hit-area', 'qm-x >>> div.row > button.remove "Remove Dana"', 18, 32)] : []) });
const r = await app({ waivers: file }, { run });
expect(r.code).toBe(0);
expect(r.out).toMatch(/^LINT: waived small-hit-area .+ \| fix: waived \(the remove glyph sits inside a 44px label target\)$/m);
expect(r.out).toMatch(/^LINT: ask waiver --waivers:2 waiver on row-crowding at "div\.never" matched no finding \(unused\) \| fix: /m);
expect(r.out).toMatch(/^LINT_APP: verdict=clean code=OK states=1\/1 viewports=1440x900,375x812 within=\.sharing matched=1 open=0 waived=1$/m);
});
});
describe('app profile contract: verdict, codes and exit precedence (T8, T15, T16, T19, T27)', () => {
test('clean is 0 only on complete coverage with nothing open; any unwaived app finding is open (1) whatever its tier', async () => {
const clean = await app({}, { run: fakeRun({}) });
expect([clean.code, clean.out.split('\n').slice(-1)[0]]).toEqual([0, 'LINT_APP: verdict=clean code=OK states=1/1 viewports=1440x900,375x812 within=.sharing matched=1 open=0 waived=0']);
const open = await app({ json: true }, { run: fakeRun({ measure: () => ok([f('row-crowding', 'div.rows > div.row:nth-of-type(1) "Deploy key"')]) }) });
expect(open.code).toBe(1);
const json = JSON.parse(open.out);
expect(json.app).toMatchObject({ verdict: 'open', code: 'OPEN', open: 2, waived: 0, codes: [] });
expect(json.app.states.map((s: { viewport: string; status: string; code: string }) => [s.viewport, s.status, s.code])).toEqual([['1440x900', 'measured', 'OK'], ['375x812', 'measured', 'OK']]);
expect(json.summary.ask).toBe(2);
});
test('incomplete wins over open and is never printed clean: one viewport not measured exits 4', async () => {
const run = fakeRun({ measure: (w) => (w === 375 ? { status: 'incomplete', code: 'NOT_READY', detail: 'no list', matched: 1, roots: 1, lists: 0, findings: [] } : ok([f('row-crowding', 'div.row "A"')])) });
const r = await app({}, { run });
expect(r.code).toBe(4);
expect(r.out).toMatch(/^LINT_APP_STATE: state=\/settings\?as=owner viewport=375x812 status=incomplete code=NOT_READY matched=1 no list \| fix: pass --wait-for .+ \(docs\/design-lint\.md#app-code-not_ready\)$/m);
expect(r.out).toMatch(/^LINT_APP: verdict=incomplete code=NOT_READY states=0\/1 .* open=1 waived=0$/m);
});
test('several codes report the first in the documented order and list them all', async () => {
const result = await lintApp({
states: [{ name: 'a', url: 'http://127.0.0.1:1/a' }, { name: 'b', url: URL1 }, { name: 'c', url: 'http://127.0.0.1:5300/c' }],
within: 'main', traceDir: tmpDir(),
reach: async (u) => (u.endsWith('/a') ? 'connection refused' : null),
run: fakeRun({ measure: (w, call) => (call.includes('"expect":null') && w === 1440 ? { status: 'incomplete', code: 'STATE_MISMATCH', matched: 1, roots: 1, lists: 1, findings: [] } : { error: 'TypeError: x is not a function', stack: 'TypeError: x\n at http://127.0.0.1:5300/app.js?token=SECRET:1:1' }) }),
});
expect(result.exitCode).toBe(4);
expect(result.app.code).toBe('UNREACHABLE');
expect(result.app.codes).toEqual(['UNREACHABLE', 'STATE_MISMATCH', 'PROBE_ERROR']);
expect(APP_CODES.indexOf('UNREACHABLE')).toBeLessThan(APP_CODES.indexOf('PROBE_ERROR'));
});
test('a failed render maps to its code: no browser, timeout, the --wait-for never matching, anything else unreachable', async () => {
const codeOf = async (fail: string, waitFor?: string) => (await lintApp({ states: [{ name: 's', url: URL1 }], within: 'main', waitFor, reach: async () => null, run: fakeRun({ fail }) })).app.code;
expect(await codeOf('no browser available: run ./setup')).toBe('NO_BROWSER');
expect(await codeOf('aside repl did not run: timed out after 130000ms')).toBe('TIMEOUT');
expect(await codeOf('waitFor expression never became truthy: (() => …)() (waited 15000ms)', '.ready')).toBe('NOT_READY');
expect(await codeOf('browse goto failed: net::ERR_CONNECTION_REFUSED')).toBe('UNREACHABLE');
});
test('a stylesheet that did not load is ASSET_MISSING; a font that did not load is a note and the state stays measured', async () => {
const sheet = await lintApp({ states: [{ name: 's', url: URL1 }], within: 'main', reach: async () => null, run: fakeRun({ setup: { lostSheets: ['app.css'], lostFonts: [] } }) });
expect([sheet.app.code, sheet.app.states[0].detail]).toEqual(['ASSET_MISSING', 'stylesheets did not load: app.css']);
const font = await lintApp({ states: [{ name: 's', url: URL1 }], within: 'main', reach: async () => null, run: fakeRun({ setup: { lostSheets: [], lostFonts: ['Inter'] } }) });
expect([font.app.verdict, font.app.notes]).toEqual(['clean', ['s: fonts did not load (Inter); measured with a fallback face']]);
});
test('a probe answer the lint does not know is PROBE_ERROR, never a finding: a page rule, an unknown code, a malformed list', async () => {
for (const answer of [ok([f('low-contrast', 'p')]), ok([{ rule: 'row-crowding' }]), { status: 'incomplete', code: 'FINE', matched: 1, findings: [] }, [1, 2], 'x']) {
const r = await lintApp({ states: [{ name: 's', url: URL1 }], within: 'main', reach: async () => null, run: fakeRun({ measure: () => answer }) });
expect([JSON.stringify(answer), r.app.code, r.findings]).toEqual([JSON.stringify(answer), 'PROBE_ERROR', []]);
}
});
});
describe('app profile contract: the plan handed to the browser (T2, T25, T32)', () => {
test('one render per state: the app install, setup, one measure per viewport at its size; readiness waits on the section through shadow roots', async () => {
const specs: RenderSpec[] = [];
await lintApp({ states: [{ name: 's', url: URL1, expect: '.owner' }], within: 'qm-sharing', viewports: [{ width: 1280, height: 800 }, { width: 390, height: 844 }], reach: async () => null, run: fakeRun({}, specs) });
expect(specs).toHaveLength(1);
const [spec] = specs;
expect(spec.file).toBe(URL1);
expect(spec.steps.map((s) => (s.kind === 'eval' ? [s.width, s.height, s.expression.includes('P.setup(P)') ? 'setup' : s.expression.includes('P.measure(P') ? 'measure' : 'install'] : s.kind))).toEqual([[1280, 800, 'install'], [1280, 800, 'setup'], [1280, 800, 'measure'], [390, 844, 'measure']]);
const first = spec.steps[0];
expect(first.kind === 'eval' && first.expression).toBe(appInstallScript());
const measure = spec.steps[2];
expect(measure.kind === 'eval' && measure.expression).toContain('"within":"qm-sharing","expect":".owner","explicitWait":false,"readyMs":5000');
expect(spec.waitFor?.selector).toBeUndefined();
expect(spec.waitFor?.timeoutMs).toBe(16_000);
expect(spec.waitFor?.expression).toContain('"qm-sharing"');
expect(spec.waitFor?.expression).toContain('shadowRoot');
expect(spec.waitFor?.expression).toContain('> 15000');
const explicit: RenderSpec[] = [];
await lintApp({ states: [{ name: 's', url: URL1 }], within: 'main', waitFor: '.loaded', reach: async () => null, run: fakeRun({}, explicit) });
expect(explicit[0].waitFor?.timeoutMs).toBe(15_000);
expect(explicit[0].waitFor?.expression).not.toContain('> 15000');
expect(explicit[0].steps[2].kind === 'eval' && explicit[0].steps[2].expression).toContain('"explicitWait":true');
});
test('the scripted variants build the same Sharing section the static replica holds', () => {
const sandbox: { SHARING_HTML?: string } = {};
new Function('window', fs.readFileSync(path.join(ROOT, 'test/fixtures/ui-spacing/sharing.js'), 'utf8'))(sandbox);
expect(sandbox.SHARING_HTML!.length).toBeGreaterThan(1000);
expect(fs.readFileSync(path.join(ROOT, 'test/fixtures/ui-spacing/replica.html'), 'utf8')).toContain(sandbox.SHARING_HTML!);
});
test('the page probes are untouched: the page install script carries no app probe, and the app install no page probe', () => {
expect(installScript()).not.toContain('__gstackLintApp');
expect(appInstallScript()).not.toContain('__gstackLint =');
expect(appInstallScript()).toContain('window.__gstackLintApp = { H: (');
});
});
describe('app profile contract: findings, redaction and traces (T22, T29, T34, T19)', () => {
test('findings keep state, viewport, locator, measured px and threshold whole; distinct rows and viewports are never merged', async () => {
const long = 'qm-panel >>> ' + Array.from({ length: 12 }, (_, i) => 'div.level-' + i).join(' > ') + ' > div.row:nth-of-type(2) "Deploy key Read-only access to the repo"';
const run = fakeRun({ measure: () => ok([f('row-crowding', long), f('row-crowding', long.replace('nth-of-type(2)', 'nth-of-type(3)'))]) });
const r = await lintApp({ states: [{ name: 'owner', url: URL1 }], within: 'main', reach: async () => null, run });
const rows = r.findings.filter((x) => x.rule === 'row-crowding');
expect(rows).toHaveLength(4);
expect(rows[0].app).toEqual({ state: 'owner', viewport: '1440x900', locator: long, measured: 4, threshold: 8 });
expect(rows[0].evidence).toBe('[owner 1440px] a 2-line row leaves 4px between its text and its top edge (under 8px) at ' + long);
expect(new Set(rows.map((x) => x.app!.viewport + x.app!.locator)).size).toBe(4);
const dup: Finding = { ...rows[0] };
expect(finalize([rows[0], dup])).toHaveLength(1);
});
test('credentials in a URL never reach text, JSON, stderr or a trace; navigation and the reachability check use the real URL', async () => {
const userinfo = ['dana', 'PWSENTINEL'].join(':') + '@';
const secret = 'http://' + userinfo + '127.0.0.1:5300/settings?as=owner&token=TOKSENTINEL&sig=SIGSENTINEL&session_id=SESSENTINEL';
const reached: string[] = [];
const specs: RenderSpec[] = [];
const run = fakeRun({ measure: (w) => (w === 375 ? { error: 'Error: boom at ' + secret, stack: 'Error: boom\n at ' + secret + ':1:1' } : ok([f('row-crowding', 'div.row "x"')])) }, specs);
const traceDir = tmpDir();
for (const json of [false, true]) {
const out: string[] = [];
const err: string[] = [];
await appMain(args({ urls: [secret], json, verbose: true }), { out: (s) => out.push(s), err: (s) => err.push(s) }, { run, traceDir, reach: async (u) => { reached.push(u); return null; } });
const all = out.join('\n') + err.join('\n') + fs.readdirSync(traceDir).map((t) => fs.readFileSync(path.join(traceDir, t), 'utf8')).join('');
for (const s of ['PWSENTINEL', 'TOKSENTINEL', 'SIGSENTINEL', 'SESSENTINEL', 'dana:']) expect([json, s, all.includes(s)]).toEqual([json, s, false]);
expect(all).toContain('token=REDACTED');
}
expect(specs.map((s) => s.file)).toEqual([secret, secret]);
expect(reached).toEqual([secret, secret]);
expect(redactUrl('https://' + ['u', 'p'].join(':') + '@h.test/a?code=1&next=/b')).toBe('https://h.test/a?code=REDACTED&next=%2Fb');
expect(redactText('see http://h.test/?jwt=abc and https://k.test/x?api_key=1')).toBe('see http://h.test/?jwt=REDACTED and https://k.test/x?api_key=REDACTED');
expect(stateNameOf('http://h.test/p?as=owner&password=x')).toBe('/p?as=owner&password=REDACTED');
expect(redactUrl('http://127.0.0.1:5300/cb#access_token=FRAGSENTINEL&state=x')).toBe('http://127.0.0.1:5300/cb#access_token=REDACTED&state=x');
expect(redactText('at http://h.test/#id_token=FRAGSENTINEL')).toBe('at http://h.test/#id_token=REDACTED');
expect(redactUrl('http://h.test/p#rows')).toBe('http://h.test/p#rows');
const hashed = await app({ urls: ['http://127.0.0.1:5300/cb#access_token=FRAGSENTINEL'], json: true }, { run: fakeRun({ measure: () => ok([f('row-crowding', 'div.row "x"')]) }) });
expect(hashed.out + hashed.err).not.toContain('FRAGSENTINEL');
});
test('two --url states that share a redacted name stay two states; the same URL twice is DUPLICATE_STATE', async () => {
const seen: string[] = [];
const two = await app({ urls: ['http://127.0.0.1:5300/p?session=owner', 'http://127.0.0.1:5300/p?session=viewer'] }, { run: async (spec) => { seen.push(spec.file); return fakeRun({})(spec); } });
expect(two.code).toBe(0);
expect(seen).toEqual(['http://127.0.0.1:5300/p?session=owner', 'http://127.0.0.1:5300/p?session=viewer']);
expect(two.out).toMatch(/^LINT_APP: verdict=clean code=OK states=2\/2 /m);
expect(two.out).toContain('state=/p?session=REDACTED~2 viewport=1440x900');
const literal = await app({ urls: ['http://127.0.0.1:5300/p', 'http://127.0.0.1:5300/p#x', 'http://127.0.0.1:5300/p~2'] }, { run: fakeRun({}) });
expect(literal.out).toMatch(/^LINT_APP: verdict=clean code=OK states=3\/3 /m);
expect([...new Set(literal.out.split('\n').filter((l) => l.startsWith('LINT_APP_STATE:')).map((l) => l.split(' ')[1]))]).toEqual(['state=/p', 'state=/p~2', 'state=/p~2~2']);
const dup = await app({ urls: [URL1, URL1] }, { run: neverRun });
expect([dup.code, dup.err]).toEqual([3, 'gstack-design-lint: DUPLICATE_STATE: --url ' + URL1 + ' is given twice | fix: measure each URL once']);
});
test('a probe that throws writes a 0600 trace under a private directory, names it, and prints its stack only with --verbose', async () => {
const traceDir = path.join(tmpDir(), 'traces');
const run = fakeRun({ measure: (w) => (w === 375 ? { error: 'TypeError: rows is undefined', stack: 'TypeError: rows is undefined\n at glAppRows (eval:1:2)' } : ok()) });
const quiet = await app({}, { run, traceDir });
expect(quiet.code).toBe(4);
const [trace] = fs.readdirSync(traceDir).map((t) => path.join(traceDir, t));
if (process.platform !== 'win32') {
expect(fs.statSync(trace).mode & 0o777).toBe(0o600);
expect(fs.statSync(traceDir).mode & 0o777).toBe(0o700);
}
expect(JSON.parse(fs.readFileSync(trace, 'utf8'))).toEqual({ probe: 'measure', state: '/settings?as=owner', viewport: '375x812', stack: 'TypeError: rows is undefined\n at glAppRows (eval:1:2)', url: URL1 });
expect(quiet.out).toContain('code=PROBE_ERROR matched=0 the measure probe failed: TypeError: rows is undefined (trace: ' + trace + ')');
expect(quiet.err).toBe('gstack-design-lint: PROBE_ERROR: the measure probe threw at /settings?as=owner 375x812; trace ' + trace);
const loud = await app({ verbose: true }, { run, traceDir: path.join(tmpDir(), 'traces') });
expect(loud.err).toContain('at glAppRows (eval:1:2)');
const blocker = path.join(tmpDir(), 'not-a-dir');
fs.writeFileSync(blocker, '');
const unwritable = await app({ verbose: true }, { run, traceDir: path.join(blocker, 'traces') });
expect(unwritable.code).toBe(4);
expect(unwritable.out).toMatch(/^LINT_APP: verdict=incomplete code=PROBE_ERROR /m);
expect(unwritable.err).toContain('gstack-design-lint: PROBE_ERROR: the measure probe threw at /settings?as=owner 375x812; no trace written (');
});
test('a section selector the browser refuses never holds readiness: the soft wait goes on to the probe, an explicit --wait-for stays not ready', async () => {
const refuses = { querySelector: () => { throw new SyntaxError("'main:foo' is not a valid selector"); }, querySelectorAll: () => [] };
const evaluate = (expression: string) => new Function('document', 'window', 'return ' + expression)(refuses, {});
expect(evaluate(composedWaitExpression('main:foo', 15_000))).toBe(true);
expect(evaluate(composedWaitExpression('main:foo', null))).toBe(false);
const late = await lintApp({ states: [{ name: 's', url: URL1 }], within: 'main', reach: async () => null, run: fakeRun({ fail: 'waitFor expression never became truthy: x (waited 16000ms)' }) });
expect([late.app.code, late.app.states[0].detail]).toEqual(['NOT_READY', 'the section main did not match within 15s']);
});
});
describe('app profile contract: no browser and the scope guard (T15, T20, T33)', () => {
const noBrowser = () => ({ engine: null, probe: { ok: false as const, reason: 'NEEDS_ASIDE' as const, detail: 'test' }, error: 'no browser available: run ./setup (NEEDS_ASIDE: test)' });
test('--url with no browser under page scope: RENDER_UNAVAILABLE names the redacted URL; --render over files keeps today\'s skip', async () => {
const url = 'http://127.0.0.1:5300/p?token=SECRET';
const r = await lint([], { urls: [url], engine: noBrowser, warn: () => {} });
expect(r.summary.render).toBe('skipped: no browser available: run ./setup (NEEDS_ASIDE: test)');
expect(renderUnavailable([url], r.summary.render)).toBe('gstack-design-lint: RENDER_UNAVAILABLE: nothing was measured at http://127.0.0.1:5300/p?token=REDACTED (no browser available: run ./setup (NEEDS_ASIDE: test)) | fix: gstack-browser-ensure, then rerun');
const files = await lint([path.join(ROOT, 'test/fixtures/design-lint/clean')], { cwd: ROOT, render: true, engine: noBrowser, warn: () => {} });
expect(renderUnavailable([], files.summary.render)).toBeNull();
expect(files.exitCode).toBe(0);
expect(renderUnavailable([url], 'ran')).toBeNull();
});
test('--scope app with no browser: every state x viewport NO_BROWSER, exit 4 and RENDER_UNAVAILABLE on stderr', async () => {
const r = await app({}, { engine: noBrowser });
expect(r.code).toBe(4);
expect(r.err).toBe('gstack-design-lint: RENDER_UNAVAILABLE: nothing was measured at ' + URL1 + ' (no browser available: run ./setup (NEEDS_ASIDE: test)) | fix: gstack-browser-ensure, then rerun');
expect(r.out).toMatch(/LINT_SUMMARY: blocks=0 ask=0 waived=0 polish=0 render=skipped: no browser available/);
expect(r.out).toMatch(/^LINT_APP: verdict=incomplete code=NO_BROWSER states=0\/1 /m);
});
test('app rules are app-only: tier ask, render, never reported by a page or component reporter; nothing else carries the marker', () => {
expect(APP_IDS.sort()).toEqual(['copy-flush-under-list', 'note-in-row-weight', 'row-crowding', 'section-overflow', 'small-hit-area']);
for (const id of APP_IDS) expect([id, RULES[id].tier, RULES[id].render]).toEqual([id, 'ask', true]);
for (const scope of ['page', 'component'] as const) expect(new Reporter(scope).report('row-crowding', 'x.html', 1, 'e')).toBeNull();
expect(new Reporter('app').report('row-crowding', 'x.html', 1, 'e')?.rule).toBe('row-crowding');
});
});
+247
View File
@@ -0,0 +1,247 @@
/**
* The app profile measured (gstack-design-lint --scope app, GST-1741 PR 1):
* the qm#3186 replica and its variants (other markup, open, slotted, nested and
* closed shadow roots, rows that load late), the fixed copy, the negative
* controls, the threshold boundaries at +-0.5px, and the false-positive
* corpus. Fixtures are served from a loopback server, so every state goes
* through the URL path the CLI takes. Skipped with a named reason when no
* browser is available, except in CI, where a missing browser fails.
*/
import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { pickEngine, resolveBrowseBin, type EngineChoice } from '../lib/aside-render';
import { lintApp, type AppOptions, type AppResult } from '../lib/design-lint/render-app';
import { asideAvailable } from './helpers/aside-available';
import { browserAvailable, NO_BROWSER_REASON } from './helpers/browser-available';
const ROOT = path.resolve(import.meta.dir, '..');
const FIX = 'test/fixtures/ui-spacing';
const hasBrowser = browserAvailable();
if (!hasBrowser && process.env.CI) throw new Error('design-lint-app-profile: CI needs a browser for the app profile tier: ' + NO_BROWSER_REASON);
if (!hasBrowser) process.stderr.write('design-lint-app-profile: SKIPPED — ' + NO_BROWSER_REASON + '\n');
const engine = (): EngineChoice => {
if (asideAvailable()) return pickEngine(true);
const bin = resolveBrowseBin();
return bin ? { engine: 'browse', bin } : { engine: null, probe: { ok: false, reason: 'NEEDS_ASIDE', detail: NO_BROWSER_REASON }, error: NO_BROWSER_REASON };
};
const FOUR = ['copy-flush-under-list', 'note-in-row-weight', 'row-crowding', 'small-hit-area'];
const gen = fs.mkdtempSync(path.join(os.tmpdir(), 'design-lint-app-gen-'));
let base = '';
let server: ReturnType<typeof Bun.serve> | null = null;
beforeAll(() => {
if (!hasBrowser) return;
server = Bun.serve({
hostname: '127.0.0.1', port: 0,
fetch(req) {
const rel = decodeURIComponent(new URL(req.url).pathname);
const file = rel.startsWith('/gen/') ? path.join(gen, rel.slice(5)) : path.join(ROOT, rel);
const inside = file.startsWith(gen + path.sep) || file.startsWith(ROOT + path.sep);
if (!inside || !fs.existsSync(file) || !fs.statSync(file).isFile()) return new Response('not found', { status: 404 });
return new Response(Bun.file(file));
},
});
base = 'http://127.0.0.1:' + server.port;
});
afterAll(() => { server?.stop(true); fs.rmSync(gen, { recursive: true, force: true }); });
async function measure(page: string, within: string, more: Partial<AppOptions> = {}): Promise<AppResult & { ids: string[] }> {
const url = page.startsWith('/') ? base + page : base + '/' + FIX + '/' + page;
const r = await lintApp({ states: [{ name: page, url }], within, engine, traceDir: gen, ...more });
return { ...r, ids: [...new Set(r.findings.filter((f) => f.grade !== 'waived').map((f) => f.rule))].sort() };
}
const genPage = (name: string, css: string, body: string): string => {
fs.writeFileSync(path.join(gen, name), '<!doctype html><html lang="en"><head><meta charset="utf-8"><title>' + name + '</title><style>* { box-sizing: border-box } body { margin: 0; font: 14px/20px system-ui, sans-serif; color: #1d2433 } main { max-width: 640px; margin: 0 auto; padding: 24px 16px } ' + css + '</style></head><body><main><section class="probe">' + body + '</section></main></body></html>');
return '/gen/' + name;
};
describe.skipIf(!hasBrowser)('app profile: the replica and its variants', () => {
test('the replica reports exactly row-crowding, copy-flush-under-list, note-in-row-weight and small-hit-area; open, exit 1', async () => {
const r = await measure('replica.html', '.sharing');
expect(r.ids).toEqual(FOUR);
expect([r.app.verdict, r.exitCode, r.app.matched, r.app.states.map((s) => s.status)]).toEqual(['open', 1, 1, ['measured', 'measured']]);
const crowd = r.findings.find((f) => f.rule === 'row-crowding')!;
expect(crowd.evidence).toMatch(/^\[replica\.html 1440px\] a 2-line row leaves 4px between its text and its top edge \(under 8px\) at .*div\.rows:nth-of-type\(1\) > div\.row:nth-of-type\(1\) "Deploy key Read-only access to the repos"$/);
expect(crowd.app).toMatchObject({ state: 'replica.html', viewport: '1440x900', measured: 4, threshold: 8 });
const hit = r.findings.find((f) => f.rule === 'small-hit-area')!;
expect([hit.app!.viewport, hit.app!.measured, hit.app!.threshold, hit.app!.locator]).toEqual(['375x812', 18, 32, expect.stringContaining('button.x "Remove')]);
expect(r.findings.filter((f) => f.rule === 'small-hit-area').every((f) => f.app!.viewport === '375x812')).toBe(true);
expect(r.findings.filter((f) => f.rule === 'copy-flush-under-list').map((f) => f.app!.measured)).toEqual([4, 4, 4, 4, 4, 4]);
expect(new Set(r.findings.map((f) => f.app!.viewport + f.app!.locator + f.rule)).size).toBe(r.findings.length);
}, 60_000);
test('the fixed copy is clean, exit 0', async () => {
const r = await measure('fixed.html', '.sharing');
expect([r.findings, r.app.verdict, r.exitCode]).toEqual([[], 'clean', 0]);
}, 60_000);
test("the replica's defects in other markup (ul/li, a grid per row, renamed classes, no test hooks) give the same four ids", async () => {
expect(fs.readFileSync(path.join(ROOT, FIX, 'structural.html'), 'utf8')).not.toContain('data-testid');
expect((await measure('structural.html', '.settings-sharing')).ids).toEqual(FOUR);
}, 60_000);
test('open, slotted and nested shadow roots match the light DOM; a closed one is incomplete CLOSED_SHADOW, exit 4', async () => {
for (const [page, within] of [['shadow-open.html', 'qm-sharing'], ['shadow-slotted.html', 'qm-card'], ['nested-shadow.html', '.sharing']]) {
const r = await measure(page, within);
expect([page, r.ids, r.app.verdict]).toEqual([page, FOUR, 'open']);
}
const open = await measure('shadow-open.html', 'qm-sharing');
expect(open.findings[0].app!.locator).toStartWith('qm-sharing >>> ');
const closed = await measure('shadow-closed.html', 'qm-sharing');
expect([closed.app.verdict, closed.app.code, closed.exitCode, closed.findings]).toEqual(['incomplete', 'CLOSED_SHADOW', 4, []]);
}, 120_000);
test('rows that load 800ms late are measured once ready and match the replica; rows that never load are NOT_READY', async () => {
const late = await measure('async.html', '.sharing');
expect([late.ids, late.app.verdict]).toEqual([FOUR, 'open']);
const replica = await measure('replica.html', '.sharing');
expect(late.findings.map((f) => f.rule + f.app!.viewport + f.app!.measured)).toEqual(replica.findings.map((f) => f.rule + f.app!.viewport + f.app!.measured));
const never = await measure('never-ready.html', '.sharing', { readyMs: 1_500 });
expect([never.app.code, never.exitCode, never.app.states[0].detail]).toEqual(['NOT_READY', 4, 'no list of two or more rows appeared inside .sharing within 2s']);
}, 90_000);
test('a section nothing matches is WITHIN_NO_MATCH naming the roots searched; a state whose expect selector is absent is STATE_MISMATCH', async () => {
const none = await measure('shadow-open.html', '.no-such-section', { sectionWaitMs: 1_000, readyMs: 500 });
expect([none.app.code, none.app.matched, none.app.states[0].detail]).toEqual(['WITHIN_NO_MATCH', 0, 'nothing matched .no-such-section in the document or its 1 open shadow roots']);
const url = base + '/' + FIX + '/replica.html?as=lastowner';
const wrong = await lintApp({ states: [{ name: 'lastowner', url, expect: '.only-owner-note' }], within: '.sharing', engine, readyMs: 1_000 });
expect([wrong.app.code, wrong.exitCode]).toEqual(['STATE_MISMATCH', 4]);
const right = await lintApp({ states: [{ name: 'owner', url, expect: '.sharing .row' }], within: '.sharing', engine });
expect(right.app.verdict).toBe('open');
}, 90_000);
});
describe.skipIf(!hasBrowser)('app profile: what must not fire', () => {
test('plain role labels beside names are never notes; a note louder than its name is', async () => {
const reader = await measure('reader.html', '.members');
expect(reader.findings.filter((f) => f.rule === 'note-in-row-weight')).toEqual([]);
expect(reader.app.verdict).toBe('clean');
expect((await measure('larger-note.html', '.integrations')).ids).toEqual(['note-in-row-weight']);
}, 60_000);
test('a list of one row is not a list; a three-line note on a phone only is not note-in-row-weight', async () => {
const one = await measure('one-row.html', '.credentials', { waitFor: '.credentials' });
expect([one.findings, one.app.verdict]).toEqual([[], 'clean']);
const phone = await measure('mobile-note.html', '.channels');
expect([phone.findings, phone.app.verdict]).toEqual([[], 'clean']);
}, 60_000);
test('a defect outside --within is not reported; the same section measured directly is', async () => {
const page = genPage('outside.html', '.list { border: 1px solid #dfe3ea } .row { padding: 3px 12px } .row + .row { border-top: 1px solid #dfe3ea } .row p { margin: 0 } .row p + p { color: #6b7385; font-size: 13px } .ok .row { padding: 10px 12px } .ok + .bad { margin-top: 24px }',
'<div class="ok"><div class="list"><div class="row"><p>Deploy key</p><p>Read-only access</p></div><div class="row"><p>Registry token</p><p>Pushes images</p></div></div></div>'
+ '<div class="bad"><div class="list"><div class="row"><p>Deploy key</p><p>Read-only access</p></div><div class="row"><p>Registry token</p><p>Pushes images</p></div></div></div>');
const inside = await measure(page, '.ok');
expect([inside.findings, inside.app.verdict]).toEqual([[], 'clean']);
expect((await measure(page, '.bad')).ids).toEqual(['row-crowding']);
}, 60_000);
test('small-hit-area counts an input\'s label toward its box and skips a link inside a run of text; a bare 16px checkbox fires', async () => {
const page = genPage('targets.html', '.list { border: 1px solid #dfe3ea } .row { display: flex; gap: 8px; align-items: center; padding: 10px 12px } .row + .row { border-top: 1px solid #dfe3ea } .row p { margin: 0 } label.big { display: flex; align-items: center; gap: 8px; min-height: 40px } input { width: 16px; height: 16px; margin: 0 }',
'<div class="list"><div class="row"><label class="big"><input type="checkbox"> Notify me</label></div>'
+ '<div class="row"><p>Read the <a href="#policy">sharing policy</a> before adding people.</p></div>'
+ '<div class="row"><p>Mute</p><input type="checkbox" aria-label="Mute"></div></div>');
const r = await measure(page, '.probe');
expect(r.findings.map((f) => [f.rule, f.app!.viewport, f.app!.locator])).toEqual([['small-hit-area', '375x812', expect.stringMatching(/input "Mute"$/)]]);
}, 60_000);
test('negative controls with trigger bait: a nav list, a table, a card grid and a list spaced right give zero app findings', async () => {
const r = await measure('negatives.html', '.negatives');
expect([r.findings, r.app.verdict, r.exitCode]).toEqual([[], 'clean', 0]);
}, 60_000);
});
describe.skipIf(!hasBrowser)('app profile: thresholds at +-0.5px', () => {
const rows = (pad: string, css = '') => genPage('rows-' + pad.replace(/\W/g, '_') + css.length + '.html',
'.list { border: 1px solid #dfe3ea } .row + .row { border-top: 1px solid #dfe3ea } .row { padding: ' + pad + ' } .row p { margin: 0 } .help { margin: 0 } button { width: 40px; height: 40px } ' + css,
'<div class="list"><div class="row"><p>Deploy key</p><p>Read-only access to the repository</p></div><div class="row"><p>Registry token</p><p>Pushes release images</p></div></div><p class="help" style="margin-top: 20px">Credentials are encrypted at rest.</p>');
const at = async (page: string, rule: string) => (await measure(page, '.probe')).findings.filter((f) => f.rule === rule).map((f) => f.app!.measured);
test('row-crowding: 7.5px to a drawn edge fires, 8.5px does not', async () => {
expect([...new Set(await at(rows('7.5px 12px'), 'row-crowding'))]).toEqual([7.5]);
expect(await at(rows('8.5px 12px'), 'row-crowding')).toEqual([]);
}, 60_000);
test('row-crowding: layout subpixel noise never fires (7.97px does not), 7.9px does', async () => {
expect(await at(rows('7.97px 12px'), 'row-crowding')).toEqual([]);
expect((await at(rows('7.9px 12px'), 'row-crowding')).length).toBeGreaterThan(0);
}, 60_000);
test('row-crowding reads an unboxed list of laid-out rows (avatar, text and control side by side); an unboxed list of prose items stays prose', async () => {
const people = genPage('people-unboxed.html',
'.row { display: flex; align-items: center; gap: 10px; padding: 4px 0 } .av { width: 28px; height: 28px; border-radius: 14px; background: #dbe7f0; flex: none } .text { flex: 1 } .text div { margin: 0 } button { width: 80px; height: 32px; flex: none } .help { margin: 24px 0 0 }',
'<div class="list"><div class="row"><span class="av"></span><div class="text"><div>Dana Whitfield</div><div>Can\'t use personas until an admin turns them on</div></div><button>Can chat</button></div><div class="row"><span class="av"></span><div class="text"><div>Design team</div><div>Personas is off for the organization</div></div><button>Can chat</button></div></div><p class="help">Owners change who has access.</p>');
const crowded = await at(people, 'row-crowding');
expect(crowded.length).toBeGreaterThan(0);
expect(crowded.every((m) => m < 16)).toBe(true);
const prose = genPage('prose-unboxed.html', 'ul { margin: 0; padding-left: 20px } li { margin: 0 0 4px } .help { margin: 24px 0 0 }',
'<ul><li>Read the docs before you start, then run the setup script once on each machine you use</li><li>Keep the token out of the repository and rotate it when someone leaves the team</li></ul><p class="help">Questions go to the platform channel.</p>');
expect(await at(prose, 'row-crowding')).toEqual([]);
}, 60_000);
test('row-crowding: 15.5px from a two-line row\'s text to the next row\'s fires, 16.5px does not', async () => {
const pair = (next: number) => genPage('next-' + next + '.html',
'.list { border: 1px solid #dfe3ea } .row { padding: 8px 12px } .row p { margin: 0 } .row + .row { padding-top: ' + next + 'px }',
'<div class="list"><div class="row"><p>Deploy key</p><p>Read-only access to the repository</p></div><div class="row"><p>Registry token</p></div></div>');
expect(await at(pair(7.5), 'row-crowding')).toEqual([15.5, 15.5]);
expect(await at(pair(8.5), 'row-crowding')).toEqual([]);
}, 60_000);
test('copy-flush-under-list: 11.5px fires, 12.5px does not', async () => {
const help = (gap: number) => genPage('help-' + gap + '.html',
'.list { border: 1px solid #dfe3ea } .row { padding: 10px 12px } .row + .row { border-top: 1px solid #dfe3ea } .row p { margin: 0 } .help { margin: ' + gap + 'px 0 0 }',
'<div class="list"><div class="row"><p>Deploy key</p></div><div class="row"><p>Registry token</p></div></div><p class="help">Credentials are encrypted at rest.</p>');
expect(await at(help(11.5), 'copy-flush-under-list')).toEqual([11.5, 11.5]);
expect(await at(help(12.5), 'copy-flush-under-list')).toEqual([]);
}, 60_000);
test('small-hit-area at 375 only: 31.5px fires, 32.5px does not', async () => {
const hit = (side: number) => genPage('hit-' + side + '.html',
'.list { border: 1px solid #dfe3ea } .row { display: flex; align-items: center; justify-content: space-between; padding: 10px 12px } .row + .row { border-top: 1px solid #dfe3ea } .row p { margin: 0 } button { width: ' + side + 'px; height: 40px; padding: 0 }',
'<div class="list"><div class="row"><p>Deploy key</p><button aria-label="Remove">×</button></div><div class="row"><p>Registry token</p><button aria-label="Remove">×</button></div></div><p class="help" style="margin-top: 16px">Credentials are encrypted at rest.</p>');
const small = (await measure(hit(31.5), '.probe')).findings;
expect(small.map((f) => [f.rule, f.app!.viewport, f.app!.measured])).toEqual([['small-hit-area', '375x812', 31.5], ['small-hit-area', '375x812', 31.5]]);
expect(await at(hit(32.5), 'small-hit-area')).toEqual([]);
}, 60_000);
});
describe.skipIf(!hasBrowser)('app profile: the false-positive corpus (decision 6, T10)', () => {
test('zero app findings on all pages but one, and at most one on that page', async () => {
const corpus = JSON.parse(fs.readFileSync(path.join(ROOT, FIX, 'corpus.json'), 'utf8')) as { pages: Array<{ path: string; within: string; waitFor: string }> };
expect(corpus.pages.length).toBeGreaterThanOrEqual(10);
const counts: Array<[string, number, string]> = [];
for (const p of corpus.pages) {
const r = await measure('/' + p.path, p.within, { waitFor: p.waitFor });
counts.push([p.path, r.findings.length, r.app.verdict === 'incomplete' ? r.app.code : 'measured']);
}
expect(counts.filter(([, , status]) => status !== 'measured')).toEqual([]);
const withAny = counts.filter(([, n]) => n > 0);
expect(withAny.length).toBeLessThanOrEqual(1);
expect(withAny.every(([, n]) => n <= 1)).toBe(true);
}, 300_000);
});
describe.skipIf(!hasBrowser)('app profile: the CLI end to end', () => {
/** Asynchronous on purpose: the fixture server runs in this process, and a synchronous spawn would block it from answering. */
async function runBin(args: string[]): Promise<{ code: number; out: string }> {
const child = Bun.spawn([path.join(ROOT, 'bin', 'gstack-design-lint'), ...args], { cwd: ROOT, stdout: 'pipe', stderr: 'pipe', timeout: 120_000 });
const out = await new Response(child.stdout).text();
return { code: await child.exited, out };
}
test('the documented invocation prints the four ids, the LINT_APP line and exits 1; --json carries the app block', async () => {
const url = base + '/' + FIX + '/replica.html';
const text = await runBin(['--scope', 'app', '--url', url, '--within', '.sharing', '--viewport', '1440x900', '--viewport', '375x812']);
const out = text.out;
expect(text.code).toBe(1);
expect([...new Set(out.split('\n').filter((l) => l.startsWith('LINT: ')).map((l) => l.split(' ')[2]))].sort()).toEqual(FOUR);
expect(out.trim().split('\n').slice(-2)).toEqual([expect.stringMatching(/^LINT_SUMMARY: blocks=0 ask=25 waived=0 polish=0 render=ran$/), 'LINT_APP: verdict=open code=OPEN states=1/1 viewports=1440x900,375x812 within=.sharing matched=1 open=25 waived=0']);
const json = await runBin(['--scope', 'app', '--url', url, '--within', '.sharing', '--json']);
const doc = JSON.parse(json.out);
expect([json.code, doc.app.verdict, doc.app.states.length, doc.findings[0].app.threshold]).toEqual([1, 'open', 2, expect.any(Number)]);
}, 120_000);
});
+40
View File
@@ -0,0 +1,40 @@
/**
* The scope guard for GST-1741's app profile: --scope page and --scope
* component print the same bytes and exit with the same codes on the existing
* design-lint fixtures as bin/gstack-design-lint did on origin/main before the
* app profile (e58be61). The golden holds each case's exit code, line count
* and sha256; test/fixtures/ui-spacing/scope-guard-golden.json says how it was
* produced. Static tier only: the render tier's numbers depend on the browser.
*/
import { describe, expect, test } from 'bun:test';
import * as fs from 'node:fs';
import * as path from 'node:path';
const ROOT = path.resolve(import.meta.dir, '..');
const BIN = path.join(ROOT, 'bin', 'gstack-design-lint');
function runBin(args: string[]) {
const r = Bun.spawnSync([BIN, ...args], { cwd: ROOT, stdout: 'pipe', stderr: 'pipe', timeout: 60_000 });
return { code: r.exitCode, out: r.stdout.toString(), err: r.stderr.toString() };
}
describe.skipIf(process.platform === 'win32')('design-lint scope guard: page and component profiles unchanged by --scope app', () => {
test('page and component output and exit codes on the existing fixtures match origin/main byte for byte', () => {
const golden = JSON.parse(fs.readFileSync(path.join(ROOT, 'test/fixtures/ui-spacing/scope-guard-golden.json'), 'utf8')) as { cases: Array<{ args: string[]; code: number; sha256: string; lines: number }> };
expect(golden.cases.length).toBeGreaterThanOrEqual(10);
for (const c of golden.cases) {
const r = runBin(c.args);
expect([c.args.join(' '), r.code, r.out.split('\n').length - 1, new Bun.CryptoHasher('sha256').update(r.out).digest('hex')]).toEqual([c.args.join(' '), c.code, c.lines, c.sha256]);
}
});
test('the app rules never print under page or component scope, and --rules lists them as app rules at ask', () => {
const page = runBin(['test/fixtures/design-lint']);
const component = runBin(['--scope', 'component', 'test/fixtures/design-lint/tailwind-fail']);
for (const id of ['row-crowding', 'copy-flush-under-list', 'note-in-row-weight', 'small-hit-area', 'section-overflow']) {
expect(page.out).not.toContain(' ' + id + ' ');
expect(component.out).not.toContain(' ' + id + ' ');
expect(runBin(['--rules']).out).toMatch(new RegExp('^ask\\s+' + id + '\\s+app\\s', 'm'));
}
});
});
+2 -1
View File
@@ -216,8 +216,9 @@ describe('design-lint: the output contract', () => {
expect(r.out).toMatch(/^LINT: blocks render http:\/\/127\.0\.0\.1:9\/:1 could not reach http:\/\/127\.0\.0\.1:9\/: .+ \| fix: .+$/m);
expect(r.out).toMatch(/^LINT_SUMMARY: blocks=1 ask=0 waived=0 polish=0 render=ran$/m);
} else {
expect(r.code).toBe(0);
expect(r.code).toBe(4);
expect(r.out).toMatch(/^LINT_SUMMARY: blocks=0 ask=0 waived=0 polish=0 render=skipped: /m);
expect(r.err).toMatch(/^gstack-design-lint: RENDER_UNAVAILABLE: nothing was measured at http:\/\/127\.0\.0\.1:9\/ \(no browser available.+\) \| fix: gstack-browser-ensure, then rerun$/m);
}
});
});
+4
View File
@@ -13,6 +13,10 @@
{ "file": "lib/design-lint/render-probe.ts", "movedFrom": "(new: the bundled design lint, GSTA-105; ported from hallmark sloplint-render.mjs)" },
{ "file": "lib/design-lint/render-probe-space.ts", "movedFrom": "(new: the bundled design lint, GSTA-105; ported from hallmark sloplint-render.mjs)" },
{ "file": "lib/design-lint/render-probe-quality.ts", "movedFrom": "(new: the bundled design lint, GSTA-105; ported from hallmark sloplint-render.mjs)" },
{ "file": "lib/design-lint/render-app.ts", "movedFrom": "(new: the design lint's app profile scheduler, GST-1741)" },
{ "file": "lib/design-lint/render-probe-app.ts", "movedFrom": "(new: the design lint's app profile probes, GST-1741)" },
{ "file": "lib/design-lint/app-input.ts", "movedFrom": "(new: the design lint's app profile inputs and URL redaction, GST-1741)" },
{ "file": "lib/design-lint/app-cli.ts", "movedFrom": "(new: the design lint's --scope app CLI, GST-1741)" },
{ "file": "lib/design-lint/render-png.ts", "movedFrom": "(new: the bundled design lint, GSTA-105; ported from hallmark sloplint-render.mjs)" },
{ "file": "lib/design-lint/rules/assets.ts", "movedFrom": "(new: the bundled design lint, GSTA-105; ported from hallmark sloplint.mjs)" },
{ "file": "lib/design-lint/rules/chrome-copy.ts", "movedFrom": "(new: the bundled design lint, GSTA-105; ported from hallmark sloplint.mjs)" },
+16
View File
@@ -0,0 +1,16 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Project settings: rows load late</title>
<link rel="stylesheet" href="replica.css">
<script src="sharing.js"></script>
</head>
<body>
<main>
<div id="mount"><section class="panel sharing"><h2>Sharing</h2><p>Loading…</p></section></div>
<script>setTimeout(() => { document.getElementById('mount').innerHTML = window.SHARING_HTML; }, 800);</script>
</main>
</body>
</html>
+95
View File
@@ -0,0 +1,95 @@
{
"about": "The app profile's false-positive corpus (GST-1741 decision 6): gstack-owned pages unrelated to the qm#3186 shape, each measured under --scope app at 1440x900 and 375x812 with the --within and --wait-for given. Bar (test/design-lint-app-profile.test.ts): zero app findings on all but one page, and at most one on that page. The pages carry no lists of the replica's kind, so --wait-for stands in for the list readiness wait.",
"pages": [
{
"path": "browse/src/welcome.html",
"within": "body",
"waitFor": "body",
"what": "the browse welcome page"
},
{
"path": "extension/popup.html",
"within": "body",
"waitFor": "body",
"what": "the extension popup"
},
{
"path": "browse/test/fixtures/forms.html",
"within": "body",
"waitFor": "body",
"what": "a login and profile form"
},
{
"path": "browse/test/fixtures/qa-eval-checkout.html",
"within": "body",
"waitFor": "body",
"what": "a checkout form"
},
{
"path": "browse/test/fixtures/qa-eval-spa.html",
"within": "body",
"waitFor": "body",
"what": "a single-page app shell"
},
{
"path": "browse/test/fixtures/states.html",
"within": "body",
"waitFor": "body",
"what": "controls in their states"
},
{
"path": "browse/test/fixtures/snapshot.html",
"within": "body",
"waitFor": "body",
"what": "a mixed app page"
},
{
"path": "browse/test/fixtures/responsive.html",
"within": "body",
"waitFor": "body",
"what": "a responsive layout"
},
{
"path": "browse/test/fixtures/basic.html",
"within": "body",
"waitFor": "body",
"what": "a basic page"
},
{
"path": "test/fixtures/design-lint/clean/index.html",
"within": "body",
"waitFor": "body",
"what": "the design lint's clean render fixture"
},
{
"path": "test/fixtures/design-lint/tailwind-pass/index.html",
"within": "body",
"waitFor": "body",
"what": "a Tailwind page with a bordered list"
},
{
"path": "test/fixtures/design-lint/eyebrow-pass/index.html",
"within": "body",
"waitFor": "body",
"what": "a docs page with a step list"
}
],
"excluded": [
{
"path": "browse/test/fixtures/qa-eval.html",
"why": "a QA fixture with planted defects; its stats line really overflows its box (section-overflow is a true positive)"
},
{
"path": "browse/test/fixtures/dropdown.html",
"why": "its search input is 320px inside a 300px container: a real overflow"
},
{
"path": "test/fixtures/design-motion-finalized.html",
"why": "absolutely positioned cards run 233px past a 375px screen: a real overflow"
},
{
"path": "extension/sidepanel.html",
"why": "links xterm.css, which ./setup builds and the repository does not hold, so it is incomplete (ASSET_MISSING), not measurable"
}
]
}
+54
View File
@@ -0,0 +1,54 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Project settings: the replica with its spacing fixed</title>
<style>
:root { --ink: #1d2433; --muted: #6b7385; --line: #dfe3ea; --paper: #ffffff; }
* { box-sizing: border-box; }
body { margin: 0; font: 14px/20px system-ui, -apple-system, "Segoe UI", sans-serif; color: var(--ink); background: #f5f6f8; }
main { max-width: 720px; margin: 0 auto; padding: 24px 16px; }
.panel { background: var(--paper); border: 1px solid var(--line); border-radius: 8px; padding: 16px 20px; }
.panel h2 { font-size: 18px; line-height: 24px; margin: 0 0 12px; }
.panel h3 { font-size: 15px; line-height: 20px; margin: 24px 0 8px; }
.rows { border: 1px solid var(--line); border-radius: 6px; }
.row { display: flex; align-items: center; gap: 12px; padding: 10px 12px; border-top: 1px solid var(--line); }
.row:first-child { border-top: 0; }
.row .text { flex: 1; min-width: 0; }
.row .name { font-weight: 600; }
.row .desc { color: var(--muted); font-size: 13px; }
.row .flag { display: flex; gap: 6px; align-items: center; color: #8a5a00; font-size: 13px; }
.row .flag svg { flex: none; width: 14px; height: 14px; }
.row .role { color: var(--muted); font-size: 13px; margin-left: 8px; }
.x { flex: none; width: 32px; height: 32px; margin: -7px -7px -7px 0; border: 0; padding: 0; background: transparent; color: var(--muted); font-size: 14px; line-height: 18px; cursor: pointer; }
.help { color: var(--muted); font-size: 13px; margin: 16px 0 0; }
</style>
</head>
<body>
<main>
<section class="panel sharing">
<h2>Sharing</h2>
<h3>Credentials</h3>
<div class="rows">
<div class="row"><div class="text"><div class="name">Deploy key</div><div class="desc">Read-only access to the repository, added by Dana on March 3</div></div><button class="x" aria-label="Remove Deploy key">×</button></div>
<div class="row"><div class="text"><div class="name">Registry token</div><div class="desc">Pushes release images, added by Omar on April 18</div></div><button class="x" aria-label="Remove Registry token">×</button></div>
</div>
<p class="help">Credentials are encrypted at rest and never shown again after you add them.</p>
<h3>Slack channels</h3>
<div class="rows">
<div class="row"><div class="text"><div class="name">#deploys</div><div class="flag"><svg viewBox="0 0 16 16" aria-hidden="true"><path d="M8 1 15 14H1z" fill="currentColor"/></svg><span>The bot is not in this channel yet, so posts will fail.</span></div></div><button class="x" aria-label="Remove #deploys">×</button></div>
<div class="row"><div class="text"><div class="name">#release-notes</div><div class="flag"><svg viewBox="0 0 16 16" aria-hidden="true"><path d="M8 1 15 14H1z" fill="currentColor"/></svg><span>Posting paused by a workspace admin.</span></div></div><button class="x" aria-label="Remove #release-notes">×</button></div>
</div>
<p class="help">Channels get a message when a deploy starts and when it finishes.</p>
<h3>Who has access</h3>
<div class="rows">
<div class="row"><div class="text"><span class="name">Dana Whitfield</span><span class="role">Owner</span></div><button class="x" aria-label="Remove Dana Whitfield">×</button></div>
<div class="row"><div class="text"><span class="name">Omar Haddad</span><span class="role">Editor</span></div><button class="x" aria-label="Remove Omar Haddad">×</button></div>
<div class="row"><div class="text"><span class="name">Lena Park</span><span class="role">Reader</span></div><button class="x" aria-label="Remove Lena Park">×</button></div>
</div>
<p class="help">Readers see deploys and logs; editors can change settings.</p>
</section>
</main>
</body>
</html>
+37
View File
@@ -0,0 +1,37 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Integrations: a note louder than its name</title>
<style>
:root { --ink: #1d2433; --muted: #6b7385; --line: #dfe3ea; --paper: #ffffff; }
* { box-sizing: border-box; }
body { margin: 0; font: 14px/20px system-ui, -apple-system, "Segoe UI", sans-serif; color: var(--ink); background: #f5f6f8; }
main { max-width: 720px; margin: 0 auto; padding: 24px 16px; }
.panel { background: var(--paper); border: 1px solid var(--line); border-radius: 8px; padding: 16px 20px; }
.panel h2 { font-size: 18px; line-height: 24px; margin: 0 0 12px; }
.rows { border: 1px solid var(--line); border-radius: 6px; }
.row { display: flex; align-items: center; gap: 12px; padding: 10px 12px; border-top: 1px solid var(--line); }
.row:first-child { border-top: 0; }
.row .text { flex: 1; min-width: 0; }
.row .name { font-weight: 600; }
.help { color: var(--muted); font-size: 13px; margin: 16px 0 0; }
.x { flex: none; width: 32px; height: 32px; border: 0; padding: 0; background: transparent; color: var(--muted); }
.row .name { font-weight: 400; font-size: 14px; }
.row .note { font-weight: 700; font-size: 16px; line-height: 22px; }
</style>
</head>
<body>
<main>
<section class="panel integrations">
<h2>Integrations</h2>
<div class="rows">
<div class="row"><div class="text"><div class="name">GitHub</div><div class="note">Reconnect: the token expired yesterday.</div></div><button class="x" aria-label="Disconnect GitHub">×</button></div>
<div class="row"><div class="text"><div class="name">PagerDuty</div><div class="note">Paging is off until a schedule is chosen.</div></div><button class="x" aria-label="Disconnect PagerDuty">×</button></div>
</div>
<p class="help">Integrations act with the permissions of the person who connected them.</p>
</section>
</main>
</body>
</html>
+36
View File
@@ -0,0 +1,36 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Slack channels: a note that wraps on a phone</title>
<style>
:root { --ink: #1d2433; --muted: #6b7385; --line: #dfe3ea; --paper: #ffffff; }
* { box-sizing: border-box; }
body { margin: 0; font: 14px/20px system-ui, -apple-system, "Segoe UI", sans-serif; color: var(--ink); background: #f5f6f8; }
main { max-width: 720px; margin: 0 auto; padding: 24px 16px; }
.panel { background: var(--paper); border: 1px solid var(--line); border-radius: 8px; padding: 16px 20px; }
.panel h2 { font-size: 18px; line-height: 24px; margin: 0 0 12px; }
.rows { border: 1px solid var(--line); border-radius: 6px; }
.row { display: flex; align-items: center; gap: 12px; padding: 10px 12px; border-top: 1px solid var(--line); }
.row:first-child { border-top: 0; }
.row .text { flex: 1; min-width: 0; }
.row .name { font-weight: 600; }
.help { color: var(--muted); font-size: 13px; margin: 16px 0 0; }
.x { flex: none; width: 32px; height: 32px; border: 0; padding: 0; background: transparent; color: var(--muted); }
.row .flag { color: var(--muted); font-size: 13px; }
</style>
</head>
<body>
<main>
<section class="panel channels">
<h2>Slack channels</h2>
<div class="rows">
<div class="row"><div class="text"><div class="name">#deploys</div><div class="flag">The bot is not in this channel yet, so the deploy summary and the rollback alerts will not post until someone invites it.</div></div><button class="x" aria-label="Remove #deploys">×</button></div>
<div class="row"><div class="text"><div class="name">#release-notes</div><div class="flag">Posting resumes after a workspace admin approves the app again for this channel and its threads.</div></div><button class="x" aria-label="Remove #release-notes">×</button></div>
</div>
<p class="help">Channels get a message when a deploy starts and when it finishes.</p>
</section>
</main>
</body>
</html>
+70
View File
@@ -0,0 +1,70 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Negative controls: a nav list, a table, a card grid and a list spaced right</title>
<style>
:root { --ink: #1d2433; --muted: #6b7385; --line: #dfe3ea; --paper: #ffffff; }
* { box-sizing: border-box; }
body { margin: 0; font: 14px/20px system-ui, -apple-system, "Segoe UI", sans-serif; color: var(--ink); background: #f5f6f8; }
main { max-width: 720px; margin: 0 auto; padding: 24px 16px; }
.panel { background: var(--paper); border: 1px solid var(--line); border-radius: 8px; padding: 16px 20px; }
.panel h2 { font-size: 18px; line-height: 24px; margin: 0 0 12px; }
.rows { border: 1px solid var(--line); border-radius: 6px; }
.row { display: flex; align-items: center; gap: 12px; padding: 10px 12px; border-top: 1px solid var(--line); }
.row:first-child { border-top: 0; }
.row .text { flex: 1; min-width: 0; }
.row .name { font-weight: 600; }
.help { color: var(--muted); font-size: 13px; margin: 16px 0 0; }
.x { flex: none; width: 32px; height: 32px; border: 0; padding: 0; background: transparent; color: var(--muted); }
.panel + .panel { margin-top: 24px; }
nav.side ul { list-style: none; margin: 0; padding: 0; border: 1px solid var(--line); }
nav.side li { padding: 2px 6px; }
nav.side a { color: var(--ink); font-size: 14px; }
nav.side small { display: block; }
table { border-collapse: collapse; width: 100%; }
td, th { padding: 4px; border: 1px solid var(--line); text-align: left; vertical-align: top; }
td small { display: block; color: var(--ink); }
td button { width: 18px; height: 18px; padding: 0; }
.table-help { margin: 2px 0 0; }
.cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(96px, 1fr)); gap: 12px; }
.tile { border: 1px solid var(--line); padding: 3px; }
.tile b { display: block; }
.row .desc { color: var(--muted); font-size: 13px; }
</style>
</head>
<body>
<main>
<section class="panel negatives">
<h2>Navigation</h2>
<nav class="side"><ul>
<li><a href="#general">General</a><small>Name, region and billing owner</small></li>
<li><a href="#sharing">Sharing</a><small>Credentials, channels and people</small></li>
<li><a href="#danger">Danger zone</a><small>Archive or delete the project</small></li>
</ul></nav>
<p class="table-help">Settings are saved as you go.</p>
<h2>Deploys</h2>
<table>
<tr><th>Service</th><th>Status</th><th></th></tr>
<tr><td>api<small>us-east</small></td><td>Live<small>2 min ago</small></td><td><button aria-label="Retry api">↻</button></td></tr>
<tr><td>web<small>us-west</small></td><td>Building<small>just now</small></td><td><button aria-label="Retry web">↻</button></td></tr>
</table>
<p class="table-help">Deploys older than 30 days are archived.</p>
<h2>Environments</h2>
<div class="cards">
<div class="tile"><b>Production</b>Three regions</div>
<div class="tile"><b>Staging</b>One region</div>
<div class="tile"><b>Preview</b>Per branch</div>
</div>
<p class="table-help">Each environment has its own secrets.</p>
<h2>Credentials</h2>
<div class="rows">
<div class="row"><div class="text"><div class="name">Deploy key</div><div class="desc">Read-only access to the repository, added by Dana on March 3</div></div><button class="x" aria-label="Remove Deploy key">×</button></div>
<div class="row"><div class="text"><div class="name">Registry token</div><div class="desc">Pushes release images, added by Omar on April 18</div></div><button class="x" aria-label="Remove Registry token">×</button></div>
</div>
<p class="help">Credentials are encrypted at rest and never shown again after you add them.</p>
</section>
</main>
</body>
</html>
+22
View File
@@ -0,0 +1,22 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Project settings: nested open shadow roots</title>
<script src="sharing.js"></script>
</head>
<body>
<main>
<qm-settings></qm-settings>
<script>
customElements.define('qm-sharing', class extends HTMLElement {
constructor() { super(); this.attachShadow({ mode: 'open' }).innerHTML = '<link rel="stylesheet" href="replica.css">' + window.SHARING_HTML; }
});
customElements.define('qm-settings', class extends HTMLElement {
constructor() { super(); this.attachShadow({ mode: 'open' }).innerHTML = '<div class="frame"><qm-sharing></qm-sharing></div>'; }
});
</script>
</main>
</body>
</html>
+14
View File
@@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Project settings: rows never load</title>
<link rel="stylesheet" href="replica.css">
</head>
<body>
<main>
<section class="panel sharing"><h2>Sharing</h2><p>Loading…</p></section>
</main>
</body>
</html>
+36
View File
@@ -0,0 +1,36 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Credentials: a list of one</title>
<style>
:root { --ink: #1d2433; --muted: #6b7385; --line: #dfe3ea; --paper: #ffffff; }
* { box-sizing: border-box; }
body { margin: 0; font: 14px/20px system-ui, -apple-system, "Segoe UI", sans-serif; color: var(--ink); background: #f5f6f8; }
main { max-width: 720px; margin: 0 auto; padding: 24px 16px; }
.panel { background: var(--paper); border: 1px solid var(--line); border-radius: 8px; padding: 16px 20px; }
.panel h2 { font-size: 18px; line-height: 24px; margin: 0 0 12px; }
.rows { border: 1px solid var(--line); border-radius: 6px; }
.row { display: flex; align-items: center; gap: 12px; padding: 10px 12px; border-top: 1px solid var(--line); }
.row:first-child { border-top: 0; }
.row .text { flex: 1; min-width: 0; }
.row .name { font-weight: 600; }
.help { color: var(--muted); font-size: 13px; margin: 16px 0 0; }
.x { flex: none; width: 32px; height: 32px; border: 0; padding: 0; background: transparent; color: var(--muted); }
.row { padding: 3px 12px; }
.row .desc { color: var(--muted); font-size: 13px; }
</style>
</head>
<body>
<main>
<section class="panel credentials">
<h2>Credentials</h2>
<div class="rows">
<div class="row"><div class="text"><div class="name">Deploy key</div><div class="desc">Read-only access to the repository, added by Dana on March 3</div></div><button class="x" aria-label="Remove Deploy key">×</button></div>
</div>
<p class="help">Credentials are encrypted at rest and never shown again after you add them.</p>
</section>
</main>
</body>
</html>
+39
View File
@@ -0,0 +1,39 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Project members: role labels beside names</title>
<style>
:root { --ink: #1d2433; --muted: #6b7385; --line: #dfe3ea; --paper: #ffffff; }
* { box-sizing: border-box; }
body { margin: 0; font: 14px/20px system-ui, -apple-system, "Segoe UI", sans-serif; color: var(--ink); background: #f5f6f8; }
main { max-width: 720px; margin: 0 auto; padding: 24px 16px; }
.panel { background: var(--paper); border: 1px solid var(--line); border-radius: 8px; padding: 16px 20px; }
.panel h2 { font-size: 18px; line-height: 24px; margin: 0 0 12px; }
.rows { border: 1px solid var(--line); border-radius: 6px; }
.row { display: flex; align-items: center; gap: 12px; padding: 10px 12px; border-top: 1px solid var(--line); }
.row:first-child { border-top: 0; }
.row .text { flex: 1; min-width: 0; }
.row .name { font-weight: 600; }
.help { color: var(--muted); font-size: 13px; margin: 16px 0 0; }
.x { flex: none; width: 32px; height: 32px; border: 0; padding: 0; background: transparent; color: var(--muted); }
.row .role { font-size: 14px; margin-left: 8px; }
.row .mail { color: var(--muted); font-size: 13px; }
.pick { height: 32px; font: inherit; }
</style>
</head>
<body>
<main>
<section class="panel members">
<h2>Members</h2>
<div class="rows">
<div class="row"><div class="text"><div><span class="name">Dana Whitfield</span><span class="role">Owner</span></div><div class="mail">dana@example.test</div></div><button class="x" aria-label="Remove Dana Whitfield">×</button></div>
<div class="row"><div class="text"><div><span class="name">Omar Haddad</span><span class="role">Reader</span></div><div class="mail">omar@example.test</div></div><button class="x" aria-label="Remove Omar Haddad">×</button></div>
<div class="row"><div class="text"><div><span class="name">Lena Park</span><span class="role">Reader</span></div><div class="mail">lena@example.test</div></div><select class="pick" aria-label="Role for Lena Park"><option>Reader</option><option>Editor</option><option>Owner</option></select><button class="x" aria-label="Remove Lena Park">×</button></div>
</div>
<p class="help">Readers see deploys and logs.</p>
</section>
</main>
</body>
</html>
+19
View File
@@ -0,0 +1,19 @@
/* The qm#3186 shape (GST-1741): a Sharing panel whose rows crowd, whose help copy sits flush under its lists, whose status notes read at the name's weight, and whose remove controls are 18px. */
:root, :host { --ink: #1d2433; --muted: #6b7385; --line: #dfe3ea; --paper: #ffffff; }
:host { display: block; font: 14px/20px system-ui, -apple-system, "Segoe UI", sans-serif; color: var(--ink); }
* { box-sizing: border-box; }
body { margin: 0; font: 14px/20px system-ui, -apple-system, "Segoe UI", sans-serif; color: var(--ink); background: #f5f6f8; }
main { max-width: 720px; margin: 0 auto; padding: 24px 16px; }
.panel { background: var(--paper); border: 1px solid var(--line); border-radius: 8px; padding: 16px 20px; }
.panel h2 { font-size: 18px; line-height: 24px; margin: 0 0 12px; }
.panel h3 { font-size: 15px; line-height: 20px; margin: 20px 0 8px; }
.rows { border: 1px solid var(--line); border-radius: 6px; }
.row { display: flex; align-items: center; gap: 12px; padding: 4px 12px; border-top: 1px solid var(--line); }
.row:first-child { border-top: 0; }
.row .text { flex: 1; min-width: 0; }
.row .name { font-weight: 600; }
.row .desc { color: var(--muted); font-size: 13px; }
.row .flag { color: var(--ink); font-size: 14px; }
.row .role { color: var(--muted); font-size: 13px; margin-left: 8px; }
.x { flex: none; width: 18px; height: 18px; border: 0; padding: 0; background: transparent; color: var(--muted); font-size: 14px; line-height: 18px; cursor: pointer; }
.help { color: var(--muted); font-size: 13px; margin: 4px 0 0; }
+35
View File
@@ -0,0 +1,35 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Project settings: replica of qm#3186</title>
<link rel="stylesheet" href="replica.css">
</head>
<body>
<main>
<section class="panel sharing">
<h2>Sharing</h2>
<h3>Credentials</h3>
<div class="rows">
<div class="row"><div class="text"><div class="name">Deploy key</div><div class="desc">Read-only access to the repository, added by Dana on March 3</div></div><button class="x" aria-label="Remove Deploy key">×</button></div>
<div class="row"><div class="text"><div class="name">Registry token</div><div class="desc">Pushes release images, added by Omar on April 18</div></div><button class="x" aria-label="Remove Registry token">×</button></div>
</div>
<p class="help">Credentials are encrypted at rest and never shown again after you add them.</p>
<h3>Slack channels</h3>
<div class="rows">
<div class="row"><div class="text"><div class="name">#deploys</div><div class="flag">The bot is not in this channel yet, so posts will fail.</div></div><button class="x" aria-label="Remove #deploys">×</button></div>
<div class="row"><div class="text"><div class="name">#release-notes</div><div class="flag">Posting paused by a workspace admin.</div></div><button class="x" aria-label="Remove #release-notes">×</button></div>
</div>
<p class="help">Channels get a message when a deploy starts and when it finishes.</p>
<h3>Who has access</h3>
<div class="rows">
<div class="row"><div class="text"><span class="name">Dana Whitfield</span><span class="role">Owner</span></div><button class="x" aria-label="Remove Dana Whitfield">×</button></div>
<div class="row"><div class="text"><span class="name">Omar Haddad</span><span class="role">Editor</span></div><button class="x" aria-label="Remove Omar Haddad">×</button></div>
<div class="row"><div class="text"><span class="name">Lena Park</span><span class="role">Reader</span></div><button class="x" aria-label="Remove Lena Park">×</button></div>
</div>
<p class="help">Readers see deploys and logs; editors can change settings.</p>
</section>
</main>
</body>
</html>
+131
View File
@@ -0,0 +1,131 @@
{
"generatedFrom": "bin/gstack-design-lint at e58be61 (origin/main before GST-1741 PR 1), run from the repository root",
"cases": [
{
"args": [
"test/fixtures/design-lint/slopped.html"
],
"code": 2,
"lines": 46,
"sha256": "295bbc0c354f8064459d30e389aa230eb53f257ad781ba844159230ddcf91515"
},
{
"args": [
"test/fixtures/design-lint/clean"
],
"code": 0,
"lines": 1,
"sha256": "90680fde67b703ba3f12bbae8638f8ff7053561d76b9722cf7cb0dda37cc6ea1"
},
{
"args": [
"test/fixtures/design-lint/eyebrow-fail"
],
"code": 2,
"lines": 18,
"sha256": "61350c90e70845368095ef0b61b650fbbef9f9206a70c8e70ff3cbda966c183c"
},
{
"args": [
"test/fixtures/design-lint/eyebrow-pass"
],
"code": 0,
"lines": 3,
"sha256": "9da5caa6d3bea8f7624f2938e22466e60791ca61afbddfeba560f69c4d2bdf28"
},
{
"args": [
"test/fixtures/design-lint/tailwind-fail"
],
"code": 2,
"lines": 41,
"sha256": "e6901b66a090638abca7e22ec89843de64e719900618b7c37e01c3cad39e0f90"
},
{
"args": [
"test/fixtures/design-lint/tailwind-pass"
],
"code": 0,
"lines": 2,
"sha256": "727a01dee14590f39de8607303db1fbe20a1236bc06bf753395b169a6fbbb326"
},
{
"args": [
"test/fixtures/design-lint/waiver"
],
"code": 1,
"lines": 12,
"sha256": "e6c850be2214066f74469e3483b4a3ee73bc0da4ff6da1e5319225df5a516d15"
},
{
"args": [
"test/fixtures/design-lint"
],
"code": 2,
"lines": 110,
"sha256": "71e69ecf0e2b93437ff29bc5f2e94bfb85089fc64f8e318191f93331cdf1626f"
},
{
"args": [
"--scope",
"component",
"test/fixtures/design-lint/tailwind-fail"
],
"code": 2,
"lines": 33,
"sha256": "2c18f9e7e5ca30bae2543d5b7aaf54c958feadecada1be9aa19e47e598231279"
},
{
"args": [
"--scope",
"component",
"test/fixtures/design-lint/tailwind-pass"
],
"code": 0,
"lines": 1,
"sha256": "90680fde67b703ba3f12bbae8638f8ff7053561d76b9722cf7cb0dda37cc6ea1"
},
{
"args": [
"--scope",
"component",
"test/fixtures/design-lint/slopped.html"
],
"code": 2,
"lines": 37,
"sha256": "e6305053b6de5a3ce42eb165875b1a135636fe6daf903b18a05078bc469aca20"
},
{
"args": [
"--scope",
"page",
"--job",
"book",
"test/fixtures/design-lint/slopped.html"
],
"code": 2,
"lines": 46,
"sha256": "295bbc0c354f8064459d30e389aa230eb53f257ad781ba844159230ddcf91515"
},
{
"args": [
"--json",
"test/fixtures/design-lint/slopped.html"
],
"code": 2,
"lines": 462,
"sha256": "61a824bec090e297fdbadd2a753892c2fa1e0ae232d1dee0ff05922fa167a18f"
},
{
"args": [
"--json",
"--scope",
"component",
"test/fixtures/design-lint/tailwind-fail"
],
"code": 2,
"lines": 332,
"sha256": "2c8c88c80449a9cefbadacfbc4886b7404378087a525fd627cc9cd0607e836a8"
}
]
}
+19
View File
@@ -0,0 +1,19 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Project settings: a closed shadow root</title>
<script src="sharing.js"></script>
</head>
<body>
<main>
<qm-sharing></qm-sharing>
<script>
customElements.define('qm-sharing', class extends HTMLElement {
constructor() { super(); this.attachShadow({ mode: 'closed' }).innerHTML = '<link rel="stylesheet" href="replica.css"><style>:host { display: block }</style>' + window.SHARING_HTML; }
});
</script>
</main>
</body>
</html>
+19
View File
@@ -0,0 +1,19 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Project settings: an open shadow root</title>
<script src="sharing.js"></script>
</head>
<body>
<main>
<qm-sharing></qm-sharing>
<script>
customElements.define('qm-sharing', class extends HTMLElement {
constructor() { super(); this.attachShadow({ mode: 'open' }).innerHTML = '<link rel="stylesheet" href="replica.css">' + window.SHARING_HTML; }
});
</script>
</main>
</body>
</html>
+21
View File
@@ -0,0 +1,21 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Project settings: rows slotted into a shadow root</title>
<link rel="stylesheet" href="replica.css">
<script src="sharing.js"></script>
</head>
<body>
<main>
<qm-card id="card"></qm-card>
<script>
customElements.define('qm-card', class extends HTMLElement {
constructor() { super(); this.attachShadow({ mode: 'open' }).innerHTML = '<style>:host { display: block }</style><div class="card-frame"><slot></slot></div>'; }
});
document.getElementById('card').innerHTML = window.SHARING_HTML;
</script>
</main>
</body>
</html>
+2
View File
@@ -0,0 +1,2 @@
// The replica's Sharing section as one string, for the variants that build it with script (async, shadow). test/design-lint-app-profile.test.ts holds it equal to replica.html's markup.
window.SHARING_HTML = "<section class=\"panel sharing\">\n <h2>Sharing</h2>\n <h3>Credentials</h3>\n <div class=\"rows\">\n <div class=\"row\"><div class=\"text\"><div class=\"name\">Deploy key</div><div class=\"desc\">Read-only access to the repository, added by Dana on March 3</div></div><button class=\"x\" aria-label=\"Remove Deploy key\">×</button></div>\n <div class=\"row\"><div class=\"text\"><div class=\"name\">Registry token</div><div class=\"desc\">Pushes release images, added by Omar on April 18</div></div><button class=\"x\" aria-label=\"Remove Registry token\">×</button></div>\n </div>\n <p class=\"help\">Credentials are encrypted at rest and never shown again after you add them.</p>\n <h3>Slack channels</h3>\n <div class=\"rows\">\n <div class=\"row\"><div class=\"text\"><div class=\"name\">#deploys</div><div class=\"flag\">The bot is not in this channel yet, so posts will fail.</div></div><button class=\"x\" aria-label=\"Remove #deploys\">×</button></div>\n <div class=\"row\"><div class=\"text\"><div class=\"name\">#release-notes</div><div class=\"flag\">Posting paused by a workspace admin.</div></div><button class=\"x\" aria-label=\"Remove #release-notes\">×</button></div>\n </div>\n <p class=\"help\">Channels get a message when a deploy starts and when it finishes.</p>\n <h3>Who has access</h3>\n <div class=\"rows\">\n <div class=\"row\"><div class=\"text\"><span class=\"name\">Dana Whitfield</span><span class=\"role\">Owner</span></div><button class=\"x\" aria-label=\"Remove Dana Whitfield\">×</button></div>\n <div class=\"row\"><div class=\"text\"><span class=\"name\">Omar Haddad</span><span class=\"role\">Editor</span></div><button class=\"x\" aria-label=\"Remove Omar Haddad\">×</button></div>\n <div class=\"row\"><div class=\"text\"><span class=\"name\">Lena Park</span><span class=\"role\">Reader</span></div><button class=\"x\" aria-label=\"Remove Lena Park\">×</button></div>\n </div>\n <p class=\"help\">Readers see deploys and logs; editors can change settings.</p>\n</section>";
+51
View File
@@ -0,0 +1,51 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Project settings: the replica's defects in other markup</title>
<style>
:root { --ink: #1d2433; --muted: #6b7385; --line: #dfe3ea; --paper: #ffffff; }
* { box-sizing: border-box; }
body { margin: 0; font: 14px/20px system-ui, -apple-system, "Segoe UI", sans-serif; color: var(--ink); background: #f5f6f8; }
main { max-width: 720px; margin: 0 auto; padding: 24px 16px; }
.card { background: var(--paper); border: 1px solid var(--line); border-radius: 8px; padding: 16px 20px; }
.card h2 { font-size: 18px; line-height: 24px; margin: 0 0 12px; }
.card h3 { font-size: 15px; line-height: 20px; margin: 20px 0 8px; }
.entries { list-style: none; margin: 0; padding: 0; border: 1px solid var(--line); border-radius: 6px; }
.entry + .entry { border-top: 1px solid var(--line); }
.cell-grid { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; column-gap: 12px; padding: 5px 12px; }
.title { margin: 0; font-weight: 600; }
.sub { margin: 0; color: var(--muted); font-size: 13px; }
.status { margin: 0; font-size: 14px; }
.badge { color: var(--muted); font-size: 13px; margin-left: 8px; }
.del { width: 20px; height: 20px; padding: 0; border: 0; background: none; color: var(--muted); }
.aside-copy { color: var(--muted); font-size: 13px; margin-top: 6px; }
</style>
</head>
<body>
<main>
<article class="card settings-sharing">
<h2>Sharing</h2>
<h3>Credentials</h3>
<ul class="entries">
<li class="entry"><div class="cell-grid"><div><p class="title">Deploy key</p><p class="sub">Read-only access to the repository, added by Dana on March 3</p></div><button class="del" aria-label="Remove Deploy key">×</button></div></li>
<li class="entry"><div class="cell-grid"><div><p class="title">Registry token</p><p class="sub">Pushes release images, added by Omar on April 18</p></div><button class="del" aria-label="Remove Registry token">×</button></div></li>
</ul>
<div class="aside-copy">Credentials are encrypted at rest and never shown again after you add them.</div>
<h3>Slack channels</h3>
<ul class="entries">
<li class="entry"><div class="cell-grid"><div><p class="title">#deploys</p><p class="status">The bot is not in this channel yet, so posts will fail.</p></div><button class="del" aria-label="Remove #deploys">×</button></div></li>
<li class="entry"><div class="cell-grid"><div><p class="title">#release-notes</p><p class="status">Posting paused by a workspace admin.</p></div><button class="del" aria-label="Remove #release-notes">×</button></div></li>
</ul>
<div class="aside-copy">Channels get a message when a deploy starts and when it finishes.</div>
<h3>Who has access</h3>
<ul class="entries">
<li class="entry"><div class="cell-grid"><div><b>Dana Whitfield</b><i class="badge">Owner</i></div><button class="del" aria-label="Remove Dana Whitfield">×</button></div></li>
<li class="entry"><div class="cell-grid"><div><b>Omar Haddad</b><i class="badge">Editor</i></div><button class="del" aria-label="Remove Omar Haddad">×</button></div></li>
</ul>
<div class="aside-copy">Readers see deploys and logs; editors can change settings.</div>
</article>
</main>
</body>
</html>
+2 -1
View File
@@ -78,7 +78,7 @@ export const FREE_FIXTURES: Readonly<Record<string, readonly string[]>> = {
'test/fixtures/design-count-native-8525.json': ['test/plan-count-completion.test.ts'],
'test/fixtures/design-count-native-issue-fields.json': ['test/plan-count-completion.test.ts'],
'test/fixtures/design-detector-source-public.json': ['test/design-detector-source-fixture.test.ts'],
'test/fixtures/design-lint/**': ['test/design-lint.test.ts', 'test/design-lint-color.test.ts', 'test/design-lint-type.test.ts', 'test/design-lint-structure.test.ts', 'test/design-lint-motion.test.ts', 'test/design-lint-chrome.test.ts', 'test/design-lint-assets.test.ts', 'test/design-lint-finish.test.ts', 'test/design-lint-tailwind.test.ts', 'test/design-lint-render.test.ts', 'test/design-lint-fourth-cycle.test.ts', 'test/design-lint-fifth-cycle.test.ts'],
'test/fixtures/design-lint/**': ['test/design-lint.test.ts', 'test/design-lint-color.test.ts', 'test/design-lint-type.test.ts', 'test/design-lint-structure.test.ts', 'test/design-lint-motion.test.ts', 'test/design-lint-chrome.test.ts', 'test/design-lint-assets.test.ts', 'test/design-lint-finish.test.ts', 'test/design-lint-tailwind.test.ts', 'test/design-lint-render.test.ts', 'test/design-lint-fourth-cycle.test.ts', 'test/design-lint-fifth-cycle.test.ts', 'test/design-lint-app-profile.test.ts', 'test/design-lint-scope-guard.test.ts'],
'test/fixtures/design-floor-focus-36597762183.json': ['test/plan-design-floor-fixture.test.ts'],
'test/fixtures/design-floor-focus-cover-all.json': ['test/plan-design-floor-fixture.test.ts'],
'test/fixtures/design-floor-focus-review-focus.json': ['test/plan-design-floor-fixture.test.ts'],
@@ -259,6 +259,7 @@ export const FREE_FIXTURES: Readonly<Record<string, readonly string[]>> = {
'test/fixtures/trial-cause/replays.json': ['test/trial-cause.test.ts'],
'test/fixtures/trial-cause/thinking-captures-37198445662.json': ['test/trial-cause.test.ts'],
'test/fixtures/trial-record-v1-reader.ts': ['test/helpers/eval-store.test.ts'],
'test/fixtures/ui-spacing/**': ['test/design-lint-app-contract.test.ts', 'test/design-lint-app-profile.test.ts', 'test/design-lint-scope-guard.test.ts'],
'test/fixtures/windows-bash-resolution.json': ['test/script-invocation.test.ts'],
'test/fixtures/workflow-required-steps.json': ['test/workflow-required-steps.test.ts'],
};