@crvouga/atlas 1.20.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 (33) hide show
  1. package/CHANGELOG.md +7 -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/cli-results.d.ts +318 -1
  15. package/dist/schema/src/cli-results.js +89 -1
  16. package/docs/agents.md +5 -0
  17. package/docs/reference/cli.md +56 -1
  18. package/docs/reference/reports.md +392 -0
  19. package/docs/reference/schema.md +41 -1
  20. package/docs/ui-catalog.md +106 -12
  21. package/docs/ui.md +6 -1
  22. package/llms-full.txt +170 -11
  23. package/package.json +1 -1
  24. package/packages/schema/schemas/ui-catalog-diff-result.schema.json +201 -0
  25. package/packages/schema/schemas/ui-catalog-export-result.schema.json +83 -0
  26. package/packages/schema/schemas/ui-catalog-publish-result.schema.json +116 -0
  27. package/packages/schema/schemas/ui-catalog-status.schema.json +197 -0
  28. package/packages/visualizer/src/catalog/Picture.tsx +32 -0
  29. package/packages/visualizer/src/catalog/UiCatalogCompare.tsx +289 -0
  30. package/packages/visualizer/src/catalog/UiCatalogPage.tsx +18 -28
  31. package/packages/visualizer/src/catalog/history.ts +61 -0
  32. package/packages/visualizer/src/catalog/search.ts +14 -3
  33. package/packages/visualizer/src/evidence/evidence-context.tsx +2 -1
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * @module Command results (`--format json`)
3
3
  *
4
- * What `atlas report publish`, `stream`, `merge`, `doctor` and `verify-recordings` print on standard output with `--format json` (alias
4
+ * What `atlas report publish`, `stream`, `merge`, `doctor` and `verify-recordings`, and `atlas ui-catalog plan`, `publish`, `status`, `diff` and `export`, print on standard output with `--format json` (alias
5
5
  * `--json`), and what any `atlas` command prints on standard error for a command that was wrong: one JSON document,
6
6
  * so an agent or a script never has to parse prose. (`status` and `policy` already print documents:
7
7
  * `ReportStatusSchema`, and an IAM policy.) Each is also published as a JSON Schema.
@@ -154,6 +154,89 @@ export const InitPlanSchema = z.looseObject({
154
154
  nextSteps: z.array(z.string()).describe('What to do next, in order, as plain instructions an agent can follow'),
155
155
  warnings: z.array(z.string())
156
156
  });
157
+ /** The coverage of one state in one profile, as the UI catalog words it. */
158
+ const UiCoverageSchema = z.enum(['captured', 'failed', 'capturing', 'skipped', 'missing']);
159
+ /** What `atlas ui-catalog plan --format json` and `atlas ui-catalog publish --format json` print: the one layer that was written. */
160
+ export const UiCatalogPublishResultSchema = z.looseObject({
161
+ ok: z.literal(true).describe('True: the layer was written. A state whose picture could not be read is published as `failed` and named in `warnings`; it does not fail the command'),
162
+ store: z.string().describe('Where it was written: a folder, `s3://…` or a server URL'),
163
+ layer: z.looseObject({
164
+ id: z.string().describe('The layer id: 64 hex digits, the SHA-256 of its content. Publishing the same input again gives the same id'),
165
+ kind: z.enum(['ui-state', 'ui-plan']).describe('`ui-state`: captures (`publish`). `ui-plan`: the expected set (`plan`)'),
166
+ producer: z.string(),
167
+ environment: z.string().nullable(),
168
+ parameters: z.record(z.string(), z.string()).describe('The rest of the profile, such as `{ "viewport": "390x844" }`'),
169
+ states: z.number().int().nonnegative().describe('States in the layer'),
170
+ captured: z.number().int().nonnegative().describe('For `publish`: states that were captured (`passed`). Zero for a plan'),
171
+ failed: z.number().int().nonnegative().describe('For `publish`: states recorded as failed, including those whose picture could not be read'),
172
+ pictures: z.number().int().nonnegative().describe('Pictures stored in the layer'),
173
+ pictureBytes: z.number().int().nonnegative(),
174
+ shard: z.string().nullable().describe('The `--shard` label, which describes the layer and never splits its slot')
175
+ }),
176
+ warnings: z.array(z.string()).describe('What was skipped and why, such as a picture file that could not be read. Never fatal')
177
+ });
178
+ /** One thing that keeps a UI catalog from being complete and good. */
179
+ export const UiCatalogProblemSchema = z.looseObject({
180
+ kind: z.enum(['failed', 'missing', 'capturing', 'unavailable', 'unreadable', 'empty']).describe('`failed`: a capture was attempted and failed. `missing`: a plan expects it and nothing was captured. `capturing`: still being taken, so not known. `unavailable`: a backend could not be read. `unreadable`: a layer could not be read. `empty`: the catalog holds no state at all'),
181
+ state: z.string().optional().describe('The state id, for `failed`, `missing` and `capturing`'),
182
+ profile: z.string().optional().describe('The profile label, for `failed`, `missing` and `capturing`'),
183
+ detail: z.string().optional().describe('Why, when the producer said')
184
+ });
185
+ /** What `atlas ui-catalog status --format json` prints. */
186
+ export const UiCatalogStatusSchema = z.looseObject({
187
+ ok: z.boolean().describe('True only when the catalog holds a state and has no problem: nothing failed, nothing expected is missing, nothing is still being captured, and every backend and layer was read (otherwise the exit code is 1). Skipped cells are counted, not problems'),
188
+ summary: z.looseObject({
189
+ states: z.number().int().nonnegative(),
190
+ profiles: z.number().int().nonnegative(),
191
+ cells: z.number().int().nonnegative().describe('States counted once per profile'),
192
+ coverage: z.record(UiCoverageSchema, z.number().int().nonnegative()).describe('Cells by coverage'),
193
+ undeclared: z.number().int().nonnegative().describe('Captured cells no plan expects; zero when no plan exists'),
194
+ planned: z.boolean().describe('Whether any plan was published. Without one, `missing` is always zero because what should exist is unknown'),
195
+ surfaces: z.number().int().nonnegative(),
196
+ uncoveredSurfaces: z.number().int().nonnegative()
197
+ }),
198
+ profiles: z.array(z.looseObject({ key: z.string(), label: z.string() })),
199
+ problems: z.array(UiCatalogProblemSchema),
200
+ sources: z.looseObject({ layers: z.number().int().nonnegative(), backends: z.array(z.looseObject({ id: z.string(), label: z.string(), available: z.boolean() })), notices: z.array(z.string()) })
201
+ });
202
+ /** One side of `atlas ui-catalog diff`. */
203
+ const UiCatalogCommitSchema = z.looseObject({ sha: z.string(), states: z.number().int().nonnegative(), layers: z.number().int().nonnegative() });
204
+ /** What `atlas ui-catalog diff --format json` prints. */
205
+ export const UiCatalogDiffResultSchema = z.looseObject({
206
+ ok: z.boolean().describe('False when a layer or backend could not be read (the comparison may be wrong), or, with `--fail-on-change`, when anything differs. Otherwise true: a difference is not a failure by itself'),
207
+ from: UiCatalogCommitSchema,
208
+ to: UiCatalogCommitSchema,
209
+ summary: z.looseObject({
210
+ coverage: z.number().int().nonnegative().describe('Pairs that stand differently (captured against failed, for example)'),
211
+ picture: z.number().int().nonnegative().describe('Both captured, and the stored bytes differ. Not a measure of how many pixels'),
212
+ unverified: z.number().int().nonnegative().describe('Both captured, but a side has no content hash, so whether the picture changed is unknown'),
213
+ added: z.number().int().nonnegative().describe('Only `to` has it'),
214
+ removed: z.number().int().nonnegative().describe('Only `from` has it. Not a regression: the other side may not have run it'),
215
+ unchanged: z.number().int().nonnegative(),
216
+ total: z.number().int().nonnegative()
217
+ }),
218
+ changes: z.array(z.looseObject({
219
+ state: z.string(), title: z.string(), profile: z.string(),
220
+ change: z.enum(['coverage', 'picture', 'unverified', 'added', 'removed']),
221
+ from: UiCoverageSchema.nullable(), to: UiCoverageSchema.nullable()
222
+ })).describe('Every pair that is not unchanged'),
223
+ notices: z.array(z.string()).describe('What could not be read. Any makes `ok` false: an unreadable layer is unknown, never unchanged')
224
+ });
225
+ /** What `atlas ui-catalog export --format json` prints. */
226
+ export const UiCatalogExportResultSchema = z.looseObject({
227
+ ok: z.literal(true),
228
+ out: z.string().describe('The HTML file that was written'),
229
+ bytes: z.number().int().nonnegative().describe('Its size'),
230
+ states: z.number().int().nonnegative(),
231
+ cells: z.number().int().nonnegative(),
232
+ pictures: z.looseObject({
233
+ embedded: z.number().int().nonnegative(),
234
+ embeddedBytes: z.number().int().nonnegative(),
235
+ left: z.number().int().nonnegative().describe('Pictures not embedded (over the size limit, not stored, not an image, or `--images none`); each is listed on the page with the reason')
236
+ }),
237
+ manifest: z.string().nullable().describe('The JSON manifest written beside it with `--manifest`, or null'),
238
+ warnings: z.array(z.string()).describe('Backends and layers that could not be read, and pictures left out. The page says so too')
239
+ });
157
240
  /**
158
241
  * JSON command contracts, shared by schema publication, agent documentation and executable command checks.
159
242
  * A new result needs an entry here and a runtime scenario in the documentation contract tests.
@@ -166,5 +249,10 @@ export const CLI_RESULTS = {
166
249
  doctor: { command: 'atlas report doctor', schemaName: 'doctor-result', schema: DoctorResultSchema },
167
250
  status: { command: 'atlas report status', schemaName: 'report-status', schema: ReportStatusSchema },
168
251
  verifyRecordings: { command: 'atlas report verify-recordings', schemaName: 'verify-recordings-result', schema: VerifyRecordingsResultSchema },
252
+ uiCatalogPlan: { command: 'atlas ui-catalog plan', schemaName: 'ui-catalog-publish-result', schema: UiCatalogPublishResultSchema },
253
+ uiCatalogPublish: { command: 'atlas ui-catalog publish', schemaName: 'ui-catalog-publish-result', schema: UiCatalogPublishResultSchema },
254
+ uiCatalogStatus: { command: 'atlas ui-catalog status', schemaName: 'ui-catalog-status', schema: UiCatalogStatusSchema },
255
+ uiCatalogDiff: { command: 'atlas ui-catalog diff', schemaName: 'ui-catalog-diff-result', schema: UiCatalogDiffResultSchema },
256
+ uiCatalogExport: { command: 'atlas ui-catalog export', schemaName: 'ui-catalog-export-result', schema: UiCatalogExportResultSchema },
169
257
  error: { command: 'a command that was wrong (standard error)', schemaName: 'command-error', schema: CommandErrorSchema }
170
258
  };
package/docs/agents.md CHANGED
@@ -164,6 +164,11 @@ JSON Schema (`@crvouga/atlas/schemas/<name>.schema.json`, draft 2020-12):
164
164
  | `atlas report doctor` | `doctor-result` |
165
165
  | `atlas report status` | `report-status` |
166
166
  | `atlas report verify-recordings` | `verify-recordings-result` |
167
+ | `atlas ui-catalog plan` | `ui-catalog-publish-result` |
168
+ | `atlas ui-catalog publish` | `ui-catalog-publish-result` |
169
+ | `atlas ui-catalog status` | `ui-catalog-status` |
170
+ | `atlas ui-catalog diff` | `ui-catalog-diff-result` |
171
+ | `atlas ui-catalog export` | `ui-catalog-export-result` |
167
172
  | `a command that was wrong (standard error)` | `command-error` |
168
173
  <!-- /generated:json-command-schemas -->
169
174
 
@@ -2,7 +2,7 @@
2
2
 
3
3
  > Generated from the usage text each command prints (`atlas <command> --help`) by `bun run docs`. Do not edit.
4
4
 
5
- Add `--format json` (alias `--json`) to `init`, `report publish`, `stream`, `merge`, `doctor` and `status` for a JSON document on standard output (schemas in `@crvouga/atlas/schemas`). Exit codes: `0` it worked, `1` it ran and the answer is no, `2` the command was wrong.
5
+ Add `--format json` (alias `--json`) to `init`, `report publish`, `stream`, `merge`, `doctor`, `status` and every `ui-catalog` command for a JSON document on standard output (schemas in `@crvouga/atlas/schemas`). Exit codes: `0` it worked, `1` it ran and the answer is no, `2` the command was wrong.
6
6
 
7
7
  ## atlas
8
8
 
@@ -21,6 +21,7 @@ atlas — executable statecharts
21
21
  atlas dev [--port 5180] develop the Atlas server and client on one host
22
22
  atlas init [--server <url>] [--write] integrate this project: detect its suites, print the plan (atlas init --help)
23
23
  atlas report publish|stream|status|merge|doctor|policy … layered evidence for any kind of spec (atlas report --help)
24
+ atlas ui-catalog plan|publish|status|diff|export … the UI catalog of captured states (atlas ui-catalog --help)
24
25
 
25
26
  --workers path attempts that run at once (default: one per CPU, capped by the driver)
26
27
  --seed only paths that start from this seed ("start" for paths setup starts); repeatable
@@ -172,3 +173,57 @@ atlas report — layered evidence for any kind of spec
172
173
 
173
174
  Exit codes: 0 ok, 1 the report does not pass (or a check failed), 2 the command was wrong.
174
175
  ```
176
+
177
+ ## atlas ui-catalog
178
+
179
+ ```text
180
+ atlas ui-catalog — a browsable inventory of captured UI states, from any collector
181
+
182
+ atlas ui-catalog plan --states <file.json> --producer <name> [--store <dir|s3://bucket/prefix|https://…>]
183
+ [--environment <name>] [--param key=value]... [--shard <label>]
184
+ [--branch <b>] [--sha <s>] [--run-url <u>] [--run-id <id>] [--run-title <t>] [--recorded-at <iso|ms>]
185
+ atlas ui-catalog publish --captures <file.json> --producer <name> [the same flags as plan]
186
+ [--attach-root <dir>]... [--max-picture-mb <n>]
187
+ atlas ui-catalog status [--store <dir|s3://…|id=url>]... [--backends <module>] [filters] [--branch <name>]...
188
+ [--ignore-modified] [--format text|json|markdown]
189
+ atlas ui-catalog diff --from <commit> --to <commit> [--store …]... [--backends <module>] [filters]
190
+ [--fail-on-change] [--format text|json|markdown]
191
+ atlas ui-catalog export --out <file.html> [--store …]... [--backends <module>] [filters]
192
+ [--images inline|none] [--max-mb <n>] [--manifest <file.json>] [--title <t>]
193
+
194
+ filters (status, diff, export): --q <words> --coverage a,b --profile <key|label>... --area a --route r --cohort c
195
+ --flag f --surface s --producer p
196
+
197
+ plan Write one layer of kind ui-plan: the states (and with "inventory": true, the surfaces) that should exist under
198
+ this profile. Every entry is not-run, so a plan never counts as a pass. The file is {"states":[{"id":…,
199
+ "title"?,"route"?,"fixture"?,"cohort"?,"area"?,"flags"?,"covers"?,"inventory"?}]} or just that list.
200
+ publish Write one layer of kind ui-state: the states one collector or shard captured under this profile. The file is
201
+ {"states":[{"id":…,"title"?,"status"?:"passed|failed|running|not-run","detail"?,"route"?,"fixture"?,"cohort"?,
202
+ "area"?,"flags"?,"covers"?,"labels"?,"tags"?,"durationMs"?,"screenshot"?:"file.png"|{"path"|"uri",
203
+ "width"?,"height"?,"contentType"?}}]}. A picture file is read from beside the file and each --attach-root, never
204
+ from a hidden file or a link out. A passed state whose picture cannot be read is published as failed, with the
205
+ reason: a state without its picture is not shown as captured.
206
+ --environment and each --param key=value are the profile (a viewport, a color scheme, a device): another
207
+ profile is another result side by side. --shard only describes the layer; it never splits a result.
208
+ --recorded-at defaults to the modification time of the file, so publishing the same file twice is one layer.
209
+ status Read every store and print the catalog's summary. Exit 1 unless the catalog holds a state and there is nothing
210
+ to attend to: a failed capture, a state a plan expects with nothing captured, a capture still in progress, a
211
+ backend or layer that could not be read. Without a plan nothing is expected, so nothing is missing.
212
+ --profile takes a profile's key or its label, as printed by status.
213
+ diff Set the catalog recorded at one commit (--from) against another (--to): each state in each profile is paired
214
+ with itself and is unchanged, changed in coverage, changed in picture (the stored bytes differ), unverified (a
215
+ side has no content hash), only in --to (added) or only in --from (removed). A commit is the whole id or at
216
+ least 7 digits of it. Exit 0 unless a store could not be read, or, with --fail-on-change, anything differs.
217
+ export Write the catalog as one HTML file with its pictures embedded and its manifest in it: no server, no network.
218
+ It is the catalog at the moment it was made. --images none leaves the pictures out. --max-mb (default 32) limits
219
+ the embedded pictures; those past it are listed in the page as left out, and the manifest stays complete.
220
+ --manifest writes the catalog's JSON manifest too.
221
+
222
+ --format json (alias --json) every subcommand prints one JSON document instead of prose
223
+ (schemas: ui-catalog-publish-result, ui-catalog-status, ui-catalog-diff-result, ui-catalog-export-result).
224
+ A command that is wrong is {"ok":false,"error":{"message":…,"code":"usage"}} on standard error.
225
+ A store is a directory, s3://bucket/prefix or an Atlas server (https://…), exactly as for atlas report; select a project
226
+ with --project <slug> or ATLAS_PROJECT. When no --store is given, ATLAS_STORE is used.
227
+
228
+ Exit codes: 0 ok, 1 the answer is no (see each command), 2 the command was wrong.
229
+ ```
@@ -1420,6 +1420,32 @@ export function summarize(combined: CombinedEvidence): ReportSummary
1420
1420
 
1421
1421
  Counts a combined report's winning results in total and per environment, and lists the specs that have no result at all.
1422
1422
 
1423
+ #### `BackendLayers`
1424
+
1425
+ ```ts
1426
+ export type BackendLayers = {
1427
+ /** Each layer that could be read, with the specs it recorded. A layer several backends hold appears once per backend. */
1428
+ readonly loaded: readonly { readonly evidence: LayerEvidence; readonly specs: readonly SpecEntry[] }[];
1429
+ /** Every backend given, sorted by id, with whether it could be listed. */
1430
+ readonly backends: readonly BackendInfo[];
1431
+ /** What could not be read, sorted and without repeats. */
1432
+ readonly notices: readonly string[];
1433
+ };
1434
+ ```
1435
+
1436
+ Every layer of some backends, read but not yet composed.
1437
+
1438
+ #### `readBackendLayers`
1439
+
1440
+ ```ts
1441
+ export async function readBackendLayers(backends: readonly BackendInput[], options: { readonly concurrency?: number } = {}): Promise<BackendLayers>
1442
+ ```
1443
+
1444
+ Reads every layer of every backend, superseded ones included, without composing them. This is what {@link combineBackends}
1445
+ starts from; a caller that needs layers a newer layer has replaced (to compare two commits, for example) reads them here.
1446
+ A backend that cannot be listed, or a layer that cannot be understood, is a notice, never an omission that looks like a pass.
1447
+ Throws for an invalid or repeated backend `id`, before anything is read.
1448
+
1423
1449
  #### `combineBackends`
1424
1450
 
1425
1451
  ```ts
@@ -6992,6 +7018,32 @@ Whether the checks ran on exactly the commit they name. A CI checkout does. A de
6992
7018
  with edits to tracked files does not: its layer is `modified`, shown with a warning and never mistaken
6993
7019
  for the commit's own result. Outside git the origin is `unknown`.
6994
7020
 
7021
+ #### `keyValue`
7022
+
7023
+ ```ts
7024
+ export function keyValue(pair: string, flag: string): [string, string]
7025
+ ```
7026
+
7027
+ Splits `key=value` (the form of `--param` and `--label`), or throws a message naming `flag`.
7028
+
7029
+ #### `parseTime`
7030
+
7031
+ ```ts
7032
+ export function parseTime(text: string | undefined, flag = '--recorded-at'): number | null
7033
+ ```
7034
+
7035
+ An ISO date or epoch milliseconds from a flag, `null` when the flag was not given. Throws a message naming `flag` for anything else.
7036
+
7037
+ #### `executionOf`
7038
+
7039
+ ```ts
7040
+ export function executionOf(rest: readonly string[], env: Env, given?: unknown): LayerRun | undefined
7041
+ ```
7042
+
7043
+ The execution the producer named, from `--run-id`, `--run-title`, `--run-started-at`, `--run-ended-at` and then
7044
+ `ATLAS_RUN_ID` / `ATLAS_RUN_TITLE`, laid over a `run` the checks file gave. `undefined` when nothing names one, so the
7045
+ documented grouping (CI run URL, else branch@sha) applies. A title or time without an id is refused: it would have nothing to belong to.
7046
+
6995
7047
  #### `runReportCommand`
6996
7048
 
6997
7049
  ```ts
@@ -7001,6 +7053,14 @@ export async function runReportCommand(argv: readonly string[]): Promise<number>
7001
7053
  `atlas report …` for a terminal: a mistake in the command or a path is a message on stderr and exit
7002
7054
  code 2, so a CI job can tell a broken invocation (2) from a report that does not pass (1).
7003
7055
 
7056
+ #### `wantsJson`
7057
+
7058
+ ```ts
7059
+ export function wantsJson(argv: readonly string[]): boolean
7060
+ ```
7061
+
7062
+ Does the command ask for a JSON document (`--format json` or `--json`) instead of prose?
7063
+
7004
7064
  #### `reportMain`
7005
7065
 
7006
7066
  ```ts
@@ -10556,6 +10616,338 @@ export type ReportData = HtmlReportBundle & {
10556
10616
 
10557
10617
  A run's tests (`HtmlReportBundle`) plus the document title shown for them, for the standalone / Solid report viewer.
10558
10618
 
10619
+ ### The `atlas ui-catalog` command
10620
+
10621
+ Source: `packages/reports/src/ui-catalog-cli.ts`. Import: `@crvouga/atlas/reports/ui-catalog-cli`
10622
+
10623
+ `uiCatalogMain` is `atlas ui-catalog plan | publish | status | diff | export`: the UI catalog ({@link ./ui-catalog}) without
10624
+ a script. `plan` and `publish` turn a small JSON file (and the pictures beside it) into the layers of the expected set and of
10625
+ the captures; `status` reads every store and says whether the catalog is complete and free of failures; `diff` sets two
10626
+ commits against each other; `export` writes the catalog as one HTML file that needs no server. Like `atlas report`, it is
10627
+ written so it can be called from code and tests: it takes its arguments, environment, working directory, clock and git as
10628
+ parameters and returns the exit code, every command prints a JSON document with `--format json`, and a command that is wrong
10629
+ throws, which `runUiCatalogCommand` turns into a message (or a JSON error) and exit code 2.
10630
+
10631
+ Nothing here knows how a picture was taken: the files it reads are the same data any collector can write. Publishing a
10632
+ catalog to a hosting platform is not a command: it would need that platform's credentials and API, which Atlas does not
10633
+ hold. The file `export` writes, or a folder of them, is what to hand to whatever hosts static files.
10634
+
10635
+ Exit codes: 0 it worked, 1 it ran and the answer is no (an incomplete catalog, a failed capture, an unreadable store, a
10636
+ difference under `--fail-on-change`), 2 the command itself was wrong.
10637
+
10638
+ See docs/ui-catalog.md
10639
+ See docs/agents.md
10640
+
10641
+ #### `UI_CATALOG_USAGE`
10642
+
10643
+ ```ts
10644
+ export const UI_CATALOG_USAGE = …
10645
+ ```
10646
+
10647
+ The text `atlas ui-catalog --help` prints: every subcommand, its flags, what each does and the exit codes. Also the source of its entry in `docs/reference/cli.md`.
10648
+
10649
+ #### `uiCatalogMain`
10650
+
10651
+ ```ts
10652
+ export async function uiCatalogMain(argv: readonly string[], io: ReportIo = { out: text => process.stdout.write(text) }): Promise<number>
10653
+ ```
10654
+
10655
+ `atlas ui-catalog …`. Returns the exit code. Throws on a mistake in the command (see {@link runUiCatalogCommand}).
10656
+
10657
+ #### `runUiCatalogCommand`
10658
+
10659
+ ```ts
10660
+ export async function runUiCatalogCommand(argv: readonly string[]): Promise<number>
10661
+ ```
10662
+
10663
+ `atlas ui-catalog …` for a terminal: a mistake in the command or a path is a message on stderr and exit code 2, so a CI job can
10664
+ tell a broken invocation (2) from a catalog that needs attention (1). With `--format json` the mistake is a JSON document.
10665
+
10666
+ ### UI catalog comparison
10667
+
10668
+ Source: `packages/reports/src/ui-catalog-diff.ts`. Import: `@crvouga/atlas/reports/ui-catalog-diff`
10669
+
10670
+ What changed in the UI catalog between two commits, or between two profiles, without claiming more than the evidence
10671
+ supports. Pure functions of {@link UiCatalog} documents (and of the layers they came from), safe in a browser; nothing here
10672
+ changes a status, a slot or a winner.
10673
+
10674
+ - **Two commits.** The catalog of a commit is the catalog of the layers recorded at that commit ({@link uiCatalogAtCommit}).
10675
+ A state is paired with itself in the same profile. A layer replaces the older one of its slot, so the effective report no
10676
+ longer holds an earlier commit's captures: compare from the layers themselves ({@link readBackendLayers}), which keep them.
10677
+ - **Two profiles.** Within one catalog, each state's cell in one profile is set beside its cell in another
10678
+ ({@link diffUiProfiles}): desktop against mobile, light against dark.
10679
+ - **Pictures are compared by content.** Two captures have the same picture when their stored bytes have the same SHA-256.
10680
+ A different hash means different bytes, which is not a measure of how different the pixels are. When a side has no
10681
+ content hash (a picture given only by address, or left out), whether it changed is `unverified`, never "unchanged".
10682
+ - **A state only one side has** is `added` or `removed`. It may be new or renamed, or the other side's shard may not have
10683
+ run, and the comparison cannot tell which: it is not called a regression.
10684
+ - **A capture that did not pass is not a picture.** A cell that failed, is being taken or was skipped on a side is a change of
10685
+ `coverage`, never compared by picture.
10686
+
10687
+ See docs/ui-catalog.md
10688
+
10689
+ #### `UI_DIFF_VERSION`
10690
+
10691
+ ```ts
10692
+ export const UI_DIFF_VERSION = …
10693
+ ```
10694
+
10695
+ The `schemaVersion` of the comparison document.
10696
+
10697
+ #### `UiCellChange`
10698
+
10699
+ ```ts
10700
+ export type UiCellChange = 'added' | 'removed' | 'coverage' | 'picture' | 'unverified' | 'unchanged';
10701
+ ```
10702
+
10703
+ How one pair of cells stands. `added`: only the after side has the cell. `removed`: only the before side has it.
10704
+ `coverage`: both have it and it stands differently (captured against failed, for example). `picture`: both captured and
10705
+ the bytes differ. `unverified`: both captured, but a side has no content hash, so whether the picture changed is unknown.
10706
+ `unchanged`: the same coverage and, when captured, the same bytes.
10707
+
10708
+ #### `UI_CELL_CHANGES`
10709
+
10710
+ ```ts
10711
+ export const UI_CELL_CHANGES = …
10712
+ ```
10713
+
10714
+ The values of {@link UiCellChange}, in the order a review reads them.
10715
+
10716
+ #### `UiDiffEntry`
10717
+
10718
+ ```ts
10719
+ export type UiDiffEntry = {
10720
+ /** The state's id. */
10721
+ readonly id: string;
10722
+ /** Its title, from the after side when it has the state. */
10723
+ readonly title: string;
10724
+ /** The profile both sides are in (a comparison of commits), or null when the two sides are different profiles. */
10725
+ readonly profile: UiProfile | null;
10726
+ readonly before: UiCell | null;
10727
+ readonly after: UiCell | null;
10728
+ readonly change: UiCellChange;
10729
+ };
10730
+ ```
10731
+
10732
+ One state in one profile on each side.
10733
+
10734
+ #### `UiCatalogDiff`
10735
+
10736
+ ```ts
10737
+ export type UiCatalogDiff = {
10738
+ readonly schemaVersion: typeof UI_DIFF_VERSION;
10739
+ /** How many entries are in each {@link UiCellChange}. */
10740
+ readonly summary: Readonly<Record<UiCellChange, number>> & { readonly total: number };
10741
+ /** Every pair, ordered by state id and then profile label. */
10742
+ readonly entries: readonly UiDiffEntry[];
10743
+ };
10744
+ ```
10745
+
10746
+ A comparison, as `atlas ui-catalog diff --format json` prints it and the viewer shows it.
10747
+
10748
+ #### `pictureIdentity`
10749
+
10750
+ ```ts
10751
+ export function pictureIdentity(image: UiImage | null | undefined): string | null
10752
+ ```
10753
+
10754
+ What identifies a picture's content: its recorded SHA-256, else the hash in a content-addressed bundle key
10755
+ (`attachments/<sha256>`), else `null`. A picture given only by address, or left out, has no identity here: its content is
10756
+ not Atlas's to vouch for.
10757
+
10758
+ #### `diffUiCatalogs`
10759
+
10760
+ ```ts
10761
+ export function diffUiCatalogs(before: UiCatalog, after: UiCatalog): UiCatalogDiff
10762
+ ```
10763
+
10764
+ Sets the catalog of an earlier commit (`before`) against a later one (`after`): each state in each profile is paired with
10765
+ itself. A cell that is only a plan (`missing`) on one side is a change of coverage like any other. The order of the
10766
+ states in either catalog does not matter.
10767
+
10768
+ #### `diffUiProfiles`
10769
+
10770
+ ```ts
10771
+ export function diffUiProfiles(catalog: UiCatalog, from: string, to: string): UiCatalogDiff
10772
+ ```
10773
+
10774
+ Sets one profile of a catalog (`from`, a {@link UiProfile.key}) against another (`to`): each state's cell in `from` beside its
10775
+ cell in `to`. A state that has a cell in only one of them is `added` or `removed`. Throws when the two are the same profile,
10776
+ because that compares a catalog with itself.
10777
+
10778
+ #### `changedUiEntries`
10779
+
10780
+ ```ts
10781
+ export function changedUiEntries(diff: UiCatalogDiff): readonly UiDiffEntry[]
10782
+ ```
10783
+
10784
+ The entries of a comparison that changed, or all of them. A change the comparison cannot verify is a change worth looking at, so it is kept.
10785
+
10786
+ #### `UiCommit`
10787
+
10788
+ ```ts
10789
+ export type UiCommit = {
10790
+ readonly sha: string;
10791
+ /** The branch of the newest layer recorded at the commit, or null. */
10792
+ readonly branch: string | null;
10793
+ /** When the newest layer at the commit was recorded, epoch milliseconds. */
10794
+ readonly recordedAtMs: number;
10795
+ /** How many layers were recorded at the commit. */
10796
+ readonly layers: number;
10797
+ };
10798
+ ```
10799
+
10800
+ A commit that has layers of the catalog's producers.
10801
+
10802
+ #### `uiCatalogCommits`
10803
+
10804
+ ```ts
10805
+ export function uiCatalogCommits(layers: readonly Pick<LayerEvidence['layer'], 'sha' | 'branch' | 'recordedAtMs' | 'freshness'>[]): UiCommit[]
10806
+ ```
10807
+
10808
+ The commits layers were recorded at, newest first. A layer with no commit is left out: it cannot be compared by commit.
10809
+ Layers recorded from a checkout with uncommitted changes (`freshness` `modified`) are not the commit's own result and are
10810
+ left out too, as the gate does by default for comparisons.
10811
+
10812
+ #### `layersAtCommit`
10813
+
10814
+ ```ts
10815
+ export function layersAtCommit(layers: readonly LayerEvidence[], sha: string): LayerEvidence[]
10816
+ ```
10817
+
10818
+ The layers recorded at one commit (`sha` is the whole commit id, or a prefix of at least 7 hexadecimal digits that names
10819
+ exactly one commit among `layers`). Throws when a prefix names none, or more than one, so a comparison never silently uses
10820
+ the wrong commit.
10821
+
10822
+ #### `uiCatalogAtCommit`
10823
+
10824
+ ```ts
10825
+ export function uiCatalogAtCommit(layers: readonly LayerEvidence[], sha: string, options: UiCatalogOptions = {}): UiCatalog
10826
+ ```
10827
+
10828
+ The UI catalog as it was recorded at one commit: only the layers recorded there take part. A state the commit's layers do
10829
+ not hold is absent, not failed. See {@link layersAtCommit} for how `sha` is matched.
10830
+
10831
+ ### UI catalog single-file export
10832
+
10833
+ Source: `packages/reports/src/ui-catalog-export.ts`. Import: `@crvouga/atlas/reports/ui-catalog-export`
10834
+
10835
+ 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,
10836
+ host it anywhere that serves a file. Pictures are embedded as `data:` addresses, the manifest is embedded as JSON
10837
+ (`<script type="application/json" id="ui-catalog">`), and a few lines of script filter the states; with scripts off, every
10838
+ 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
10839
+ was made, and says when.
10840
+
10841
+ 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
10842
+ budget ({@link gatherUiPictures}): pictures past it are listed as left out with the reason, never silently dropped, and the
10843
+ manifest stays complete.
10844
+
10845
+ 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
10846
+ are embedded (never SVG, never what a layer merely claims); a page address is linked only when it is `http` or `https`; the
10847
+ page carries a Content-Security-Policy that allows no network request at all.
10848
+
10849
+ See docs/ui-catalog.md
10850
+
10851
+ #### `UiExportPicture`
10852
+
10853
+ ```ts
10854
+ export type UiExportPicture = { readonly contentType: string; readonly data: Uint8Array };
10855
+ ```
10856
+
10857
+ A picture ready to embed.
10858
+
10859
+ #### `UiExportOptions`
10860
+
10861
+ ```ts
10862
+ export type UiExportOptions = {
10863
+ /** The page's title. Default `UI catalog`. */
10864
+ readonly title?: string;
10865
+ /** The pictures to embed, by {@link uiPictureKey}. A cell whose key is not here shows why it has no picture. */
10866
+ readonly pictures?: ReadonlyMap<string, UiExportPicture>;
10867
+ /** Why a picture that could not be embedded is not here, by {@link uiPictureKey}: shown in its place. */
10868
+ readonly left?: ReadonlyMap<string, string>;
10869
+ /** When the export was made, epoch milliseconds. It is stated on the page so nobody mistakes it for a live view. Default: now. */
10870
+ readonly generatedAtMs?: number;
10871
+ };
10872
+ ```
10873
+
10874
+ What {@link uiCatalogHtml} may be given besides the catalog.
10875
+
10876
+ #### `uiPictureKey`
10877
+
10878
+ ```ts
10879
+ export function uiPictureKey(capture: Pick<UiCapture, 'origin' | 'image'> | null | undefined): string | null
10880
+ ```
10881
+
10882
+ 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.
10883
+
10884
+ #### `UiPictureWant`
10885
+
10886
+ ```ts
10887
+ export type UiPictureWant = { readonly key: string; readonly layer: string; readonly backend: string; readonly bundleKey: string; readonly bytes?: number };
10888
+ ```
10889
+
10890
+ One picture an export may embed.
10891
+
10892
+ #### `uiPicturesWanted`
10893
+
10894
+ ```ts
10895
+ export function uiPicturesWanted(catalog: UiCatalog): UiPictureWant[]
10896
+ ```
10897
+
10898
+ 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
10899
+ that failed (a picture of what the page showed), and whose picture is stored in a layer, has one.
10900
+
10901
+ #### `sniffImage`
10902
+
10903
+ ```ts
10904
+ export function sniffImage(data: Uint8Array): string | null
10905
+ ```
10906
+
10907
+ 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.
10908
+
10909
+ #### `UiPicturesGathered`
10910
+
10911
+ ```ts
10912
+ export type UiPicturesGathered = {
10913
+ readonly pictures: ReadonlyMap<string, UiExportPicture>;
10914
+ /** Why each picture that is not in `pictures` is not. */
10915
+ readonly left: ReadonlyMap<string, string>;
10916
+ /** Bytes embedded. */
10917
+ readonly bytes: number;
10918
+ };
10919
+ ```
10920
+
10921
+ What {@link gatherUiPictures} found.
10922
+
10923
+ #### `gatherUiPictures`
10924
+
10925
+ ```ts
10926
+ export async function gatherUiPictures(wanted: readonly UiPictureWant[], read: (want: UiPictureWant) => Promise<Uint8Array | null>, options: { readonly maxBytes: number }): Promise<UiPicturesGathered>
10927
+ ```
10928
+
10929
+ Reads the pictures an export wants through `read` (which returns the stored bytes of a picture, or `null` when the store
10930
+ 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
10931
+ WebP, cannot be read, or would pass the budget is recorded in `left` with the reason; the pictures are taken in the page's
10932
+ order, so which are left out is deterministic. A `read` that throws is a picture left out, never a failed export.
10933
+
10934
+ #### `uiCatalogHtml`
10935
+
10936
+ ```ts
10937
+ export function uiCatalogHtml(catalog: UiCatalog, options: UiExportOptions = {}): string
10938
+ ```
10939
+
10940
+ The whole catalog as one self-contained HTML document. See the module description for what it contains and how it treats
10941
+ the data in it. Deterministic for the same catalog, pictures and `generatedAtMs`.
10942
+
10943
+ **Example**
10944
+
10945
+ ```ts
10946
+ const wanted = uiPicturesWanted(catalog);
10947
+ const { pictures, left } = await gatherUiPictures(wanted, want => readPicture(want), { maxBytes: 32 * 1024 * 1024 });
10948
+ await writeFile('catalog.html', uiCatalogHtml(catalog, { pictures, left }));
10949
+ ```
10950
+
10559
10951
  ### Writing a UI catalog from any collector
10560
10952
 
10561
10953
  Source: `packages/reports/src/ui-catalog-publish.ts`. Import: `@crvouga/atlas/reports/ui-catalog-publish`