@crvouga/atlas 1.19.0 → 1.21.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.
Files changed (48) hide show
  1. package/CHANGELOG.md +13 -1
  2. package/dist/core/src/cli.d.ts +1 -1
  3. package/dist/core/src/cli.js +3 -0
  4. package/dist/reports/src/compose.d.ts +23 -1
  5. package/dist/reports/src/compose.js +29 -19
  6. package/dist/reports/src/report-cli.d.ts +13 -0
  7. package/dist/reports/src/report-cli.js +6 -4
  8. package/dist/reports/src/ui-catalog-cli.d.ts +10 -0
  9. package/dist/reports/src/ui-catalog-cli.js +391 -0
  10. package/dist/reports/src/ui-catalog-diff.d.ts +104 -0
  11. package/dist/reports/src/ui-catalog-diff.js +130 -0
  12. package/dist/reports/src/ui-catalog-export.d.ts +82 -0
  13. package/dist/reports/src/ui-catalog-export.js +221 -0
  14. package/dist/schema/src/api.d.ts +55 -0
  15. package/dist/schema/src/api.js +33 -0
  16. package/dist/schema/src/cli-results.d.ts +318 -1
  17. package/dist/schema/src/cli-results.js +89 -1
  18. package/dist/server/src/app.js +3 -1
  19. package/dist/server/src/openapi.d.ts +1 -1
  20. package/dist/server/src/openapi.js +24 -2
  21. package/dist/server/src/recording-verification.d.ts +34 -0
  22. package/dist/server/src/recording-verification.js +118 -0
  23. package/docs/agents.md +5 -0
  24. package/docs/reference/cli.md +56 -1
  25. package/docs/reference/http-api.md +52 -0
  26. package/docs/reference/reports.md +392 -0
  27. package/docs/reference/schema.md +82 -1
  28. package/docs/reference/server.md +39 -0
  29. package/docs/rich-evidence.md +12 -0
  30. package/docs/server.md +12 -0
  31. package/docs/ui-catalog.md +106 -12
  32. package/docs/ui.md +6 -1
  33. package/llms-full.txt +246 -11
  34. package/package.json +1 -1
  35. package/packages/schema/schemas/ui-catalog-diff-result.schema.json +201 -0
  36. package/packages/schema/schemas/ui-catalog-export-result.schema.json +83 -0
  37. package/packages/schema/schemas/ui-catalog-publish-result.schema.json +116 -0
  38. package/packages/schema/schemas/ui-catalog-status.schema.json +197 -0
  39. package/packages/server/openapi.json +527 -1
  40. package/packages/visualizer/src/catalog/Picture.tsx +32 -0
  41. package/packages/visualizer/src/catalog/UiCatalogCompare.tsx +289 -0
  42. package/packages/visualizer/src/catalog/UiCatalogPage.tsx +18 -28
  43. package/packages/visualizer/src/catalog/history.ts +61 -0
  44. package/packages/visualizer/src/catalog/search.ts +14 -3
  45. package/packages/visualizer/src/evidence/evidence-context.tsx +2 -1
  46. package/packages/visualizer/src/explorer/RunsView.tsx +32 -6
  47. package/packages/visualizer/src/explorer/catalog.tsx +24 -1
  48. package/packages/visualizer/src/explorer/recording-verification.ts +58 -0
@@ -0,0 +1,82 @@
1
+ /**
2
+ * @module UI catalog single-file export
3
+ *
4
+ * The UI catalog as one HTML file that needs no server, no network and no build: open it from a disk, attach it to a ticket,
5
+ * host it anywhere that serves a file. Pictures are embedded as `data:` addresses, the manifest is embedded as JSON
6
+ * (`<script type="application/json" id="ui-catalog">`), and a few lines of script filter the states; with scripts off, every
7
+ * state is simply shown. It shows what the viewer's catalog shows, but it does not stream: it is the catalog at the moment it
8
+ * was made, and says when.
9
+ *
10
+ * Hosting platforms cap the size of one file, which is the reason a catalog is also a store and a viewer. The export keeps to a
11
+ * budget ({@link gatherUiPictures}): pictures past it are listed as left out with the reason, never silently dropped, and the
12
+ * manifest stays complete.
13
+ *
14
+ * Everything in the page is data from a test run. Text is escaped; only PNG, JPEG, GIF and WebP bytes whose first bytes say so
15
+ * are embedded (never SVG, never what a layer merely claims); a page address is linked only when it is `http` or `https`; the
16
+ * page carries a Content-Security-Policy that allows no network request at all.
17
+ *
18
+ * @see docs/ui-catalog.md
19
+ */
20
+ import { type UiCapture, type UiCatalog } from './ui-catalog.js';
21
+ /** A picture ready to embed. */
22
+ export type UiExportPicture = {
23
+ readonly contentType: string;
24
+ readonly data: Uint8Array;
25
+ };
26
+ /** What {@link uiCatalogHtml} may be given besides the catalog. */
27
+ export type UiExportOptions = {
28
+ /** The page's title. Default `UI catalog`. */
29
+ readonly title?: string;
30
+ /** The pictures to embed, by {@link uiPictureKey}. A cell whose key is not here shows why it has no picture. */
31
+ readonly pictures?: ReadonlyMap<string, UiExportPicture>;
32
+ /** Why a picture that could not be embedded is not here, by {@link uiPictureKey}: shown in its place. */
33
+ readonly left?: ReadonlyMap<string, string>;
34
+ /** When the export was made, epoch milliseconds. It is stated on the page so nobody mistakes it for a live view. Default: now. */
35
+ readonly generatedAtMs?: number;
36
+ };
37
+ /** The key of a capture's picture in {@link UiExportOptions.pictures}: its layer and its bundle key, or `null` when the picture is not stored in a layer. */
38
+ export declare function uiPictureKey(capture: Pick<UiCapture, 'origin' | 'image'> | null | undefined): string | null;
39
+ /** One picture an export may embed. */
40
+ export type UiPictureWant = {
41
+ readonly key: string;
42
+ readonly layer: string;
43
+ readonly backend: string;
44
+ readonly bundleKey: string;
45
+ readonly bytes?: number;
46
+ };
47
+ /**
48
+ * The pictures of a catalog an export would embed, once each, in the order the page shows them. Only a cell that was captured, or
49
+ * that failed (a picture of what the page showed), and whose picture is stored in a layer, has one.
50
+ */
51
+ export declare function uiPicturesWanted(catalog: UiCatalog): UiPictureWant[];
52
+ /** The media type a picture's first bytes say it has (`image/png`, `image/jpeg`, `image/gif`, `image/webp`), or `null` for anything else, SVG included. */
53
+ export declare function sniffImage(data: Uint8Array): string | null;
54
+ /** What {@link gatherUiPictures} found. */
55
+ export type UiPicturesGathered = {
56
+ readonly pictures: ReadonlyMap<string, UiExportPicture>;
57
+ /** Why each picture that is not in `pictures` is not. */
58
+ readonly left: ReadonlyMap<string, string>;
59
+ /** Bytes embedded. */
60
+ readonly bytes: number;
61
+ };
62
+ /**
63
+ * Reads the pictures an export wants through `read` (which returns the stored bytes of a picture, or `null` when the store
64
+ * no longer holds it) and keeps those that fit in `maxBytes` in total. A picture that is not stored, is not a PNG, JPEG, GIF or
65
+ * WebP, cannot be read, or would pass the budget is recorded in `left` with the reason; the pictures are taken in the page's
66
+ * order, so which are left out is deterministic. A `read` that throws is a picture left out, never a failed export.
67
+ */
68
+ export declare function gatherUiPictures(wanted: readonly UiPictureWant[], read: (want: UiPictureWant) => Promise<Uint8Array | null>, options: {
69
+ readonly maxBytes: number;
70
+ }): Promise<UiPicturesGathered>;
71
+ /**
72
+ * The whole catalog as one self-contained HTML document. See the module description for what it contains and how it treats
73
+ * the data in it. Deterministic for the same catalog, pictures and `generatedAtMs`.
74
+ *
75
+ * @example
76
+ * ```ts
77
+ * const wanted = uiPicturesWanted(catalog);
78
+ * const { pictures, left } = await gatherUiPictures(wanted, want => readPicture(want), { maxBytes: 32 * 1024 * 1024 });
79
+ * await writeFile('catalog.html', uiCatalogHtml(catalog, { pictures, left }));
80
+ * ```
81
+ */
82
+ export declare function uiCatalogHtml(catalog: UiCatalog, options?: UiExportOptions): string;
@@ -0,0 +1,221 @@
1
+ /**
2
+ * @module UI catalog single-file export
3
+ *
4
+ * The UI catalog as one HTML file that needs no server, no network and no build: open it from a disk, attach it to a ticket,
5
+ * host it anywhere that serves a file. Pictures are embedded as `data:` addresses, the manifest is embedded as JSON
6
+ * (`<script type="application/json" id="ui-catalog">`), and a few lines of script filter the states; with scripts off, every
7
+ * state is simply shown. It shows what the viewer's catalog shows, but it does not stream: it is the catalog at the moment it
8
+ * was made, and says when.
9
+ *
10
+ * Hosting platforms cap the size of one file, which is the reason a catalog is also a store and a viewer. The export keeps to a
11
+ * budget ({@link gatherUiPictures}): pictures past it are listed as left out with the reason, never silently dropped, and the
12
+ * manifest stays complete.
13
+ *
14
+ * Everything in the page is data from a test run. Text is escaped; only PNG, JPEG, GIF and WebP bytes whose first bytes say so
15
+ * are embedded (never SVG, never what a layer merely claims); a page address is linked only when it is `http` or `https`; the
16
+ * page carries a Content-Security-Policy that allows no network request at all.
17
+ *
18
+ * @see docs/ui-catalog.md
19
+ */
20
+ import { UI_COVERAGE } from './ui-catalog.js';
21
+ /** The key of a capture's picture in {@link UiExportOptions.pictures}: its layer and its bundle key, or `null` when the picture is not stored in a layer. */
22
+ export function uiPictureKey(capture) {
23
+ const key = capture?.image?.bundleKey;
24
+ return capture && key ? `${capture.origin.layer}/${key}` : null;
25
+ }
26
+ /**
27
+ * The pictures of a catalog an export would embed, once each, in the order the page shows them. Only a cell that was captured, or
28
+ * that failed (a picture of what the page showed), and whose picture is stored in a layer, has one.
29
+ */
30
+ export function uiPicturesWanted(catalog) {
31
+ const seen = new Set();
32
+ const wanted = [];
33
+ for (const state of catalog.states)
34
+ for (const cell of state.cells) {
35
+ if (cell.coverage !== 'captured' && cell.coverage !== 'failed')
36
+ continue;
37
+ const key = uiPictureKey(cell.capture);
38
+ if (!key || seen.has(key) || !cell.capture?.image?.bundleKey)
39
+ continue;
40
+ seen.add(key);
41
+ wanted.push({ key, layer: cell.capture.origin.layer, backend: cell.capture.origin.backend, bundleKey: cell.capture.image.bundleKey, ...(cell.capture.image.bytes !== undefined ? { bytes: cell.capture.image.bytes } : {}) });
42
+ }
43
+ return wanted;
44
+ }
45
+ /** The media type a picture's first bytes say it has (`image/png`, `image/jpeg`, `image/gif`, `image/webp`), or `null` for anything else, SVG included. */
46
+ export function sniffImage(data) {
47
+ const at = (offset, ...bytes) => bytes.every((byte, i) => data[offset + i] === byte);
48
+ if (at(0, 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a))
49
+ return 'image/png';
50
+ if (at(0, 0xff, 0xd8, 0xff))
51
+ return 'image/jpeg';
52
+ if (at(0, 0x47, 0x49, 0x46, 0x38))
53
+ return 'image/gif';
54
+ if (at(0, 0x52, 0x49, 0x46, 0x46) && at(8, 0x57, 0x45, 0x42, 0x50))
55
+ return 'image/webp';
56
+ return null;
57
+ }
58
+ /**
59
+ * Reads the pictures an export wants through `read` (which returns the stored bytes of a picture, or `null` when the store
60
+ * no longer holds it) and keeps those that fit in `maxBytes` in total. A picture that is not stored, is not a PNG, JPEG, GIF or
61
+ * WebP, cannot be read, or would pass the budget is recorded in `left` with the reason; the pictures are taken in the page's
62
+ * order, so which are left out is deterministic. A `read` that throws is a picture left out, never a failed export.
63
+ */
64
+ export async function gatherUiPictures(wanted, read, options) {
65
+ const pictures = new Map();
66
+ const left = new Map();
67
+ let bytes = 0;
68
+ for (const want of wanted) {
69
+ let data;
70
+ try {
71
+ data = await read(want);
72
+ }
73
+ catch (error) {
74
+ left.set(want.key, `could not be read (${error instanceof Error ? error.message.split('\n')[0] : String(error)})`);
75
+ continue;
76
+ }
77
+ if (!data) {
78
+ left.set(want.key, 'is not stored in its layer');
79
+ continue;
80
+ }
81
+ const contentType = sniffImage(data);
82
+ if (!contentType) {
83
+ left.set(want.key, 'is not a PNG, JPEG, GIF or WebP image');
84
+ continue;
85
+ }
86
+ if (bytes + data.length > options.maxBytes) {
87
+ left.set(want.key, `would pass the export's size limit of ${(options.maxBytes / 1024 / 1024).toFixed(0)} MiB`);
88
+ continue;
89
+ }
90
+ bytes += data.length;
91
+ pictures.set(want.key, { contentType, data });
92
+ }
93
+ return { pictures, left, bytes };
94
+ }
95
+ const WORDS = { captured: 'Captured', failed: 'Capture failed', capturing: 'Capturing', skipped: 'Skipped', missing: 'Not captured yet' };
96
+ const escapes = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' };
97
+ const esc = (text) => String(text).replace(/[&<>"']/g, character => escapes[character]);
98
+ function base64(data) {
99
+ let text = '';
100
+ for (let i = 0; i < data.length; i += 0x8000)
101
+ text += String.fromCharCode(...data.subarray(i, i + 0x8000));
102
+ return btoa(text);
103
+ }
104
+ /** JSON that is safe inside a `<script>`: nothing in it can close the element or start a comment. */
105
+ function scriptJson(value) {
106
+ return JSON.stringify(value).replace(/</g, '\\u003c').replace(/>/g, '\\u003e').replace(/&/g, '\\u0026').replace(/\u2028/g, '\\u2028').replace(/\u2029/g, '\\u2029');
107
+ }
108
+ const webAddress = (uri) => (uri && /^https?:\/\//i.test(uri) ? uri : null);
109
+ const STYLE = `
110
+ :root{color-scheme:light dark;--line:#8886;--soft:#8882;--good:#1a7f37;--bad:#cf222e;--wait:#0969da}
111
+ body{margin:0 auto;max-width:96rem;padding:1.5rem;font:16px/1.5 system-ui,sans-serif}
112
+ h1{margin:0 0 .25rem}h2{margin:0;font-size:1.1rem}h3{margin:1.5rem 0 .5rem}
113
+ .muted{opacity:.75}.small{font-size:.85rem}
114
+ .bar{display:flex;flex-wrap:wrap;gap:.75rem;align-items:center;margin:1rem 0}
115
+ .bar input,.bar select{font:inherit;padding:.35rem .5rem;border:1px solid var(--line);border-radius:6px;background:transparent;color:inherit}
116
+ .bar input{min-width:18rem}
117
+ ul.states{list-style:none;margin:0;padding:0;display:grid;gap:1rem}
118
+ li.state{border:1px solid var(--line);border-radius:8px;padding:1rem}
119
+ li.state[hidden],figure[hidden]{display:none}
120
+ .cells{display:grid;grid-template-columns:repeat(auto-fill,minmax(14rem,1fr));gap:1rem;margin-top:.75rem}
121
+ figure{margin:0;border:1px solid var(--soft);border-radius:6px;padding:.5rem}
122
+ figure img{display:block;max-width:100%;height:auto;margin:0 auto;border:1px solid var(--soft)}
123
+ .nopic{padding:1.5rem .5rem;text-align:center;opacity:.8;border:1px dashed var(--line)}
124
+ figcaption{margin-top:.5rem}
125
+ .tag{display:inline-block;padding:0 .5rem;border:1px solid currentColor;border-radius:999px;font-size:.8rem}
126
+ [data-coverage=captured] .tag{color:var(--good)}[data-coverage=failed] .tag{color:var(--bad)}[data-coverage=capturing] .tag{color:var(--wait)}
127
+ .note{border-left:4px solid var(--line);padding:.25rem .75rem;margin:.5rem 0}
128
+ table{border-collapse:collapse}th,td{text-align:left;padding:.25rem .75rem;border-bottom:1px solid var(--soft)}
129
+ code{font-family:ui-monospace,monospace;font-size:.9em}
130
+ `;
131
+ const SCRIPT = `
132
+ (function(){
133
+ var q=document.getElementById('q'),c=document.getElementById('coverage'),n=document.getElementById('shown');
134
+ if(!q||!c)return;
135
+ var items=[].slice.call(document.querySelectorAll('li.state'));
136
+ function run(){
137
+ var terms=q.value.toLowerCase().split(/\\s+/).filter(Boolean),want=c.value,shown=0;
138
+ items.forEach(function(item){
139
+ var text=item.getAttribute('data-text')||'',ok=terms.every(function(t){return text.indexOf(t)>=0});
140
+ var cells=[].slice.call(item.querySelectorAll('figure')),any=false;
141
+ cells.forEach(function(cell){var on=!want||cell.getAttribute('data-coverage')===want;cell.hidden=!on;any=any||on});
142
+ var show=ok&&any;item.hidden=!show;if(show)shown++;
143
+ });
144
+ if(n)n.textContent=shown+' of '+items.length+' states shown';
145
+ }
146
+ q.addEventListener('input',run);c.addEventListener('change',run);run();
147
+ })();
148
+ `;
149
+ function cellHtml(state, cell, options) {
150
+ const capture = cell.capture;
151
+ const key = uiPictureKey(capture);
152
+ const picture = key ? options.pictures?.get(key) : undefined;
153
+ const address = webAddress(capture?.image?.uri);
154
+ const shows = cell.coverage === 'captured' || cell.coverage === 'failed';
155
+ let figure;
156
+ if (shows && picture) {
157
+ const { width, height } = capture.image;
158
+ figure = `<img alt="${esc(`${state.title}, ${cell.profile.label}`)}" loading="lazy" src="data:${picture.contentType};base64,${base64(picture.data)}"${width && height ? ` width="${esc(width)}" height="${esc(height)}"` : ''}>`;
159
+ }
160
+ else if (shows && capture?.image?.omitted)
161
+ figure = `<div class="nopic">Picture left out by its producer (${esc(capture.image.omitted)})</div>`;
162
+ else if (shows && key && options.left?.get(key))
163
+ figure = `<div class="nopic">The picture ${esc(options.left.get(key))}</div>`;
164
+ else if (shows && address)
165
+ figure = `<div class="nopic"><a href="${esc(address)}" rel="noopener noreferrer">Open the picture at its address</a></div>`;
166
+ else
167
+ figure = `<div class="nopic">${cell.coverage === 'captured' ? 'Captured without a picture' : esc(WORDS[cell.coverage])}</div>`;
168
+ const origin = capture?.origin;
169
+ const where = origin ? [
170
+ `layer <code>${esc(origin.layer.slice(0, 12))}</code> in backend <code>${esc(origin.backend)}</code>`, `producer ${esc(origin.producer)}`,
171
+ capture.labels.shard ? `shard ${esc(capture.labels.shard)}` : '', origin.branch ? esc(origin.branch) : '', origin.sha ? `<code>${esc(origin.sha.slice(0, 12))}</code>` : '',
172
+ `<time datetime="${esc(new Date(origin.recordedAtMs).toISOString())}">${esc(new Date(origin.recordedAtMs).toISOString().slice(0, 19).replace('T', ' '))} UTC</time>`,
173
+ origin.freshness === 'modified' ? 'recorded with local changes' : origin.freshness === 'unknown' ? 'origin not captured' : ''
174
+ ].filter(Boolean).join(', ') : cell.planned ? `expected by plan layer <code>${esc(cell.planned.layer.slice(0, 12))}</code>; nothing has been captured` : '';
175
+ return `<figure data-coverage="${esc(cell.coverage)}" data-profile="${esc(cell.profile.key)}">${figure}<figcaption><strong>${esc(cell.profile.label)}</strong> <span class="tag">${esc(WORDS[cell.coverage])}</span>${cell.expected === 'undeclared' ? ' <span class="small muted">not in the expected set</span>' : ''}${capture?.detail ? `<div class="small">${esc(capture.detail)}</div>` : ''}${where ? `<div class="small muted">${where}</div>` : ''}</figcaption></figure>`;
176
+ }
177
+ function stateHtml(state, index, options) {
178
+ const facts = [['Route', state.route], ['Fixture', state.fixture], ['Cohort', state.cohort], ['Area', state.area], ['Feature flags', state.flags.join(', ')], ['Surfaces covered', state.covers.join(', ')], ['Producers', state.producers.join(', ')]]
179
+ .filter(([, value]) => value).map(([name, value]) => `${esc(name)}: ${esc(value)}`).join(' · ');
180
+ const text = [state.id, state.title, state.route, state.fixture, state.cohort, state.area, ...state.flags, ...state.covers, ...state.producers].filter(Boolean).join(' ').toLowerCase();
181
+ return `<li class="state" id="state-${index}" data-text="${esc(text)}"><h2>${esc(state.title)}</h2><div class="small muted"><code>${esc(state.id)}</code>${facts ? ` · ${facts}` : ''}</div><div class="cells">${state.cells.map(cell => cellHtml(state, cell, options)).join('')}</div></li>`;
182
+ }
183
+ /**
184
+ * The whole catalog as one self-contained HTML document. See the module description for what it contains and how it treats
185
+ * the data in it. Deterministic for the same catalog, pictures and `generatedAtMs`.
186
+ *
187
+ * @example
188
+ * ```ts
189
+ * const wanted = uiPicturesWanted(catalog);
190
+ * const { pictures, left } = await gatherUiPictures(wanted, want => readPicture(want), { maxBytes: 32 * 1024 * 1024 });
191
+ * await writeFile('catalog.html', uiCatalogHtml(catalog, { pictures, left }));
192
+ * ```
193
+ */
194
+ export function uiCatalogHtml(catalog, options = {}) {
195
+ const { summary } = catalog;
196
+ const title = options.title ?? 'UI catalog';
197
+ const made = new Date(options.generatedAtMs ?? Date.now()).toISOString();
198
+ const open = catalog.surfaces.filter(surface => surface.status !== 'covered');
199
+ const unavailable = catalog.sources.backends.filter(backend => !backend.available);
200
+ const notices = [...catalog.sources.notices, ...unavailable.map(backend => `${backend.label} is not available: captures held there are not shown, and nothing that depends on them is shown as captured.`)];
201
+ return `<!doctype html>
202
+ <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
203
+ <meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src data:; style-src 'unsafe-inline'; script-src 'unsafe-inline'">
204
+ <title>${esc(title)}</title><style>${STYLE}</style></head><body>
205
+ <header><h1>${esc(title)}</h1>
206
+ <p class="muted">Made ${esc(made.slice(0, 19).replace('T', ' '))} UTC from ${esc(catalog.sources.layers)} layer${catalog.sources.layers === 1 ? '' : 's'}. This is a copy as of that moment; it does not update.</p>
207
+ <p role="status">${esc(summary.states)} state${summary.states === 1 ? '' : 's'} in ${esc(summary.profiles)} profile${summary.profiles === 1 ? '' : 's'}: ${UI_COVERAGE.map(value => `${esc(summary.coverage[value])} ${esc(WORDS[value].toLowerCase())}`).join(', ')}.${summary.planned ? (summary.undeclared ? ` ${esc(summary.undeclared)} captured cells are not in the expected set.` : '') : ' No expected set was published, so what should exist is unknown and nothing is listed as not captured yet.'}</p>
208
+ ${notices.map(notice => `<p class="note" role="status">${esc(notice)}</p>`).join('\n')}
209
+ </header>
210
+ <form class="bar" role="search" onsubmit="return false"><label>Search <input id="q" type="search" placeholder="Words in a state, route, area, surface…"></label>
211
+ <label>Show <select id="coverage"><option value="">every coverage</option>${UI_COVERAGE.map(value => `<option value="${value}">${esc(WORDS[value])}</option>`).join('')}</select></label>
212
+ <span id="shown" class="muted small" role="status"></span></form>
213
+ ${open.length ? `<section aria-label="Surfaces without a captured state"><h3>Surfaces without a captured state</h3><table><thead><tr><th scope="col">Surface</th><th scope="col">Coverage</th></tr></thead><tbody>${open.map(surface => `<tr><td>${esc(surface.id)}</td><td>${surface.status === 'uncovered' ? 'No state covers it' : 'States cover it, none captured yet'}</td></tr>`).join('')}</tbody></table></section>` : ''}
214
+ <main><ul class="states" aria-label="States">
215
+ ${catalog.states.map((state, index) => stateHtml(state, index, options)).join('\n')}
216
+ </ul>${catalog.states.length ? '' : '<p class="muted">No UI states have been recorded.</p>'}</main>
217
+ <script type="application/json" id="ui-catalog">${scriptJson(catalog)}</script>
218
+ <script>${SCRIPT}</script>
219
+ </body></html>
220
+ `;
221
+ }
@@ -1679,6 +1679,61 @@ export declare const ArtifactTagsSchema: z.ZodObject<{
1679
1679
  atMs: z.ZodNumber;
1680
1680
  }, z.core.$loose>>;
1681
1681
  }, z.core.$loose>;
1682
+ /** One stored video that failed the server's read-back: which check holds it and what is wrong with it. */
1683
+ export declare const StoredRecordingGapSchema: z.ZodObject<{
1684
+ check: z.ZodNullable<z.ZodString>;
1685
+ name: z.ZodString;
1686
+ bundleKey: z.ZodString;
1687
+ problem: z.ZodString;
1688
+ reason: z.ZodString;
1689
+ }, z.core.$loose>;
1690
+ /** What reading back the stored videos of one layer found. */
1691
+ export declare const LayerRecordingCheckSchema: z.ZodObject<{
1692
+ layer: z.ZodString;
1693
+ run: z.ZodString;
1694
+ verifiedAtMs: z.ZodNumber;
1695
+ checked: z.ZodNumber;
1696
+ ok: z.ZodNumber;
1697
+ referenced: z.ZodNumber;
1698
+ gaps: z.ZodArray<z.ZodObject<{
1699
+ check: z.ZodNullable<z.ZodString>;
1700
+ name: z.ZodString;
1701
+ bundleKey: z.ZodString;
1702
+ problem: z.ZodString;
1703
+ reason: z.ZodString;
1704
+ }, z.core.$loose>>;
1705
+ }, z.core.$loose>;
1706
+ /**
1707
+ * `GET` and `POST /api/reports/recordings/verification`: the newest read-back the server did of stored videos, per layer.
1708
+ * A layer that was never read back is absent, which means "not verified", never "fine".
1709
+ */
1710
+ export declare const RecordingVerificationSchema: z.ZodObject<{
1711
+ schemaVersion: z.ZodLiteral<1>;
1712
+ layers: z.ZodArray<z.ZodObject<{
1713
+ layer: z.ZodString;
1714
+ run: z.ZodString;
1715
+ verifiedAtMs: z.ZodNumber;
1716
+ checked: z.ZodNumber;
1717
+ ok: z.ZodNumber;
1718
+ referenced: z.ZodNumber;
1719
+ gaps: z.ZodArray<z.ZodObject<{
1720
+ check: z.ZodNullable<z.ZodString>;
1721
+ name: z.ZodString;
1722
+ bundleKey: z.ZodString;
1723
+ problem: z.ZodString;
1724
+ reason: z.ZodString;
1725
+ }, z.core.$loose>>;
1726
+ }, z.core.$loose>>;
1727
+ notices: z.ZodArray<z.ZodString>;
1728
+ verified: z.ZodOptional<z.ZodNumber>;
1729
+ }, z.core.$loose>;
1730
+ /** The document of {@link RecordingVerificationSchema}, as a type. */
1731
+ export type RecordingVerification = z.infer<typeof RecordingVerificationSchema>;
1732
+ /** The body of `POST /api/reports/recordings/verification`. Both fields are optional; an empty body reads back the newest layers. */
1733
+ export declare const RecordingVerifyRequestSchema: z.ZodObject<{
1734
+ layers: z.ZodOptional<z.ZodArray<z.ZodString>>;
1735
+ limit: z.ZodOptional<z.ZodNumber>;
1736
+ }, z.core.$loose>;
1682
1737
  /** A source a review collection points at: one recorded result of one archived layer, and optionally one of its recordings. Exact and immutable: a layer never changes. */
1683
1738
  export declare const ReviewSourceSchema: z.ZodObject<{
1684
1739
  layer: z.ZodString;
@@ -254,6 +254,39 @@ export const ArtifactTagsSchema = z.looseObject({
254
254
  schemaVersion: z.literal(1),
255
255
  entries: z.array(ArtifactTagEntrySchema)
256
256
  });
257
+ /** One stored video that failed the server's read-back: which check holds it and what is wrong with it. */
258
+ export const StoredRecordingGapSchema = z.looseObject({
259
+ check: z.string().nullable().describe('The id of the check that holds the video, or null for a video attached to the run or a part'),
260
+ name: z.string().describe('The attachment\'s name'),
261
+ bundleKey: z.string().describe('The key the layer names the file by'),
262
+ problem: z.string().describe('`not-in-bundle`, `blob-missing`, `unreadable`, `size-mismatch`, `checksum-mismatch` or `undecodable`. An open vocabulary: treat an unknown value as a gap'),
263
+ reason: z.string().describe('A short explanation without secrets or paths')
264
+ });
265
+ /** What reading back the stored videos of one layer found. */
266
+ export const LayerRecordingCheckSchema = z.looseObject({
267
+ layer: z.string().describe('The layer id'),
268
+ run: z.string().describe('The key of the run the layer belongs to (a `RunSummary.key`)'),
269
+ verifiedAtMs: z.number().describe('When the server read the videos back, epoch milliseconds'),
270
+ checked: z.number().int().describe('Videos read back: those that name a bundle key'),
271
+ ok: z.number().int().describe('Of those, the ones that passed every check'),
272
+ referenced: z.number().int().describe('Videos given only by address, which Atlas cannot read back and so does not vouch for'),
273
+ gaps: z.array(StoredRecordingGapSchema).describe('The videos that did not pass. Never a test result: statuses are unchanged')
274
+ });
275
+ /**
276
+ * `GET` and `POST /api/reports/recordings/verification`: the newest read-back the server did of stored videos, per layer.
277
+ * A layer that was never read back is absent, which means "not verified", never "fine".
278
+ */
279
+ export const RecordingVerificationSchema = z.looseObject({
280
+ schemaVersion: z.literal(1),
281
+ layers: z.array(LayerRecordingCheckSchema).describe('Layers the server has read back and still holds, newest first'),
282
+ notices: z.array(z.string()).describe('Layers the last pass could not read at all: their recordings are unknown, not verified'),
283
+ verified: z.number().int().optional().describe('POST only: how many layers this pass read back')
284
+ });
285
+ /** The body of `POST /api/reports/recordings/verification`. Both fields are optional; an empty body reads back the newest layers. */
286
+ export const RecordingVerifyRequestSchema = z.looseObject({
287
+ layers: z.array(z.string().regex(/^[0-9a-f]{7,64}$/i)).max(50).optional().describe('Only layers whose id starts with one of these (at least 7 hex digits)'),
288
+ limit: z.number().int().min(1).max(200).optional().describe('How many of the newest layers to read back. Default 20, at most 200')
289
+ });
257
290
  /** A source a review collection points at: one recorded result of one archived layer, and optionally one of its recordings. Exact and immutable: a layer never changes. */
258
291
  export const ReviewSourceSchema = z.looseObject({
259
292
  layer: z.string().regex(/^[0-9a-f]{64}$/).describe('The id of the archived layer that recorded the result'),