@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
@@ -0,0 +1,402 @@
1
+ /**
2
+ * @module Needs-polish page
3
+ *
4
+ * The opinions of automated reviewers (critics) about the product's captured UI states, ranked so people review the likeliest
5
+ * problems first. It reads the report the viewer already holds, so a critique appears the moment its layer arrives. A critique is
6
+ * an opinion and is shown as one: never with a passed or failed mark, never in a test count, and never changing whether anything
7
+ * passes. A critique that judged an earlier picture is shown as that and is not ranked; a critic that could not judge says why.
8
+ *
9
+ * Three readings sit on the same evidence: the findings drawn where they were found on the picture (`FindingOverlay`), whether the
10
+ * critics agree about the pictures several of them judged (the Agreement view), and what reviewers decided about an opinion (marks:
11
+ * accepted or dismissed, per finding or for the whole opinion). Marks are made with `atlas critic review`, because a browser's sign-in
12
+ * box may only look; the viewer reads them and says how to make one.
13
+ */
14
+ import { markStanding, type AgreementOutcome, type MarkStanding, type Stance } from '@crvouga/atlas/reports/critique-review';
15
+ import type { EntryCritique } from '@crvouga/atlas/reports/ui-catalog-entries';
16
+ import { Accordion, Alert, Anchor, Badge, Button, Code, Container, Drawer, Group, Image, NativeSelect, Stack, Switch, Table, Tabs, Text, Title } from '@mantine/core';
17
+ import { Link } from '@tanstack/react-router';
18
+ import { useMemo, useState } from 'react';
19
+
20
+ import { reportAssetUrl } from '../evidence/api';
21
+ import { useEvidence } from '../evidence/evidence-context';
22
+ import { ago, exactTime, plural, useNow } from '../evidence/format';
23
+ import { EvidenceIncomplete, EvidenceWaiting } from '../evidence/load-state';
24
+ import { FindingOverlay, SEVERITY_COLOR } from './FindingOverlay';
25
+ import { polishList, type PolishList, type RankedEntry } from './ranking';
26
+ import { useCritiqueNavigate, useCritiqueSearch } from './search';
27
+
28
+ type Critique = EntryCritique['critique'];
29
+
30
+ const severityRank = (severity: string | undefined) => (severity === 'major' ? 3 : severity === 'minor' ? 2 : severity === 'info' ? 1 : 0);
31
+
32
+ function worst(critique: Critique): string | undefined {
33
+ return [...(critique.findings ?? [])].sort((a, b) => severityRank(b.severity) - severityRank(a.severity))[0]?.severity;
34
+ }
35
+
36
+ function score(critique: Critique): string {
37
+ const value = critique.verdict?.score;
38
+ return value === undefined ? 'No score' : String(Math.round(value * 1000) / 1000);
39
+ }
40
+
41
+ function standing(critique: Critique): string {
42
+ const relative = critique.verdict?.relative;
43
+ if (!relative || relative.percentile === undefined) return 'Not compared';
44
+ return `${Math.round(relative.percentile)}th percentile among ${plural(relative.sampleSize ?? 0, 'score', 'scores')} of ${relative.stateKind}`;
45
+ }
46
+
47
+ /** What reviewers decided, as a short word and who. Never a pass or a failure: accepting an opinion says the UI needs polish. */
48
+ const MARK_WORD: Readonly<Record<MarkStanding['state'], string>> = { accepted: 'Accepted', dismissed: 'Dismissed', disputed: 'Reviewers disagree', unmarked: 'Not reviewed' };
49
+ const MARK_COLOR: Readonly<Record<MarkStanding['state'], string>> = { accepted: 'orange', dismissed: 'gray', disputed: 'yellow', unmarked: 'gray' };
50
+
51
+ /** Who decided what, one line each. */
52
+ function markWho(standing: MarkStanding): string[] {
53
+ const lines = [];
54
+ if (standing.accepted.length) lines.push(`Accepted by ${standing.accepted.join(', ')}`);
55
+ if (standing.dismissed.length) lines.push(`Dismissed by ${standing.dismissed.join(', ')}`);
56
+ if (standing.earlier) lines.push(`Marks at an earlier picture (${standing.earlier}) count for nothing now`);
57
+ return lines;
58
+ }
59
+
60
+ function MarkBadge({ standing }: { standing: MarkStanding }) {
61
+ const who = markWho(standing);
62
+ return (
63
+ <Stack gap={2} align="flex-start">
64
+ <Badge variant={standing.state === 'unmarked' ? 'outline' : 'light'} color={MARK_COLOR[standing.state]}>{MARK_WORD[standing.state]}</Badge>
65
+ {who.map((line) => <Text key={line} fz="xs" c="dimmed">{line}</Text>)}
66
+ {standing.notes.map((note, index) => <Text key={index} fz="xs" c="dimmed">“{note}”</Text>)}
67
+ </Stack>
68
+ );
69
+ }
70
+
71
+ function Label({ critique }: { critique: Critique }) {
72
+ const label = critique.verdict?.label;
73
+ return label ? <Badge variant="light" color={label === 'needs-polish' ? 'orange' : 'gray'}>{label}</Badge> : <Text span c="dimmed" fz="sm">No label</Text>;
74
+ }
75
+
76
+ function Thumb({ item }: { item: RankedEntry }) {
77
+ return <Image src={reportAssetUrl(item.entry.image.path)} alt={`${item.entry.title}, ${item.entry.profile.label}`} loading="lazy" fit="contain" h={56} w={56} />;
78
+ }
79
+
80
+ function EntryLink({ item }: { item: RankedEntry }) {
81
+ return (
82
+ <Anchor fw={700} aria-label={`Open ${item.entry.title}, ${item.entry.profile.label}`} renderRoot={(props) => <Link {...props} to="/critiques" search={((prev: Record<string, unknown>) => ({ ...prev, entry: item.entry.key })) as never} resetScroll={false} />}>
83
+ {item.entry.title}
84
+ </Anchor>
85
+ );
86
+ }
87
+
88
+ function regionText(region: NonNullable<Critique['findings']>[number]['region']): string {
89
+ return region ? `${region.x}, ${region.y} · ${region.w}×${region.h} ${region.unit}` : '';
90
+ }
91
+
92
+ function Detail({ item, onClose }: { item: RankedEntry; onClose: () => void }) {
93
+ const now = useNow();
94
+ const { critique } = item.found;
95
+ const findings = critique.findings ?? [];
96
+ const [showFindings, setShowFindings] = useState(true);
97
+ const [selected, setSelected] = useState<number | null>(null);
98
+ const placed = findings.some((finding) => finding.region);
99
+ const whole = markStanding(item.entry.marks, { critic: critique.critic.name });
100
+ return (
101
+ <Drawer opened onClose={onClose} position="right" size="xl" title={item.entry.title} closeButtonProps={{ 'aria-label': 'Close the critique' }}>
102
+ <Stack gap="md">
103
+ <Text c="dimmed" fz="sm">{item.entry.key} · {item.entry.state}</Text>
104
+ <FindingOverlay src={reportAssetUrl(item.entry.image.path)} alt={`${item.entry.title}, ${item.entry.profile.label}`} size={item.entry.image} findings={findings} selected={selected} show={showFindings && item.found.currency === 'current'} />
105
+ {placed && (
106
+ <Switch checked={showFindings} onChange={(event) => setShowFindings(event.currentTarget.checked)} label="Show findings on the picture" description={item.found.currency === 'current' ? 'Numbered boxes match the findings below. A finding without a region is listed but not drawn.' : 'Not drawn: the picture changed since these findings were made, so their places no longer apply.'} disabled={item.found.currency !== 'current'} />
107
+ )}
108
+ {item.found.currency === 'earlier-image' && (
109
+ <Alert role="status" color="orange" title="This critique judged an earlier picture">
110
+ The picture of this state has changed since {critique.critic.name} looked at it. Its opinion is shown for reference and does not count as a judgement of what is shown now.
111
+ </Alert>
112
+ )}
113
+ {critique.failure ? (
114
+ <Alert role="status" color="gray" title="The critic could not judge this picture">
115
+ Reason: <Code>{critique.failure.reason}</Code>{critique.failure.message ? ` — ${critique.failure.message}` : ''}. The next run tries again. This says nothing about the picture.
116
+ </Alert>
117
+ ) : (
118
+ <Group gap="sm" aria-label="Verdict">
119
+ <Title order={3}>Opinion</Title>
120
+ <Badge variant="outline">Score {score(critique)}</Badge>
121
+ <Label critique={critique} />
122
+ {critique.verdict?.confidence !== undefined && <Badge variant="outline">Confidence {Math.round(critique.verdict.confidence * 100)}%</Badge>}
123
+ <Text fz="sm">{standing(critique)}</Text>
124
+ {critique.verdict?.relative?.delta !== undefined && <Text fz="sm">Change against the previous picture: {critique.verdict.relative.delta > 0 ? '+' : ''}{critique.verdict.relative.delta}</Text>}
125
+ </Group>
126
+ )}
127
+ {!critique.failure && (
128
+ <Stack gap={4} aria-label="Review of the opinion">
129
+ <Title order={3}>Review</Title>
130
+ <MarkBadge standing={whole} />
131
+ <Text c="dimmed" fz="sm">
132
+ Accepting says the opinion is right and the UI needs polish; dismissing says it does not. Marks are made on the command line (a browser sign-in only reads):{' '}
133
+ <Code>atlas critic review --entry {item.entry.key} --critic {critique.critic.name} --accept</Code> or <Code>--dismiss</Code>, with <Code>--finding &lt;id&gt;</Code> for one finding. A mark never changes a result or the gate.
134
+ </Text>
135
+ </Stack>
136
+ )}
137
+ {findings.length > 0 ? (
138
+ <Table.ScrollContainer minWidth={420}>
139
+ <Table aria-label="Findings">
140
+ <Table.Thead>
141
+ <Table.Tr>
142
+ <Table.Th scope="col">#</Table.Th>
143
+ <Table.Th scope="col">Category</Table.Th>
144
+ <Table.Th scope="col">Severity</Table.Th>
145
+ <Table.Th scope="col">Where</Table.Th>
146
+ <Table.Th scope="col">Why</Table.Th>
147
+ <Table.Th scope="col">Suggestion</Table.Th>
148
+ <Table.Th scope="col">Review</Table.Th>
149
+ </Table.Tr>
150
+ </Table.Thead>
151
+ <Table.Tbody>
152
+ {findings.map((finding, index) => (
153
+ <Table.Tr key={finding.id ?? index} data-finding={finding.id ?? String(index + 1)}>
154
+ <Table.Td>
155
+ {finding.region && item.found.currency === 'current' && showFindings
156
+ ? <Button size="compact-xs" variant={selected === index ? 'filled' : 'subtle'} aria-pressed={selected === index} aria-label={`Locate finding ${index + 1} on the picture`} onClick={() => setSelected(selected === index ? null : index)}>{index + 1}</Button>
157
+ : index + 1}
158
+ </Table.Td>
159
+ <Table.Td>{finding.category ?? 'Uncategorized'}</Table.Td>
160
+ <Table.Td><Badge variant="light" color={SEVERITY_COLOR[finding.severity ?? ''] ?? 'gray'}>{finding.severity ?? 'No severity'}</Badge></Table.Td>
161
+ <Table.Td>{regionText(finding.region) || <Text span c="dimmed">Whole picture</Text>}</Table.Td>
162
+ <Table.Td>{finding.rationale ?? ''}</Table.Td>
163
+ <Table.Td>{finding.suggestion ?? ''}</Table.Td>
164
+ <Table.Td>{finding.id ? <MarkBadge standing={markStanding(item.entry.marks, { critic: critique.critic.name, finding: finding.id })} /> : <Text span c="dimmed" fz="sm">Cannot be marked alone: it has no id</Text>}</Table.Td>
165
+ </Table.Tr>
166
+ ))}
167
+ </Table.Tbody>
168
+ </Table>
169
+ </Table.ScrollContainer>
170
+ ) : (
171
+ !critique.failure && <Text c="dimmed">This critic gives a score and no findings, so it cannot say what to fix.</Text>
172
+ )}
173
+ <Accordion variant="contained">
174
+ <Accordion.Item value="provenance">
175
+ <Accordion.Control>Where this opinion came from</Accordion.Control>
176
+ <Accordion.Panel>
177
+ <Table aria-label="Provenance" withRowBorders>
178
+ <Table.Tbody>
179
+ {([
180
+ ['Critic', `${critique.critic.name} ${critique.critic.version}${critique.critic.kind ? ` (${critique.critic.kind})` : ''}`],
181
+ ['Model', critique.model ? `${critique.model.id}${critique.model.provider ? `, ${critique.model.provider}` : ''}` : 'None declared'],
182
+ ['Parameters', critique.parameters ? JSON.stringify(critique.parameters) : 'None declared'],
183
+ ['Picture judged', critique.subject.imageSha256],
184
+ ['Layer', `${item.found.layer.slice(0, 12)} (${item.found.producer})`],
185
+ ['Recorded', `${ago(item.found.recordedAtMs, now)} (${exactTime(item.found.recordedAtMs)})`],
186
+ ['Cost', critique.usage ? [critique.usage.inputTokens === undefined ? '' : `${critique.usage.inputTokens} tokens in`, critique.usage.outputTokens === undefined ? '' : `${critique.usage.outputTokens} out`, critique.usage.latencyMs === undefined ? '' : `${critique.usage.latencyMs} ms`].filter(Boolean).join(', ') || 'Not reported' : 'Not reported']
187
+ ] as const).map(([name, value]) => (
188
+ <Table.Tr key={name}>
189
+ <Table.Th scope="row" w="30%">{name}</Table.Th>
190
+ <Table.Td>{value}</Table.Td>
191
+ </Table.Tr>
192
+ ))}
193
+ </Table.Tbody>
194
+ </Table>
195
+ </Accordion.Panel>
196
+ </Accordion.Item>
197
+ </Accordion>
198
+ </Stack>
199
+ </Drawer>
200
+ );
201
+ }
202
+
203
+ const STANCE_WORDS: Readonly<Record<Stance, string>> = { flags: 'Flags it', clear: 'Does not flag it', 'no-view': 'No view' };
204
+ const STANCE_COLOR: Readonly<Record<Stance, string>> = { flags: 'orange', clear: 'gray', 'no-view': 'gray' };
205
+ const OUTCOME_WORDS: Readonly<Record<AgreementOutcome, string>> = { 'agree-flag': 'All flag it', 'agree-clear': 'None flag it', disagree: 'Critics disagree', unclear: 'Not enough views' };
206
+
207
+ /** Whether the critics agree about the pictures several of them judged. A reading for people: not a verdict, a status or a gate. */
208
+ function AgreementPanel({ list }: { list: PolishList }) {
209
+ const { agreement } = list;
210
+ if (agreement.critics.length < 2 || agreement.rows.length === 0) {
211
+ return (
212
+ <Text c="dimmed" role="status" component="div" data-empty>
213
+ {agreement.critics.length < 2
214
+ ? 'Agreement needs two critics to have judged the same picture. Run another critic against this server (atlas critic run --critic <name> --all) and compare them here.'
215
+ : `No picture has been judged by more than one critic yet${list.kinds.length > 1 ? ' in this state kind' : ''}.`}
216
+ </Text>
217
+ );
218
+ }
219
+ return (
220
+ <Stack gap="md">
221
+ <Text c="dimmed" fz="sm">
222
+ Only each critic’s opinion of the picture an entry has now is compared. A critic flags a UI when its label says needs-polish, when it has findings, or, with neither, when its score is in the lowest quarter of its state kind; a bare score with no standing, or a critic that failed, gives no view and never makes critics agree. Entries every critic flags come first, then those they disagree about.
223
+ {agreement.single > 0 ? ` ${plural(agreement.single, 'state has', 'states have')} been judged by only one critic and ${agreement.single === 1 ? 'is' : 'are'} not shown.` : ''}
224
+ </Text>
225
+ <Table.ScrollContainer minWidth={560}>
226
+ <Table aria-label="Critics compared, those that all flag a state first" striped>
227
+ <Table.Thead>
228
+ <Table.Tr>
229
+ <Table.Th scope="col">State</Table.Th>
230
+ <Table.Th scope="col">Kind</Table.Th>
231
+ {agreement.critics.map((critic) => <Table.Th key={critic} scope="col">{critic}</Table.Th>)}
232
+ <Table.Th scope="col">Agreement</Table.Th>
233
+ </Table.Tr>
234
+ </Table.Thead>
235
+ <Table.Tbody>
236
+ {agreement.rows.map((row) => (
237
+ <Table.Tr key={row.entry.key} data-entry={row.entry.key} data-outcome={row.outcome}>
238
+ <Table.Td><Stack gap={2}><Text fw={700}>{row.entry.title}</Text><Text c="dimmed" fz="xs">{row.entry.profile.label}</Text></Stack></Table.Td>
239
+ <Table.Td>{row.entry.state}</Table.Td>
240
+ {agreement.critics.map((critic) => {
241
+ const view = row.views.find((candidate) => candidate.critic === critic);
242
+ if (!view) return <Table.Td key={critic}><Text c="dimmed" fz="sm">Not judged at this picture</Text></Table.Td>;
243
+ return (
244
+ <Table.Td key={critic}>
245
+ <Stack gap={2} align="flex-start">
246
+ <Badge variant="light" color={STANCE_COLOR[view.stance]}>{STANCE_WORDS[view.stance]}</Badge>
247
+ <Anchor fz="sm" aria-label={`Open ${critic}’s critique of ${row.entry.title}, ${row.entry.profile.label}`} renderRoot={(props) => <Link {...props} to="/critiques" search={((prev: Record<string, unknown>) => ({ ...prev, critic, entry: row.entry.key })) as never} resetScroll={false} />}>
248
+ {view.score === undefined ? 'No score' : `Score ${Math.round(view.score * 1000) / 1000}`}{view.percentile === undefined ? '' : `, ${Math.round(view.percentile)}th percentile`}
249
+ </Anchor>
250
+ </Stack>
251
+ </Table.Td>
252
+ );
253
+ })}
254
+ <Table.Td><Badge variant={row.outcome === 'agree-flag' ? 'filled' : 'outline'} color={row.outcome === 'disagree' ? 'yellow' : row.outcome === 'agree-flag' ? 'orange' : 'gray'}>{OUTCOME_WORDS[row.outcome]}</Badge></Table.Td>
255
+ </Table.Tr>
256
+ ))}
257
+ </Table.Tbody>
258
+ </Table>
259
+ </Table.ScrollContainer>
260
+ </Stack>
261
+ );
262
+ }
263
+
264
+ /** The `/critiques` route. */
265
+ export function CritiquesPage() {
266
+ const evidence = useEvidence();
267
+ const search = useCritiqueSearch();
268
+ const go = useCritiqueNavigate();
269
+ const list = useMemo(() => polishList(evidence.evidence, search), [evidence.evidence, search]);
270
+ const unavailable = evidence.backends.filter((backend) => !backend.available);
271
+ const all = [...list.ranked, ...list.unranked];
272
+ const view = search.view === 'agreement' ? 'agreement' : 'ranked';
273
+ const open = search.entry ? all.find((item) => item.entry.key === search.entry) ?? null : null;
274
+ return (
275
+ <Container size="xl" w="100%" py="xl" component="article" aria-label="Needs polish">
276
+ <Stack gap="md">
277
+ <Stack gap={4}>
278
+ <Title order={1}>Needs polish</Title>
279
+ <Text c="dimmed">
280
+ What automated reviewers think of the captured UI states, lowest first, so the likeliest problems are reviewed first. These are opinions: they are not test results, are never counted with them, and never change whether a report passes.
281
+ </Text>
282
+ </Stack>
283
+ {evidence.unavailable && <Alert role="alert" color="red" title="Recorded results cannot be read">{evidence.unavailable}</Alert>}
284
+ {unavailable.length > 0 && (
285
+ <Alert role="status" color="orange" title={`${plural(unavailable.length, 'backend is', 'backends are')} not available`}>
286
+ {unavailable.map((backend) => backend.label).join(', ')}: critiques held there are not shown. This list may be incomplete.
287
+ </Alert>
288
+ )}
289
+ <EvidenceIncomplete />
290
+ {!evidence.ready && !evidence.unavailable && <EvidenceWaiting label="Reading the recorded critiques…" />}
291
+ {evidence.ready && list.critics.length === 0 && (
292
+ <Text c="dimmed" role="status" component="div" data-empty>
293
+ No critic has judged a captured UI state yet. Run <Code>atlas critic run --critic &lt;name&gt; --all</Code> against this server; critiques appear here the moment they arrive.
294
+ {list.unjudgeable > 0 ? ` ${plural(list.unjudgeable, 'captured state has', 'captured states have')} no stored picture, so a critic cannot see ${list.unjudgeable === 1 ? 'it' : 'them'}.` : ''}
295
+ </Text>
296
+ )}
297
+ {list.critic && (
298
+ <>
299
+ <Group align="flex-end" gap="md">
300
+ {view === 'ranked' && <NativeSelect label="Critic" description="Scores are ranked within one critic" value={list.critic} data={list.critics.map((critic) => ({ value: critic.name, label: `${critic.name} (${critic.critiques})` }))} onChange={(event) => go({ critic: event.currentTarget.value })} />}
301
+ <NativeSelect label="State kind" value={search.kind ?? ''} data={[{ value: '', label: 'All kinds' }, ...list.kinds.map((kind) => ({ value: kind, label: kind }))]} onChange={(event) => go({ kind: event.currentTarget.value || undefined })} />
302
+ </Group>
303
+ <Tabs value={view} onChange={(next) => go({ view: next === 'agreement' ? 'agreement' : undefined })} keepMounted={false}>
304
+ <Tabs.List>
305
+ <Tabs.Tab value="ranked">Ranked</Tabs.Tab>
306
+ <Tabs.Tab value="agreement">Agreement across critics</Tabs.Tab>
307
+ </Tabs.List>
308
+ <Tabs.Panel value="agreement" pt="md"><AgreementPanel list={list} /></Tabs.Panel>
309
+ <Tabs.Panel value="ranked" pt="md">
310
+ <Stack gap="md">
311
+ <Group gap="xs" role="status" aria-label="Critique summary">
312
+ <Badge variant="light">{plural(list.ranked.length, 'state judged', 'states judged')}</Badge>
313
+ {list.unranked.length > 0 && <Badge variant="outline">{list.unranked.length} not judged at the current picture</Badge>}
314
+ {list.unjudged > 0 && <Badge variant="outline">{list.unjudged} not judged yet</Badge>}
315
+ </Group>
316
+ {list.ranked.length > 0 ? (
317
+ <Table.ScrollContainer minWidth={640}>
318
+ <Table aria-label="States ranked by score, lowest first" striped>
319
+ <Table.Thead>
320
+ <Table.Tr>
321
+ <Table.Th scope="col">Rank</Table.Th>
322
+ <Table.Th scope="col">Picture</Table.Th>
323
+ <Table.Th scope="col">State</Table.Th>
324
+ <Table.Th scope="col">Kind</Table.Th>
325
+ <Table.Th scope="col">Score</Table.Th>
326
+ <Table.Th scope="col">Within its kind</Table.Th>
327
+ <Table.Th scope="col">Findings</Table.Th>
328
+ <Table.Th scope="col">Review</Table.Th>
329
+ </Table.Tr>
330
+ </Table.Thead>
331
+ <Table.Tbody>
332
+ {list.ranked.map((item, index) => {
333
+ const severity = worst(item.found.critique);
334
+ return (
335
+ <Table.Tr key={item.entry.key} data-entry={item.entry.key}>
336
+ <Table.Td>{index + 1}</Table.Td>
337
+ <Table.Td><Thumb item={item} /></Table.Td>
338
+ <Table.Td><Stack gap={2}><EntryLink item={item} /><Text c="dimmed" fz="xs">{item.entry.profile.label}</Text></Stack></Table.Td>
339
+ <Table.Td>{item.entry.state}</Table.Td>
340
+ <Table.Td><Group gap="xs" wrap="nowrap"><Text span>{score(item.found.critique)}</Text><Label critique={item.found.critique} /></Group></Table.Td>
341
+ <Table.Td>{standing(item.found.critique)}</Table.Td>
342
+ <Table.Td>
343
+ {(item.found.critique.findings ?? []).length > 0 ? (
344
+ <Group gap="xs" wrap="nowrap">
345
+ <Text span>{plural((item.found.critique.findings ?? []).length, 'finding', 'findings')}</Text>
346
+ {severity && <Badge variant="light" color={SEVERITY_COLOR[severity] ?? 'gray'}>{severity}</Badge>}
347
+ </Group>
348
+ ) : <Text span c="dimmed">None given</Text>}
349
+ </Table.Td>
350
+ <Table.Td><MarkBadge standing={markStanding(item.entry.marks, { critic: item.found.critique.critic.name })} /></Table.Td>
351
+ </Table.Tr>
352
+ );
353
+ })}
354
+ </Table.Tbody>
355
+ </Table>
356
+ </Table.ScrollContainer>
357
+ ) : (
358
+ <Text c="dimmed" role="status" component="div">{list.critic} has not produced a score for any state in view.</Text>
359
+ )}
360
+ {list.unranked.length > 0 && (
361
+ <Stack gap="xs">
362
+ <Title order={2}>Not judged at the current picture</Title>
363
+ <Table.ScrollContainer minWidth={420}>
364
+ <Table aria-label="States not judged at the current picture">
365
+ <Table.Thead>
366
+ <Table.Tr>
367
+ <Table.Th scope="col">State</Table.Th>
368
+ <Table.Th scope="col">Why</Table.Th>
369
+ </Table.Tr>
370
+ </Table.Thead>
371
+ <Table.Tbody>
372
+ {list.unranked.map((item) => (
373
+ <Table.Tr key={item.entry.key} data-entry={item.entry.key}>
374
+ <Table.Td><Stack gap={2}><EntryLink item={item} /><Text c="dimmed" fz="xs">{item.entry.profile.label}</Text></Stack></Table.Td>
375
+ <Table.Td>
376
+ {item.found.critique.failure
377
+ ? `The critic could not judge it (${item.found.critique.failure.reason}); the next run tries again`
378
+ : 'The picture changed since it was judged; the older opinion is kept for reference'}
379
+ </Table.Td>
380
+ </Table.Tr>
381
+ ))}
382
+ </Table.Tbody>
383
+ </Table>
384
+ </Table.ScrollContainer>
385
+ </Stack>
386
+ )}
387
+ </Stack>
388
+ </Tabs.Panel>
389
+ </Tabs>
390
+ </>
391
+ )}
392
+ {search.entry && !open && evidence.ready && evidence.pending === 0 && (
393
+ <Alert role="status" color="gray" title="That entry has no critique in this view">
394
+ <Text span inherit>{search.entry}</Text> is not among the states this critic has judged or tried to judge.{' '}
395
+ <Anchor renderRoot={(props) => <Link {...props} to="/critiques" search={((prev: Record<string, unknown>) => ({ ...prev, entry: undefined })) as never} />}>Back to the list</Anchor>
396
+ </Alert>
397
+ )}
398
+ </Stack>
399
+ {open && <Detail item={open} onClose={() => go({ entry: undefined })} />}
400
+ </Container>
401
+ );
402
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * @module Finding overlays
3
+ *
4
+ * A critic's findings drawn where they were found, on the picture they are about. A finding's region is in the unit the critic stated:
5
+ * `normalized` (0..1 of the picture) or `px` (pixels of the stored picture, which needs the picture's size, taken from the catalog
6
+ * or from the loaded image). A region in any other unit cannot be placed and is not drawn; the findings table still says where in
7
+ * words. Every box carries the finding's number, so it is never told apart by color alone, and the picture keeps its own alternative text.
8
+ * An overlay is an opinion drawn over evidence: it changes nothing about the picture and is never a result.
9
+ */
10
+ import type { EntryCritique } from '@crvouga/atlas/reports/ui-catalog-entries';
11
+ import { Badge, Box, Image } from '@mantine/core';
12
+ import { useState } from 'react';
13
+
14
+ type CritiqueFinding = NonNullable<EntryCritique['critique']['findings']>[number];
15
+
16
+ /** The color of a severity, as the table shows it. Unknown severities are gray. */
17
+ export const SEVERITY_COLOR: Readonly<Record<string, string>> = { major: 'red', minor: 'yellow', info: 'blue' };
18
+
19
+ /** A region as percentages of the picture, or `null` when it cannot be placed (an unknown unit, no known size for pixels, or not finite numbers). */
20
+ export function regionBox(region: NonNullable<CritiqueFinding['region']>, size: { readonly width: number; readonly height: number } | null): { left: number; top: number; width: number; height: number } | null {
21
+ const [fx, fy] = region.unit === 'normalized' ? [1, 1] : region.unit === 'px' && size ? [size.width, size.height] : [0, 0];
22
+ if (!fx || !fy || ![region.x, region.y, region.w, region.h].every(Number.isFinite)) return null;
23
+ const clamp = (value: number) => Math.min(100, Math.max(0, value));
24
+ const left = clamp((region.x / fx) * 100), top = clamp((region.y / fy) * 100);
25
+ return { left, top, width: Math.min(100 - left, clamp((region.w / fx) * 100)), height: Math.min(100 - top, clamp((region.h / fy) * 100)) };
26
+ }
27
+
28
+ /** The picture with a numbered box for each finding that has a region. `selected` (an index into `findings`) is drawn thicker. */
29
+ export function FindingOverlay({ src, alt, size, findings, selected, show }: {
30
+ src: string; alt: string; size?: { width?: number | undefined; height?: number | undefined };
31
+ findings: readonly CritiqueFinding[]; selected: number | null; show: boolean;
32
+ }) {
33
+ const [natural, setNatural] = useState<{ width: number; height: number } | null>(null);
34
+ const known = size?.width && size.height ? { width: size.width, height: size.height } : natural;
35
+ return (
36
+ <Box pos="relative" w="fit-content" maw="100%">
37
+ <Image src={src} alt={alt} loading="lazy" fit="contain" onLoad={(event) => { const img = event.currentTarget; if (img.naturalWidth && img.naturalHeight) setNatural({ width: img.naturalWidth, height: img.naturalHeight }); }} />
38
+ {show && findings.map((finding, index) => {
39
+ const box = finding.region ? regionBox(finding.region, known) : null;
40
+ if (!box) return null;
41
+ const color = SEVERITY_COLOR[finding.severity ?? ''] ?? 'gray';
42
+ return (
43
+ <Box key={finding.id ?? index} data-finding={finding.id ?? String(index + 1)} aria-hidden pos="absolute" left={`${box.left}%`} top={`${box.top}%`} w={`${box.width}%`} h={`${box.height}%`} data-selected={selected === index} bd={`${selected === index ? 4 : 2}px solid ${color}.7`}>
44
+ <Badge circle size="sm" color={color} variant="filled">{index + 1}</Badge>
45
+ </Box>
46
+ );
47
+ })}
48
+ </Box>
49
+ );
50
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * @module Needs-polish ranking
3
+ *
4
+ * Reads the critiques the viewer holds into a ranked list for one critic. A critique is an opinion, never a result: nothing
5
+ * here counts toward a test total or changes a status. The list ranks the entries a critic judged at their current picture, lowest
6
+ * first (a score is read as higher-is-better, which is the convention of the critique format); when every ranked score also
7
+ * carries its standing among the scores of the same state kind (`relative.percentile`), that standing ranks them instead, because a
8
+ * loading overlay is not compared with a full screen. An entry whose picture changed since it was judged, or whose critic
9
+ * could not judge it, is listed apart and is never ranked, and an entry the critic never saw is only counted.
10
+ */
11
+ import { critiqueAgreement, rankCritiques, type CritiqueAgreement, type RankedEntry } from '@crvouga/atlas/reports/critique-review';
12
+ import { catalogEntries, type CatalogEntry } from '@crvouga/atlas/reports/ui-catalog-entries';
13
+ import type { LayerEvidence } from '@crvouga/atlas/reports/evidence-types';
14
+
15
+ export type { RankedEntry } from '@crvouga/atlas/reports/critique-review';
16
+
17
+ /** What the page shows for one critic and (optionally) one kind of UI state. */
18
+ export type PolishList = {
19
+ /** Critics that have a critique of any entry, most critiques first. */
20
+ readonly critics: readonly { readonly name: string; readonly critiques: number }[];
21
+ /** The critic whose scores rank the list; null when no critic has judged anything. */
22
+ readonly critic: string | null;
23
+ /** The state kinds of the entries that are in scope, for the filter. */
24
+ readonly kinds: readonly string[];
25
+ /** Entries judged at their current picture with a verdict, lowest first. */
26
+ readonly ranked: readonly RankedEntry[];
27
+ /** How the list was ranked: by standing within the state kind, or by raw score. */
28
+ readonly rankedBy: 'standing-within-kind' | 'score';
29
+ /** Entries whose newest critique judged an earlier picture, or could not judge: not ranked. */
30
+ readonly unranked: readonly RankedEntry[];
31
+ /** Entries in scope the critic has not judged at all. */
32
+ readonly unjudged: number;
33
+ /** Captured states that are not entries because no stored picture backs them. */
34
+ readonly unjudgeable: number;
35
+ /** Every captured state with a stored picture, for the readings that look across critics. */
36
+ readonly entries: readonly CatalogEntry[];
37
+ /** Whether the critics agree on the pictures two or more of them judged (state kind applied). */
38
+ readonly agreement: CritiqueAgreement;
39
+ };
40
+
41
+ /**
42
+ * The list for a critic and state kind over the layers the viewer holds. With no `critic` the one with most critiques is used.
43
+ */
44
+ export function polishList(layers: readonly LayerEvidence[], choice: { readonly critic?: string | undefined; readonly kind?: string | undefined } = {}): PolishList {
45
+ const { entries, unjudgeable } = catalogEntries(layers);
46
+ const tally = new Map<string, number>();
47
+ for (const entry of entries) for (const found of entry.critiques) tally.set(found.critique.critic.name, (tally.get(found.critique.critic.name) ?? 0) + 1);
48
+ const critics = [...tally].map(([name, critiques]) => ({ name, critiques })).sort((a, b) => b.critiques - a.critiques || (a.name < b.name ? -1 : 1));
49
+ const critic = choice.critic && tally.has(choice.critic) ? choice.critic : critics[0]?.name ?? null;
50
+ const kinds = [...new Set(entries.map((entry) => entry.state))].sort();
51
+ const { ranked, rankedBy, unranked, unjudged } = rankCritiques(entries, critic, choice.kind);
52
+ const agreement = critiqueAgreement(entries, choice.kind);
53
+ return { critics, critic, kinds, ranked, rankedBy, unranked, unjudged, unjudgeable, entries, agreement };
54
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * @module Needs-polish address
3
+ *
4
+ * What the needs-polish page shows is in the address of the `/critiques` route, so a link reproduces the view: the critic whose
5
+ * scores rank the list, the kind of UI state, and the entry opened. Anything unreadable is dropped, never an error.
6
+ */
7
+ import { useNavigate, useSearch } from '@tanstack/react-router';
8
+ import { useMemo } from 'react';
9
+ import { z } from 'zod/v4';
10
+
11
+ const text = z
12
+ .preprocess((value) => (typeof value === 'number' || typeof value === 'boolean' ? String(value) : value), z.string().min(1))
13
+ .optional()
14
+ .catch(undefined);
15
+
16
+ /** The `/critiques` route's search: the critic, the state kind, the open entry (an entry key) and the view (`ranked` or `agreement`). */
17
+ export const CritiqueSearchFields = { critic: text, kind: text, entry: text, view: text };
18
+
19
+ /** The address's parts, as the router holds them. */
20
+ export type CritiqueSearch = { readonly [K in keyof typeof CritiqueSearchFields]?: string | undefined };
21
+
22
+ /** The needs-polish parameters, read from the address. */
23
+ export function useCritiqueSearch(): CritiqueSearch {
24
+ const raw = useSearch({ strict: false }) as Record<string, unknown>;
25
+ return useMemo(() => {
26
+ const search: Record<string, string> = {};
27
+ for (const name of Object.keys(CritiqueSearchFields)) {
28
+ const value = raw[name];
29
+ if (typeof value === 'string' && value) search[name] = value;
30
+ else if (typeof value === 'number') search[name] = String(value);
31
+ }
32
+ return search;
33
+ }, [raw]);
34
+ }
35
+
36
+ /** Returns `go(patch)`, which moves the page to the address with `patch` applied (fields set to undefined are removed). */
37
+ export function useCritiqueNavigate() {
38
+ const navigate = useNavigate();
39
+ return (patch: CritiqueSearch, options: { replace?: boolean } = {}) =>
40
+ void navigate({ to: '/critiques', search: ((prev: Record<string, unknown>) => ({ ...prev, ...patch })) as never, replace: options.replace ?? false, resetScroll: false });
41
+ }
@@ -21,13 +21,16 @@ export type ApplicabilityState = {
21
21
  readonly setOnlyStale: (on: boolean) => void;
22
22
  };
23
23
 
24
+ /** The address fields the release and the evidence filter are held in, shared by the spec route and the behaviors route. */
25
+ type ReleaseAddress = { release?: string; releaseSha?: string; releasePlatform?: string; evidence?: 'stale' };
26
+
24
27
  /**
25
- * The release (`release` build, `releaseSha`, `releasePlatform`) and evidence filter (`evidence=stale`) of the spec
26
- * address. They are held in the address, so a shared link shows the same reading. Must be used under the spec route.
28
+ * The release (`release` build, `releaseSha`, `releasePlatform`) and evidence filter (`evidence=stale`) of the address of a
29
+ * spec page or of the behaviors page. They are held in the address, so a shared link shows the same reading.
27
30
  */
28
31
  export function useApplicabilityState(): ApplicabilityState {
29
- const search = useSearch({ from: '/specs/$kind' });
30
- const navigate = useNavigate({ from: '/specs/$kind' });
32
+ const search = useSearch({ strict: false }) as ReleaseAddress;
33
+ const navigate = useNavigate();
31
34
  const release = useMemo<ReleaseIdentity>(
32
35
  () => ({
33
36
  ...(search.release ? { build: search.release } : {}),
@@ -39,18 +42,19 @@ export function useApplicabilityState(): ApplicabilityState {
39
42
  const setRelease = useCallback(
40
43
  (next: ReleaseIdentity) =>
41
44
  void navigate({
42
- search: (prev) => ({
45
+ to: '.',
46
+ search: ((prev: Record<string, unknown>) => ({
43
47
  ...prev,
44
48
  release: next.build || undefined,
45
49
  releaseSha: next.sha || undefined,
46
50
  releasePlatform: next.platform || undefined
47
- }),
51
+ })) as never,
48
52
  replace: true
49
53
  }),
50
54
  [navigate]
51
55
  );
52
56
  const setOnlyStale = useCallback(
53
- (on: boolean) => void navigate({ search: (prev) => ({ ...prev, evidence: on ? ('stale' as const) : undefined }), replace: true }),
57
+ (on: boolean) => void navigate({ to: '.', search: ((prev: Record<string, unknown>) => ({ ...prev, evidence: on ? 'stale' : undefined })) as never, replace: true }),
54
58
  [navigate]
55
59
  );
56
60
  return { release, onlyStale: search.evidence === 'stale', setRelease, setOnlyStale };