@variance-authority/core 0.1.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +245 -10
- package/README.md +279 -110
- package/dist/attribute/call-site.d.ts +20 -2
- package/dist/attribute/call-site.js +17 -20
- package/dist/attribute/call-site.js.map +1 -1
- package/dist/attribute/closure.d.ts +105 -0
- package/dist/attribute/closure.js +180 -0
- package/dist/attribute/closure.js.map +1 -0
- package/dist/attribute/composition.d.ts +5 -3
- package/dist/attribute/composition.js +67 -11
- package/dist/attribute/composition.js.map +1 -1
- package/dist/attribute/divergence.js +7 -1
- package/dist/attribute/divergence.js.map +1 -1
- package/dist/attribute/index.d.ts +7 -1
- package/dist/attribute/index.js +4 -1
- package/dist/attribute/index.js.map +1 -1
- package/dist/attribute/landmark.d.ts +97 -0
- package/dist/attribute/landmark.js +144 -0
- package/dist/attribute/landmark.js.map +1 -0
- package/dist/attribute/lexicon.d.ts +112 -0
- package/dist/attribute/lexicon.js +210 -0
- package/dist/attribute/lexicon.js.map +1 -0
- package/dist/attribute/source.d.ts +19 -2
- package/dist/attribute/source.js +12 -0
- package/dist/attribute/source.js.map +1 -1
- package/dist/attribute/stack.d.ts +62 -1
- package/dist/attribute/stack.js +116 -5
- package/dist/attribute/stack.js.map +1 -1
- package/dist/compare/derive.d.ts +17 -0
- package/dist/compare/derive.js.map +1 -1
- package/dist/compare/diff/index.d.ts +25 -1
- package/dist/compare/diff/index.js +8 -5
- package/dist/compare/diff/index.js.map +1 -1
- package/dist/compare/explain.d.ts +1 -1
- package/dist/compare/explain.js +15 -8
- package/dist/compare/explain.js.map +1 -1
- package/dist/compare/index.d.ts +2 -2
- package/dist/compare/index.js +1 -1
- package/dist/compare/index.js.map +1 -1
- package/dist/compare/observability.d.ts +25 -1
- package/dist/compare/observability.js +54 -0
- package/dist/compare/observability.js.map +1 -1
- package/dist/compare/parting.d.ts +24 -2
- package/dist/compare/parting.js +14 -9
- package/dist/compare/parting.js.map +1 -1
- package/dist/compare/slice.d.ts +15 -2
- package/dist/compare/slice.js +6 -2
- package/dist/compare/slice.js.map +1 -1
- package/dist/format/capture.d.ts +15 -0
- package/dist/format/capture.js +30 -1
- package/dist/format/capture.js.map +1 -1
- package/dist/format/document.d.ts +65 -5
- package/dist/format/document.js +34 -0
- package/dist/format/document.js.map +1 -1
- package/dist/format/hash.d.ts +26 -0
- package/dist/format/hash.js +41 -0
- package/dist/format/hash.js.map +1 -1
- package/dist/format/index.d.ts +4 -3
- package/dist/format/index.js +3 -2
- package/dist/format/index.js.map +1 -1
- package/dist/format/sha256.d.ts +10 -2
- package/dist/format/sha256.js +42 -9
- package/dist/format/sha256.js.map +1 -1
- package/dist/format/stabilize.d.ts +15 -0
- package/dist/format/stabilize.js +22 -1
- package/dist/format/stabilize.js.map +1 -1
- package/dist/index.d.ts +3 -11
- package/dist/index.js +3 -11
- package/dist/index.js.map +1 -1
- package/dist/judge/inspect.js +57 -4
- package/dist/judge/inspect.js.map +1 -1
- package/dist/relate/before.d.ts +137 -0
- package/dist/relate/before.js +155 -0
- package/dist/relate/before.js.map +1 -0
- package/dist/relate/graph.d.ts +53 -16
- package/dist/relate/graph.js +33 -15
- package/dist/relate/graph.js.map +1 -1
- package/dist/relate/index.d.ts +3 -2
- package/dist/relate/index.js +2 -1
- package/dist/relate/index.js.map +1 -1
- package/dist/relate/merkle.d.ts +14 -5
- package/dist/relate/merkle.js +14 -6
- package/dist/relate/merkle.js.map +1 -1
- package/dist/relate/reach.d.ts +19 -5
- package/dist/relate/reach.js +10 -8
- package/dist/relate/reach.js.map +1 -1
- package/dist/relate/records.d.ts +95 -4
- package/dist/relate/records.js +93 -13
- package/dist/relate/records.js.map +1 -1
- package/dist/segment/index.d.ts +123 -0
- package/dist/segment/index.js +265 -0
- package/dist/segment/index.js.map +1 -0
- package/dist/share/index.d.ts +148 -0
- package/dist/share/index.js +190 -0
- package/dist/share/index.js.map +1 -0
- package/package.json +10 -2
package/dist/format/stabilize.js
CHANGED
|
@@ -37,6 +37,21 @@ export const pinAnimations = {
|
|
|
37
37
|
'transition-delay:0s !important;' +
|
|
38
38
|
'scroll-behavior:auto !important}',
|
|
39
39
|
};
|
|
40
|
+
/**
|
|
41
|
+
* A stylesheet rather than the driver's own caret switch, deliberately.
|
|
42
|
+
*
|
|
43
|
+
* Playwright and its peers hide a caret by writing `caret-color` onto every
|
|
44
|
+
* focusable element and putting it back afterwards, and putting it back leaves
|
|
45
|
+
* `style=""` where there was no `style` attribute at all. Any capture that reads
|
|
46
|
+
* the DOM on both sides of a screenshot — which is what the in-place path does
|
|
47
|
+
* to prove the subject held still — then sees markup that moved, and reports the
|
|
48
|
+
* driver's housekeeping as the page being unstable. A page with one text input
|
|
49
|
+
* is enough.
|
|
50
|
+
*
|
|
51
|
+
* So this hold owns the caret the way {@link pinAnimations} owns animations: one
|
|
52
|
+
* stylesheet, installed before the subject is read, removed with the rest of the
|
|
53
|
+
* recipe.
|
|
54
|
+
*/
|
|
40
55
|
export const hideCaret = {
|
|
41
56
|
id: 'hide-caret',
|
|
42
57
|
trick: 'support',
|
|
@@ -45,7 +60,7 @@ export const hideCaret = {
|
|
|
45
60
|
needs: 'raster',
|
|
46
61
|
governs: 'caret',
|
|
47
62
|
because: 'text caret hidden, because it blinks on its own schedule',
|
|
48
|
-
|
|
63
|
+
css: '*{caret-color:transparent !important}',
|
|
49
64
|
};
|
|
50
65
|
export const hideScrollbars = {
|
|
51
66
|
id: 'hide-scrollbars',
|
|
@@ -239,6 +254,12 @@ export const RASTER_RECIPE = [...LAYOUT_RECIPE, hideCaret];
|
|
|
239
254
|
export const COLLECT_RECIPE = [
|
|
240
255
|
pinAnimations,
|
|
241
256
|
hideScrollbars,
|
|
257
|
+
// Here as well as in {@link RASTER_RECIPE}, because the in-place path
|
|
258
|
+
// photographs the live page and applies no screenshot options of its own: the
|
|
259
|
+
// only caret hold that reaches that image is one installed here. A subject
|
|
260
|
+
// holding a focused input otherwise disagrees with itself between two
|
|
261
|
+
// screenshots taken 600ms apart, and is refused for blinking.
|
|
262
|
+
hideCaret,
|
|
242
263
|
waitForFonts,
|
|
243
264
|
waitForImages,
|
|
244
265
|
];
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"stabilize.js","sourceRoot":"","sources":["../../src/format/stabilize.ts"],"names":[],"mappings":"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","sourcesContent":["import { digestValue, type Digest } from './hash.js';\nimport { tierReaches, type Tier } from './tier.js';\nimport type { Intervention, PageGlobals, ScreenshotOptions, SettleTarget } from './intervention.js';\n\n/**\n * Interventions as separate, nameable tricks — not a fixed set of switches.\n *\n * A subject that is still changing cannot be compared, so every system of this\n * kind reaches into the page: it holds animations, waits for fonts, hides a\n * caret, suppresses scrollbars. Written as one struct of booleans that becomes a\n * closed vocabulary: adding a trick edits a type every caller depends on, and a\n * project with a need nobody anticipated has to fork.\n *\n * So each is a value. It carries an id, the tier that can actually observe what\n * it fixes, what it costs, and how it is applied — and a *recipe* is any list of\n * them. Two tricks may express the same intent through different mechanisms and\n * remain separate values, because they produce different images and choosing\n * between them is the caller's business.\n *\n * ## What this buys\n *\n * A recipe can be filtered by tier, so the structure-and-style rung waits for\n * nothing; composed from another project's tricks alongside these; digested, so\n * a baseline made under one recipe is `incomparable` with a run made under\n * another rather than `changed`; and checked for tricks that fight each other.\n *\n * ## Where the damage lands\n *\n * Ordered by increasing cost, earliest sufficient option first:\n *\n * - **Outside the subject** — injected CSS, browser screenshot options. Nothing\n * in the product imports it and deleting the tool deletes the intervention.\n * - **Runtime substitution** — wrapping `Promise`, replacing a suspense\n * boundary. Buys the same knowledge without touching a component and is\n * deliberately not shipped here: it moves the damage from design into\n * semantics, where a difference caused by the patch cannot be told apart from\n * a difference caused by the code. Expressible as a trick if a project decides\n * the trade is worth it — that is the point of an open set.\n * - **A contract the subject implements** — a readiness marker. Real design\n * damage, and reserved for what the outside genuinely cannot know.\n *\n * ## Why this is in `format` and not in the raster package\n *\n * It was in `@variance-authority/raster` until 2026-08-06, on the reading that\n * holding a page still is something you do before you photograph it. That\n * reading was wrong in a way that cost correctness: an animation in flight is a\n * *computed style value*, so it reaches the cheap representation too — and the\n * allowlist excludes `animation-*` and `transition-*` precisely because it\n * assumed a snapshot is taken with animation already disabled. Nothing disabled\n * it. See {@link COLLECT_RECIPE} and ADR-0028.\n *\n * So a recipe is a render input on every tier, `EnvironmentInputs` carries its\n * digest, and the vocabulary belongs beside the key it is part of.\n */\n\n// Re-exported from here because this is where a reader arrives looking for the\n// vocabulary; [`intervention.ts`](./intervention.js) holds the declaration.\nexport type { Intervention, ScreenshotOptions, SettleTarget, Trick } from './intervention.js';\n\n/**\n * Hand animations to the browser rather than emulating the hold in CSS.\n *\n * A browser disabling animations for a screenshot fast-forwards a finite\n * animation to completion — the state a user comes to rest on — and cancels an\n * infinite one to its initial frame, replaying it afterwards. That is the\n * behaviour worth having, and CSS cannot express it.\n */\nexport const holdAnimations: Intervention = {\n id: 'hold-animations',\n trick: 'hold',\n needs: 'layout',\n governs: 'animations',\n because: 'animations settled by the browser: finite fast-forwarded, infinite reset',\n screenshot: { animations: 'disabled' },\n};\n\n/**\n * The same intent without a browser that can do it, and it is not equivalent.\n *\n * Pins every animation at its first frame, so a fade-in is captured at the\n * moment it is invisible. `animation: none` is avoided deliberately — removing\n * an animation drops whatever layout its keyframes contribute, which changes the\n * page rather than stopping it.\n */\nexport const pinAnimations: Intervention = {\n id: 'pin-animations',\n trick: 'hold',\n needs: 'layout',\n governs: 'animations',\n because: 'animations pinned at their first frame, which is not where a user sees them',\n css:\n '*,*::before,*::after{' +\n 'animation-play-state:paused !important;' +\n 'animation-delay:-0.0001s !important;' +\n 'transition-duration:0s !important;' +\n 'transition-delay:0s !important;' +\n 'scroll-behavior:auto !important}',\n};\n\nexport const hideCaret: Intervention = {\n id: 'hide-caret',\n trick: 'support',\n // A caret paints and does not lay out, so a tier that never rasterizes cannot\n // see it and must not pay to hide it.\n needs: 'raster',\n governs: 'caret',\n because: 'text caret hidden, because it blinks on its own schedule',\n screenshot: { caret: 'hide' },\n};\n\nexport const hideScrollbars: Intervention = {\n id: 'hide-scrollbars',\n trick: 'support',\n needs: 'layout',\n governs: 'scrollbars',\n because: 'scrollbars hidden, removing a platform and preference difference — and their width',\n css: '*{scrollbar-width:none !important}\\n*::-webkit-scrollbar{display:none !important}',\n};\n\n/**\n * Drop the pixels of images the page itself called decorative, keeping their boxes.\n *\n * The other half of blanking, and it lives here because of what it needs to\n * know. A driver rewriting responses (`@variance-authority/playwright`'s\n * `blank` rules) can decide by URL and by intrinsic size, which answers \"every\n * illustration over 40,000 pixels\" and answers it before the bytes are even\n * fetched. It cannot answer `role=\"presentation\"`, because a request carries no\n * idea which element wanted it — that fact exists only in the document, so the\n * trick that uses it is a stylesheet.\n *\n * `visibility:hidden` rather than `display:none`, and the distinction is the\n * entire design: a hidden element still occupies exactly the box it would have,\n * so a page whose column height comes from an image's intrinsic size is\n * unchanged. `display:none` would collapse it and report a layout regression\n * this tool caused.\n *\n * **Opt-in, and it belongs in no default recipe.** Every other trick here\n * removes something that is not part of the assertion — a caret, a scrollbar, an\n * animation mid-flight. This one removes page content, which is a judgement\n * about what a suite is for, and a default that quietly stopped watching every\n * `alt=\"\"` image would hide real regressions under a green run.\n */\nexport const hidePresentationalImages: Intervention = {\n id: 'hide-presentational-images',\n trick: 'support',\n // `layout` rather than `raster`, and the difference is whether it runs at all.\n // A raster-tier trick is filtered out of every *collection* — `tierOfProfile`\n // never returns `raster` — so it would only reach a page through a renderer\n // option, which is not where an operator configures their suite. A collection\n // sheet is in force when the document is serialized, so hiding it here is what\n // makes the pixels absent from the render.\n needs: 'layout',\n governs: 'presentational-images',\n because:\n 'images the page marked decorative hidden, keeping their boxes — real changes inside them ' +\n 'are not reported',\n css:\n 'img[role=\"presentation\"],img[alt=\"\"],[role=\"presentation\"] img,[role=\"none\"] img' +\n '{visibility:hidden !important}',\n};\n\nexport const waitForFonts: Intervention = {\n id: 'wait-for-fonts',\n trick: 'wait',\n needs: 'layout',\n governs: 'fonts',\n because: 'waited for web fonts, whose advances change every metric on the page',\n // Written without a module-scope helper on purpose: a settle closure is\n // shipped to a page as source text, so anything it names by identifier is a\n // `ReferenceError` on the far side. Types are erased and therefore free.\n settle: async (target) => {\n await target.evaluate(async () => {\n const view = globalThis as unknown as PageGlobals;\n await view.document.fonts?.ready;\n });\n },\n};\n\nexport const waitForImages: Intervention = {\n id: 'wait-for-images',\n trick: 'wait',\n needs: 'layout',\n governs: 'images',\n because:\n 'waited for images to decode, since their intrinsic size participates in layout, and asked ' +\n 'for the ones the browser had deferred',\n settle: async (target) => {\n await target.evaluate(async () => {\n const view = globalThis as unknown as PageGlobals;\n const pending = Array.from(view.document.images).filter((image) => !image.complete);\n\n const arrived = Promise.all(\n pending.map(\n (image) =>\n new Promise<void>((resolve) => {\n image.addEventListener('load', () => resolve(), { once: true });\n image.addEventListener('error', () => resolve(), { once: true });\n }),\n ),\n );\n\n // Asked for, after the listeners are attached and before anything is\n // awaited. A `loading=\"lazy\"` image outside the viewport has not been\n // requested and will not be until something scrolls, so waiting on it is\n // waiting for a decision the browser has already taken the other way.\n //\n // This is the same act as pinning an animation, not a different kind of\n // thing: what the page shows stops depending on where the viewport\n // happens to be. It matters most for the readings that need it most — a\n // full-page capture of a long page is exactly the case where most of the\n // images are deferred, and photographing it without them yields a picture\n // full of empty boxes whose contents change with the browser's loading\n // heuristics rather than with the product.\n const asServed = pending.map((image) => image.loading);\n for (const image of pending) image.loading = 'eager';\n\n // Bounded, and it throws when the bound is reached.\n //\n // A response can stall: a CDN that never answers, an image endpoint that\n // deadlocks under its own concurrency, an image the browser deferred and\n // will not request from where the page is scrolled. None of those fire\n // `load` and none fire `error`, so an unbounded wait turns one stuck\n // request into a run that never ends and never says why — not a wrong\n // answer, no answer.\n //\n // Giving up quietly would be worse than the hang in a subtler way: it\n // photographs a page with holes in it and reports the holes as a change,\n // inventing a regression out of the network this trick exists to hold\n // still. Refused instead, naming what it was still waiting for, because\n // \"these two never answered\" is a finding about the application.\n //\n // The bound is written twice — once as the delay, once in the sentence —\n // because a settle closure is shipped to the page as source text and a\n // constant it named would be a `ReferenceError` on the far side.\n // Held so the loser can be cancelled. A timer left to fire after the\n // images arrived rejects a promise nothing is waiting on any more, which\n // the page reports as an unhandled rejection — this trick's own noise,\n // arriving in the console of every subject it succeeded on.\n let timer: unknown;\n\n const expired = new Promise<never>((_, reject) => {\n timer = view.setTimeout(() => {\n const stuck = pending.filter((image) => !image.complete);\n reject(\n new Error(\n `${stuck.length} image(s) had not loaded after 15000ms, and neither answered ` +\n `nor failed: ${stuck.map((image) => image.currentSrc || image.src).join(', ')}`,\n ),\n );\n }, 15000);\n });\n\n try {\n await Promise.race([arrived, expired]);\n } finally {\n view.clearTimeout(timer);\n\n // Put back, because the trick has to be invisible to the tier that\n // reads the page after it. `loading` is a reflected attribute, so a\n // document whose deferred images were switched to `eager` and left that\n // way is a document that no longer matches the one the product served —\n // and which images were still undecoded when this ran is a question\n // about the network, so the edit lands in some readings and not others.\n // Left in, it makes the same page at two widths disagree about its own\n // markup: this trick filed as a finding against the application.\n for (let index = 0; index < pending.length; index += 1) {\n pending[index]!.loading = asServed[index]!;\n }\n }\n });\n },\n};\n\nexport const INTERVENTIONS: readonly Intervention[] = [\n holdAnimations,\n pinAnimations,\n hideCaret,\n hideScrollbars,\n hidePresentationalImages,\n waitForFonts,\n waitForImages,\n];\n\nexport type Recipe = readonly Intervention[];\n\n/**\n * Nothing at all, and correct for the tier that reads structure and declared style.\n *\n * A font that has not loaded cannot change which rules match or what they\n * declare; an image that has not decoded cannot either. Waiting for both before\n * a structure-and-style hash buys nothing and costs the wait per subject, on the\n * rung the tier ladder exists to make cheap.\n */\nexport const SEMANTIC_RECIPE: Recipe = [];\n\nexport const LAYOUT_RECIPE: Recipe = [holdAnimations, hideScrollbars, waitForFonts, waitForImages];\n\nexport const RASTER_RECIPE: Recipe = [...LAYOUT_RECIPE, hideCaret];\n\n/**\n * What is applied to a live page *before a subject is observed*, as opposed to\n * before it is painted.\n *\n * The two are different recipes and it is not a preference. {@link\n * holdAnimations} is a *screenshot option* — it asks the browser to settle\n * animations for the image it is about to take — and there is no screenshot at\n * collection time, so it would be a trick that silently does nothing. The CSS\n * variant is the one that works where nobody is holding a camera, which is the\n * whole reason both exist as separate values rather than as one switch.\n *\n * ## Why the cheap tier needs this at all\n *\n * The claim in [`ruleset.ts`](../rules/ruleset.ts) that `transition-*` and\n * `animation-*` \"describe a journey the snapshot does not contain\" is only true\n * if the snapshot is taken with the journey stopped. `transform`, `opacity`,\n * `filter`, `color` and every geometric longhand *are* admitted, and an\n * animation in flight moves all of them — so an unstabilized collection turns a\n * 300ms fade into a component-attributed regression with a real file name on it,\n * which is worse than an unexplained pixel diff because it is credible.\n *\n * Filtered by {@link forTier}, so jsdom applies none of it: with no layout\n * engine and no animation clock there is nothing to hold still, and paying a\n * `fonts.ready` wait per subject on the rung that exists to be cheap is exactly\n * the trade {@link SEMANTIC_RECIPE} refuses.\n */\nexport const COLLECT_RECIPE: Recipe = [\n pinAnimations,\n hideScrollbars,\n waitForFonts,\n waitForImages,\n];\n\n/**\n * Resolve a trick by the name a caller wrote down.\n *\n * The boundary a collection recipe crosses is a `page.evaluate`, and an\n * `Intervention` does not survive it — `settle` is a function. Ids do survive,\n * and the page holds this same registry, so a recipe travels as the list of\n * names it is. A name nothing answers to is a caller error worth failing on\n * rather than a trick to skip quietly: under-stabilizing is how a suite gets a\n * flake it has already paid to prevent.\n */\nexport function interventionById(id: string): Intervention | undefined {\n return INTERVENTIONS.find((intervention) => intervention.id === id);\n}\n\n/**\n * A recipe from the names it travels as.\n *\n * Throws on a name nothing answers to, and names it. The quiet alternative —\n * skip what cannot be resolved — turns a typo in a config into a suite that is\n * one trick less stable than its operator believes, discovered later as a flake\n * they have already paid to prevent. Failing here costs one run and one reading\n * of the message.\n */\nexport function recipeOf(ids: readonly string[]): Recipe {\n return ids.map((id) => {\n const intervention = interventionById(id);\n if (intervention === undefined) {\n throw new Error(\n `no stabilization trick is called \\`${id}\\`; this build knows ` +\n INTERVENTIONS.map((known) => `\\`${known.id}\\``).join(', '),\n );\n }\n return intervention;\n });\n}\n\n/** Only the tricks a tier can observe the effect of. */\nexport function forTier(recipe: Recipe, tier: Tier): Recipe {\n return recipe.filter((intervention) => tierReaches(tier, intervention.needs));\n}\n\n/**\n * Tricks in a recipe that claim the same property.\n *\n * Returned rather than thrown, so a caller can decide — an override is a\n * legitimate composition, and silently applying both is not. Two tricks over one\n * property means one of them wins by accident of ordering, and which one is\n * invisible in the result.\n */\nexport function conflicts(recipe: Recipe): readonly { governs: string; ids: readonly string[] }[] {\n const byProperty = new Map<string, string[]>();\n\n for (const intervention of recipe) {\n byProperty.set(intervention.governs, [\n ...(byProperty.get(intervention.governs) ?? []),\n intervention.id,\n ]);\n }\n\n return [...byProperty.entries()]\n .filter(([, ids]) => ids.length > 1)\n .map(([governs, ids]) => ({ governs, ids }));\n}\n\nexport function recipeCss(recipe: Recipe): string {\n return recipe\n .map((intervention) => intervention.css)\n .filter((css): css is string => css !== undefined)\n .join('\\n');\n}\n\nexport function recipeScreenshot(recipe: Recipe): ScreenshotOptions {\n return recipe.reduce<ScreenshotOptions>(\n (options, intervention) => ({ ...options, ...intervention.screenshot }),\n {},\n );\n}\n\nexport async function settleRecipe(recipe: Recipe, target: SettleTarget): Promise<void> {\n for (const intervention of recipe) {\n if (intervention.settle !== undefined) await intervention.settle(target);\n }\n}\n\n/**\n * The identity of a recipe.\n *\n * Sorted, so the order tricks were composed in does not change the identity —\n * only which ones are present. Folded into `RenderIdentity`, which is what makes\n * a cross-recipe comparison report `incomparable` instead of inventing a\n * component to blame.\n */\nexport function recipeDigest(recipe: Recipe): Digest {\n return digestValue(recipe.map((intervention) => intervention.id).sort());\n}\n\n/** The recipe as a sentence, so the gap between image and product stays visible. */\nexport function describeRecipe(recipe: Recipe): string {\n return recipe.length === 0\n ? 'the subject was observed untouched'\n : `the subject was altered to be observable: ${recipe.map((i) => i.because).join('; ')}`;\n}\n"]}
|
|
1
|
+
{"version":3,"file":"stabilize.js","sourceRoot":"","sources":["../../src/format/stabilize.ts"],"names":[],"mappings":"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","sourcesContent":["import { digestValue, type Digest } from './hash.js';\nimport { tierReaches, type Tier } from './tier.js';\nimport type { Intervention, PageGlobals, ScreenshotOptions, SettleTarget } from './intervention.js';\n\n/**\n * Interventions as separate, nameable tricks — not a fixed set of switches.\n *\n * A subject that is still changing cannot be compared, so every system of this\n * kind reaches into the page: it holds animations, waits for fonts, hides a\n * caret, suppresses scrollbars. Written as one struct of booleans that becomes a\n * closed vocabulary: adding a trick edits a type every caller depends on, and a\n * project with a need nobody anticipated has to fork.\n *\n * So each is a value. It carries an id, the tier that can actually observe what\n * it fixes, what it costs, and how it is applied — and a *recipe* is any list of\n * them. Two tricks may express the same intent through different mechanisms and\n * remain separate values, because they produce different images and choosing\n * between them is the caller's business.\n *\n * ## What this buys\n *\n * A recipe can be filtered by tier, so the structure-and-style rung waits for\n * nothing; composed from another project's tricks alongside these; digested, so\n * a baseline made under one recipe is `incomparable` with a run made under\n * another rather than `changed`; and checked for tricks that fight each other.\n *\n * ## Where the damage lands\n *\n * Ordered by increasing cost, earliest sufficient option first:\n *\n * - **Outside the subject** — injected CSS, browser screenshot options. Nothing\n * in the product imports it and deleting the tool deletes the intervention.\n * - **Runtime substitution** — wrapping `Promise`, replacing a suspense\n * boundary. Buys the same knowledge without touching a component and is\n * deliberately not shipped here: it moves the damage from design into\n * semantics, where a difference caused by the patch cannot be told apart from\n * a difference caused by the code. Expressible as a trick if a project decides\n * the trade is worth it — that is the point of an open set.\n * - **A contract the subject implements** — a readiness marker. Real design\n * damage, and reserved for what the outside genuinely cannot know.\n *\n * ## Why this is in `format` and not in the raster package\n *\n * It was in `@variance-authority/raster` until 2026-08-06, on the reading that\n * holding a page still is something you do before you photograph it. That\n * reading was wrong in a way that cost correctness: an animation in flight is a\n * *computed style value*, so it reaches the cheap representation too — and the\n * allowlist excludes `animation-*` and `transition-*` precisely because it\n * assumed a snapshot is taken with animation already disabled. Nothing disabled\n * it. See {@link COLLECT_RECIPE} and ADR-0028.\n *\n * So a recipe is a render input on every tier, `EnvironmentInputs` carries its\n * digest, and the vocabulary belongs beside the key it is part of.\n */\n\n// Re-exported from here because this is where a reader arrives looking for the\n// vocabulary; [`intervention.ts`](./intervention.js) holds the declaration.\nexport type { Intervention, ScreenshotOptions, SettleTarget, Trick } from './intervention.js';\n\n/**\n * Hand animations to the browser rather than emulating the hold in CSS.\n *\n * A browser disabling animations for a screenshot fast-forwards a finite\n * animation to completion — the state a user comes to rest on — and cancels an\n * infinite one to its initial frame, replaying it afterwards. That is the\n * behaviour worth having, and CSS cannot express it.\n */\nexport const holdAnimations: Intervention = {\n id: 'hold-animations',\n trick: 'hold',\n needs: 'layout',\n governs: 'animations',\n because: 'animations settled by the browser: finite fast-forwarded, infinite reset',\n screenshot: { animations: 'disabled' },\n};\n\n/**\n * The same intent without a browser that can do it, and it is not equivalent.\n *\n * Pins every animation at its first frame, so a fade-in is captured at the\n * moment it is invisible. `animation: none` is avoided deliberately — removing\n * an animation drops whatever layout its keyframes contribute, which changes the\n * page rather than stopping it.\n */\nexport const pinAnimations: Intervention = {\n id: 'pin-animations',\n trick: 'hold',\n needs: 'layout',\n governs: 'animations',\n because: 'animations pinned at their first frame, which is not where a user sees them',\n css:\n '*,*::before,*::after{' +\n 'animation-play-state:paused !important;' +\n 'animation-delay:-0.0001s !important;' +\n 'transition-duration:0s !important;' +\n 'transition-delay:0s !important;' +\n 'scroll-behavior:auto !important}',\n};\n\n/**\n * A stylesheet rather than the driver's own caret switch, deliberately.\n *\n * Playwright and its peers hide a caret by writing `caret-color` onto every\n * focusable element and putting it back afterwards, and putting it back leaves\n * `style=\"\"` where there was no `style` attribute at all. Any capture that reads\n * the DOM on both sides of a screenshot — which is what the in-place path does\n * to prove the subject held still — then sees markup that moved, and reports the\n * driver's housekeeping as the page being unstable. A page with one text input\n * is enough.\n *\n * So this hold owns the caret the way {@link pinAnimations} owns animations: one\n * stylesheet, installed before the subject is read, removed with the rest of the\n * recipe.\n */\nexport const hideCaret: Intervention = {\n id: 'hide-caret',\n trick: 'support',\n // A caret paints and does not lay out, so a tier that never rasterizes cannot\n // see it and must not pay to hide it.\n needs: 'raster',\n governs: 'caret',\n because: 'text caret hidden, because it blinks on its own schedule',\n css: '*{caret-color:transparent !important}',\n};\n\nexport const hideScrollbars: Intervention = {\n id: 'hide-scrollbars',\n trick: 'support',\n needs: 'layout',\n governs: 'scrollbars',\n because: 'scrollbars hidden, removing a platform and preference difference — and their width',\n css: '*{scrollbar-width:none !important}\\n*::-webkit-scrollbar{display:none !important}',\n};\n\n/**\n * Drop the pixels of images the page itself called decorative, keeping their boxes.\n *\n * The other half of blanking, and it lives here because of what it needs to\n * know. A driver rewriting responses (`@variance-authority/playwright`'s\n * `blank` rules) can decide by URL and by intrinsic size, which answers \"every\n * illustration over 40,000 pixels\" and answers it before the bytes are even\n * fetched. It cannot answer `role=\"presentation\"`, because a request carries no\n * idea which element wanted it — that fact exists only in the document, so the\n * trick that uses it is a stylesheet.\n *\n * `visibility:hidden` rather than `display:none`, and the distinction is the\n * entire design: a hidden element still occupies exactly the box it would have,\n * so a page whose column height comes from an image's intrinsic size is\n * unchanged. `display:none` would collapse it and report a layout regression\n * this tool caused.\n *\n * **Opt-in, and it belongs in no default recipe.** Every other trick here\n * removes something that is not part of the assertion — a caret, a scrollbar, an\n * animation mid-flight. This one removes page content, which is a judgement\n * about what a suite is for, and a default that quietly stopped watching every\n * `alt=\"\"` image would hide real regressions under a green run.\n */\nexport const hidePresentationalImages: Intervention = {\n id: 'hide-presentational-images',\n trick: 'support',\n // `layout` rather than `raster`, and the difference is whether it runs at all.\n // A raster-tier trick is filtered out of every *collection* — `tierOfProfile`\n // never returns `raster` — so it would only reach a page through a renderer\n // option, which is not where an operator configures their suite. A collection\n // sheet is in force when the document is serialized, so hiding it here is what\n // makes the pixels absent from the render.\n needs: 'layout',\n governs: 'presentational-images',\n because:\n 'images the page marked decorative hidden, keeping their boxes — real changes inside them ' +\n 'are not reported',\n css:\n 'img[role=\"presentation\"],img[alt=\"\"],[role=\"presentation\"] img,[role=\"none\"] img' +\n '{visibility:hidden !important}',\n};\n\nexport const waitForFonts: Intervention = {\n id: 'wait-for-fonts',\n trick: 'wait',\n needs: 'layout',\n governs: 'fonts',\n because: 'waited for web fonts, whose advances change every metric on the page',\n // Written without a module-scope helper on purpose: a settle closure is\n // shipped to a page as source text, so anything it names by identifier is a\n // `ReferenceError` on the far side. Types are erased and therefore free.\n settle: async (target) => {\n await target.evaluate(async () => {\n const view = globalThis as unknown as PageGlobals;\n await view.document.fonts?.ready;\n });\n },\n};\n\nexport const waitForImages: Intervention = {\n id: 'wait-for-images',\n trick: 'wait',\n needs: 'layout',\n governs: 'images',\n because:\n 'waited for images to decode, since their intrinsic size participates in layout, and asked ' +\n 'for the ones the browser had deferred',\n settle: async (target) => {\n await target.evaluate(async () => {\n const view = globalThis as unknown as PageGlobals;\n const pending = Array.from(view.document.images).filter((image) => !image.complete);\n\n const arrived = Promise.all(\n pending.map(\n (image) =>\n new Promise<void>((resolve) => {\n image.addEventListener('load', () => resolve(), { once: true });\n image.addEventListener('error', () => resolve(), { once: true });\n }),\n ),\n );\n\n // Asked for, after the listeners are attached and before anything is\n // awaited. A `loading=\"lazy\"` image outside the viewport has not been\n // requested and will not be until something scrolls, so waiting on it is\n // waiting for a decision the browser has already taken the other way.\n //\n // This is the same act as pinning an animation, not a different kind of\n // thing: what the page shows stops depending on where the viewport\n // happens to be. It matters most for the readings that need it most — a\n // full-page capture of a long page is exactly the case where most of the\n // images are deferred, and photographing it without them yields a picture\n // full of empty boxes whose contents change with the browser's loading\n // heuristics rather than with the product.\n const asServed = pending.map((image) => image.loading);\n for (const image of pending) image.loading = 'eager';\n\n // Bounded, and it throws when the bound is reached.\n //\n // A response can stall: a CDN that never answers, an image endpoint that\n // deadlocks under its own concurrency, an image the browser deferred and\n // will not request from where the page is scrolled. None of those fire\n // `load` and none fire `error`, so an unbounded wait turns one stuck\n // request into a run that never ends and never says why — not a wrong\n // answer, no answer.\n //\n // Giving up quietly would be worse than the hang in a subtler way: it\n // photographs a page with holes in it and reports the holes as a change,\n // inventing a regression out of the network this trick exists to hold\n // still. Refused instead, naming what it was still waiting for, because\n // \"these two never answered\" is a finding about the application.\n //\n // The bound is written twice — once as the delay, once in the sentence —\n // because a settle closure is shipped to the page as source text and a\n // constant it named would be a `ReferenceError` on the far side.\n // Held so the loser can be cancelled. A timer left to fire after the\n // images arrived rejects a promise nothing is waiting on any more, which\n // the page reports as an unhandled rejection — this trick's own noise,\n // arriving in the console of every subject it succeeded on.\n let timer: unknown;\n\n const expired = new Promise<never>((_, reject) => {\n timer = view.setTimeout(() => {\n const stuck = pending.filter((image) => !image.complete);\n reject(\n new Error(\n `${stuck.length} image(s) had not loaded after 15000ms, and neither answered ` +\n `nor failed: ${stuck.map((image) => image.currentSrc || image.src).join(', ')}`,\n ),\n );\n }, 15000);\n });\n\n try {\n await Promise.race([arrived, expired]);\n } finally {\n view.clearTimeout(timer);\n\n // Put back, because the trick has to be invisible to the tier that\n // reads the page after it. `loading` is a reflected attribute, so a\n // document whose deferred images were switched to `eager` and left that\n // way is a document that no longer matches the one the product served —\n // and which images were still undecoded when this ran is a question\n // about the network, so the edit lands in some readings and not others.\n // Left in, it makes the same page at two widths disagree about its own\n // markup: this trick filed as a finding against the application.\n for (let index = 0; index < pending.length; index += 1) {\n pending[index]!.loading = asServed[index]!;\n }\n }\n });\n },\n};\n\nexport const INTERVENTIONS: readonly Intervention[] = [\n holdAnimations,\n pinAnimations,\n hideCaret,\n hideScrollbars,\n hidePresentationalImages,\n waitForFonts,\n waitForImages,\n];\n\nexport type Recipe = readonly Intervention[];\n\n/**\n * Nothing at all, and correct for the tier that reads structure and declared style.\n *\n * A font that has not loaded cannot change which rules match or what they\n * declare; an image that has not decoded cannot either. Waiting for both before\n * a structure-and-style hash buys nothing and costs the wait per subject, on the\n * rung the tier ladder exists to make cheap.\n */\nexport const SEMANTIC_RECIPE: Recipe = [];\n\nexport const LAYOUT_RECIPE: Recipe = [holdAnimations, hideScrollbars, waitForFonts, waitForImages];\n\nexport const RASTER_RECIPE: Recipe = [...LAYOUT_RECIPE, hideCaret];\n\n/**\n * What is applied to a live page *before a subject is observed*, as opposed to\n * before it is painted.\n *\n * The two are different recipes and it is not a preference. {@link\n * holdAnimations} is a *screenshot option* — it asks the browser to settle\n * animations for the image it is about to take — and there is no screenshot at\n * collection time, so it would be a trick that silently does nothing. The CSS\n * variant is the one that works where nobody is holding a camera, which is the\n * whole reason both exist as separate values rather than as one switch.\n *\n * ## Why the cheap tier needs this at all\n *\n * The claim in [`ruleset.ts`](../rules/ruleset.ts) that `transition-*` and\n * `animation-*` \"describe a journey the snapshot does not contain\" is only true\n * if the snapshot is taken with the journey stopped. `transform`, `opacity`,\n * `filter`, `color` and every geometric longhand *are* admitted, and an\n * animation in flight moves all of them — so an unstabilized collection turns a\n * 300ms fade into a component-attributed regression with a real file name on it,\n * which is worse than an unexplained pixel diff because it is credible.\n *\n * Filtered by {@link forTier}, so jsdom applies none of it: with no layout\n * engine and no animation clock there is nothing to hold still, and paying a\n * `fonts.ready` wait per subject on the rung that exists to be cheap is exactly\n * the trade {@link SEMANTIC_RECIPE} refuses.\n */\nexport const COLLECT_RECIPE: Recipe = [\n pinAnimations,\n hideScrollbars,\n // Here as well as in {@link RASTER_RECIPE}, because the in-place path\n // photographs the live page and applies no screenshot options of its own: the\n // only caret hold that reaches that image is one installed here. A subject\n // holding a focused input otherwise disagrees with itself between two\n // screenshots taken 600ms apart, and is refused for blinking.\n hideCaret,\n waitForFonts,\n waitForImages,\n];\n\n/**\n * Resolve a trick by the name a caller wrote down.\n *\n * The boundary a collection recipe crosses is a `page.evaluate`, and an\n * `Intervention` does not survive it — `settle` is a function. Ids do survive,\n * and the page holds this same registry, so a recipe travels as the list of\n * names it is. A name nothing answers to is a caller error worth failing on\n * rather than a trick to skip quietly: under-stabilizing is how a suite gets a\n * flake it has already paid to prevent.\n */\nexport function interventionById(id: string): Intervention | undefined {\n return INTERVENTIONS.find((intervention) => intervention.id === id);\n}\n\n/**\n * A recipe from the names it travels as.\n *\n * Throws on a name nothing answers to, and names it. The quiet alternative —\n * skip what cannot be resolved — turns a typo in a config into a suite that is\n * one trick less stable than its operator believes, discovered later as a flake\n * they have already paid to prevent. Failing here costs one run and one reading\n * of the message.\n */\nexport function recipeOf(ids: readonly string[]): Recipe {\n return ids.map((id) => {\n const intervention = interventionById(id);\n if (intervention === undefined) {\n throw new Error(\n `no stabilization trick is called \\`${id}\\`; this build knows ` +\n INTERVENTIONS.map((known) => `\\`${known.id}\\``).join(', '),\n );\n }\n return intervention;\n });\n}\n\n/** Only the tricks a tier can observe the effect of. */\nexport function forTier(recipe: Recipe, tier: Tier): Recipe {\n return recipe.filter((intervention) => tierReaches(tier, intervention.needs));\n}\n\n/**\n * Tricks in a recipe that claim the same property.\n *\n * Returned rather than thrown, so a caller can decide — an override is a\n * legitimate composition, and silently applying both is not. Two tricks over one\n * property means one of them wins by accident of ordering, and which one is\n * invisible in the result.\n */\nexport function conflicts(recipe: Recipe): readonly { governs: string; ids: readonly string[] }[] {\n const byProperty = new Map<string, string[]>();\n\n for (const intervention of recipe) {\n byProperty.set(intervention.governs, [\n ...(byProperty.get(intervention.governs) ?? []),\n intervention.id,\n ]);\n }\n\n return [...byProperty.entries()]\n .filter(([, ids]) => ids.length > 1)\n .map(([governs, ids]) => ({ governs, ids }));\n}\n\nexport function recipeCss(recipe: Recipe): string {\n return recipe\n .map((intervention) => intervention.css)\n .filter((css): css is string => css !== undefined)\n .join('\\n');\n}\n\nexport function recipeScreenshot(recipe: Recipe): ScreenshotOptions {\n return recipe.reduce<ScreenshotOptions>(\n (options, intervention) => ({ ...options, ...intervention.screenshot }),\n {},\n );\n}\n\nexport async function settleRecipe(recipe: Recipe, target: SettleTarget): Promise<void> {\n for (const intervention of recipe) {\n if (intervention.settle !== undefined) await intervention.settle(target);\n }\n}\n\n/**\n * The identity of a recipe.\n *\n * Sorted, so the order tricks were composed in does not change the identity —\n * only which ones are present. Folded into `RenderIdentity`, which is what makes\n * a cross-recipe comparison report `incomparable` instead of inventing a\n * component to blame.\n */\nexport function recipeDigest(recipe: Recipe): Digest {\n return digestValue(recipe.map((intervention) => intervention.id).sort());\n}\n\n/** The recipe as a sentence, so the gap between image and product stays visible. */\nexport function describeRecipe(recipe: Recipe): string {\n return recipe.length === 0\n ? 'the subject was observed untouched'\n : `the subject was altered to be observable: ${recipe.map((i) => i.because).join('; ')}`;\n}\n"]}
|
package/dist/index.d.ts
CHANGED
|
@@ -19,17 +19,9 @@
|
|
|
19
19
|
* | `core/judge` | policy: verdicts, intent, the docket a reader is handed |
|
|
20
20
|
* | `core/plan` | a composition as a value, and the identity derived from it |
|
|
21
21
|
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
* everything else.
|
|
22
|
+
* Every name lives in exactly one of them; nothing is reachable from two paths.
|
|
23
|
+
* This entrypoint holds only what belongs to no group: the artifact a capture is
|
|
24
|
+
* written to.
|
|
26
25
|
*/
|
|
27
|
-
export * from './format/index.js';
|
|
28
|
-
export * from './rules/index.js';
|
|
29
|
-
export * from './compare/index.js';
|
|
30
|
-
export * from './attribute/index.js';
|
|
31
|
-
export * from './relate/index.js';
|
|
32
|
-
export * from './judge/index.js';
|
|
33
|
-
export * from './plan/index.js';
|
|
34
26
|
export * from './artifact.js';
|
|
35
27
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.js
CHANGED
|
@@ -19,17 +19,9 @@
|
|
|
19
19
|
* | `core/judge` | policy: verdicts, intent, the docket a reader is handed |
|
|
20
20
|
* | `core/plan` | a composition as a value, and the identity derived from it |
|
|
21
21
|
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
* everything else.
|
|
22
|
+
* Every name lives in exactly one of them; nothing is reachable from two paths.
|
|
23
|
+
* This entrypoint holds only what belongs to no group: the artifact a capture is
|
|
24
|
+
* written to.
|
|
26
25
|
*/
|
|
27
|
-
export * from './format/index.js';
|
|
28
|
-
export * from './rules/index.js';
|
|
29
|
-
export * from './compare/index.js';
|
|
30
|
-
export * from './attribute/index.js';
|
|
31
|
-
export * from './relate/index.js';
|
|
32
|
-
export * from './judge/index.js';
|
|
33
|
-
export * from './plan/index.js';
|
|
34
26
|
export * from './artifact.js';
|
|
35
27
|
//# sourceMappingURL=index.js.map
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AAEH,cAAc,eAAe,CAAC","sourcesContent":["/**\n * `@variance-authority/core` — the format, the rules, and the verdict model.\n *\n * This package is pure data in, pure data out: no DOM, no React, no I/O, no\n * async. That is enforced by its `tsconfig` (`lib: ES2022` only), so \"the core\n * cannot peek at a live document\" is a compile error rather than a convention\n * (ADR-0001). Collectors extract; core normalizes and adjudicates.\n *\n * Seven groups, each its own entrypoint, in the order an answer travels through\n * them:\n *\n * | entrypoint | what it holds |\n * |---|---|\n * | `core/format` | what a subject *is*: capture, snapshot, document, identity |\n * | `core/rules` | the versioned opinions: allowlist, cascade, canonicalization |\n * | `core/compare` | two snapshots become deltas — and no verdict |\n * | `core/attribute` | a position becomes a component becomes a file |\n * | `core/relate` | what reaches what: the graph a change is traversed over |\n * | `core/judge` | policy: verdicts, intent, the docket a reader is handed |\n * | `core/plan` | a composition as a value, and the identity derived from it |\n *\n * Every name lives in exactly one of them; nothing is reachable from two paths.\n * This entrypoint holds only what belongs to no group: the artifact a capture is\n * written to.\n */\n\nexport * from './artifact.js';\n"]}
|
package/dist/judge/inspect.js
CHANGED
|
@@ -55,6 +55,14 @@ export function inspect(snapshot) {
|
|
|
55
55
|
});
|
|
56
56
|
return findings;
|
|
57
57
|
function visit(node, insideControl) {
|
|
58
|
+
// Nothing below a node the browser never built. A responsive site renders
|
|
59
|
+
// the mobile navigation into the desktop document and hides it with a
|
|
60
|
+
// class, and a reader that counts it sees two `navigation` landmarks where
|
|
61
|
+
// Chrome's tree holds one — a duplicate nobody can reach, reported against
|
|
62
|
+
// markup that is correct. Every rule here is about what a screen reader
|
|
63
|
+
// encounters, so the gate belongs at the walk rather than in each rule.
|
|
64
|
+
if (!exposed(node))
|
|
65
|
+
return;
|
|
58
66
|
const interactive = node.role !== undefined && INTERACTIVE.has(node.role);
|
|
59
67
|
if (interactive && node.name === undefined) {
|
|
60
68
|
findings.push(finding('control-without-name', node, `<${node.tag}> is a ${node.role} with no accessible name`, snapshot));
|
|
@@ -124,6 +132,27 @@ export function inspect(snapshot) {
|
|
|
124
132
|
visit(child, insideControl || interactive);
|
|
125
133
|
}
|
|
126
134
|
}
|
|
135
|
+
/**
|
|
136
|
+
* Whether a screen reader reaches this node at all.
|
|
137
|
+
*
|
|
138
|
+
* Three ways out of the accessibility tree, and only one of them is declared.
|
|
139
|
+
* `aria-hidden="true"` and the `hidden` attribute are the author saying so, and
|
|
140
|
+
* arrive as state. `display: none` is the one nobody says: it is a utility
|
|
141
|
+
* class, a media query, a `<details>` that is closed at this width. All three
|
|
142
|
+
* take the subtree with them — `aria-hidden` is not reversible by a descendant,
|
|
143
|
+
* and neither is a box that was never generated.
|
|
144
|
+
*
|
|
145
|
+
* `display` is read from the node's own computed style, which the capture
|
|
146
|
+
* already carries: it is in the style allowlist, so this costs no new field and
|
|
147
|
+
* moves no hash. Absent — a structural-only profile, where no style was
|
|
148
|
+
* computed — the answer is "exposed", because refusing to report on a document
|
|
149
|
+
* whose visibility could not be observed would silently empty the inspection.
|
|
150
|
+
*/
|
|
151
|
+
function exposed(node) {
|
|
152
|
+
if (node.state?.['hidden'] === true)
|
|
153
|
+
return false;
|
|
154
|
+
return node.style['display'] !== 'none';
|
|
155
|
+
}
|
|
127
156
|
function hasHeaders(node) {
|
|
128
157
|
if (node.role === 'columnheader' || node.role === 'rowheader')
|
|
129
158
|
return true;
|
|
@@ -137,11 +166,35 @@ function hasHeaders(node) {
|
|
|
137
166
|
* has nothing to compare.
|
|
138
167
|
*/
|
|
139
168
|
function visibleTextOf(node) {
|
|
140
|
-
|
|
169
|
+
return runsOf(node).replace(/\s+/g, ' ').trim();
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* The same separator rule accname uses, so the two readings can be compared.
|
|
173
|
+
*
|
|
174
|
+
* `label-mismatch` asks whether the accessible name contains the visible words,
|
|
175
|
+
* and that question is only meaningful if both sides were assembled the same
|
|
176
|
+
* way. Joining every child with a space while `accessibleName` concatenated
|
|
177
|
+
* them produced the failure this rule exists to prevent, in the rule itself: a
|
|
178
|
+
* flex button reading "Search ⌘K" and named "Search⌘K" was reported as
|
|
179
|
+
* unreachable by voice, against markup a browser names correctly.
|
|
180
|
+
*
|
|
181
|
+
* So a child contributes separated only when its computed `display` is not
|
|
182
|
+
* inline — which is what a browser does, and which makes `<b>` inside a
|
|
183
|
+
* sentence contribute as part of the word it is inside.
|
|
184
|
+
*/
|
|
185
|
+
function runsOf(node) {
|
|
186
|
+
if (!exposed(node))
|
|
141
187
|
return '';
|
|
142
|
-
|
|
143
|
-
const
|
|
144
|
-
|
|
188
|
+
let text = node.text ?? '';
|
|
189
|
+
for (const child of node.children) {
|
|
190
|
+
const contribution = runsOf(child);
|
|
191
|
+
if (contribution.length === 0)
|
|
192
|
+
continue;
|
|
193
|
+
const display = child.style['display'];
|
|
194
|
+
const separated = display !== undefined && display !== 'inline' && display !== 'contents';
|
|
195
|
+
text += separated ? ` ${contribution} ` : contribution;
|
|
196
|
+
}
|
|
197
|
+
return text;
|
|
145
198
|
}
|
|
146
199
|
function levelOf(node) {
|
|
147
200
|
// `aria-level` wins, because it is what an assistive technology reads. The tag
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"inspect.js","sourceRoot":"","sources":["../../src/judge/inspect.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,MAAM,EAAE,MAAM,wBAAwB,CAAC;AAChD,OAAO,EAAE,YAAY,EAAE,aAAa,EAAoB,MAAM,wBAAwB,CAAC;AAmFvF;;;;;;;;;;;;;;;GAeG;AACH,MAAM,UAAU,WAAW,CAAC,OAAyD;IACnF,OAAO,GAAG,OAAO,CAAC,IAAI,IAAI,OAAO,CAAC,IAAI,EAAE,CAAC;AAC3C,CAAC;AAyCD,sFAAsF;AACtF,MAAM,WAAW,GAAG,IAAI,GAAG,CAAC;IAC1B,QAAQ,EAAE,MAAM,EAAE,UAAU,EAAE,OAAO,EAAE,QAAQ,EAAE,SAAS,EAAE,WAAW;IACvE,UAAU,EAAE,QAAQ,EAAE,YAAY,EAAE,UAAU,EAAE,kBAAkB;IAClE,eAAe,EAAE,KAAK,EAAE,UAAU;CACnC,CAAC,CAAC;AAEH,kFAAkF;AAClF,MAAM,oBAAoB,GAAG,IAAI,GAAG,CAAC,CAAC,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,SAAS,CAAC,CAAC,CAAC;AAEzE,MAAM,YAAY,GAAqC;IACrD,EAAE,EAAE,CAAC,EAAE,EAAE,EAAE,CAAC,EAAE,EAAE,EAAE,CAAC,EAAE,EAAE,EAAE,CAAC,EAAE,EAAE,EAAE,CAAC,EAAE,EAAE,EAAE,CAAC;CACzC,CAAC;AAEF,gEAAgE;AAChE,MAAM,SAAS,GAAG,IAAI,GAAG,CAAC;IACxB,QAAQ,EAAE,YAAY,EAAE,MAAM,EAAE,eAAe,EAAE,aAAa;IAC9D,QAAQ,EAAE,MAAM,EAAE,QAAQ;CAC3B,CAAC,CAAC;AAEH,+DAA+D;AAC/D,MAAM,UAAU,GAAG,QAAQ,CAAC;AAE5B,MAAM,UAAU,OAAO,CAAC,QAA0B;IAChD,MAAM,QAAQ,GAAc,EAAE,CAAC;IAC/B,MAAM,QAAQ,GAA4C,EAAE,CAAC;IAC7D,MAAM,SAAS,GAAG,IAAI,GAAG,EAAwB,CAAC;IAElD,KAAK,CAAC,QAAQ,CAAC,IAAI,EAAE,KAAK,CAAC,CAAC;IAE5B,6EAA6E;IAC7E,8EAA8E;IAC9E,+EAA+E;IAC/E,yBAAyB;IACzB,QAAQ,CAAC,OAAO,CAAC,CAAC,OAAO,EAAE,KAAK,EAAE,EAAE;QAClC,MAAM,QAAQ,GAAG,QAAQ,CAAC,KAAK,GAAG,CAAC,CAAC,CAAC;QACrC,IAAI,QAAQ,KAAK,SAAS,IAAI,OAAO,CAAC,KAAK,IAAI,QAAQ,CAAC,KAAK,GAAG,CAAC;YAAE,OAAO;QAE1E,QAAQ,CAAC,IAAI,CACX,OAAO,CACL,uBAAuB,EACvB,OAAO,CAAC,IAAI,EACZ,4BAA4B,QAAQ,CAAC,KAAK,OAAO,OAAO,CAAC,KAAK,EAAE;YAC9D,CAAC,OAAO,CAAC,IAAI,CAAC,IAAI,KAAK,SAAS,CAAC,CAAC,CAAC,QAAQ,OAAO,CAAC,IAAI,CAAC,IAAI,GAAG,CAAC,CAAC,CAAC,EAAE,CAAC,EACvE,QAAQ,CACT,CACF,CAAC;IACJ,CAAC,CAAC,CAAC;IAEH,OAAO,QAAQ,CAAC;IAEhB,SAAS,KAAK,CAAC,IAAkB,EAAE,aAAsB;QACvD,MAAM,WAAW,GAAG,IAAI,CAAC,IAAI,KAAK,SAAS,IAAI,WAAW,CAAC,GAAG,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;QAE1E,IAAI,WAAW,IAAI,IAAI,CAAC,IAAI,KAAK,SAAS,EAAE,CAAC;YAC3C,QAAQ,CAAC,IAAI,CACX,OAAO,CACL,sBAAsB,EACtB,IAAI,EACJ,IAAI,IAAI,CAAC,GAAG,UAAU,IAAI,CAAC,IAAI,0BAA0B,EACzD,QAAQ,CACT,CACF,CAAC;QACJ,CAAC;QAED,IAAI,WAAW,IAAI,aAAa,EAAE,CAAC;YACjC,QAAQ,CAAC,IAAI,CACX,OAAO,CACL,oBAAoB,EACpB,IAAI,EACJ,KAAK,IAAI,CAAC,IAAI,qEAAqE,EACnF,QAAQ,CACT,CACF,CAAC;QACJ,CAAC;QAED,4EAA4E;QAC5E,sEAAsE;QACtE,IAAI,IAAI,CAAC,IAAI,KAAK,KAAK,IAAI,IAAI,CAAC,IAAI,KAAK,SAAS,IAAI,IAAI,CAAC,UAAU,CAAC,KAAK,CAAC,KAAK,SAAS,EAAE,CAAC;YAC3F,QAAQ,CAAC,IAAI,CACX,OAAO,CACL,mBAAmB,EACnB,IAAI,EACJ,IAAI,IAAI,CAAC,GAAG,gFAAgF,EAC5F,QAAQ,CACT,CACF,CAAC;QACJ,CAAC;QAED,KAAK,MAAM,CAAC,SAAS,EAAE,KAAK,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,IAAI,CAAC,UAAU,CAAC,EAAE,CAAC;YACjE,IAAI,CAAC,oBAAoB,CAAC,GAAG,CAAC,SAAS,CAAC,IAAI,CAAC,KAAK,CAAC,QAAQ,CAAC,UAAU,CAAC;gBAAE,SAAS;YAElF,QAAQ,CAAC,IAAI,CACX,OAAO,CACL,oBAAoB,EACpB,IAAI;YACJ,kEAAkE;YAClE,uEAAuE;YACvE,IAAI,IAAI,CAAC,GAAG,IAAI,SAAS,qDAAqD,EAC9E,QAAQ,CACT,CACF,CAAC;QACJ,CAAC;QAED,IAAI,IAAI,CAAC,IAAI,KAAK,SAAS,EAAE,CAAC;YAC5B,MAAM,KAAK,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;YAC5B,IAAI,KAAK,KAAK,SAAS;gBAAE,QAAQ,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,KAAK,EAAE,CAAC,CAAC;QAC1D,CAAC;QAED,IAAI,IAAI,CAAC,IAAI,KAAK,SAAS,IAAI,SAAS,CAAC,GAAG,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,CAAC;YACxD,wEAAwE;YACxE,2EAA2E;YAC3E,8CAA8C;YAC9C,MAAM,GAAG,GAAG,GAAG,IAAI,CAAC,IAAI,SAAS,IAAI,CAAC,IAAI,IAAI,EAAE,EAAE,CAAC;YACnD,MAAM,KAAK,GAAG,SAAS,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC;YAEjC,IAAI,KAAK,KAAK,SAAS,EAAE,CAAC;gBACxB,SAAS,CAAC,GAAG,CAAC,GAAG,EAAE,IAAI,CAAC,CAAC;YAC3B,CAAC;iBAAM,CAAC;gBACN,QAAQ,CAAC,IAAI,CACX,OAAO,CACL,oBAAoB,EACpB,IAAI,EACJ,YAAY,IAAI,CAAC,IAAI,WAAW;oBAC9B,CAAC,IAAI,CAAC,IAAI,KAAK,SAAS,CAAC,CAAC,CAAC,gBAAgB,IAAI,CAAC,IAAI,GAAG,CAAC,CAAC,CAAC,eAAe,CAAC;oBAC1E,qDAAqD,EACvD,QAAQ,CACT,CACF,CAAC;YACJ,CAAC;QACH,CAAC;QAED,IAAI,IAAI,CAAC,IAAI,KAAK,OAAO,IAAI,CAAC,UAAU,CAAC,IAAI,CAAC,EAAE,CAAC;YAC/C,QAAQ,CAAC,IAAI,CACX,OAAO,CACL,uBAAuB,EACvB,IAAI,EACJ,IAAI,IAAI,CAAC,GAAG,wEAAwE;gBAClF,0BAA0B,EAC5B,QAAQ,CACT,CACF,CAAC;QACJ,CAAC;QAED,MAAM,QAAQ,GAAG,MAAM,CAAC,IAAI,CAAC,UAAU,CAAC,UAAU,CAAC,CAAC,CAAC;QACrD,IAAI,MAAM,CAAC,SAAS,CAAC,QAAQ,CAAC,IAAI,QAAQ,GAAG,CAAC,EAAE,CAAC;YAC/C,QAAQ,CAAC,IAAI,CACX,OAAO,CACL,mBAAmB,EACnB,IAAI,EACJ,IAAI,IAAI,CAAC,GAAG,kBAAkB,QAAQ,6CAA6C;gBACjF,0CAA0C,EAC5C,QAAQ,CACT,CACF,CAAC;QACJ,CAAC;QAED,2EAA2E;QAC3E,wEAAwE;QACxE,gDAAgD;QAChD,EAAE;QACF,uEAAuE;QACvE,6EAA6E;QAC7E,sDAAsD;QACtD,IAAI,WAAW,IAAI,IAAI,CAAC,IAAI,KAAK,SAAS,EAAE,CAAC;YAC3C,MAAM,OAAO,GAAG,aAAa,CAAC,IAAI,CAAC,CAAC;YACpC,IACE,OAAO,CAAC,MAAM,GAAG,CAAC;gBAClB,UAAU,CAAC,IAAI,CAAC,OAAO,CAAC;gBACxB,CAAC,IAAI,CAAC,IAAI,CAAC,WAAW,EAAE,CAAC,QAAQ,CAAC,OAAO,CAAC,WAAW,EAAE,CAAC,EACxD,CAAC;gBACD,QAAQ,CAAC,IAAI,CACX,OAAO,CACL,gBAAgB,EAChB,IAAI,EACJ,UAAU,OAAO,mBAAmB,IAAI,CAAC,IAAI,kCAAkC;oBAC7E,iCAAiC,EACnC,QAAQ,CACT,CACF,CAAC;YACJ,CAAC;QACH,CAAC;QAED,KAAK,MAAM,KAAK,IAAI,IAAI,CAAC,QAAQ;YAAE,KAAK,CAAC,KAAK,EAAE,aAAa,IAAI,WAAW,CAAC,CAAC;IAChF,CAAC;AACH,CAAC;AAED,SAAS,UAAU,CAAC,IAAkB;IACpC,IAAI,IAAI,CAAC,IAAI,KAAK,cAAc,IAAI,IAAI,CAAC,IAAI,KAAK,WAAW;QAAE,OAAO,IAAI,CAAC;IAC3E,OAAO,IAAI,CAAC,QAAQ,CAAC,IAAI,CAAC,UAAU,CAAC,CAAC;AACxC,CAAC;AAED;;;;;;GAMG;AACH,SAAS,aAAa,CAAC,IAAkB;IACvC,IAAI,IAAI,CAAC,KAAK,EAAE,CAAC,QAAQ,CAAC,KAAK,IAAI;QAAE,OAAO,EAAE,CAAC;IAE/C,MAAM,GAAG,GAAG,IAAI,CAAC,IAAI,IAAI,EAAE,CAAC;IAC5B,MAAM,QAAQ,GAAG,IAAI,CAAC,QAAQ,CAAC,GAAG,CAAC,aAAa,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;IAE5D,OAAO,GAAG,GAAG,IAAI,QAAQ,EAAE,CAAC,OAAO,CAAC,MAAM,EAAE,GAAG,CAAC,CAAC,IAAI,EAAE,CAAC;AAC1D,CAAC;AAED,SAAS,OAAO,CAAC,IAAkB;IACjC,+EAA+E;IAC/E,8EAA8E;IAC9E,MAAM,QAAQ,GAAG,IAAI,CAAC,KAAK,EAAE,CAAC,OAAO,CAAC,CAAC;IACvC,IAAI,OAAO,QAAQ,KAAK,QAAQ;QAAE,OAAO,QAAQ,CAAC;IAClD,IAAI,OAAO,QAAQ,KAAK,QAAQ,IAAI,OAAO,CAAC,IAAI,CAAC,QAAQ,CAAC;QAAE,OAAO,MAAM,CAAC,QAAQ,CAAC,CAAC;IAEpF,OAAO,YAAY,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;AAChC,CAAC;AAED,SAAS,OAAO,CACd,IAAiB,EACjB,IAAkB,EAClB,IAAY,EACZ,QAA0B;IAE1B,MAAM,KAAK,GAAG,MAAM,CAAC,QAAQ,CAAC,IAAI,EAAE,IAAI,CAAC,IAAI,CAAC,CAAC,KAAK,CAAC;IACrD,MAAM,SAAS,GAAG,IAAI,CAAC,UAAU,EAAE,SAAS,IAAI,IAAI,CAAC,UAAU,EAAE,MAAM,CAAC,CAAC,CAAC,EAAE,IAAI,CAAC;IAEjF,OAAO;QACL,IAAI;QACJ,IAAI,EAAE,MAAM;QACZ,IAAI,EAAE,IAAI,CAAC,IAAI;QACf,IAAI;QACJ,GAAG,CAAC,KAAK,KAAK,EAAE,CAAC,CAAC,CAAC,EAAE,KAAK,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;QAClC,GAAG,CAAC,SAAS,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,SAAS,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;QACjD,GAAG,CAAC,IAAI,CAAC,UAAU,CAAC,CAAC,CAAC,EAAE,MAAM,EAAE,IAAI,CAAC,UAAU,CAAC,MAAM,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;QAC9D,GAAG,CAAC,IAAI,CAAC,UAAU,EAAE,MAAM,CAAC,CAAC,CAAC,EAAE,MAAM,EAAE,IAAI,CAAC,UAAU,CAAC,MAAM,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;KACvE,CAAC;AACJ,CAAC;AAOD;;;;;GAKG;AACH,MAAM,UAAU,iBAAiB,CAC/B,QAA4B,EAC5B,OAAO,GAA4B,EAAE;IAErC,IAAI,QAAQ,CAAC,MAAM,KAAK,CAAC;QAAE,OAAO,cAAc,CAAC;IAEjD,MAAM,MAAM,GAAG,IAAI,GAAG,EAAuB,CAAC;IAC9C,KAAK,MAAM,KAAK,IAAI,QAAQ;QAAE,MAAM,CAAC,GAAG,CAAC,KAAK,CAAC,IAAI,EAAE,CAAC,MAAM,CAAC,GAAG,CAAC,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC;IAExF,MAAM,MAAM,GACV,GAAG,QAAQ,CAAC,MAAM,WAAW,QAAQ,CAAC,MAAM,KAAK,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,GAAG,IAAI;QACjE,CAAC,GAAG,MAAM,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,IAAI,EAAE,KAAK,CAAC,EAAE,EAAE,CAAC,GAAG,KAAK,IAAI,IAAI,EAAE,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;IAEpE,MAAM,KAAK,GAAG,QAAQ,CAAC,OAAO,CAAC,CAAC,KAAK,EAAE,EAAE;QACvC,yEAAyE;QACzE,2EAA2E;QAC3E,6EAA6E;QAC7E,6CAA6C;QAC7C,MAAM,QAAQ,GACZ,KAAK,CAAC,SAAS,KAAK,SAAS,IAAI,OAAO,CAAC,MAAM;YAC7C,CAAC,CAAC,aAAa,CAAC,KAAK,CAAC,SAAS,EAAE,OAAO,CAAC,MAAM,CAAC;YAChD,CAAC,CAAC,IAAI,CAAC;QACX,MAAM,KAAK,GACT,KAAK,CAAC,MAAM,KAAK,SAAS;YACxB,CAAC,CAAC,GAAG,KAAK,CAAC,MAAM,CAAC,IAAI,IAAI,MAAM,CAAC,KAAK,CAAC,MAAM,CAAC,IAAI,CAAC,EAAE;YACrD,CAAC,CAAC,QAAQ;gBACR,CAAC,CAAC,YAAY,CAAC,QAAQ,CAAC;gBACxB,CAAC,CAAC,EAAE,CAAC;QAEX,MAAM,WAAW,GAAG,CAAC,KAAK,CAAC,SAAS,EAAE,KAAK,KAAK,EAAE,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,KAAK,CAAC;aACpE,MAAM,CAAC,CAAC,IAAI,EAAkB,EAAE,CAAC,IAAI,KAAK,SAAS,CAAC;aACpD,IAAI,CAAC,GAAG,CAAC,CAAC;QAEb,OAAO;YACL,MAAM,KAAK,CAAC,IAAI,KAAK,KAAK,CAAC,IAAI,EAAE;YACjC,KAAK,CAAC,KAAK,KAAK,SAAS,CAAC,CAAC,CAAC,YAAY,KAAK,CAAC,KAAK,EAAE,CAAC,CAAC,CAAC,IAAI;YAC5D,WAAW,KAAK,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,SAAS,WAAW,EAAE;SACnD,CAAC,MAAM,CAAC,CAAC,IAAI,EAAkB,EAAE,CAAC,IAAI,KAAK,IAAI,CAAC,CAAC;IACpD,CAAC,CAAC,CAAC;IAEH,OAAO,CAAC,MAAM,EAAE,GAAG,KAAK,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;AACvC,CAAC","sourcesContent":["import type { Band } from '../compare/band.js';\nimport type { OwnerFrame, SourceLocation } from '../format/provenance.js';\nimport type { NodePath, SemanticNode, SemanticSnapshot } from '../format/snapshot.js';\nimport { locate } from '../attribute/locate.js';\nimport { formatSource, resolveSource, type SourceIndex } from '../attribute/source.js';\n\n/**\n * What a render says without a baseline.\n *\n * Two producers, one `Finding` type. `inspect` reads a single snapshot;\n * `compareLocales` reads two renders of one subject in different languages,\n * which is also not a regression comparison — both renders are correct. Sharing\n * the type is what lets the report, the CLI and the MCP tools carry both without\n * knowing which produced what.\n *\n * The rest of this comment is about `inspect`, which reads one snapshot with no\n * baseline and no second run.\n *\n * Everything else in this package answers *what changed*, which requires two of\n * something. That framing has a blind spot the whole category shares: **a defect\n * that was present on the first run is invisible to a comparison forever.** A\n * button that never had an accessible name compares equal to itself on every run\n * until someone happens to edit it, and a picture of it is a perfectly good\n * picture. Approving the first baseline approves the defect.\n *\n * These rules read the normalized snapshot and report defects in it. Consequences\n * worth stating, because they are the practical argument for having the document\n * rather than an image of it:\n *\n * - **A fresh checkout with zero baselines is already useful.** The expensive\n * part of adopting a visual-regression tool is that it says nothing until it\n * has a history; this half says something on the first run.\n * - **Every finding names a component and a file**, through the same provenance\n * chain a delta uses. A rule engine that reports a DOM path reports where the\n * symptom is; this reports whose JSX wrote it.\n * - **They band as `a11y`**, so a project that writes `blocking: ['a11y']` gets\n * both halves under one policy: regressions found by comparison, and defects\n * found by inspection.\n *\n * Deliberately *not* an axe-core reimplementation. Axe runs against a live DOM\n * with computed visibility, contrast and focus order, and does dozens of things\n * this cannot. What is here is the subset a normalized snapshot can decide\n * without guessing — and the subset it can decide **offline, from a stored\n * artifact, months later**, which is a thing axe cannot do at all.\n *\n * There is no severity field. A rule that needs one to be tolerable is a rule\n * whose condition is too broad, and the fix is a better condition or no rule.\n *\n * **Why there is no contrast rule, stated here so nobody adds one badly later.**\n * The snapshot carries a resolved `color` and `background-color` per node, so a\n * check looks like four lines and would be wrong: the background a glyph is\n * actually painted on is whatever is behind it, which is a stacking question a\n * layout engine answers and a document does not. A node with a transparent\n * background over a dark ancestor, an image, a gradient, or a positioned sibling\n * all read as \"background-color: rgba(0,0,0,0)\" here. A rule that is right most\n * of the time about accessibility is worse than no rule: it gets disabled after\n * the second false alarm, and takes the four that work with it. Spec 0009\n * records the same for focus order and anything about motion.\n */\n\nexport type FindingRule =\n /** An interactive control with no accessible name. Nothing announces it. */\n | 'control-without-name'\n /** A meaningful image with neither a name nor an explicit `alt=\"\"`. */\n | 'image-without-alt'\n /** Heading levels jump forward by more than one. */\n | 'heading-level-skipped'\n /** A control inside another control. Only one of them is reachable. */\n | 'nested-interactive'\n /** An id reference that resolves to nothing inside this subject. */\n | 'dangling-reference'\n /** A control whose accessible name does not contain its visible label. */\n | 'label-mismatch'\n /** Two landmarks of one role that nothing tells apart. */\n | 'duplicate-landmark'\n /** A table with no header cells. */\n | 'table-without-headers'\n /** `tabindex` above zero, which reorders focus for the whole page. */\n | 'positive-tabindex'\n /**\n * The same string in two locales. From `compareLocales`, not from `inspect` —\n * one render cannot know whether its text was translated.\n */\n | 'untranslated'\n /** A box outside the box that contains it, in one locale and not the other. */\n | 'overflows-container';\n\n/**\n * The identity of a finding, as a string two runs can be compared on.\n *\n * A finding is *the same finding* when the same rule fires on the same node. Not\n * the same sentence: `what` quotes the text it found, so a copy edit beside a\n * control with no accessible name would read as the old defect going away and a\n * new one arriving in the same place. And not the same component either — a\n * component renders in many places, and every one of them would collapse to one.\n *\n * Small on purpose. This is what a baseline carries so that the next run can say\n * whether a defect it found was already there, and a baseline sidecar rides\n * beside a PNG in a tracked directory: a mark is a few dozen bytes and a stored\n * `Finding` is a paragraph. It can only answer membership, which is the only\n * question the next run is allowed to ask of it — the same restraint\n * `Described.components` is written under.\n */\nexport function findingMark(finding: { readonly rule: string; readonly path: string }): string {\n return `${finding.rule}@${finding.path}`;\n}\n\nexport interface Finding {\n readonly rule: FindingRule;\n\n /**\n * The band this finding would block under.\n *\n * Carried so that inspection and comparison answer to one policy. A team that\n * blocks `a11y` should not have to discover that it blocks regressions and not\n * defects.\n */\n readonly band: Band;\n\n readonly path: NodePath;\n\n /** One sentence, naming the thing rather than the rule. */\n readonly what: string;\n\n /** Spoken outside-in, from `locate` — `main → region \"Todos\" → item 2 of 3`. */\n readonly where?: string;\n\n /** The component whose JSX created the node, when provenance reached it. */\n readonly component?: string;\n readonly owners?: readonly OwnerFrame[];\n\n /**\n * The line that wrote *this element*, when a JSX runtime recorded one.\n *\n * Different from — and better than — resolving `component` through the source\n * index, which answers with where the component is *declared*. A defect is\n * rarely at a declaration: a button with no accessible name is a specific\n * element on a specific line inside that component, and that line is the edit.\n * The index stays as the fallback, because it needs no build change.\n *\n * Requires `@variance-authority/jsx-source` in the transform. Absent otherwise,\n * and absence is normal.\n */\n readonly source?: SourceLocation;\n}\n\n/** Roles that are operated. A control nobody can name is a control nobody can use. */\nconst INTERACTIVE = new Set([\n 'button', 'link', 'checkbox', 'radio', 'switch', 'textbox', 'searchbox',\n 'combobox', 'slider', 'spinbutton', 'menuitem', 'menuitemcheckbox',\n 'menuitemradio', 'tab', 'treeitem',\n]);\n\n/** Attributes whose value the aliaser rewrote, so `#extern:` means unresolved. */\nconst REFERENCE_ATTRIBUTES = new Set(['for', 'form', 'list', 'headers']);\n\nconst HEADING_TAGS: Readonly<Record<string, number>> = {\n h1: 1, h2: 2, h3: 3, h4: 4, h5: 5, h6: 6,\n};\n\n/** Regions of a page a screen reader user navigates between. */\nconst LANDMARKS = new Set([\n 'banner', 'navigation', 'main', 'complementary', 'contentinfo',\n 'region', 'form', 'search',\n]);\n\n/** Has at least one letter. A glyph is not a visible label. */\nconst HAS_LETTER = /\\p{L}/u;\n\nexport function inspect(snapshot: SemanticSnapshot): readonly Finding[] {\n const findings: Finding[] = [];\n const headings: { node: SemanticNode; level: number }[] = [];\n const landmarks = new Map<string, SemanticNode>();\n\n visit(snapshot.root, false);\n\n // Heading order is a property of the sequence, not of any one node, so it is\n // decided after the walk. Only *forward* jumps are reported: a component that\n // starts at `h3` is a component rendered inside something, not a defect, and a\n // subject is not a page.\n headings.forEach((heading, index) => {\n const previous = headings[index - 1];\n if (previous === undefined || heading.level <= previous.level + 1) return;\n\n findings.push(\n finding(\n 'heading-level-skipped',\n heading.node,\n `heading level jumps from ${previous.level} to ${heading.level}` +\n (heading.node.name !== undefined ? ` at \"${heading.node.name}\"` : ''),\n snapshot,\n ),\n );\n });\n\n return findings;\n\n function visit(node: SemanticNode, insideControl: boolean): void {\n const interactive = node.role !== undefined && INTERACTIVE.has(node.role);\n\n if (interactive && node.name === undefined) {\n findings.push(\n finding(\n 'control-without-name',\n node,\n `<${node.tag}> is a ${node.role} with no accessible name`,\n snapshot,\n ),\n );\n }\n\n if (interactive && insideControl) {\n findings.push(\n finding(\n 'nested-interactive',\n node,\n `a ${node.role} is nested inside another control; only one of the two is reachable`,\n snapshot,\n ),\n );\n }\n\n // `alt=\"\"` is the author saying \"decorative\", which is a correct answer and\n // must not be reported. A missing `alt` is the author saying nothing.\n if (node.role === 'img' && node.name === undefined && node.attributes['alt'] === undefined) {\n findings.push(\n finding(\n 'image-without-alt',\n node,\n `<${node.tag}> has an image role, no accessible name, and no alt=\"\" to say it is decorative`,\n snapshot,\n ),\n );\n }\n\n for (const [attribute, value] of Object.entries(node.attributes)) {\n if (!REFERENCE_ATTRIBUTES.has(attribute) || !value.includes('#extern:')) continue;\n\n findings.push(\n finding(\n 'dangling-reference',\n node,\n // Honest about the ambiguity the aliaser records: from inside the\n // subject, escaping the subtree and pointing at nothing look the same.\n `<${node.tag} ${attribute}> references an element that is not in this subject`,\n snapshot,\n ),\n );\n }\n\n if (node.role === 'heading') {\n const level = levelOf(node);\n if (level !== undefined) headings.push({ node, level });\n }\n\n if (node.role !== undefined && LANDMARKS.has(node.role)) {\n // Keyed by role *and* name: two `navigation` landmarks called \"Primary\"\n // and \"Footer\" are how a page is meant to be built. Two called nothing are\n // two identical entries in the landmark list.\n const key = `${node.role}\\u0000${node.name ?? ''}`;\n const first = landmarks.get(key);\n\n if (first === undefined) {\n landmarks.set(key, node);\n } else {\n findings.push(\n finding(\n 'duplicate-landmark',\n node,\n `a second ${node.role} landmark` +\n (node.name !== undefined ? ` also named \"${node.name}\"` : ' with no name') +\n ', so nothing tells the two apart in a landmark list',\n snapshot,\n ),\n );\n }\n }\n\n if (node.role === 'table' && !hasHeaders(node)) {\n findings.push(\n finding(\n 'table-without-headers',\n node,\n `<${node.tag}> is a table with no header cells, so every cell is announced without ` +\n 'the column it belongs to',\n snapshot,\n ),\n );\n }\n\n const tabindex = Number(node.attributes['tabindex']);\n if (Number.isInteger(tabindex) && tabindex > 0) {\n findings.push(\n finding(\n 'positive-tabindex',\n node,\n `<${node.tag}> has tabindex=${tabindex}, which pulls it ahead of every element in ` +\n 'the document that relies on source order',\n snapshot,\n ),\n );\n }\n\n // WCAG 2.5.3. A control whose accessible name does not contain its visible\n // label cannot be operated by voice: \"click Save\" does nothing when the\n // button reads Save and is named \"Submit form\".\n //\n // Only when the visible text carries a letter. An icon button labelled\n // `aria-label=\"Refresh\"` around a glyph is correct, and a rule that reported\n // it would fire on every icon in every design system.\n if (interactive && node.name !== undefined) {\n const visible = visibleTextOf(node);\n if (\n visible.length > 0 &&\n HAS_LETTER.test(visible) &&\n !node.name.toLowerCase().includes(visible.toLowerCase())\n ) {\n findings.push(\n finding(\n 'label-mismatch',\n node,\n `reads \"${visible}\" and is named \"${node.name}\", so a voice command using the ` +\n 'visible words does not reach it',\n snapshot,\n ),\n );\n }\n }\n\n for (const child of node.children) visit(child, insideControl || interactive);\n }\n}\n\nfunction hasHeaders(node: SemanticNode): boolean {\n if (node.role === 'columnheader' || node.role === 'rowheader') return true;\n return node.children.some(hasHeaders);\n}\n\n/**\n * Text a sighted user reads, normalized to single spaces.\n *\n * `aria-hidden` subtrees are excluded, which is what makes the icon-button case\n * work: the glyph is hidden, so the visible text is empty and `label-mismatch`\n * has nothing to compare.\n */\nfunction visibleTextOf(node: SemanticNode): string {\n if (node.state?.['hidden'] === true) return '';\n\n const own = node.text ?? '';\n const children = node.children.map(visibleTextOf).join(' ');\n\n return `${own} ${children}`.replace(/\\s+/g, ' ').trim();\n}\n\nfunction levelOf(node: SemanticNode): number | undefined {\n // `aria-level` wins, because it is what an assistive technology reads. The tag\n // is the fallback, and a `role=\"heading\"` with neither is not a level at all.\n const declared = node.state?.['level'];\n if (typeof declared === 'number') return declared;\n if (typeof declared === 'string' && /^\\d+$/.test(declared)) return Number(declared);\n\n return HEADING_TAGS[node.tag];\n}\n\nfunction finding(\n rule: FindingRule,\n node: SemanticNode,\n what: string,\n snapshot: SemanticSnapshot,\n): Finding {\n const where = locate(snapshot.root, node.path).where;\n const component = node.provenance?.createdBy ?? node.provenance?.owners[0]?.name;\n\n return {\n rule,\n band: 'a11y',\n path: node.path,\n what,\n ...(where !== '' ? { where } : {}),\n ...(component !== undefined ? { component } : {}),\n ...(node.provenance ? { owners: node.provenance.owners } : {}),\n ...(node.provenance?.source ? { source: node.provenance.source } : {}),\n };\n}\n\nexport interface InspectionReportOptions {\n /** Component → file, so a finding names an edit rather than an identifier. */\n readonly source?: SourceIndex;\n}\n\n/**\n * Findings as the thing a reviewer or an agent reads.\n *\n * Same shape as `summarizeAdjudication`: what, where, which file. A finding a\n * reader has to go and locate is a finding that gets skipped.\n */\nexport function summarizeFindings(\n findings: readonly Finding[],\n options: InspectionReportOptions = {},\n): string {\n if (findings.length === 0) return 'no findings.';\n\n const byRule = new Map<FindingRule, number>();\n for (const found of findings) byRule.set(found.rule, (byRule.get(found.rule) ?? 0) + 1);\n\n const header =\n `${findings.length} finding${findings.length === 1 ? '' : 's'}: ` +\n [...byRule].map(([rule, count]) => `${count} ${rule}`).join(', ');\n\n const lines = findings.flatMap((found) => {\n // The element's own line wins over the component's declaration. Both are\n // `file:line`, so the reader cannot tell them apart and does not need to —\n // either one opens at something worth editing, and the recorded one opens at\n // the element the finding is actually about.\n const declared =\n found.component !== undefined && options.source\n ? resolveSource(found.component, options.source)\n : null;\n const where =\n found.source !== undefined\n ? `${found.source.file}:${String(found.source.line)}`\n : declared\n ? formatSource(declared)\n : '';\n\n const attribution = [found.component, where === '' ? undefined : where]\n .filter((part): part is string => part !== undefined)\n .join(' ');\n\n return [\n ` [${found.rule}] ${found.what}`,\n found.where !== undefined ? ` in ${found.where}` : null,\n attribution === '' ? null : ` ${attribution}`,\n ].filter((line): line is string => line !== null);\n });\n\n return [header, ...lines].join('\\n');\n}\n"]}
|
|
1
|
+
{"version":3,"file":"inspect.js","sourceRoot":"","sources":["../../src/judge/inspect.ts"],"names":[],"mappings":"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","sourcesContent":["import type { Band } from '../compare/band.js';\nimport type { OwnerFrame, SourceLocation } from '../format/provenance.js';\nimport type { NodePath, SemanticNode, SemanticSnapshot } from '../format/snapshot.js';\nimport { locate } from '../attribute/locate.js';\nimport { formatSource, resolveSource, type SourceIndex } from '../attribute/source.js';\n\n/**\n * What a render says without a baseline.\n *\n * Two producers, one `Finding` type. `inspect` reads a single snapshot;\n * `compareLocales` reads two renders of one subject in different languages,\n * which is also not a regression comparison — both renders are correct. Sharing\n * the type is what lets the report, the CLI and the MCP tools carry both without\n * knowing which produced what.\n *\n * The rest of this comment is about `inspect`, which reads one snapshot with no\n * baseline and no second run.\n *\n * Everything else in this package answers *what changed*, which requires two of\n * something. That framing has a blind spot the whole category shares: **a defect\n * that was present on the first run is invisible to a comparison forever.** A\n * button that never had an accessible name compares equal to itself on every run\n * until someone happens to edit it, and a picture of it is a perfectly good\n * picture. Approving the first baseline approves the defect.\n *\n * These rules read the normalized snapshot and report defects in it. Consequences\n * worth stating, because they are the practical argument for having the document\n * rather than an image of it:\n *\n * - **A fresh checkout with zero baselines is already useful.** The expensive\n * part of adopting a visual-regression tool is that it says nothing until it\n * has a history; this half says something on the first run.\n * - **Every finding names a component and a file**, through the same provenance\n * chain a delta uses. A rule engine that reports a DOM path reports where the\n * symptom is; this reports whose JSX wrote it.\n * - **They band as `a11y`**, so a project that writes `blocking: ['a11y']` gets\n * both halves under one policy: regressions found by comparison, and defects\n * found by inspection.\n *\n * Deliberately *not* an axe-core reimplementation. Axe runs against a live DOM\n * with computed visibility, contrast and focus order, and does dozens of things\n * this cannot. What is here is the subset a normalized snapshot can decide\n * without guessing — and the subset it can decide **offline, from a stored\n * artifact, months later**, which is a thing axe cannot do at all.\n *\n * There is no severity field. A rule that needs one to be tolerable is a rule\n * whose condition is too broad, and the fix is a better condition or no rule.\n *\n * **Why there is no contrast rule, stated here so nobody adds one badly later.**\n * The snapshot carries a resolved `color` and `background-color` per node, so a\n * check looks like four lines and would be wrong: the background a glyph is\n * actually painted on is whatever is behind it, which is a stacking question a\n * layout engine answers and a document does not. A node with a transparent\n * background over a dark ancestor, an image, a gradient, or a positioned sibling\n * all read as \"background-color: rgba(0,0,0,0)\" here. A rule that is right most\n * of the time about accessibility is worse than no rule: it gets disabled after\n * the second false alarm, and takes the four that work with it. Spec 0009\n * records the same for focus order and anything about motion.\n */\n\nexport type FindingRule =\n /** An interactive control with no accessible name. Nothing announces it. */\n | 'control-without-name'\n /** A meaningful image with neither a name nor an explicit `alt=\"\"`. */\n | 'image-without-alt'\n /** Heading levels jump forward by more than one. */\n | 'heading-level-skipped'\n /** A control inside another control. Only one of them is reachable. */\n | 'nested-interactive'\n /** An id reference that resolves to nothing inside this subject. */\n | 'dangling-reference'\n /** A control whose accessible name does not contain its visible label. */\n | 'label-mismatch'\n /** Two landmarks of one role that nothing tells apart. */\n | 'duplicate-landmark'\n /** A table with no header cells. */\n | 'table-without-headers'\n /** `tabindex` above zero, which reorders focus for the whole page. */\n | 'positive-tabindex'\n /**\n * The same string in two locales. From `compareLocales`, not from `inspect` —\n * one render cannot know whether its text was translated.\n */\n | 'untranslated'\n /** A box outside the box that contains it, in one locale and not the other. */\n | 'overflows-container';\n\n/**\n * The identity of a finding, as a string two runs can be compared on.\n *\n * A finding is *the same finding* when the same rule fires on the same node. Not\n * the same sentence: `what` quotes the text it found, so a copy edit beside a\n * control with no accessible name would read as the old defect going away and a\n * new one arriving in the same place. And not the same component either — a\n * component renders in many places, and every one of them would collapse to one.\n *\n * Small on purpose. This is what a baseline carries so that the next run can say\n * whether a defect it found was already there, and a baseline sidecar rides\n * beside a PNG in a tracked directory: a mark is a few dozen bytes and a stored\n * `Finding` is a paragraph. It can only answer membership, which is the only\n * question the next run is allowed to ask of it — the same restraint\n * `Described.components` is written under.\n */\nexport function findingMark(finding: { readonly rule: string; readonly path: string }): string {\n return `${finding.rule}@${finding.path}`;\n}\n\nexport interface Finding {\n readonly rule: FindingRule;\n\n /**\n * The band this finding would block under.\n *\n * Carried so that inspection and comparison answer to one policy. A team that\n * blocks `a11y` should not have to discover that it blocks regressions and not\n * defects.\n */\n readonly band: Band;\n\n readonly path: NodePath;\n\n /** One sentence, naming the thing rather than the rule. */\n readonly what: string;\n\n /** Spoken outside-in, from `locate` — `main → region \"Todos\" → item 2 of 3`. */\n readonly where?: string;\n\n /** The component whose JSX created the node, when provenance reached it. */\n readonly component?: string;\n readonly owners?: readonly OwnerFrame[];\n\n /**\n * The line that wrote *this element*, when a JSX runtime recorded one.\n *\n * Different from — and better than — resolving `component` through the source\n * index, which answers with where the component is *declared*. A defect is\n * rarely at a declaration: a button with no accessible name is a specific\n * element on a specific line inside that component, and that line is the edit.\n * The index stays as the fallback, because it needs no build change.\n *\n * Requires `@variance-authority/jsx-source` in the transform. Absent otherwise,\n * and absence is normal.\n */\n readonly source?: SourceLocation;\n}\n\n/** Roles that are operated. A control nobody can name is a control nobody can use. */\nconst INTERACTIVE = new Set([\n 'button', 'link', 'checkbox', 'radio', 'switch', 'textbox', 'searchbox',\n 'combobox', 'slider', 'spinbutton', 'menuitem', 'menuitemcheckbox',\n 'menuitemradio', 'tab', 'treeitem',\n]);\n\n/** Attributes whose value the aliaser rewrote, so `#extern:` means unresolved. */\nconst REFERENCE_ATTRIBUTES = new Set(['for', 'form', 'list', 'headers']);\n\nconst HEADING_TAGS: Readonly<Record<string, number>> = {\n h1: 1, h2: 2, h3: 3, h4: 4, h5: 5, h6: 6,\n};\n\n/** Regions of a page a screen reader user navigates between. */\nconst LANDMARKS = new Set([\n 'banner', 'navigation', 'main', 'complementary', 'contentinfo',\n 'region', 'form', 'search',\n]);\n\n/** Has at least one letter. A glyph is not a visible label. */\nconst HAS_LETTER = /\\p{L}/u;\n\nexport function inspect(snapshot: SemanticSnapshot): readonly Finding[] {\n const findings: Finding[] = [];\n const headings: { node: SemanticNode; level: number }[] = [];\n const landmarks = new Map<string, SemanticNode>();\n\n visit(snapshot.root, false);\n\n // Heading order is a property of the sequence, not of any one node, so it is\n // decided after the walk. Only *forward* jumps are reported: a component that\n // starts at `h3` is a component rendered inside something, not a defect, and a\n // subject is not a page.\n headings.forEach((heading, index) => {\n const previous = headings[index - 1];\n if (previous === undefined || heading.level <= previous.level + 1) return;\n\n findings.push(\n finding(\n 'heading-level-skipped',\n heading.node,\n `heading level jumps from ${previous.level} to ${heading.level}` +\n (heading.node.name !== undefined ? ` at \"${heading.node.name}\"` : ''),\n snapshot,\n ),\n );\n });\n\n return findings;\n\n function visit(node: SemanticNode, insideControl: boolean): void {\n // Nothing below a node the browser never built. A responsive site renders\n // the mobile navigation into the desktop document and hides it with a\n // class, and a reader that counts it sees two `navigation` landmarks where\n // Chrome's tree holds one — a duplicate nobody can reach, reported against\n // markup that is correct. Every rule here is about what a screen reader\n // encounters, so the gate belongs at the walk rather than in each rule.\n if (!exposed(node)) return;\n\n const interactive = node.role !== undefined && INTERACTIVE.has(node.role);\n\n if (interactive && node.name === undefined) {\n findings.push(\n finding(\n 'control-without-name',\n node,\n `<${node.tag}> is a ${node.role} with no accessible name`,\n snapshot,\n ),\n );\n }\n\n if (interactive && insideControl) {\n findings.push(\n finding(\n 'nested-interactive',\n node,\n `a ${node.role} is nested inside another control; only one of the two is reachable`,\n snapshot,\n ),\n );\n }\n\n // `alt=\"\"` is the author saying \"decorative\", which is a correct answer and\n // must not be reported. A missing `alt` is the author saying nothing.\n if (node.role === 'img' && node.name === undefined && node.attributes['alt'] === undefined) {\n findings.push(\n finding(\n 'image-without-alt',\n node,\n `<${node.tag}> has an image role, no accessible name, and no alt=\"\" to say it is decorative`,\n snapshot,\n ),\n );\n }\n\n for (const [attribute, value] of Object.entries(node.attributes)) {\n if (!REFERENCE_ATTRIBUTES.has(attribute) || !value.includes('#extern:')) continue;\n\n findings.push(\n finding(\n 'dangling-reference',\n node,\n // Honest about the ambiguity the aliaser records: from inside the\n // subject, escaping the subtree and pointing at nothing look the same.\n `<${node.tag} ${attribute}> references an element that is not in this subject`,\n snapshot,\n ),\n );\n }\n\n if (node.role === 'heading') {\n const level = levelOf(node);\n if (level !== undefined) headings.push({ node, level });\n }\n\n if (node.role !== undefined && LANDMARKS.has(node.role)) {\n // Keyed by role *and* name: two `navigation` landmarks called \"Primary\"\n // and \"Footer\" are how a page is meant to be built. Two called nothing are\n // two identical entries in the landmark list.\n const key = `${node.role}\\u0000${node.name ?? ''}`;\n const first = landmarks.get(key);\n\n if (first === undefined) {\n landmarks.set(key, node);\n } else {\n findings.push(\n finding(\n 'duplicate-landmark',\n node,\n `a second ${node.role} landmark` +\n (node.name !== undefined ? ` also named \"${node.name}\"` : ' with no name') +\n ', so nothing tells the two apart in a landmark list',\n snapshot,\n ),\n );\n }\n }\n\n if (node.role === 'table' && !hasHeaders(node)) {\n findings.push(\n finding(\n 'table-without-headers',\n node,\n `<${node.tag}> is a table with no header cells, so every cell is announced without ` +\n 'the column it belongs to',\n snapshot,\n ),\n );\n }\n\n const tabindex = Number(node.attributes['tabindex']);\n if (Number.isInteger(tabindex) && tabindex > 0) {\n findings.push(\n finding(\n 'positive-tabindex',\n node,\n `<${node.tag}> has tabindex=${tabindex}, which pulls it ahead of every element in ` +\n 'the document that relies on source order',\n snapshot,\n ),\n );\n }\n\n // WCAG 2.5.3. A control whose accessible name does not contain its visible\n // label cannot be operated by voice: \"click Save\" does nothing when the\n // button reads Save and is named \"Submit form\".\n //\n // Only when the visible text carries a letter. An icon button labelled\n // `aria-label=\"Refresh\"` around a glyph is correct, and a rule that reported\n // it would fire on every icon in every design system.\n if (interactive && node.name !== undefined) {\n const visible = visibleTextOf(node);\n if (\n visible.length > 0 &&\n HAS_LETTER.test(visible) &&\n !node.name.toLowerCase().includes(visible.toLowerCase())\n ) {\n findings.push(\n finding(\n 'label-mismatch',\n node,\n `reads \"${visible}\" and is named \"${node.name}\", so a voice command using the ` +\n 'visible words does not reach it',\n snapshot,\n ),\n );\n }\n }\n\n for (const child of node.children) visit(child, insideControl || interactive);\n }\n}\n\n/**\n * Whether a screen reader reaches this node at all.\n *\n * Three ways out of the accessibility tree, and only one of them is declared.\n * `aria-hidden=\"true\"` and the `hidden` attribute are the author saying so, and\n * arrive as state. `display: none` is the one nobody says: it is a utility\n * class, a media query, a `<details>` that is closed at this width. All three\n * take the subtree with them — `aria-hidden` is not reversible by a descendant,\n * and neither is a box that was never generated.\n *\n * `display` is read from the node's own computed style, which the capture\n * already carries: it is in the style allowlist, so this costs no new field and\n * moves no hash. Absent — a structural-only profile, where no style was\n * computed — the answer is \"exposed\", because refusing to report on a document\n * whose visibility could not be observed would silently empty the inspection.\n */\nfunction exposed(node: SemanticNode): boolean {\n if (node.state?.['hidden'] === true) return false;\n return node.style['display'] !== 'none';\n}\n\nfunction hasHeaders(node: SemanticNode): boolean {\n if (node.role === 'columnheader' || node.role === 'rowheader') return true;\n return node.children.some(hasHeaders);\n}\n\n/**\n * Text a sighted user reads, normalized to single spaces.\n *\n * `aria-hidden` subtrees are excluded, which is what makes the icon-button case\n * work: the glyph is hidden, so the visible text is empty and `label-mismatch`\n * has nothing to compare.\n */\nfunction visibleTextOf(node: SemanticNode): string {\n return runsOf(node).replace(/\\s+/g, ' ').trim();\n}\n\n/**\n * The same separator rule accname uses, so the two readings can be compared.\n *\n * `label-mismatch` asks whether the accessible name contains the visible words,\n * and that question is only meaningful if both sides were assembled the same\n * way. Joining every child with a space while `accessibleName` concatenated\n * them produced the failure this rule exists to prevent, in the rule itself: a\n * flex button reading \"Search ⌘K\" and named \"Search⌘K\" was reported as\n * unreachable by voice, against markup a browser names correctly.\n *\n * So a child contributes separated only when its computed `display` is not\n * inline — which is what a browser does, and which makes `<b>` inside a\n * sentence contribute as part of the word it is inside.\n */\nfunction runsOf(node: SemanticNode): string {\n if (!exposed(node)) return '';\n\n let text = node.text ?? '';\n\n for (const child of node.children) {\n const contribution = runsOf(child);\n if (contribution.length === 0) continue;\n\n const display = child.style['display'];\n const separated = display !== undefined && display !== 'inline' && display !== 'contents';\n text += separated ? ` ${contribution} ` : contribution;\n }\n\n return text;\n}\n\nfunction levelOf(node: SemanticNode): number | undefined {\n // `aria-level` wins, because it is what an assistive technology reads. The tag\n // is the fallback, and a `role=\"heading\"` with neither is not a level at all.\n const declared = node.state?.['level'];\n if (typeof declared === 'number') return declared;\n if (typeof declared === 'string' && /^\\d+$/.test(declared)) return Number(declared);\n\n return HEADING_TAGS[node.tag];\n}\n\nfunction finding(\n rule: FindingRule,\n node: SemanticNode,\n what: string,\n snapshot: SemanticSnapshot,\n): Finding {\n const where = locate(snapshot.root, node.path).where;\n const component = node.provenance?.createdBy ?? node.provenance?.owners[0]?.name;\n\n return {\n rule,\n band: 'a11y',\n path: node.path,\n what,\n ...(where !== '' ? { where } : {}),\n ...(component !== undefined ? { component } : {}),\n ...(node.provenance ? { owners: node.provenance.owners } : {}),\n ...(node.provenance?.source ? { source: node.provenance.source } : {}),\n };\n}\n\nexport interface InspectionReportOptions {\n /** Component → file, so a finding names an edit rather than an identifier. */\n readonly source?: SourceIndex;\n}\n\n/**\n * Findings as the thing a reviewer or an agent reads.\n *\n * Same shape as `summarizeAdjudication`: what, where, which file. A finding a\n * reader has to go and locate is a finding that gets skipped.\n */\nexport function summarizeFindings(\n findings: readonly Finding[],\n options: InspectionReportOptions = {},\n): string {\n if (findings.length === 0) return 'no findings.';\n\n const byRule = new Map<FindingRule, number>();\n for (const found of findings) byRule.set(found.rule, (byRule.get(found.rule) ?? 0) + 1);\n\n const header =\n `${findings.length} finding${findings.length === 1 ? '' : 's'}: ` +\n [...byRule].map(([rule, count]) => `${count} ${rule}`).join(', ');\n\n const lines = findings.flatMap((found) => {\n // The element's own line wins over the component's declaration. Both are\n // `file:line`, so the reader cannot tell them apart and does not need to —\n // either one opens at something worth editing, and the recorded one opens at\n // the element the finding is actually about.\n const declared =\n found.component !== undefined && options.source\n ? resolveSource(found.component, options.source)\n : null;\n const where =\n found.source !== undefined\n ? `${found.source.file}:${String(found.source.line)}`\n : declared\n ? formatSource(declared)\n : '';\n\n const attribution = [found.component, where === '' ? undefined : where]\n .filter((part): part is string => part !== undefined)\n .join(' ');\n\n return [\n ` [${found.rule}] ${found.what}`,\n found.where !== undefined ? ` in ${found.where}` : null,\n attribution === '' ? null : ` ${attribution}`,\n ].filter((line): line is string => line !== null);\n });\n\n return [header, ...lines].join('\\n');\n}\n"]}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a run rests on that nothing in it imports.
|
|
3
|
+
*
|
|
4
|
+
* Selection walks one line. It starts at the test harness, moves right through
|
|
5
|
+
* the tests into the repository's own files, and goes out past them into the
|
|
6
|
+
* dependencies ([`records.ts`](./records.ts) holds all three as nodes). Every
|
|
7
|
+
* question it asks is asked *against* the arrows: what depends on the thing
|
|
8
|
+
* that moved.
|
|
9
|
+
*
|
|
10
|
+
* The far-left end of that line answers no such question. A `vitest.config.ts`,
|
|
11
|
+
* a `jest.config.js`, a Next.js setup, a CI workflow, the Node version in a
|
|
12
|
+
* `.nvmrc`: nothing imports them, so nothing has an edge to them, so a walk
|
|
13
|
+
* from one reaches nothing and a walk to one arrives never. They are *before
|
|
14
|
+
* reach* — able to repaint the whole suite and invisible to the only mechanism
|
|
15
|
+
* that could say so.
|
|
16
|
+
*
|
|
17
|
+
* ## Why the accident is not enough
|
|
18
|
+
*
|
|
19
|
+
* A diff that names only such a file already runs everything, because a diff no
|
|
20
|
+
* part of which is in the graph is refused. That holds only while it is the
|
|
21
|
+
* *whole* diff. A CI workflow edited beside one component file is a diff with a
|
|
22
|
+
* seed in it, and the walk from that seed answers confidently for a change it
|
|
23
|
+
* never looked at. The widening was a side effect of having nothing to say, and
|
|
24
|
+
* a side effect stops the moment somebody else has something to say.
|
|
25
|
+
*
|
|
26
|
+
* ## Why the operator names them
|
|
27
|
+
*
|
|
28
|
+
* Because no rule can. *Every changed path the graph does not hold* is the
|
|
29
|
+
* README, the changelog, the fixture JSON, the editor settings: a whole run
|
|
30
|
+
* each, forever, and an operator who turns that off has turned off the config
|
|
31
|
+
* files too. Which paths govern a run is a fact about the repository that only
|
|
32
|
+
* the repository knows, so it is declared — and once declared it is exact,
|
|
33
|
+
* where a heuristic would be both too wide and too narrow on the same diff.
|
|
34
|
+
*
|
|
35
|
+
* ## What declaring one buys beyond its own name
|
|
36
|
+
*
|
|
37
|
+
* Everything below it. A `setup.ts` the config loads, a fixture module only
|
|
38
|
+
* that setup imports, a polyfill, the environment package it names: each is an
|
|
39
|
+
* ordinary file the scan already holds, that nothing imports, whose change
|
|
40
|
+
* reaches no component and narrows the run to nothing. Declared, the entry
|
|
41
|
+
* point is walked *along* the arrows once and that whole set comes with it.
|
|
42
|
+
*
|
|
43
|
+
* ## Where the descent stops
|
|
44
|
+
*
|
|
45
|
+
* At the first file the scan can already reason about. A setup file that
|
|
46
|
+
* imports `src/theme.ts` does not drag `src/theme.ts` in with it: that file has
|
|
47
|
+
* dependents, a change to it is answered exactly by walking them, and pulling
|
|
48
|
+
* it into the harness would trade an exact answer for a whole run — the one
|
|
49
|
+
* direction this is not allowed to be wrong in. The walk avoids sensed files,
|
|
50
|
+
* and avoiding a node stops the walk there rather than stepping over it: what
|
|
51
|
+
* lies below a sensed file is reached *through* it, and it already answers.
|
|
52
|
+
*
|
|
53
|
+
* ## Where the two ends meet
|
|
54
|
+
*
|
|
55
|
+
* The same walk carries into the package layer, and that is the whole of the
|
|
56
|
+
* mixed case. A config imports `jest-environment-jsdom`, which rests on
|
|
57
|
+
* `jsdom`; `jsdom` is bumped; the install comparison names it. It is a package
|
|
58
|
+
* the harness reaches, so the run does not narrow — and no file in the
|
|
59
|
+
* repository ever wrote the word. A change beyond reach, arriving before it.
|
|
60
|
+
*
|
|
61
|
+
* That cost is real and is the operator's to spend: a config that imports a
|
|
62
|
+
* bundler reaches everything the bundler rests on, so a bump inside that set
|
|
63
|
+
* widens. It is the correct answer — the harness did move — and a repository
|
|
64
|
+
* that finds it too wide narrows what it declares rather than what this
|
|
65
|
+
* concludes.
|
|
66
|
+
*/
|
|
67
|
+
import { type Relations } from './graph.js';
|
|
68
|
+
/** The part of a run that sits before the tests, as names. */
|
|
69
|
+
export interface BeforeReach {
|
|
70
|
+
/**
|
|
71
|
+
* The entry points as they were declared, matched against a diff by path.
|
|
72
|
+
*
|
|
73
|
+
* Kept apart from what they reach because they are matched differently: a
|
|
74
|
+
* declared path claims everything under it, so a directory of workflows is
|
|
75
|
+
* one entry, while a file the walk found is one file.
|
|
76
|
+
*/
|
|
77
|
+
readonly entries: readonly string[];
|
|
78
|
+
/** Files below the entry points that the scan does not otherwise sense. */
|
|
79
|
+
readonly files: ReadonlySet<string>;
|
|
80
|
+
/** Packages they rest on, direct and transitive. */
|
|
81
|
+
readonly packages: ReadonlySet<string>;
|
|
82
|
+
/**
|
|
83
|
+
* Declared entry points the graph does not hold, and so has no closure for.
|
|
84
|
+
*
|
|
85
|
+
* Ordinary for most of them: a `.nvmrc`, a CI workflow and a `tsconfig` have
|
|
86
|
+
* nothing below them a scan could read, and they contribute their own name,
|
|
87
|
+
* which is all they have. Not ordinary for a config the scan should have
|
|
88
|
+
* read, where it means the closure that would have come with it — the setup
|
|
89
|
+
* module, the environment — is missing and those files still narrow to
|
|
90
|
+
* nothing. The two are indistinguishable from here, so both are reported and
|
|
91
|
+
* neither is guessed at.
|
|
92
|
+
*/
|
|
93
|
+
readonly unread: readonly string[];
|
|
94
|
+
}
|
|
95
|
+
export interface BeforeReachOptions {
|
|
96
|
+
/**
|
|
97
|
+
* Files the scan already answers for, as the directories they live under.
|
|
98
|
+
*
|
|
99
|
+
* The descent stops at these. Absent, nothing stops it, and one setup import
|
|
100
|
+
* into `src/` would put the repository's own source before reach.
|
|
101
|
+
*/
|
|
102
|
+
readonly sensed?: readonly string[];
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Walk down from each declared entry point, stopping where the scan takes over.
|
|
106
|
+
*
|
|
107
|
+
* Along the arrows, unlike every other walk here, because this is the one
|
|
108
|
+
* question whose subject has no dependents: *what does the harness rest on*.
|
|
109
|
+
* Type-only edges are not followed — a config's `import type` is erased before
|
|
110
|
+
* the suite runs, exactly as a component's is.
|
|
111
|
+
*/
|
|
112
|
+
export declare function beforeReach(relations: Relations, entries: readonly string[], options?: BeforeReachOptions): BeforeReach;
|
|
113
|
+
/**
|
|
114
|
+
* Which of a diff's own inputs the harness rests on, as they were named.
|
|
115
|
+
*
|
|
116
|
+
* Files and packages together and in that order, because the caller's next act
|
|
117
|
+
* is to print them in one sentence and a reader does not care which list a name
|
|
118
|
+
* came out of. Empty is a real answer: the diff moved nothing the run rests on.
|
|
119
|
+
*
|
|
120
|
+
* A declared entry claims every path under it, so naming a directory of
|
|
121
|
+
* workflows is one line of configuration rather than one per file. A file the
|
|
122
|
+
* walk found claims only itself: it was reached, so it is known exactly, and
|
|
123
|
+
* widening it to its directory would put its neighbours before reach without
|
|
124
|
+
* anybody saying they were.
|
|
125
|
+
*/
|
|
126
|
+
export declare function movedBefore(before: BeforeReach, changed: readonly string[], packages?: readonly string[]): readonly string[];
|
|
127
|
+
/**
|
|
128
|
+
* Whether a path lies under one of these directories.
|
|
129
|
+
*
|
|
130
|
+
* On directory boundaries rather than characters: `src` must not claim
|
|
131
|
+
* `srcery/`, or a repository would put half of itself before reach and find out
|
|
132
|
+
* from the runs that never narrowed. Exported because every caller that draws
|
|
133
|
+
* this boundary over paths draws the same one, and two spellings of it would
|
|
134
|
+
* disagree on the day one of them was fixed.
|
|
135
|
+
*/
|
|
136
|
+
export declare function within(file: string, dirs: readonly string[]): boolean;
|
|
137
|
+
//# sourceMappingURL=before.d.ts.map
|