@variance-authority/core 0.13.0 → 0.15.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 +458 -0
- package/README.md +13 -10
- package/dist/attribute/component-hash.d.ts +3 -2
- package/dist/attribute/component-hash.js +3 -2
- package/dist/attribute/component-hash.js.map +1 -1
- package/dist/attribute/composition.d.ts +16 -0
- package/dist/attribute/composition.js +4 -6
- package/dist/attribute/composition.js.map +1 -1
- package/dist/attribute/index.d.ts +3 -1
- package/dist/attribute/index.js +2 -1
- package/dist/attribute/index.js.map +1 -1
- package/dist/attribute/region.js +1 -5
- package/dist/attribute/region.js.map +1 -1
- package/dist/attribute/source.d.ts +10 -24
- package/dist/attribute/source.js +4 -38
- package/dist/attribute/source.js.map +1 -1
- package/dist/attribute/subject-pieces.d.ts +37 -0
- package/dist/attribute/subject-pieces.js +122 -0
- package/dist/attribute/subject-pieces.js.map +1 -0
- package/dist/format/capture.d.ts +25 -0
- package/dist/format/capture.js +6 -0
- package/dist/format/capture.js.map +1 -1
- package/dist/format/document.d.ts +2 -2
- package/dist/format/document.js +8 -2
- package/dist/format/document.js.map +1 -1
- package/dist/format/drive.d.ts +191 -0
- package/dist/format/drive.js +343 -0
- package/dist/format/drive.js.map +1 -0
- package/dist/format/environment.d.ts +20 -2
- package/dist/format/environment.js +7 -0
- package/dist/format/environment.js.map +1 -1
- package/dist/format/index.d.ts +6 -3
- package/dist/format/index.js +4 -2
- package/dist/format/index.js.map +1 -1
- package/dist/format/intervention.d.ts +79 -0
- package/dist/format/intervention.js.map +1 -1
- package/dist/format/origin.d.ts +32 -0
- package/dist/format/origin.js +54 -0
- package/dist/format/origin.js.map +1 -0
- package/dist/format/snapshot.d.ts +2 -0
- package/dist/format/snapshot.js.map +1 -1
- package/dist/format/stabilize.d.ts +28 -5
- package/dist/format/stabilize.js +48 -100
- package/dist/format/stabilize.js.map +1 -1
- package/dist/format/waits.d.ts +12 -0
- package/dist/format/waits.js +156 -0
- package/dist/format/waits.js.map +1 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/nearest.d.ts +46 -0
- package/dist/nearest.js +102 -0
- package/dist/nearest.js.map +1 -0
- package/dist/relate/before.d.ts +19 -24
- package/dist/relate/before.js +35 -37
- package/dist/relate/before.js.map +1 -1
- package/dist/relate/beyond.d.ts +59 -0
- package/dist/relate/beyond.js +98 -0
- package/dist/relate/beyond.js.map +1 -0
- package/dist/relate/carried.d.ts +122 -0
- package/dist/relate/carried.js +279 -0
- package/dist/relate/carried.js.map +1 -0
- package/dist/relate/dominate.d.ts +19 -0
- package/dist/relate/dominate.js +77 -0
- package/dist/relate/dominate.js.map +1 -0
- package/dist/relate/index.d.ts +3 -0
- package/dist/relate/index.js +3 -0
- package/dist/relate/index.js.map +1 -1
- package/dist/relate/merkle.js +2 -11
- package/dist/relate/merkle.js.map +1 -1
- package/dist/relate/narrow.d.ts +2 -2
- package/dist/relate/narrow.js +26 -73
- package/dist/relate/narrow.js.map +1 -1
- package/dist/relate/reach.d.ts +1 -1
- package/dist/relate/reach.js +10 -53
- package/dist/relate/reach.js.map +1 -1
- package/dist/relate/records.d.ts +1 -1
- package/dist/relate/records.js.map +1 -1
- package/dist/rules/index.d.ts +3 -3
- package/dist/rules/index.js +3 -3
- package/dist/rules/index.js.map +1 -1
- package/dist/rules/normalize/cascade.d.ts +12 -0
- package/dist/rules/normalize/cascade.js +34 -16
- package/dist/rules/normalize/cascade.js.map +1 -1
- package/dist/rules/normalize/fixture.d.ts +1 -0
- package/dist/rules/normalize/fixture.js +1 -0
- package/dist/rules/normalize/fixture.js.map +1 -1
- package/dist/rules/normalize/index.d.ts +2 -2
- package/dist/rules/normalize/index.js +9 -2
- package/dist/rules/normalize/index.js.map +1 -1
- package/dist/rules/normalize/value.d.ts +13 -2
- package/dist/rules/normalize/value.js +42 -8
- package/dist/rules/normalize/value.js.map +1 -1
- package/dist/rules/normalize/wrapper.js +5 -0
- package/dist/rules/normalize/wrapper.js.map +1 -1
- package/dist/rules/ruleset.d.ts +24 -4
- package/dist/rules/ruleset.js +24 -4
- package/dist/rules/ruleset.js.map +1 -1
- package/dist/segment/index.d.ts +20 -4
- package/dist/segment/index.js +28 -7
- package/dist/segment/index.js.map +1 -1
- package/dist/shard/index.d.ts +11 -0
- package/dist/shard/index.js +11 -0
- package/dist/shard/index.js.map +1 -0
- package/dist/shard/placement.d.ts +117 -0
- package/dist/shard/placement.js +170 -0
- package/dist/shard/placement.js.map +1 -0
- package/package.json +8 -1
package/dist/format/stabilize.js
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { digestValue } from './hash.js';
|
|
2
2
|
import { tierReaches } from './tier.js';
|
|
3
|
+
import { clockById, freezeClock, freezeClockAt, noFocus, noHover, reducedMotion, UNTOUCHED } from './drive.js';
|
|
4
|
+
import { waitForFonts, waitForImages } from './waits.js';
|
|
5
|
+
export { waitForFonts, waitForImages } from './waits.js';
|
|
3
6
|
/**
|
|
4
7
|
* Hand animations to the browser rather than emulating the hold in CSS.
|
|
5
8
|
*
|
|
@@ -109,108 +112,16 @@ export const hidePresentationalImages = {
|
|
|
109
112
|
css: 'img[role="presentation"],img[alt=""],[role="presentation"] img,[role="none"] img' +
|
|
110
113
|
'{visibility:hidden !important}',
|
|
111
114
|
};
|
|
112
|
-
export const waitForFonts = {
|
|
113
|
-
id: 'wait-for-fonts',
|
|
114
|
-
trick: 'wait',
|
|
115
|
-
needs: 'layout',
|
|
116
|
-
governs: 'fonts',
|
|
117
|
-
because: 'waited for web fonts, whose advances change every metric on the page',
|
|
118
|
-
// Written without a module-scope helper on purpose: a settle closure is
|
|
119
|
-
// shipped to a page as source text, so anything it names by identifier is a
|
|
120
|
-
// `ReferenceError` on the far side. Types are erased and therefore free.
|
|
121
|
-
settle: async (target) => {
|
|
122
|
-
await target.evaluate(async () => {
|
|
123
|
-
const view = globalThis;
|
|
124
|
-
await view.document.fonts?.ready;
|
|
125
|
-
});
|
|
126
|
-
},
|
|
127
|
-
};
|
|
128
|
-
export const waitForImages = {
|
|
129
|
-
id: 'wait-for-images',
|
|
130
|
-
trick: 'wait',
|
|
131
|
-
needs: 'layout',
|
|
132
|
-
governs: 'images',
|
|
133
|
-
because: 'waited for images to decode, since their intrinsic size participates in layout, and asked ' +
|
|
134
|
-
'for the ones the browser had deferred',
|
|
135
|
-
settle: async (target) => {
|
|
136
|
-
await target.evaluate(async () => {
|
|
137
|
-
const view = globalThis;
|
|
138
|
-
const pending = Array.from(view.document.images).filter((image) => !image.complete);
|
|
139
|
-
const arrived = Promise.all(pending.map((image) => new Promise((resolve) => {
|
|
140
|
-
image.addEventListener('load', () => resolve(), { once: true });
|
|
141
|
-
image.addEventListener('error', () => resolve(), { once: true });
|
|
142
|
-
})));
|
|
143
|
-
// Asked for, after the listeners are attached and before anything is
|
|
144
|
-
// awaited. A `loading="lazy"` image outside the viewport has not been
|
|
145
|
-
// requested and will not be until something scrolls, so waiting on it is
|
|
146
|
-
// waiting for a decision the browser has already taken the other way.
|
|
147
|
-
//
|
|
148
|
-
// This is the same act as pinning an animation, not a different kind of
|
|
149
|
-
// thing: what the page shows stops depending on where the viewport
|
|
150
|
-
// happens to be. It matters most for the readings that need it most — a
|
|
151
|
-
// full-page capture of a long page is exactly the case where most of the
|
|
152
|
-
// images are deferred, and photographing it without them yields a picture
|
|
153
|
-
// full of empty boxes whose contents change with the browser's loading
|
|
154
|
-
// heuristics rather than with the product.
|
|
155
|
-
const asServed = pending.map((image) => image.loading);
|
|
156
|
-
for (const image of pending)
|
|
157
|
-
image.loading = 'eager';
|
|
158
|
-
// Bounded, and it throws when the bound is reached.
|
|
159
|
-
//
|
|
160
|
-
// A response can stall: a CDN that never answers, an image endpoint that
|
|
161
|
-
// deadlocks under its own concurrency, an image the browser deferred and
|
|
162
|
-
// will not request from where the page is scrolled. None of those fire
|
|
163
|
-
// `load` and none fire `error`, so an unbounded wait turns one stuck
|
|
164
|
-
// request into a run that never ends and never says why — not a wrong
|
|
165
|
-
// answer, no answer.
|
|
166
|
-
//
|
|
167
|
-
// Giving up quietly would be worse than the hang in a subtler way: it
|
|
168
|
-
// photographs a page with holes in it and reports the holes as a change,
|
|
169
|
-
// inventing a regression out of the network this trick exists to hold
|
|
170
|
-
// still. Refused instead, naming what it was still waiting for, because
|
|
171
|
-
// "these two never answered" is a finding about the application.
|
|
172
|
-
//
|
|
173
|
-
// The bound is written twice — once as the delay, once in the sentence —
|
|
174
|
-
// because a settle closure is shipped to the page as source text and a
|
|
175
|
-
// constant it named would be a `ReferenceError` on the far side.
|
|
176
|
-
// Held so the loser can be cancelled. A timer left to fire after the
|
|
177
|
-
// images arrived rejects a promise nothing is waiting on any more, which
|
|
178
|
-
// the page reports as an unhandled rejection — this trick's own noise,
|
|
179
|
-
// arriving in the console of every subject it succeeded on.
|
|
180
|
-
let timer;
|
|
181
|
-
const expired = new Promise((_, reject) => {
|
|
182
|
-
timer = view.setTimeout(() => {
|
|
183
|
-
const stuck = pending.filter((image) => !image.complete);
|
|
184
|
-
reject(new Error(`${stuck.length} image(s) had not loaded after 15000ms, and neither answered ` +
|
|
185
|
-
`nor failed: ${stuck.map((image) => image.currentSrc || image.src).join(', ')}`));
|
|
186
|
-
}, 15000);
|
|
187
|
-
});
|
|
188
|
-
try {
|
|
189
|
-
await Promise.race([arrived, expired]);
|
|
190
|
-
}
|
|
191
|
-
finally {
|
|
192
|
-
view.clearTimeout(timer);
|
|
193
|
-
// Put back, because the trick has to be invisible to the tier that
|
|
194
|
-
// reads the page after it. `loading` is a reflected attribute, so a
|
|
195
|
-
// document whose deferred images were switched to `eager` and left that
|
|
196
|
-
// way is a document that no longer matches the one the product served —
|
|
197
|
-
// and which images were still undecoded when this ran is a question
|
|
198
|
-
// about the network, so the edit lands in some readings and not others.
|
|
199
|
-
// Left in, it makes the same page at two widths disagree about its own
|
|
200
|
-
// markup: this trick filed as a finding against the application.
|
|
201
|
-
for (let index = 0; index < pending.length; index += 1) {
|
|
202
|
-
pending[index].loading = asServed[index];
|
|
203
|
-
}
|
|
204
|
-
}
|
|
205
|
-
});
|
|
206
|
-
},
|
|
207
|
-
};
|
|
208
115
|
export const INTERVENTIONS = [
|
|
209
116
|
holdAnimations,
|
|
210
117
|
pinAnimations,
|
|
211
118
|
hideCaret,
|
|
212
119
|
hideScrollbars,
|
|
213
120
|
hidePresentationalImages,
|
|
121
|
+
noHover,
|
|
122
|
+
noFocus,
|
|
123
|
+
reducedMotion,
|
|
124
|
+
freezeClock,
|
|
214
125
|
waitForFonts,
|
|
215
126
|
waitForImages,
|
|
216
127
|
];
|
|
@@ -223,7 +134,18 @@ export const INTERVENTIONS = [
|
|
|
223
134
|
* rung the tier ladder exists to make cheap.
|
|
224
135
|
*/
|
|
225
136
|
export const SEMANTIC_RECIPE = [];
|
|
226
|
-
|
|
137
|
+
// TODO: a `<script>` inside a subject root runs in the render document under the
|
|
138
|
+
// real clock, which no trick in this recipe holds.
|
|
139
|
+
// TODO: a document is painted under this recipe whatever recipe it was read
|
|
140
|
+
// under: a `RenderDocument` carries no collection recipe to follow.
|
|
141
|
+
/**
|
|
142
|
+
* What a document is painted under, and of {@link UNTOUCHED} only `no-focus`: a
|
|
143
|
+
* collected `autofocus` takes focus again. The other three have nothing but a
|
|
144
|
+
* carried script to act on: the printed date and the animation's place are in
|
|
145
|
+
* the document already, its `@media` rules were resolved when it was read, and
|
|
146
|
+
* nothing moves the pointer over it.
|
|
147
|
+
*/
|
|
148
|
+
export const LAYOUT_RECIPE = [holdAnimations, hideScrollbars, noFocus, waitForFonts, waitForImages];
|
|
227
149
|
export const RASTER_RECIPE = [...LAYOUT_RECIPE, hideCaret];
|
|
228
150
|
/**
|
|
229
151
|
* What is applied to a live page *before a subject is observed*, as opposed to
|
|
@@ -260,9 +182,16 @@ export const COLLECT_RECIPE = [
|
|
|
260
182
|
// holding a focused input otherwise disagrees with itself between two
|
|
261
183
|
// screenshots taken 600ms apart, and is refused for blinking.
|
|
262
184
|
hideCaret,
|
|
185
|
+
...UNTOUCHED,
|
|
263
186
|
waitForFonts,
|
|
264
187
|
waitForImages,
|
|
265
188
|
];
|
|
189
|
+
/**
|
|
190
|
+
* The ids of {@link COLLECT_RECIPE}, as an absent `stabilize` means them. A list
|
|
191
|
+
* replaces the recipe, so one trick is turned off with this list without it:
|
|
192
|
+
* `stabilize: DEFAULT_STABILIZE.filter((id) => id !== 'freeze-clock')`.
|
|
193
|
+
*/
|
|
194
|
+
export const DEFAULT_STABILIZE = COLLECT_RECIPE.map((intervention) => intervention.id);
|
|
266
195
|
/**
|
|
267
196
|
* Resolve a trick by the name a caller wrote down.
|
|
268
197
|
*
|
|
@@ -274,7 +203,7 @@ export const COLLECT_RECIPE = [
|
|
|
274
203
|
* flake it has already paid to prevent.
|
|
275
204
|
*/
|
|
276
205
|
export function interventionById(id) {
|
|
277
|
-
return INTERVENTIONS.find((intervention) => intervention.id === id);
|
|
206
|
+
return INTERVENTIONS.find((intervention) => intervention.id === id) ?? clockById(id);
|
|
278
207
|
}
|
|
279
208
|
/**
|
|
280
209
|
* A recipe from the names it travels as.
|
|
@@ -290,11 +219,30 @@ export function recipeOf(ids) {
|
|
|
290
219
|
const intervention = interventionById(id);
|
|
291
220
|
if (intervention === undefined) {
|
|
292
221
|
throw new Error(`no stabilization trick is called \`${id}\`; this build knows ` +
|
|
293
|
-
INTERVENTIONS.map((known) => `\`${known.id}\``).join(', ')
|
|
222
|
+
INTERVENTIONS.map((known) => `\`${known.id}\``).join(', ') +
|
|
223
|
+
', and `freeze-clock@<instant>`');
|
|
294
224
|
}
|
|
295
225
|
return intervention;
|
|
296
226
|
});
|
|
297
227
|
}
|
|
228
|
+
/**
|
|
229
|
+
* The recipe a host that drives the browser applies, checked before it opens one:
|
|
230
|
+
* `stabilize` by id, or {@link COLLECT_RECIPE} when it is absent, with its clock
|
|
231
|
+
* fixed at `now`, which travels in the clock's id and lands in the digest. Throws
|
|
232
|
+
* on an unknown id, an instant with no zone, or a `now` with no clock to fix, so
|
|
233
|
+
* the operator hears about it before a page is loaded, not at the first subject.
|
|
234
|
+
*/
|
|
235
|
+
export function recipeToDrive(options) {
|
|
236
|
+
const recipe = options.stabilize === undefined ? COLLECT_RECIPE : recipeOf(options.stabilize);
|
|
237
|
+
if (options.now === undefined)
|
|
238
|
+
return recipe;
|
|
239
|
+
const clock = freezeClockAt(options.now);
|
|
240
|
+
if (!recipe.some((intervention) => intervention.driver?.clock !== undefined)) {
|
|
241
|
+
throw new Error(`\`now\` is the instant \`freeze-clock\` fixes, and this recipe has no \`freeze-clock\`: ` +
|
|
242
|
+
'add it to `stabilize`, or leave `now` out');
|
|
243
|
+
}
|
|
244
|
+
return recipe.map((intervention) => (intervention.driver?.clock === undefined ? intervention : clock));
|
|
245
|
+
}
|
|
298
246
|
/** Only the tricks a tier can observe the effect of. */
|
|
299
247
|
export function forTier(recipe, tier) {
|
|
300
248
|
return recipe.filter((intervention) => tierReaches(tier, intervention.needs));
|
|
@@ -339,7 +287,7 @@ export async function settleRecipe(recipe, target) {
|
|
|
339
287
|
*
|
|
340
288
|
* Sorted, so the order tricks were composed in does not change the identity —
|
|
341
289
|
* only which ones are present. Folded into `RenderIdentity`, which is what makes
|
|
342
|
-
* a cross-recipe comparison
|
|
290
|
+
* a cross-recipe comparison name the recipe as its cause instead of inventing a
|
|
343
291
|
* component to blame.
|
|
344
292
|
*/
|
|
345
293
|
export function recipeDigest(recipe) {
|
|
@@ -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\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"]}
|
|
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 { clockById, freezeClock, freezeClockAt, noFocus, noHover, reducedMotion, UNTOUCHED } from './drive.js';\nimport type { Intervention, ScreenshotOptions, SettleTarget } from './intervention.js';\nimport { waitForFonts, waitForImages } from './waits.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 run under another recipe than its baseline names the recipe as the cause;\n * 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';\nexport { waitForFonts, waitForImages } from './waits.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 INTERVENTIONS: readonly Intervention[] = [\n holdAnimations,\n pinAnimations,\n hideCaret,\n hideScrollbars,\n hidePresentationalImages,\n noHover,\n noFocus,\n reducedMotion,\n freezeClock,\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\n// TODO: a `<script>` inside a subject root runs in the render document under the\n// real clock, which no trick in this recipe holds.\n// TODO: a document is painted under this recipe whatever recipe it was read\n// under: a `RenderDocument` carries no collection recipe to follow.\n/**\n * What a document is painted under, and of {@link UNTOUCHED} only `no-focus`: a\n * collected `autofocus` takes focus again. The other three have nothing but a\n * carried script to act on: the printed date and the animation's place are in\n * the document already, its `@media` rules were resolved when it was read, and\n * nothing moves the pointer over it.\n */\nexport const LAYOUT_RECIPE: Recipe = [holdAnimations, hideScrollbars, noFocus, 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 ...UNTOUCHED,\n waitForFonts,\n waitForImages,\n];\n\n/**\n * The ids of {@link COLLECT_RECIPE}, as an absent `stabilize` means them. A list\n * replaces the recipe, so one trick is turned off with this list without it:\n * `stabilize: DEFAULT_STABILIZE.filter((id) => id !== 'freeze-clock')`.\n */\nexport const DEFAULT_STABILIZE: readonly string[] = COLLECT_RECIPE.map((intervention) => intervention.id);\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) ?? clockById(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 ', and `freeze-clock@<instant>`',\n );\n }\n return intervention;\n });\n}\n\n/**\n * The recipe a host that drives the browser applies, checked before it opens one:\n * `stabilize` by id, or {@link COLLECT_RECIPE} when it is absent, with its clock\n * fixed at `now`, which travels in the clock's id and lands in the digest. Throws\n * on an unknown id, an instant with no zone, or a `now` with no clock to fix, so\n * the operator hears about it before a page is loaded, not at the first subject.\n */\nexport function recipeToDrive(options: { readonly stabilize?: readonly string[]; readonly now?: string }): Recipe {\n const recipe = options.stabilize === undefined ? COLLECT_RECIPE : recipeOf(options.stabilize);\n if (options.now === undefined) return recipe;\n const clock = freezeClockAt(options.now);\n if (!recipe.some((intervention) => intervention.driver?.clock !== undefined)) {\n throw new Error(\n `\\`now\\` is the instant \\`freeze-clock\\` fixes, and this recipe has no \\`freeze-clock\\`: ` +\n 'add it to `stabilize`, or leave `now` out',\n );\n }\n return recipe.map((intervention) => (intervention.driver?.clock === undefined ? intervention : clock));\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 name the recipe as its cause 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"]}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { Intervention } from './intervention.js';
|
|
2
|
+
/**
|
|
3
|
+
* The two tricks that block until something outside the page has landed.
|
|
4
|
+
*
|
|
5
|
+
* Apart from [`stabilize.ts`](./stabilize.ts) because they are the tricks with
|
|
6
|
+
* the most page-side code: each ships a closure into the page, bounds its wait at
|
|
7
|
+
* 15000ms and names what never answered. The rest of the vocabulary is a line of
|
|
8
|
+
* CSS or a screenshot option.
|
|
9
|
+
*/
|
|
10
|
+
export declare const waitForFonts: Intervention;
|
|
11
|
+
export declare const waitForImages: Intervention;
|
|
12
|
+
//# sourceMappingURL=waits.d.ts.map
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The two tricks that block until something outside the page has landed.
|
|
3
|
+
*
|
|
4
|
+
* Apart from [`stabilize.ts`](./stabilize.ts) because they are the tricks with
|
|
5
|
+
* the most page-side code: each ships a closure into the page, bounds its wait at
|
|
6
|
+
* 15000ms and names what never answered. The rest of the vocabulary is a line of
|
|
7
|
+
* CSS or a screenshot option.
|
|
8
|
+
*/
|
|
9
|
+
export const waitForFonts = {
|
|
10
|
+
id: 'wait-for-fonts',
|
|
11
|
+
trick: 'wait',
|
|
12
|
+
needs: 'layout',
|
|
13
|
+
governs: 'fonts',
|
|
14
|
+
because: 'waited for web fonts, whose advances change every metric on the page',
|
|
15
|
+
// Written without a module-scope helper on purpose: a settle closure is
|
|
16
|
+
// shipped to a page as source text, so anything it names by identifier is a
|
|
17
|
+
// `ReferenceError` on the far side. Types are erased and therefore free.
|
|
18
|
+
settle: async (target) => {
|
|
19
|
+
await target.evaluate(async () => {
|
|
20
|
+
const view = globalThis;
|
|
21
|
+
const fonts = view.document.fonts;
|
|
22
|
+
if (fonts === undefined)
|
|
23
|
+
return;
|
|
24
|
+
// Bounded as `waitForImages` is: a font request that never answers neither
|
|
25
|
+
// loads nor fails, so `ready` never settles. Refused at the same 15000ms,
|
|
26
|
+
// naming each face still loading by family and the source its rule asked for.
|
|
27
|
+
let timer;
|
|
28
|
+
const expired = new Promise((_, reject) => {
|
|
29
|
+
timer = view.setTimeout(() => {
|
|
30
|
+
const unquoted = (family) => family.trim().replace(/^(["'])(.*)\1$/, '$2');
|
|
31
|
+
const sources = new Map();
|
|
32
|
+
const visit = (rules) => {
|
|
33
|
+
for (const rule of Array.from(rules)) {
|
|
34
|
+
if (rule.cssRules !== undefined)
|
|
35
|
+
visit(rule.cssRules);
|
|
36
|
+
// An `@import` carries its sheet; a cross-origin one hides its rules.
|
|
37
|
+
try {
|
|
38
|
+
if (rule.styleSheet != null)
|
|
39
|
+
visit(rule.styleSheet.cssRules);
|
|
40
|
+
}
|
|
41
|
+
catch { }
|
|
42
|
+
// Only a `@font-face` rule carries `src`; a style rule reads empty.
|
|
43
|
+
const source = rule.style?.getPropertyValue('src') ?? '';
|
|
44
|
+
if (source === '')
|
|
45
|
+
continue;
|
|
46
|
+
const family = unquoted(rule.style.getPropertyValue('font-family'));
|
|
47
|
+
sources.set(family, [...(sources.get(family) ?? []), source]);
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
for (const sheet of Array.from(view.document.styleSheets)) {
|
|
51
|
+
try {
|
|
52
|
+
visit(sheet.cssRules);
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
// A cross-origin sheet hides its rules; its faces are named by family alone.
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
const stuck = Array.from(fonts).filter((face) => face.status === 'loading');
|
|
59
|
+
const named = stuck.map((face) => {
|
|
60
|
+
const asked = sources.get(unquoted(face.family));
|
|
61
|
+
return asked === undefined ? face.family : `${face.family} from ${asked.join(' or ')}`;
|
|
62
|
+
});
|
|
63
|
+
reject(new Error(`${stuck.length} web font(s) had not loaded after 15000ms, and neither answered ` +
|
|
64
|
+
`nor failed: ${named.join('; ')}`));
|
|
65
|
+
}, 15000);
|
|
66
|
+
});
|
|
67
|
+
try {
|
|
68
|
+
await Promise.race([fonts.ready, expired]);
|
|
69
|
+
}
|
|
70
|
+
finally {
|
|
71
|
+
view.clearTimeout(timer);
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
export const waitForImages = {
|
|
77
|
+
id: 'wait-for-images',
|
|
78
|
+
trick: 'wait',
|
|
79
|
+
needs: 'layout',
|
|
80
|
+
governs: 'images',
|
|
81
|
+
because: 'waited for images to decode, since their intrinsic size participates in layout, and asked ' +
|
|
82
|
+
'for the ones the browser had deferred',
|
|
83
|
+
settle: async (target) => {
|
|
84
|
+
await target.evaluate(async () => {
|
|
85
|
+
const view = globalThis;
|
|
86
|
+
const pending = Array.from(view.document.images).filter((image) => !image.complete);
|
|
87
|
+
const arrived = Promise.all(pending.map((image) => new Promise((resolve) => {
|
|
88
|
+
image.addEventListener('load', () => resolve(), { once: true });
|
|
89
|
+
image.addEventListener('error', () => resolve(), { once: true });
|
|
90
|
+
})));
|
|
91
|
+
// Asked for, after the listeners are attached and before anything is
|
|
92
|
+
// awaited. A `loading="lazy"` image outside the viewport has not been
|
|
93
|
+
// requested and will not be until something scrolls, so waiting on it is
|
|
94
|
+
// waiting for a decision the browser has already taken the other way.
|
|
95
|
+
//
|
|
96
|
+
// This is the same act as pinning an animation, not a different kind of
|
|
97
|
+
// thing: what the page shows stops depending on where the viewport
|
|
98
|
+
// happens to be. It matters most for the readings that need it most — a
|
|
99
|
+
// full-page capture of a long page is exactly the case where most of the
|
|
100
|
+
// images are deferred, and photographing it without them yields a picture
|
|
101
|
+
// full of empty boxes whose contents change with the browser's loading
|
|
102
|
+
// heuristics rather than with the product.
|
|
103
|
+
const asServed = pending.map((image) => image.loading);
|
|
104
|
+
for (const image of pending)
|
|
105
|
+
image.loading = 'eager';
|
|
106
|
+
// Bounded, and it throws when the bound is reached.
|
|
107
|
+
//
|
|
108
|
+
// A response can stall: a CDN that never answers, an image endpoint that
|
|
109
|
+
// deadlocks under its own concurrency, an image the browser deferred and
|
|
110
|
+
// will not request from where the page is scrolled. None of those fire
|
|
111
|
+
// `load` and none fire `error`, so an unbounded wait turns one stuck
|
|
112
|
+
// request into a run that never ends and never says why — not a wrong
|
|
113
|
+
// answer, no answer.
|
|
114
|
+
//
|
|
115
|
+
// Giving up quietly would be worse than the hang in a subtler way: it
|
|
116
|
+
// photographs a page with holes in it and reports the holes as a change,
|
|
117
|
+
// inventing a regression out of the network this trick exists to hold
|
|
118
|
+
// still. Refused instead, naming what it was still waiting for, because
|
|
119
|
+
// "these two never answered" is a finding about the application.
|
|
120
|
+
//
|
|
121
|
+
// The bound is written twice — once as the delay, once in the sentence —
|
|
122
|
+
// because a settle closure is shipped to the page as source text and a
|
|
123
|
+
// constant it named would be a `ReferenceError` on the far side.
|
|
124
|
+
// Held so the loser can be cancelled. A timer left to fire after the
|
|
125
|
+
// images arrived rejects a promise nothing is waiting on any more, which
|
|
126
|
+
// the page reports as an unhandled rejection — this trick's own noise,
|
|
127
|
+
// arriving in the console of every subject it succeeded on.
|
|
128
|
+
let timer;
|
|
129
|
+
const expired = new Promise((_, reject) => {
|
|
130
|
+
timer = view.setTimeout(() => {
|
|
131
|
+
const stuck = pending.filter((image) => !image.complete);
|
|
132
|
+
reject(new Error(`${stuck.length} image(s) had not loaded after 15000ms, and neither answered ` +
|
|
133
|
+
`nor failed: ${stuck.map((image) => image.currentSrc || image.src).join(', ')}`));
|
|
134
|
+
}, 15000);
|
|
135
|
+
});
|
|
136
|
+
try {
|
|
137
|
+
await Promise.race([arrived, expired]);
|
|
138
|
+
}
|
|
139
|
+
finally {
|
|
140
|
+
view.clearTimeout(timer);
|
|
141
|
+
// Put back, because the trick has to be invisible to the tier that
|
|
142
|
+
// reads the page after it. `loading` is a reflected attribute, so a
|
|
143
|
+
// document whose deferred images were switched to `eager` and left that
|
|
144
|
+
// way is a document that no longer matches the one the product served —
|
|
145
|
+
// and which images were still undecoded when this ran is a question
|
|
146
|
+
// about the network, so the edit lands in some readings and not others.
|
|
147
|
+
// Left in, it makes the same page at two widths disagree about its own
|
|
148
|
+
// markup: this trick filed as a finding against the application.
|
|
149
|
+
for (let index = 0; index < pending.length; index += 1) {
|
|
150
|
+
pending[index].loading = asServed[index];
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
},
|
|
155
|
+
};
|
|
156
|
+
//# sourceMappingURL=waits.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"waits.js","sourceRoot":"","sources":["../../src/format/waits.ts"],"names":[],"mappings":"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","sourcesContent":["import type { Intervention, PageGlobals } from './intervention.js';\n\n/**\n * The two tricks that block until something outside the page has landed.\n *\n * Apart from [`stabilize.ts`](./stabilize.ts) because they are the tricks with\n * the most page-side code: each ships a closure into the page, bounds its wait at\n * 15000ms and names what never answered. The rest of the vocabulary is a line of\n * CSS or a screenshot option.\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 const fonts = view.document.fonts;\n if (fonts === undefined) return;\n // Bounded as `waitForImages` is: a font request that never answers neither\n // loads nor fails, so `ready` never settles. Refused at the same 15000ms,\n // naming each face still loading by family and the source its rule asked for.\n let timer: unknown;\n const expired = new Promise<never>((_, reject) => {\n timer = view.setTimeout(() => {\n const unquoted = (family: string): string => family.trim().replace(/^([\"'])(.*)\\1$/, '$2');\n const sources = new Map<string, string[]>();\n type Rules = PageGlobals['document']['styleSheets'][number]['cssRules'];\n const visit = (rules: Rules): void => {\n for (const rule of Array.from(rules)) {\n if (rule.cssRules !== undefined) visit(rule.cssRules);\n // An `@import` carries its sheet; a cross-origin one hides its rules.\n try {\n if (rule.styleSheet != null) visit(rule.styleSheet.cssRules);\n } catch {}\n // Only a `@font-face` rule carries `src`; a style rule reads empty.\n const source = rule.style?.getPropertyValue('src') ?? '';\n if (source === '') continue;\n const family = unquoted(rule.style!.getPropertyValue('font-family'));\n sources.set(family, [...(sources.get(family) ?? []), source]);\n }\n };\n for (const sheet of Array.from(view.document.styleSheets)) {\n try {\n visit(sheet.cssRules);\n } catch {\n // A cross-origin sheet hides its rules; its faces are named by family alone.\n }\n }\n const stuck = Array.from(fonts).filter((face) => face.status === 'loading');\n const named = stuck.map((face) => {\n const asked = sources.get(unquoted(face.family));\n return asked === undefined ? face.family : `${face.family} from ${asked.join(' or ')}`;\n });\n reject(\n new Error(\n `${stuck.length} web font(s) had not loaded after 15000ms, and neither answered ` +\n `nor failed: ${named.join('; ')}`,\n ),\n );\n }, 15000);\n });\n try {\n await Promise.race([fonts.ready, expired]);\n } finally {\n view.clearTimeout(timer);\n }\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"]}
|
package/dist/index.d.ts
CHANGED
|
@@ -21,7 +21,8 @@
|
|
|
21
21
|
*
|
|
22
22
|
* Every name lives in exactly one of them; nothing is reachable from two paths.
|
|
23
23
|
* This entrypoint holds only what belongs to no group: the artifact a capture is
|
|
24
|
-
* written to.
|
|
24
|
+
* written to, and the name a refusal offers when one was mistyped.
|
|
25
25
|
*/
|
|
26
26
|
export * from './artifact.js';
|
|
27
|
+
export { didYouMean, nearest } from './nearest.js';
|
|
27
28
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.js
CHANGED
|
@@ -21,7 +21,8 @@
|
|
|
21
21
|
*
|
|
22
22
|
* Every name lives in exactly one of them; nothing is reachable from two paths.
|
|
23
23
|
* This entrypoint holds only what belongs to no group: the artifact a capture is
|
|
24
|
-
* written to.
|
|
24
|
+
* written to, and the name a refusal offers when one was mistyped.
|
|
25
25
|
*/
|
|
26
26
|
export * from './artifact.js';
|
|
27
|
+
export { didYouMean, nearest } from './nearest.js';
|
|
27
28
|
//# 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;;;;;;;;;;;;;;;;;;;;;;;;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"]}
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AAEH,cAAc,eAAe,CAAC;AAC9B,OAAO,EAAE,UAAU,EAAE,OAAO,EAAE,MAAM,cAAc,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, and the name a refusal offers when one was mistyped.\n */\n\nexport * from './artifact.js';\nexport { didYouMean, nearest } from './nearest.js';\n"]}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The name somebody meant, when exactly one name is close enough.
|
|
3
|
+
*
|
|
4
|
+
* A refusal names what was typed and then the command's synopsis, which lists
|
|
5
|
+
* what it accepts. The reader is usually an agent, and an agent that mistyped
|
|
6
|
+
* `--quer` should correct it from the same message instead of spending a second
|
|
7
|
+
* turn on it. So one sentence goes in front of the synopsis. Naming a candidate
|
|
8
|
+
* is cheap and reversible: nothing is run on the guess, and a wrong guess costs
|
|
9
|
+
* one line.
|
|
10
|
+
*
|
|
11
|
+
* ## Why one, and why not always
|
|
12
|
+
*
|
|
13
|
+
* Two names equally close is not a suggestion, it is a second decision handed
|
|
14
|
+
* back — so a tie suggests nothing and the set alone answers. And the distance
|
|
15
|
+
* is bounded by the length of what was typed: without that, `--x` is one edit
|
|
16
|
+
* from nothing and three from half the table, and a suggestion that fires on
|
|
17
|
+
* anything is one nobody can read as a signal.
|
|
18
|
+
*
|
|
19
|
+
* Damerau-Levenshtein, iterative, over three rows. Transposed neighbours count
|
|
20
|
+
* as one edit rather than two, which is not a refinement: `aks` for `ask` and
|
|
21
|
+
* `--fromat` for `--format` are the typo, and under plain Levenshtein both sit
|
|
22
|
+
* at distance two and are missed by the bound below. No dependency, for the
|
|
23
|
+
* reason `parse.ts` gives — a tool whose claim is that its output can be trusted
|
|
24
|
+
* does not acquire a transitive graph to spell-check a flag.
|
|
25
|
+
*/
|
|
26
|
+
/**
|
|
27
|
+
* The single closest candidate, or nothing.
|
|
28
|
+
*
|
|
29
|
+
* Case-insensitive, because a name typed in the wrong case is the same mistake
|
|
30
|
+
* and the answer is the same name. Comparison only — what comes back is the
|
|
31
|
+
* candidate as it is actually spelled, since that is what has to be typed next.
|
|
32
|
+
*/
|
|
33
|
+
export declare function nearest(typed: string, candidates: readonly string[]): string | undefined;
|
|
34
|
+
/**
|
|
35
|
+
* The line a refusal adds beside its list, or nothing.
|
|
36
|
+
*
|
|
37
|
+
* Its own function so every refusal says it the same way. An agent reading three
|
|
38
|
+
* different phrasings for one idea has three things to recognise.
|
|
39
|
+
*
|
|
40
|
+
* A line of its own, and it carries the newline itself so a refusal with nothing
|
|
41
|
+
* to suggest does not print a blank one. Beside the accepted set rather than
|
|
42
|
+
* inside that sentence: the set is the part a reader parses — a name split out
|
|
43
|
+
* of a list should not have half a second sentence attached to it.
|
|
44
|
+
*/
|
|
45
|
+
export declare function didYouMean(typed: string, candidates: readonly string[]): string;
|
|
46
|
+
//# sourceMappingURL=nearest.d.ts.map
|