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,394 @@
1
+ // The player: play and pause, one frame at a time, step by step, and a note
2
+ // on the moment the film is paused at, with a rectangle drawn on the frame
3
+ // when the owner drags one.
4
+ //
5
+ // A film keeps every frame for as long as it was on screen, so "one frame" is
6
+ // the distance to the next time a frame is first shown. The server lists those
7
+ // times; this reads them from the page.
8
+
9
+ const EPS = 0.002;
10
+ const FALLBACK_FRAME = 1 / 12;
11
+ const DRAG = 4;
12
+
13
+ const film = document.querySelector("[data-film]");
14
+ const video = film.querySelector("video");
15
+ const screen = film.querySelector("[data-screen]");
16
+ const regions = film.querySelector("[data-regions]");
17
+ const timeline = film.querySelector("[data-timeline]");
18
+ const playhead = film.querySelector("[data-playhead]");
19
+ const playButton = film.querySelector("[data-play]");
20
+ const clockOutput = film.querySelector("[data-clock]");
21
+ const whereOutput = film.querySelector("[data-where]");
22
+ const dialog = document.querySelector("#note-dialog");
23
+ const form = dialog.querySelector("form");
24
+ const about = dialog.querySelector("[data-about]");
25
+
26
+ const frames = JSON.parse(document.querySelector("#frames").textContent);
27
+ const steps = [...film.querySelectorAll("[data-step]")].map((item) => ({
28
+ item,
29
+ name: item.dataset.name,
30
+ from: Number(item.dataset.from),
31
+ }));
32
+ const segments = [...film.querySelectorAll(".segments li")];
33
+ const notes = [...film.querySelectorAll("[data-card]")].map((card) => ({
34
+ card,
35
+ id: card.dataset.card,
36
+ time: Number(card.dataset.time),
37
+ box: card.dataset.box ? card.dataset.box.split(",").map(Number) : null,
38
+ }));
39
+
40
+ const length = () =>
41
+ Number.isFinite(video.duration)
42
+ ? video.duration
43
+ : Number(film.dataset.length);
44
+
45
+ const clock = (seconds) => {
46
+ const whole = Math.floor(seconds);
47
+ const minutes = Math.floor(whole / 60);
48
+ return `${minutes}:${String(whole % 60).padStart(2, "0")}`;
49
+ };
50
+
51
+ // Where a time stands among the frames and the steps.
52
+
53
+ const frameAt = (time) => {
54
+ let index = -1;
55
+ for (let at = 0; at < frames.length && frames[at] <= time + EPS; at++)
56
+ index = at;
57
+ return index;
58
+ };
59
+
60
+ const stepAt = (time) => {
61
+ let index = -1;
62
+ for (let at = 0; at < steps.length && steps[at].from <= time + EPS; at++)
63
+ index = at;
64
+ return index;
65
+ };
66
+
67
+ const seek = (time) => {
68
+ video.currentTime = Math.min(Math.max(time, 0), length());
69
+ render();
70
+ };
71
+
72
+ const frameBy = (direction) => {
73
+ video.pause();
74
+ if (!frames.length)
75
+ return seek(video.currentTime + direction * FALLBACK_FRAME);
76
+ const target = Math.min(
77
+ Math.max(frameAt(video.currentTime) + direction, 0),
78
+ frames.length - 1,
79
+ );
80
+ seek(frames[target] + EPS);
81
+ };
82
+
83
+ const stepBy = (direction) => {
84
+ const target = stepAt(video.currentTime) + direction;
85
+ if (target < 0) return seek(0);
86
+ if (target >= steps.length) return seek(length());
87
+ seek(steps[target].from);
88
+ };
89
+
90
+ // What the page says about where the film is.
91
+
92
+ const light = (element) => {
93
+ clearTimeout(element.fadeOut);
94
+ element.classList.add("lit");
95
+ element.fadeOut = setTimeout(() => element.classList.remove("lit"), 1800);
96
+ };
97
+
98
+ const showRegions = () => {
99
+ const shown = frameAt(video.currentTime);
100
+ for (const old of regions.querySelectorAll(".region:not(.draft)"))
101
+ old.remove();
102
+ for (const note of notes) {
103
+ if (!note.box || frameAt(note.time) !== shown) continue;
104
+ const [left, top, width, height] = note.box.map((part) => `${part * 100}%`);
105
+ const region = regions.appendChild(document.createElement("span"));
106
+ region.className = "region";
107
+ region.dataset.kind = note.card.dataset.kind;
108
+ Object.assign(region.style, { left, top, width, height });
109
+ }
110
+ };
111
+
112
+ function render() {
113
+ const time = video.currentTime;
114
+ const step = stepAt(time);
115
+ const frame = frameAt(time);
116
+ playhead.style.left = `${(100 * time) / length()}%`;
117
+ clockOutput.textContent = `${clock(time)} / ${clock(length())}`;
118
+ playButton.textContent = video.paused ? "Play" : "Pause";
119
+ steps.forEach(({ item }, index) => {
120
+ if (index === step) item.setAttribute("aria-current", "step");
121
+ else item.removeAttribute("aria-current");
122
+ });
123
+ segments.forEach((segment, index) => {
124
+ segment.classList.toggle("here", index === step);
125
+ });
126
+ whereOutput.textContent = [
127
+ step >= 0
128
+ ? `Step ${step + 1} of ${steps.length}: ${steps[step].name}`
129
+ : null,
130
+ frames.length ? `frame ${frame + 1} of ${frames.length}` : null,
131
+ ]
132
+ .filter(Boolean)
133
+ .join(" · ");
134
+ showRegions();
135
+ }
136
+
137
+ // While it plays the playhead follows every painted frame, not the four
138
+ // updates a second that timeupdate gives.
139
+ let following = false;
140
+ const follow = () => {
141
+ render();
142
+ following = !video.paused;
143
+ if (following) requestAnimationFrame(follow);
144
+ };
145
+
146
+ const toggle = () => (video.paused ? video.play() : video.pause());
147
+
148
+ video.addEventListener("play", () => following || follow());
149
+ for (const event of [
150
+ "pause",
151
+ "seeked",
152
+ "loadedmetadata",
153
+ "ended",
154
+ "timeupdate",
155
+ ])
156
+ video.addEventListener(event, render);
157
+
158
+ // Pressing.
159
+
160
+ playButton.addEventListener("click", toggle);
161
+ film
162
+ .querySelector("[data-frame-back]")
163
+ .addEventListener("click", () => frameBy(-1));
164
+ film
165
+ .querySelector("[data-frame-forward]")
166
+ .addEventListener("click", () => frameBy(1));
167
+ film
168
+ .querySelector("[data-step-back]")
169
+ .addEventListener("click", () => stepBy(-1));
170
+ film
171
+ .querySelector("[data-step-forward]")
172
+ .addEventListener("click", () => stepBy(1));
173
+ film
174
+ .querySelector("[data-note-here]")
175
+ .addEventListener("click", () => compose(null));
176
+
177
+ for (const { item, from } of steps)
178
+ item.querySelector("button").addEventListener("click", () => seek(from));
179
+
180
+ // A still that could not be made leaves its step's name alone.
181
+ for (const still of film.querySelectorAll("img[data-still]")) {
182
+ if (still.complete && !still.naturalWidth) still.remove();
183
+ else still.addEventListener("error", () => still.remove());
184
+ }
185
+
186
+ // Pins and cards go to their moment, and a note arrived at by its address too.
187
+
188
+ const goTo = (note) => {
189
+ video.pause();
190
+ seek(note.time + EPS);
191
+ light(note.card);
192
+ for (const pin of film.querySelectorAll(`[data-pin="${note.id}"]`))
193
+ light(pin);
194
+ note.card.scrollIntoView({ block: "nearest" });
195
+ };
196
+
197
+ for (const note of notes) {
198
+ note.card.addEventListener("click", (event) => {
199
+ if (!event.target.closest("form")) goTo(note);
200
+ });
201
+ }
202
+ for (const pin of film.querySelectorAll("[data-pin]")) {
203
+ pin.addEventListener("click", (event) => {
204
+ event.stopPropagation();
205
+ goTo(notes.find((note) => note.id === pin.dataset.pin));
206
+ });
207
+ }
208
+
209
+ const arrive = () => {
210
+ const target =
211
+ location.hash && document.getElementById(location.hash.slice(1));
212
+ const note = notes.find((candidate) => candidate.card === target);
213
+ if (note) goTo(note);
214
+ };
215
+ if (video.readyState >= 1) arrive();
216
+ else video.addEventListener("loadedmetadata", arrive, { once: true });
217
+ addEventListener("hashchange", arrive);
218
+
219
+ // The timeline: press or drag to go to a moment.
220
+
221
+ const along = (event) => {
222
+ const box = timeline.getBoundingClientRect();
223
+ return ((event.clientX - box.left) / box.width) * length();
224
+ };
225
+
226
+ timeline.addEventListener("pointerdown", (event) => {
227
+ if (event.button !== 0 || event.target.closest(".pin")) return;
228
+ timeline.setPointerCapture(event.pointerId);
229
+ timeline.scrubbing = true;
230
+ seek(along(event));
231
+ });
232
+ timeline.addEventListener("pointermove", (event) => {
233
+ if (timeline.scrubbing) seek(along(event));
234
+ });
235
+ timeline.addEventListener("pointerup", () => {
236
+ timeline.scrubbing = false;
237
+ });
238
+
239
+ // The frame: a press plays or pauses, a drag marks a place and asks for a note.
240
+
241
+ const fraction = (value) => Math.min(Math.max(value, 0), 1);
242
+ const round = (value) => Math.round(value * 1e4) / 1e4;
243
+
244
+ const pointOn = (event, frame) => ({
245
+ x: fraction((event.clientX - frame.left) / frame.width),
246
+ y: fraction((event.clientY - frame.top) / frame.height),
247
+ });
248
+
249
+ const boxBetween = (from, to) => [
250
+ Math.min(from.x, to.x),
251
+ Math.min(from.y, to.y),
252
+ Math.abs(to.x - from.x),
253
+ Math.abs(to.y - from.y),
254
+ ];
255
+
256
+ let drag = null;
257
+
258
+ const sketch = (box) => {
259
+ drag.shape ??= regions.appendChild(
260
+ Object.assign(document.createElement("span"), {
261
+ className: "region draft",
262
+ }),
263
+ );
264
+ const [left, top, width, height] = box.map((part) => `${part * 100}%`);
265
+ Object.assign(drag.shape.style, { left, top, width, height });
266
+ };
267
+
268
+ screen.addEventListener("pointerdown", (event) => {
269
+ if (event.button !== 0) return;
270
+ screen.setPointerCapture(event.pointerId);
271
+ const frame = screen.getBoundingClientRect();
272
+ drag = {
273
+ frame,
274
+ from: pointOn(event, frame),
275
+ start: event,
276
+ shape: null,
277
+ wasPlaying: !video.paused,
278
+ };
279
+ // A note is on one frame, so the frame holds still while it is marked.
280
+ video.pause();
281
+ });
282
+
283
+ screen.addEventListener("pointermove", (event) => {
284
+ if (!drag) return;
285
+ const moved = Math.hypot(
286
+ event.clientX - drag.start.clientX,
287
+ event.clientY - drag.start.clientY,
288
+ );
289
+ if (moved >= DRAG) sketch(boxBetween(drag.from, pointOn(event, drag.frame)));
290
+ });
291
+
292
+ screen.addEventListener("pointerup", (event) => {
293
+ if (!drag) return;
294
+ const { frame, from, start, wasPlaying } = drag;
295
+ const dragged =
296
+ Math.hypot(event.clientX - start.clientX, event.clientY - start.clientY) >=
297
+ DRAG;
298
+ if (!dragged) {
299
+ drag = null;
300
+ return wasPlaying || video.play();
301
+ }
302
+ const box = boxBetween(from, pointOn(event, frame));
303
+ sketch(box);
304
+ drag = null;
305
+ compose(box.map(round));
306
+ });
307
+
308
+ screen.addEventListener("pointercancel", () => {
309
+ drag?.shape?.remove();
310
+ drag = null;
311
+ });
312
+
313
+ // Writing a note: the film is paused, and the dialog says which moment it is.
314
+
315
+ function compose(box) {
316
+ video.pause();
317
+ const time = video.currentTime;
318
+ const step = stepAt(time);
319
+ form.reset();
320
+ form.elements.time.value = time;
321
+ form.elements.box.value = box ? box.join(",") : "";
322
+ about.textContent = [
323
+ `At ${clock(time)}`,
324
+ step >= 0 ? `in the step "${steps[step].name}"` : null,
325
+ box ? "with a place marked on the frame" : null,
326
+ ]
327
+ .filter(Boolean)
328
+ .join(", ");
329
+ dialog.showModal();
330
+ }
331
+
332
+ dialog.addEventListener("close", () => {
333
+ for (const draft of regions.querySelectorAll(".draft")) draft.remove();
334
+ });
335
+
336
+ form.addEventListener("keydown", (event) => {
337
+ if (event.key === "Enter" && (event.metaKey || event.ctrlKey))
338
+ form.requestSubmit();
339
+ });
340
+
341
+ // Filling the window moves the steps under the video. Remembered if the browser lets it.
342
+
343
+ const fill = document.querySelector("[data-fill]");
344
+ const wide = (on) => {
345
+ film.classList.toggle("wide", on);
346
+ fill.setAttribute("aria-pressed", String(on));
347
+ fill.textContent = on ? "Steps beside it" : "Fill the window";
348
+ };
349
+ fill.addEventListener("click", () => {
350
+ wide(!film.classList.contains("wide"));
351
+ try {
352
+ localStorage.setItem(
353
+ "proctolog:film:wide",
354
+ film.classList.contains("wide"),
355
+ );
356
+ } catch {
357
+ // Without storage it opens as the server lays it out.
358
+ }
359
+ });
360
+ try {
361
+ wide(localStorage.getItem("proctolog:film:wide") === "true");
362
+ } catch {
363
+ wide(false);
364
+ }
365
+
366
+ // Keys. They are ignored while typing and while the dialog is open. Space is
367
+ // left to a button that has the focus, which it presses.
368
+
369
+ const keys = {
370
+ " ": toggle,
371
+ ",": () => frameBy(-1),
372
+ ".": () => frameBy(1),
373
+ "[": () => stepBy(-1),
374
+ "]": () => stepBy(1),
375
+ c: () => compose(null),
376
+ };
377
+
378
+ document.addEventListener("keydown", (event) => {
379
+ if (event.metaKey || event.ctrlKey || event.altKey || dialog.open) return;
380
+ if (!Object.hasOwn(keys, event.key)) return;
381
+ if (event.target.closest("input, textarea, select, [contenteditable]"))
382
+ return;
383
+ if (event.key === " " && event.target.closest("button, a, summary")) return;
384
+
385
+ event.preventDefault();
386
+ keys[event.key]();
387
+ });
388
+
389
+ // A button the mouse pressed gives the keys back to the page.
390
+ film.addEventListener("click", (event) => {
391
+ if (event.detail > 0) event.target.closest("button")?.blur();
392
+ });
393
+
394
+ render();
data/public/release.js ADDED
@@ -0,0 +1,25 @@
1
+ // A newer PRoctolog, as the desk last asked RubyGems: said once in each
2
+ // browser for each version, in the toast every page uses, and kept among the
3
+ // messages for m to show again.
4
+
5
+ import { toast } from "./toast.js";
6
+
7
+ const TOLD = "proctolog:told-of-release";
8
+ const newer = document.querySelector('meta[name="proctolog-newer"]');
9
+
10
+ const told = () => {
11
+ try {
12
+ return localStorage.getItem(TOLD);
13
+ } catch {
14
+ return null;
15
+ }
16
+ };
17
+
18
+ if (newer && told() !== newer.content) {
19
+ toast(newer.dataset.news);
20
+ try {
21
+ localStorage.setItem(TOLD, newer.content);
22
+ } catch {
23
+ // A browser that keeps nothing is told again on the next page.
24
+ }
25
+ }
@@ -0,0 +1,260 @@
1
+ /* Notes where they stand: the kinds of a note, its card, what stands under a
2
+ box of "Decisions for the owner", and the small form a note is written in.
3
+ The pull request's page and the decisions page show all of these. */
4
+
5
+ :root {
6
+ --change: var(--pin);
7
+ --question: var(--chalk);
8
+ --remark: #7d6a2a;
9
+ }
10
+
11
+ @media (prefers-color-scheme: dark) {
12
+ :root {
13
+ --remark: #d6c27a;
14
+ }
15
+ }
16
+
17
+ [data-kind="change"] {
18
+ --kind: var(--change);
19
+ }
20
+
21
+ [data-kind="question"] {
22
+ --kind: var(--question);
23
+ }
24
+
25
+ [data-kind="remark"] {
26
+ --kind: var(--remark);
27
+ }
28
+
29
+ .visually-hidden {
30
+ position: absolute;
31
+ width: 1px;
32
+ height: 1px;
33
+ overflow: hidden;
34
+ clip-path: inset(50%);
35
+ }
36
+
37
+ .lit {
38
+ box-shadow: 0 0 0 4px
39
+ color-mix(in srgb, var(--kind, var(--chalk)) 45%, transparent);
40
+ }
41
+
42
+ .card {
43
+ transition: box-shadow 0.6s;
44
+ }
45
+
46
+ .cards {
47
+ display: grid;
48
+ gap: 0.6rem;
49
+ margin: 0;
50
+ padding: 0;
51
+ list-style: none;
52
+ }
53
+
54
+ .card {
55
+ padding: 0.65rem 0.85rem 0.55rem;
56
+ border: 1px solid var(--rule);
57
+ border-left: 3px solid var(--kind, var(--quiet));
58
+ border-radius: 3px;
59
+ background: color-mix(in srgb, var(--rest) 50%, transparent);
60
+ cursor: pointer;
61
+ }
62
+
63
+ li.card p {
64
+ margin: 0;
65
+ }
66
+
67
+ .card .meta {
68
+ font-size: 0.72rem;
69
+ letter-spacing: 0.1em;
70
+ text-transform: uppercase;
71
+ }
72
+
73
+ .card .kind {
74
+ color: var(--kind, var(--quiet));
75
+ }
76
+
77
+ .card .state {
78
+ margin-left: 0.6rem;
79
+ color: var(--quiet);
80
+ }
81
+
82
+ .card .text {
83
+ margin: 0.3rem 0;
84
+ white-space: pre-wrap;
85
+ overflow-wrap: anywhere;
86
+ }
87
+
88
+ .card .words {
89
+ margin: 0.45rem 0 0;
90
+ padding-left: 0.7rem;
91
+ border-left: 2px solid var(--rule);
92
+ color: var(--quiet);
93
+ font: italic 0.9rem / 1.45 var(--serif);
94
+ overflow-wrap: anywhere;
95
+ }
96
+
97
+ .card .where,
98
+ .card .reply {
99
+ margin-top: 0.4rem;
100
+ color: var(--quiet);
101
+ font-size: 0.85rem;
102
+ overflow-wrap: anywhere;
103
+ }
104
+
105
+ .card .moves {
106
+ display: flex;
107
+ gap: 0.9rem;
108
+ margin-top: 0.35rem;
109
+ font-size: 0.85rem;
110
+ }
111
+
112
+ .card[data-state="confirmed"],
113
+ .card[data-state="withdrawn"] {
114
+ opacity: 0.7;
115
+ }
116
+
117
+ /* Under a box */
118
+
119
+ .beneath {
120
+ max-width: 68ch;
121
+ font:
122
+ 0.87rem / 1.45 ui-sans-serif,
123
+ system-ui,
124
+ sans-serif;
125
+ }
126
+
127
+ .beneath .status {
128
+ margin: 0.35rem 0 0;
129
+ color: var(--quiet);
130
+ font-size: 0.82rem;
131
+ }
132
+
133
+ .beneath .status .state {
134
+ color: var(--ink);
135
+ font-weight: 500;
136
+ }
137
+
138
+ .beneath .cards {
139
+ margin: 0.55rem 0 0;
140
+ padding: 0;
141
+ }
142
+
143
+ .beneath .card {
144
+ margin: 0;
145
+ cursor: default;
146
+ }
147
+
148
+ .actions {
149
+ display: flex;
150
+ flex-wrap: wrap;
151
+ gap: 0.3rem 1.3rem;
152
+ margin-top: 0.5rem;
153
+ }
154
+
155
+ .actions form {
156
+ display: inline;
157
+ margin: 0;
158
+ }
159
+
160
+ /* The small form, in the flow of the page right under what it is about */
161
+
162
+ .slot:empty {
163
+ display: none;
164
+ }
165
+
166
+ li.slot {
167
+ display: block;
168
+ list-style: none;
169
+ }
170
+
171
+ .slot td {
172
+ padding: 0;
173
+ border: 0;
174
+ }
175
+
176
+ .respond {
177
+ --kind: var(--change);
178
+ box-sizing: border-box;
179
+ max-width: 38rem;
180
+ margin: 0.5rem 0;
181
+ padding: 0.6rem 0.8rem 0.55rem;
182
+ border: 1px solid var(--rule);
183
+ border-left: 3px solid var(--kind);
184
+ border-radius: 3px;
185
+ background: var(--paper);
186
+ color: var(--ink);
187
+ font:
188
+ 0.87rem / 1.45 ui-sans-serif,
189
+ system-ui,
190
+ sans-serif;
191
+ }
192
+
193
+ .respond:has([value="question"]:checked) {
194
+ --kind: var(--question);
195
+ }
196
+
197
+ .respond:has([value="remark"]:checked) {
198
+ --kind: var(--remark);
199
+ }
200
+
201
+ .respond .on {
202
+ display: -webkit-box;
203
+ margin: 0 0 0.45rem;
204
+ color: var(--quiet);
205
+ font:
206
+ italic 0.9rem / 1.4 ui-serif,
207
+ Georgia,
208
+ serif;
209
+ overflow: hidden;
210
+ overflow-wrap: anywhere;
211
+ -webkit-box-orient: vertical;
212
+ -webkit-line-clamp: 2;
213
+ }
214
+
215
+ .respond .on[hidden] {
216
+ display: none;
217
+ }
218
+
219
+ .respond .kinds {
220
+ display: flex;
221
+ gap: 1.2rem;
222
+ margin: 0 0 0.45rem;
223
+ padding: 0;
224
+ border: 0;
225
+ }
226
+
227
+ .respond textarea {
228
+ box-sizing: border-box;
229
+ width: 100%;
230
+ padding: 0.5rem 0.6rem;
231
+ border: 1px solid var(--rule);
232
+ border-radius: 3px;
233
+ background: var(--paper);
234
+ color: inherit;
235
+ font: inherit;
236
+ line-height: 1.5;
237
+ resize: vertical;
238
+ }
239
+
240
+ .respond textarea:focus-visible {
241
+ border-color: var(--chalk);
242
+ outline: 2px solid color-mix(in srgb, var(--chalk) 35%, transparent);
243
+ }
244
+
245
+ .respond .buttons {
246
+ display: flex;
247
+ gap: 1rem;
248
+ justify-content: flex-end;
249
+ align-items: baseline;
250
+ margin-top: 0.55rem;
251
+ }
252
+
253
+ .respond button.primary {
254
+ padding: 0.35rem 0.95rem;
255
+ border: 0;
256
+ border-radius: 4px;
257
+ background: var(--chalk);
258
+ color: var(--paper);
259
+ cursor: pointer;
260
+ }