rvlib-shipkit 2.3.0 → 2.5.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 (104) hide show
  1. package/CHANGELOG.md +19 -2
  2. package/package.json +2 -2
  3. package/src/assets/kits/identity-kit.ts +5 -2
  4. package/src/cli/cmd_Commands.ts +30 -5
  5. package/src/cli/cmd_FleetPush.ts +47 -0
  6. package/src/cli/cmd_GlobalProcesses.ts +5 -5
  7. package/src/cli/cmd_Review.ts +52 -11
  8. package/src/cli/cmd_Sound.ts +209 -0
  9. package/src/cli/cmd_Trailer.ts +11 -3
  10. package/src/cli/family-pages.ts +49 -0
  11. package/src/cli/kit/builtins.ts +1 -0
  12. package/src/cli/kit/verb.ts +11 -3
  13. package/src/cli/registry.ts +137 -0
  14. package/src/cli.ts +47 -133
  15. package/src/core/app-icon.ts +10 -6
  16. package/src/core/last-good.ts +217 -0
  17. package/src/core/load-probe.ts +71 -0
  18. package/src/core/reexec.ts +5 -3
  19. package/src/fleet/push-fleet.ts +80 -0
  20. package/src/home/home-routes.ts +12 -2
  21. package/src/home/lib-watch.ts +110 -20
  22. package/src/home/process-rows.ts +4 -2
  23. package/src/home/run-daemon.ts +1 -1
  24. package/src/repo-config.ts +3 -0
  25. package/src/review/format.ts +11 -3
  26. package/src/review/model.ts +66 -5
  27. package/src/review/paste.ts +30 -0
  28. package/src/review/project.ts +20 -4
  29. package/src/review/routes.ts +22 -4
  30. package/src/review/store.ts +15 -6
  31. package/src/review/unit.ts +19 -1
  32. package/src/scaffold/files/agent-index.md +2 -2
  33. package/src/scaffold/project-scaffold.ts +2 -2
  34. package/src/serve/hub-app/activity-st.ts +31 -0
  35. package/src/serve/hub-app/activity-view.tsx +9 -0
  36. package/src/serve/hub-app/activity.css +6 -0
  37. package/src/serve/hub-app/admin-svc.ts +1 -1
  38. package/src/serve/hub-app/admin-view.tsx +1 -1
  39. package/src/serve/hub-app/hash-route.ts +12 -4
  40. package/src/serve/hub-app/home-st.ts +7 -1
  41. package/src/serve/hub-app/home-strip.tsx +3 -1
  42. package/src/serve/hub-app/hub.css +38 -1
  43. package/src/serve/hub-app/icons.tsx +6 -0
  44. package/src/serve/hub-app/layout-st.ts +2 -2
  45. package/src/serve/hub-app/machine-st.ts +1 -1
  46. package/src/serve/hub-app/md.ts +32 -0
  47. package/src/serve/hub-app/project-list.css +9 -7
  48. package/src/serve/hub-app/project-list.tsx +5 -2
  49. package/src/serve/hub-app/projects-svc.ts +3 -2
  50. package/src/serve/hub-app/repo-st.ts +22 -7
  51. package/src/serve/hub-app/review/clip-st.ts +57 -7
  52. package/src/serve/hub-app/review/kind-view.tsx +78 -9
  53. package/src/serve/hub-app/review/review-answer.tsx +67 -58
  54. package/src/serve/hub-app/review/review-page.tsx +17 -15
  55. package/src/serve/hub-app/review/review-st.ts +191 -20
  56. package/src/serve/hub-app/review/review-views.tsx +154 -9
  57. package/src/serve/hub-app/review.css +73 -13
  58. package/src/serve/hub-app/wiki-st.ts +190 -0
  59. package/src/serve/hub-app/wikis-view.tsx +134 -5
  60. package/src/serve/hub-runtime.ts +4 -4
  61. package/src/serve/project-gutter-route.ts +25 -1
  62. package/src/serve/project-gutter.ts +1 -1
  63. package/src/serve/run-serve.ts +25 -0
  64. package/src/serve/serve-hub.ts +15 -1
  65. package/src/serve/stale-restart.ts +19 -2
  66. package/src/shot/client.ts +17 -5
  67. package/src/shot/shooter.ts +19 -3
  68. package/src/shot/shot-ask.ts +9 -1
  69. package/src/shot/stepped-clip.ts +53 -0
  70. package/src/trailer/adapter.ts +33 -4
  71. package/src/trailer/build.ts +182 -33
  72. package/src/trailer/ffmpeg.ts +21 -1
  73. package/src/trailer/fonts.ts +55 -0
  74. package/src/trailer/footage.ts +4 -4
  75. package/src/trailer/grammar.ts +429 -67
  76. package/src/trailer/graph.ts +130 -56
  77. package/src/trailer/look.ts +3 -0
  78. package/src/trailer/motion.ts +207 -0
  79. package/src/trailer/plan.ts +2 -2
  80. package/src/trailer/prune.ts +13 -2
  81. package/src/trailer/score/compose.ts +18 -2
  82. package/src/trailer/score/gm-render.ts +43 -0
  83. package/src/trailer/score/gm.ts +90 -0
  84. package/src/trailer/score/midi.ts +143 -0
  85. package/src/trailer/score/pattern.ts +13 -6
  86. package/src/trailer/score/score.ts +28 -14
  87. package/src/trailer/score/sound.ts +12 -1
  88. package/src/trailer/score/synth.ts +24 -2
  89. package/src/trailer/sound/audio.ts +148 -0
  90. package/src/trailer/sound/brand.ts +42 -0
  91. package/src/trailer/sound/comfy.ts +38 -0
  92. package/src/trailer/sound/eleven.ts +72 -0
  93. package/src/trailer/sound/fluid.ts +71 -0
  94. package/src/trailer/sound/gen.ts +139 -0
  95. package/src/trailer/sound/index.ts +60 -0
  96. package/src/trailer/sound/presets.ts +69 -0
  97. package/src/trailer/sound/run.ts +189 -0
  98. package/src/trailer/titles.ts +451 -235
  99. package/src/trailer/url.ts +18 -3
  100. package/src/trailer/vocab.ts +12 -1
  101. package/src/trailer/words.ts +29 -8
  102. package/src/wiki/graph-mount.ts +3 -0
  103. package/src/wiki/graph-page.ts +45 -9
  104. package/src/wiki/graph-pages.ts +128 -0
@@ -3,10 +3,17 @@
3
3
  // sent. The list is read while the page shows, every 2 s, answered 304 when the store did not move
4
4
  import { makeAutoObservable, reaction, runInAction } from 'mobx'
5
5
  import {
6
+ asOptions,
7
+ isApproved,
8
+ isChoice,
6
9
  isGallery,
10
+ isPair,
7
11
  laneOf,
8
12
  moveOf,
13
+ pasteBlock,
9
14
  reworkOf,
15
+ STARS,
16
+ STATUS_LABEL,
10
17
  stateOf,
11
18
  unreadBy,
12
19
  verdictOf,
@@ -25,12 +32,15 @@ import { answererOf, chainOf, childrenOf, conversationOf, followedIds, isActive,
25
32
  import { reach } from '../reach.ts'
26
33
  import type { RepoSt } from '../repo-st.ts'
27
34
  import type { Role } from '../../../review/role.ts'
35
+ import type { PasteHosts } from '../../../review/paste.ts'
28
36
  import { ClipSt } from './clip-st.ts'
29
37
 
30
38
  interface Draft {
31
39
  rects: Rect[]
32
40
  comment: string
33
41
  reactions?: ReviewReaction[]
42
+ /** the option he picked, on a choice card */
43
+ pick?: number
34
44
  }
35
45
 
36
46
  /** a live `sk review watch`: its process, the session or lane it keeps, when it started */
@@ -58,7 +68,7 @@ export const drawnSide = (mode: CompareMode, drawn: Side): Side => (mode === 'mo
58
68
 
59
69
  /** below this width the page is the phone's: one column at a time, the verdicts in a bar under the thumb */
60
70
  /** a repo's review unit as the page reads it: the words of its kinds and topics, its dispatcher and who holds the role */
61
- export type RepoSpec = Spec & { role: Role | null; sources: string[]; auto: { id: string; every: string }[] }
71
+ export type RepoSpec = Spec & { role: Role | null; sources: string[]; auto: { id: string; every: string }[]; where?: PasteHosts | null }
62
72
 
63
73
  export const PHONE_PX = 768
64
74
  /** below this width of the review page itself (the project list and the sidebar already took theirs), `auto` stacks
@@ -71,6 +81,10 @@ export type PhonePane = 'list' | 'card' | 'answer'
71
81
  export type PairSide = 'before' | 'after' | 'swipe'
72
82
 
73
83
  const POLL_MS = 2000
84
+ /** a sound's clock steps a tenth of a second */
85
+ const SOUND_FPS = 10
86
+ /** ← → on a sound jump this many seconds */
87
+ const SOUND_JUMP = 2
74
88
 
75
89
  const sameSpot = (r: ReviewReaction, view: number, tile: number | undefined): boolean => r.view === view && r.tile === tile
76
90
 
@@ -97,7 +111,7 @@ const draftOf = (s: string | null): Draft | null => {
97
111
  try {
98
112
  const d = s === null ? null : (JSON.parse(s) as Partial<Draft>)
99
113
  if (!d || !Array.isArray(d.rects) || typeof d.comment !== 'string') return null
100
- return { rects: d.rects, comment: d.comment, ...(Array.isArray(d.reactions) ? { reactions: d.reactions } : {}) }
114
+ return { rects: d.rects, comment: d.comment, ...(Array.isArray(d.reactions) ? { reactions: d.reactions } : {}), ...(typeof d.pick === 'number' ? { pick: d.pick } : {}) }
101
115
  } catch {
102
116
  return null
103
117
  }
@@ -132,6 +146,26 @@ async function postJson<T>(url: string, body: unknown): Promise<T> {
132
146
  return JSON.parse(text) as T
133
147
  }
134
148
 
149
+ /** a text to the clipboard: the clipboard api, else (a page over plain http, the phone through a public host) the
150
+ * old copy command on a hidden field */
151
+ async function copyText(text: string): Promise<boolean> {
152
+ try {
153
+ await navigator.clipboard.writeText(text)
154
+ return true
155
+ } catch {
156
+ const el = document.createElement('textarea')
157
+ el.value = text
158
+ el.setAttribute('readonly', '')
159
+ el.style.position = 'fixed'
160
+ el.style.opacity = '0'
161
+ document.body.appendChild(el)
162
+ el.select()
163
+ const ok = document.execCommand('copy')
164
+ el.remove()
165
+ return ok
166
+ }
167
+ }
168
+
135
169
  /** the pair's compare mode, the curtain's line and the onion's opacity, shared by every pair of the page */
136
170
  export class CompareSt {
137
171
  mode: CompareMode = 'side'
@@ -179,6 +213,8 @@ export class PhoneSt {
179
213
  }
180
214
  }
181
215
 
216
+ const isShownStatus = (s: string): s is ShownStatus => Object.hasOwn(STATUS_LABEL, s)
217
+
182
218
  export class ReviewSt {
183
219
  list: Listing | null = null
184
220
  listError: string | null = null
@@ -206,6 +242,10 @@ export class ReviewSt {
206
242
  viewZoom: Record<number, Zoom> = {}
207
243
  /** the gallery tile shown large across the column, `<view>/<tile>` */
208
244
  bigTile: string | null = null
245
+ /** a card of options shown full, every view stacked, by id: his switch, this visit */
246
+ full: Record<string, boolean> = {}
247
+ /** the option rows unfolded to their full view, by view index */
248
+ openRows: Record<number, boolean> = {}
209
249
  replies: Record<string, string> = {}
210
250
  private reading = new Set<string>()
211
251
  /** « hide handled »: only the active threads in the list */
@@ -216,6 +256,9 @@ export class ReviewSt {
216
256
  unfolded: Record<string, boolean> = {}
217
257
  busy = false
218
258
  error: string | null = null
259
+ /** what the last copy put on the clipboard, in words, shown a moment */
260
+ toast: string | null = null
261
+ private toastTimer: ReturnType<typeof setTimeout> | null = null
219
262
  /** the card against its answer, his pick, kept with the prefs */
220
263
  place: CardPlace = 'auto'
221
264
  /** the page is narrower than STACK_PX */
@@ -239,6 +282,27 @@ export class ReviewSt {
239
282
  setPlace(p: CardPlace): void {
240
283
  this.place = p
241
284
  }
285
+ /** the open card as thin option rows: a choice, or views that each play alone, unless he asked them full */
286
+ get rows(): boolean {
287
+ const x = this.open
288
+ return x !== null && asOptions(x.item) && this.full[x.item.id] !== true
289
+ }
290
+ setFull(on: boolean): void {
291
+ const id = this.open?.item.id
292
+ if (id) this.full = { ...this.full, [id]: on }
293
+ }
294
+ get choice(): boolean {
295
+ const x = this.open
296
+ return x !== null && isChoice(x.item)
297
+ }
298
+ /** the card's header (title, state, what, and in one column the verdicts) sits over its views: one column, the
299
+ * phone's card pane, a choice's options */
300
+ get headInViews(): boolean {
301
+ return this.stacked || this.phone.on || this.choice
302
+ }
303
+ toggleRow(i: number): void {
304
+ this.openRows = { ...this.openRows, [i]: !this.openRows[i] }
305
+ }
242
306
 
243
307
  constructor(private readonly st: RepoSt) {
244
308
  this.prefix = `shipkit.review.${st.key}.`
@@ -255,7 +319,7 @@ export class ReviewSt {
255
319
  } catch {
256
320
  // a broken pref is the default
257
321
  }
258
- makeAutoObservable<this, 'st' | 'clips' | 'clipsCard' | 'reading' | 'timer' | 'loadList' | 'loadWatchers' | 'prefix'>(this, {
322
+ makeAutoObservable<this, 'st' | 'clips' | 'clipsCard' | 'reading' | 'timer' | 'loadList' | 'loadWatchers' | 'prefix' | 'toastTimer'>(this, {
259
323
  st: false,
260
324
  clips: false,
261
325
  clipsCard: false,
@@ -264,6 +328,7 @@ export class ReviewSt {
264
328
  loadList: false,
265
329
  loadWatchers: false,
266
330
  prefix: false,
331
+ toastTimer: false,
267
332
  compare: false,
268
333
  phone: false,
269
334
  })
@@ -317,6 +382,27 @@ export class ReviewSt {
317
382
  }
318
383
  }
319
384
 
385
+ /** the open card as one paste a fresh agent session in any repo acts on: what it is, its repo, the command that shows
386
+ * it whole, its link here and on the phone (C) */
387
+ async copyCard(): Promise<void> {
388
+ const x = this.open
389
+ if (!x) return
390
+ const id = x.item.id
391
+ const w = this.spec?.where
392
+ const here = typeof location === 'undefined' ? (w?.open ?? '') : `${location.origin}${location.pathname}${location.search}`
393
+ const text = pasteBlock(x.item, STATUS_LABEL[this.statusOf(x)], {
394
+ root: w?.root ?? '<the repo>',
395
+ open: `${here}#page:review:${id}`,
396
+ phone: w?.phone ? `${w.phone}#page:review:${id}` : null,
397
+ })
398
+ const ok = await copyText(text)
399
+ runInAction(() => {
400
+ this.toast = ok ? `copied for an agent: title, repo, sk review show, ${w?.phone ? 'links' : 'link'}` : 'the browser refused the copy'
401
+ if (this.toastTimer !== null) clearTimeout(this.toastTimer)
402
+ this.toastTimer = setTimeout(() => runInAction(() => (this.toast = null)), 2600)
403
+ })
404
+ }
405
+
320
406
  setBoard(on: boolean): void {
321
407
  this.board = on
322
408
  }
@@ -470,7 +556,14 @@ export class ReviewSt {
470
556
  if (!x) return { rects: [], comment: '' }
471
557
  const v = verdictOf(x)
472
558
  const sent = v?.reactions
473
- return this.drafts[x.item.id] ?? { rects: v?.rects ?? [], comment: v?.comment ?? '', ...(sent && sent.length > 0 ? { reactions: sent } : {}) }
559
+ return (
560
+ this.drafts[x.item.id] ?? {
561
+ rects: v?.rects ?? [],
562
+ comment: v?.comment ?? '',
563
+ ...(sent && sent.length > 0 ? { reactions: sent } : {}),
564
+ ...(v?.pick !== undefined ? { pick: v.pick } : {}),
565
+ }
566
+ )
474
567
  }
475
568
 
476
569
  get dirty(): boolean {
@@ -482,7 +575,7 @@ export class ReviewSt {
482
575
  const x = this.open
483
576
  if (!x) return
484
577
  const d = this.draft
485
- const { reactions, ...rest } = f({ rects: [...d.rects], comment: d.comment, reactions: [...(d.reactions ?? [])] })
578
+ const { reactions, ...rest } = f({ ...d, rects: [...d.rects], reactions: [...(d.reactions ?? [])] })
486
579
  const next: Draft = reactions && reactions.length > 0 ? { ...rest, reactions } : rest
487
580
  this.drafts = { ...this.drafts, [x.item.id]: next }
488
581
  store.put(`${this.prefix}draft.${x.item.id}`, JSON.stringify(next))
@@ -494,12 +587,43 @@ export class ReviewSt {
494
587
  if (this.phone.on) this.phone.show('card')
495
588
  }
496
589
 
590
+ /** what the address carries beside the card: its view and the list's filters, `view=1&subject=place`, each only
591
+ * off its default (`handled=1` shows the handled threads, `lanes=1` groups by lane) */
592
+ get address(): string {
593
+ const q = new URLSearchParams()
594
+ if (this.view > 0) q.set('view', String(this.view))
595
+ if (this.subject !== null) q.set('subject', this.subject)
596
+ if (this.whose !== null) q.set('whose', this.whose)
597
+ if (this.status !== null) q.set('status', this.status)
598
+ if (!this.hideHandled) q.set('handled', '1')
599
+ if (this.byLane) q.set('lanes', '1')
600
+ return q.toString()
601
+ }
602
+
603
+ /** an address's view and filters read back: a key it leaves out keeps what the page has; an unknown value is dropped */
604
+ applyAddress(raw: string): void {
605
+ const q = new URLSearchParams(raw)
606
+ const view = Number(q.get('view'))
607
+ if (Number.isInteger(view) && view > 0) this.view = view
608
+ const subject = q.get('subject')
609
+ if (subject) this.subject = subject
610
+ const whose = q.get('whose')
611
+ if (whose === 'owner' || whose === 'agents' || whose === 'none') this.whose = whose
612
+ const status = q.get('status')
613
+ if (status !== null && isShownStatus(status)) this.status = status
614
+ if (q.get('handled') === '1') this.hideHandled = false
615
+ if (q.get('lanes') === '1') this.byLane = true
616
+ }
617
+
497
618
  /** the card the address names; its draft is read again from storage, the truth over every window */
498
619
  select(id: string | null): void {
499
620
  if (id !== this.selectedId) {
500
621
  this.view = 0
501
622
  this.viewZoom = {}
502
623
  this.bigTile = null
624
+ this.openRows = {}
625
+ // a link to a card on the phone opens the card, never the list
626
+ if (id !== null && this.phone.on) this.phone.show('card')
503
627
  }
504
628
  this.selectedId = id
505
629
  if (id !== null) {
@@ -545,6 +669,13 @@ export class ReviewSt {
545
669
  this.rect = null
546
670
  }
547
671
 
672
+ /** the playable view an option row or a clip shows: a clip, or a sound shown as a row */
673
+ private playableAt(i: number): MediaView | null {
674
+ const v = this.open?.item.views[i]
675
+ if (!v || isGallery(v)) return null
676
+ return v.clip || (v.audio && this.rows && !isPair(v.files)) ? v : null
677
+ }
678
+
548
679
  get openView(): MediaView | null {
549
680
  const v = this.open?.item.views[this.view]
550
681
  return v && !isGallery(v) ? v : null
@@ -575,15 +706,28 @@ export class ReviewSt {
575
706
  /** a reaction's toggle: sets that status, or clears it when it is already the one */
576
707
  react(view: number, tile: number | undefined, status: ReviewStatus): void {
577
708
  const cur = this.reactionOf(view, tile)
578
- this.putReaction(view, tile, { status: cur?.status === status ? undefined : status, comment: cur?.comment ?? '' })
709
+ this.putReaction(view, tile, { ...cur, status: cur?.status === status ? undefined : status, comment: cur?.comment ?? '' })
579
710
  }
580
711
 
581
712
  setReactionComment(view: number, tile: number | undefined, comment: string): void {
582
- this.putReaction(view, tile, { status: this.reactionOf(view, tile)?.status, comment })
713
+ this.putReaction(view, tile, { ...this.reactionOf(view, tile), comment })
714
+ }
715
+
716
+ /** an option's stars; the same number again clears them */
717
+ setStars(view: number, n: number): void {
718
+ const cur = this.reactionOf(view)
719
+ this.putReaction(view, undefined, { ...cur, comment: cur?.comment ?? '', stars: cur?.stars === n ? undefined : n })
583
720
  }
584
721
 
585
722
  removeReaction(view: number, tile: number | undefined): void {
586
- this.putReaction(view, tile, { status: undefined, comment: '' })
723
+ this.putReaction(view, tile, { comment: '' })
724
+ }
725
+
726
+ /** « pick »: that option is his answer, sent at once as an approval; his stars and words go with it */
727
+ async choose(view: number): Promise<void> {
728
+ if (!this.choice) return
729
+ this.edit((d) => ({ ...d, pick: view }))
730
+ await this.submit('approved')
587
731
  }
588
732
 
589
733
  /** the reactions that go to the agent with the verdict, in the card's order */
@@ -591,19 +735,27 @@ export class ReviewSt {
591
735
  return [...(this.draft.reactions ?? [])].sort((a, b) => a.view - b.view || (a.tile ?? -1) - (b.tile ?? -1))
592
736
  }
593
737
 
594
- private putReaction(view: number, tile: number | undefined, r: { status: ReviewStatus | undefined; comment: string }): void {
595
- const next: ReviewReaction = { view, ...(tile !== undefined ? { tile } : {}), ...(r.status ? { status: r.status } : {}), comment: r.comment }
596
- const empty = !r.status && r.comment === ''
738
+ private putReaction(view: number, tile: number | undefined, r: { status?: ReviewStatus | undefined; comment: string; stars?: number | undefined }): void {
739
+ const next: ReviewReaction = { view, ...(tile !== undefined ? { tile } : {}), ...(r.status ? { status: r.status } : {}), comment: r.comment, ...(r.stars ? { stars: r.stars } : {}) }
740
+ const empty = !r.status && r.comment === '' && !r.stars
597
741
  this.edit((d) => {
598
742
  const rest = (d.reactions ?? []).filter((q) => !sameSpot(q, view, tile))
599
743
  return { ...d, reactions: empty ? rest : [...rest, next] }
600
744
  })
601
745
  }
602
746
 
603
- /** the open view's clip identity, empty when it shows images */
747
+ /** the open view's clip identity (a clip, or a sound on a row), empty when it shows images */
604
748
  get clipKey(): string {
605
749
  const x = this.open
606
- return x && this.openView?.clip ? `${x.item.id}#${this.view}` : ''
750
+ return x && this.playableAt(this.view) ? `${x.item.id}#${this.view}` : ''
751
+ }
752
+
753
+ /** a row's ▶: that option plays with its sound, the one playing before it stops; again, it pauses */
754
+ listen(i: number): void {
755
+ this.setView(i)
756
+ const c = this.clipOf(i)
757
+ if (c.paused) c.listen()
758
+ else c.pause()
607
759
  }
608
760
 
609
761
  /** the clock of a clip view of the open card: every clip view plays and steps on its own */
@@ -616,9 +768,14 @@ export class ReviewSt {
616
768
  let c = this.clips.get(view)
617
769
  if (!c) {
618
770
  c = new ClipSt()
619
- const v = this.open?.item.views[view]
620
- const info = v && !isGallery(v) ? v.clip : undefined
621
- c.load(`${id}#${view}`, info?.duration ?? 0, info?.fps ?? 20)
771
+ const views = this.open?.item.views ?? []
772
+ const v = views[view]
773
+ const media = v && !isGallery(v) ? v : undefined
774
+ // one thing plays at a time: the first clip of a card loops on its own, the others and every option wait
775
+ const first = views.findIndex((w) => !isGallery(w) && w.clip !== undefined)
776
+ const still = this.rows || view !== first
777
+ if (media?.audio) c.load(`${id}#${view}`, media.audio.duration, SOUND_FPS, true)
778
+ else c.load(`${id}#${view}`, media?.clip?.duration ?? 0, media?.clip?.fps ?? 20, still)
622
779
  this.clips.set(view, c)
623
780
  }
624
781
  return c
@@ -740,7 +897,8 @@ export class ReviewSt {
740
897
  this.busy = true
741
898
  this.error = null
742
899
  try {
743
- const m = await postJson<Message>(this.st.url(`/api/review/${id}/feedback`), { status, comment: d.comment, rects: d.rects, reactions: d.reactions ?? [] })
900
+ const pick = isChoice(x.item) && isApproved(status) && d.pick !== undefined ? { pick: d.pick } : {}
901
+ const m = await postJson<Message>(this.st.url(`/api/review/${id}/feedback`), { status, comment: d.comment, rects: d.rects, reactions: d.reactions ?? [], ...pick })
744
902
  runInAction(() => {
745
903
  // the verdict shows at once: the list of every card comes back after it
746
904
  const r = this.list
@@ -760,10 +918,22 @@ export class ReviewSt {
760
918
  }
761
919
 
762
920
  /** the page's keys, as the atelier takes its own: true when it used the key. V approves, F ok with a fix, R rejects,
763
- * A archives, N the next waiting card, M the rings, Delete the picked rectangle; on a clip Space plays, ← → a frame */
764
- key(e: Pick<KeyboardEvent, 'key' | 'shiftKey'>): boolean {
921
+ * A archives, N the next waiting card, C copies the card for an agent, M the rings, Delete the picked rectangle; on a clip Space plays, ← → a frame
922
+ * (a sound: two seconds); on option rows 1 to 9 play that row, ⇧1 to ⇧5 score the row playing, P picks it. A digit is
923
+ * read from its physical key (`code`), so ⇧ and an AZERTY keyboard read the same */
924
+ key(e: Pick<KeyboardEvent, 'key' | 'shiftKey'> & { code?: string }): boolean {
765
925
  const k = e.key.toLowerCase()
766
- if (this.clipKey && e.key === ' ') this.clip.toggle()
926
+ const digit = Number(/^Digit([1-9])$/.exec(e.code ?? '')?.[1] ?? (/^[1-9]$/.test(e.key) ? e.key : 0))
927
+ const sound = this.openView?.audio !== undefined
928
+ if (this.rows && digit > 0 && !e.shiftKey) {
929
+ if (digit <= (this.open?.item.views.length ?? 0)) this.listen(digit - 1)
930
+ } else if (this.choice && this.rows && digit > 0 && e.shiftKey) {
931
+ if (digit <= STARS) this.setStars(this.view, digit)
932
+ } else if (this.choice && k === 'p') void this.choose(this.view)
933
+ else if (this.clipKey && e.key === ' ') {
934
+ if (this.clip.paused && this.rows) this.clip.listen()
935
+ else this.clip.toggle()
936
+ } else if (this.clipKey && sound && (e.key === 'ArrowLeft' || e.key === 'ArrowRight')) this.clip.jump(this.clip.time + (e.key === 'ArrowRight' ? SOUND_JUMP : -SOUND_JUMP))
767
937
  else if (this.clipKey && (e.key === 'ArrowLeft' || e.key === 'ArrowRight')) this.clip.step(e.key === 'ArrowRight' ? 1 : -1)
768
938
  else if (e.key === 'ArrowUp' || e.key === 'ArrowDown') this.move(e.key === 'ArrowDown' ? 1 : -1)
769
939
  else if (k === 'v') void this.submit('approved')
@@ -771,6 +941,7 @@ export class ReviewSt {
771
941
  else if (k === 'r') void this.submit('rejected')
772
942
  else if (k === 'a') void this.submit('archived')
773
943
  else if (k === 'n') this.nextPending()
944
+ else if (k === 'c') void this.copyCard()
774
945
  else if (k === 'm') this.toggleMarks()
775
946
  else if ((e.key === 'Delete' || e.key === 'Backspace') && this.rect !== null) this.removeRect(this.rect)
776
947
  else return false
@@ -1,10 +1,12 @@
1
1
  // the review page's middle column: every view of the open card stacked, each under its heading with the owner's reaction and
2
2
  // its zoom; a view is an image, a clip, a pair (side by side, the curtain, the onion skin, the after alone) or a
3
- // gallery of tiles. A drag on an image draws a rectangle, on a clip it pauses and keeps the frame's time
3
+ // gallery of tiles. A drag on an image draws a rectangle, on a clip it pauses and keeps the frame's time. A choice, or
4
+ // views that each play alone, show as thin option rows; the card's header sits on top when the page reads in one column
4
5
  import { observer } from 'mobx-react-lite'
5
6
  import { useEffect, useRef, useState, type ReactNode } from 'react'
6
- import { isGallery, isPair, onFrame, viewName, type GalleryView, type MediaView, type Rect, type ReviewClip, type ReviewItem, type ReviewMark, type ReviewView, type Side } from '../../../review/model.ts'
7
+ import { isGallery, isPair, onFrame, STARS, thumbFileOf, verdictOf, viewName, type GalleryView, type MediaView, type Rect, type ReviewClip, type ReviewItem, type ReviewMark, type ReviewView, type Side } from '../../../review/model.ts'
7
8
  import { blurOnEscape, Empty, FIT_ROOM, keepFocus, PAIR_NAMES, REACTIONS, secs } from './kit.tsx'
9
+ import { CardHead } from './review-answer.tsx'
8
10
  import type { ClipSt } from './clip-st.ts'
9
11
  import { CARD_PLACES, COMPARE_MODES, drawnSide, ZOOMS, type CompareMode, type CompareSt, type PairSide, type ReviewSt, type Zoom } from './review-st.ts'
10
12
 
@@ -16,12 +18,20 @@ export const ReviewImage = observer(({ rv }: { rv: ReviewSt }) => {
16
18
  if (!x) return <Empty text={rv.list ? 'No card. An agent adds one with sk review add.' : 'Reading the cards…'} />
17
19
  const r = x.item
18
20
  const views = r.views
19
- const drawable = views.some((v) => !isGallery(v) && !v.clip)
21
+ const drawable = views.some((v) => !isGallery(v) && !v.clip && !v.audio)
22
+ const rows = rv.rows
20
23
  return (
21
24
  <div className="rv-views">
25
+ {rv.headInViews ? <CardHead rv={rv} x={x} /> : null}
22
26
  <div className="rv-viewsbar">
23
- {views.length > 1 ? <JumpList rv={rv} /> : <span />}
27
+ {views.length > 1 && !rows ? <JumpList rv={rv} /> : <span />}
24
28
  <span className="rv-viewsctl">
29
+ {asOptionsCard(rv) ? (
30
+ <span className="seg" title="the options as thin rows, one below the other, or every view full">
31
+ <button className={rows ? 'on' : ''} onMouseDown={keepFocus} onClick={() => rv.setFull(false)}>rows</button>
32
+ <button className={rows ? '' : 'on'} onMouseDown={keepFocus} onClick={() => rv.setFull(true)}>full</button>
33
+ </span>
34
+ ) : null}
25
35
  {rv.phone.on && drawable ? (
26
36
  <button className={rv.phone.draw ? 'primary' : ''} onClick={() => rv.phone.toggleDraw()}>Circle</button>
27
37
  ) : null}
@@ -36,12 +46,144 @@ export const ReviewImage = observer(({ rv }: { rv: ReviewSt }) => {
36
46
  </span>
37
47
  </div>
38
48
  {rv.answered.map((y) => <Raised key={y.item.id} rv={rv} x={y} />)}
39
- {views.map((v, i) => <ViewBlock key={`${r.id}#${i}`} rv={rv} id={r.id} v={v} i={i} many={views.length > 1} />)}
49
+ {rows ? (
50
+ <div className="rv-opts">{views.map((v, i) => <OptionRow key={`${r.id}#${i}`} rv={rv} x={x} v={v} i={i} />)}</div>
51
+ ) : (
52
+ views.map((v, i) => <ViewBlock key={`${r.id}#${i}`} rv={rv} id={r.id} v={v} i={i} many={views.length > 1} />)
53
+ )}
40
54
  {views.length === 0 ? <TextOnly x={x} /> : null}
41
55
  </div>
42
56
  )
43
57
  })
44
58
 
59
+ /** the open card may show as rows: a choice, or views that each play alone (the switch shows while it shows full) */
60
+ const asOptionsCard = (rv: ReviewSt): boolean => rv.rows || rv.full[rv.open?.item.id ?? ''] === true
61
+
62
+ /** one option of a choice, or one view of a group, as a thin row: its number, ▶ and the clock, its label, a strip that
63
+ * scrubs it, its stars and « pick » (a choice) or OK, fix, no (a group), ✎ his word, ⤢ the full view under it */
64
+ const OptionRow = observer(({ rv, x, v, i }: { rv: ReviewSt; x: ReviewItem; v: ReviewView; i: number }) => {
65
+ const [word, setWord] = useState(false)
66
+ const id = x.item.id
67
+ const media = isGallery(v) ? null : v
68
+ const playable = media !== null && !isPair(media.files) && (media.clip !== undefined || media.audio !== undefined) ? media : null
69
+ const c = playable ? rv.clipOf(i) : null
70
+ const reaction = rv.reactionOf(i)
71
+ const choice = rv.choice
72
+ const picked = rv.draft.pick === i
73
+ const sent = verdictOf(x)?.pick === i
74
+ const unfolded = rv.openRows[i] === true
75
+ const file = playable ? ('video' in playable.files ? playable.files.video : 'audio' in playable.files ? playable.files.audio : '') : ''
76
+ const duration = playable?.clip?.duration ?? playable?.audio?.duration ?? 0
77
+ const showWord = word || (reaction?.comment ?? '') !== ''
78
+ return (
79
+ <div id={`review-view-${i}`} className={`rv-opt${rv.view === i ? ' active' : ''}${picked ? ' picked' : ''}${toneClass(reaction?.status)}`} onMouseDownCapture={() => rv.view !== i && rv.setView(i)}>
80
+ <div className="rv-optrow">
81
+ <span className="rv-optn">{i + 1}</span>
82
+ {c ? (
83
+ <button className={`rv-optplay${c.paused ? '' : ' on'}`} title={`${c.paused ? 'play' : 'pause'} (${i + 1})`} onMouseDown={keepFocus} onClick={() => rv.listen(i)}>
84
+ {c.paused ? '▶' : '❚❚'}
85
+ </button>
86
+ ) : null}
87
+ <b className="rv-optlabel" title={viewName(v, i)}>{viewName(v, i)}</b>
88
+ {c && playable?.clip && !unfolded ? (
89
+ <span className="rv-optthumb" style={{ aspectRatio: `${playable.size.w} / ${playable.size.h}` }} onClick={() => rv.toggleRow(i)} title="the full view">
90
+ <ClipVideo clip={c} src={rv.fileUrl(id, file)} poster={rv.fileUrl(id, playable.clip.poster)} loop={false} />
91
+ </span>
92
+ ) : null}
93
+ {c && playable ? (
94
+ <Strip clip={c} duration={duration} wave={playable.audio ? rv.fileUrl(id, playable.audio.wave) : undefined} />
95
+ ) : (
96
+ <span className="rv-optthumb img" onClick={() => rv.toggleRow(i)} title="the full view"><img src={rv.thumbUrl(x, thumbFileOf(v))} alt="" draggable={false} /></span>
97
+ )}
98
+ {c ? <span className="rv-mono rv-opttime">{clock(c.time)} / {clock(duration)}</span> : null}
99
+ <span className="rv-optacts">
100
+ {choice ? <Stars rv={rv} view={i} /> : <ReactionToggles rv={rv} view={i} small />}
101
+ <button className={`rv-optword${showWord ? ' on' : ''}`} title="a word on this one" onMouseDown={keepFocus} onClick={() => setWord(!word)}>✎</button>
102
+ {playable?.clip || !playable ? (
103
+ <button className={unfolded ? 'on' : ''} title={unfolded ? 'fold it back to its row' : 'the full view'} onMouseDown={keepFocus} onClick={() => rv.toggleRow(i)}>⤢</button>
104
+ ) : null}
105
+ {playable?.audio ? <a className="rv-dl" href={rv.fileUrl(id, file)} download={`${id}-${file}`} title="download the sound">⤓</a> : null}
106
+ {choice ? (
107
+ <button className={`rv-pick${picked ? ' on' : ''}`} title={`pick this one: it is your answer (P)${sent ? ', sent' : ''}`} disabled={rv.busy} onClick={() => void rv.choose(i)}>
108
+ {picked ? '✓ picked' : 'pick'}
109
+ </button>
110
+ ) : null}
111
+ </span>
112
+ </div>
113
+ {media?.note ? <div className="rv-optnote">{media.note}</div> : null}
114
+ {showWord ? (
115
+ <textarea className="rv-word" rows={1} placeholder="a word on this one" value={reaction?.comment ?? ''} autoFocus={word && !reaction?.comment}
116
+ onKeyDown={blurOnEscape} onChange={(e) => rv.setReactionComment(i, undefined, e.currentTarget.value)} />
117
+ ) : null}
118
+ {c && playable?.audio ? <SoundEl clip={c} src={rv.fileUrl(id, file)} /> : null}
119
+ {unfolded ? (
120
+ <div className="rv-optbody">
121
+ {isGallery(v) ? <Gallery rv={rv} id={id} v={v} view={i} /> : <MediaBody rv={rv} id={id} v={v} i={i} zoom={rv.zoomOf(i)} />}
122
+ </div>
123
+ ) : null}
124
+ </div>
125
+ )
126
+ })
127
+
128
+ /** `1:05`: a time on a row, short */
129
+ const clock = (t: number): string => `${Math.floor(t / 60)}:${String(Math.floor(t % 60)).padStart(2, '0')}`
130
+
131
+ /** an option's score: STARS stars, a click on the one given clears it */
132
+ const Stars = observer(({ rv, view }: { rv: ReviewSt; view: number }) => {
133
+ const n = rv.reactionOf(view)?.stars ?? 0
134
+ return (
135
+ <span className="rv-stars" title={`your score (⇧1 to ⇧${STARS} on the row playing)`}>
136
+ {Array.from({ length: STARS }, (_, k) => (
137
+ <button key={k} className={k < n ? 'on' : ''} aria-label={`${k + 1} of ${STARS}`} onMouseDown={keepFocus} onClick={() => rv.setStars(view, k + 1)}>{k < n ? '★' : '☆'}</button>
138
+ ))}
139
+ </span>
140
+ )
141
+ })
142
+
143
+ /** a thin track a row scrubs: a sound's waveform under what has played; a click or a drag moves the time */
144
+ const Strip = observer(({ clip, duration, wave }: { clip: ClipSt; duration: number; wave: string | undefined }) => {
145
+ const el = useRef<HTMLDivElement>(null)
146
+ const down = (e: React.PointerEvent): void => {
147
+ const b = el.current?.getBoundingClientRect()
148
+ if (!b || duration <= 0) return
149
+ e.preventDefault()
150
+ const at = (px: number): void => clip.jump(Math.max(0, Math.min(1, (px - b.left) / b.width)) * duration)
151
+ at(e.clientX)
152
+ track((m) => at(m.clientX))
153
+ }
154
+ const played = duration > 0 ? Math.min(1, clip.time / duration) : 0
155
+ return (
156
+ <div ref={el} className={`rv-strip${wave ? ' wave' : ''}`} onPointerDown={down}>
157
+ {wave ? (
158
+ <>
159
+ <Wave src={wave} className="rv-stripwave" />
160
+ <Wave src={wave} className="rv-stripwave done" style={{ clipPath: `inset(0 ${100 - played * 100}% 0 0)` }} />
161
+ </>
162
+ ) : (
163
+ <div className="rv-stripdone" style={{ width: `${played * 100}%` }} />
164
+ )}
165
+ <div className="rv-striphead" style={{ left: `${played * 100}%` }} />
166
+ </div>
167
+ )
168
+ })
169
+
170
+ /** a waveform png as a mask the page fills with its own colour, so it reads on either theme (any colour it was drawn in) */
171
+ const Wave = ({ src, className, style }: { src: string; className: string; style?: React.CSSProperties }) => {
172
+ const mask = `url("${src}") center / 100% 100% no-repeat`
173
+ return <div className={className} style={{ ...style, mask, WebkitMask: mask }} />
174
+ }
175
+
176
+ /** a sound row's player, no controls of its own: the row's clock drives it; at its end it waits at the start */
177
+ const SoundEl = ({ clip, src }: { clip: ClipSt; src: string }) => {
178
+ const ref = useRef<HTMLAudioElement>(null)
179
+ useEffect(() => {
180
+ const el = ref.current
181
+ if (!el) return
182
+ return clip.attach(el)
183
+ }, [clip, src])
184
+ return <audio ref={ref} src={src} preload="metadata" onEnded={() => clip.seek(0)} />
185
+ }
186
+
45
187
  /** a raised item the open card answers, nested above its views: the owner's words, his still */
46
188
  const Raised = observer(({ rv, x }: { rv: ReviewSt; x: ReviewItem }) => {
47
189
  const opening = x.thread.find((m) => m.op === undefined && m.text !== undefined)
@@ -213,7 +355,9 @@ const SoundBody = ({ rv, id, v }: { rv: ReviewSt; id: string; v: MediaView }) =>
213
355
  {rows.map((r) => (
214
356
  <div key={r.file} className="rv-soundrow">
215
357
  {r.label ? <div className="rv-dim">{r.label}</div> : null}
216
- <img src={rv.fileUrl(id, r.wave)} alt={`the waveform of ${r.file}`} draggable={false} style={{ width: '100%', aspectRatio: `${v.size.w} / ${v.size.h}`, display: 'block' }} />
358
+ <div role="img" aria-label={`the waveform of ${r.file}`} className="rv-wavebox" style={{ aspectRatio: `${v.size.w} / ${v.size.h}` }}>
359
+ <Wave src={rv.fileUrl(id, r.wave)} className="rv-stripwave full" />
360
+ </div>
217
361
  <audio controls preload="metadata" src={rv.fileUrl(id, r.file)} style={{ width: '100%' }} />
218
362
  </div>
219
363
  ))}
@@ -463,15 +607,16 @@ const RectMark = ({ r, n, on, faint, at, pick }: { r: Rect; n: number; on: boole
463
607
 
464
608
  // ── a clip ──
465
609
 
466
- /** one video of the open view, driven by the view's one clock; muted until the sound button, looping, inline */
467
- const ClipVideo = ({ clip, src }: { clip: ClipSt; src: string }) => {
610
+ /** one video of the open view, driven by the view's one clock; muted until the sound button, inline; looping, or at its
611
+ * end waiting at the start */
612
+ const ClipVideo = ({ clip, src, poster, loop = true }: { clip: ClipSt; src: string; poster?: string; loop?: boolean }) => {
468
613
  const ref = useRef<HTMLVideoElement>(null)
469
614
  useEffect(() => {
470
615
  const el = ref.current
471
616
  if (!el) return
472
617
  return clip.attach(el)
473
618
  }, [clip, src])
474
- return <video ref={ref} className="rv-video" src={src} muted loop playsInline preload="auto" />
619
+ return <video ref={ref} className="rv-video" src={src} poster={poster} muted loop={loop} playsInline preload="auto" onEnded={loop ? undefined : () => clip.seek(0)} />
475
620
  }
476
621
 
477
622
  /** the sound button and the volume: a volume moved turns the sound on */