@variance-authority/tribunal 0.1.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 (271) hide show
  1. package/CHANGELOG.md +100 -0
  2. package/LICENSE +21 -0
  3. package/README.md +506 -0
  4. package/dist/__fixtures__/review.d.ts +47 -0
  5. package/dist/__fixtures__/review.js +145 -0
  6. package/dist/__fixtures__/review.js.map +1 -0
  7. package/dist/bindings.d.ts +134 -0
  8. package/dist/bindings.js +88 -0
  9. package/dist/bindings.js.map +1 -0
  10. package/dist/changelog.d.ts +132 -0
  11. package/dist/changelog.js +129 -0
  12. package/dist/changelog.js.map +1 -0
  13. package/dist/history-queries.d.ts +82 -0
  14. package/dist/history-queries.js +200 -0
  15. package/dist/history-queries.js.map +1 -0
  16. package/dist/history-rows.d.ts +42 -0
  17. package/dist/history-rows.js +148 -0
  18. package/dist/history-rows.js.map +1 -0
  19. package/dist/history.d.ts +40 -0
  20. package/dist/history.js +186 -0
  21. package/dist/history.js.map +1 -0
  22. package/dist/index.d.ts +13 -0
  23. package/dist/index.js +12 -0
  24. package/dist/index.js.map +1 -0
  25. package/dist/migration-steps.d.ts +32 -0
  26. package/dist/migration-steps.js +303 -0
  27. package/dist/migration-steps.js.map +1 -0
  28. package/dist/migrations.d.ts +58 -0
  29. package/dist/migrations.js +283 -0
  30. package/dist/migrations.js.map +1 -0
  31. package/dist/next.d.ts +94 -0
  32. package/dist/next.js +44 -0
  33. package/dist/next.js.map +1 -0
  34. package/dist/node/bin.d.ts +120 -0
  35. package/dist/node/bin.js +337 -0
  36. package/dist/node/bin.js.map +1 -0
  37. package/dist/node/bucket.d.ts +58 -0
  38. package/dist/node/bucket.js +142 -0
  39. package/dist/node/bucket.js.map +1 -0
  40. package/dist/node/database.d.ts +33 -0
  41. package/dist/node/database.js +157 -0
  42. package/dist/node/database.js.map +1 -0
  43. package/dist/node/index.d.ts +43 -0
  44. package/dist/node/index.js +43 -0
  45. package/dist/node/index.js.map +1 -0
  46. package/dist/node/serve.d.ts +81 -0
  47. package/dist/node/serve.js +193 -0
  48. package/dist/node/serve.js.map +1 -0
  49. package/dist/node/ui-assets.d.ts +47 -0
  50. package/dist/node/ui-assets.js +60 -0
  51. package/dist/node/ui-assets.js.map +1 -0
  52. package/dist/review-ingest.d.ts +24 -0
  53. package/dist/review-ingest.js +184 -0
  54. package/dist/review-ingest.js.map +1 -0
  55. package/dist/review-read.d.ts +87 -0
  56. package/dist/review-read.js +361 -0
  57. package/dist/review-read.js.map +1 -0
  58. package/dist/review-rows.d.ts +30 -0
  59. package/dist/review-rows.js +67 -0
  60. package/dist/review-rows.js.map +1 -0
  61. package/dist/review-types.d.ts +414 -0
  62. package/dist/review-types.js +2 -0
  63. package/dist/review-types.js.map +1 -0
  64. package/dist/review-write.d.ts +35 -0
  65. package/dist/review-write.js +86 -0
  66. package/dist/review-write.js.map +1 -0
  67. package/dist/review.d.ts +53 -0
  68. package/dist/review.fixtures.d.ts +47 -0
  69. package/dist/review.fixtures.js +145 -0
  70. package/dist/review.fixtures.js.map +1 -0
  71. package/dist/review.js +245 -0
  72. package/dist/review.js.map +1 -0
  73. package/dist/schema.d.ts +36 -0
  74. package/dist/schema.js +39 -0
  75. package/dist/schema.js.map +1 -0
  76. package/dist/store.d.ts +58 -0
  77. package/dist/store.js +278 -0
  78. package/dist/store.js.map +1 -0
  79. package/dist/testing.d.ts +38 -0
  80. package/dist/testing.js +92 -0
  81. package/dist/testing.js.map +1 -0
  82. package/dist/ui/__probe.d.ts +3 -0
  83. package/dist/ui/__probe.js +5 -0
  84. package/dist/ui/__probe.js.map +1 -0
  85. package/dist/ui/attribution.d.ts +46 -0
  86. package/dist/ui/attribution.js +79 -0
  87. package/dist/ui/attribution.js.map +1 -0
  88. package/dist/ui/because.d.ts +51 -0
  89. package/dist/ui/because.js +52 -0
  90. package/dist/ui/because.js.map +1 -0
  91. package/dist/ui/browser-entry.d.ts +2 -0
  92. package/dist/ui/browser-entry.js +34 -0
  93. package/dist/ui/browser-entry.js.map +1 -0
  94. package/dist/ui/builds.d.ts +43 -0
  95. package/dist/ui/builds.js +79 -0
  96. package/dist/ui/builds.js.map +1 -0
  97. package/dist/ui/carried.d.ts +73 -0
  98. package/dist/ui/carried.js +63 -0
  99. package/dist/ui/carried.js.map +1 -0
  100. package/dist/ui/cause.d.ts +73 -0
  101. package/dist/ui/cause.js +133 -0
  102. package/dist/ui/cause.js.map +1 -0
  103. package/dist/ui/change-story.d.ts +153 -0
  104. package/dist/ui/change-story.js +253 -0
  105. package/dist/ui/change-story.js.map +1 -0
  106. package/dist/ui/change.d.ts +51 -0
  107. package/dist/ui/change.js +125 -0
  108. package/dist/ui/change.js.map +1 -0
  109. package/dist/ui/client.d.ts +74 -0
  110. package/dist/ui/client.js +88 -0
  111. package/dist/ui/client.js.map +1 -0
  112. package/dist/ui/consumers.d.ts +80 -0
  113. package/dist/ui/consumers.js +125 -0
  114. package/dist/ui/consumers.js.map +1 -0
  115. package/dist/ui/control.d.ts +63 -0
  116. package/dist/ui/control.js +39 -0
  117. package/dist/ui/control.js.map +1 -0
  118. package/dist/ui/crossing.d.ts +55 -0
  119. package/dist/ui/crossing.js +74 -0
  120. package/dist/ui/crossing.js.map +1 -0
  121. package/dist/ui/declarations.d.ts +36 -0
  122. package/dist/ui/declarations.js +73 -0
  123. package/dist/ui/declarations.js.map +1 -0
  124. package/dist/ui/distance.d.ts +154 -0
  125. package/dist/ui/distance.js +180 -0
  126. package/dist/ui/distance.js.map +1 -0
  127. package/dist/ui/divergence.d.ts +40 -0
  128. package/dist/ui/divergence.js +176 -0
  129. package/dist/ui/divergence.js.map +1 -0
  130. package/dist/ui/docket.d.ts +39 -0
  131. package/dist/ui/docket.js +172 -0
  132. package/dist/ui/docket.js.map +1 -0
  133. package/dist/ui/findings.d.ts +42 -0
  134. package/dist/ui/findings.js +110 -0
  135. package/dist/ui/findings.js.map +1 -0
  136. package/dist/ui/foreseen.d.ts +55 -0
  137. package/dist/ui/foreseen.js +91 -0
  138. package/dist/ui/foreseen.js.map +1 -0
  139. package/dist/ui/glance.d.ts +101 -0
  140. package/dist/ui/glance.js +101 -0
  141. package/dist/ui/glance.js.map +1 -0
  142. package/dist/ui/grouping.d.ts +158 -0
  143. package/dist/ui/grouping.js +187 -0
  144. package/dist/ui/grouping.js.map +1 -0
  145. package/dist/ui/handed.d.ts +60 -0
  146. package/dist/ui/handed.js +122 -0
  147. package/dist/ui/handed.js.map +1 -0
  148. package/dist/ui/history.d.ts +88 -0
  149. package/dist/ui/history.js +170 -0
  150. package/dist/ui/history.js.map +1 -0
  151. package/dist/ui/holding.d.ts +84 -0
  152. package/dist/ui/holding.js +90 -0
  153. package/dist/ui/holding.js.map +1 -0
  154. package/dist/ui/impact.d.ts +31 -0
  155. package/dist/ui/impact.js +132 -0
  156. package/dist/ui/impact.js.map +1 -0
  157. package/dist/ui/index.d.ts +21 -0
  158. package/dist/ui/index.js +21 -0
  159. package/dist/ui/index.js.map +1 -0
  160. package/dist/ui/lead.d.ts +35 -0
  161. package/dist/ui/lead.js +34 -0
  162. package/dist/ui/lead.js.map +1 -0
  163. package/dist/ui/look.d.ts +109 -0
  164. package/dist/ui/look.js +248 -0
  165. package/dist/ui/look.js.map +1 -0
  166. package/dist/ui/mark.d.ts +11 -0
  167. package/dist/ui/mark.js +13 -0
  168. package/dist/ui/mark.js.map +1 -0
  169. package/dist/ui/moved.d.ts +121 -0
  170. package/dist/ui/moved.js +143 -0
  171. package/dist/ui/moved.js.map +1 -0
  172. package/dist/ui/navigation.d.ts +26 -0
  173. package/dist/ui/navigation.js +53 -0
  174. package/dist/ui/navigation.js.map +1 -0
  175. package/dist/ui/order.d.ts +113 -0
  176. package/dist/ui/order.js +226 -0
  177. package/dist/ui/order.js.map +1 -0
  178. package/dist/ui/origins.d.ts +42 -0
  179. package/dist/ui/origins.js +149 -0
  180. package/dist/ui/origins.js.map +1 -0
  181. package/dist/ui/outcome.d.ts +93 -0
  182. package/dist/ui/outcome.js +162 -0
  183. package/dist/ui/outcome.js.map +1 -0
  184. package/dist/ui/parted.d.ts +68 -0
  185. package/dist/ui/parted.js +101 -0
  186. package/dist/ui/parted.js.map +1 -0
  187. package/dist/ui/rail.d.ts +31 -0
  188. package/dist/ui/rail.js +21 -0
  189. package/dist/ui/rail.js.map +1 -0
  190. package/dist/ui/reach.d.ts +66 -0
  191. package/dist/ui/reach.js +185 -0
  192. package/dist/ui/reach.js.map +1 -0
  193. package/dist/ui/regions.d.ts +82 -0
  194. package/dist/ui/regions.js +67 -0
  195. package/dist/ui/regions.js.map +1 -0
  196. package/dist/ui/review.bundle.js +69 -0
  197. package/dist/ui/review.d.ts +97 -0
  198. package/dist/ui/review.js +39 -0
  199. package/dist/ui/review.js.map +1 -0
  200. package/dist/ui/root.d.ts +72 -0
  201. package/dist/ui/root.js +102 -0
  202. package/dist/ui/root.js.map +1 -0
  203. package/dist/ui/route.d.ts +95 -0
  204. package/dist/ui/route.js +114 -0
  205. package/dist/ui/route.js.map +1 -0
  206. package/dist/ui/run.d.ts +26 -0
  207. package/dist/ui/run.js +12 -0
  208. package/dist/ui/run.js.map +1 -0
  209. package/dist/ui/sense.d.ts +187 -0
  210. package/dist/ui/sense.js +222 -0
  211. package/dist/ui/sense.js.map +1 -0
  212. package/dist/ui/settled.d.ts +43 -0
  213. package/dist/ui/settled.js +59 -0
  214. package/dist/ui/settled.js.map +1 -0
  215. package/dist/ui/shell.d.ts +76 -0
  216. package/dist/ui/shell.js +65 -0
  217. package/dist/ui/shell.js.map +1 -0
  218. package/dist/ui/shift.d.ts +52 -0
  219. package/dist/ui/shift.js +98 -0
  220. package/dist/ui/shift.js.map +1 -0
  221. package/dist/ui/styles-docket.d.ts +18 -0
  222. package/dist/ui/styles-docket.js +281 -0
  223. package/dist/ui/styles-docket.js.map +1 -0
  224. package/dist/ui/styles-stage.d.ts +18 -0
  225. package/dist/ui/styles-stage.js +92 -0
  226. package/dist/ui/styles-stage.js.map +1 -0
  227. package/dist/ui/styles.d.ts +12 -0
  228. package/dist/ui/styles.js +482 -0
  229. package/dist/ui/styles.js.map +1 -0
  230. package/dist/ui/subject.d.ts +36 -0
  231. package/dist/ui/subject.js +138 -0
  232. package/dist/ui/subject.js.map +1 -0
  233. package/dist/ui/text.d.ts +129 -0
  234. package/dist/ui/text.js +164 -0
  235. package/dist/ui/text.js.map +1 -0
  236. package/dist/ui/variations.d.ts +37 -0
  237. package/dist/ui/variations.js +60 -0
  238. package/dist/ui/variations.js.map +1 -0
  239. package/dist/ui/viewer.d.ts +67 -0
  240. package/dist/ui/viewer.js +246 -0
  241. package/dist/ui/viewer.js.map +1 -0
  242. package/dist/worker-auth.d.ts +48 -0
  243. package/dist/worker-auth.js +74 -0
  244. package/dist/worker-auth.js.map +1 -0
  245. package/dist/worker-entry.d.ts +58 -0
  246. package/dist/worker-entry.js +36 -0
  247. package/dist/worker-entry.js.map +1 -0
  248. package/dist/worker-http.d.ts +39 -0
  249. package/dist/worker-http.js +111 -0
  250. package/dist/worker-http.js.map +1 -0
  251. package/dist/worker-input.d.ts +81 -0
  252. package/dist/worker-input.js +296 -0
  253. package/dist/worker-input.js.map +1 -0
  254. package/dist/worker.d.ts +91 -0
  255. package/dist/worker.js +287 -0
  256. package/dist/worker.js.map +1 -0
  257. package/mark.svg +30 -0
  258. package/migrations/0001_initial.sql +193 -0
  259. package/migrations/0002_changelog.sql +30 -0
  260. package/migrations/0003_accessibility.sql +8 -0
  261. package/migrations/0004_variations.sql +19 -0
  262. package/migrations/0005_reach.sql +26 -0
  263. package/migrations/0006_baseline-size.sql +7 -0
  264. package/migrations/0007_declarations.sql +7 -0
  265. package/migrations/0008_green-because.sql +7 -0
  266. package/migrations/0009_moved-bands.sql +6 -0
  267. package/migrations/0010_composition.sql +15 -0
  268. package/migrations/0011_movements.sql +21 -0
  269. package/migrations/0012_control-size.sql +6 -0
  270. package/package.json +110 -0
  271. package/wrangler.jsonc +45 -0
@@ -0,0 +1,248 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * Looking at a change from the card that decides it.
4
+ *
5
+ * The docket answers *what moved and why*, and then offers two buttons. That is
6
+ * one question short: a reviewer approving a restyle of `Button` across eleven
7
+ * renders has been told the name of the component, the size of the difference and
8
+ * the shape it repeats in, and has not been shown the difference. Sending them to
9
+ * the full stage for that is sending them away from the decision — a route
10
+ * capture is nine thousand pixels tall, the difference is a hundred and thirty
11
+ * across, and finding it is a scroll they perform once per subject.
12
+ *
13
+ * So the change is brought to the card, cropped to where the run measured it.
14
+ *
15
+ * ## One crop per shape, not one per place
16
+ *
17
+ * The card's own argument is *one edit, N places*. A strip of eleven thumbnails
18
+ * would contradict it in the same breath as making it, and would ask the browser
19
+ * to decode eleven full-page captures to say a thing the fingerprints already
20
+ * said. What is drawn instead is one crop per distinct shape, which is exactly
21
+ * the set the card claims is redundant — if two of them look different, the claim
22
+ * was wrong and the reviewer can see that it was.
23
+ *
24
+ * An appearance whose run recorded no shape is its own crop. Absent is not equal:
25
+ * two differences nothing characterised have not been shown to match, and folding
26
+ * them together would hide one behind a picture of the other.
27
+ *
28
+ * ## The difference is the first layer
29
+ *
30
+ * The strip opens on the mask the comparison drew. A crop of the candidate is a
31
+ * picture of the component, and a reviewer reading one against a baseline they
32
+ * remember is redoing by eye the comparison the run already did to the pixel. The
33
+ * two readings are one press behind it and stay mounted once asked for, so moving
34
+ * between the three is a repaint rather than a fetch.
35
+ *
36
+ * ## Deferred by the window, not by a button
37
+ *
38
+ * A route candidate here is 1280 × 9000 and costs about 46MB of bitmap to decode,
39
+ * and a change with six shapes asks for six of them. That cost is why the strip
40
+ * used to sit behind a *look at the change* button — which priced every difference
41
+ * on the page at a click, and there is no reviewer who does not want to see the
42
+ * difference. It also capped the strip at three, so a change with six shapes drew
43
+ * half of itself and counted the rest.
44
+ *
45
+ * So the deferral moved to where the cost is. Each crop asks for its raster when
46
+ * its own window comes near the viewport, and only for the layer being shown.
47
+ * `loading="lazy"` cannot do that job here: the plate is the whole capture
48
+ * translated so the region lands in a 264px window, which on a mobile route is a
49
+ * 17,000px offset — the browser sees a box far outside the viewport and defers it
50
+ * forever, leaving a red ring over an empty frame. What is watched instead is the
51
+ * window, which is 264 × 176 and sitting exactly where the reviewer is looking.
52
+ */
53
+ import { useCallback, useState } from 'react';
54
+ import { leadOf } from './lead.js';
55
+ import { count, number } from './text.js';
56
+ import { share, union } from './viewer.js';
57
+ /**
58
+ * The crop window, in CSS pixels.
59
+ *
60
+ * Fixed, and the same for every crop in a strip. Sizing each window to its own
61
+ * region would make the strip a bar chart of region areas, which is the ranking
62
+ * by area this whole surface exists to refuse — and it would do it in the one
63
+ * place a reviewer reads as *how big is this change*.
64
+ */
65
+ const WINDOW = { width: 264, height: 176 };
66
+ /** Source pixels kept around the region, so the difference has a page to be on. */
67
+ const PAD = 20;
68
+ /**
69
+ * How far the crop may be pushed, in either direction.
70
+ *
71
+ * Below 1:1 a two-pixel shift is handed to the resampler, which is the whole
72
+ * reason the sheet refuses smoothing — but a region larger than the window has to
73
+ * come down or it is not a picture of anything, so the floor is a quarter rather
74
+ * than one, and {@link Frame.whole} says which of the two happened.
75
+ */
76
+ const FURTHEST = 0.25;
77
+ const CLOSEST = 8;
78
+ const LAYERS = ['diff', 'after', 'before'];
79
+ const SAY = {
80
+ diff: 'difference',
81
+ after: 'this build',
82
+ before: 'baseline',
83
+ };
84
+ /**
85
+ * The magnification and offset that put one region in the middle of the window.
86
+ *
87
+ * The clamp on the second axis is what keeps the crop a picture rather than a
88
+ * coordinate: a region near the top of a capture, centred honestly, would put
89
+ * half the window above the first row of the image, and a reviewer would be
90
+ * looking at a difference with a band of nothing over it. So the plate is allowed
91
+ * to be off-centre when the region is near an edge, and is centred outright when
92
+ * the whole capture is smaller than the window — which is most stories.
93
+ */
94
+ export function frameOf(region, box, window) {
95
+ const wanted = Math.min(window.width / (region.width + PAD * 2), window.height / (region.height + PAD * 2));
96
+ const scale = Math.min(CLOSEST, Math.max(FURTHEST, wanted));
97
+ return {
98
+ scale,
99
+ left: place(region.x + region.width / 2, box.width, window.width, scale),
100
+ top: place(region.y + region.height / 2, box.height, window.height, scale),
101
+ whole: region.width * scale <= window.width && region.height * scale <= window.height,
102
+ };
103
+ }
104
+ /** One axis of {@link frameOf}: centre on the region, then refuse to leave the plate. */
105
+ function place(centre, extent, window, scale) {
106
+ const drawn = extent * scale;
107
+ if (drawn <= window)
108
+ return (window - drawn) / 2;
109
+ return Math.min(0, Math.max(window - drawn, window / 2 - centre * scale));
110
+ }
111
+ /**
112
+ * Which of the appearances get a picture.
113
+ *
114
+ * Report order within a shape, so the crop shown is the one the docket lists
115
+ * first rather than the biggest — the same rule the leading cause is chosen by,
116
+ * and for the same reason: largest-first names the container that reflowed.
117
+ */
118
+ export function sightings(appearances) {
119
+ const seen = new Set();
120
+ const found = [];
121
+ for (const appearance of appearances) {
122
+ const at = leadOf(appearance.subject);
123
+ if (at === undefined)
124
+ continue;
125
+ if (appearance.shape !== undefined) {
126
+ if (seen.has(appearance.shape))
127
+ continue;
128
+ seen.add(appearance.shape);
129
+ }
130
+ found.push({
131
+ appearance,
132
+ at,
133
+ shared: appearance.shape === undefined
134
+ ? 1
135
+ : appearances.filter((each) => each.shape === appearance.shape).length,
136
+ });
137
+ }
138
+ return found;
139
+ }
140
+ /**
141
+ * The strip, and the one control on it.
142
+ *
143
+ * The switch is card-level rather than per crop, because the question a reviewer
144
+ * asks is *did all of these move the same way* and answering it means moving them
145
+ * together. A layer that has been asked for once stays mounted and switches
146
+ * opacity, so returning to it does not go through a frame of nothing — a flash
147
+ * reads as a third state, and on a difference of twenty pixels it is the loudest
148
+ * thing on screen.
149
+ */
150
+ export function Look({ client, build, component, appearances, onOpen, }) {
151
+ // Every layer ever asked for, most recent first. A list rather than one
152
+ // selection: what is showing and what is loaded are different questions, and
153
+ // unmounting the layer somebody just left is how a flip becomes a fetch.
154
+ const [asked, setAsked] = useState([]);
155
+ const found = sightings(appearances).filter(({ appearance }) => LAYERS.some((layer) => appearance.subject.has[layer]));
156
+ const offered = LAYERS.filter((layer) => found.some(({ appearance }) => appearance.subject.has[layer]));
157
+ const first = offered[0];
158
+ if (first === undefined)
159
+ return null;
160
+ const showing = asked[0] ?? first;
161
+ const mounted = new Set([first, ...asked]);
162
+ const look = (layer) => setAsked((was) => [layer, ...was.filter((each) => each !== layer)]);
163
+ return (_jsxs(_Fragment, { children: [offered.length === 1 ? null : (_jsx("p", { className: "va-origin-look", children: _jsx("span", { className: "va-modes", children: offered.map((layer) => (_jsx("button", { type: "button", className: layer === showing ? 'va-mode va-current' : 'va-mode', "aria-pressed": layer === showing, onClick: () => look(layer), children: SAY[layer] }, layer))) }) })), _jsx("div", { className: "va-look", children: found.map((sighting) => (_jsx(Sighted, { client: client, build: build, component: component, sighting: sighting, showing: showing, mounted: mounted, onOpen: onOpen }, sighting.appearance.subject.subject))) })] }));
164
+ }
165
+ /** One crop, its caption, and the click that opens the whole render. */
166
+ function Sighted({ client, build, component, sighting, showing, mounted, onOpen, }) {
167
+ const { appearance, at, shared } = sighting;
168
+ const subject = appearance.subject;
169
+ const body = (_jsx(Crop, { client: client, build: build, subject: subject, at: at, showing: showing, mounted: mounted }));
170
+ const caption = (_jsxs("span", { className: "va-sight-note", children: [subject.subject, " \u00B7 ", number(appearance.pixels), " px", shared === 1 ? '' : ` · ${count(shared, 'place')}`] }));
171
+ if (onOpen === undefined) {
172
+ return (_jsxs("span", { className: "va-sight", children: [body, caption] }));
173
+ }
174
+ return (_jsxs("button", { type: "button", className: "va-sight", onClick: () => onOpen(subject.subject), title: `Open ${subject.subject} — ${component} at ${number(at.x)}, ${number(at.y)}`, children: [body, caption] }));
175
+ }
176
+ /**
177
+ * Whether this crop's window has come near the viewport.
178
+ *
179
+ * A renderer without the observer is told yes at once. A deferral nothing can
180
+ * lift is a permanently empty frame, which is the exact failure `loading="lazy"`
181
+ * had here — and a static render has no scroll to wait for.
182
+ */
183
+ function useSeen() {
184
+ const [seen, setSeen] = useState(() => typeof IntersectionObserver === 'undefined');
185
+ const watch = useCallback((node) => {
186
+ if (node === null || typeof IntersectionObserver === 'undefined')
187
+ return undefined;
188
+ const observer = new IntersectionObserver((entries) => {
189
+ if (entries.some((entry) => entry.isIntersecting))
190
+ setSeen(true);
191
+ },
192
+ // A screen ahead of the scroll, so the crop is decoded by the time it is
193
+ // reached rather than arriving after it.
194
+ { rootMargin: '600px' });
195
+ observer.observe(node);
196
+ return () => observer.disconnect();
197
+ }, []);
198
+ return [watch, seen];
199
+ }
200
+ /**
201
+ * The readings that have been asked for, at one magnification, region ringed.
202
+ *
203
+ * The layers are placed exactly as the stage places them — each at its own share
204
+ * of the union box — because that is the one part of this that is easy to get
205
+ * wrong invisibly. A baseline stretched to the candidate's width turns a width
206
+ * change into a hairline, and at 3× on a 130-pixel region the hairline is off
207
+ * screen entirely. The mask is the exception and needs no share: the comparison
208
+ * drew it on the box it padded both captures to.
209
+ */
210
+ function Crop({ client, build, subject, at, showing, mounted, }) {
211
+ const [watch, seen] = useSeen();
212
+ const box = union(subject.size, subject.baseline);
213
+ if (box === undefined) {
214
+ return (_jsx("span", { className: "va-crop va-crop-unplaced", style: boxOf(WINDOW), children: _jsx("span", { className: "va-note", children: "This run did not record the capture\u2019s size." }) }));
215
+ }
216
+ // Said rather than drawn empty. The switch is card-level, so a render the run
217
+ // kept no baseline for is asked for one, and a blank window under a red ring
218
+ // reads as a difference nobody can see rather than an image nobody kept.
219
+ if (!subject.has[showing]) {
220
+ return (_jsx("span", { className: "va-crop va-crop-unplaced", style: boxOf(WINDOW), children: _jsxs("span", { className: "va-note", children: ["No ", SAY[showing], " was kept for this render."] }) }));
221
+ }
222
+ const frame = frameOf(at, box, WINDOW);
223
+ const plate = {
224
+ height: `${String(box.height * frame.scale)}px`,
225
+ left: `${String(frame.left)}px`,
226
+ top: `${String(frame.top)}px`,
227
+ width: `${String(box.width * frame.scale)}px`,
228
+ };
229
+ return (_jsxs("span", { className: "va-crop", style: boxOf(WINDOW), ref: watch, children: [_jsxs("span", { className: "va-crop-plate", style: plate, children: [seen
230
+ ? LAYERS.filter((layer) => mounted.has(layer) && subject.has[layer]).map((layer) => (_jsx("img", { src: client.imageUrl(build, subject.subject, layer), alt: "", decoding: "async", style: { ...spread(layer, subject, box), opacity: layer === showing ? 1 : 0 } }, layer)))
231
+ : null, _jsx("span", { className: "va-crop-box", style: {
232
+ height: `${String((at.height / box.height) * 100)}%`,
233
+ left: `${String((at.x / box.width) * 100)}%`,
234
+ top: `${String((at.y / box.height) * 100)}%`,
235
+ width: `${String((at.width / box.width) * 100)}%`,
236
+ } })] }), frame.whole ? null : _jsx("span", { className: "va-crop-part", children: "part of it" })] }));
237
+ }
238
+ /** One layer's share of the plate. The mask already spans it; the readings may not. */
239
+ function spread(layer, subject, box) {
240
+ if (layer === 'diff')
241
+ return undefined;
242
+ return share(layer === 'after' ? subject.size : subject.baseline, box);
243
+ }
244
+ /** The window's own dimensions, written where a stylesheet cannot know them. */
245
+ function boxOf(window) {
246
+ return { height: `${String(window.height)}px`, width: `${String(window.width)}px` };
247
+ }
248
+ //# sourceMappingURL=look.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"look.js","sourceRoot":"","sources":["../../src/ui/look.tsx"],"names":[],"mappings":"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","sourcesContent":["/**\n * Looking at a change from the card that decides it.\n *\n * The docket answers *what moved and why*, and then offers two buttons. That is\n * one question short: a reviewer approving a restyle of `Button` across eleven\n * renders has been told the name of the component, the size of the difference and\n * the shape it repeats in, and has not been shown the difference. Sending them to\n * the full stage for that is sending them away from the decision — a route\n * capture is nine thousand pixels tall, the difference is a hundred and thirty\n * across, and finding it is a scroll they perform once per subject.\n *\n * So the change is brought to the card, cropped to where the run measured it.\n *\n * ## One crop per shape, not one per place\n *\n * The card's own argument is *one edit, N places*. A strip of eleven thumbnails\n * would contradict it in the same breath as making it, and would ask the browser\n * to decode eleven full-page captures to say a thing the fingerprints already\n * said. What is drawn instead is one crop per distinct shape, which is exactly\n * the set the card claims is redundant — if two of them look different, the claim\n * was wrong and the reviewer can see that it was.\n *\n * An appearance whose run recorded no shape is its own crop. Absent is not equal:\n * two differences nothing characterised have not been shown to match, and folding\n * them together would hide one behind a picture of the other.\n *\n * ## The difference is the first layer\n *\n * The strip opens on the mask the comparison drew. A crop of the candidate is a\n * picture of the component, and a reviewer reading one against a baseline they\n * remember is redoing by eye the comparison the run already did to the pixel. The\n * two readings are one press behind it and stay mounted once asked for, so moving\n * between the three is a repaint rather than a fetch.\n *\n * ## Deferred by the window, not by a button\n *\n * A route candidate here is 1280 × 9000 and costs about 46MB of bitmap to decode,\n * and a change with six shapes asks for six of them. That cost is why the strip\n * used to sit behind a *look at the change* button — which priced every difference\n * on the page at a click, and there is no reviewer who does not want to see the\n * difference. It also capped the strip at three, so a change with six shapes drew\n * half of itself and counted the rest.\n *\n * So the deferral moved to where the cost is. Each crop asks for its raster when\n * its own window comes near the viewport, and only for the layer being shown.\n * `loading=\"lazy\"` cannot do that job here: the plate is the whole capture\n * translated so the region lands in a 264px window, which on a mobile route is a\n * 17,000px offset — the browser sees a box far outside the viewport and defers it\n * forever, leaving a red ring over an empty frame. What is watched instead is the\n * window, which is 264 × 176 and sitting exactly where the reviewer is looking.\n */\n\nimport { useCallback, useState, type CSSProperties, type ReactElement } from 'react';\nimport type { RegionRecord } from '@variance-authority/report';\nimport type { ReviewClient } from './client.js';\nimport type { Appearance } from './grouping.js';\nimport { leadOf } from './lead.js';\nimport { count, number } from './text.js';\nimport { share, union, type Size } from './viewer.js';\n\n/**\n * The crop window, in CSS pixels.\n *\n * Fixed, and the same for every crop in a strip. Sizing each window to its own\n * region would make the strip a bar chart of region areas, which is the ranking\n * by area this whole surface exists to refuse — and it would do it in the one\n * place a reviewer reads as *how big is this change*.\n */\nconst WINDOW: Size = { width: 264, height: 176 };\n\n/** Source pixels kept around the region, so the difference has a page to be on. */\nconst PAD = 20;\n\n/**\n * How far the crop may be pushed, in either direction.\n *\n * Below 1:1 a two-pixel shift is handed to the resampler, which is the whole\n * reason the sheet refuses smoothing — but a region larger than the window has to\n * come down or it is not a picture of anything, so the floor is a quarter rather\n * than one, and {@link Frame.whole} says which of the two happened.\n */\nconst FURTHEST = 0.25;\nconst CLOSEST = 8;\n\n/**\n * The three readings, in the order they are offered and preferred.\n *\n * `diff` first because it is the answer rather than the evidence for it. The\n * names are the stage's, so a reviewer who opens the full render finds the same\n * three words on the same three things.\n */\ntype Layer = 'diff' | 'after' | 'before';\n\nconst LAYERS: readonly Layer[] = ['diff', 'after', 'before'];\n\nconst SAY: Readonly<Record<Layer, string>> = {\n diff: 'difference',\n after: 'this build',\n before: 'baseline',\n};\n\n/** Where the plate sits inside the window, and at what magnification. */\nexport interface Frame {\n readonly scale: number;\n /** The plate's offset inside the window, in CSS pixels. Usually negative. */\n readonly left: number;\n readonly top: number;\n /** `false` when the region is bigger than the window even pushed all the way out. */\n readonly whole: boolean;\n}\n\n/**\n * The magnification and offset that put one region in the middle of the window.\n *\n * The clamp on the second axis is what keeps the crop a picture rather than a\n * coordinate: a region near the top of a capture, centred honestly, would put\n * half the window above the first row of the image, and a reviewer would be\n * looking at a difference with a band of nothing over it. So the plate is allowed\n * to be off-centre when the region is near an edge, and is centred outright when\n * the whole capture is smaller than the window — which is most stories.\n */\nexport function frameOf(region: RegionRecord, box: Size, window: Size): Frame {\n const wanted = Math.min(\n window.width / (region.width + PAD * 2),\n window.height / (region.height + PAD * 2),\n );\n const scale = Math.min(CLOSEST, Math.max(FURTHEST, wanted));\n\n return {\n scale,\n left: place(region.x + region.width / 2, box.width, window.width, scale),\n top: place(region.y + region.height / 2, box.height, window.height, scale),\n whole: region.width * scale <= window.width && region.height * scale <= window.height,\n };\n}\n\n/** One axis of {@link frameOf}: centre on the region, then refuse to leave the plate. */\nfunction place(centre: number, extent: number, window: number, scale: number): number {\n const drawn = extent * scale;\n if (drawn <= window) return (window - drawn) / 2;\n return Math.min(0, Math.max(window - drawn, window / 2 - centre * scale));\n}\n\n/** One difference worth drawing, and how many places carry the same one. */\nexport interface Sighting {\n readonly appearance: Appearance;\n readonly at: RegionRecord;\n /** Places carrying this exact shape. `1` when the run recorded none. */\n readonly shared: number;\n}\n\n/**\n * Which of the appearances get a picture.\n *\n * Report order within a shape, so the crop shown is the one the docket lists\n * first rather than the biggest — the same rule the leading cause is chosen by,\n * and for the same reason: largest-first names the container that reflowed.\n */\nexport function sightings(appearances: readonly Appearance[]): readonly Sighting[] {\n const seen = new Set<string>();\n const found: Sighting[] = [];\n\n for (const appearance of appearances) {\n const at = leadOf(appearance.subject);\n if (at === undefined) continue;\n if (appearance.shape !== undefined) {\n if (seen.has(appearance.shape)) continue;\n seen.add(appearance.shape);\n }\n found.push({\n appearance,\n at,\n shared:\n appearance.shape === undefined\n ? 1\n : appearances.filter((each) => each.shape === appearance.shape).length,\n });\n }\n\n return found;\n}\n\n/**\n * The strip, and the one control on it.\n *\n * The switch is card-level rather than per crop, because the question a reviewer\n * asks is *did all of these move the same way* and answering it means moving them\n * together. A layer that has been asked for once stays mounted and switches\n * opacity, so returning to it does not go through a frame of nothing — a flash\n * reads as a third state, and on a difference of twenty pixels it is the loudest\n * thing on screen.\n */\nexport function Look({\n client,\n build,\n component,\n appearances,\n onOpen,\n}: {\n readonly client: ReviewClient;\n readonly build: string;\n readonly component: string;\n readonly appearances: readonly Appearance[];\n readonly onOpen?: ((subject: string) => void) | undefined;\n}): ReactElement | null {\n // Every layer ever asked for, most recent first. A list rather than one\n // selection: what is showing and what is loaded are different questions, and\n // unmounting the layer somebody just left is how a flip becomes a fetch.\n const [asked, setAsked] = useState<readonly Layer[]>([]);\n\n const found = sightings(appearances).filter(({ appearance }) =>\n LAYERS.some((layer) => appearance.subject.has[layer]),\n );\n\n const offered = LAYERS.filter((layer) =>\n found.some(({ appearance }) => appearance.subject.has[layer]),\n );\n const first = offered[0];\n if (first === undefined) return null;\n\n const showing = asked[0] ?? first;\n const mounted = new Set<Layer>([first, ...asked]);\n const look = (layer: Layer): void =>\n setAsked((was) => [layer, ...was.filter((each) => each !== layer)]);\n\n return (\n <>\n {offered.length === 1 ? null : (\n <p className=\"va-origin-look\">\n <span className=\"va-modes\">\n {offered.map((layer) => (\n <button\n key={layer}\n type=\"button\"\n className={layer === showing ? 'va-mode va-current' : 'va-mode'}\n aria-pressed={layer === showing}\n onClick={() => look(layer)}\n >\n {SAY[layer]}\n </button>\n ))}\n </span>\n </p>\n )}\n\n <div className=\"va-look\">\n {found.map((sighting) => (\n <Sighted\n key={sighting.appearance.subject.subject}\n client={client}\n build={build}\n component={component}\n sighting={sighting}\n showing={showing}\n mounted={mounted}\n onOpen={onOpen}\n />\n ))}\n </div>\n </>\n );\n}\n\n/** One crop, its caption, and the click that opens the whole render. */\nfunction Sighted({\n client,\n build,\n component,\n sighting,\n showing,\n mounted,\n onOpen,\n}: {\n readonly client: ReviewClient;\n readonly build: string;\n readonly component: string;\n readonly sighting: Sighting;\n readonly showing: Layer;\n readonly mounted: ReadonlySet<Layer>;\n readonly onOpen?: ((subject: string) => void) | undefined;\n}): ReactElement {\n const { appearance, at, shared } = sighting;\n const subject = appearance.subject;\n\n const body = (\n <Crop\n client={client}\n build={build}\n subject={subject}\n at={at}\n showing={showing}\n mounted={mounted}\n />\n );\n\n const caption = (\n <span className=\"va-sight-note\">\n {subject.subject} · {number(appearance.pixels)} px\n {shared === 1 ? '' : ` · ${count(shared, 'place')}`}\n </span>\n );\n\n if (onOpen === undefined) {\n return (\n <span className=\"va-sight\">\n {body}\n {caption}\n </span>\n );\n }\n\n return (\n <button\n type=\"button\"\n className=\"va-sight\"\n onClick={() => onOpen(subject.subject)}\n title={`Open ${subject.subject} — ${component} at ${number(at.x)}, ${number(at.y)}`}\n >\n {body}\n {caption}\n </button>\n );\n}\n\n/**\n * Whether this crop's window has come near the viewport.\n *\n * A renderer without the observer is told yes at once. A deferral nothing can\n * lift is a permanently empty frame, which is the exact failure `loading=\"lazy\"`\n * had here — and a static render has no scroll to wait for.\n */\nfunction useSeen(): readonly [(node: HTMLSpanElement | null) => (() => void) | undefined, boolean] {\n const [seen, setSeen] = useState(() => typeof IntersectionObserver === 'undefined');\n\n const watch = useCallback((node: HTMLSpanElement | null) => {\n if (node === null || typeof IntersectionObserver === 'undefined') return undefined;\n const observer = new IntersectionObserver(\n (entries) => {\n if (entries.some((entry) => entry.isIntersecting)) setSeen(true);\n },\n // A screen ahead of the scroll, so the crop is decoded by the time it is\n // reached rather than arriving after it.\n { rootMargin: '600px' },\n );\n observer.observe(node);\n return () => observer.disconnect();\n }, []);\n\n return [watch, seen];\n}\n\n/**\n * The readings that have been asked for, at one magnification, region ringed.\n *\n * The layers are placed exactly as the stage places them — each at its own share\n * of the union box — because that is the one part of this that is easy to get\n * wrong invisibly. A baseline stretched to the candidate's width turns a width\n * change into a hairline, and at 3× on a 130-pixel region the hairline is off\n * screen entirely. The mask is the exception and needs no share: the comparison\n * drew it on the box it padded both captures to.\n */\nfunction Crop({\n client,\n build,\n subject,\n at,\n showing,\n mounted,\n}: {\n readonly client: ReviewClient;\n readonly build: string;\n readonly subject: Appearance['subject'];\n readonly at: RegionRecord;\n readonly showing: Layer;\n readonly mounted: ReadonlySet<Layer>;\n}): ReactElement {\n const [watch, seen] = useSeen();\n const box = union(subject.size, subject.baseline);\n\n if (box === undefined) {\n return (\n <span className=\"va-crop va-crop-unplaced\" style={boxOf(WINDOW)}>\n <span className=\"va-note\">This run did not record the capture&rsquo;s size.</span>\n </span>\n );\n }\n\n // Said rather than drawn empty. The switch is card-level, so a render the run\n // kept no baseline for is asked for one, and a blank window under a red ring\n // reads as a difference nobody can see rather than an image nobody kept.\n if (!subject.has[showing]) {\n return (\n <span className=\"va-crop va-crop-unplaced\" style={boxOf(WINDOW)}>\n <span className=\"va-note\">No {SAY[showing]} was kept for this render.</span>\n </span>\n );\n }\n\n const frame = frameOf(at, box, WINDOW);\n const plate: CSSProperties = {\n height: `${String(box.height * frame.scale)}px`,\n left: `${String(frame.left)}px`,\n top: `${String(frame.top)}px`,\n width: `${String(box.width * frame.scale)}px`,\n };\n\n return (\n <span className=\"va-crop\" style={boxOf(WINDOW)} ref={watch}>\n <span className=\"va-crop-plate\" style={plate}>\n {seen\n ? LAYERS.filter((layer) => mounted.has(layer) && subject.has[layer]).map((layer) => (\n <img\n key={layer}\n src={client.imageUrl(build, subject.subject, layer)}\n alt=\"\"\n decoding=\"async\"\n style={{ ...spread(layer, subject, box), opacity: layer === showing ? 1 : 0 }}\n />\n ))\n : null}\n <span\n className=\"va-crop-box\"\n style={{\n height: `${String((at.height / box.height) * 100)}%`,\n left: `${String((at.x / box.width) * 100)}%`,\n top: `${String((at.y / box.height) * 100)}%`,\n width: `${String((at.width / box.width) * 100)}%`,\n }}\n />\n </span>\n {frame.whole ? null : <span className=\"va-crop-part\">part of it</span>}\n </span>\n );\n}\n\n/** One layer's share of the plate. The mask already spans it; the readings may not. */\nfunction spread(\n layer: Layer,\n subject: Appearance['subject'],\n box: Size,\n): CSSProperties | undefined {\n if (layer === 'diff') return undefined;\n return share(layer === 'after' ? subject.size : subject.baseline, box);\n}\n\n/** The window's own dimensions, written where a stylesheet cannot know them. */\nfunction boxOf(window: Size): CSSProperties {\n return { height: `${String(window.height)}px`, width: `${String(window.width)}px` };\n}\n"]}
@@ -0,0 +1,11 @@
1
+ import type { ReactElement } from 'react';
2
+ /**
3
+ * The wordmark's glyph: two strokes and the variance path that crosses them.
4
+ *
5
+ * The same four paths the site draws, with the two strokes taken from
6
+ * `currentColor` rather than from ivory — this surface has a second scheme, and a
7
+ * mark that names its own foreground disappears into whichever ground it did not
8
+ * expect. The orange pair is the mark and stays literal.
9
+ */
10
+ export declare function Mark(): ReactElement;
11
+ //# sourceMappingURL=mark.d.ts.map
@@ -0,0 +1,13 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * The wordmark's glyph: two strokes and the variance path that crosses them.
4
+ *
5
+ * The same four paths the site draws, with the two strokes taken from
6
+ * `currentColor` rather than from ivory — this surface has a second scheme, and a
7
+ * mark that names its own foreground disappears into whichever ground it did not
8
+ * expect. The orange pair is the mark and stays literal.
9
+ */
10
+ export function Mark() {
11
+ return (_jsxs("svg", { className: "va-brand", viewBox: "0 0 512 320", width: "34", height: "21", "aria-hidden": "true", children: [_jsx("path", { fill: "currentColor", d: "M64 54H159L256 266H160Z" }), _jsx("path", { fill: "currentColor", d: "M331 28H419L494 266H397Z" }), _jsx("path", { fill: "#ff4a19", d: "M256 266L202 152L283 28H376L301 165Z" }), _jsx("path", { fill: "#d83a13", d: "M202 152L256 266L301 165L264 103Z" })] }));
12
+ }
13
+ //# sourceMappingURL=mark.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"mark.js","sourceRoot":"","sources":["../../src/ui/mark.tsx"],"names":[],"mappings":";AAEA;;;;;;;GAOG;AACH,MAAM,UAAU,IAAI;IAClB,OAAO,CACL,eAAK,SAAS,EAAC,UAAU,EAAC,OAAO,EAAC,aAAa,EAAC,KAAK,EAAC,IAAI,EAAC,MAAM,EAAC,IAAI,iBAAa,MAAM,aACvF,eAAM,IAAI,EAAC,cAAc,EAAC,CAAC,EAAC,yBAAyB,GAAG,EACxD,eAAM,IAAI,EAAC,cAAc,EAAC,CAAC,EAAC,0BAA0B,GAAG,EACzD,eAAM,IAAI,EAAC,SAAS,EAAC,CAAC,EAAC,sCAAsC,GAAG,EAChE,eAAM,IAAI,EAAC,SAAS,EAAC,CAAC,EAAC,mCAAmC,GAAG,IACzD,CACP,CAAC;AACJ,CAAC","sourcesContent":["import type { ReactElement } from 'react';\n\n/**\n * The wordmark's glyph: two strokes and the variance path that crosses them.\n *\n * The same four paths the site draws, with the two strokes taken from\n * `currentColor` rather than from ivory — this surface has a second scheme, and a\n * mark that names its own foreground disappears into whichever ground it did not\n * expect. The orange pair is the mark and stays literal.\n */\nexport function Mark(): ReactElement {\n return (\n <svg className=\"va-brand\" viewBox=\"0 0 512 320\" width=\"34\" height=\"21\" aria-hidden=\"true\">\n <path fill=\"currentColor\" d=\"M64 54H159L256 266H160Z\" />\n <path fill=\"currentColor\" d=\"M331 28H419L494 266H397Z\" />\n <path fill=\"#ff4a19\" d=\"M256 266L202 152L283 28H376L301 165Z\" />\n <path fill=\"#d83a13\" d=\"M202 152L256 266L301 165L264 103Z\" />\n </svg>\n );\n}\n"]}
@@ -0,0 +1,121 @@
1
+ /**
2
+ * The sentence the page was missing: what changed, rather than how much of it.
3
+ *
4
+ * Every other line on a change page is a fact *about* the difference — how many
5
+ * renders carry it, whether the commit reaches it, whether the last build showed
6
+ * it already. None of them is the difference. A reviewer asked to approve
7
+ * something has to be told what it is, and *2 distinct differences, in 7 renders,
8
+ * 30,373 px in total* tells them what was measured instead.
9
+ *
10
+ * [`sense.ts`](./sense.ts) does the reading. This draws the two halves of it, and
11
+ * the second half is the one worth defending: a component the hashes say moved
12
+ * and no region names is not a rendering detail, it is the picture failing on
13
+ * exactly the change that matters most. An edit that reflows its neighbours
14
+ * merges into one blob, the blob fits no component, and the name that comes back
15
+ * is the document root — so the change with the widest consequence is the one
16
+ * most likely to arrive anonymous. It is printed here as a finding rather than
17
+ * left as a silence, because until it is, the page is confidently describing a
18
+ * subject it never mentioned.
19
+ */
20
+ import type { ReactElement } from 'react';
21
+ import type { SubjectView } from '../review-types.js';
22
+ import { type Ruler } from './distance.js';
23
+ import type { Handed } from './handed.js';
24
+ import type { Route } from './route.js';
25
+ import { type Across, type Elsewhere } from './sense.js';
26
+ /**
27
+ * The lead sentence: what this change is, before anything about its size.
28
+ *
29
+ * It goes first because it is the only line on the page a reviewer cannot decide
30
+ * without. Nothing at all when no render here compared hashes — a page whose
31
+ * loudest line said the baseline format is old would be spending it on a fact
32
+ * about the store rather than about the change.
33
+ */
34
+ export declare function MovedLead({ component, across, handed, }: {
35
+ readonly component: string;
36
+ readonly across: Across;
37
+ /** Bands a parent owns, subtracted here and named by {@link HandedTo}. */
38
+ readonly handed: readonly Handed[];
39
+ }): ReactElement | null;
40
+ /**
41
+ * The bands this component was handed, each under the name that owns it.
42
+ *
43
+ * Directly under the lead, because it is the other half of the same sentence:
44
+ * the lead now says less than the hashes did, and this is where the rest went.
45
+ * The count is the evidence and not decoration — *moved in the 6 renders
46
+ * ProductCard draws, still in the other 10* is the partition [`handed.ts`](./handed.ts)
47
+ * found, and it is the whole reason the band is filed here rather than on the
48
+ * component's own line.
49
+ *
50
+ * The holder is the link when the docket has a page for it. A reviewer reading
51
+ * *what it announces is `ProductCard`’s* has exactly one next move.
52
+ */
53
+ export declare function HandedTo({ handed, build, changes, go, }: {
54
+ readonly handed: readonly Handed[];
55
+ readonly build: string;
56
+ /** Components with a change page of their own, so a link goes somewhere. */
57
+ readonly changes: ReadonlySet<string>;
58
+ readonly go: (route: Route) => void;
59
+ }): ReactElement | null;
60
+ /**
61
+ * The rest of the reading: what the picture lost, and what moved beside it.
62
+ *
63
+ * Separate from {@link MovedLead} because it is detail and the lead is not, and
64
+ * it renders nothing at all when there is no detail to give. A heading over a
65
+ * sentence saying everything was ordinary is how a reader learns to skip the
66
+ * place the exceptions will appear.
67
+ */
68
+ export declare function WhatMoved({ component, across, far, except, }: {
69
+ readonly component: string;
70
+ readonly across: Across;
71
+ /** How far each passenger is from this change, when a diff was read. */
72
+ readonly far?: Ruler | undefined;
73
+ /**
74
+ * Names a section above already accounted for, left out of the list here.
75
+ *
76
+ * A consumer that reflowed is in both piles by construction — it draws this
77
+ * component, and it moved in the same renders. Above it is placed against the
78
+ * ones that held still, which is what makes it readable; here it would be a
79
+ * name in an alphabetised list with a band beside it, saying the same thing
80
+ * with the comparison removed.
81
+ */
82
+ readonly except?: ReadonlySet<string> | undefined;
83
+ }): ReactElement | null;
84
+ /**
85
+ * The same reading for one render, where the join is worth showing in full.
86
+ *
87
+ * A change page folds the renders together and answers *what did this component
88
+ * do*. Here both tiers are in front of the reviewer at once, so the useful shape
89
+ * is the disagreement: which names the hashes and the regions agree on, which the
90
+ * hashes hold alone, and which the regions hold alone. The third pile is the one
91
+ * a reader is most likely to get backwards — a component a region named while its
92
+ * own hashes held still was pushed, and calling that a change is the ranking this
93
+ * project exists to correct.
94
+ */
95
+ export declare function MovedHere({ subject, anchor, far, }: {
96
+ readonly subject: SubjectView;
97
+ /** The change this render is filed under, which `far` measures from. */
98
+ readonly anchor?: string | undefined;
99
+ readonly far?: Ruler | undefined;
100
+ }): ReactElement;
101
+ /**
102
+ * The renders this change moved in that the docket files under another name.
103
+ *
104
+ * The gap between the two tiers, printed. A subject belongs to whichever
105
+ * component its leading *region* resolved to, and a commit that edits a button
106
+ * and a price string in the same card files every card under whichever of the two
107
+ * drew the larger box — so the button's page can list seven renders of the twelve
108
+ * its hashes moved in, and be silent about five.
109
+ *
110
+ * Silent is the problem. Approving here decides the renders here; the five stay
111
+ * open under a name nobody looking for this change would open. So they are named,
112
+ * with the change they were filed under, and with the one sentence that keeps a
113
+ * reviewer from assuming the button is finished.
114
+ */
115
+ export declare function MovedElsewhere({ component, found, build, go, }: {
116
+ readonly component: string;
117
+ readonly found: readonly Elsewhere[];
118
+ readonly build: string;
119
+ readonly go: (route: Route) => void;
120
+ }): ReactElement | null;
121
+ //# sourceMappingURL=moved.d.ts.map
@@ -0,0 +1,143 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { howFar, placed } from './distance.js';
3
+ import { senseOf, senseOfSubject, senses, } from './sense.js';
4
+ import { Go } from './shell.js';
5
+ import { count } from './text.js';
6
+ /** How many render names are listed before the rest become a count. */
7
+ const NAMED = 4;
8
+ /**
9
+ * The lead sentence: what this change is, before anything about its size.
10
+ *
11
+ * It goes first because it is the only line on the page a reviewer cannot decide
12
+ * without. Nothing at all when no render here compared hashes — a page whose
13
+ * loudest line said the baseline format is old would be spending it on a fact
14
+ * about the store rather than about the change.
15
+ */
16
+ export function MovedLead({ component, across, handed, }) {
17
+ const given = new Set(handed.map((each) => each.band));
18
+ const own = across.bands.filter((band) => !given.has(band));
19
+ if (own.length > 0) {
20
+ return (_jsxs("p", { className: "va-decide-lead", children: [_jsx("strong", { children: component }), " moved in ", _jsx("em", { children: senses(own) }), "."] }));
21
+ }
22
+ if (across.measured === 0)
23
+ return null;
24
+ // Two different nothings. Everything this component moved in belongs to
25
+ // somebody above it, which the lines under this name — against a component
26
+ // whose own hashes held still in every render, which is collateral wearing a
27
+ // name because the largest differing area happened to resolve here.
28
+ return given.size > 0 ? (_jsxs("p", { className: "va-decide-lead", children: ["Nothing of ", _jsx("strong", { children: component }), "\u2019s own moved."] })) : (_jsxs("p", { className: "va-decide-lead", children: ["Nothing in ", _jsx("strong", { children: component }), "\u2019s own hashes moved: every render here was pushed by something else on the page."] }));
29
+ }
30
+ /**
31
+ * The bands this component was handed, each under the name that owns it.
32
+ *
33
+ * Directly under the lead, because it is the other half of the same sentence:
34
+ * the lead now says less than the hashes did, and this is where the rest went.
35
+ * The count is the evidence and not decoration — *moved in the 6 renders
36
+ * ProductCard draws, still in the other 10* is the partition [`handed.ts`](./handed.ts)
37
+ * found, and it is the whole reason the band is filed here rather than on the
38
+ * component's own line.
39
+ *
40
+ * The holder is the link when the docket has a page for it. A reviewer reading
41
+ * *what it announces is `ProductCard`’s* has exactly one next move.
42
+ */
43
+ export function HandedTo({ handed, build, changes, go, }) {
44
+ if (handed.length === 0)
45
+ return null;
46
+ return (_jsx(_Fragment, { children: handed.map(({ band, holders, moved, held }) => (_jsxs("p", { className: "va-handed", children: [_jsx("em", { children: senseOf(band) }), " is", ' ', holders.map((holder, index) => (_jsxs("span", { children: [index === 0 ? null : ' and ', changes.has(holder) ? (_jsx(Go, { to: { page: 'change', build, change: holder }, go: go, className: "va-handed-go", title: `The decision belongs to ${holder}, which is a change on this build`, children: holder })) : (_jsx("strong", { children: holder }))] }, holder))), "\u2019s.", ' ', _jsxs("span", { className: "va-note", children: ["Moved in ", count(moved, 'render'), " ", holders.length === 1 ? 'it draws' : 'they draw', ", still in the other ", held, "."] })] }, band))) }));
47
+ }
48
+ /**
49
+ * The rest of the reading: what the picture lost, and what moved beside it.
50
+ *
51
+ * Separate from {@link MovedLead} because it is detail and the lead is not, and
52
+ * it renders nothing at all when there is no detail to give. A heading over a
53
+ * sentence saying everything was ordinary is how a reader learns to skip the
54
+ * place the exceptions will appear.
55
+ */
56
+ export function WhatMoved({ component, across, far, except, }) {
57
+ const alongside = across.alongside.filter((other) => except?.has(other.component) !== true);
58
+ const quiet = across.missedIn.length === 0 && alongside.length === 0 && across.unmeasured.length === 0;
59
+ if (quiet)
60
+ return null;
61
+ return (_jsxs("section", { className: "va-moved", children: [_jsx("h2", { children: "What moved with it" }), across.missedIn.length === 0 ? null : (_jsxs("p", { className: "va-moved-lost", children: ["In ", count(across.missedIn.length, 'render'), " no region carries", ' ', _jsx("strong", { children: component }), " \u2014 the difference merged into a larger area and resolved elsewhere, so the picture there is not a picture of this.", ' ', _jsx(Renders, { subjects: across.missedIn })] })), alongside.length === 0 ? null : (_jsxs(_Fragment, { children: [across.missedIn.length === 0 ? null : _jsx("h3", { children: "Moving beside it" }), _jsx("ul", { className: "va-moved-with", children: alongside.map((other) => (_jsxs("li", { children: [_jsx("span", { className: "va-moved-name", children: other.component }), ' ', _jsx("span", { className: "va-note", children: senses(other.bands) }), _jsx(Far, { component: other.component, far: far })] }, other.component))) }), _jsx(Unplaced, { anchor: component, components: alongside.map((other) => other.component), far: far })] })), across.unmeasured.length === 0 ? null : (_jsxs("p", { className: "va-note", children: [count(across.unmeasured.length, 'render'), " compared against a baseline carrying no component hashes, so nothing above was measured there.", ' ', _jsx(Renders, { subjects: across.unmeasured })] }))] }));
62
+ }
63
+ /**
64
+ * The same reading for one render, where the join is worth showing in full.
65
+ *
66
+ * A change page folds the renders together and answers *what did this component
67
+ * do*. Here both tiers are in front of the reviewer at once, so the useful shape
68
+ * is the disagreement: which names the hashes and the regions agree on, which the
69
+ * hashes hold alone, and which the regions hold alone. The third pile is the one
70
+ * a reader is most likely to get backwards — a component a region named while its
71
+ * own hashes held still was pushed, and calling that a change is the ranking this
72
+ * project exists to correct.
73
+ */
74
+ export function MovedHere({ subject, anchor, far, }) {
75
+ const sense = senseOfSubject(subject);
76
+ if (!sense.measured) {
77
+ return (_jsx("p", { className: "va-note", children: "This render was compared against a baseline carrying no component hashes, so nothing says which components moved \u2014 only where the pixels differ." }));
78
+ }
79
+ if (sense.moved.length === 0) {
80
+ return (_jsx("p", { className: "va-note", children: "Both revisions were read and every component hash matched. Whatever differs here is beneath the semantic tier, or belongs to a part of the page the walk assigned no component to." }));
81
+ }
82
+ return (_jsxs(_Fragment, { children: [_jsx("ul", { className: "va-moved-list", children: sense.moved.map((entry) => (_jsx(Moved, { entry: entry, anchor: anchor, far: far }, entry.component))) }), anchor === undefined ? null : (_jsx(Unplaced, { anchor: anchor, components: sense.moved.map((entry) => entry.component), far: far })), sense.pushed.length === 0 ? null : (_jsxs("p", { className: "va-note", children: ["A region names ", sense.pushed.join(', '), ", whose own hashes held still: pushed by something else, not edited."] }))] }));
83
+ }
84
+ /** One component, its bands, and whether the picture found it. */
85
+ function Moved({ entry, anchor, far, }) {
86
+ return (_jsxs("li", { className: entry.cause ? 'va-moved-row' : 'va-moved-row va-moved-passenger', children: [_jsx("span", { className: "va-moved-name", children: entry.component }), _jsx("span", { className: "va-note", children: entry.presence === undefined ? senses(entry.bands) : entry.presence }), entry.component === anchor ? null : _jsx(Far, { component: entry.component, far: far }), entry.drawn ? null : (_jsx("span", { className: "va-mark va-alarm", title: "The hashes say this component moved, and no region in this render carries its name \u2014 the difference merged into a larger area and resolved elsewhere.", children: "no region" }))] }));
87
+ }
88
+ /** A few render names, then a count — a list of twenty is not read as evidence. */
89
+ function Renders({ subjects }) {
90
+ const named = subjects.slice(0, NAMED);
91
+ const rest = subjects.length - named.length;
92
+ return (_jsxs("span", { className: "va-note", title: subjects.join(', '), children: [named.join(', '), rest === 0 ? '' : ` and ${count(rest, 'other')}`] }));
93
+ }
94
+ /**
95
+ * The renders this change moved in that the docket files under another name.
96
+ *
97
+ * The gap between the two tiers, printed. A subject belongs to whichever
98
+ * component its leading *region* resolved to, and a commit that edits a button
99
+ * and a price string in the same card files every card under whichever of the two
100
+ * drew the larger box — so the button's page can list seven renders of the twelve
101
+ * its hashes moved in, and be silent about five.
102
+ *
103
+ * Silent is the problem. Approving here decides the renders here; the five stay
104
+ * open under a name nobody looking for this change would open. So they are named,
105
+ * with the change they were filed under, and with the one sentence that keeps a
106
+ * reviewer from assuming the button is finished.
107
+ */
108
+ export function MovedElsewhere({ component, found, build, go, }) {
109
+ if (found.length === 0)
110
+ return null;
111
+ return (_jsxs("section", { className: "va-moved", children: [_jsx("h2", { children: "Also moved, filed elsewhere" }), _jsxs("p", { className: "va-moved-lost", children: [_jsx("strong", { children: component }), "\u2019s hashes moved in ", count(found.length, 'further render'), ", where a larger difference took the name. Deciding this change does not decide them."] }), _jsx("ul", { className: "va-moved-list", children: found.map((each) => (_jsxs("li", { className: "va-moved-row", children: [_jsx(Go, { to: { page: 'subject', build, subject: each.subject }, go: go, className: "va-moved-name", children: each.subject }), _jsx("span", { className: "va-note", children: each.filedUnder === undefined ? 'no component named the cause' : `under ${each.filedUnder}` }), each.drawn ? null : (_jsx("span", { className: "va-mark va-alarm", title: "No region in that render carries this component either, so its picture is not a picture of this change.", children: "no region" }))] }, each.subject))) })] }));
112
+ }
113
+ /** How far a component is from the change, when the records place it. */
114
+ function Far({ component, far, }) {
115
+ const said = distanceOf(far, component);
116
+ if (said === undefined)
117
+ return null;
118
+ return _jsx("span", { className: "va-moved-far", children: said });
119
+ }
120
+ function distanceOf(far, component) {
121
+ const distance = far?.(component);
122
+ return distance === undefined ? undefined : howFar(distance);
123
+ }
124
+ /**
125
+ * What the list could not place, said once instead of on every row.
126
+ *
127
+ * A reader who sees a distance on two rows and nothing on the other seven will
128
+ * read the silence as *near*, which is the one thing it does not mean. So the
129
+ * absence is stated — as a fact about the graph, not about the components, and
130
+ * without claiming they are unrelated. Nothing at all when the run carried no
131
+ * diff: `unknown` is the whole page's condition and the page says it elsewhere.
132
+ */
133
+ function Unplaced({ anchor, components, far, }) {
134
+ if (far === undefined)
135
+ return null;
136
+ const distances = components.map((component) => far(component));
137
+ const lost = distances.filter((distance) => distance.kind === 'unreached').length;
138
+ if (lost === 0)
139
+ return null;
140
+ const some = distances.some((distance) => placed(distance));
141
+ return (_jsxs("p", { className: "va-note", children: ["Nothing the commit walked arrives at ", some ? count(lost, 'other') : 'any of these', ", and no file is recorded for them either, so how far they are from ", _jsx("strong", { children: anchor }), " is not something this build measured."] }));
142
+ }
143
+ //# sourceMappingURL=moved.js.map