proctolog 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 (228) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +23 -0
  3. data/README.md +178 -0
  4. data/app/controllers/proctolog/acceptances_controller.rb +29 -0
  5. data/app/controllers/proctolog/application_controller.rb +32 -0
  6. data/app/controllers/proctolog/bursts_controller.rb +34 -0
  7. data/app/controllers/proctolog/choices_controller.rb +30 -0
  8. data/app/controllers/proctolog/code_reviews_controller.rb +26 -0
  9. data/app/controllers/proctolog/decisions_controller.rb +33 -0
  10. data/app/controllers/proctolog/entries_controller.rb +14 -0
  11. data/app/controllers/proctolog/evidence_controller.rb +23 -0
  12. data/app/controllers/proctolog/freshness_controller.rb +27 -0
  13. data/app/controllers/proctolog/good_to_go_controller.rb +29 -0
  14. data/app/controllers/proctolog/memberships_controller.rb +20 -0
  15. data/app/controllers/proctolog/mentions_controller.rb +16 -0
  16. data/app/controllers/proctolog/moments_controller.rb +26 -0
  17. data/app/controllers/proctolog/note_params.rb +24 -0
  18. data/app/controllers/proctolog/notes_controller.rb +56 -0
  19. data/app/controllers/proctolog/open_pull_requests_controller.rb +59 -0
  20. data/app/controllers/proctolog/pictures_controller.rb +27 -0
  21. data/app/controllers/proctolog/prompts_controller.rb +28 -0
  22. data/app/controllers/proctolog/pull_request_scoped.rb +21 -0
  23. data/app/controllers/proctolog/pull_requests_controller.rb +34 -0
  24. data/app/controllers/proctolog/put_away_controller.rb +19 -0
  25. data/app/controllers/proctolog/recordings_controller.rb +28 -0
  26. data/app/controllers/proctolog/rendered_pieces_controller.rb +11 -0
  27. data/app/controllers/proctolog/review_data_controller.rb +12 -0
  28. data/app/controllers/proctolog/rules_controller.rb +20 -0
  29. data/app/controllers/proctolog/scenario_notes_controller.rb +26 -0
  30. data/app/controllers/proctolog/scenario_scoped.rb +21 -0
  31. data/app/controllers/proctolog/scenarios_controller.rb +67 -0
  32. data/app/controllers/proctolog/sitting_servers_controller.rb +30 -0
  33. data/app/controllers/proctolog/waiting_controller.rb +14 -0
  34. data/app/controllers/proctolog/with_decisions.rb +28 -0
  35. data/app/helpers/proctolog/bursts_helper.rb +52 -0
  36. data/app/helpers/proctolog/decisions_helper.rb +102 -0
  37. data/app/helpers/proctolog/desk_helper.rb +61 -0
  38. data/app/helpers/proctolog/mentions_helper.rb +27 -0
  39. data/app/helpers/proctolog/open_pull_requests_helper.rb +31 -0
  40. data/app/helpers/proctolog/prompts_helper.rb +21 -0
  41. data/app/helpers/proctolog/pull_requests_helper.rb +67 -0
  42. data/app/helpers/proctolog/recordings_helper.rb +30 -0
  43. data/app/helpers/proctolog/rounds_helper.rb +27 -0
  44. data/app/helpers/proctolog/scenarios_helper.rb +123 -0
  45. data/app/helpers/proctolog/standings_helper.rb +80 -0
  46. data/app/jobs/proctolog/application_job.rb +8 -0
  47. data/app/jobs/proctolog/ask_for_release_job.rb +9 -0
  48. data/app/jobs/proctolog/fetch_pictures_ahead_job.rb +36 -0
  49. data/app/jobs/proctolog/prune_fresh_profiles_job.rb +9 -0
  50. data/app/jobs/proctolog/read_mentions_ahead_job.rb +12 -0
  51. data/app/models/proctolog/acceptance.rb +47 -0
  52. data/app/models/proctolog/app.rb +92 -0
  53. data/app/models/proctolog/browser.rb +42 -0
  54. data/app/models/proctolog/burst.rb +268 -0
  55. data/app/models/proctolog/burst_notices.rb +143 -0
  56. data/app/models/proctolog/chapters.rb +42 -0
  57. data/app/models/proctolog/check.rb +122 -0
  58. data/app/models/proctolog/choices.rb +112 -0
  59. data/app/models/proctolog/code_review.rb +251 -0
  60. data/app/models/proctolog/decision_record.rb +12 -0
  61. data/app/models/proctolog/decisions.rb +199 -0
  62. data/app/models/proctolog/description.rb +165 -0
  63. data/app/models/proctolog/difference.rb +138 -0
  64. data/app/models/proctolog/doctor.rb +127 -0
  65. data/app/models/proctolog/evidence.rb +29 -0
  66. data/app/models/proctolog/failure.rb +7 -0
  67. data/app/models/proctolog/focus.rb +48 -0
  68. data/app/models/proctolog/fresh_sitting.rb +35 -0
  69. data/app/models/proctolog/github.rb +353 -0
  70. data/app/models/proctolog/handover/brief.rb +138 -0
  71. data/app/models/proctolog/handover/layers.rb +128 -0
  72. data/app/models/proctolog/handover/places.rb +98 -0
  73. data/app/models/proctolog/handover/taken.rb +41 -0
  74. data/app/models/proctolog/handover/worklist.rb +35 -0
  75. data/app/models/proctolog/handover.rb +123 -0
  76. data/app/models/proctolog/housekeeping.rb +23 -0
  77. data/app/models/proctolog/kit.rb +36 -0
  78. data/app/models/proctolog/marks.rb +130 -0
  79. data/app/models/proctolog/membership.rb +91 -0
  80. data/app/models/proctolog/mentions.rb +117 -0
  81. data/app/models/proctolog/note.rb +94 -0
  82. data/app/models/proctolog/outside.rb +45 -0
  83. data/app/models/proctolog/overlay/brief.rb +52 -0
  84. data/app/models/proctolog/overlay/written.rb +33 -0
  85. data/app/models/proctolog/overlay.rb +87 -0
  86. data/app/models/proctolog/owner.rb +16 -0
  87. data/app/models/proctolog/pick.rb +47 -0
  88. data/app/models/proctolog/picture.rb +138 -0
  89. data/app/models/proctolog/pieces_rendering.rb +40 -0
  90. data/app/models/proctolog/project.rb +118 -0
  91. data/app/models/proctolog/pull_request.rb +89 -0
  92. data/app/models/proctolog/recording.rb +194 -0
  93. data/app/models/proctolog/release.rb +59 -0
  94. data/app/models/proctolog/rendering.rb +303 -0
  95. data/app/models/proctolog/reports.rb +66 -0
  96. data/app/models/proctolog/response_files.rb +55 -0
  97. data/app/models/proctolog/rounds.rb +305 -0
  98. data/app/models/proctolog/route.rb +151 -0
  99. data/app/models/proctolog/rules.rb +121 -0
  100. data/app/models/proctolog/scenario.rb +126 -0
  101. data/app/models/proctolog/server.rb +273 -0
  102. data/app/models/proctolog/since.rb +108 -0
  103. data/app/models/proctolog/size.rb +30 -0
  104. data/app/models/proctolog/standing.rb +132 -0
  105. data/app/models/proctolog/theme.rb +40 -0
  106. data/app/models/proctolog/unopened/numbers.rb +48 -0
  107. data/app/models/proctolog/unopened/opening.rb +88 -0
  108. data/app/models/proctolog/unopened.rb +203 -0
  109. data/app/models/proctolog/visit.rb +26 -0
  110. data/app/views/layouts/proctolog/application.html.erb +58 -0
  111. data/app/views/proctolog/acceptances/new.html.erb +34 -0
  112. data/app/views/proctolog/bursts/_line.html.erb +28 -0
  113. data/app/views/proctolog/bursts/_merged.html.erb +23 -0
  114. data/app/views/proctolog/bursts/_moved.html.erb +16 -0
  115. data/app/views/proctolog/bursts/_notices.html.erb +39 -0
  116. data/app/views/proctolog/bursts/_put_away.html.erb +11 -0
  117. data/app/views/proctolog/bursts/_rules.html.erb +27 -0
  118. data/app/views/proctolog/bursts/_side.html.erb +49 -0
  119. data/app/views/proctolog/bursts/_side_stop.html.erb +10 -0
  120. data/app/views/proctolog/bursts/show.html.erb +54 -0
  121. data/app/views/proctolog/code_reviews/show.html.erb +33 -0
  122. data/app/views/proctolog/decisions/_alternative.html.erb +5 -0
  123. data/app/views/proctolog/decisions/_beneath.html.erb +21 -0
  124. data/app/views/proctolog/decisions/_box.html.erb +27 -0
  125. data/app/views/proctolog/decisions/_choices.html.erb +9 -0
  126. data/app/views/proctolog/decisions/_draft_tools.html.erb +47 -0
  127. data/app/views/proctolog/decisions/_part.html.erb +61 -0
  128. data/app/views/proctolog/decisions/_round_data.html.erb +1 -0
  129. data/app/views/proctolog/decisions/_theme.html.erb +13 -0
  130. data/app/views/proctolog/decisions/index.html.erb +75 -0
  131. data/app/views/proctolog/notes/_card.html.erb +19 -0
  132. data/app/views/proctolog/notes/_template.html.erb +41 -0
  133. data/app/views/proctolog/open_pull_requests/_side.html.erb +30 -0
  134. data/app/views/proctolog/open_pull_requests/index.html.erb +106 -0
  135. data/app/views/proctolog/prompts/show.html.erb +41 -0
  136. data/app/views/proctolog/pull_requests/_connections.html.erb +65 -0
  137. data/app/views/proctolog/pull_requests/_finish.html.erb +42 -0
  138. data/app/views/proctolog/pull_requests/_margin.html.erb +33 -0
  139. data/app/views/proctolog/pull_requests/_note.html.erb +4 -0
  140. data/app/views/proctolog/pull_requests/_outline.html.erb +26 -0
  141. data/app/views/proctolog/pull_requests/_paragraphs.html.erb +9 -0
  142. data/app/views/proctolog/pull_requests/_scenarios.html.erb +16 -0
  143. data/app/views/proctolog/pull_requests/_since.html.erb +42 -0
  144. data/app/views/proctolog/pull_requests/_standing.html.erb +51 -0
  145. data/app/views/proctolog/pull_requests/_status.html.erb +5 -0
  146. data/app/views/proctolog/pull_requests/_try.html.erb +122 -0
  147. data/app/views/proctolog/pull_requests/show.html.erb +145 -0
  148. data/app/views/proctolog/recordings/_dialog.html.erb +26 -0
  149. data/app/views/proctolog/recordings/_list.html.erb +8 -0
  150. data/app/views/proctolog/recordings/_note.html.erb +22 -0
  151. data/app/views/proctolog/recordings/index.html.erb +33 -0
  152. data/app/views/proctolog/recordings/show.html.erb +118 -0
  153. data/app/views/proctolog/scenarios/_dialog.html.erb +22 -0
  154. data/app/views/proctolog/scenarios/_note.html.erb +18 -0
  155. data/app/views/proctolog/scenarios/_open.html.erb +9 -0
  156. data/app/views/proctolog/scenarios/_scenario.html.erb +48 -0
  157. data/app/views/proctolog/scenarios/_server.html.erb +20 -0
  158. data/app/views/proctolog/scenarios/index.html.erb +62 -0
  159. data/bin/proctolog +330 -0
  160. data/completions/_proctolog +143 -0
  161. data/config/application.rb +94 -0
  162. data/config/database.yml +16 -0
  163. data/config/environment.rb +6 -0
  164. data/config/initializers/grouse.rb +113 -0
  165. data/config/initializers/grouse_notes.rb +9 -0
  166. data/config/keys.yml +6 -0
  167. data/config/routes.rb +71 -0
  168. data/config/storage.yml +4 -0
  169. data/db/migrate/20261019213542_create_grouse_tables.grouse.rb +128 -0
  170. data/db/migrate/20261019213550_create_active_storage_tables.active_storage.rb +58 -0
  171. data/docs/manual/app.md +157 -0
  172. data/docs/manual/bursts.md +129 -0
  173. data/docs/manual/deciding.md +89 -0
  174. data/docs/manual/desk.png +0 -0
  175. data/docs/manual/films.md +20 -0
  176. data/docs/manual/handover.md +93 -0
  177. data/docs/manual/pr-format.md +75 -0
  178. data/docs/manual/reading.md +233 -0
  179. data/docs/manual/unopened.md +80 -0
  180. data/docs/manual/updates.md +55 -0
  181. data/lib/proctolog/installation.rb +125 -0
  182. data/lib/proctolog/version.rb +7 -0
  183. data/public/asking.js +50 -0
  184. data/public/code-review.js +40 -0
  185. data/public/cursor.js +292 -0
  186. data/public/decision-screenshots.js +97 -0
  187. data/public/decisions.css +949 -0
  188. data/public/decisions.js +1289 -0
  189. data/public/desk.css +582 -0
  190. data/public/desk.js +126 -0
  191. data/public/favicon.png +0 -0
  192. data/public/keys.js +228 -0
  193. data/public/mentions.js +389 -0
  194. data/public/messages.js +103 -0
  195. data/public/notes.js +731 -0
  196. data/public/pick.css +99 -0
  197. data/public/plaque.png +0 -0
  198. data/public/pr-number.js +11 -0
  199. data/public/prompt.css +46 -0
  200. data/public/prompt.js +16 -0
  201. data/public/pull_request.css +805 -0
  202. data/public/recording.css +379 -0
  203. data/public/recording.js +394 -0
  204. data/public/release.js +25 -0
  205. data/public/respond.css +260 -0
  206. data/public/respond.js +95 -0
  207. data/public/review-drafts.js +949 -0
  208. data/public/review-rounds.js +1447 -0
  209. data/public/scenarios.css +253 -0
  210. data/public/scenarios.js +36 -0
  211. data/public/schemas/proctolog-response-v1.json +101 -0
  212. data/public/side.js +133 -0
  213. data/public/standing.css +478 -0
  214. data/public/terminal-apl.svg +6 -0
  215. data/public/terminal-cuneiform.svg +3 -0
  216. data/public/terminal-grain-dots.svg +6 -0
  217. data/public/terminal-grain-weave.svg +6 -0
  218. data/public/terminal-grain-wedges.svg +6 -0
  219. data/public/terminal-shades.svg +3 -0
  220. data/public/terminal.css +4426 -0
  221. data/public/toast.js +26 -0
  222. data/public/try.js +124 -0
  223. data/public/viewer.js +808 -0
  224. data/public/words.js +81 -0
  225. data/skills/proctolog-handover/SKILL.md +77 -0
  226. data/skills/proctolog-pr-writing/SKILL.md +228 -0
  227. data/skills/proctolog-pr-writing/references/example-description.md +33 -0
  228. metadata +366 -0
@@ -0,0 +1,1289 @@
1
+ // Choosing on the decisions of a burst, by key or by button: on the page
2
+ // that lists them all, and inline on a pull request's own page, where the
3
+ // keys answer only once a decision is entered (f, or moving onto one).
4
+
5
+ import { askingPrompt, sessionAddress } from "./asking.js";
6
+ import {
7
+ closeScreenshots,
8
+ openScreenshots,
9
+ prepareScreenshots,
10
+ toggleScreenshots,
11
+ } from "./decision-screenshots.js";
12
+ import { helping, keyOf, mode } from "./keys.js";
13
+ import { withMentions } from "./mentions.js";
14
+ import { prNumber } from "./pr-number.js";
15
+ import {
16
+ announce,
17
+ answeredWith,
18
+ choiceFor,
19
+ clearChoice,
20
+ cursor,
21
+ handOver,
22
+ draft,
23
+ inventory,
24
+ labels,
25
+ leaveInBulk,
26
+ pendingFor,
27
+ picturesSince,
28
+ progress,
29
+ rememberCursor,
30
+ reopened,
31
+ reviewed as reviewedItem,
32
+ ruleFor,
33
+ saveChoice,
34
+ saveRule,
35
+ saveTogether,
36
+ scope,
37
+ settled,
38
+ stale,
39
+ undoChoice,
40
+ } from "./review-drafts.js";
41
+ import "./review-rounds.js";
42
+ import { toast } from "./toast.js";
43
+
44
+ const inline = !!document.querySelector("[data-inline-decisions]");
45
+ if (inline) {
46
+ for (const controls of document.querySelectorAll("[data-decision]")) {
47
+ const box = controls.closest("li");
48
+ if (!box) continue;
49
+ box.dataset.decision = controls.dataset.decision;
50
+ box.dataset.grade = controls.dataset.grade;
51
+ box.dataset.held = controls.dataset.held;
52
+ box.dataset.reading = "";
53
+ delete controls.dataset.decision;
54
+ box.id ||= `box-${box.dataset.decision}`;
55
+ box.tabIndex = -1;
56
+ }
57
+ }
58
+ // Whether the owner is deciding. On the page that lists them all they always
59
+ // are; on a pull request's page, from f (or Enter on one) until they leave.
60
+ let engaged = !inline;
61
+ const boxes = [...document.querySelectorAll("[data-decision]")];
62
+ if (inline) boxes.forEach(prepareScreenshots);
63
+ const main = document.querySelector("main.decisions, main.description");
64
+ const finish = document.querySelector("[data-finish]");
65
+ // Where the decisions end, a line stands for each of them.
66
+ const lineOf = (box) =>
67
+ finish?.querySelector(`[data-line-of="${box.dataset.decision}"]`);
68
+ const boxOf = (line) =>
69
+ boxes.find((box) => box.dataset.decision === line.dataset.lineOf);
70
+ const items = new Map(inventory.map((item) => [item.id, item]));
71
+ let filter = new URL(location.href).searchParams.get("show") || "unreviewed";
72
+ let active;
73
+ const nonBlocking = (box) => box.dataset.grade === "non_blocking";
74
+ // What the list shows: not hidden by its view, nor folded away with the
75
+ // non-blocking decisions.
76
+ const visible = () =>
77
+ boxes.filter(
78
+ (box) =>
79
+ !box.hidden && !box.closest("details[data-non-blocking]:not([open])"),
80
+ );
81
+ const reviewed = (box) => {
82
+ const item = items.get(box.dataset.decision);
83
+ return !!item && reviewedItem(item);
84
+ };
85
+
86
+ // By theme, the page lists one kind of question across the burst, a heading
87
+ // over each, which the cursor rests on like a decision.
88
+ const byTheme = main?.dataset.byTheme === "true";
89
+ const isHeading = (element) => !!element?.matches?.("[data-theme-heading]");
90
+ const under = (heading) =>
91
+ [
92
+ ...heading.closest(".theme-group").querySelectorAll("[data-decision]"),
93
+ ].filter((box) => !box.hidden);
94
+ // Where j and k rest: the decisions the list shows, and by theme the heading
95
+ // over each group that shows any.
96
+ const rests = () =>
97
+ byTheme
98
+ ? [
99
+ ...main.querySelectorAll("[data-theme-heading], [data-decision]"),
100
+ ].filter((element) =>
101
+ isHeading(element) ? under(element).length : !element.hidden,
102
+ )
103
+ : visible();
104
+
105
+ // The decisions the owner selected, on the page that lists them all, to give
106
+ // them one answer: a, q, c and l then answer every one of them, each with a
107
+ // choice of its own.
108
+ const selected = new Set();
109
+ const setSelected = (boxesToSelect, on) => {
110
+ for (const { dataset } of boxesToSelect)
111
+ selected[on ? "add" : "delete"](dataset.decision);
112
+ };
113
+ // x on a decision selects it, or no longer; on a theme's heading, the whole
114
+ // group the list shows.
115
+ const toggleSelection = () => {
116
+ if (!active) return;
117
+ const group = isHeading(active) ? under(active) : [active];
118
+ setSelected(group, !group.every((box) => selected.has(box.dataset.decision)));
119
+ paint();
120
+ sayMode();
121
+ };
122
+ const clearSelection = () => {
123
+ if (!selected.size) return false;
124
+ selected.clear();
125
+ paint();
126
+ sayMode();
127
+ return true;
128
+ };
129
+ // The decisions an answer goes to: those selected, and the one at hand.
130
+ const answering = () =>
131
+ boxes.filter((box) => selected.has(box.dataset.decision) || box === active);
132
+ // What deciding on a pull request's page goes through: its blocking
133
+ // decisions; and the non-blocking ones once the owner opens their fold and
134
+ // decides one. Otherwise those wait for one gesture.
135
+ const sequence = () =>
136
+ boxes.filter((box) => nonBlocking(box) === !!(active && nonBlocking(active)));
137
+ // Where deciding begins: the first blocking decision still to review.
138
+ const firstToReview = () =>
139
+ boxes.find((box) => !nonBlocking(box) && !reviewed(box));
140
+
141
+ // The pull request a decision is of: the page's own, on its page.
142
+ const prOf = (box) => prNumber(box.dataset.pr || scope);
143
+ // Why leaving the non-blocking at once passes over a decision: what the desk
144
+ // knows of it (starred, named in the burst file, asked, answered), and a
145
+ // draft on it.
146
+ const holds = (box) => [
147
+ ...(box.dataset.held ? box.dataset.held.split(",") : []),
148
+ ...(pendingFor(box.dataset.decision) ? ["drafted"] : []),
149
+ ];
150
+ // The non-blocking decisions of a pull request still to review, and those
151
+ // of them one gesture leaves: the ones that wait for nothing.
152
+ const waitingOn = (pr) =>
153
+ boxes.filter((box) => nonBlocking(box) && prOf(box) === pr && !reviewed(box));
154
+ const leavable = (pr) => waitingOn(pr).filter((box) => !holds(box).length);
155
+ // "1 starred, 1 drafted": what holds each of these decisions, counted.
156
+ const heldBy = (held) =>
157
+ Object.entries(Object.groupBy(held, (box) => holds(box)[0]))
158
+ .map(([reason, them]) => `${them.length} ${reason}`)
159
+ .join(", ");
160
+ // What the last gesture passed over, and why, said on its line.
161
+ const passedOver = new Map();
162
+
163
+ // Leaves the non-blocking decisions of a pull request that wait for nothing,
164
+ // in one gesture that u takes back whole, and names those it passed over.
165
+ const leaveNonBlocking = (pr) => {
166
+ const leaving = leavable(pr);
167
+ const passed = waitingOn(pr).filter((box) => holds(box).length);
168
+ if (!leaving.length)
169
+ return toast(
170
+ passed.length
171
+ ? `Each non-blocking decision left here waits on something: ${passed.map((box) => holds(box)[0]).join(", ")}.`
172
+ : "No non-blocking decision of this pull request is left to review.",
173
+ );
174
+ leaveInBulk(leaving.map((box) => box.dataset.decision));
175
+ passedOver.clear();
176
+ for (const box of passed) passedOver.set(box.dataset.decision, holds(box)[0]);
177
+ paint();
178
+ sayMode();
179
+ announce(
180
+ `Left ${leaving.length} non-blocking as is${passed.length ? ` · ${passed.length} skipped: ${heldBy(passed)}` : ""} · u undoes it`,
181
+ );
182
+ };
183
+ // Which decisions the list shows. Deciding on a pull request's page passes
184
+ // over every one of them in order, whichever the list shows.
185
+ const matches = (box) => {
186
+ if (inline && engaged) return true;
187
+ switch (filter) {
188
+ case "all":
189
+ return true;
190
+ case "unreviewed":
191
+ return !reviewed(box);
192
+ case "reviewed":
193
+ return reviewed(box);
194
+ case "waiting":
195
+ return box.dataset.waiting === "true";
196
+ default:
197
+ return box.dataset.state === filter;
198
+ }
199
+ };
200
+
201
+ // The bar at the foot of the page: where the owner is, and the few keys that
202
+ // matter there. The rest stand beside the decision itself: each choice by its
203
+ // key in the menu, and on a pull request's page its other keys in the margin.
204
+ const DECIDING = inline
205
+ ? [
206
+ ["← →", "previous, next"],
207
+ ["↑ ↓", "scroll"],
208
+ ["a b q c l", "choose"],
209
+ ]
210
+ : [
211
+ ["j k", "move"],
212
+ ["a b q c l", "choose"],
213
+ ];
214
+ const sayMode = () => {
215
+ const at = document.activeElement;
216
+ // A choice's one line, a note on the alternative or a rule, saves on Enter.
217
+ if (at.matches(".choice-editor input"))
218
+ return mode("Writing", [
219
+ ["Enter", "save"],
220
+ ["Esc", "close"],
221
+ ]);
222
+ if (at.closest("textarea, input, select"))
223
+ return mode("Writing", [
224
+ ["⌘ Enter", "save and move on"],
225
+ ["Esc", "close, keeping the draft"],
226
+ ]);
227
+ const response = at.closest("[data-request]");
228
+ if (response) {
229
+ // Which of the responses shown the reading is in.
230
+ const shown = [
231
+ ...document.querySelectorAll("[data-round-results] [data-request]"),
232
+ ].filter((node) => node.getClientRects().length);
233
+ return mode(
234
+ `Returned work ${shown.indexOf(response) + 1} of ${shown.length}`,
235
+ [
236
+ ...(at.matches("figure.evidence") ? [["Space", "look closely"]] : []),
237
+ ["↑ ↓", "move"],
238
+ ["[ ]", "responses"],
239
+ ["a", "confirm"],
240
+ ["q", "answer or ask"],
241
+ ["c", "correction"],
242
+ ["l", "later"],
243
+ ["d", "diff"],
244
+ ["Esc", "leave"],
245
+ ],
246
+ );
247
+ }
248
+ if (at.closest("[data-finish]")) {
249
+ const leaves = leavable(scope).length;
250
+ return mode(
251
+ waitingOn(scope).length ? "Blocking reviewed" : "All reviewed",
252
+ [
253
+ ["j k", "move"],
254
+ ...(leaves ? [["A", `leave ${leaves} as is`]] : []),
255
+ ["g", "good to go"],
256
+ ["N", "next to review"],
257
+ ],
258
+ );
259
+ }
260
+ if (!inline && selected.size)
261
+ return mode(`Deciding · ${selected.size} selected`, [
262
+ ["a q c l", `answer the ${selected.size}`],
263
+ ["x", "select, or not"],
264
+ ["Esc", "clear the selection"],
265
+ ]);
266
+ if (isHeading(active))
267
+ return mode(`Deciding · ${active.dataset.themeHeading}`, [
268
+ ["x", `select its ${under(active).length}`],
269
+ ["j k", "move"],
270
+ ]);
271
+ if (!inline || engaged) {
272
+ // On a pull request's page the count is the stepper's: its place among
273
+ // the decisions deciding goes through, whichever of them the view lists.
274
+ const queue = inline ? sequence() : visible();
275
+ const place = queue.indexOf(active) + 1;
276
+ return mode(
277
+ place ? `Deciding ${place} of ${queue.length}` : "Deciding",
278
+ inline ? DECIDING : [...DECIDING, ["u", "undo"]],
279
+ );
280
+ }
281
+ // What the reading rests on adds the key that opens it.
282
+ const opens = at.matches("li[data-decision]")
283
+ ? [["Enter", "decide on this"]]
284
+ : at.closest("figure.picture")
285
+ ? [["Space", "look closely"]]
286
+ : [];
287
+ mode("Reading", [
288
+ ...opens,
289
+ ["↑ ↓", "move"],
290
+ ["[ ]", "sections"],
291
+ ...(boxes.length ? [["f", "review decisions"]] : []),
292
+ ]);
293
+ };
294
+
295
+ // What a pull request's page shows: the whole of it, or, while the owner
296
+ // decides, one decision alone ("decide") and then where they end ("done").
297
+ // The stylesheet does the showing; this must be said before anything it
298
+ // reveals is given the focus.
299
+ const frame = (name) => {
300
+ if (!inline) return;
301
+ if (name) document.body.dataset.focus = name;
302
+ else delete document.body.dataset.focus;
303
+ };
304
+
305
+ // Into deciding on a pull request's page, or out of it, back to reading. The
306
+ // list shows every decision while the owner decides, and its own view after.
307
+ const engage = (on) => {
308
+ if (!inline || engaged === on) return;
309
+ engaged = on;
310
+ if (on) document.body.dataset.decisionMode = "true";
311
+ else {
312
+ delete document.body.dataset.decisionMode;
313
+ delete document.body.dataset.returnsTo;
314
+ }
315
+ paint();
316
+ };
317
+
318
+ // Deciding begun on a line where the decisions end goes back to that line,
319
+ // on Escape or once nothing is left to review.
320
+ const decideFrom = (line) => {
321
+ document.body.dataset.returnsTo = "done";
322
+ select(boxOf(line));
323
+ };
324
+
325
+ // One bead for each decision of the pull request, in order: which are done,
326
+ // and, while the owner decides, which one is at hand. A bead goes to its decision.
327
+ // While reading, the bead and the line f would begin on are marked too, for the
328
+ // page to point at from the decisions' heading.
329
+ const stepper = document.querySelector("[data-stepper]");
330
+ const step = () => {
331
+ if (!stepper) return;
332
+ const at = engaged ? active : undefined;
333
+ const first = firstToReview();
334
+ for (const box of boxes) box.toggleAttribute("data-first", box === first);
335
+ const queue = sequence();
336
+ const cells = queue.map((box) => {
337
+ const cell = document.createElement("span");
338
+ cell.className = "cell";
339
+ cell.dataset.state = box.dataset.choice || (reviewed(box) ? "ticked" : "");
340
+ cell.title = box.querySelector("strong")?.textContent ?? "";
341
+ if (box === at) cell.dataset.at = "";
342
+ if (box === first) cell.dataset.first = "";
343
+ cell.addEventListener("click", () => select(box));
344
+ return cell;
345
+ });
346
+ const left = queue.filter((box) => !reviewed(box)).length;
347
+ const graded = boxes.some(nonBlocking)
348
+ ? queue.some(nonBlocking)
349
+ ? "non-blocking "
350
+ : "blocking "
351
+ : "";
352
+ const place = document.createElement("span");
353
+ place.className = "place";
354
+ place.textContent = [
355
+ at
356
+ ? `Decision ${queue.indexOf(at) + 1} of ${queue.length}`
357
+ : `${queue.length} ${graded}decisions`,
358
+ `${left} to review`,
359
+ ].join(" · ");
360
+ stepper.replaceChildren(...cells, place);
361
+ };
362
+
363
+ const select = (box, { focus = true } = {}) => {
364
+ if (active !== box) closeScreenshots(active);
365
+ active?.removeAttribute("aria-current");
366
+ active = box;
367
+ if (!box) {
368
+ step();
369
+ return sayMode();
370
+ }
371
+ box.setAttribute("aria-current", "true");
372
+ if (inline && focus) {
373
+ engage(true);
374
+ box.closest("details.section").open = true;
375
+ const fold = box.closest("details[data-non-blocking]");
376
+ if (fold) fold.open = true;
377
+ frame("decide");
378
+ openScreenshots(box);
379
+ }
380
+ step();
381
+ if (focus) {
382
+ box.focus({ preventScroll: true });
383
+ // Alone on the page, a decision is read from the page's top.
384
+ if (inline) scrollTo({ top: 0 });
385
+ else box.scrollIntoView({ block: "nearest" });
386
+ }
387
+ sayMode();
388
+ if (inline && !focus && !box.contains(document.activeElement)) return;
389
+ const url = new URL(location.href);
390
+ url.hash = box === boxes[0] ? "" : box.id;
391
+ history.replaceState(null, "", url);
392
+ if (box.dataset.decision && cursor() !== box.dataset.decision)
393
+ rememberCursor(box.dataset.decision);
394
+ };
395
+
396
+ const fraction = ({ chosen, ticked, total }) => `${chosen + ticked}/${total}`;
397
+
398
+ // A choice in a word or two, beside its decision in the list and over it while
399
+ // it is the one at hand.
400
+ const CHOSEN = {
401
+ leave: "as is",
402
+ question: "question asked",
403
+ change: "change suggested",
404
+ later: "left for later",
405
+ };
406
+
407
+ // Under a decision whose pictures changed since it was chosen on: each
408
+ // picture as it was beside as it is now, matched by name, and one that is
409
+ // new or gone said so.
410
+ const showPicturesSince = (box, since) => {
411
+ const moved = since.filter(({ said }) =>
412
+ ["changed", "added", "removed"].includes(said),
413
+ );
414
+ const shown = box.querySelector("[data-pictures-since]");
415
+ const key = JSON.stringify(moved);
416
+ if (shown?.dataset.key === key) return;
417
+ shown?.remove();
418
+ if (!moved.length) return;
419
+ const panel = document.createElement("div");
420
+ Object.assign(panel.dataset, { picturesSince: "", desk: "", key });
421
+ panel.className = "pictures-since";
422
+ for (const { name, before, after, said } of moved) {
423
+ const row = document.createElement("figure");
424
+ row.dataset.said = said;
425
+ for (const [picture, when] of [
426
+ [before, "as it was"],
427
+ [after, "now"],
428
+ ]) {
429
+ const cell = document.createElement("span");
430
+ if (picture?.file)
431
+ cell.append(
432
+ Object.assign(document.createElement("img"), {
433
+ src: `/pictures/${picture.file}`,
434
+ alt: `${name}, ${when}`,
435
+ loading: "lazy",
436
+ }),
437
+ );
438
+ else cell.textContent = picture ? "not on this Mac" : said;
439
+ row.append(cell);
440
+ }
441
+ const caption = document.createElement("figcaption");
442
+ caption.textContent = `${name}: ${said}`;
443
+ row.append(caption);
444
+ panel.append(row);
445
+ }
446
+ box.querySelector("[data-choice-status]").after(panel);
447
+ };
448
+
449
+ const paint = () => {
450
+ for (const box of boxes) {
451
+ const saved = choiceFor(box.dataset.decision);
452
+ const pending = pendingFor(box.dataset.decision);
453
+ const status = box.querySelector("[data-choice-status]");
454
+ const changed = saved && stale(saved);
455
+ const passed = passedOver.get(box.dataset.decision);
456
+ const others = saved ? answeredWith(saved) : 0;
457
+ const together = others
458
+ ? ` (answered with ${others} other${others === 1 ? "" : "s"})`
459
+ : "";
460
+ const rule = saved && ruleFor(saved);
461
+ const since = saved ? picturesSince(saved) : [];
462
+ // A choice that no longer stands says why, and what it was; one given
463
+ // with others is answered again alone.
464
+ status.textContent = [
465
+ saved
466
+ ? changed
467
+ ? `${reopened(saved)}: needs review again. It was: ${labels[saved.kind]}${saved.text ? ` — ${saved.text}` : ""}${together}`
468
+ : `Saved: ${labels[saved.kind]}${saved.text ? ` — ${saved.text}` : ""}${together}`
469
+ : pending
470
+ ? `Unfinished draft: ${labels[pending.kind]}`
471
+ : "",
472
+ passed && !saved ? `Not left with the others: ${passed}` : "",
473
+ rule ? `Rule: “${rule.text}”` : "",
474
+ since.some(({ said }) => said === "unverified")
475
+ ? "Pictures unverified: their pixels could not be read"
476
+ : "",
477
+ ]
478
+ .filter(Boolean)
479
+ .join(" · ");
480
+ withMentions(status);
481
+ showPicturesSince(box, changed ? since : []);
482
+ const ruleButton = box.querySelector("[data-make-rule]");
483
+ ruleButton.hidden = !saved || changed;
484
+ ruleButton.querySelector(".does").textContent = rule
485
+ ? "Edit its rule"
486
+ : "Make it a rule";
487
+ box.dataset.choice = changed ? "stale" : saved?.kind || "";
488
+ box.toggleAttribute("data-selected", selected.has(box.dataset.decision));
489
+ // What a line of the list says of itself while another decision is at hand.
490
+ box.dataset.said = changed
491
+ ? "review again"
492
+ : saved
493
+ ? CHOSEN[saved.kind]
494
+ : reviewed(box)
495
+ ? "ticked on GitHub"
496
+ : "";
497
+ box.querySelector("[data-choice-clear]").hidden = !saved && !pending;
498
+ for (const button of box.querySelectorAll("[data-choice]"))
499
+ button.setAttribute(
500
+ "aria-pressed",
501
+ String(!changed && saved?.kind === button.dataset.choice),
502
+ );
503
+ box.hidden = !matches(box);
504
+ }
505
+ for (const part of main.querySelectorAll(".part")) {
506
+ part.hidden = ![...part.querySelectorAll("[data-decision]")].some(
507
+ (box) => !box.hidden,
508
+ );
509
+ const now = progress(prNumber(part.dataset.part));
510
+ const said = part.querySelector("[data-part-progress]");
511
+ if (said) {
512
+ said.textContent = `${fraction(now)} reviewed`;
513
+ said.dataset.done = String(!now.left);
514
+ }
515
+ }
516
+ for (const heading of main.querySelectorAll(".lead-in"))
517
+ heading.hidden = ![
518
+ ...heading.nextElementSibling.querySelectorAll("[data-decision]"),
519
+ ].some((box) => !box.hidden);
520
+ for (const link of document.querySelectorAll("a[data-filter]")) {
521
+ if (link.dataset.filter === filter)
522
+ link.setAttribute("aria-current", "page");
523
+ else link.removeAttribute("aria-current");
524
+ }
525
+ for (const [attribute, name] of [
526
+ ["pending", "unreviewed"],
527
+ ["reviewed", "reviewed"],
528
+ ["all", "all"],
529
+ ]) {
530
+ for (const control of document.querySelectorAll(`[data-${attribute}-view]`))
531
+ control.setAttribute("aria-pressed", String(filter === name));
532
+ }
533
+ const counts = {
534
+ unreviewed: boxes.filter((box) => !reviewed(box)).length,
535
+ reviewed: boxes.filter(reviewed).length,
536
+ all: boxes.length,
537
+ };
538
+ for (const count of document.querySelectorAll("[data-count-of]"))
539
+ count.textContent = counts[count.dataset.countOf];
540
+ // The non-blocking decisions, folded: how many wait, and how many of
541
+ // them one gesture would leave as they are.
542
+ for (const fold of document.querySelectorAll("details[data-non-blocking]")) {
543
+ const pr = prNumber(fold.dataset.pr || scope);
544
+ const open = waitingOn(pr).length;
545
+ fold.querySelector("[data-non-blocking-count]").textContent = open
546
+ ? `${open} to review`
547
+ : "all reviewed";
548
+ fold.querySelector("summary").dataset.leaves = leavable(pr).length;
549
+ }
550
+ for (const button of document.querySelectorAll("[data-leave-non-blocking]")) {
551
+ const leaves = leavable(prNumber(button.dataset.pr)).length;
552
+ button.hidden = !leaves;
553
+ button.querySelector("[data-leave-said]").textContent =
554
+ `leave ${leaves} as is`;
555
+ }
556
+ if (inline) {
557
+ const blockingLeft = boxes.filter(
558
+ (box) => !nonBlocking(box) && !reviewed(box),
559
+ ).length;
560
+ const count = document.querySelector("#decisions > summary .count");
561
+ if (count) {
562
+ count.textContent = blockingLeft
563
+ ? `${blockingLeft} to review`
564
+ : counts.unreviewed
565
+ ? "the non-blocking wait"
566
+ : "all reviewed";
567
+ count.dataset.waits = blockingLeft > 0;
568
+ }
569
+ // Where deciding ends: after the last blocking decision. Non-blocking
570
+ // ones still open wait for one gesture, offered here.
571
+ if (finish) {
572
+ finish.hidden = !boxes.length || blockingLeft > 0;
573
+ paintEnd();
574
+ }
575
+ }
576
+ const empty = document.querySelector("[data-queue-empty]");
577
+ if (empty) empty.hidden = visible().length > 0;
578
+ const emptySaid = empty?.querySelector("[data-empty-said]");
579
+ if (emptySaid)
580
+ emptySaid.textContent = filter === "unreviewed" && !counts.unreviewed
581
+ ? "Every decision in this view is reviewed."
582
+ : "No decisions match this view.";
583
+ step();
584
+ };
585
+
586
+ // What the end of the decisions says first: what is reviewed, and how many
587
+ // non-blocking still wait.
588
+ const blockingReviewed = (count) =>
589
+ ({
590
+ 0: "No decision here is blocking",
591
+ 1: "The blocking decision is reviewed",
592
+ })[count] ?? `The ${count} blocking decisions are reviewed`;
593
+ const endSaid = () => {
594
+ const waiting = waitingOn(scope).length;
595
+ if (!waiting) return "Every decision of this pull request is reviewed.";
596
+ const blocking = boxes.filter((box) => !nonBlocking(box)).length;
597
+ return `${blockingReviewed(blocking)}; ${waiting} non-blocking ${waiting === 1 ? "waits" : "wait"}.`;
598
+ };
599
+
600
+ // The end of the decisions, line by line: each decision's bead, and what was
601
+ // chosen on it or what holds it; over each grade, how many are left; under
602
+ // the non-blocking, why one gesture would not leave them all.
603
+ const paintEnd = () => {
604
+ finish.querySelector("[data-finish-said]").textContent = endSaid();
605
+ for (const line of finish.querySelectorAll("[data-line-of]")) {
606
+ const box = boxOf(line);
607
+ line.dataset.state = box.dataset.choice || (reviewed(box) ? "ticked" : "");
608
+ line.querySelector("[data-line-said]").textContent =
609
+ box.dataset.said || holds(box).join(", ");
610
+ // Unfolded, the choice in full, as the decision itself says it.
611
+ const choice = line.querySelector("[data-line-choice]");
612
+ choice.textContent =
613
+ box.querySelector("[data-choice-status]").textContent ||
614
+ (reviewed(box) ? "Ticked on GitHub." : "");
615
+ choice.hidden = !choice.textContent;
616
+ }
617
+ for (const grade of finish.querySelectorAll(".grade")) {
618
+ const left = [...grade.querySelectorAll("[data-line-of]")].filter(
619
+ (line) => !reviewed(boxOf(line)),
620
+ ).length;
621
+ grade.querySelector("[data-grade-count]").textContent = left
622
+ ? `${left} to review`
623
+ : "all reviewed";
624
+ }
625
+ const leave = finish.querySelector("[data-finish-leave]");
626
+ if (!leave) return;
627
+ const waiting = waitingOn(scope);
628
+ const held = waiting.filter((box) => holds(box).length);
629
+ leave.hidden = !waiting.length;
630
+ leave.querySelector("[data-held-said]").textContent = held.length
631
+ ? `${held.length} ${held.length === 1 ? "waits" : "wait"} for you: ${heldBy(held)}`
632
+ : "";
633
+ };
634
+
635
+ const show = (name) => {
636
+ filter = name;
637
+ const url = new URL(location.href);
638
+ url.searchParams.set("show", name);
639
+ history.replaceState(null, "", url);
640
+ paint();
641
+ select(active && !active.hidden ? active : visible()[0]);
642
+ };
643
+
644
+ const next = (direction) => {
645
+ const queue = inline ? sequence() : rests();
646
+ const at = Math.max(0, queue.indexOf(active));
647
+ select(queue[Math.min(Math.max(at + direction, 0), queue.length - 1)]);
648
+ };
649
+
650
+ // The first decision of the pull request before or after the one at hand.
651
+ const nextPart = (direction) => {
652
+ // By theme, from heading to heading.
653
+ if (byTheme) {
654
+ const stops = rests();
655
+ const at = stops.indexOf(active);
656
+ const ahead =
657
+ direction > 0
658
+ ? stops.slice(at + 1)
659
+ : stops.slice(0, Math.max(at, 0)).reverse();
660
+ const heading = ahead.find(isHeading);
661
+ if (heading) select(heading);
662
+ return;
663
+ }
664
+ const queue = visible();
665
+ const here = active?.dataset.pr;
666
+ const others =
667
+ direction > 0
668
+ ? queue.slice(queue.indexOf(active) + 1)
669
+ : queue.slice(0, Math.max(queue.indexOf(active), 0)).reverse();
670
+ const other = others.find((box) => box.dataset.pr !== here);
671
+ if (!other) return;
672
+ select(queue.find((box) => box.dataset.pr === other.dataset.pr));
673
+ };
674
+
675
+ // How the owner moves from decision to decision. Down the list, on the page of
676
+ // them all, and by [ ] from one pull request's to the next. Along the beads, on
677
+ // a pull request's page, where the one decision shown fills the page and the
678
+ // keys that go down and up scroll it.
679
+ const ABOUT_THREE_LINES = 80;
680
+ const MOVING = inline
681
+ ? {
682
+ ArrowRight: () => next(1),
683
+ ArrowLeft: () => next(-1),
684
+ j: () => scrollBy({ top: ABOUT_THREE_LINES }),
685
+ ArrowDown: () => scrollBy({ top: ABOUT_THREE_LINES }),
686
+ k: () => scrollBy({ top: -ABOUT_THREE_LINES }),
687
+ ArrowUp: () => scrollBy({ top: -ABOUT_THREE_LINES }),
688
+ }
689
+ : {
690
+ j: () => next(1),
691
+ ArrowDown: () => next(1),
692
+ k: () => next(-1),
693
+ ArrowUp: () => next(-1),
694
+ "[": () => nextPart(-1),
695
+ "]": () => nextPart(1),
696
+ };
697
+
698
+ // The end of a pull request's decisions: its panel takes the focus, or the
699
+ // line of the decision deciding went back from, and the keys that move on
700
+ // from here are on it.
701
+ const finished = (from) => {
702
+ engage(false);
703
+ select(undefined);
704
+ finish.hidden = false;
705
+ frame("done");
706
+ const line = from && lineOf(from);
707
+ (line ?? finish).focus({ preventScroll: true });
708
+ if (line) line.scrollIntoView({ block: "nearest" });
709
+ else scrollTo({ top: 0 });
710
+ sayMode();
711
+ };
712
+
713
+ // A line where the decisions end unfolds to its decision's own words and the
714
+ // choice in full, and folds again.
715
+ const unfolded = (line) => line.getAttribute("aria-expanded") === "true";
716
+ const unfold = (line, open) => {
717
+ if (!line) return;
718
+ line.setAttribute("aria-expanded", String(open));
719
+ // The cursor's keys beside the line change with it.
720
+ line.dispatchEvent(new Event("toggle"));
721
+ line.scrollIntoView({ block: "nearest" });
722
+ };
723
+
724
+ // Where the decisions end, the reading goes from the panel down its lines.
725
+ const alongTheEnd = (direction) => {
726
+ const stops = [finish, ...finish.querySelectorAll("[data-line-of]")];
727
+ const at = Math.max(stops.indexOf(document.activeElement), 0);
728
+ const to = stops[Math.min(Math.max(at + direction, 0), stops.length - 1)];
729
+ to.focus({ preventScroll: true });
730
+ if (to === finish) scrollTo({ top: 0 });
731
+ else to.scrollIntoView({ block: "nearest" });
732
+ };
733
+ const AT_THE_END = {
734
+ j: () => alongTheEnd(1),
735
+ ArrowDown: () => alongTheEnd(1),
736
+ k: () => alongTheEnd(-1),
737
+ ArrowUp: () => alongTheEnd(-1),
738
+ Home: () => alongTheEnd(-Infinity),
739
+ End: () => alongTheEnd(Infinity),
740
+ ArrowRight: (line) => unfold(line, true),
741
+ ArrowLeft: (line) => unfold(line, false),
742
+ };
743
+
744
+ // What follows a choice. On a pull request's page, the next decision in order,
745
+ // reviewed or not, and past the last the first still to review; once none is,
746
+ // where they end. On the page that lists them all, the next still to review.
747
+ const commit = (box, kind, text = "") => {
748
+ const queue = inline ? sequence() : visible();
749
+ const at = queue.indexOf(box);
750
+ // With decisions selected, one answer goes to each of them.
751
+ const together = inline ? [] : answering();
752
+ const remaining = [...queue.slice(at + 1), ...queue.slice(0, at)].find(
753
+ (item) => !reviewed(item) && !together.includes(item),
754
+ );
755
+ const saved =
756
+ together.length > 1
757
+ ? saveTogether(
758
+ together.map((item) => item.dataset.decision),
759
+ kind,
760
+ text,
761
+ )
762
+ : saveChoice(box.dataset.decision, kind, text);
763
+ if (!saved) return;
764
+ selected.clear();
765
+ box.querySelector(".choice-slot").replaceChildren();
766
+ paint();
767
+ if (inline && !remaining) {
768
+ finished(document.body.dataset.returnsTo ? box : undefined);
769
+ announce(
770
+ waitingOn(scope).length
771
+ ? `Every blocking decision of this pull request is reviewed. A leaves the non-blocking that wait for nothing as they are.`
772
+ : "Every decision of this pull request is reviewed.",
773
+ );
774
+ return;
775
+ }
776
+ if (inline) return select(queue[at + 1] ?? remaining);
777
+ select(remaining && !remaining.hidden ? remaining : visible()[0]);
778
+ if (!visible().length) {
779
+ active?.removeAttribute("aria-current");
780
+ active = undefined;
781
+ document.querySelector("[data-hand-over]").focus();
782
+ announce(
783
+ "This view is reviewed. Hand it over with e, or use All to revisit your choices.",
784
+ );
785
+ }
786
+ };
787
+
788
+ const edit = (box, kind) => {
789
+ select(box, { focus: inline });
790
+ if (kind === "leave") return commit(box, kind);
791
+ const slot = box.querySelector(".choice-slot");
792
+ const old = slot.querySelector("textarea")?.value;
793
+ const pending = pendingFor(box.dataset.decision);
794
+ const saved = choiceFor(box.dataset.decision);
795
+ const text = old ?? pending?.text ?? saved?.text ?? "";
796
+ slot.replaceChildren();
797
+ const form = document.createElement("form");
798
+ form.className = "choice-editor";
799
+ const label = document.createElement("label");
800
+ const textarea = document.createElement("textarea");
801
+ textarea.id = `answer-${box.dataset.decision}`;
802
+ textarea.rows = 3;
803
+ textarea.required = true;
804
+ textarea.value = text;
805
+ label.htmlFor = textarea.id;
806
+ label.textContent =
807
+ kind === "later" ? "What should the backlog task cover?" : labels[kind];
808
+ const help = document.createElement("p");
809
+ help.className = "reading";
810
+ help.textContent =
811
+ kind === "later"
812
+ ? "Keep today's implementation; ask the agent to create a task. Ctrl/⌘ + Enter saves and moves on. Escape closes this draft."
813
+ : "Ctrl/⌘ + Enter saves and moves on. Enter adds a line. Escape closes this draft.";
814
+ const save = document.createElement("button");
815
+ save.type = "submit";
816
+ save.className = "primary";
817
+ save.textContent = "Save and next";
818
+ const cancel = document.createElement("button");
819
+ cancel.type = "button";
820
+ cancel.className = "quiet";
821
+ cancel.textContent = "Close draft";
822
+ const close = () => {
823
+ slot.replaceChildren();
824
+ paint();
825
+ select(box);
826
+ };
827
+ cancel.addEventListener("click", close);
828
+ textarea.addEventListener("input", () =>
829
+ draft(box.dataset.decision, kind, textarea.value),
830
+ );
831
+ form.addEventListener("submit", (event) => {
832
+ event.preventDefault();
833
+ if (!textarea.value.trim()) {
834
+ textarea.setCustomValidity("Write your question, change or task first.");
835
+ textarea.reportValidity();
836
+ return;
837
+ }
838
+ textarea.setCustomValidity("");
839
+ commit(box, kind, textarea.value);
840
+ });
841
+ textarea.addEventListener("input", () => textarea.setCustomValidity(""));
842
+ form.addEventListener("keydown", (event) => {
843
+ if (event.isComposing) return;
844
+ if (keyOf(event) === "Escape") {
845
+ event.preventDefault();
846
+ close();
847
+ } else if (keyOf(event) === "Enter" && (event.metaKey || event.ctrlKey)) {
848
+ event.preventDefault();
849
+ form.requestSubmit();
850
+ }
851
+ });
852
+ form.append(
853
+ label,
854
+ textarea,
855
+ help,
856
+ save,
857
+ ...(kind === "question" ? askNow(box, textarea) : []),
858
+ cancel,
859
+ );
860
+ slot.append(form);
861
+ draft(box.dataset.decision, kind, text);
862
+ textarea.focus();
863
+ };
864
+
865
+ // A question may be asked now instead, in a Claude Code session of its own,
866
+ // on the decisions it would answer. Asking leaves the draft as it is: the
867
+ // owner chooses here once the answer is in.
868
+ const askNow = (box, textarea) => {
869
+ const prompt = () =>
870
+ askingPrompt(
871
+ (inline ? [box] : answering()).map((item) => item.dataset.decision),
872
+ textarea.value,
873
+ );
874
+ const ask = document.createElement("a");
875
+ ask.className = "session-link";
876
+ ask.textContent = "Ask Claude now";
877
+ const follow = () => {
878
+ ask.href = sessionAddress(prompt());
879
+ };
880
+ follow();
881
+ textarea.addEventListener("input", follow);
882
+ ask.addEventListener("click", () => {
883
+ follow();
884
+ announce(
885
+ "Claude opens a new session in this checkout with the question typed in. Nothing is sent until you send it there; here the question stays a draft.",
886
+ );
887
+ });
888
+ const copy = document.createElement("button");
889
+ copy.type = "button";
890
+ copy.className = "quiet";
891
+ copy.textContent = "Copy prompt";
892
+ copy.addEventListener("click", () =>
893
+ navigator.clipboard.writeText(prompt()).then(
894
+ () => announce("Copied: the decision, quoted, and your question last."),
895
+ () => toast("This browser would not copy it. Ask Claude now opens it."),
896
+ ),
897
+ );
898
+ return [ask, copy];
899
+ };
900
+
901
+ // The change b asks for: the decision's own alternative, and the owner's note
902
+ // on it where it needs one, such as which of the routes it names they mean.
903
+ const ALTERNATIVE = "Use the suggested alternative: ";
904
+ const NOTED = " My note: ";
905
+ const withNote = (alternative, note) =>
906
+ `${ALTERNATIVE}${alternative}${note.trim() ? `${NOTED}${note.trim()}` : ""}`;
907
+ const noteIn = (text = "", alternative) => {
908
+ const noted = `${ALTERNATIVE}${alternative}${NOTED}`;
909
+ return text.startsWith(noted) ? text.slice(noted.length) : "";
910
+ };
911
+
912
+ // b opens one line under the decision for that note. Enter saves and moves
913
+ // on, the line empty or not, so the alternative alone is still two keys away.
914
+ const useAlternative = (box) => {
915
+ // An alternative is one decision's own: it is no answer for several.
916
+ if (answering().length > 1)
917
+ return toast(
918
+ `b uses one decision's own alternative, so it waits while ${answering().length} are selected. Esc clears the selection.`,
919
+ );
920
+ const alternative = box?.querySelector("[data-choice-alternative]")?.dataset
921
+ .alternative;
922
+ if (!alternative) {
923
+ if (box) toast("This decision names no alternative to use.");
924
+ return;
925
+ }
926
+ select(box, { focus: inline });
927
+ const slot = box.querySelector(".choice-slot");
928
+ const form = document.createElement("form");
929
+ form.className = "choice-editor";
930
+ form.dataset.alternativeNote = "";
931
+ const label = document.createElement("label");
932
+ const input = document.createElement("input");
933
+ input.type = "text";
934
+ input.id = `note-${box.dataset.decision}`;
935
+ input.value = noteIn(choiceFor(box.dataset.decision)?.text, alternative);
936
+ label.htmlFor = input.id;
937
+ label.textContent = "Use the alternative, with a note if it needs one";
938
+ const help = document.createElement("p");
939
+ help.className = "reading";
940
+ help.textContent =
941
+ "Say which part of it you mean, or leave the line empty. Enter saves and moves on. Escape closes.";
942
+ const close = () => {
943
+ slot.replaceChildren();
944
+ paint();
945
+ select(box);
946
+ };
947
+ form.addEventListener("submit", (event) => {
948
+ event.preventDefault();
949
+ commit(box, "change", withNote(alternative, input.value));
950
+ });
951
+ form.addEventListener("keydown", (event) => {
952
+ if (event.isComposing || keyOf(event) !== "Escape") return;
953
+ event.preventDefault();
954
+ close();
955
+ });
956
+ form.append(label, input, help);
957
+ slot.replaceChildren(form);
958
+ input.focus();
959
+ };
960
+
961
+ // R makes the answer on a decision a rule the agent records beyond this
962
+ // burst, or rewrites the one made of it: one line, under the decision.
963
+ const makeRule = (box) => {
964
+ if (!box) return;
965
+ const saved = choiceFor(box.dataset.decision);
966
+ if (!saved || stale(saved))
967
+ return toast("R makes a rule of an answer: choose on this decision first.");
968
+ select(box, { focus: inline });
969
+ const slot = box.querySelector(".choice-slot");
970
+ const form = document.createElement("form");
971
+ form.className = "choice-editor";
972
+ form.dataset.ruleEditor = "";
973
+ const label = document.createElement("label");
974
+ const input = document.createElement("input");
975
+ input.type = "text";
976
+ input.id = `rule-${box.dataset.decision}`;
977
+ input.required = true;
978
+ input.value = ruleFor(saved)?.text ?? "";
979
+ label.htmlFor = input.id;
980
+ label.textContent = "The rule, in one line";
981
+ const help = document.createElement("p");
982
+ help.className = "reading";
983
+ help.textContent =
984
+ "The agent records it where this project keeps its rules. Enter saves. Escape closes.";
985
+ const close = () => {
986
+ slot.replaceChildren();
987
+ paint();
988
+ select(box);
989
+ };
990
+ form.addEventListener("submit", (event) => {
991
+ event.preventDefault();
992
+ if (saveRule(saved, input.value)) close();
993
+ });
994
+ form.addEventListener("keydown", (event) => {
995
+ if (event.isComposing || keyOf(event) !== "Escape") return;
996
+ event.preventDefault();
997
+ close();
998
+ });
999
+ form.append(label, input, help);
1000
+ slot.replaceChildren(form);
1001
+ input.focus();
1002
+ };
1003
+
1004
+ const screenshots = (box) => {
1005
+ if (!box) return;
1006
+ if (box.querySelector("[data-decision-screenshots]"))
1007
+ return toggleScreenshots(box);
1008
+ toast(
1009
+ inline
1010
+ ? "This decision links no screenshots."
1011
+ : "Screenshots are previewed on the pull request's page: Enter opens this decision there.",
1012
+ );
1013
+ };
1014
+
1015
+ const undo = () => {
1016
+ const id = undoChoice();
1017
+ if (!id) return;
1018
+ passedOver.clear();
1019
+ const box = boxes.find((box) => box.dataset.decision === id);
1020
+ paint();
1021
+ // Taking back the gesture leaves the folded decisions folded.
1022
+ if (box?.closest("details[data-non-blocking]:not([open])")) return;
1023
+ if (box?.hidden) show("all");
1024
+ select(box);
1025
+ };
1026
+
1027
+ // Marks the pull request good to go, once the desk has the last choice.
1028
+ const goodToGo = async () => {
1029
+ const form = document.querySelector("form[data-good-to-go]");
1030
+ if (!form) return toast("This pull request is already marked good to go.");
1031
+ await settled();
1032
+ form.requestSubmit();
1033
+ };
1034
+
1035
+ document.addEventListener("click", (event) => {
1036
+ const link = event.target.closest("a[data-filter]");
1037
+ if (
1038
+ link &&
1039
+ !event.metaKey &&
1040
+ !event.ctrlKey &&
1041
+ !event.shiftKey &&
1042
+ !event.altKey
1043
+ ) {
1044
+ event.preventDefault();
1045
+ show(link.dataset.filter);
1046
+ return;
1047
+ }
1048
+ if (event.target.closest("[data-pending-view]")) return show("unreviewed");
1049
+ if (event.target.closest("[data-reviewed-view]")) return show("reviewed");
1050
+ if (event.target.closest("[data-all-view]")) return show("all");
1051
+ if (event.target.closest("[data-finish-good]")) return goodToGo();
1052
+ if (event.target.closest("[data-finish-handover]")) return handOver();
1053
+ const leave = event.target.closest("[data-leave-non-blocking]");
1054
+ if (leave) return leaveNonBlocking(prNumber(leave.dataset.pr));
1055
+ // A line where the decisions end unfolds at a press, and folds again. A
1056
+ // picture its words name is seen where the decision is decided.
1057
+ const line = event.target.closest("[data-line-of]");
1058
+ if (line) {
1059
+ if (event.target.closest('a[href*="#screenshot-"]')) {
1060
+ event.preventDefault();
1061
+ return decideFrom(line);
1062
+ }
1063
+ if (event.target.closest(".more")) return;
1064
+ line.focus({ preventScroll: true });
1065
+ return unfold(line, !unfolded(line));
1066
+ }
1067
+ const heading = event.target.closest("[data-theme-heading]");
1068
+ if (heading) return select(heading);
1069
+ const box = event.target.closest("[data-decision]");
1070
+ if (!box) return;
1071
+ if (event.target.closest("[data-choice-alternative]"))
1072
+ return useAlternative(box);
1073
+ if (event.target.closest("[data-make-rule]")) return makeRule(box);
1074
+ const action = event.target.closest("button[data-choice]");
1075
+ if (action) return edit(box, action.dataset.choice);
1076
+ if (event.target.closest("[data-choice-clear]")) {
1077
+ clearChoice(box.dataset.decision);
1078
+ paint();
1079
+ select(box.hidden ? visible()[0] : box);
1080
+ return;
1081
+ }
1082
+ // A click that opened the viewer over the page leaves the page as it was.
1083
+ if (document.querySelector("dialog[open]")) return;
1084
+ // A click on a decision as it is read opens it to decide, unless it was to
1085
+ // follow a link, press something of the page's, or select its words.
1086
+ const opening =
1087
+ inline &&
1088
+ !engaged &&
1089
+ getSelection().isCollapsed &&
1090
+ !event.target.closest("a, button, input, textarea, summary, img");
1091
+ select(box, { focus: opening });
1092
+ });
1093
+
1094
+ document.addEventListener("focusin", (event) => {
1095
+ // A panel over the page, a picture looked at closely, takes the focus
1096
+ // without leaving the decision: its Esc comes back to it.
1097
+ if (event.target.closest("dialog")) return;
1098
+ const box = event.target.closest("[data-decision]");
1099
+ if (inline) {
1100
+ // Deciding begins only when it is asked for (f, Enter, a click, a choice's
1101
+ // key): the reading passes over a decision like any other block. It ends
1102
+ // when the focus leaves the decision.
1103
+ if (engaged && !box) engage(false);
1104
+ if (event.target.closest("[data-request]"))
1105
+ active?.removeAttribute("aria-current");
1106
+ if (!engaged) frame(event.target.closest("[data-finish]") ? "done" : "");
1107
+ }
1108
+ const rest = box ?? event.target.closest("[data-theme-heading]");
1109
+ if (rest && rest !== active) select(rest, { focus: false });
1110
+ sayMode();
1111
+ });
1112
+ document.addEventListener("focusout", () => setTimeout(sayMode));
1113
+
1114
+ // Into the decisions: the first still to review, or where they end once none is.
1115
+ const enter = () => {
1116
+ if (!boxes.length) return toast("This pull request has no decisions.");
1117
+ const box = inline
1118
+ ? firstToReview()
1119
+ : visible().find((box) => !reviewed(box));
1120
+ if (box) return select(box);
1121
+ if (inline && finish && !finish.hidden) {
1122
+ finish.closest("details.section").open = true;
1123
+ return finished();
1124
+ }
1125
+ if (visible()[0]) select(visible()[0]);
1126
+ };
1127
+ for (const control of document.querySelectorAll("[data-enter-decisions]")) {
1128
+ control.addEventListener("click", (event) => {
1129
+ event.preventDefault();
1130
+ enter();
1131
+ });
1132
+ }
1133
+ document.addEventListener("keydown", (event) => {
1134
+ if (event.isComposing || event.metaKey || event.ctrlKey || event.altKey)
1135
+ return;
1136
+ // A key the reading already answered, by moving onto a decision, is spent.
1137
+ if (helping() || event.defaultPrevented) return;
1138
+ if (event.target.closest("input, textarea, select, [contenteditable]"))
1139
+ return;
1140
+ const anywhere = {
1141
+ f: inline && enter,
1142
+ e: inline && handOver,
1143
+ g: inline && goodToGo,
1144
+ A: inline && (() => leaveNonBlocking(scope)),
1145
+ u: undo,
1146
+ };
1147
+ if (anywhere[keyOf(event)]) {
1148
+ event.preventDefault();
1149
+ anywhere[keyOf(event)]();
1150
+ return;
1151
+ }
1152
+ if (inline && keyOf(event) === "Escape" && engaged) {
1153
+ if (closeScreenshots(active)) {
1154
+ event.preventDefault();
1155
+ active.focus({ preventScroll: true });
1156
+ return;
1157
+ }
1158
+ if (document.body.dataset.returnsTo && !firstToReview())
1159
+ return finished(active);
1160
+ engage(false);
1161
+ frame("");
1162
+ active?.removeAttribute("aria-current");
1163
+ const heading = active?.closest("details.section").querySelector("summary");
1164
+ heading?.focus({ preventScroll: true });
1165
+ // The list keeps its view again, which may not show the decision left.
1166
+ const left = active && !active.hidden ? active : heading;
1167
+ left?.scrollIntoView({ block: "center" });
1168
+ step();
1169
+ return;
1170
+ }
1171
+ if (inline && keyOf(event) === "Escape" && document.body.dataset.focus) {
1172
+ // From where the decisions end, back to the page.
1173
+ frame("");
1174
+ document.querySelector("#decisions > summary")?.focus();
1175
+ return;
1176
+ }
1177
+ // On the condition that its decisions are reviewed, Enter goes to them as f does.
1178
+ if (
1179
+ inline &&
1180
+ keyOf(event) === "Enter" &&
1181
+ event.target.matches?.('[data-about="decisions"]')
1182
+ ) {
1183
+ event.preventDefault();
1184
+ return enter();
1185
+ }
1186
+ // Where the decisions end, the reading goes down their lines, and → and ←
1187
+ // unfold and fold the one it rests on. Enter, or a choice's key, opens its
1188
+ // decision, as on a decision the reading rests on; nothing else moves the
1189
+ // reading from the panel.
1190
+ if (inline && !engaged && document.body.dataset.focus === "done") {
1191
+ const line = event.target.closest?.("[data-line-of]");
1192
+ const choosing = "abqcl".includes(keyOf(event));
1193
+ if (line && (choosing || keyOf(event) === "Enter")) {
1194
+ decideFrom(line);
1195
+ if (!choosing) return event.preventDefault();
1196
+ } else {
1197
+ const move = AT_THE_END[keyOf(event)];
1198
+ if (move) {
1199
+ event.preventDefault();
1200
+ move(line);
1201
+ }
1202
+ return;
1203
+ }
1204
+ }
1205
+ if (inline && !engaged) {
1206
+ // On a decision the reading rests on, Enter opens it to decide, and a
1207
+ // choice's key opens it and makes that choice. Anywhere else those keys
1208
+ // do nothing: say where they work, once asked.
1209
+ const row = event.target.matches?.("li[data-decision]")
1210
+ ? event.target
1211
+ : undefined;
1212
+ const choosing = "abqcl".includes(keyOf(event));
1213
+ if (row && (choosing || keyOf(event) === "Enter")) {
1214
+ select(row);
1215
+ if (!choosing) return event.preventDefault();
1216
+ } else {
1217
+ if (
1218
+ choosing &&
1219
+ keyOf(event) !== "c" &&
1220
+ boxes.length &&
1221
+ !event.target.closest("[data-request]")
1222
+ )
1223
+ toast("That key chooses on a decision: f goes to the first one.");
1224
+ return;
1225
+ }
1226
+ }
1227
+ const follow = (link) => {
1228
+ if (link) settled().then(() => location.assign(link.href));
1229
+ };
1230
+ // The decision a choice opens on: the one at hand, or, from a theme's
1231
+ // heading, the first of those selected.
1232
+ const choosing = (kind) => {
1233
+ const box = isHeading(active) ? answering()[0] : active;
1234
+ if (box) edit(box, kind);
1235
+ else if (active)
1236
+ toast(
1237
+ "x selects this theme's decisions; a choice then answers them all.",
1238
+ );
1239
+ };
1240
+ const actions = {
1241
+ ...MOVING,
1242
+ Home: () => select(visible()[0]),
1243
+ End: () => select(visible().at(-1)),
1244
+ a: () => choosing("leave"),
1245
+ b: () => useAlternative(isHeading(active) ? undefined : active),
1246
+ R: () => makeRule(isHeading(active) ? undefined : active),
1247
+ s: () => screenshots(active),
1248
+ q: () => choosing("question"),
1249
+ c: () => choosing("change"),
1250
+ l: () => choosing("later"),
1251
+ e: handOver,
1252
+ ...(inline
1253
+ ? {}
1254
+ : {
1255
+ x: toggleSelection,
1256
+ Escape: () => clearSelection(),
1257
+ A: () =>
1258
+ active && !isHeading(active) && leaveNonBlocking(prOf(active)),
1259
+ n: () =>
1260
+ toast(
1261
+ "Notes are written on the pull request's page: Enter opens this decision there.",
1262
+ ),
1263
+ h: () => follow(document.querySelector('a[data-key="h"]')),
1264
+ Enter: () => follow(active?.querySelector("a[data-on-its-page]")),
1265
+ }),
1266
+ };
1267
+ if (!Object.hasOwn(actions, keyOf(event))) return;
1268
+ if (keyOf(event) === "Enter" && event.target !== active) return;
1269
+ event.preventDefault();
1270
+ actions[keyOf(event)]();
1271
+ });
1272
+
1273
+ document.addEventListener("drafts-changed", paint);
1274
+ window.addEventListener("popstate", () => {
1275
+ filter = new URL(location.href).searchParams.get("show") || "unreviewed";
1276
+ paint();
1277
+ select(
1278
+ boxes.find((box) => `#${box.id}` === location.hash && !box.hidden) ||
1279
+ visible()[0],
1280
+ );
1281
+ });
1282
+ paint();
1283
+ select(
1284
+ boxes.find((box) => `#${box.id}` === location.hash && !box.hidden) ||
1285
+ boxes.find((box) => box.dataset.decision === cursor() && !box.hidden) ||
1286
+ visible()[0],
1287
+ { focus: false },
1288
+ );
1289
+ sayMode();