proctolog 0.1.1 → 0.3.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 (80) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +111 -3
  3. data/README.md +32 -18
  4. data/app/controllers/proctolog/application_controller.rb +2 -2
  5. data/app/controllers/proctolog/bursts_controller.rb +1 -1
  6. data/app/controllers/proctolog/choices_controller.rb +3 -1
  7. data/app/controllers/proctolog/decisions_controller.rb +1 -1
  8. data/app/controllers/proctolog/notes_controller.rb +2 -2
  9. data/app/controllers/proctolog/prompts_controller.rb +1 -1
  10. data/app/controllers/proctolog/pull_requests_controller.rb +2 -4
  11. data/app/controllers/proctolog/review_data_controller.rb +3 -1
  12. data/app/controllers/proctolog/shares_controller.rb +27 -0
  13. data/app/helpers/proctolog/decisions_helper.rb +6 -2
  14. data/app/helpers/proctolog/desk_helper.rb +48 -8
  15. data/app/helpers/proctolog/rounds_helper.rb +4 -2
  16. data/app/helpers/proctolog/shares_helper.rb +11 -0
  17. data/app/helpers/proctolog/standings_helper.rb +5 -4
  18. data/app/models/proctolog/burst.rb +17 -8
  19. data/app/models/proctolog/choices.rb +8 -0
  20. data/app/models/proctolog/doctor.rb +23 -12
  21. data/app/models/proctolog/github.rb +1 -1
  22. data/app/models/proctolog/localhost.rb +17 -0
  23. data/app/models/proctolog/marks.rb +1 -1
  24. data/app/models/proctolog/note.rb +4 -0
  25. data/app/models/proctolog/owner.rb +3 -0
  26. data/app/models/proctolog/pick.rb +12 -0
  27. data/app/models/proctolog/rounds.rb +37 -0
  28. data/app/models/proctolog/server.rb +1 -7
  29. data/app/models/proctolog/setup.rb +190 -0
  30. data/app/models/proctolog/share/ngrok.rb +120 -0
  31. data/app/models/proctolog/share/settings.rb +23 -0
  32. data/app/models/proctolog/share/tailscale.rb +103 -0
  33. data/app/models/proctolog/share.rb +101 -0
  34. data/app/models/proctolog/standing.rb +24 -7
  35. data/app/views/proctolog/bursts/_line.html.erb +2 -2
  36. data/app/views/proctolog/bursts/_side.html.erb +6 -5
  37. data/app/views/proctolog/bursts/_side_stop.html.erb +4 -4
  38. data/app/views/proctolog/notes/_card.html.erb +7 -3
  39. data/app/views/proctolog/open_pull_requests/_side.html.erb +4 -3
  40. data/app/views/proctolog/open_pull_requests/_start.html.erb +8 -0
  41. data/app/views/proctolog/open_pull_requests/index.html.erb +4 -5
  42. data/app/views/proctolog/open_pull_requests/start/_first_time.html.erb +3 -0
  43. data/app/views/proctolog/open_pull_requests/start/_nothing_open.html.erb +3 -0
  44. data/app/views/proctolog/open_pull_requests/start/_nothing_written.html.erb +3 -0
  45. data/app/views/proctolog/shares/_line.html.erb +5 -0
  46. data/app/views/proctolog/shares/show.html.erb +48 -0
  47. data/app/views/proctolog/shares/ways/_ngrok.html.erb +6 -0
  48. data/app/views/proctolog/shares/ways/_tailscale.html.erb +2 -0
  49. data/bin/proctolog +154 -27
  50. data/completions/_proctolog +31 -6
  51. data/config/application.rb +23 -4
  52. data/config/initializers/grouse.rb +1 -1
  53. data/config/routes.rb +3 -0
  54. data/docs/manual/bursts.md +7 -2
  55. data/docs/manual/deciding.md +4 -1
  56. data/docs/manual/handover.md +6 -0
  57. data/docs/manual/pr-format.md +1 -1
  58. data/docs/manual/reading.md +54 -27
  59. data/docs/manual/setup.md +59 -0
  60. data/docs/manual/sharing.md +77 -0
  61. data/docs/manual/updates.md +11 -4
  62. data/lib/proctolog/changelog/news.rb +67 -0
  63. data/lib/proctolog/changelog.rb +75 -0
  64. data/lib/proctolog/installation.rb +19 -0
  65. data/lib/proctolog/version.rb +1 -1
  66. data/public/asking.js +31 -4
  67. data/public/decisions.js +4 -4
  68. data/public/desk.js +10 -6
  69. data/public/keys.js +6 -0
  70. data/public/notes.js +164 -58
  71. data/public/pick.css +21 -0
  72. data/public/respond.css +6 -0
  73. data/public/respond.js +51 -7
  74. data/public/review-drafts.js +47 -9
  75. data/public/review-rounds.js +27 -3
  76. data/public/share.css +58 -0
  77. data/public/side.js +11 -20
  78. data/public/terminal.css +295 -34
  79. data/public/viewer.js +700 -173
  80. metadata +22 -1
data/public/notes.js CHANGED
@@ -8,7 +8,7 @@
8
8
  // written in a small form right under what it is about (respond.js).
9
9
 
10
10
  import { helping, keyOf } from "./keys.js";
11
- import { respond } from "./respond.js";
11
+ import { putAway, respond, saveInPlace } from "./respond.js";
12
12
  import { toast } from "./toast.js";
13
13
  import { tryIt } from "./try.js";
14
14
  import { viewPictures } from "./viewer.js";
@@ -195,9 +195,111 @@ const placeOn = (figure, box) => ({
195
195
  },
196
196
  });
197
197
 
198
- // A note on a pair's half is written under the pair.
199
- const notePicture = (figure, box = [0, 0, 1, 1]) =>
200
- respond(slotAfter(figure.closest("figure.picture")), placeOn(figure, box));
198
+ // A note on a picture is written under it (under the pair, for one of its
199
+ // halves) and saved without leaving the page, which stays where it was and
200
+ // draws the note there.
201
+ const notePicture = (figure, box = [0, 0, 1, 1]) => {
202
+ const slot = slotAfter(figure.closest("figure.picture"));
203
+ const form = respond(slot, placeOn(figure, box));
204
+ saveInPlace(form, async ({ id }) => {
205
+ await redrawNotes();
206
+ putAway(form);
207
+ lightNote(id);
208
+ });
209
+ };
210
+
211
+ // The marks of a note on the page and its card, lit where they stand.
212
+ const lightNote = (id) => {
213
+ for (const part of document.querySelectorAll(
214
+ `[data-note="${id}"], [data-card="${id}"]`,
215
+ ))
216
+ light(part);
217
+ };
218
+
219
+ // The page as the desk has it now, or nothing when the desk does not answer.
220
+ const freshCopy = async () => {
221
+ const address = new URL(location.href);
222
+ // The page as it is, not GitHub read again.
223
+ address.searchParams.delete("fresh");
224
+ address.hash = "";
225
+ const response = await fetch(address, {
226
+ headers: { Accept: "text/html" },
227
+ }).catch(() => undefined);
228
+ if (!response?.ok) return undefined;
229
+ return new DOMParser().parseFromString(await response.text(), "text/html");
230
+ };
231
+
232
+ // Ways to find a place of the page, which a page and its fresh copy answer
233
+ // alike.
234
+ const selecting = (selector) => (page) => page.querySelector(selector);
235
+
236
+ // The count beside the heading of one of the page's own sections, not the
237
+ // description's: the description may have a section of the same name.
238
+ const sectionCount = (heading) =>
239
+ selecting(`.page > details[data-heading="${heading}"] > summary .count`);
240
+
241
+ // The count on the outline's line for a section: the last line so named, as
242
+ // the description's sections come first and may have the same name.
243
+ const outlineCount = (name) => (page) =>
244
+ [...page.querySelectorAll("[data-outline] > *")]
245
+ .findLast((line) => line.querySelector(".name")?.textContent === name)
246
+ ?.querySelector(".count");
247
+
248
+ // Where the page shows what a saved note changes: where the pull request
249
+ // stands, the Standing section, the cards of the notes, and the counts of what
250
+ // is open, beside two headings and on the outline. The outline's other counts
251
+ // are kept by the scripts that own them.
252
+ const CHANGED_BY_NOTES = [
253
+ selecting("#standing"),
254
+ selecting("section.standing"),
255
+ sectionCount("Standing"),
256
+ outlineCount("Standing"),
257
+ selecting("aside.margin"),
258
+ sectionCount("Notes"),
259
+ outlineCount("Notes"),
260
+ ];
261
+
262
+ // The fresh copy's element in place of the page's, when both have one.
263
+ const swapIn = (now, was) => {
264
+ if (!now || !was) return;
265
+ was.replaceWith(now);
266
+ readable(now);
267
+ };
268
+
269
+ // The notes as the desk now has them, drawn without the page going anywhere:
270
+ // the boxes on its pictures, then everything else that notes change, from a
271
+ // fresh copy of the page.
272
+ const redrawNotes = async () => {
273
+ const fresh = await freshCopy();
274
+ if (!fresh) return location.reload();
275
+ redrawRegions(fresh);
276
+ for (const find of CHANGED_BY_NOTES) swapIn(find(fresh), find(document));
277
+ };
278
+
279
+ // The pictures of the description that notes are drawn on, by their asset:
280
+ // the same picture shown twice is the same picture twice, in order.
281
+ const figuresByAsset = (page) =>
282
+ Map.groupBy(
283
+ page.querySelectorAll(
284
+ "main.description :is(figure.picture:not(.pair), figure.pair > .half)[data-asset]",
285
+ ),
286
+ (figure) => figure.dataset.asset,
287
+ );
288
+
289
+ const redrawRegions = (fresh) => {
290
+ const drawn = figuresByAsset(fresh);
291
+ for (const [asset, figures] of figuresByAsset(document))
292
+ figures.forEach((figure, index) => {
293
+ const now = drawn.get(asset)?.[index];
294
+ if (!now) return;
295
+ // The region being drawn for a note not yet saved stays.
296
+ for (const region of figure.querySelectorAll(
297
+ ":scope > .region:not(.draft)",
298
+ ))
299
+ region.remove();
300
+ figure.append(...now.querySelectorAll(":scope > .region"));
301
+ });
302
+ };
201
303
 
202
304
  let pending;
203
305
  document.addEventListener("selectionchange", () => {
@@ -211,11 +313,26 @@ document.addEventListener("selectionchange", () => {
211
313
  rect.right,
212
314
  innerWidth - selectionButton.offsetWidth - 8,
213
315
  );
214
- selectionButton.style.left = `${Math.max(8, left) + scrollX}px`;
215
- selectionButton.style.top = `${rect.top + scrollY - selectionButton.offsetHeight - 6}px`;
316
+ const origin = originOf(selectionButton);
317
+ selectionButton.style.left = `${Math.max(8, left) - origin.left}px`;
318
+ selectionButton.style.top = `${rect.top - origin.top - selectionButton.offsetHeight - 6}px`;
216
319
  }, 120);
217
320
  });
218
321
 
322
+ // Where an element placed absolutely counts its left and top from, on the
323
+ // screen: the box of the nearest ancestor laid out on its own (the column
324
+ // beside the burst's pane), or else the page.
325
+ const originOf = (element) => {
326
+ const parent = element.offsetParent;
327
+ if (!parent || getComputedStyle(parent).position === "static")
328
+ return { left: -scrollX, top: -scrollY };
329
+ const box = parent.getBoundingClientRect();
330
+ return {
331
+ left: box.left + parent.clientLeft - parent.scrollLeft,
332
+ top: box.top + parent.clientTop - parent.scrollTop,
333
+ };
334
+ };
335
+
219
336
  // Keeps the selection alive while the button is pressed.
220
337
  selectionButton.addEventListener("mousedown", (event) =>
221
338
  event.preventDefault(),
@@ -353,14 +470,18 @@ document.addEventListener("pointercancel", () => {
353
470
  drag = null;
354
471
  });
355
472
 
356
- // Reading has its own cursor; Tab still reaches every link and control.
357
- for (const element of document.querySelectorAll(
358
- "h1, h2, h3, h4, summary, .conditions > li, li.scenario, [data-card]",
359
- )) {
360
- if (element.closest("[data-desk]")) continue;
361
- element.dataset.reading = "";
362
- if (element.localName !== "summary") element.tabIndex = -1;
363
- }
473
+ // Reading has its own cursor; Tab still reaches every link and control. What
474
+ // the page draws again, a card or a condition, is read the same way.
475
+ const readable = (root) => {
476
+ for (const element of root.querySelectorAll(
477
+ "h1, h2, h3, h4, summary, .conditions > li, li.scenario, [data-card]",
478
+ )) {
479
+ if (element.closest("[data-desk]")) continue;
480
+ element.dataset.reading = "";
481
+ if (element.localName !== "summary") element.tabIndex = -1;
482
+ }
483
+ };
484
+ readable(document);
364
485
  for (const body of document.querySelectorAll("[data-quotable]")) {
365
486
  for (const block of blocksIn(body)) {
366
487
  if (!block.matches("figure.picture") && !hasWords(block)) continue;
@@ -498,7 +619,10 @@ const keys = {
498
619
  G: () => {
499
620
  const link = document.querySelector('.whole a[data-key="G"]');
500
621
  if (link) link.click();
501
- else toast("Nobody has opened this pull request yet: GitHub has nothing of it.");
622
+ else
623
+ toast(
624
+ "Nobody has opened this pull request yet: GitHub has nothing of it.",
625
+ );
502
626
  },
503
627
  };
504
628
 
@@ -539,10 +663,9 @@ const linkedShown = (preview) => {
539
663
  // A note on a place marked in the viewer is written beside it, and saved
540
664
  // without leaving it, so that the owner goes on looking where they were. A
541
665
  // note drawn there opens beside it the same way, with its words, to be
542
- // written over or withdrawn. The page comes back at the last of them once the
543
- // viewer closes. A note the desk would not take is sent as the page sends it,
544
- // which says why.
545
- let notedAt;
666
+ // written over or withdrawn. Once the viewer closes, the page draws them
667
+ // where it is.
668
+ let notedHere = false;
546
669
  const notesAddress = document
547
670
  .querySelector("#note-form")
548
671
  ?.content.querySelector("form")
@@ -552,7 +675,13 @@ const savedHere = new Map();
552
675
  const writtenNote = (id) => {
553
676
  const card = document.querySelector(`[data-card="${id}"]`);
554
677
  const said = savedHere.get(id) ?? card?.dataset;
555
- return said && { address: `${notesAddress}/${id}`, kind: said.kind, text: said.text };
678
+ return (
679
+ said && {
680
+ address: `${notesAddress}/${id}`,
681
+ kind: said.kind,
682
+ text: said.text,
683
+ }
684
+ );
556
685
  };
557
686
  const noteInViewer = ({ figure }, box, beside, id) => {
558
687
  if (!figure?.dataset.asset) {
@@ -564,46 +693,23 @@ const noteInViewer = ({ figure }, box, beside, id) => {
564
693
  toast("That note is not on this page, so it cannot be opened here.");
565
694
  return false;
566
695
  }
567
- respond(beside, { ...placeOn(figure, box), written });
568
- const form = beside.querySelector("form");
569
- if (form.dataset.inViewer === undefined) {
570
- form.dataset.inViewer = "";
571
- form.addEventListener("submit", async (event) => {
572
- event.preventDefault();
573
- const response = await fetch(form.action, {
574
- method: "POST",
575
- body: new FormData(form, event.submitter),
576
- headers: { Accept: "application/json" },
577
- }).catch(() => undefined);
578
- if (!response?.headers.get("content-type")?.includes("json"))
579
- return form.submit();
580
- const saved = await response.json();
581
- const withdrawn = event.submitter?.matches("[data-withdraw]") ?? false;
582
- notedAt = saved.at;
583
- savedHere.set(saved.id, {
584
- kind: form.elements.kind.value,
585
- text: form.elements.text.value.trim(),
586
- });
587
- beside.dispatchEvent(
588
- new CustomEvent("noted", { detail: { id: saved.id, withdrawn } }),
589
- );
696
+ const form = respond(beside, { ...placeOn(figure, box), written });
697
+ saveInPlace(form, ({ id: saved, withdrawn }) => {
698
+ notedHere = true;
699
+ savedHere.set(saved, {
700
+ kind: form.elements.kind.value,
701
+ text: form.elements.text.value.trim(),
590
702
  });
591
- }
703
+ beside.dispatchEvent(
704
+ new CustomEvent("noted", { detail: { id: saved, withdrawn } }),
705
+ );
706
+ });
592
707
  return true;
593
708
  };
594
- const backAtNote = () => {
595
- if (!notedAt) return;
596
- const at = new URL(notedAt, location.href);
597
- notedAt = undefined;
598
- // A decision being decided keeps the page where it is: the page shows
599
- // the note the next time it loads.
600
- if (document.body.dataset.focus)
601
- return toast(
602
- "Your note is kept. The description shows it the next time the page loads.",
603
- );
604
- if (at.pathname !== location.pathname) return location.assign(at);
605
- history.replaceState(history.state, "", at);
606
- location.reload();
709
+ const redrawSavedNotes = () => {
710
+ if (!notedHere) return;
711
+ notedHere = false;
712
+ redrawNotes();
607
713
  };
608
714
  // The pictures Space would open here, and which of them first; nothing when
609
715
  // the owner is not at a picture, and Space is the browser's to scroll with.
@@ -633,7 +739,7 @@ const lookClosely = (found = picturesHere(), first = found?.first) => {
633
739
  if (found)
634
740
  viewPictures(found.pictures, first, {
635
741
  note: noteInViewer,
636
- closed: backAtNote,
742
+ closed: redrawSavedNotes,
637
743
  });
638
744
  };
639
745
  keys[" "] = lookClosely;
data/public/pick.css CHANGED
@@ -97,3 +97,24 @@
97
97
  margin-left: 10ch;
98
98
  }
99
99
  }
100
+
101
+ /* What to do here, before anything to pick: framed, so a first look lands on it. */
102
+ .pick .start {
103
+ margin: 0 0 24px;
104
+ padding: 12px 16px;
105
+ border: 1px solid var(--rule-strong);
106
+ background: var(--surface);
107
+ }
108
+
109
+ .pick .start h2 {
110
+ margin-top: 0;
111
+ }
112
+
113
+ .pick .start p {
114
+ max-width: 72ch;
115
+ margin: 0 0 8px;
116
+ }
117
+
118
+ .pick .start p:last-child {
119
+ margin-bottom: 0;
120
+ }
data/public/respond.css CHANGED
@@ -109,6 +109,12 @@ li.card p {
109
109
  font-size: 0.85rem;
110
110
  }
111
111
 
112
+ /* Where a note answered in a handover is judged, in place of the buttons. */
113
+ .card .moves .judged {
114
+ margin: 0;
115
+ color: var(--quiet);
116
+ }
117
+
112
118
  .card[data-state="confirmed"],
113
119
  .card[data-state="withdrawn"] {
114
120
  opacity: 0.7;
data/public/respond.js CHANGED
@@ -15,10 +15,11 @@ const fill = (form, name, values) => {
15
15
  };
16
16
 
17
17
  // Opens the form in `slot`, or turns the one already there to this note,
18
- // keeping what was typed. `quote` and `picture` are the parts of a place;
19
- // `back` is the address to come back to once the note is saved, when the page
20
- // is not the pull request's own. `written`, a note already there by its
21
- // `address`, `kind` and `text`, opens it again to be written over or withdrawn.
18
+ // keeping what was typed, and gives the form back. `quote` and `picture` are
19
+ // the parts of a place; `back` is the address to come back to once the note is
20
+ // saved, when the page is not the pull request's own. `written`, a note
21
+ // already there by its `address`, `kind` and `text`, opens it again to be
22
+ // written over or withdrawn.
22
23
  export const respond = (
23
24
  slot,
24
25
  { kind = "change", action, on, quote, picture, back, written },
@@ -54,17 +55,60 @@ export const respond = (
54
55
  // Written again from where it ends.
55
56
  const end = form.elements.text.value.length;
56
57
  if (written) form.elements.text.setSelectionRange(end, end);
58
+ return form;
57
59
  };
58
60
 
59
- const close = (holder) => {
61
+ // The form taken away, its note saved or not: its slot says so by "closed".
62
+ // It is given the form, or anything in it.
63
+ export const putAway = (inside) => {
64
+ const holder = inside.closest(".respond");
60
65
  const slot = holder.parentElement;
61
66
  holder.remove();
62
67
  slot.dispatchEvent(new CustomEvent("closed", { bubbles: true }));
63
68
  };
64
69
 
70
+ // A note sent from `form` without leaving the page: `saved` is called with
71
+ // what the desk answers, the note's `id` and `at`, the address that shows it,
72
+ // and whether it was withdrawn. A note the desk would not take is sent as the
73
+ // page sends it, which says why.
74
+ export const saveInPlace = (form, saved) => {
75
+ // The form already in a slot is turned to each note opened there: it is
76
+ // listened to once.
77
+ if (form.dataset.inPlace !== undefined) return;
78
+ form.dataset.inPlace = "";
79
+ form.addEventListener("submit", async (event) => {
80
+ event.preventDefault();
81
+ const response = await fetch(form.action, {
82
+ method: "POST",
83
+ body: new FormData(form, event.submitter),
84
+ headers: { Accept: "application/json" },
85
+ }).catch(() => undefined);
86
+ if (!response?.headers.get("content-type")?.includes("json"))
87
+ return sendAsThePage(form, event.submitter);
88
+ saved({
89
+ ...(await response.json()),
90
+ withdrawn: event.submitter?.matches("[data-withdraw]") ?? false,
91
+ });
92
+ });
93
+ };
94
+
95
+ // The form sent as the page sends it, with the button pressed, which
96
+ // form.submit() leaves out: without it a withdrawal would go as an edit.
97
+ const sendAsThePage = (form, button) => {
98
+ if (button?.name)
99
+ form.append(
100
+ Object.assign(document.createElement("input"), {
101
+ type: "hidden",
102
+ name: button.name,
103
+ value: button.value,
104
+ }),
105
+ );
106
+ form.submit();
107
+ };
108
+
65
109
  document.addEventListener("click", (event) => {
66
110
  const cancel = event.target.closest("[data-cancel]");
67
- if (cancel) return close(cancel.closest(".respond"));
111
+ if (cancel) return putAway(cancel);
68
112
 
69
113
  const action = event.target.closest("[data-respond]");
70
114
  if (!action) return;
@@ -91,5 +135,5 @@ document.addEventListener("keydown", (event) => {
91
135
 
92
136
  if (event.key === "Enter" && (event.metaKey || event.ctrlKey))
93
137
  holder.querySelector("form").requestSubmit();
94
- if (event.key === "Escape" && !event.target.value) close(holder);
138
+ if (event.key === "Escape" && !event.target.value) putAway(holder);
95
139
  });
@@ -447,11 +447,14 @@ export const progress = (pr) => {
447
447
  const mine = (entry) => of(entry.decision.pr);
448
448
  const picked = items.filter(chosen).length;
449
449
  const ticked = items.filter((item) => !chosen(item) && item.ticked).length;
450
+ const blocking = items.filter((item) => item.grade !== "non_blocking");
450
451
  return {
451
452
  total: items.length,
452
453
  chosen: picked,
453
454
  ticked,
454
455
  left: items.length - picked - ticked,
456
+ blocking: blocking.length,
457
+ blockingLeft: blocking.filter((item) => !reviewed(item)).length,
455
458
  drafts: state.pending.filter(mine).length,
456
459
  again: state.choices.filter(
457
460
  (entry) =>
@@ -485,20 +488,55 @@ const changed = () => {
485
488
  };
486
489
 
487
490
  // The pane at the left counts the same choices: its rows follow them as they
488
- // are made, in the marks the server wrote them with.
491
+ // are made, in beads as the server draws them (DeskHelper#decision_beads).
492
+ const reviewedInWords = ({ name, total, left }) => {
493
+ const decisions = `${name} ${total === 1 ? "decision" : "decisions"}`;
494
+ const reviewed = total - left;
495
+ if (!left)
496
+ return total === 1
497
+ ? `its one ${decisions} reviewed`
498
+ : `all ${total} ${decisions} reviewed`;
499
+ if (!reviewed) return `none of ${total} ${decisions} reviewed yet`;
500
+ return `${reviewed} of ${total} ${decisions} reviewed`;
501
+ };
502
+ const span = (properties, ...children) => {
503
+ const element = Object.assign(document.createElement("span"), properties);
504
+ element.append(...children);
505
+ return element;
506
+ };
507
+ const beadsOf = (now) =>
508
+ span(
509
+ { className: "beads" },
510
+ ...[
511
+ { name: "blocking", total: now.blocking, left: now.blockingLeft },
512
+ {
513
+ name: "non-blocking",
514
+ total: now.total - now.blocking,
515
+ left: now.left - now.blockingLeft,
516
+ },
517
+ ]
518
+ .filter((grade) => grade.total)
519
+ .map((grade) =>
520
+ span(
521
+ {
522
+ className: grade.name,
523
+ role: "img",
524
+ ariaLabel: reviewedInWords(grade),
525
+ },
526
+ ...Array.from({ length: grade.total }, (_, at) =>
527
+ span({
528
+ className: at < grade.total - grade.left ? "bead reviewed" : "bead",
529
+ }),
530
+ ),
531
+ ),
532
+ ),
533
+ );
489
534
  const side = () => {
490
535
  const count = (number) => number.toLocaleString("en-US");
491
536
  for (const row of document.querySelectorAll("[data-side-pr]")) {
492
537
  const now = progress(prNumber(row.dataset.sidePr));
493
538
  if (!now.total) continue;
494
- const done = now.chosen + now.ticked;
495
- row.querySelector("[data-side-mark]").textContent = !now.left
496
- ? "x"
497
- : done
498
- ? "~"
499
- : "";
500
- row.querySelector("[data-side-fraction]").textContent =
501
- `${done}/${now.total}`;
539
+ row.querySelector("[data-side-decisions]").replaceChildren(beadsOf(now));
502
540
  }
503
541
  const overall = document.querySelector("[data-side-overall]");
504
542
  if (!overall || !inventory.length) return;
@@ -1,10 +1,11 @@
1
- import { helping, keyOf } from "./keys.js";
1
+ import { helping, keyOf, overThePage } from "./keys.js";
2
2
  import { prNumber } from "./pr-number.js";
3
3
  import {
4
4
  announce,
5
5
  handOver,
6
6
  draftsState,
7
7
  registerRounds,
8
+ settled,
8
9
  stale,
9
10
  updateDrafts,
10
11
  } from "./review-drafts.js";
@@ -28,6 +29,25 @@ const reviewData = async () => {
28
29
  if (!response.ok) throw new Error(`Desk returned ${response.status}`);
29
30
  return response.json();
30
31
  };
32
+ // The pane's ↩, on each pull request something came back on, in the words
33
+ // the desk gives (DeskHelper#side_mark).
34
+ const markThePane = (back = {}) => {
35
+ for (const row of document.querySelectorAll("[data-side-pr]")) {
36
+ const said = back[row.dataset.sidePr];
37
+ Object.assign(row.querySelector("[data-side-mark]"), {
38
+ textContent: said ? "↩" : "",
39
+ role: said ? "img" : null,
40
+ ariaLabel: said ?? null,
41
+ });
42
+ }
43
+ };
44
+ // Once the desk has the owner's word on an answer, it has moved the notes the
45
+ // answer was to (Rounds#move_notes), and the pane is marked again from it. A
46
+ // desk that does not answer leaves the pane as the page drew it.
47
+ const followThePane = async () => {
48
+ await settled();
49
+ markThePane((await reviewData()).back);
50
+ };
31
51
  // What GitHub draws of each piece of markdown, by its words, asked once while
32
52
  // the page is open: returned work is laid out again at every change of the
33
53
  // drafts, and must not fall back to markdown each time. A piece that cannot
@@ -589,6 +609,7 @@ const mark = (item, resolution, latest, kind, text) => {
589
609
  },
590
610
  };
591
611
  });
612
+ followThePane().catch(() => {});
592
613
  selectResponse(
593
614
  visibleResponses().find((node) => node.dataset.request === next) ||
594
615
  visibleResponses()[0],
@@ -1275,6 +1296,7 @@ registerRounds({
1275
1296
  context = await reviewData();
1276
1297
  importServerBatches();
1277
1298
  importServerResponses();
1299
+ markThePane(context.back);
1278
1300
  },
1279
1301
  });
1280
1302
 
@@ -1290,6 +1312,7 @@ tools
1290
1312
  ? "Agent response imported. Confirm or reply to its existing requests."
1291
1313
  : "The desk has this answer already.",
1292
1314
  );
1315
+ followThePane().catch(() => {});
1293
1316
  } catch (error) {
1294
1317
  announce(`Response could not be imported: ${error.message}`);
1295
1318
  } finally {
@@ -1361,8 +1384,9 @@ document
1361
1384
  .querySelector("[data-enter-responses]")
1362
1385
  ?.addEventListener("click", enterResponses);
1363
1386
  document.addEventListener("focusin", (event) => {
1364
- // A picture looked at closely leaves the response marked: Esc comes back to it.
1365
- if (event.target.closest("dialog")) return;
1387
+ // A picture looked at closely, or a note written in Grouse's overlay, leaves
1388
+ // the response marked: Esc comes back to it.
1389
+ if (overThePage(event.target)) return;
1366
1390
  const node = event.target.closest("[data-request]");
1367
1391
  if (node) markResponse(node);
1368
1392
  else activeResponse?.removeAttribute("aria-current");
data/public/share.css ADDED
@@ -0,0 +1,58 @@
1
+ /* Sharing the desk: where it is now, then each way to share it, in the
2
+ route's own frame, with what it needs first or its button under its words. */
3
+
4
+ .share .now {
5
+ margin-block: 0 0.5lh;
6
+ }
7
+
8
+ .share .ways {
9
+ border: 1px solid var(--rule);
10
+ border-top: 0;
11
+ background: var(--surface);
12
+ }
13
+
14
+ .share .way {
15
+ display: grid;
16
+ row-gap: 8px;
17
+ padding: var(--row-pad) 12px;
18
+ border-bottom: 1px solid var(--rule-soft);
19
+ }
20
+
21
+ .share .way:last-child {
22
+ border-bottom: 0;
23
+ }
24
+
25
+ .share .way h3 {
26
+ margin: 0;
27
+ font-size: 1rem;
28
+ color: var(--ink);
29
+ }
30
+
31
+ .share .way p {
32
+ margin: 0;
33
+ }
34
+
35
+ .share .way .facts {
36
+ color: var(--quiet);
37
+ }
38
+
39
+ .share .way form {
40
+ display: grid;
41
+ row-gap: 8px;
42
+ justify-items: start;
43
+ }
44
+
45
+ .share .allow {
46
+ display: flex;
47
+ flex-wrap: wrap;
48
+ gap: 4px 2ch;
49
+ align-items: baseline;
50
+ }
51
+
52
+ .share .allow input {
53
+ width: min(40ch, 100%);
54
+ }
55
+
56
+ .share code {
57
+ white-space: nowrap;
58
+ }
data/public/side.js CHANGED
@@ -14,32 +14,23 @@ const made = (name, properties = {}, ...children) => {
14
14
  return element;
15
15
  };
16
16
 
17
- // "3 of 19 decisions reviewed", from the row's "~" and "3/19".
18
- const progressInWords = (mark, fraction) => {
19
- if (!fraction) return "no decisions to review";
20
- const [reviewed, total] = fraction.split("/").map(Number);
21
- const decisions = total === 1 ? "decision" : "decisions";
22
- if (mark === "x")
23
- return total === 1
24
- ? "its one decision reviewed"
25
- : `all ${total} decisions reviewed`;
26
- if (!reviewed) return `none of ${total} ${decisions} reviewed yet`;
27
- return `${reviewed} of ${total} ${decisions} reviewed`;
28
- };
29
-
30
17
  const MOVED = {
31
18
  changed: "changed on GitHub since it was read",
32
19
  closed: "no longer open on GitHub",
33
20
  };
34
21
 
22
+ // The row's marks said in words: what came back from the agent, then each
23
+ // grade's beads with what they count, in the words the server labelled them with.
35
24
  const marksOf = (row) => {
36
- const mark = row.querySelector("[data-side-mark]").textContent.trim();
37
- const fraction = row.querySelector("[data-side-fraction]").textContent.trim();
25
+ const back = row.querySelector("[data-side-mark]");
26
+ const grades = row.querySelectorAll("[data-side-decisions] [role=img]");
38
27
  return [
39
- [
40
- [mark, fraction].filter(Boolean).join(" "),
41
- progressInWords(mark, fraction),
42
- ],
28
+ back.textContent && [back.textContent, back.ariaLabel],
29
+ ...[...grades].map((grade) => [
30
+ made("span", { className: "beads" }, grade.cloneNode(true)),
31
+ grade.ariaLabel,
32
+ ]),
33
+ !grades.length && ["", "no decisions to review"],
43
34
  row.dataset.moved && ["*", MOVED[row.dataset.moved]],
44
35
  ].filter(Boolean);
45
36
  };
@@ -62,7 +53,7 @@ const cardOf = (row) => {
62
53
  made(
63
54
  "div",
64
55
  {},
65
- made("dt", { textContent: shown }),
56
+ made("dt", {}, shown),
66
57
  made("dd", { textContent: said }),
67
58
  ),
68
59
  ),