@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 @@
1
+ {"version":3,"file":"styles.js","sourceRoot":"","sources":["../../src/ui/styles.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAyCG;AACH,OAAO,EAAE,aAAa,EAAE,MAAM,oBAAoB,CAAC;AACnD,OAAO,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAC;AAEjD;;;;;;;;;GASG;AACH,MAAM,CAAC,MAAM,aAAa,GAAG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAyT3B,YAAY;EACZ,aAAa;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAgHd,CAAC","sourcesContent":["/**\n * The stylesheet, as a string the operator injects.\n *\n * Not imported, not bundled, not a CSS module and not a dependency on anybody's\n * build. A component library that ships a `.css` import decides the consumer's\n * toolchain for them; this one hands over text and lets them put it in a\n * `<style>` tag, a `layout.tsx`, or nowhere at all — the markup carries plain\n * class names and degrades to unstyled rather than to broken.\n *\n * Three decisions here are load-bearing rather than decorative:\n *\n * **Cause and collateral are drawn differently.** A single highlight colour would\n * make the box that was edited and the box that merely reflowed look like the\n * same finding, which is precisely the mistake ranking by area makes.\n *\n * **Images render at their own pixel grid.** `image-rendering: pixelated` and no\n * smoothing, because a reviewer zooming into a 2px shift must see 2px and not an\n * interpolation of it.\n *\n * **The page does not grow with the build.** Every pane scrolls inside its own\n * box and the document does not scroll at all. Twenty full-page routes stacked\n * down one document is twenty-seven thousand pixels of page, and a reviewer who\n * has to scroll past a whole render to reach the next subject stops reading them\n * — which is the review blindness this surface exists to refuse, arriving as a\n * layout instead of as a ranking.\n *\n * Colour is spent through tokens on `:root`, so a second scheme re-decides values\n * and never rules. A scheme that could move a box is a second chance to disagree\n * with the region coordinates the run measured.\n *\n * The ground is dark, and that is a decision rather than a default. A capture is\n * the only thing on this page whose colour is evidence; every pixel of chrome\n * around it is the room the evidence is read in, and a room that is brighter than\n * the exhibit shifts what the exhibit looks like. `prefers-color-scheme: light`\n * re-decides the same values for a reader who asks for it.\n *\n * The palette is `docs/visual-guidelines.md` — deep, panel, ivory, quiet, warm,\n * variance orange, signal green — spent here on a surface that also has to say\n * *unknown* and *refused*, which brand assets never do. The font tokens name\n * Inter and JetBrains Mono first and fall back to the system stacks: this sheet is\n * handed over as text, so it can ask for a face and must not require one.\n */\nimport { DOCKET_STYLES } from './styles-docket.js';\nimport { STAGE_STYLES } from './styles-stage.js';\n\n/**\n * The whole sheet, in one string, with the stage spliced into the middle of it.\n *\n * One value rather than two exports a consumer composes, because the two halves\n * are not optional with respect to each other: a page that injected this and not\n * [`STAGE_STYLES`](./styles-stage.ts) would have every caption styled and the\n * evidence unstyled, which is the one failure mode worth making unreachable. The\n * split is a limit on how much of this a person has to hold at once, not an\n * offer.\n */\nexport const REVIEW_STYLES = `\n:root, .va-app { --va-bg: #181b1d; --va-surface: #1e2224; --va-sunken: #181b1d; --va-line: #2f3437; --va-line-firm: #434a4d; --va-ink: #f3f4f6; --va-ink-2: #a8a09b; --va-ink-3: #8f8580; --va-accent: #ff4a19; --va-accent-ink: #181b1d; --va-warn: #e0a458; --va-warn-bg: #2a2118; --va-warn-ink: #f0c48a; --va-bad: #e5695c; --va-bad-bg: #2c1b18; --va-bad-ink: #f3a99e; --va-good: #7fa28c; --va-good-bg: #1a2420; --va-good-ink: #a8c9b5; --va-info-bg: #24282a; --va-info-ink: #d6d0cb; --va-cause: #ff4a19; --va-collateral: #8f8580; --va-accent-soft: rgba(255, 74, 25, 0.14); --va-veil: rgba(24, 27, 29, 0.52); --va-sans: \"Inter\", ui-sans-serif, system-ui, -apple-system, \"Segoe UI\", Roboto, \"Helvetica Neue\", Arial, sans-serif; --va-mono: \"JetBrains Mono\", ui-monospace, SFMono-Regular, \"SF Mono\", Menlo, Consolas, monospace; }\n\n.va-app { background: var(--va-bg); color: var(--va-ink); color-scheme: dark light; display: flex; flex-direction: column; font-family: var(--va-sans); font-size: 14px; height: 100dvh; line-height: 1.5; overflow: hidden; }\n/* The element reset, every rule of it wrapped in :where() so the whole block\n weighs nothing. Written plainly, a rule like .va-app h2 scores higher than any\n single-class rule below it, and the component that says what its own heading\n looks like loses to a reset it never knew it was arguing with. That failure is\n invisible on the page and invisible in review: the sheet reads correctly and\n the render simply is not it. A reset is the sentence \"unless somebody says\n otherwise\", and :where() is how that clause is spelled. */\n:where(.va-app *, .va-app *::before, .va-app *::after) { box-sizing: border-box; }\n:where(.va-app h1, .va-app h2, .va-app h3) { line-height: 1.25; margin: 0; }\n:where(.va-app h1) { font-size: 1.35rem; letter-spacing: -0.01em; }\n:where(.va-app h2) { font-size: 0.95rem; letter-spacing: -0.005em; }\n:where(.va-app h3) { font-size: 1.05rem; letter-spacing: -0.005em; }\n:where(.va-app p) { margin: 0; }\n:where(.va-app code, .va-commit) { font-family: var(--va-mono); font-size: 0.85em; }\n.va-num { font-variant-numeric: tabular-nums; }\n\n/* Same reasoning, and this is where it was actually caught: the rail item, the\n mode pill and the build opener each declared no border and were given one\n anyway. */\n:where(.va-app button) { background: var(--va-surface); border: 1px solid var(--va-line-firm); border-radius: 7px; color: var(--va-ink); cursor: pointer; font: inherit; padding: 0.35rem 0.75rem; }\n:where(.va-app button:hover) { border-color: var(--va-ink-3); }\n:where(.va-app button:disabled) { cursor: not-allowed; opacity: 0.55; }\n:where(.va-app button.va-current:disabled) { cursor: default; opacity: 1; }\n:where(.va-app input) { background: var(--va-surface); border: 1px solid var(--va-line-firm); border-radius: 7px; color: var(--va-ink); font: inherit; padding: 0.35rem 0.6rem; }\n:where(.va-app ul, .va-app ol) { list-style: none; margin: 0; padding: 0; }\n\n/* The two things the reset above would otherwise have taken away. A surface that\n reports \\`nested-interactive\\` and unreachable controls cannot itself leave a\n keyboard reviewer guessing where they are — and the seam is the case that\n forces it, being an appearance-stripped range with no border of its own. */\n:where(.va-app a, .va-app button, .va-app summary, .va-app input, .va-app [tabindex]):focus-visible { outline: 2px solid var(--va-accent); outline-offset: 3px; }\n.va-app ::selection { background: var(--va-accent); color: var(--va-accent-ink); }\n\n/* The three panes. \\`min-height: 0\\` on each, because a flex child defaults to a\n floor of its content and a rail of two hundred subjects would push the shell\n past the viewport rather than scrolling inside it. */\n.va-body { display: flex; flex: 1; min-height: 0; }\n.va-scroll { min-height: 0; overflow: auto; overscroll-behavior: contain; }\n.va-page { flex: 1; margin: 0 auto; max-width: 72rem; min-width: 0; padding: 1.5rem 1.75rem 4rem; }\n\n.va-topbar { align-items: center; background: var(--va-surface); border-bottom: 1px solid var(--va-line); display: flex; flex: none; gap: 0.75rem; padding: 0.7rem 1rem; }\n.va-brand { color: var(--va-ink); flex: none; }\n.va-topbar-title { display: flex; flex-direction: column; line-height: 1.2; }\n.va-topbar-title strong { font-size: 0.95rem; }\n.va-topbar-sub { color: var(--va-ink-3); font-size: 0.78rem; }\n.va-topbar-meta { align-items: center; color: var(--va-ink-3); display: flex; flex-wrap: wrap; font-size: 0.8rem; gap: 0.5rem; justify-content: flex-end; margin-left: auto; }\n.va-topbar-meta .va-commit { background: var(--va-sunken); border-radius: 5px; color: var(--va-ink-2); padding: 0.1rem 0.35rem; }\n.va-back { flex: none; }\n\n.va-nav { display: flex; gap: 0.35rem; }\n.va-nav button.va-current { background: var(--va-accent-soft); border-color: var(--va-accent); color: var(--va-accent); cursor: default; }\n\n.va-pill { border-radius: 999px; flex: none; font-size: 0.75rem; font-weight: 600; padding: 0.15rem 0.6rem; white-space: nowrap; }\n.va-pill.va-warn { background: var(--va-warn-bg); color: var(--va-warn-ink); }\n.va-pill.va-good { background: var(--va-good-bg); color: var(--va-good-ink); }\n.va-pill.va-bad { background: var(--va-bad-bg); color: var(--va-bad-ink); }\n\n.va-note { color: var(--va-ink-3); font-size: 0.88rem; }\n.va-failure { background: var(--va-bad-bg); border-radius: 8px; color: var(--va-bad-ink); font-size: 0.88rem; margin: 0.5rem 0; padding: 0.6rem 0.75rem; }\n.va-card { background: var(--va-surface); border: 1px solid var(--va-line); border-radius: 12px; margin-bottom: 1rem; padding: 1rem 1.1rem; }\n.va-card > h2 { color: var(--va-ink-3); font-size: 0.72rem; font-weight: 700; letter-spacing: 0.09em; margin-bottom: 0.6rem; text-transform: uppercase; }\n\n.va-builds { display: grid; gap: 0.85rem; }\n.va-build { background: var(--va-surface); border: 1px solid var(--va-line); border-radius: 12px; padding: 0.9rem 1.1rem; }\n.va-build-head { align-items: center; display: flex; flex-wrap: wrap; gap: 0.6rem; margin-bottom: 0.45rem; }\n.va-build-open { align-items: baseline; background: none; border: 0; border-radius: 0; display: flex; gap: 0.5rem; padding: 0; }\n.va-build-id { font-size: 1.05rem; font-weight: 650; letter-spacing: -0.01em; }\n.va-build-go { color: var(--va-accent); font-size: 0.82rem; font-weight: 600; }\n.va-commit { background: var(--va-sunken); border-radius: 5px; color: var(--va-ink-2); padding: 0.1rem 0.35rem; }\n.va-branch { color: var(--va-ink-2); font-size: 0.82rem; }\n.va-when { color: var(--va-ink-3); font-size: 0.78rem; margin-left: auto; }\n\n.va-verdicts { align-items: center; display: flex; flex-wrap: wrap; gap: 0.5rem; margin-bottom: 0.3rem; }\n.va-pending { color: var(--va-warn); font-weight: 650; }\n.va-settled { color: var(--va-good); font-weight: 650; }\n.va-counts { color: var(--va-ink-3); font-size: 0.84rem; }\n.va-coverage { color: var(--va-ink-2); font-size: 0.84rem; }\n.va-coverage.va-unknown { color: var(--va-warn); }\n.va-coverage.va-incomplete { color: var(--va-bad); font-weight: 600; }\n\n/* The rail: every reviewable subject at a glance, so the reviewer chooses which\n render to open instead of scrolling through all of them. */\n.va-rail { background: var(--va-surface); border-right: 1px solid var(--va-line); display: flex; flex-direction: column; flex: none; min-height: 0; padding: 0.6rem; width: 21rem; }\n.va-rail-group { align-items: center; color: var(--va-ink-3); display: flex; font-size: 0.7rem; font-weight: 700; gap: 0.4rem; letter-spacing: 0.09em; padding: 0.9rem 0.55rem 0.35rem; text-transform: uppercase; }\n.va-rail-count { background: var(--va-sunken); border-radius: 999px; color: var(--va-ink-2); font-size: 0.7rem; letter-spacing: 0; padding: 0 0.4rem; }\n.va-rail-item { align-items: center; background: none; border: 1px solid transparent; border-radius: 8px; display: flex; gap: 0.55rem; margin-bottom: 0.1rem; padding: 0.45rem 0.55rem; text-align: left; width: 100%; }\n.va-rail-item:hover { background: var(--va-sunken); }\n.va-rail-item.va-current { background: var(--va-accent-soft); border-color: var(--va-accent); }\n.va-rail-body { min-width: 0; }\n.va-rail-name { display: block; font-size: 0.86rem; font-weight: 550; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }\n.va-rail-note { color: var(--va-ink-3); display: block; font-size: 0.75rem; overflow-wrap: anywhere; }\n.va-rail-item.va-current .va-rail-note { color: var(--va-ink-2); }\n.va-dot { border-radius: 50%; flex: none; height: 8px; width: 8px; }\n.va-dot.va-changed { background: var(--va-warn); }\n.va-dot.va-new { background: var(--va-accent); }\n.va-dot.va-incomparable { background: var(--va-bad); }\n.va-dot.va-unstable { background: var(--va-bad); }\n.va-dot.va-failed { background: var(--va-bad); }\n.va-dot.va-ignored { background: var(--va-ink-3); }\n.va-dot.va-unchanged { background: var(--va-good); }\n.va-mark { flex: none; font-size: 0.8rem; margin-left: auto; }\n.va-mark.va-approved { color: var(--va-good); }\n.va-mark.va-rejected { color: var(--va-bad); }\n\n.va-stage { background: var(--va-bg); flex: 1; min-width: 0; }\n.va-subject { display: flex; flex: 1; min-height: 0; min-width: 0; }\n.va-subject-head { align-items: center; display: flex; flex-wrap: wrap; gap: 0.6rem; margin-bottom: 0.35rem; }\n.va-verdict { border-radius: 999px; font-size: 0.7rem; font-weight: 700; letter-spacing: 0.06em; padding: 0.15rem 0.55rem; text-transform: uppercase; }\n.va-verdict.va-changed { background: var(--va-warn-bg); color: var(--va-warn-ink); }\n.va-verdict.va-new { background: var(--va-info-bg); color: var(--va-info-ink); }\n.va-verdict.va-incomparable { background: var(--va-bad-bg); color: var(--va-bad-ink); }\n.va-verdict.va-unstable { background: var(--va-bad-bg); color: var(--va-bad-ink); }\n.va-because { color: var(--va-ink-2); font-size: 0.88rem; }\n\n/* The three measurements a reviewer takes in before reading anything: who moved,\n how much the canvas grew, and how much of it differs. The sentence underneath\n said all three in one clause each and was skipped by the second subject, so the\n numbers are set large and the units small and the prose stands down. Accent on\n the component name only, because that is the one answer the picture cannot give\n — a region is named from where its box landed, not from what moved it. */\n.va-glance { align-items: flex-start; display: flex; flex-wrap: wrap; gap: 1.6rem; margin: 0.55rem 0 0.35rem; }\n.va-glance-cell { display: flex; flex-direction: column; gap: 0.12rem; margin: 0; }\n.va-glance-cell b { color: var(--va-ink); font-family: var(--va-mono); font-size: 1.4rem; font-weight: 650; letter-spacing: -0.02em; line-height: 1.15; }\n.va-glance-cell span { color: var(--va-ink-3); font-family: var(--va-mono); font-size: 0.66rem; letter-spacing: 0.1em; text-transform: uppercase; }\n.va-glance-cell small { color: var(--va-ink-2); font-family: var(--va-mono); font-size: 0.72rem; }\n.va-glance-cause b { color: var(--va-cause); font-family: var(--va-sans); font-size: 1.45rem; letter-spacing: -0.01em; }\n.va-glance-none b { color: var(--va-ink-2); font-size: 1.05rem; }\n.va-glance-off { background: var(--va-warn-bg); border-radius: 4px; color: var(--va-warn-ink); margin-top: 0.15rem; padding: 0.05rem 0.35rem; width: fit-content; }\n.va-said { color: var(--va-ink-3); font-size: 0.78rem; margin: 0.1rem 0 0; max-width: 74ch; }\n.va-intent { color: var(--va-ink-2); font-size: 0.88rem; }\n.va-subtitle { color: var(--va-ink-3); font-size: 0.88rem; margin-top: 0.2rem; }\n\n/* The right-hand column: everything that is not the picture. A reviewer decides\n from the record and the defect list, and neither should cost a scroll past a\n nine-thousand-pixel render to reach. */\n.va-aside { background: var(--va-surface); border-left: 1px solid var(--va-line); flex: none; padding: 1rem; width: 22rem; }\n.va-aside .va-card { background: var(--va-sunken); border-color: transparent; }\n.va-aside h2 { color: var(--va-ink-3); font-size: 0.72rem; font-weight: 700; letter-spacing: 0.09em; margin-bottom: 0.5rem; text-transform: uppercase; }\n\n/* The review unit, which is a change and not a picture. Each card is one edit\n and every place it landed, so the card is given the weight a row in a table\n never had: a reviewer approves at this level and reads the subjects inside it\n as evidence. The left edge is the cause colour, the same one the region boxes\n use, because that is what the card is. */\n.va-origins { display: grid; gap: 0.75rem; margin-top: 0.75rem; }\n.va-origin { background: var(--va-sunken); border-left: 3px solid var(--va-cause); border-radius: 10px; padding: 0.85rem 1rem; }\n.va-origin.va-orphan { border-left-color: var(--va-warn); }\n.va-origin-head { align-items: baseline; display: flex; flex-wrap: wrap; gap: 0.5rem; }\n.va-origin-head h3 { font-size: 1rem; margin: 0; }\n.va-origin-pixels { color: var(--va-ink-3); font-size: 0.84rem; margin-left: auto; }\n.va-file { color: var(--va-ink-2); }\n.va-origin p { font-size: 0.86rem; margin: 0.35rem 0 0; }\n.va-origin .va-churn, .va-origin .va-stability { margin: 0.35rem 0 0; }\n.va-origin-arrival.va-alarm { background: var(--va-bad-bg); border-radius: 6px; color: var(--va-bad-ink); padding: 0.35rem 0.55rem; }\n/* Two shapes under one component name is two edits, and the sentence saying so\n is the one thing on the card that withdraws the batch approval. */\n.va-note.va-warned { color: var(--va-warn-ink); }\n.va-origin-where { display: grid; font-size: 0.86rem; gap: 0.35rem; margin-top: 0.7rem; }\n.va-origin-where li { align-items: baseline; display: flex; flex-wrap: wrap; gap: 0.5rem; }\n.va-origin-subject { font-weight: 600; }\n.va-origin-act { align-items: center; display: flex; flex-wrap: wrap; gap: 0.55rem; margin-top: 0.8rem; }\n.va-origin-act button { font-weight: 600; }\n.va-origin-act .va-approve { background: var(--va-good); border-color: var(--va-good); color: #ffffff; }\n.va-origin-act .va-approve:disabled { background: var(--va-sunken); border-color: var(--va-line-firm); color: var(--va-ink-3); }\n.va-collateral { border-top: 1px solid var(--va-line); color: var(--va-ink-3); font-size: 0.84rem; margin-top: 0.75rem; padding-top: 0.65rem; }\n\n/* Looking at the change from the card that decides it. The window is fixed and\n the same for every crop: sizing each one to its own region would turn the strip\n into a bar chart of areas, which is the ranking by area this whole surface\n exists to refuse, drawn in the place a reviewer reads as how big is this. */\n.va-origin-look { align-items: center; display: flex; flex-wrap: wrap; gap: 0.5rem; }\n.va-look { display: flex; flex-wrap: wrap; gap: 0.6rem; margin-top: 0.6rem; }\n.va-sight { background: none; border: 0; color: inherit; cursor: pointer; display: flex; flex-direction: column; font: inherit; gap: 0.3rem; padding: 0; text-align: left; }\n.va-crop { background: var(--va-bg); border: 1px solid var(--va-line); border-radius: 8px; display: block; overflow: hidden; position: relative; }\n.va-sight:hover .va-crop, .va-sight:focus-visible .va-crop { border-color: var(--va-accent); }\n.va-crop-plate { position: absolute; }\n.va-crop-plate img { display: block; height: auto; image-rendering: pixelated; left: 0; position: absolute; top: 0; width: 100%; }\n/* The veil rather than a ring alone: at 3x on a hundred-pixel region the ring is\n most of the window, and a reviewer looking for the difference is looking for\n which part of the window is not dimmed. The spread only has to exceed the crop,\n which is 264px wide and clips it. */\n.va-crop-box { border: 1px solid var(--va-cause); box-shadow: 0 0 0 900px var(--va-veil); position: absolute; }\n.va-crop-part { background: var(--va-bg); border-radius: 5px; bottom: 0.3rem; color: var(--va-ink-3); font-family: var(--va-mono); font-size: 0.65rem; padding: 0.1rem 0.35rem; position: absolute; right: 0.3rem; }\n.va-crop-unplaced { align-items: center; display: flex; justify-content: center; padding: 0.75rem; text-align: center; }\n.va-sight-note { color: var(--va-ink-2); font-family: var(--va-mono); font-size: 0.7rem; max-width: 264px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }\n/* A subject name that goes somewhere. Underlined rather than coloured, because\n the accent on this page means cause and a second meaning for it would make the\n colour say nothing. */\n.va-open { background: none; border: 0; color: inherit; cursor: pointer; font: inherit; padding: 0; text-align: left; text-decoration: underline; text-decoration-color: var(--va-line-firm); text-underline-offset: 3px; }\n.va-open:hover { text-decoration-color: var(--va-accent); }\n/* The commit does not reach this name, and reaches every render it moved in.\n Ordinary, and therefore not wearing the alarm's colour: a page that shouts at\n every component out of node_modules has spent the shout before the one render\n nothing accounts for arrives. */\n.va-origin-arrival.va-unnamed { background: var(--va-info-bg); border-radius: 6px; color: var(--va-info-ink); padding: 0.35rem 0.55rem; }\n\n/* The third axis: what the commit reaches, crossed with what the run saw. Drawn\n as a grid rather than four counts, because the four cells are one crossing and\n a reader who takes them in as a shape sees at once which half of it is the\n anomaly. */\n.va-reach .va-quad { display: grid; gap: 0.5rem; grid-template-columns: repeat(2, minmax(0, 1fr)); margin-top: 0.9rem; }\n.va-cell { background: var(--va-sunken); border-left: 3px solid var(--va-line-firm); border-radius: 9px; display: grid; gap: 0.1rem; padding: 0.7rem 0.85rem; }\n.va-cell-n { font-size: 1.6rem; font-weight: 650; letter-spacing: -0.02em; line-height: 1.15; }\n.va-cell-title { font-size: 0.82rem; font-weight: 650; }\n.va-cell-note { color: var(--va-ink-3); font-size: 0.78rem; }\n.va-cell.va-expected { border-left-color: var(--va-accent); }\n.va-cell.va-alarm { background: var(--va-bad-bg); border-left-color: var(--va-bad); }\n.va-cell.va-alarm .va-cell-n, .va-cell.va-alarm .va-cell-title { color: var(--va-bad-ink); }\n.va-cell.va-inert { background: var(--va-warn-bg); border-left-color: var(--va-warn); }\n.va-cell.va-inert .va-cell-n, .va-cell.va-inert .va-cell-title { color: var(--va-warn-ink); }\n/* A quadrant holding nothing keeps its place and loses its voice. Dropped, it\n reads as a question nobody asked; coloured as a finding, as one nobody\n answered. */\n.va-cell.va-zero { background: transparent; border-left-color: var(--va-line); }\n.va-cell.va-zero .va-cell-n, .va-cell.va-zero .va-cell-title { color: var(--va-ink-3); font-weight: 550; }\n\n.va-reach .va-band { border-left: 2px solid var(--va-line-firm); margin-top: 1.1rem; padding-left: 0.85rem; }\n.va-reach .va-band.va-alarm { border-left-color: var(--va-bad); }\n.va-reach .va-band.va-inert { border-left-color: var(--va-warn); }\n.va-reach .va-band h3 { font-size: 0.95rem; margin-bottom: 0.25rem; }\n.va-reach .va-stability, .va-reach .va-churn { margin: 0.2rem 0 0; }\n.va-reach-list { display: grid; font-size: 0.86rem; gap: 0.5rem; margin-top: 0.55rem; }\n.va-reach-list.va-columns { columns: 2 18rem; display: block; }\n.va-reach-list.va-columns li { break-inside: avoid; margin-bottom: 0.35rem; }\n\n/* The chain, which is the half a reviewer can disprove. A component named\n without it is an assertion; tokens.css to button.tsx to Button is a claim\n somebody can open three files and refute. */\n.va-trails { display: grid; font-size: 0.84rem; gap: 0.35rem; margin-top: 0.55rem; }\n.va-trails code { background: var(--va-sunken); border-radius: 5px; color: var(--va-ink-2); padding: 0.1rem 0.35rem; }\n.va-arrow { color: var(--va-ink-3); padding: 0 0.3rem; }\n.va-axis.va-unread { background: var(--va-warn-bg); color: var(--va-warn-ink); margin-left: 0.5rem; }\n\n/* Drawn where the crossing would have been, never beside it: an empty grid over\n a diff nothing could attribute reads as this commit reaches none of your\n subjects, which is a sentence somebody merges on. */\n.va-refusal { background: var(--va-warn-bg); border-radius: 9px; color: var(--va-warn-ink); font-size: 0.86rem; margin-top: 0.9rem; padding: 0.6rem 0.75rem; }\n.va-reach-note { border-top: 1px solid var(--va-line); margin-top: 0.9rem; padding-top: 0.6rem; }\n.va-reach-note code { color: var(--va-ink-2); }\n.va-lost { color: var(--va-bad); }\n\n/* The map. Three registers on one row — what was edited, what it arrived at, and\n what became of the renders drawing that — so the eye reads left to right and\n lands on the track. The indent under a file carries a hairline because the\n relation is containment rather than sequence: at this density an indent alone\n reads as a second list. */\n.va-map { display: grid; gap: 0.55rem; margin-top: 0.9rem; }\n.va-edits { display: grid; gap: 0.5rem; }\n.va-edit { background: var(--va-sunken); border-radius: 9px; padding: 0.6rem 0.8rem; }\n.va-edit-head { align-items: baseline; display: flex; flex-wrap: wrap; gap: 0.5rem; }\n.va-edit-file { min-width: 0; overflow-wrap: anywhere; }\n/* The verdict on an edit is the one label on this page that is read by scanning a\n column rather than by reading a sentence, which is what the mono face is for. */\n.va-effect { flex: none; font-family: var(--va-mono); font-size: 0.7rem; letter-spacing: 0.02em; margin-left: auto; text-transform: uppercase; }\n.va-effect-cause { color: var(--va-accent); }\n.va-effect-ask { color: var(--va-warn-ink); }\n.va-effect-quiet { color: var(--va-ink-3); }\n.va-arrivals { border-left: 1px solid var(--va-line-firm); display: grid; gap: 0.35rem; margin: 0.5rem 0 0 0.25rem; padding-left: 0.8rem; }\n.va-arrival { align-items: center; display: flex; flex-wrap: wrap; gap: 0.5rem; min-width: 0; }\n.va-arrival-name { font-size: 0.86rem; font-weight: 600; }\n.va-arrival-through { color: var(--va-ink-3); font-size: 0.76rem; }\n.va-arrival-tally { color: var(--va-ink-2); flex: none; font-size: 0.76rem; margin-left: auto; }\n/* Cause, collateral, still and not-compared are the same four readings the region\n overlay spends colour on, spent the same way here. A track that invented its own\n palette would be a second vocabulary for one set of facts. */\n.va-bar { background: var(--va-line); border-radius: 3px; display: flex; flex: 1; gap: 1px; height: 5px; max-width: 11rem; min-width: 3rem; overflow: hidden; }\n.va-bar-part { display: block; }\n.va-bar-caused { background: var(--va-cause); }\n.va-bar-moved { background: var(--va-collateral); }\n.va-bar-still { background: var(--va-good); }\n.va-bar-uncompared { background: var(--va-line-firm); }\n.va-map-aside { border-top: 1px solid var(--va-line); display: grid; gap: 0.5rem; padding-top: 0.6rem; }\n.va-map-note { color: var(--va-ink-3); font-size: 0.8rem; }\n.va-map-alarm { color: var(--va-bad-ink); }\n\n/* Since the last run. Each state carries its own left rule, because the section is\n read by jumping to the one that matters and a uniform stack would have to be\n read in order to find it. */\n.va-shift { border-left: 3px solid var(--va-line-firm); display: grid; gap: 0.25rem; margin-top: 0.85rem; padding-left: 0.8rem; }\n.va-shift-head { align-items: baseline; display: flex; gap: 0.5rem; }\n.va-shift-title { font-size: 0.9rem; font-weight: 650; }\n.va-shift-count { font-size: 1.2rem; font-weight: 650; letter-spacing: -0.02em; margin-left: auto; }\n.va-shift-alarm { border-left-color: var(--va-bad); }\n.va-shift-alarm .va-shift-title, .va-shift-alarm .va-shift-count { color: var(--va-bad-ink); }\n.va-shift-ask { border-left-color: var(--va-warn); }\n.va-shift-ask .va-shift-title, .va-shift-ask .va-shift-count { color: var(--va-warn-ink); }\n.va-shift-known { border-left-color: var(--va-accent); }\n.va-shift-known .va-shift-count { color: var(--va-accent); }\n.va-shift-good { border-left-color: var(--va-good); }\n.va-shift-good .va-shift-count { color: var(--va-good-ink); }\n.va-shift-quiet { border-left-color: var(--va-line); }\n.va-shift-list { display: grid; font-size: 0.84rem; gap: 0.3rem; margin-top: 0.25rem; }\n.va-shift-row { align-items: baseline; display: flex; flex-wrap: wrap; gap: 0.5rem; min-width: 0; }\n.va-shift-subject { font-weight: 600; min-width: 0; overflow-wrap: anywhere; }\n.va-shift-held { border-top: 1px solid var(--va-line); margin-top: 0.95rem; padding-top: 0.6rem; }\n\n.va-variation-list { display: grid; gap: 0.5rem; }\n.va-variation { background: var(--va-sunken); border-radius: 9px; padding: 0.6rem 0.75rem; }\n.va-variation-head { align-items: baseline; display: flex; flex-wrap: wrap; gap: 0.4rem; margin-bottom: 0.15rem; }\n/* Every chip carries its own text colour rather than inheriting the page's: the\n backgrounds are light in both schemes, and a chip that inherited a dark\n scheme's foreground would be pale text on a pale block. */\n.va-axis { background: var(--va-info-bg); border-radius: 999px; color: var(--va-info-ink); font-size: 0.72rem; font-weight: 650; padding: 0.1rem 0.5rem; }\n/* The two states a reviewer can act on, and they are not the same finding: an\n arm that changes nothing was measured, a pair with no parent never was. */\n.va-variation.va-inert .va-axis { background: var(--va-warn-bg); color: var(--va-warn-ink); }\n.va-variation.va-unlinked .va-axis { background: var(--va-bad-bg); color: var(--va-bad-ink); }\n.va-how { color: var(--va-ink-3); font-size: 0.75rem; }\n\n${STAGE_STYLES}\n${DOCKET_STYLES}\n\n.va-filter { align-items: center; display: flex; flex-wrap: wrap; gap: 0.5rem; margin: 1rem 0; }\n\n.va-changes { display: grid; gap: 0.85rem; }\n.va-change { background: var(--va-surface); border: 1px solid var(--va-line); border-radius: 12px; padding: 0.9rem 1.1rem; }\n.va-change h3 { font-size: 1rem; margin-bottom: 0.25rem; }\n.va-change-meta, .va-fingerprint { color: var(--va-ink-3); font-size: 0.82rem; }\n.va-fingerprint { border-top: 1px solid var(--va-line); margin-top: 0.65rem; padding-top: 0.55rem; }\n.va-change-subjects { color: var(--va-ink-2); columns: 3 14rem; font-size: 0.84rem; margin-top: 0.4rem; }\n.va-ungrouped { border-top: 1px solid var(--va-line-firm); margin-top: 1.75rem; padding-top: 1.25rem; }\n\n/* The three stability states are drawn as three, deliberately: unknown is not\n clean, and a rate nobody swept for is not a rate of zero. */\n.va-history { font-size: 0.86rem; }\n.va-ask { font-size: 0.86rem; width: 100%; }\n.va-stability, .va-churn { color: var(--va-ink-2); font-size: 0.86rem; margin-bottom: 0.5rem; }\n.va-stability.va-unknown, .va-churn.va-unknown { color: var(--va-warn); }\n.va-stability.va-flaky { color: var(--va-bad); }\n\n/* One finding, in three registers: what it is, where it is, and the sentence\n that explains it. The rule id is last because it is the least of the three to\n a person and the whole of it to an ignore list.\n\n The lead is the only loud thing in the panel and it is one sentence about this\n change. The method line that used to open the panel is now the last line in it,\n at note size: it says why nothing here moved the verdict, which is worth\n knowing and is worth nobody's first glance. */\n.va-findings-lead { font-size: 0.9rem; font-weight: 650; margin-bottom: 0.7rem; }\n.va-findings-lead.va-findings-mine { color: var(--va-warn-ink); }\n.va-findings-why { border-top: 1px solid var(--va-line); margin-top: 0.9rem; padding-top: 0.5rem; }\n/* Shut by default, and the summary is the whole argument for the fold: a count a\n reviewer can decide to open, rather than twenty-two rows they have to scroll. */\n.va-findings-rest { margin-top: 1rem; }\n.va-findings-rest > summary { color: var(--va-ink-2); cursor: pointer; font-size: 0.8rem; padding: 0.35rem 0; }\n.va-findings-rest[open] > summary { color: var(--va-ink); }\n.va-findings-rest > .va-note { margin-bottom: 0.7rem; }\n.va-findings-band + .va-findings-band { margin-top: 0.9rem; }\n.va-band-head { color: var(--va-ink-2); font-size: 0.72rem; font-weight: 700; letter-spacing: 0.09em; margin-bottom: 0.5rem; text-transform: uppercase; }\n.va-findings { display: grid; gap: 0.7rem; }\n.va-finding { border-left: 2px solid var(--va-line-firm); padding-left: 0.7rem; }\n.va-finding-new { border-left-color: var(--va-warn); }\n/* The headline is an item and the marks may wrap under it. Left as a bare text\n node beside two flex-none badges it became an anonymous item with nothing\n holding its width, and \"a control inside another control\" read down the rail\n one word to a line. */\n.va-finding-title { align-items: baseline; display: flex; flex-wrap: wrap; font-size: 0.88rem; font-weight: 650; gap: 0.5rem; justify-content: space-between; }\n.va-finding-said { flex: 1 1 9rem; }\n.va-finding-marks { align-items: baseline; display: flex; flex: none; gap: 0.45rem; }\n.va-times { color: var(--va-accent); flex: none; font-family: var(--va-mono); font-size: 0.72rem; font-weight: 500; }\n.va-age { border-radius: 5px; color: var(--va-ink-3); flex: none; font-size: 0.7rem; font-weight: 500; padding: 0.1rem 0.4rem; white-space: nowrap; }\n.va-age-new { background: var(--va-warn-bg); color: var(--va-warn-ink); }\n.va-age-standing, .va-age-undated { background: var(--va-info-bg); }\n.va-finding-where { color: var(--va-ink-2); font-size: 0.8rem; }\n.va-finding-what { color: var(--va-ink-2); font-size: 0.8rem; margin-top: 0.2rem; }\n.va-finding-owner { align-items: center; display: flex; flex-wrap: wrap; gap: 0.35rem; margin-top: 0.35rem; }\n.va-tag { background: var(--va-surface); border: 1px solid var(--va-line); border-radius: 5px; color: var(--va-ink-2); font-size: 0.72rem; padding: 0.1rem 0.4rem; }\n.va-tag.va-rule { color: var(--va-ink-3); }\n\n/* The declaration ledgers. Every rule is a row, including the ones that absorbed\n nothing — those are the finding, and a table that hid them would be a table of\n rules that are working. The rule name is the widest column because it is the\n thing a reader copies into their config; the reason is the widest prose. */\n.va-ledger { margin-top: 0.5rem; }\n.va-ledger code { font-size: 0.78rem; }\n.va-ledger em { color: var(--va-ink-3); font-size: 0.72rem; font-style: normal; }\n.va-ledger .va-none { color: var(--va-ink-3); }\n.va-ledger .va-ledger-why { color: var(--va-ink-2); width: 38%; }\n.va-ledger .va-tag { margin-right: 0.25rem; }\n/* Marked on the row rather than only on the badge: a reviewer scanning for the\n one rule that is wrong reads the left edge, not the third column. */\n.va-ledger tr.va-spent td:first-child { border-left: 2px solid var(--va-warn); padding-left: 0.4rem; }\n.va-tag.va-warn { background: var(--va-warn-bg); border-color: transparent; color: var(--va-warn-ink); }\n.va-ledger-head { color: var(--va-ink-2); font-size: 0.8rem; font-weight: 650; margin-top: 0.9rem; }\n.va-ledger-head .va-note { font-weight: 400; margin-left: 0.5rem; }\n\n/* Folded, because on a run of three hundred stories this is three hundred lines\n of nothing — and present, because a green subject that is green by declaration\n is the one a mask hides behind. */\n.va-settled > summary { color: var(--va-ink-2); cursor: pointer; font-size: 0.86rem; }\n.va-settled-list { display: grid; gap: 0.3rem; margin-top: 0.6rem; }\n.va-settled-list li { align-items: baseline; display: flex; font-size: 0.84rem; gap: 0.45rem; }\n.va-settled-list strong { font-family: var(--va-mono); font-size: 0.78rem; font-weight: 500; }\n\n.va-not-observed { color: var(--va-ink-2); display: grid; font-size: 0.86rem; gap: 0.35rem; }\n.va-not-observed .va-failed { color: var(--va-bad); font-weight: 600; }\n\n.va-decision { color: var(--va-good); font-size: 0.86rem; margin-bottom: 0.5rem; }\n.va-actions { display: flex; flex-wrap: wrap; gap: 0.5rem; }\n.va-actions button { flex: 1; font-weight: 600; }\n.va-actions .va-approve { background: var(--va-good); border-color: var(--va-good); color: #ffffff; }\n.va-actions .va-approve:disabled { background: var(--va-sunken); border-color: var(--va-line-firm); color: var(--va-ink-3); }\n\n@media (max-width: 60rem) {\n .va-body { flex-wrap: wrap; }\n .va-rail { border-right: 0; width: 100%; }\n .va-subject { flex-wrap: wrap; }\n .va-aside { border-left: 0; width: 100%; }\n .va-reach .va-quad { grid-template-columns: minmax(0, 1fr); }\n}\n\n/* Motion is never how anything here is said, so there is nothing to preserve when\n a reader has asked for less of it. The blink and the scroll to a region are\n turned off in the component that starts them, because neither is a CSS\n animation this could reach; this catches the transitions. */\n@media (prefers-reduced-motion: reduce) {\n .va-app *, .va-app *::before, .va-app *::after { animation-duration: 1ms; animation-iteration-count: 1; scroll-behavior: auto; transition-duration: 1ms; }\n}\n\n@media (prefers-color-scheme: light) {\n :root, .va-app { --va-bg: #f3f4f6; --va-surface: #ffffff; --va-sunken: #e9eaec; --va-line: #d8d6d3; --va-line-firm: #b8b3ae; --va-ink: #181b1d; --va-ink-2: #4c4844; --va-ink-3: #756d67; --va-accent: #d83a13; --va-accent-ink: #ffffff; --va-warn: #8a5a12; --va-warn-bg: #f7ecd9; --va-warn-ink: #5c3c08; --va-bad: #b03a2b; --va-bad-bg: #f8e3df; --va-bad-ink: #7a2418; --va-good: #46705a; --va-good-bg: #dfece4; --va-good-ink: #2b4a38; --va-info-bg: #ecebe9; --va-info-ink: #3d3935; --va-cause: #d83a13; --va-collateral: #756d67; --va-accent-soft: rgba(216, 58, 19, 0.10); --va-veil: rgba(243, 244, 246, 0.55); }\n}\n`;\n"]}
@@ -0,0 +1,36 @@
1
+ /**
2
+ * One render, whole: the picture on the left and everything that is not the
3
+ * picture on the right.
4
+ *
5
+ * Apart from the build page because it is the deepest address on the surface and
6
+ * the only one where the raster is the subject rather than the evidence. What
7
+ * reaches it is a link from a change — the reviewer has already been told what
8
+ * moved and why, and came here to look.
9
+ */
10
+ import { type ReactElement } from 'react';
11
+ import type { SubjectView } from '../review-types.js';
12
+ import type { ReviewClient } from './client.js';
13
+ import type { Ruler } from './distance.js';
14
+ /**
15
+ * One subject: the render on the left, everything that is not the render on the
16
+ * right.
17
+ *
18
+ * The split is not decoration. A reviewer decides from the defect list, the
19
+ * record and the sentence explaining the verdict, and on a full-page capture all
20
+ * three sat nine thousand pixels below the thing they are about. They are a
21
+ * column now, and it does not move when the picture does.
22
+ */
23
+ export declare function SubjectPanel({ client, reviewer, build, subject, anchor, far, sourced, onDecided, }: {
24
+ readonly client: ReviewClient;
25
+ readonly reviewer: string;
26
+ readonly build: string;
27
+ readonly subject: SubjectView;
28
+ /** The change this render is filed under, which distances are measured from. */
29
+ readonly anchor?: string | undefined;
30
+ /** How far each component here is from that change, when a diff was read. */
31
+ readonly far?: Ruler | undefined;
32
+ /** Whether the run resolved any source file — see {@link RegionTable}. */
33
+ readonly sourced?: boolean | undefined;
34
+ readonly onDecided: () => void;
35
+ }): ReactElement;
36
+ //# sourceMappingURL=subject.d.ts.map
@@ -0,0 +1,138 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * One render, whole: the picture on the left and everything that is not the
4
+ * picture on the right.
5
+ *
6
+ * Apart from the build page because it is the deepest address on the surface and
7
+ * the only one where the raster is the subject rather than the evidence. What
8
+ * reaches it is a link from a change — the reviewer has already been told what
9
+ * moved and why, and came here to look.
10
+ */
11
+ import { useEffect, useRef, useState } from 'react';
12
+ import { Findings } from './findings.js';
13
+ import { glanceOf } from './glance.js';
14
+ import { SubjectHistory } from './history.js';
15
+ import { MovedHere } from './moved.js';
16
+ import { messageOf } from './shell.js';
17
+ import { count, number, when } from './text.js';
18
+ import { Viewer } from './viewer.js';
19
+ /**
20
+ * One subject: the render on the left, everything that is not the render on the
21
+ * right.
22
+ *
23
+ * The split is not decoration. A reviewer decides from the defect list, the
24
+ * record and the sentence explaining the verdict, and on a full-page capture all
25
+ * three sat nine thousand pixels below the thing they are about. They are a
26
+ * column now, and it does not move when the picture does.
27
+ */
28
+ export function SubjectPanel({ client, reviewer, build, subject, anchor, far, sourced, onDecided, }) {
29
+ const [busy, setBusy] = useState(false);
30
+ const [failed, setFailed] = useState(null);
31
+ const page = useRef(null);
32
+ // This panel used to be remounted per subject, which threw away the comparison
33
+ // mode and the magnification along with everything else — sixty re-clicks to
34
+ // read twenty subjects the same way. Those are the reviewer's working method
35
+ // and they stay; a failure from the last subject and its scroll position are
36
+ // not, and are what the remount was really for.
37
+ useEffect(() => {
38
+ setBusy(false);
39
+ setFailed(null);
40
+ page.current?.scrollTo({ top: 0 });
41
+ }, [subject.subject]);
42
+ const decide = async (decision) => {
43
+ setBusy(true);
44
+ setFailed(null);
45
+ try {
46
+ await client.decide(build, subject.subject, decision, reviewer);
47
+ onDecided();
48
+ }
49
+ catch (error) {
50
+ // Kept on the page rather than swallowed. A decision that silently did not
51
+ // land is a reviewer who believes a baseline was promoted and a next run
52
+ // that reports the same change again.
53
+ setFailed(messageOf(error));
54
+ }
55
+ finally {
56
+ setBusy(false);
57
+ }
58
+ };
59
+ return (_jsxs("section", { className: "va-subject", children: [_jsx("div", { className: "va-stage va-scroll", ref: page, children: _jsxs("div", { className: "va-page", children: [_jsxs("header", { className: "va-subject-head", children: [_jsx("h3", { children: subject.subject }), _jsx("span", { className: `va-verdict va-${subject.verdict}`, children: subject.verdict })] }), _jsx(Glanced, { subject: subject }), _jsx("p", { className: "va-said", children: subject.because }), _jsx(Viewer, { client: client, build: build, subject: subject, sourced: sourced })] }) }), _jsxs("aside", { className: "va-aside va-scroll", children: [_jsxs("section", { className: "va-card", children: [_jsx("h2", { children: "Decision" }), subject.decision === null ? null : (_jsxs("p", { className: "va-decision", children: [subject.decision.decision, " by ", subject.decision.by, " \u00B7 ", when(subject.decision.at), subject.decision.note === undefined ? null : _jsxs(_Fragment, { children: [" \u2014 ", subject.decision.note] })] })), failed === null ? null : _jsx("p", { className: "va-failure", children: failed }), _jsxs("p", { className: "va-actions", children: [_jsx("button", { type: "button", className: "va-approve", disabled: busy || !subject.approvable, onClick: () => void decide('approved'), title: subject.approvable
60
+ ? 'Make this build’s candidate the baseline'
61
+ : 'This run kept no candidate image, so there is nothing to promote. Approving would mean rendering one now, which is recording rather than promoting.', children: "Approve" }), _jsx("button", { type: "button", disabled: busy, onClick: () => void decide('rejected'), children: "Reject" })] }), subject.approvable ? null : (_jsx("p", { className: "va-note", style: { marginTop: '0.5rem' }, children: "No candidate was uploaded for this subject, so it cannot be approved here." }))] }), _jsxs("section", { className: "va-card", children: [_jsx("h2", { children: "What moved" }), _jsx(MovedHere, { subject: subject, anchor: anchor, far: far })] }), _jsxs("section", { className: "va-card", children: [_jsx("h2", { children: "Findings" }), _jsx(Findings, { subject: subject })] }), _jsxs("section", { className: "va-card", children: [_jsx("h2", { children: "The record" }), _jsx(SubjectHistory, { client: client, subject: subject })] })] })] }));
62
+ }
63
+ /**
64
+ * The three measurements, before the sentence that used to carry them.
65
+ *
66
+ * A reviewer arriving here has one question — *what changed* — and the line that
67
+ * answered it was prose: pixels, then a name, then a resize, then a caveat, in
68
+ * one two-hundred-character sentence with the interesting part in the middle.
69
+ * Read once. Skipped on the second subject, because the shape of it is the same
70
+ * every time and a reader who has parsed it twice stops looking for where the
71
+ * numbers moved.
72
+ *
73
+ * Three cells instead, each a number over its unit. Nothing to read in order,
74
+ * because these are three answers to one question and a reviewer takes them at a
75
+ * glance or not at all. The sentence stays underneath and stays quiet: it is the
76
+ * run's own words, it carries the collection's complaints, and demoting it is
77
+ * not the same as dropping it.
78
+ *
79
+ * `null` for a subject with nothing to measure. An unchanged render would get
80
+ * three cells reading `—`, which is a page insisting it has something to say.
81
+ */
82
+ function Glanced({ subject }) {
83
+ const at = glanceOf(subject);
84
+ if (at.pixels === 0 && at.grew === undefined && at.blamed.length === 0)
85
+ return null;
86
+ return (_jsxs("div", { className: "va-glance", children: [_jsx(Cause, { at: at }), at.grew === undefined ? null : (_jsxs("p", { className: "va-glance-cell", children: [_jsxs("b", { children: [at.grew.by > 0 ? '+' : '', number(at.grew.by), " px"] }), _jsx("span", { children: at.grew.axis }), _jsxs("small", { children: [number(at.grew.from.width), "\u00D7", number(at.grew.from.height), " \u2192", ' ', number(at.grew.to.width), "\u00D7", number(at.grew.to.height)] })] })), at.pixels === 0 ? null : (_jsxs("p", { className: "va-glance-cell", children: [_jsxs("b", { children: [number(at.pixels), " px"] }), _jsx("span", { children: count(at.regions, 'region') }), at.landedIn === undefined ? null : _jsxs("small", { children: ["drawn over ", at.landedIn] })] }))] }));
87
+ }
88
+ /**
89
+ * Who moved, per the hashes — which is not who the regions are named after.
90
+ *
91
+ * The distinction this cell exists for. A region is named from where its box
92
+ * landed, so an edit that pushed its container reports under the container;
93
+ * these entries compared digests and never saw a pixel. On the subject that
94
+ * prompted this, the prose said `Card` and the hashes say `Button`, and only one
95
+ * of those is a file anybody edited.
96
+ *
97
+ * A cause no region names is marked rather than left implicit. That is the two
98
+ * tiers disagreeing about where a change is, and it is the state a reviewer
99
+ * should distrust the picture in.
100
+ */
101
+ function Cause({ at }) {
102
+ if (!at.measured)
103
+ return null;
104
+ if (at.blamed.length === 0) {
105
+ return (_jsxs("p", { className: "va-glance-cell va-glance-none", children: [_jsx("b", { children: "nothing owned it" }), _jsx("span", { children: "every digest matched" })] }));
106
+ }
107
+ const shown = at.blamed.slice(0, 2);
108
+ return (_jsxs("p", { className: "va-glance-cell va-glance-cause", children: [_jsxs("b", { children: [shown.map((each) => each.component).join(', '), at.blamed.length > shown.length ? ` +${number(at.blamed.length - shown.length)}` : ''] }), _jsx("span", { children: sense(shown) }), shown[0]?.grew === undefined || shown.length > 1 ? null : (_jsx("small", { children: sized(shown[0].grew) })), shown.every((each) => each.drawn) ? null : (_jsx("small", { className: "va-glance-off", title: "the hashes name it; no region does", children: "no region carries it" }))] }));
109
+ }
110
+ /**
111
+ * A box delta, on the axes that moved.
112
+ *
113
+ * Both axes get the cross form and one gets a word, because *+36 x +8 px* with a
114
+ * zero in it is a reader working out which number is the one that changed.
115
+ */
116
+ function sized({ width, height }) {
117
+ const w = `${width > 0 ? '+' : ''}${number(width)}`;
118
+ const h = `${height > 0 ? '+' : ''}${number(height)}`;
119
+ if (width === 0)
120
+ return `${h} px tall`;
121
+ if (height === 0)
122
+ return `${w} px wide`;
123
+ return `${w} × ${h} px`;
124
+ }
125
+ /**
126
+ * The bands, once each, in the order they were found.
127
+ *
128
+ * A component present on one side only says that instead: `added` and `removed`
129
+ * are not bands and a list that folded them in would report a control that did
130
+ * not exist before as having changed its geometry.
131
+ */
132
+ function sense(blamed) {
133
+ const presence = [...new Set(blamed.map((each) => each.presence).filter(Boolean))];
134
+ if (presence.length > 0)
135
+ return presence.join(', ');
136
+ return [...new Set(blamed.flatMap((each) => each.bands))].join(' · ');
137
+ }
138
+ //# sourceMappingURL=subject.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"subject.js","sourceRoot":"","sources":["../../src/ui/subject.tsx"],"names":[],"mappings":";AAAA;;;;;;;;GAQG;AAEH,OAAO,EAAE,SAAS,EAAE,MAAM,EAAE,QAAQ,EAAqB,MAAM,OAAO,CAAC;AAIvE,OAAO,EAAE,QAAQ,EAAE,MAAM,eAAe,CAAC;AACzC,OAAO,EAAE,QAAQ,EAA4B,MAAM,aAAa,CAAC;AACjE,OAAO,EAAE,cAAc,EAAE,MAAM,cAAc,CAAC;AAC9C,OAAO,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AACvC,OAAO,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AACvC,OAAO,EAAE,KAAK,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,WAAW,CAAC;AAChD,OAAO,EAAE,MAAM,EAAE,MAAM,aAAa,CAAC;AAErC;;;;;;;;GAQG;AACH,MAAM,UAAU,YAAY,CAAC,EAC3B,MAAM,EACN,QAAQ,EACR,KAAK,EACL,OAAO,EACP,MAAM,EACN,GAAG,EACH,OAAO,EACP,SAAS,GAaV;IACC,MAAM,CAAC,IAAI,EAAE,OAAO,CAAC,GAAG,QAAQ,CAAC,KAAK,CAAC,CAAC;IACxC,MAAM,CAAC,MAAM,EAAE,SAAS,CAAC,GAAG,QAAQ,CAAgB,IAAI,CAAC,CAAC;IAC1D,MAAM,IAAI,GAAG,MAAM,CAAiB,IAAI,CAAC,CAAC;IAE1C,+EAA+E;IAC/E,6EAA6E;IAC7E,6EAA6E;IAC7E,6EAA6E;IAC7E,gDAAgD;IAChD,SAAS,CAAC,GAAG,EAAE;QACb,OAAO,CAAC,KAAK,CAAC,CAAC;QACf,SAAS,CAAC,IAAI,CAAC,CAAC;QAChB,IAAI,CAAC,OAAO,EAAE,QAAQ,CAAC,EAAE,GAAG,EAAE,CAAC,EAAE,CAAC,CAAC;IACrC,CAAC,EAAE,CAAC,OAAO,CAAC,OAAO,CAAC,CAAC,CAAC;IAEtB,MAAM,MAAM,GAAG,KAAK,EAAE,QAAkB,EAAiB,EAAE;QACzD,OAAO,CAAC,IAAI,CAAC,CAAC;QACd,SAAS,CAAC,IAAI,CAAC,CAAC;QAChB,IAAI,CAAC;YACH,MAAM,MAAM,CAAC,MAAM,CAAC,KAAK,EAAE,OAAO,CAAC,OAAO,EAAE,QAAQ,EAAE,QAAQ,CAAC,CAAC;YAChE,SAAS,EAAE,CAAC;QACd,CAAC;QAAC,OAAO,KAAK,EAAE,CAAC;YACf,2EAA2E;YAC3E,yEAAyE;YACzE,sCAAsC;YACtC,SAAS,CAAC,SAAS,CAAC,KAAK,CAAC,CAAC,CAAC;QAC9B,CAAC;gBAAS,CAAC;YACT,OAAO,CAAC,KAAK,CAAC,CAAC;QACjB,CAAC;IACH,CAAC,CAAC;IAEF,OAAO,CACL,mBAAS,SAAS,EAAC,YAAY,aAC7B,cAAK,SAAS,EAAC,oBAAoB,EAAC,GAAG,EAAE,IAAI,YAC3C,eAAK,SAAS,EAAC,SAAS,aACtB,kBAAQ,SAAS,EAAC,iBAAiB,aACjC,uBAAK,OAAO,CAAC,OAAO,GAAM,EAC1B,eAAM,SAAS,EAAE,iBAAiB,OAAO,CAAC,OAAO,EAAE,YAAG,OAAO,CAAC,OAAO,GAAQ,IACtE,EACT,KAAC,OAAO,IAAC,OAAO,EAAE,OAAO,GAAI,EAC7B,YAAG,SAAS,EAAC,SAAS,YAAE,OAAO,CAAC,OAAO,GAAK,EAE5C,KAAC,MAAM,IAAC,MAAM,EAAE,MAAM,EAAE,KAAK,EAAE,KAAK,EAAE,OAAO,EAAE,OAAO,EAAE,OAAO,EAAE,OAAO,GAAI,IACxE,GACF,EAEN,iBAAO,SAAS,EAAC,oBAAoB,aACnC,mBAAS,SAAS,EAAC,SAAS,aAC1B,oCAAiB,EAChB,OAAO,CAAC,QAAQ,KAAK,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,CAClC,aAAG,SAAS,EAAC,aAAa,aACvB,OAAO,CAAC,QAAQ,CAAC,QAAQ,UAAM,OAAO,CAAC,QAAQ,CAAC,EAAE,cAAK,IAAI,CAAC,OAAO,CAAC,QAAQ,CAAC,EAAE,CAAC,EAChF,OAAO,CAAC,QAAQ,CAAC,IAAI,KAAK,SAAS,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,0CAAM,OAAO,CAAC,QAAQ,CAAC,IAAI,IAAI,IAC3E,CACL,EACA,MAAM,KAAK,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,YAAG,SAAS,EAAC,YAAY,YAAE,MAAM,GAAK,EAChE,aAAG,SAAS,EAAC,YAAY,aACvB,iBACE,IAAI,EAAC,QAAQ,EACb,SAAS,EAAC,YAAY,EACtB,QAAQ,EAAE,IAAI,IAAI,CAAC,OAAO,CAAC,UAAU,EACrC,OAAO,EAAE,GAAG,EAAE,CAAC,KAAK,MAAM,CAAC,UAAU,CAAC,EACtC,KAAK,EACH,OAAO,CAAC,UAAU;4CAChB,CAAC,CAAC,0CAA0C;4CAC5C,CAAC,CAAC,qJAAqJ,wBAIpJ,EACT,iBAAQ,IAAI,EAAC,QAAQ,EAAC,QAAQ,EAAE,IAAI,EAAE,OAAO,EAAE,GAAG,EAAE,CAAC,KAAK,MAAM,CAAC,UAAU,CAAC,uBAEnE,IACP,EACH,OAAO,CAAC,UAAU,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,CAC3B,YAAG,SAAS,EAAC,SAAS,EAAC,KAAK,EAAE,EAAE,SAAS,EAAE,QAAQ,EAAE,2FAEjD,CACL,IACO,EAEV,mBAAS,SAAS,EAAC,SAAS,aAC1B,sCAAmB,EACnB,KAAC,SAAS,IAAC,OAAO,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,GAAG,EAAE,GAAG,GAAI,IACjD,EAEV,mBAAS,SAAS,EAAC,SAAS,aAC1B,oCAAiB,EACjB,KAAC,QAAQ,IAAC,OAAO,EAAE,OAAO,GAAI,IACtB,EAEV,mBAAS,SAAS,EAAC,SAAS,aAC1B,sCAAmB,EACnB,KAAC,cAAc,IAAC,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,OAAO,GAAI,IAC5C,IACJ,IACA,CACX,CAAC;AACJ,CAAC;AAED;;;;;;;;;;;;;;;;;;GAkBG;AACH,SAAS,OAAO,CAAC,EAAE,OAAO,EAAqC;IAC7D,MAAM,EAAE,GAAG,QAAQ,CAAC,OAAO,CAAC,CAAC;IAC7B,IAAI,EAAE,CAAC,MAAM,KAAK,CAAC,IAAI,EAAE,CAAC,IAAI,KAAK,SAAS,IAAI,EAAE,CAAC,MAAM,CAAC,MAAM,KAAK,CAAC;QAAE,OAAO,IAAI,CAAC;IAEpF,OAAO,CACL,eAAK,SAAS,EAAC,WAAW,aACxB,KAAC,KAAK,IAAC,EAAE,EAAE,EAAE,GAAI,EAChB,EAAE,CAAC,IAAI,KAAK,SAAS,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,CAC9B,aAAG,SAAS,EAAC,gBAAgB,aAC3B,wBACG,EAAE,CAAC,IAAI,CAAC,EAAE,GAAG,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EACzB,MAAM,CAAC,EAAE,CAAC,IAAI,CAAC,EAAE,CAAC,WACjB,EACJ,yBAAO,EAAE,CAAC,IAAI,CAAC,IAAI,GAAQ,EAC3B,4BACG,MAAM,CAAC,EAAE,CAAC,IAAI,CAAC,IAAI,CAAC,KAAK,CAAC,YAAG,MAAM,CAAC,EAAE,CAAC,IAAI,CAAC,IAAI,CAAC,MAAM,CAAC,aAAI,GAAG,EAC/D,MAAM,CAAC,EAAE,CAAC,IAAI,CAAC,EAAE,CAAC,KAAK,CAAC,YAAG,MAAM,CAAC,EAAE,CAAC,IAAI,CAAC,EAAE,CAAC,MAAM,CAAC,IAC/C,IACN,CACL,EACA,EAAE,CAAC,MAAM,KAAK,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,CACxB,aAAG,SAAS,EAAC,gBAAgB,aAC3B,wBAAI,MAAM,CAAC,EAAE,CAAC,MAAM,CAAC,WAAQ,EAC7B,yBAAO,KAAK,CAAC,EAAE,CAAC,OAAO,EAAE,QAAQ,CAAC,GAAQ,EACzC,EAAE,CAAC,QAAQ,KAAK,SAAS,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,2CAAmB,EAAE,CAAC,QAAQ,IAAS,IACzE,CACL,IACG,CACP,CAAC;AACJ,CAAC;AAED;;;;;;;;;;;;GAYG;AACH,SAAS,KAAK,CAAC,EAAE,EAAE,EAA2B;IAC5C,IAAI,CAAC,EAAE,CAAC,QAAQ;QAAE,OAAO,IAAI,CAAC;IAC9B,IAAI,EAAE,CAAC,MAAM,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;QAC3B,OAAO,CACL,aAAG,SAAS,EAAC,+BAA+B,aAC1C,2CAAuB,EACvB,kDAAiC,IAC/B,CACL,CAAC;IACJ,CAAC;IAED,MAAM,KAAK,GAAG,EAAE,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC;IAEpC,OAAO,CACL,aAAG,SAAS,EAAC,gCAAgC,aAC3C,wBACG,KAAK,CAAC,GAAG,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,IAAI,CAAC,SAAS,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,EAC9C,EAAE,CAAC,MAAM,CAAC,MAAM,GAAG,KAAK,CAAC,MAAM,CAAC,CAAC,CAAC,KAAK,MAAM,CAAC,EAAE,CAAC,MAAM,CAAC,MAAM,GAAG,KAAK,CAAC,MAAM,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,IACpF,EACJ,yBAAO,KAAK,CAAC,KAAK,CAAC,GAAQ,EAC1B,KAAK,CAAC,CAAC,CAAC,EAAE,IAAI,KAAK,SAAS,IAAI,KAAK,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,CACzD,0BAAQ,KAAK,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,GAAS,CACtC,EACA,KAAK,CAAC,KAAK,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,CAC1C,gBAAO,SAAS,EAAC,eAAe,EAAC,KAAK,EAAC,oCAAoC,qCAEnE,CACT,IACC,CACL,CAAC;AACJ,CAAC;AAED;;;;;GAKG;AACH,SAAS,KAAK,CAAC,EAAE,KAAK,EAAE,MAAM,EAAuD;IACnF,MAAM,CAAC,GAAG,GAAG,KAAK,GAAG,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,GAAG,MAAM,CAAC,KAAK,CAAC,EAAE,CAAC;IACpD,MAAM,CAAC,GAAG,GAAG,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,GAAG,MAAM,CAAC,MAAM,CAAC,EAAE,CAAC;IACtD,IAAI,KAAK,KAAK,CAAC;QAAE,OAAO,GAAG,CAAC,UAAU,CAAC;IACvC,IAAI,MAAM,KAAK,CAAC;QAAE,OAAO,GAAG,CAAC,UAAU,CAAC;IACxC,OAAO,GAAG,CAAC,MAAM,CAAC,KAAK,CAAC;AAC1B,CAAC;AAED;;;;;;GAMG;AACH,SAAS,KAAK,CAAC,MAAyB;IACtC,MAAM,QAAQ,GAAG,CAAC,GAAG,IAAI,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC,MAAM,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC;IACnF,IAAI,QAAQ,CAAC,MAAM,GAAG,CAAC;QAAE,OAAO,QAAQ,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;IACpD,OAAO,CAAC,GAAG,IAAI,GAAG,CAAC,MAAM,CAAC,OAAO,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;AACxE,CAAC","sourcesContent":["/**\n * One render, whole: the picture on the left and everything that is not the\n * picture on the right.\n *\n * Apart from the build page because it is the deepest address on the surface and\n * the only one where the raster is the subject rather than the evidence. What\n * reaches it is a link from a change — the reviewer has already been told what\n * moved and why, and came here to look.\n */\n\nimport { useEffect, useRef, useState, type ReactElement } from 'react';\nimport type { Decision, SubjectView } from '../review-types.js';\nimport type { ReviewClient } from './client.js';\nimport type { Ruler } from './distance.js';\nimport { Findings } from './findings.js';\nimport { glanceOf, type Blamed, type Glance } from './glance.js';\nimport { SubjectHistory } from './history.js';\nimport { MovedHere } from './moved.js';\nimport { messageOf } from './shell.js';\nimport { count, number, when } from './text.js';\nimport { Viewer } from './viewer.js';\n\n/**\n * One subject: the render on the left, everything that is not the render on the\n * right.\n *\n * The split is not decoration. A reviewer decides from the defect list, the\n * record and the sentence explaining the verdict, and on a full-page capture all\n * three sat nine thousand pixels below the thing they are about. They are a\n * column now, and it does not move when the picture does.\n */\nexport function SubjectPanel({\n client,\n reviewer,\n build,\n subject,\n anchor,\n far,\n sourced,\n onDecided,\n}: {\n readonly client: ReviewClient;\n readonly reviewer: string;\n readonly build: string;\n readonly subject: SubjectView;\n /** The change this render is filed under, which distances are measured from. */\n readonly anchor?: string | undefined;\n /** How far each component here is from that change, when a diff was read. */\n readonly far?: Ruler | undefined;\n /** Whether the run resolved any source file — see {@link RegionTable}. */\n readonly sourced?: boolean | undefined;\n readonly onDecided: () => void;\n}): ReactElement {\n const [busy, setBusy] = useState(false);\n const [failed, setFailed] = useState<string | null>(null);\n const page = useRef<HTMLDivElement>(null);\n\n // This panel used to be remounted per subject, which threw away the comparison\n // mode and the magnification along with everything else — sixty re-clicks to\n // read twenty subjects the same way. Those are the reviewer's working method\n // and they stay; a failure from the last subject and its scroll position are\n // not, and are what the remount was really for.\n useEffect(() => {\n setBusy(false);\n setFailed(null);\n page.current?.scrollTo({ top: 0 });\n }, [subject.subject]);\n\n const decide = async (decision: Decision): Promise<void> => {\n setBusy(true);\n setFailed(null);\n try {\n await client.decide(build, subject.subject, decision, reviewer);\n onDecided();\n } catch (error) {\n // Kept on the page rather than swallowed. A decision that silently did not\n // land is a reviewer who believes a baseline was promoted and a next run\n // that reports the same change again.\n setFailed(messageOf(error));\n } finally {\n setBusy(false);\n }\n };\n\n return (\n <section className=\"va-subject\">\n <div className=\"va-stage va-scroll\" ref={page}>\n <div className=\"va-page\">\n <header className=\"va-subject-head\">\n <h3>{subject.subject}</h3>\n <span className={`va-verdict va-${subject.verdict}`}>{subject.verdict}</span>\n </header>\n <Glanced subject={subject} />\n <p className=\"va-said\">{subject.because}</p>\n\n <Viewer client={client} build={build} subject={subject} sourced={sourced} />\n </div>\n </div>\n\n <aside className=\"va-aside va-scroll\">\n <section className=\"va-card\">\n <h2>Decision</h2>\n {subject.decision === null ? null : (\n <p className=\"va-decision\">\n {subject.decision.decision} by {subject.decision.by} · {when(subject.decision.at)}\n {subject.decision.note === undefined ? null : <> — {subject.decision.note}</>}\n </p>\n )}\n {failed === null ? null : <p className=\"va-failure\">{failed}</p>}\n <p className=\"va-actions\">\n <button\n type=\"button\"\n className=\"va-approve\"\n disabled={busy || !subject.approvable}\n onClick={() => void decide('approved')}\n title={\n subject.approvable\n ? 'Make this build’s candidate the baseline'\n : 'This run kept no candidate image, so there is nothing to promote. Approving would mean rendering one now, which is recording rather than promoting.'\n }\n >\n Approve\n </button>\n <button type=\"button\" disabled={busy} onClick={() => void decide('rejected')}>\n Reject\n </button>\n </p>\n {subject.approvable ? null : (\n <p className=\"va-note\" style={{ marginTop: '0.5rem' }}>\n No candidate was uploaded for this subject, so it cannot be approved here.\n </p>\n )}\n </section>\n\n <section className=\"va-card\">\n <h2>What moved</h2>\n <MovedHere subject={subject} anchor={anchor} far={far} />\n </section>\n\n <section className=\"va-card\">\n <h2>Findings</h2>\n <Findings subject={subject} />\n </section>\n\n <section className=\"va-card\">\n <h2>The record</h2>\n <SubjectHistory client={client} subject={subject} />\n </section>\n </aside>\n </section>\n );\n}\n\n/**\n * The three measurements, before the sentence that used to carry them.\n *\n * A reviewer arriving here has one question — *what changed* — and the line that\n * answered it was prose: pixels, then a name, then a resize, then a caveat, in\n * one two-hundred-character sentence with the interesting part in the middle.\n * Read once. Skipped on the second subject, because the shape of it is the same\n * every time and a reader who has parsed it twice stops looking for where the\n * numbers moved.\n *\n * Three cells instead, each a number over its unit. Nothing to read in order,\n * because these are three answers to one question and a reviewer takes them at a\n * glance or not at all. The sentence stays underneath and stays quiet: it is the\n * run's own words, it carries the collection's complaints, and demoting it is\n * not the same as dropping it.\n *\n * `null` for a subject with nothing to measure. An unchanged render would get\n * three cells reading `—`, which is a page insisting it has something to say.\n */\nfunction Glanced({ subject }: { readonly subject: SubjectView }): ReactElement | null {\n const at = glanceOf(subject);\n if (at.pixels === 0 && at.grew === undefined && at.blamed.length === 0) return null;\n\n return (\n <div className=\"va-glance\">\n <Cause at={at} />\n {at.grew === undefined ? null : (\n <p className=\"va-glance-cell\">\n <b>\n {at.grew.by > 0 ? '+' : ''}\n {number(at.grew.by)} px\n </b>\n <span>{at.grew.axis}</span>\n <small>\n {number(at.grew.from.width)}×{number(at.grew.from.height)} →{' '}\n {number(at.grew.to.width)}×{number(at.grew.to.height)}\n </small>\n </p>\n )}\n {at.pixels === 0 ? null : (\n <p className=\"va-glance-cell\">\n <b>{number(at.pixels)} px</b>\n <span>{count(at.regions, 'region')}</span>\n {at.landedIn === undefined ? null : <small>drawn over {at.landedIn}</small>}\n </p>\n )}\n </div>\n );\n}\n\n/**\n * Who moved, per the hashes — which is not who the regions are named after.\n *\n * The distinction this cell exists for. A region is named from where its box\n * landed, so an edit that pushed its container reports under the container;\n * these entries compared digests and never saw a pixel. On the subject that\n * prompted this, the prose said `Card` and the hashes say `Button`, and only one\n * of those is a file anybody edited.\n *\n * A cause no region names is marked rather than left implicit. That is the two\n * tiers disagreeing about where a change is, and it is the state a reviewer\n * should distrust the picture in.\n */\nfunction Cause({ at }: { readonly at: Glance }): ReactElement | null {\n if (!at.measured) return null;\n if (at.blamed.length === 0) {\n return (\n <p className=\"va-glance-cell va-glance-none\">\n <b>nothing owned it</b>\n <span>every digest matched</span>\n </p>\n );\n }\n\n const shown = at.blamed.slice(0, 2);\n\n return (\n <p className=\"va-glance-cell va-glance-cause\">\n <b>\n {shown.map((each) => each.component).join(', ')}\n {at.blamed.length > shown.length ? ` +${number(at.blamed.length - shown.length)}` : ''}\n </b>\n <span>{sense(shown)}</span>\n {shown[0]?.grew === undefined || shown.length > 1 ? null : (\n <small>{sized(shown[0].grew)}</small>\n )}\n {shown.every((each) => each.drawn) ? null : (\n <small className=\"va-glance-off\" title=\"the hashes name it; no region does\">\n no region carries it\n </small>\n )}\n </p>\n );\n}\n\n/**\n * A box delta, on the axes that moved.\n *\n * Both axes get the cross form and one gets a word, because *+36 x +8 px* with a\n * zero in it is a reader working out which number is the one that changed.\n */\nfunction sized({ width, height }: { readonly width: number; readonly height: number }): string {\n const w = `${width > 0 ? '+' : ''}${number(width)}`;\n const h = `${height > 0 ? '+' : ''}${number(height)}`;\n if (width === 0) return `${h} px tall`;\n if (height === 0) return `${w} px wide`;\n return `${w} × ${h} px`;\n}\n\n/**\n * The bands, once each, in the order they were found.\n *\n * A component present on one side only says that instead: `added` and `removed`\n * are not bands and a list that folded them in would report a control that did\n * not exist before as having changed its geometry.\n */\nfunction sense(blamed: readonly Blamed[]): string {\n const presence = [...new Set(blamed.map((each) => each.presence).filter(Boolean))];\n if (presence.length > 0) return presence.join(', ');\n return [...new Set(blamed.flatMap((each) => each.bands))].join(' · ');\n}\n"]}
@@ -0,0 +1,129 @@
1
+ /**
2
+ * The words the surface says, apart from the components that arrange them.
3
+ *
4
+ * A review page is read by a person deciding whether to merge, and the report it
5
+ * draws from is written for a machine. Everything here is that translation, and
6
+ * it is kept in one file because the two halves of the surface — the docket in
7
+ * [`review.tsx`](./review.tsx) and the viewer in [`viewer.tsx`](./viewer.tsx) —
8
+ * would otherwise each grow their own.
9
+ *
10
+ * Nothing here decides anything. A phrase that changed what a reviewer concludes
11
+ * would belong to the store, not to the presentation.
12
+ */
13
+ /** `1 pixel` / `8,818 pixels`, so a count of one does not read as a template. */
14
+ export declare function count(value: number, noun: string, plural?: string): string;
15
+ /** Grouped, because six digits of pixels is a number nobody reads as a quantity. */
16
+ export declare function number(value: number): string;
17
+ /**
18
+ * A finding's headline, in the words a reviewer thinks in.
19
+ *
20
+ * `FindingRecord` carries a rule id and a sentence, and the sentence is written
21
+ * to follow a noun the report never prints — which is how `label-mismatch reads
22
+ * "SNKR. shop" and is named …` reaches a page. The id is the stable half and is
23
+ * what an ignore list names, so it is kept and shown last; this is the half a
24
+ * person reads first.
25
+ *
26
+ * Unknown rules are humanised rather than dropped. A rule this table has not
27
+ * heard of is a newer report, and printing the slug is a worse page than
28
+ * printing nothing is a wrong one.
29
+ */
30
+ export declare function headline(rule: string): string;
31
+ /**
32
+ * The element itself, out of the landmark phrase that leads to it.
33
+ *
34
+ * `where` is spoken outside-in — `navigation → link "SNKR.shop"` — because the
35
+ * path is how a person finds the thing on screen. The last hop is *what* it is,
36
+ * and it is the noun the finding's sentence was written to follow.
37
+ */
38
+ export declare function element(where: string | undefined): string | undefined;
39
+ /** A clause the report wrote to continue a noun, given back its capital and stop. */
40
+ export declare function sentence(what: string): string;
41
+ /**
42
+ * A timestamp a person reads, in the zone the record was written in.
43
+ *
44
+ * `2026-08-29T07:34:47.404Z` is a sortable key, and a review page that prints one
45
+ * is showing its storage. UTC is kept and named rather than converted to the
46
+ * reader's zone: a build is compared against other builds, and two rows in the
47
+ * same list must be readable against each other by whoever opens them.
48
+ *
49
+ * The string back unchanged if it is not a timestamp — the field is whatever the
50
+ * writer put there, and a surface that silently prints `Invalid Date` has lost
51
+ * the only copy.
52
+ */
53
+ export declare function when(at: string): string;
54
+ /**
55
+ * A backticked sentence split into what is prose and what is not.
56
+ *
57
+ * The report writes subject names and fingerprints in backticks, which is the
58
+ * right thing for a file somebody reads in a terminal and the wrong thing here:
59
+ * rendered as text, the marks survive into the page and a reviewer reads
60
+ * ``story:product-card--sale`` with the quotes still on. Splitting rather than
61
+ * stripping, because the mark is carrying a real distinction — that run of
62
+ * characters is an identifier the reader may have to match against something
63
+ * else, and it should be set as one.
64
+ *
65
+ * An unclosed backtick keeps its mark and stays prose. The alternative is to
66
+ * treat the rest of the sentence as an identifier on the strength of one
67
+ * character.
68
+ */
69
+ export declare function segments(text: string): readonly {
70
+ readonly text: string;
71
+ readonly code: boolean;
72
+ }[];
73
+ /**
74
+ * The size of a change, in the terms it is actually reviewed in.
75
+ *
76
+ * `3,885 pixels differ` is the number every tool in this category leads with, and
77
+ * it is the least useful one available. It answers *how much of the raster is a
78
+ * different colour*, which nobody is deciding anything about, and it degrades in
79
+ * exactly the wrong direction: the bigger the edit, the more the total collapses
80
+ * toward one meaningless figure. A restyle that moved a button in nine places and
81
+ * a page that shifted down two pixels can print the same number.
82
+ *
83
+ * So the count leads with what a reviewer is counting:
84
+ *
85
+ * 1. **Changes** — distinct fingerprints, the shape of the difference with
86
+ * position and values removed. Two regions with one shape are one thing
87
+ * happening twice. This is the number of decisions in front of the reviewer.
88
+ * 2. **Regions** — where it happened. Distinct from the above precisely when an
89
+ * edit lands in many places, which is when the distinction earns its keep.
90
+ * 3. **Pixels** — kept, last, and per region, because the areas are the ranking.
91
+ *
92
+ * The total is printed only when the listed regions do not already carry it.
93
+ * Given three regions of 2,140, 900 and 420 there is no version of `3,460 in all`
94
+ * that tells a reader anything the line above did not.
95
+ */
96
+ export interface Magnitude {
97
+ /** Distinct fingerprints; absent when no region carried one. */
98
+ readonly changes?: number;
99
+ readonly regions: number;
100
+ /** Regions the run localised but recorded no shape for. */
101
+ readonly unshaped: number;
102
+ /** Per-region areas, largest first. */
103
+ readonly areas: readonly number[];
104
+ readonly total: number;
105
+ }
106
+ export declare function magnitudeOf(subject: {
107
+ readonly changedPixels: number;
108
+ readonly regions: readonly {
109
+ readonly pixels?: number;
110
+ readonly fingerprint?: string;
111
+ }[];
112
+ }): Magnitude;
113
+ /** The long form, for a surface with a line to spend on it. */
114
+ export declare function magnitude(subject: {
115
+ readonly changedPixels: number;
116
+ readonly regions: readonly {
117
+ readonly pixels?: number;
118
+ readonly fingerprint?: string;
119
+ }[];
120
+ }): string;
121
+ /** The short form, for a rail item that has to stay one line. */
122
+ export declare function briefly(subject: {
123
+ readonly changedPixels: number;
124
+ readonly regions: readonly {
125
+ readonly pixels?: number;
126
+ readonly fingerprint?: string;
127
+ }[];
128
+ }): string;
129
+ //# sourceMappingURL=text.d.ts.map
@@ -0,0 +1,164 @@
1
+ /**
2
+ * The words the surface says, apart from the components that arrange them.
3
+ *
4
+ * A review page is read by a person deciding whether to merge, and the report it
5
+ * draws from is written for a machine. Everything here is that translation, and
6
+ * it is kept in one file because the two halves of the surface — the docket in
7
+ * [`review.tsx`](./review.tsx) and the viewer in [`viewer.tsx`](./viewer.tsx) —
8
+ * would otherwise each grow their own.
9
+ *
10
+ * Nothing here decides anything. A phrase that changed what a reviewer concludes
11
+ * would belong to the store, not to the presentation.
12
+ */
13
+ /** `1 pixel` / `8,818 pixels`, so a count of one does not read as a template. */
14
+ export function count(value, noun, plural) {
15
+ return `${number(value)} ${value === 1 ? noun : (plural ?? `${noun}s`)}`;
16
+ }
17
+ /** Grouped, because six digits of pixels is a number nobody reads as a quantity. */
18
+ export function number(value) {
19
+ return value.toLocaleString('en-US');
20
+ }
21
+ /**
22
+ * A finding's headline, in the words a reviewer thinks in.
23
+ *
24
+ * `FindingRecord` carries a rule id and a sentence, and the sentence is written
25
+ * to follow a noun the report never prints — which is how `label-mismatch reads
26
+ * "SNKR. shop" and is named …` reaches a page. The id is the stable half and is
27
+ * what an ignore list names, so it is kept and shown last; this is the half a
28
+ * person reads first.
29
+ *
30
+ * Unknown rules are humanised rather than dropped. A rule this table has not
31
+ * heard of is a newer report, and printing the slug is a worse page than
32
+ * printing nothing is a wrong one.
33
+ */
34
+ export function headline(rule) {
35
+ return TITLES[rule] ?? `${rule.slice(0, 1).toUpperCase()}${rule.slice(1).replace(/-/g, ' ')}`;
36
+ }
37
+ const TITLES = {
38
+ 'control-without-name': 'Nothing announces this control',
39
+ 'image-without-alt': 'An image with nothing to read',
40
+ 'heading-level-skipped': 'The heading outline skips a level',
41
+ 'nested-interactive': 'A control inside another control',
42
+ 'dangling-reference': 'A reference that points at nothing',
43
+ 'label-mismatch': 'The name and the visible words disagree',
44
+ 'duplicate-landmark': 'Two landmarks nothing tells apart',
45
+ 'table-without-headers': 'A table with no header cells',
46
+ 'positive-tabindex': 'Focus order forced out of document order',
47
+ untranslated: 'The same string in two locales',
48
+ 'overflows-container': 'A box outside the box that contains it',
49
+ };
50
+ /**
51
+ * The element itself, out of the landmark phrase that leads to it.
52
+ *
53
+ * `where` is spoken outside-in — `navigation → link "SNKR.shop"` — because the
54
+ * path is how a person finds the thing on screen. The last hop is *what* it is,
55
+ * and it is the noun the finding's sentence was written to follow.
56
+ */
57
+ export function element(where) {
58
+ return where?.split('→').at(-1)?.trim();
59
+ }
60
+ /** A clause the report wrote to continue a noun, given back its capital and stop. */
61
+ export function sentence(what) {
62
+ const trimmed = what.trim();
63
+ const stopped = /[.!?]$/.test(trimmed) ? trimmed : `${trimmed}.`;
64
+ return `${stopped.slice(0, 1).toUpperCase()}${stopped.slice(1)}`;
65
+ }
66
+ /**
67
+ * A timestamp a person reads, in the zone the record was written in.
68
+ *
69
+ * `2026-08-29T07:34:47.404Z` is a sortable key, and a review page that prints one
70
+ * is showing its storage. UTC is kept and named rather than converted to the
71
+ * reader's zone: a build is compared against other builds, and two rows in the
72
+ * same list must be readable against each other by whoever opens them.
73
+ *
74
+ * The string back unchanged if it is not a timestamp — the field is whatever the
75
+ * writer put there, and a surface that silently prints `Invalid Date` has lost
76
+ * the only copy.
77
+ */
78
+ export function when(at) {
79
+ const date = new Date(at);
80
+ if (Number.isNaN(date.getTime()))
81
+ return at;
82
+ const day = date.toLocaleDateString('en-GB', {
83
+ day: 'numeric',
84
+ month: 'short',
85
+ timeZone: 'UTC',
86
+ year: 'numeric',
87
+ });
88
+ const time = date.toLocaleTimeString('en-GB', {
89
+ hour: '2-digit',
90
+ minute: '2-digit',
91
+ timeZone: 'UTC',
92
+ });
93
+ return `${day}, ${time} UTC`;
94
+ }
95
+ /**
96
+ * A backticked sentence split into what is prose and what is not.
97
+ *
98
+ * The report writes subject names and fingerprints in backticks, which is the
99
+ * right thing for a file somebody reads in a terminal and the wrong thing here:
100
+ * rendered as text, the marks survive into the page and a reviewer reads
101
+ * ``story:product-card--sale`` with the quotes still on. Splitting rather than
102
+ * stripping, because the mark is carrying a real distinction — that run of
103
+ * characters is an identifier the reader may have to match against something
104
+ * else, and it should be set as one.
105
+ *
106
+ * An unclosed backtick keeps its mark and stays prose. The alternative is to
107
+ * treat the rest of the sentence as an identifier on the strength of one
108
+ * character.
109
+ */
110
+ export function segments(text) {
111
+ const parts = text.split('`');
112
+ if (parts.length % 2 === 0)
113
+ return [{ text, code: false }];
114
+ return parts
115
+ .map((part, index) => ({ text: part, code: index % 2 === 1 }))
116
+ .filter((part) => part.text !== '');
117
+ }
118
+ export function magnitudeOf(subject) {
119
+ const shapes = new Set(subject.regions
120
+ .map((region) => region.fingerprint)
121
+ .filter((shape) => shape !== undefined));
122
+ return {
123
+ ...(shapes.size === 0 ? {} : { changes: shapes.size }),
124
+ regions: subject.regions.length,
125
+ unshaped: subject.regions.filter((region) => region.fingerprint === undefined).length,
126
+ areas: subject.regions
127
+ .map((region) => region.pixels ?? 0)
128
+ .sort((left, right) => right - left),
129
+ total: subject.changedPixels,
130
+ };
131
+ }
132
+ /** How many areas are named before the rest become a count. */
133
+ const NAMED = 3;
134
+ /** The long form, for a surface with a line to spend on it. */
135
+ export function magnitude(subject) {
136
+ const size = magnitudeOf(subject);
137
+ // Nothing localised the difference, so the total is all there is to say — and
138
+ // saying it plainly is better than dressing up a number the run did not break
139
+ // down. The absence is the finding, and it belongs to whoever wrote the run.
140
+ if (size.regions === 0)
141
+ return `${count(size.total, 'pixel')} differ, in regions this run did not record`;
142
+ const where = size.changes === undefined
143
+ ? count(size.regions, 'region')
144
+ : `${count(size.changes, 'change')} over ${count(size.regions, 'region')}`;
145
+ const named = size.areas.slice(0, NAMED);
146
+ const rest = size.areas.length - named.length;
147
+ const areas = `${named.map(number).join(', ')} ${named[0] === 1 && named.length === 1 ? 'pixel' : 'pixels'}${rest === 0 ? '' : ` and ${number(rest)} smaller`}`;
148
+ const carried = named.reduce((sum, area) => sum + area, 0);
149
+ const all = rest === 0 && carried === size.total ? '' : ` · ${number(size.total)} in all`;
150
+ const partial = size.changes === undefined || size.unshaped === 0
151
+ ? ''
152
+ : ` · ${count(size.unshaped, 'region')} the run recorded no shape for`;
153
+ return `${where}: ${areas}${all}${partial}`;
154
+ }
155
+ /** The short form, for a rail item that has to stay one line. */
156
+ export function briefly(subject) {
157
+ const size = magnitudeOf(subject);
158
+ if (size.regions === 0)
159
+ return count(size.total, 'pixel');
160
+ return size.changes === undefined
161
+ ? `${count(size.regions, 'region')} · ${number(size.total)}px`
162
+ : `${count(size.changes, 'change')} · ${count(size.regions, 'region')} · ${number(size.total)}px`;
163
+ }
164
+ //# sourceMappingURL=text.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"text.js","sourceRoot":"","sources":["../../src/ui/text.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * The words the surface says, apart from the components that arrange them.\n *\n * A review page is read by a person deciding whether to merge, and the report it\n * draws from is written for a machine. Everything here is that translation, and\n * it is kept in one file because the two halves of the surface — the docket in\n * [`review.tsx`](./review.tsx) and the viewer in [`viewer.tsx`](./viewer.tsx) —\n * would otherwise each grow their own.\n *\n * Nothing here decides anything. A phrase that changed what a reviewer concludes\n * would belong to the store, not to the presentation.\n */\n\n/** `1 pixel` / `8,818 pixels`, so a count of one does not read as a template. */\nexport function count(value: number, noun: string, plural?: string): string {\n return `${number(value)} ${value === 1 ? noun : (plural ?? `${noun}s`)}`;\n}\n\n/** Grouped, because six digits of pixels is a number nobody reads as a quantity. */\nexport function number(value: number): string {\n return value.toLocaleString('en-US');\n}\n\n/**\n * A finding's headline, in the words a reviewer thinks in.\n *\n * `FindingRecord` carries a rule id and a sentence, and the sentence is written\n * to follow a noun the report never prints — which is how `label-mismatch reads\n * \"SNKR. shop\" and is named …` reaches a page. The id is the stable half and is\n * what an ignore list names, so it is kept and shown last; this is the half a\n * person reads first.\n *\n * Unknown rules are humanised rather than dropped. A rule this table has not\n * heard of is a newer report, and printing the slug is a worse page than\n * printing nothing is a wrong one.\n */\nexport function headline(rule: string): string {\n return TITLES[rule] ?? `${rule.slice(0, 1).toUpperCase()}${rule.slice(1).replace(/-/g, ' ')}`;\n}\n\nconst TITLES: Readonly<Record<string, string>> = {\n 'control-without-name': 'Nothing announces this control',\n 'image-without-alt': 'An image with nothing to read',\n 'heading-level-skipped': 'The heading outline skips a level',\n 'nested-interactive': 'A control inside another control',\n 'dangling-reference': 'A reference that points at nothing',\n 'label-mismatch': 'The name and the visible words disagree',\n 'duplicate-landmark': 'Two landmarks nothing tells apart',\n 'table-without-headers': 'A table with no header cells',\n 'positive-tabindex': 'Focus order forced out of document order',\n untranslated: 'The same string in two locales',\n 'overflows-container': 'A box outside the box that contains it',\n};\n\n/**\n * The element itself, out of the landmark phrase that leads to it.\n *\n * `where` is spoken outside-in — `navigation → link \"SNKR.shop\"` — because the\n * path is how a person finds the thing on screen. The last hop is *what* it is,\n * and it is the noun the finding's sentence was written to follow.\n */\nexport function element(where: string | undefined): string | undefined {\n return where?.split('→').at(-1)?.trim();\n}\n\n/** A clause the report wrote to continue a noun, given back its capital and stop. */\nexport function sentence(what: string): string {\n const trimmed = what.trim();\n const stopped = /[.!?]$/.test(trimmed) ? trimmed : `${trimmed}.`;\n return `${stopped.slice(0, 1).toUpperCase()}${stopped.slice(1)}`;\n}\n\n/**\n * A timestamp a person reads, in the zone the record was written in.\n *\n * `2026-08-29T07:34:47.404Z` is a sortable key, and a review page that prints one\n * is showing its storage. UTC is kept and named rather than converted to the\n * reader's zone: a build is compared against other builds, and two rows in the\n * same list must be readable against each other by whoever opens them.\n *\n * The string back unchanged if it is not a timestamp — the field is whatever the\n * writer put there, and a surface that silently prints `Invalid Date` has lost\n * the only copy.\n */\nexport function when(at: string): string {\n const date = new Date(at);\n if (Number.isNaN(date.getTime())) return at;\n\n const day = date.toLocaleDateString('en-GB', {\n day: 'numeric',\n month: 'short',\n timeZone: 'UTC',\n year: 'numeric',\n });\n const time = date.toLocaleTimeString('en-GB', {\n hour: '2-digit',\n minute: '2-digit',\n timeZone: 'UTC',\n });\n\n return `${day}, ${time} UTC`;\n}\n\n/**\n * A backticked sentence split into what is prose and what is not.\n *\n * The report writes subject names and fingerprints in backticks, which is the\n * right thing for a file somebody reads in a terminal and the wrong thing here:\n * rendered as text, the marks survive into the page and a reviewer reads\n * ``story:product-card--sale`` with the quotes still on. Splitting rather than\n * stripping, because the mark is carrying a real distinction — that run of\n * characters is an identifier the reader may have to match against something\n * else, and it should be set as one.\n *\n * An unclosed backtick keeps its mark and stays prose. The alternative is to\n * treat the rest of the sentence as an identifier on the strength of one\n * character.\n */\nexport function segments(text: string): readonly { readonly text: string; readonly code: boolean }[] {\n const parts = text.split('`');\n if (parts.length % 2 === 0) return [{ text, code: false }];\n\n return parts\n .map((part, index) => ({ text: part, code: index % 2 === 1 }))\n .filter((part) => part.text !== '');\n}\n\n/**\n * The size of a change, in the terms it is actually reviewed in.\n *\n * `3,885 pixels differ` is the number every tool in this category leads with, and\n * it is the least useful one available. It answers *how much of the raster is a\n * different colour*, which nobody is deciding anything about, and it degrades in\n * exactly the wrong direction: the bigger the edit, the more the total collapses\n * toward one meaningless figure. A restyle that moved a button in nine places and\n * a page that shifted down two pixels can print the same number.\n *\n * So the count leads with what a reviewer is counting:\n *\n * 1. **Changes** — distinct fingerprints, the shape of the difference with\n * position and values removed. Two regions with one shape are one thing\n * happening twice. This is the number of decisions in front of the reviewer.\n * 2. **Regions** — where it happened. Distinct from the above precisely when an\n * edit lands in many places, which is when the distinction earns its keep.\n * 3. **Pixels** — kept, last, and per region, because the areas are the ranking.\n *\n * The total is printed only when the listed regions do not already carry it.\n * Given three regions of 2,140, 900 and 420 there is no version of `3,460 in all`\n * that tells a reader anything the line above did not.\n */\nexport interface Magnitude {\n /** Distinct fingerprints; absent when no region carried one. */\n readonly changes?: number;\n readonly regions: number;\n /** Regions the run localised but recorded no shape for. */\n readonly unshaped: number;\n /** Per-region areas, largest first. */\n readonly areas: readonly number[];\n readonly total: number;\n}\n\nexport function magnitudeOf(subject: {\n readonly changedPixels: number;\n readonly regions: readonly { readonly pixels?: number; readonly fingerprint?: string }[];\n}): Magnitude {\n const shapes = new Set(\n subject.regions\n .map((region) => region.fingerprint)\n .filter((shape): shape is string => shape !== undefined),\n );\n\n return {\n ...(shapes.size === 0 ? {} : { changes: shapes.size }),\n regions: subject.regions.length,\n unshaped: subject.regions.filter((region) => region.fingerprint === undefined).length,\n areas: subject.regions\n .map((region) => region.pixels ?? 0)\n .sort((left, right) => right - left),\n total: subject.changedPixels,\n };\n}\n\n/** How many areas are named before the rest become a count. */\nconst NAMED = 3;\n\n/** The long form, for a surface with a line to spend on it. */\nexport function magnitude(subject: {\n readonly changedPixels: number;\n readonly regions: readonly { readonly pixels?: number; readonly fingerprint?: string }[];\n}): string {\n const size = magnitudeOf(subject);\n\n // Nothing localised the difference, so the total is all there is to say — and\n // saying it plainly is better than dressing up a number the run did not break\n // down. The absence is the finding, and it belongs to whoever wrote the run.\n if (size.regions === 0) return `${count(size.total, 'pixel')} differ, in regions this run did not record`;\n\n const where =\n size.changes === undefined\n ? count(size.regions, 'region')\n : `${count(size.changes, 'change')} over ${count(size.regions, 'region')}`;\n\n const named = size.areas.slice(0, NAMED);\n const rest = size.areas.length - named.length;\n const areas = `${named.map(number).join(', ')} ${named[0] === 1 && named.length === 1 ? 'pixel' : 'pixels'}${\n rest === 0 ? '' : ` and ${number(rest)} smaller`\n }`;\n\n const carried = named.reduce((sum, area) => sum + area, 0);\n const all = rest === 0 && carried === size.total ? '' : ` · ${number(size.total)} in all`;\n\n const partial =\n size.changes === undefined || size.unshaped === 0\n ? ''\n : ` · ${count(size.unshaped, 'region')} the run recorded no shape for`;\n\n return `${where}: ${areas}${all}${partial}`;\n}\n\n/** The short form, for a rail item that has to stay one line. */\nexport function briefly(subject: {\n readonly changedPixels: number;\n readonly regions: readonly { readonly pixels?: number; readonly fingerprint?: string }[];\n}): string {\n const size = magnitudeOf(subject);\n if (size.regions === 0) return count(size.total, 'pixel');\n\n return size.changes === undefined\n ? `${count(size.regions, 'region')} · ${number(size.total)}px`\n : `${count(size.changes, 'change')} · ${count(size.regions, 'region')} · ${number(size.total)}px`;\n}\n"]}