@crvouga/atlas 1.20.0 → 1.22.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 (114) hide show
  1. package/CHANGELOG.md +15 -1
  2. package/dist/core/src/cli.d.ts +2 -2
  3. package/dist/core/src/cli.js +7 -1
  4. package/dist/critics/src/catalog-client.d.ts +91 -0
  5. package/dist/critics/src/catalog-client.js +194 -0
  6. package/dist/critics/src/cli-calibrate.d.ts +4 -0
  7. package/dist/critics/src/cli-calibrate.js +211 -0
  8. package/dist/critics/src/cli-common.d.ts +108 -0
  9. package/dist/critics/src/cli-common.js +155 -0
  10. package/dist/critics/src/cli-review.d.ts +4 -0
  11. package/dist/critics/src/cli-review.js +174 -0
  12. package/dist/critics/src/cli-watch.d.ts +4 -0
  13. package/dist/critics/src/cli-watch.js +191 -0
  14. package/dist/critics/src/cli.d.ts +13 -0
  15. package/dist/critics/src/cli.js +490 -0
  16. package/dist/critics/src/index.d.ts +3 -0
  17. package/dist/critics/src/index.js +3 -0
  18. package/dist/critics/src/judging.d.ts +8 -0
  19. package/dist/critics/src/llm.d.ts +120 -0
  20. package/dist/critics/src/llm.js +266 -0
  21. package/dist/critics/src/marks.d.ts +74 -0
  22. package/dist/critics/src/marks.js +76 -0
  23. package/dist/critics/src/registry.d.ts +125 -0
  24. package/dist/critics/src/registry.js +203 -0
  25. package/dist/critics/src/spool.d.ts +32 -0
  26. package/dist/critics/src/spool.js +129 -0
  27. package/dist/critics/src/uiclip.d.ts +82 -0
  28. package/dist/critics/src/uiclip.js +167 -0
  29. package/dist/reports/src/compose.d.ts +23 -1
  30. package/dist/reports/src/compose.js +29 -19
  31. package/dist/reports/src/critique-review.d.ts +111 -0
  32. package/dist/reports/src/critique-review.js +123 -0
  33. package/dist/reports/src/critique-row.d.ts +24 -0
  34. package/dist/reports/src/critique-row.js +39 -0
  35. package/dist/reports/src/evidence-types.d.ts +5 -0
  36. package/dist/reports/src/layer-merge.js +5 -0
  37. package/dist/reports/src/report-cli.d.ts +13 -0
  38. package/dist/reports/src/report-cli.js +6 -4
  39. package/dist/reports/src/report-index.d.ts +6 -0
  40. package/dist/reports/src/report-index.js +12 -0
  41. package/dist/reports/src/ui-catalog-cli.d.ts +10 -0
  42. package/dist/reports/src/ui-catalog-cli.js +391 -0
  43. package/dist/reports/src/ui-catalog-diff.d.ts +104 -0
  44. package/dist/reports/src/ui-catalog-diff.js +130 -0
  45. package/dist/reports/src/ui-catalog-entries.d.ts +147 -0
  46. package/dist/reports/src/ui-catalog-entries.js +190 -0
  47. package/dist/reports/src/ui-catalog-export.d.ts +82 -0
  48. package/dist/reports/src/ui-catalog-export.js +221 -0
  49. package/dist/reports/src/ui-catalog-publish.js +2 -2
  50. package/dist/reports/src/ui-catalog.d.ts +2 -0
  51. package/dist/reports/src/ui-catalog.js +1 -1
  52. package/dist/reports/src/ui-state.d.ts +2 -0
  53. package/dist/reports/src/ui-state.js +2 -2
  54. package/dist/schema/src/api.d.ts +653 -1
  55. package/dist/schema/src/api.js +44 -0
  56. package/dist/schema/src/cli-results.d.ts +991 -1
  57. package/dist/schema/src/cli-results.js +265 -1
  58. package/dist/schema/src/critique.d.ts +31 -0
  59. package/dist/schema/src/critique.js +26 -0
  60. package/dist/schema/src/server-config.d.ts +3 -3
  61. package/dist/server/src/app.js +28 -1
  62. package/dist/server/src/openapi.js +19 -2
  63. package/docs/agents.md +16 -2
  64. package/docs/critics.md +273 -9
  65. package/docs/extending.md +1 -1
  66. package/docs/reference/cli.md +126 -1
  67. package/docs/reference/core.md +1 -1
  68. package/docs/reference/critics.md +1131 -29
  69. package/docs/reference/http-api.md +40 -0
  70. package/docs/reference/reports.md +855 -0
  71. package/docs/reference/schema.md +197 -1
  72. package/docs/report-contract.md +2 -1
  73. package/docs/server.md +2 -0
  74. package/docs/ui-catalog.md +110 -13
  75. package/docs/ui.md +69 -2
  76. package/llms-full.txt +636 -26
  77. package/llms.txt +1 -1
  78. package/package.json +9 -1
  79. package/packages/critics/README.md +1 -0
  80. package/packages/schema/schemas/critic-calibrate-result.schema.json +274 -0
  81. package/packages/schema/schemas/critic-doctor-result.schema.json +74 -0
  82. package/packages/schema/schemas/critic-list-result.schema.json +90 -0
  83. package/packages/schema/schemas/critic-review-result.schema.json +397 -0
  84. package/packages/schema/schemas/critic-run-result.schema.json +249 -0
  85. package/packages/schema/schemas/critic-setup-result.schema.json +34 -0
  86. package/packages/schema/schemas/critic-watch-result.schema.json +211 -0
  87. package/packages/schema/schemas/critique-mark.schema.json +81 -0
  88. package/packages/schema/schemas/effective-delta.schema.json +337 -0
  89. package/packages/schema/schemas/effective-evidence.schema.json +337 -0
  90. package/packages/schema/schemas/layer-rows.schema.json +337 -0
  91. package/packages/schema/schemas/ui-catalog-diff-result.schema.json +201 -0
  92. package/packages/schema/schemas/ui-catalog-export-result.schema.json +83 -0
  93. package/packages/schema/schemas/ui-catalog-publish-result.schema.json +116 -0
  94. package/packages/schema/schemas/ui-catalog-status.schema.json +197 -0
  95. package/packages/server/openapi.json +6755 -4738
  96. package/packages/visualizer/src/app/AppShell.tsx +3 -1
  97. package/packages/visualizer/src/app/router.tsx +20 -7
  98. package/packages/visualizer/src/catalog/Picture.tsx +32 -0
  99. package/packages/visualizer/src/catalog/UiCatalogCompare.tsx +289 -0
  100. package/packages/visualizer/src/catalog/UiCatalogPage.tsx +18 -28
  101. package/packages/visualizer/src/catalog/history.ts +61 -0
  102. package/packages/visualizer/src/catalog/search.ts +14 -3
  103. package/packages/visualizer/src/components/Header.tsx +16 -4
  104. package/packages/visualizer/src/critiques/CritiquesPage.tsx +402 -0
  105. package/packages/visualizer/src/critiques/FindingOverlay.tsx +50 -0
  106. package/packages/visualizer/src/critiques/ranking.ts +54 -0
  107. package/packages/visualizer/src/critiques/search.ts +41 -0
  108. package/packages/visualizer/src/evidence/applicability-view.tsx +11 -7
  109. package/packages/visualizer/src/evidence/behaviors-page.tsx +22 -15
  110. package/packages/visualizer/src/evidence/evidence-context.tsx +2 -1
  111. package/packages/visualizer/src/explorer/Explorer.tsx +5 -0
  112. package/packages/visualizer/src/explorer/TestDetail.tsx +4 -1
  113. package/packages/visualizer/src/explorer/parts.tsx +19 -1
  114. package/packages/visualizer/src/explorer/playlist.tsx +11 -7
@@ -24,6 +24,8 @@ function Index() {
24
24
  const kind = kindOfAddress(pathname);
25
25
  return pathname === '/catalog' ? (
26
26
  <UiCatalogFilters />
27
+ ) : pathname === '/critiques' ? (
28
+ <Box component="nav" aria-label="Critiques" />
27
29
  ) : kind ? (
28
30
  <SpecIndex kind={kind} />
29
31
  ) : pathname.startsWith('/specs/') || pathname === '/behaviors' ? (
@@ -39,7 +41,7 @@ function Body() {
39
41
  const { view, spec } = useAtlas();
40
42
  const { pathname } = useLocation();
41
43
  if (pathname === '/' && evidence.ready && !evidence.filesPending && !evidence.files.some(isStatechartFile)) return <Explorer />;
42
- if (!pathname.startsWith('/specs/') && pathname !== '/catalog' && pathname !== '/behaviors' && !view && spec.error)
44
+ if (!pathname.startsWith('/specs/') && pathname !== '/catalog' && pathname !== '/critiques' && pathname !== '/behaviors' && !view && spec.error)
43
45
  return (
44
46
  <Container py="xl">
45
47
  <Alert color="red" title="The spec couldn’t be loaded">
@@ -12,6 +12,8 @@ import {
12
12
  import { z } from 'zod/v4';
13
13
  import { CatalogSearchFields } from '../catalog/search';
14
14
  import { UiCatalogPage } from '../catalog/UiCatalogPage';
15
+ import { CritiquesPage } from '../critiques/CritiquesPage';
16
+ import { CritiqueSearchFields } from '../critiques/search';
15
17
  import { COVERAGE_ORDER } from '../evidence/behavior';
16
18
  import { BehaviorsPage } from '../evidence/behaviors-page';
17
19
  import { SpecPage } from '../evidence/spec-page';
@@ -29,6 +31,13 @@ const ChartSearch = RootSearch.extend({
29
31
  export type ChartSearch = z.infer<typeof ChartSearch>;
30
32
  /** The project home: the test explorer's filters, grouping, order, open test and open run (see `explorer/search.ts`). */
31
33
  const HomeSearch = RootSearch.extend(ExplorerSearchFields);
34
+ /** The release evidence is read against: its build, commit and platform, and the evidence filter (see `evidence/applicability.ts`). A build such as `90` is read from a typed link as a number: coerced back to its text. */
35
+ const ReleaseSearch = {
36
+ release: z.coerce.string().min(1).optional().catch(undefined),
37
+ releaseSha: z.coerce.string().min(1).optional().catch(undefined),
38
+ releasePlatform: z.coerce.string().min(1).optional().catch(undefined),
39
+ evidence: z.enum(['stale']).optional().catch(undefined)
40
+ };
32
41
  /** A spec document, the check opened in it, one layer's record of that check, and a recorded moment. */
33
42
  const SpecSearch = RootSearch.extend({
34
43
  spec: z.string().min(1).optional().catch(undefined),
@@ -39,11 +48,7 @@ const SpecSearch = RootSearch.extend({
39
48
  clipAt: z.coerce.number().nonnegative().optional().catch(undefined),
40
49
  view: z.enum(['recording']).optional().catch(undefined),
41
50
  moment: z.coerce.number().int().positive().optional().catch(undefined),
42
- /** The release evidence is read against: its build, commit and platform, and the evidence filter (see `evidence/applicability.ts`). A build such as `90` is read from a typed link as a number: coerced back to its text. */
43
- release: z.coerce.string().min(1).optional().catch(undefined),
44
- releaseSha: z.coerce.string().min(1).optional().catch(undefined),
45
- releasePlatform: z.coerce.string().min(1).optional().catch(undefined),
46
- evidence: z.enum(['stale']).optional().catch(undefined)
51
+ ...ReleaseSearch
47
52
  });
48
53
  export type SpecSearch = z.infer<typeof SpecSearch>;
49
54
  /**
@@ -58,7 +63,8 @@ const BehaviorsSearch = RootSearch.extend({
58
63
  workspace: z.preprocess((value) => (value === 'true' ? true : value), z.literal(true)).optional().catch(undefined),
59
64
  layer: z.string().min(1).optional().catch(undefined),
60
65
  clip: z.coerce.string().min(1).optional().catch(undefined),
61
- clipAt: z.coerce.number().nonnegative().optional().catch(undefined)
66
+ clipAt: z.coerce.number().nonnegative().optional().catch(undefined),
67
+ ...ReleaseSearch
62
68
  });
63
69
  export type BehaviorsSearch = z.infer<typeof BehaviorsSearch>;
64
70
  /** A fresh route tree per router, so a test can run its own viewer beside the page's. */
@@ -120,7 +126,14 @@ function routeTree() {
120
126
  validateSearch: (search) => BehaviorsSearch.parse(search),
121
127
  component: BehaviorsPage
122
128
  });
123
- return rootRoute.addChildren([productRoute, chartRoute, specRoute, catalogRoute, behaviorsRoute]);
129
+ /** Needs polish: the opinions of automated reviewers about the captured states, ranked (see `critiques/`). */
130
+ const critiquesRoute = createRoute({
131
+ getParentRoute: () => rootRoute,
132
+ path: '/critiques',
133
+ validateSearch: (search) => RootSearch.extend(CritiqueSearchFields).parse(search),
134
+ component: CritiquesPage
135
+ });
136
+ return rootRoute.addChildren([productRoute, chartRoute, specRoute, catalogRoute, behaviorsRoute, critiquesRoute]);
124
137
  }
125
138
  export function createAtlasRouter(history: RouterHistory) {
126
139
  return createRouter({ routeTree: routeTree(), history, defaultPreload: false, scrollRestoration: true });
@@ -0,0 +1,32 @@
1
+ /**
2
+ * @module UI catalog picture
3
+ *
4
+ * The picture of one captured cell, shared by the catalog's cards, the open state and the comparison. A picture is shown only
5
+ * for a cell that was captured, or that failed (a picture of what the page showed, for diagnosis); every other cell says in
6
+ * words what it is instead of showing an empty frame. Pictures are `loading="lazy"`, so only what is on screen is fetched.
7
+ */
8
+ import type { UiCell, UiImage } from '@crvouga/atlas/reports/ui-catalog';
9
+ import { Image, Text } from '@mantine/core';
10
+
11
+ import { reportAssetUrl } from '../evidence/api';
12
+ import { safeUri } from '../evidence/format';
13
+ import { coverageWord } from './words';
14
+
15
+ /** Where a browser loads a picture from: its stored file in the layer, else its safe web address. */
16
+ export function imageSource(image: UiImage | null | undefined): string | null {
17
+ if (!image) return null;
18
+ return image.path ? reportAssetUrl(image.path) : safeUri(image.uri);
19
+ }
20
+
21
+ /** The picture of a cell, or the words that say why there is none. `label` names the side of a comparison it belongs to. */
22
+ export function Picture({ cell, title, height, label }: { cell: UiCell; title: string; height?: number; label?: string }) {
23
+ const image = cell.capture?.image;
24
+ const source = cell.coverage === 'captured' || cell.coverage === 'failed' ? imageSource(image) : null;
25
+ const name = `${title}, ${label ?? cell.profile.label}`;
26
+ if (source) return <Image src={source} alt={name} loading="lazy" fit="contain" {...(height ? { h: height } : {})} {...(image?.width ? { w: 'auto', maw: '100%' } : {})} />;
27
+ return (
28
+ <Text c="dimmed" fz="sm" role="img" aria-label={`${name}: no picture`}>
29
+ {cell.coverage === 'captured' ? (image?.omitted ? `Picture left out (${image.omitted})` : 'Captured without a picture') : coverageWord(cell.coverage)}
30
+ </Text>
31
+ );
32
+ }
@@ -0,0 +1,289 @@
1
+ /**
2
+ * @module UI catalog comparison
3
+ *
4
+ * The catalog set against itself: two commits (what the same states looked like when each commit was recorded) or two profiles
5
+ * (desktop against mobile, light against dark). Both sides are in the address (`compare`, `from`, `to`), so a link reproduces the
6
+ * comparison. Pictures are compared by their stored bytes, and the page says so: "changed" is not a measure of pixels. A state only
7
+ * one side has is not called a regression (the other side may not have captured it), a capture that did not pass is a change of
8
+ * coverage and never compared by picture, a layer that could not be read is named and never taken to be unchanged, and a picture
9
+ * with no content hash is "cannot tell".
10
+ */
11
+ import { filterUiCatalog, type UiCatalog, type UiCatalogQuery } from '@crvouga/atlas/reports/ui-catalog';
12
+ import { UI_CELL_CHANGES, changedUiEntries, diffUiCatalogs, diffUiProfiles, uiCatalogCommits, type UiCatalogDiff, type UiCellChange, type UiDiffEntry } from '@crvouga/atlas/reports/ui-catalog-diff';
13
+ import { Alert, Anchor, Badge, Button, Card, Code, Group, NativeSelect, SegmentedControl, SimpleGrid, Stack, Switch, Text, Title } from '@mantine/core';
14
+ import { Link } from '@tanstack/react-router';
15
+ import { useMemo, useState } from 'react';
16
+
17
+ import { useEvidence } from '../evidence/evidence-context';
18
+ import { ago, plural, shortSha, useNow } from '../evidence/format';
19
+ import { Picture } from './Picture';
20
+ import { useCommitCatalog, useLayerHeaders } from './history';
21
+ import { useCatalogNavigate, useCatalogSearch } from './search';
22
+ import { coverageWord } from './words';
23
+
24
+ const ENTRIES = 24;
25
+
26
+ const CHANGE: Readonly<Record<UiCellChange, { label: string; color: string; meaning: string }>> = {
27
+ coverage: { label: 'Coverage changed', color: 'orange', meaning: 'It stands differently on the two sides, such as captured on one and failed on the other.' },
28
+ picture: { label: 'Picture changed', color: 'blue', meaning: 'Captured on both sides, and the stored picture is not the same file. This says the bytes differ, not how many pixels.' },
29
+ unverified: { label: 'Cannot tell', color: 'gray', meaning: 'Captured on both sides, but a picture has no content hash (it is given only by address), so whether it changed is unknown.' },
30
+ added: { label: 'Only after', color: 'green', meaning: 'Only the after side has it. It may be new, or the before side may not have captured it.' },
31
+ removed: { label: 'Only before', color: 'gray', meaning: 'Only the before side has it. It is not a regression: the after side may not have captured it, or it may have been renamed.' },
32
+ unchanged: { label: 'Unchanged', color: 'gray', meaning: 'The same coverage and, when captured, the same stored picture.' }
33
+ };
34
+
35
+ /**
36
+ * The query a comparison narrows by: the catalog's filters except coverage, because the comparison is about coverage changing, and
37
+ * (when profiles are what is compared) except the profile, because the two sides are the profiles.
38
+ */
39
+ function narrowedBy(query: UiCatalogQuery, mode: 'commit' | 'profile'): UiCatalogQuery {
40
+ const { coverage: _coverage, profile: _profile, ...rest } = query;
41
+ return mode === 'profile' ? rest : { ...rest, ...(query.profile ? { profile: query.profile } : {}) };
42
+ }
43
+
44
+ function Entry({ entry, before, after }: { entry: UiDiffEntry; before: string; after: string }) {
45
+ const change = CHANGE[entry.change];
46
+ return (
47
+ <Card withBorder role="listitem" data-state={entry.id} data-change={entry.change}>
48
+ <Stack gap="sm">
49
+ <Group justify="space-between" align="flex-start" wrap="nowrap">
50
+ <Stack gap={2}>
51
+ <Anchor fw={700} aria-label={`Open ${entry.title}`} renderRoot={(props) => <Link {...props} to="/catalog" search={((prev: Record<string, unknown>) => ({ ...prev, state: entry.id })) as never} resetScroll={false} />}>
52
+ {entry.title}
53
+ </Anchor>
54
+ <Text c="dimmed" fz="xs">
55
+ {entry.id}
56
+ {entry.profile ? ` · ${entry.profile.label}` : ''}
57
+ </Text>
58
+ </Stack>
59
+ <Badge variant="light" color={change.color} title={change.meaning}>
60
+ {entry.change === 'coverage' && entry.before && entry.after ? `${coverageWord(entry.before.coverage)} → ${coverageWord(entry.after.coverage)}` : change.label}
61
+ </Badge>
62
+ </Group>
63
+ <SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
64
+ {([['Before', before, entry.before], ['After', after, entry.after]] as const).map(([side, name, cell]) => (
65
+ <Stack key={side} gap="xs" component="section" aria-label={`${side}: ${name}`}>
66
+ <Text fw={600} fz="sm">
67
+ {side}: {name}
68
+ </Text>
69
+ {cell ? (
70
+ <>
71
+ <Picture cell={cell} title={entry.title} label={`${side.toLowerCase()}, ${name}`} height={180} />
72
+ <Text c="dimmed" fz="xs">
73
+ {coverageWord(cell.coverage)}
74
+ {cell.capture ? ` · layer ${cell.capture.origin.layer.slice(0, 12)} in ${cell.capture.origin.backend}` : ''}
75
+ </Text>
76
+ </>
77
+ ) : (
78
+ <Text c="dimmed" fz="sm">
79
+ Not on this side
80
+ </Text>
81
+ )}
82
+ </Stack>
83
+ ))}
84
+ </SimpleGrid>
85
+ </Stack>
86
+ </Card>
87
+ );
88
+ }
89
+
90
+ function Results({ diff, before, after }: { diff: UiCatalogDiff; before: string; after: string }) {
91
+ const { search } = useCatalogSearch();
92
+ const go = useCatalogNavigate();
93
+ const [shown, setShown] = useState(ENTRIES);
94
+ const all = search.all === '1';
95
+ const entries = all ? diff.entries : changedUiEntries(diff);
96
+ return (
97
+ <Stack gap="md">
98
+ <Group gap="xs" role="status" aria-label="Comparison summary">
99
+ {UI_CELL_CHANGES.map((name) =>
100
+ diff.summary[name] > 0 ? (
101
+ <Badge key={name} variant="light" color={CHANGE[name].color} title={CHANGE[name].meaning}>
102
+ {diff.summary[name]} {CHANGE[name].label.toLowerCase()}
103
+ </Badge>
104
+ ) : null
105
+ )}
106
+ {diff.summary.total === 0 && <Text c="dimmed">There is nothing to compare.</Text>}
107
+ </Group>
108
+ <Switch label="Show unchanged" checked={all} onChange={(event) => go({ all: event.currentTarget.checked ? '1' : undefined })} />
109
+ {diff.summary.total > 0 && entries.length === 0 && (
110
+ <Text role="status" data-no-change>
111
+ Nothing differs between the two sides.
112
+ </Text>
113
+ )}
114
+ {entries.length > 0 && (
115
+ <SimpleGrid role="list" aria-label="Differences" cols={1} spacing="md">
116
+ {entries.slice(0, shown).map((entry) => (
117
+ <Entry key={`${entry.id}|${entry.profile?.key ?? ''}|${entry.before?.profile.key ?? ''}`} entry={entry} before={before} after={after} />
118
+ ))}
119
+ </SimpleGrid>
120
+ )}
121
+ {entries.length > shown && (
122
+ <Button type="button" variant="subtle" onClick={() => setShown(shown + ENTRIES)}>
123
+ Show more ({entries.length - shown} more)
124
+ </Button>
125
+ )}
126
+ </Stack>
127
+ );
128
+ }
129
+
130
+ function CommitComparison({ whole, query }: { whole: UiCatalog; query: UiCatalogQuery }) {
131
+ const { search } = useCatalogSearch();
132
+ const go = useCatalogNavigate();
133
+ const now = useNow();
134
+ const evidence = useEvidence();
135
+ const history = useLayerHeaders(true);
136
+ const incomplete = evidence.notices.length > 0 || evidence.backends.some((backend) => !backend.available);
137
+ const producers = useMemo(() => new Set(whole.states.flatMap((state) => state.producers)), [whole]);
138
+ const headers = useMemo(() => history.headers.filter((layer) => producers.has(layer.producer)), [history.headers, producers]);
139
+ const commits = useMemo(() => uiCatalogCommits(headers), [headers]);
140
+ const from = useCommitCatalog(headers, search.from);
141
+ const to = useCommitCatalog(headers, search.to);
142
+ const diff = useMemo(() => (from.catalog && to.catalog ? diffUiCatalogs(filterUiCatalog(from.catalog, query), filterUiCatalog(to.catalog, query)) : null), [from.catalog, to.catalog, query]);
143
+ const unreadable = [...from.failed, ...to.failed];
144
+ const pending = from.pending + to.pending;
145
+ const options = (value: string | undefined) => (
146
+ <>
147
+ <option value="">Choose a commit</option>
148
+ {value && !commits.some((commit) => commit.sha === value) && <option value={value}>{shortSha(value)} (no layers of this catalog&rsquo;s producers)</option>}
149
+ {commits.map((commit) => (
150
+ <option key={commit.sha} value={commit.sha}>
151
+ {[shortSha(commit.sha), commit.branch, plural(commit.layers, 'layer', 'layers'), ago(commit.recordedAtMs, now)].filter(Boolean).join(' · ')}
152
+ </option>
153
+ ))}
154
+ </>
155
+ );
156
+ return (
157
+ <Stack gap="md">
158
+ <Text c="dimmed">
159
+ The states each commit recorded, set side by side. A layer recorded from a checkout with uncommitted changes is not a commit&rsquo;s own result and is left out.
160
+ </Text>
161
+ {history.error && (
162
+ <Alert role="alert" color="red" title="The recorded layers cannot be read">
163
+ <Group gap="sm">
164
+ <Text span inherit>{history.error}</Text>
165
+ <Button type="button" variant="subtle" onClick={history.retry}>Try again</Button>
166
+ </Group>
167
+ </Alert>
168
+ )}
169
+ <SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
170
+ <NativeSelect label="Before" value={search.from ?? ''} onChange={(event) => go({ from: event.currentTarget.value || undefined })} data-compare-picker="before">
171
+ {options(search.from)}
172
+ </NativeSelect>
173
+ <NativeSelect label="After" value={search.to ?? ''} onChange={(event) => go({ to: event.currentTarget.value || undefined })} data-compare-picker="after">
174
+ {options(search.to)}
175
+ </NativeSelect>
176
+ </SimpleGrid>
177
+ {!search.from && !search.to && commits.length >= 2 && (
178
+ <Group>
179
+ <Button type="button" variant="light" onClick={() => go({ from: commits[1]!.sha, to: commits[0]!.sha })}>
180
+ Compare the two newest commits
181
+ </Button>
182
+ </Group>
183
+ )}
184
+ {!history.loading && commits.length < 2 && (
185
+ <Text c="dimmed" role="status" data-no-commits>
186
+ {commits.length === 0 ? 'No layer of this catalog records the commit it was captured at, so there is nothing to compare by commit.' : 'Only one commit has recorded layers, so there is nothing to compare it with.'}
187
+ </Text>
188
+ )}
189
+ {search.from && search.from === search.to && <Alert color="gray" role="status">The same commit is on both sides.</Alert>}
190
+ {unreadable.length > 0 && (
191
+ <Alert role="status" color="orange" title="Some layers could not be read">
192
+ {plural(unreadable.length, 'layer', 'layers')} at these commits could not be read ({unreadable.map((layer) => layer.id.slice(0, 12)).join(', ')}). What they hold is unknown here, not unchanged, so this comparison may be incomplete.
193
+ </Alert>
194
+ )}
195
+ {incomplete && (
196
+ <Alert role="status" color="orange" title="This comparison may be incomplete">
197
+ Some results or backends could not be read (named above). A state missing from a side may be one that could not be read, so it is unknown there, not absent.
198
+ </Alert>
199
+ )}
200
+ {pending > 0 && (
201
+ <Text role="status" c="dimmed">
202
+ Reading {plural(pending, 'more layer', 'more layers')}; the comparison fills in as they arrive.
203
+ </Text>
204
+ )}
205
+ {diff && search.from !== search.to && <Results diff={diff} before={`commit ${shortSha(search.from ?? null)}`} after={`commit ${shortSha(search.to ?? null)}`} />}
206
+ {search.from && search.to && !diff && !history.loading && pending === 0 && <Text c="dimmed">There is nothing recorded at those commits.</Text>}
207
+ </Stack>
208
+ );
209
+ }
210
+
211
+ function ProfileComparison({ whole, query }: { whole: UiCatalog; query: UiCatalogQuery }) {
212
+ const { search } = useCatalogSearch();
213
+ const go = useCatalogNavigate();
214
+ const known = (key: string | undefined) => whole.profiles.some((profile) => profile.key === key);
215
+ const diff = useMemo(
216
+ () => (search.from && search.to && search.from !== search.to && whole.profiles.some((p) => p.key === search.from) && whole.profiles.some((p) => p.key === search.to) ? diffUiProfiles(filterUiCatalog(whole, query), search.from, search.to) : null),
217
+ [whole, query, search.from, search.to]
218
+ );
219
+ const label = (key: string | undefined) => whole.profiles.find((profile) => profile.key === key)?.label ?? 'a profile';
220
+ const options = (value: string | undefined) => (
221
+ <>
222
+ <option value="">Choose a profile</option>
223
+ {value && !known(value) && <option value={value}>Not a profile of this catalog</option>}
224
+ {whole.profiles.map((profile) => (
225
+ <option key={profile.key} value={profile.key}>
226
+ {profile.label}
227
+ </option>
228
+ ))}
229
+ </>
230
+ );
231
+ return (
232
+ <Stack gap="md">
233
+ <Text c="dimmed">Each state in one profile set beside the same state in another: desktop against mobile, light against dark.</Text>
234
+ <SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
235
+ <NativeSelect label="Before" value={search.from ?? ''} onChange={(event) => go({ from: event.currentTarget.value || undefined })} data-compare-picker="before">
236
+ {options(search.from)}
237
+ </NativeSelect>
238
+ <NativeSelect label="After" value={search.to ?? ''} onChange={(event) => go({ to: event.currentTarget.value || undefined })} data-compare-picker="after">
239
+ {options(search.to)}
240
+ </NativeSelect>
241
+ </SimpleGrid>
242
+ {whole.profiles.length < 2 && (
243
+ <Text c="dimmed" role="status" data-no-profiles>
244
+ This catalog has {whole.profiles.length === 1 ? 'one profile' : 'no profiles'}, so there is nothing to compare it with.
245
+ </Text>
246
+ )}
247
+ {search.from && search.from === search.to && <Alert color="gray" role="status">The same profile is on both sides.</Alert>}
248
+ {diff && <Results diff={diff} before={label(search.from)} after={label(search.to)} />}
249
+ </Stack>
250
+ );
251
+ }
252
+
253
+ /** The comparison of the catalog with itself: its mode (commits or profiles) and both sides are the address. */
254
+ export function UiCatalogCompare({ whole }: { whole: UiCatalog }) {
255
+ const { search, query } = useCatalogSearch();
256
+ const go = useCatalogNavigate();
257
+ const mode = search.compare === 'profile' ? 'profile' : 'commit';
258
+ const narrowed = useMemo(() => narrowedBy(query, mode), [query, mode]);
259
+ return (
260
+ <Stack gap="md" component="section" aria-label="Compare">
261
+ <Group justify="space-between" align="flex-end">
262
+ <Title order={2}>Compare</Title>
263
+ <Group gap="sm">
264
+ <SegmentedControl
265
+ aria-label="What to compare"
266
+ value={mode}
267
+ onChange={(value) => go({ compare: value, from: undefined, to: undefined })}
268
+ data={[
269
+ { value: 'commit', label: 'Two commits' },
270
+ { value: 'profile', label: 'Two profiles' }
271
+ ]}
272
+ />
273
+ <Button type="button" variant="subtle" onClick={() => go({ compare: undefined, from: undefined, to: undefined, all: undefined })}>
274
+ Back to the catalog
275
+ </Button>
276
+ </Group>
277
+ </Group>
278
+ {query.coverage?.length || (mode === 'profile' && query.profile?.length) ? (
279
+ <Text c="dimmed" fz="sm">
280
+ {mode === 'profile' ? 'The coverage and profile filters do not apply here: coverage is what is compared, and the profiles are chosen below.' : 'The coverage filter does not apply to a comparison, which is about coverage changing.'} The other filters do.
281
+ </Text>
282
+ ) : null}
283
+ {mode === 'commit' ? <CommitComparison whole={whole} query={narrowed} /> : <ProfileComparison whole={whole} query={narrowed} />}
284
+ <Text c="dimmed" fz="xs">
285
+ Pictures are compared by their stored bytes: <Code>Picture changed</Code> means the file is not the same, not how many pixels differ.
286
+ </Text>
287
+ </Stack>
288
+ );
289
+ }
@@ -7,18 +7,19 @@
7
7
  * capture exists; a backend or layer that could not be read is named, and nothing that depended on it is shown as captured.
8
8
  * Pictures are loaded lazily, only for the cards on screen.
9
9
  */
10
- import { IconDownload } from '@tabler/icons-react';
11
- import type { UiCapture, UiCatalog, UiCatalogState, UiCell, UiImage } from '@crvouga/atlas/reports/ui-catalog';
10
+ import { IconArrowsExchange, IconDownload } from '@tabler/icons-react';
11
+ import type { UiCapture, UiCatalog, UiCatalogState, UiCell } from '@crvouga/atlas/reports/ui-catalog';
12
12
  import { UI_COVERAGE, filterUiCatalog, isUiCatalogFiltered } from '@crvouga/atlas/reports/ui-catalog';
13
- import { Accordion, Alert, Anchor, Badge, Button, Card, Code, Container, Drawer, Group, Image, SimpleGrid, Stack, Table, Text, Title } from '@mantine/core';
13
+ import { Accordion, Alert, Anchor, Badge, Button, Card, Code, Container, Drawer, Group, SimpleGrid, Stack, Table, Text, Title } from '@mantine/core';
14
14
  import { Link } from '@tanstack/react-router';
15
15
  import { useMemo, useState } from 'react';
16
16
 
17
- import { reportAssetUrl } from '../evidence/api';
18
17
  import { useEvidence } from '../evidence/evidence-context';
19
- import { ago, exactTime, plural, safeUri, shortSha, useNow } from '../evidence/format';
18
+ import { ago, exactTime, plural, shortSha, useNow } from '../evidence/format';
20
19
  import { EvidenceIncomplete, EvidenceWaiting } from '../evidence/load-state';
21
20
  import { StatusMark } from '../evidence/marks';
21
+ import { Picture, imageSource } from './Picture';
22
+ import { UiCatalogCompare } from './UiCatalogCompare';
22
23
  import { useUiCatalog } from './provider';
23
24
  import { useCatalogNavigate, useCatalogSearch } from './search';
24
25
  import { coverageStatus, coverageWord } from './words';
@@ -26,25 +27,6 @@ import { coverageStatus, coverageWord } from './words';
26
27
  /** How many state cards are drawn before "Show more". */
27
28
  const CARDS = 48;
28
29
 
29
- /** Where a browser loads a picture from: its stored file in the layer, else its safe web address. */
30
- export function imageSource(image: UiImage | null | undefined): string | null {
31
- if (!image) return null;
32
- return image.path ? reportAssetUrl(image.path) : safeUri(image.uri);
33
- }
34
-
35
- function Picture({ cell, title, height }: { cell: UiCell; title: string; height?: number }) {
36
- const image = cell.capture?.image;
37
- // A failed capture may carry a picture of what the page showed, for diagnosis; nothing else has one worth showing.
38
- const source = cell.coverage === 'captured' || cell.coverage === 'failed' ? imageSource(image) : null;
39
- if (source)
40
- return <Image src={source} alt={`${title}, ${cell.profile.label}`} loading="lazy" fit="contain" {...(height ? { h: height } : {})} {...(image?.width ? { w: 'auto', maw: '100%' } : {})} />;
41
- return (
42
- <Text c="dimmed" fz="sm" role="img" aria-label={`${title}, ${cell.profile.label}: no picture`}>
43
- {cell.coverage === 'captured' ? (image?.omitted ? `Picture left out (${image.omitted})` : 'Captured without a picture') : coverageWord(cell.coverage)}
44
- </Text>
45
- );
46
- }
47
-
48
30
  function CoverageBadge({ cell }: { cell: UiCell }) {
49
31
  return (
50
32
  <Badge variant="light" color={cell.coverage === 'captured' ? 'green' : cell.coverage === 'failed' ? 'red' : cell.coverage === 'capturing' ? 'blue' : 'gray'} leftSection={<StatusMark status={coverageStatus(cell.coverage)} size={12} />}>
@@ -266,9 +248,16 @@ export function UiCatalogPage() {
266
248
  <Text c="dimmed">Each state of the product&rsquo;s interface, captured once per profile, with where every picture came from.</Text>
267
249
  </Stack>
268
250
  {catalog && catalog.states.length > 0 && (
269
- <Button type="button" leftSection={<IconDownload size={16} aria-hidden="true" />} onClick={() => download(catalog)}>
270
- Download the manifest
271
- </Button>
251
+ <Group gap="sm">
252
+ {!search.compare && (
253
+ <Button type="button" variant="default" leftSection={<IconArrowsExchange size={16} aria-hidden="true" />} onClick={() => go({ compare: 'commit' })}>
254
+ Compare
255
+ </Button>
256
+ )}
257
+ <Button type="button" leftSection={<IconDownload size={16} aria-hidden="true" />} onClick={() => download(catalog)}>
258
+ Download the manifest
259
+ </Button>
260
+ </Group>
272
261
  )}
273
262
  </Group>
274
263
  {evidence.unavailable && (
@@ -296,7 +285,8 @@ export function UiCatalogPage() {
296
285
  Reading {plural(evidence.pending, 'more layer', 'more layers')}; states appear here as they arrive.
297
286
  </Text>
298
287
  )}
299
- {catalog && whole && (
288
+ {catalog && whole && search.compare && <UiCatalogCompare whole={whole} />}
289
+ {catalog && whole && !search.compare && (
300
290
  <>
301
291
  <Summary catalog={catalog} narrowed={narrowed} />
302
292
  <Surfaces catalog={whole} />
@@ -0,0 +1,61 @@
1
+ /**
2
+ * @module UI catalog history
3
+ *
4
+ * What a comparison of two commits needs and the report the viewer holds does not: the layers of an earlier commit. A newer layer
5
+ * replaces the older one of its slot, so the effective report keeps only the newest capture of every state. The layer list
6
+ * (`/evidence`) and a layer's rows (`/evidence/rows/<backend>/<id>`) still hold the rest, and are read here only while a
7
+ * comparison is open: the list once, and the rows of the layers of the commits being compared. The rows share their cache with the
8
+ * rest of the viewer, so a layer is never read twice.
9
+ */
10
+ import type { EvidencePage, LayerEvidence, LayerHeader, LayerRows } from '@crvouga/atlas/reports/evidence-types';
11
+ import { distinctHeaders } from '@crvouga/atlas/reports/layer-merge';
12
+ import { uiCatalog, type UiCatalog } from '@crvouga/atlas/reports/ui-catalog';
13
+ import { useInfiniteQuery, useQueries } from '@tanstack/react-query';
14
+ import { useEffect, useMemo } from 'react';
15
+
16
+ import { reportJson } from '../evidence/api';
17
+ import { rowsKey } from '../evidence/evidence-context';
18
+
19
+ /** Every layer header the stores hold (superseded layers included), read page by page while `enabled`. */
20
+ export function useLayerHeaders(enabled: boolean): { readonly headers: readonly LayerHeader[]; readonly loading: boolean; readonly error: string | null; readonly retry: () => void } {
21
+ const index = useInfiniteQuery({
22
+ queryKey: ['catalog-layers'],
23
+ enabled,
24
+ initialPageParam: null as string | null,
25
+ queryFn: ({ pageParam, signal }) => reportJson<EvidencePage>(`/evidence${pageParam ? `?cursor=${encodeURIComponent(pageParam)}` : ''}`, signal),
26
+ getNextPageParam: (page) => page.cursor,
27
+ staleTime: 30_000,
28
+ retry: false
29
+ });
30
+ const { hasNextPage, isFetching, isError, fetchNextPage } = index;
31
+ useEffect(() => {
32
+ if (enabled && hasNextPage && !isFetching && !isError) void fetchNextPage();
33
+ }, [enabled, hasNextPage, isFetching, isError, fetchNextPage]);
34
+ const pages = index.data?.pages;
35
+ const headers = useMemo(() => distinctHeaders(pages?.flatMap((page) => page.layers) ?? []), [pages]);
36
+ return { headers, loading: enabled && (index.isPending || hasNextPage), error: index.error ? index.error.message : null, retry: () => void index.refetch() };
37
+ }
38
+
39
+ /** The catalog recorded at one commit, from the rows of that commit's layers. `pending` layers are still being read; `failed` ones could not be read and are absent from the catalog. */
40
+ export function useCommitCatalog(headers: readonly LayerHeader[], sha: string | undefined): { readonly catalog: UiCatalog | null; readonly pending: number; readonly failed: readonly LayerHeader[] } {
41
+ const wanted = useMemo(() => (sha ? headers.filter((layer) => layer.sha === sha && layer.freshness !== 'modified') : []), [headers, sha]);
42
+ const rows = useQueries({
43
+ queries: wanted.map((layer) => ({
44
+ queryKey: rowsKey(layer),
45
+ queryFn: ({ signal }: { signal: AbortSignal }) => reportJson<LayerRows>(`/evidence/rows/${encodeURIComponent(layer.backend)}/${encodeURIComponent(layer.id)}`, signal),
46
+ staleTime: Number.POSITIVE_INFINITY,
47
+ retry: 1
48
+ })),
49
+ combine: (results) => ({ data: results.map((result) => result.data), failed: results.map((result) => result.isError && result.data === undefined), stamp: results.map((result) => result.dataUpdatedAt).join(',') })
50
+ });
51
+ return useMemo(() => {
52
+ const loaded: LayerEvidence[] = [];
53
+ wanted.forEach((layer, i) => {
54
+ const read = rows.data[i];
55
+ if (read) loaded.push({ ...read, layer });
56
+ });
57
+ const failed = wanted.filter((_, i) => rows.failed[i]);
58
+ return { catalog: sha ? uiCatalog(loaded) : null, pending: Math.max(0, wanted.length - loaded.length - failed.length), failed };
59
+ // `stamp` changes exactly when a layer's rows do.
60
+ }, [wanted, rows.stamp, sha]);
61
+ }
@@ -2,7 +2,7 @@
2
2
  * @module UI catalog address
3
3
  *
4
4
  * Everything the UI catalog shows is in the address of the `/catalog` route, so a link reproduces the view: the words searched,
5
- * the filters and the open state. Filters take comma-separated values (a comma or backslash inside a value is written `\,` or
5
+ * the filters, the open state and an open comparison (`compare`, `from`, `to`). Filters take comma-separated values (a comma or backslash inside a value is written `\,` or
6
6
  * `\\`), the same form `GET /api/reports/ui-catalog` takes. Anything unreadable is dropped, never an error.
7
7
  */
8
8
  import { parseUiCatalogQuery, uiCatalogParams, type UiCatalogQuery } from '@crvouga/atlas/reports/ui-catalog';
@@ -28,16 +28,27 @@ export const CatalogSearchFields = {
28
28
  surface: text,
29
29
  producer: text,
30
30
  /** The open state: a state id. */
31
- state: text
31
+ state: text,
32
+ /** A comparison is open: `commit` (two commits of the same profiles) or `profile` (two profiles of this report). */
33
+ compare: text,
34
+ /** The comparison's before side: a commit id, or a profile key. */
35
+ from: text,
36
+ /** The comparison's after side. */
37
+ to: text,
38
+ /** `1` shows the pairs that did not change too. */
39
+ all: text
32
40
  };
33
41
 
42
+ /** The parts of the address that are not filters of the catalog's states. */
43
+ const NOT_FILTERS = ['state', 'compare', 'from', 'to', 'all'];
44
+
34
45
  /** The address's parts, as the router holds them. */
35
46
  export type CatalogSearch = { readonly [K in keyof typeof CatalogSearchFields]?: string | undefined };
36
47
 
37
48
  /** The query an address asks for. */
38
49
  export function catalogQueryOf(search: CatalogSearch): UiCatalogQuery {
39
50
  const params = new URLSearchParams();
40
- for (const [name, value] of Object.entries(search)) if (name !== 'state' && typeof value === 'string') params.set(name, value);
51
+ for (const [name, value] of Object.entries(search)) if (!NOT_FILTERS.includes(name) && typeof value === 'string') params.set(name, value);
41
52
  return parseUiCatalogQuery(params);
42
53
  }
43
54
 
@@ -1,5 +1,5 @@
1
1
  import { SpecLogo } from './SpecLogo';
2
- import { IconDots, IconLayoutGrid, IconListSearch } from '@tabler/icons-react';
2
+ import { IconDots, IconLayoutGrid, IconListSearch, IconSparkles } from '@tabler/icons-react';
3
3
  import { isUiKind } from '@crvouga/atlas/reports/ui-state';
4
4
  import { isStatechartFile } from '@crvouga/atlas/schema';
5
5
  import { ActionIcon, Anchor, Badge, Box, Button, Group, Menu, NativeSelect, Popover, Stack, Tooltip } from '@mantine/core';
@@ -139,10 +139,13 @@ export function Header() {
139
139
  );
140
140
  const project = currentProject();
141
141
  const catalogOpen = pathname === '/catalog';
142
+ const critiquesOpen = pathname === '/critiques';
142
143
  const behaviorsOpen = pathname === '/behaviors';
143
144
  const hasFeatures = evidence.files.some(SPEC_KINDS[0]!.accepts);
144
145
  // The catalog is offered once the report holds a state or a plan for one.
145
146
  const hasCatalog = useMemo(() => evidence.evidence.some((layer) => layer.rows.some((row) => isUiKind(row.kind))), [evidence.evidence]);
147
+ // Needs polish is offered once a critic has recorded an opinion.
148
+ const hasCritiques = useMemo(() => evidence.evidence.some((layer) => layer.rows.some((row) => row.critique !== undefined)), [evidence.evidence]);
146
149
  return (
147
150
  <Group flex={1} miw={0} justify="space-between" wrap="nowrap" gap="xs">
148
151
  <Group gap="xs" wrap="nowrap" miw={0}>
@@ -157,13 +160,13 @@ export function Header() {
157
160
  leftSection={kind ? <SpecLogo kind={kind} /> : undefined}
158
161
  rightSection={<ChevronDownIcon aria-hidden="true" size={12} />}
159
162
  >
160
- {catalogOpen ? 'UI catalog' : behaviorsOpen ? 'Behaviors' : (kind?.label ?? (hasCharts ? 'Statecharts' : evidence.ready && !tabs.length ? 'Tests' : 'Specs'))}
163
+ {catalogOpen ? 'UI catalog' : critiquesOpen ? 'Needs polish' : behaviorsOpen ? 'Behaviors' : (kind?.label ?? (hasCharts ? 'Statecharts' : evidence.ready && !tabs.length ? 'Tests' : 'Specs'))}
161
164
  </Button>
162
165
  </Menu.Target>
163
166
  <Menu.Dropdown>
164
167
  <Box component="nav" aria-label="Spec type">
165
168
  <Menu.Item
166
- aria-current={pathname.startsWith('/specs/') || catalogOpen || behaviorsOpen ? undefined : 'page'}
169
+ aria-current={pathname.startsWith('/specs/') || catalogOpen || behaviorsOpen || critiquesOpen ? undefined : 'page'}
167
170
  renderRoot={(props) => <Link {...props} to="/" search={(prev) => runOnly(prev)} />}
168
171
  >
169
172
  {hasCharts || !evidence.ready ? 'Statecharts' : 'Tests'}
@@ -203,6 +206,15 @@ export function Header() {
203
206
  UI catalog
204
207
  </Menu.Item>
205
208
  )}
209
+ {(hasCritiques || critiquesOpen) && (
210
+ <Menu.Item
211
+ leftSection={<IconSparkles size={16} aria-hidden="true" />}
212
+ aria-current={critiquesOpen ? 'page' : undefined}
213
+ renderRoot={(props) => <Link {...props} to="/critiques" search={{}} />}
214
+ >
215
+ Needs polish
216
+ </Menu.Item>
217
+ )}
206
218
  </Box>
207
219
  </Menu.Dropdown>
208
220
  </Menu>
@@ -274,7 +286,7 @@ function SpecBreadcrumb() {
274
286
  name: item.name,
275
287
  running: (evidence.view.specs.get(outlinePath(item.file))?.summary.counts.running ?? 0) > 0
276
288
  }));
277
- if (!options.length || pathname === '/catalog' || pathname === '/behaviors') return null;
289
+ if (!options.length || pathname === '/catalog' || pathname === '/behaviors' || pathname === '/critiques') return null;
278
290
  return (
279
291
  <NativeSelect
280
292
  data-running={options.some((option) => option.running) || undefined}