rvlib-shipkit 2.3.0 → 2.4.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. package/CHANGELOG.md +11 -2
  2. package/package.json +1 -1
  3. package/src/assets/kits/identity-kit.ts +5 -2
  4. package/src/cli/cmd_Commands.ts +4 -0
  5. package/src/cli/cmd_Review.ts +36 -10
  6. package/src/cli/cmd_Sound.ts +209 -0
  7. package/src/cli/cmd_Trailer.ts +7 -2
  8. package/src/cli/kit/verb.ts +11 -3
  9. package/src/cli.ts +2 -0
  10. package/src/core/app-icon.ts +10 -6
  11. package/src/repo-config.ts +3 -0
  12. package/src/review/format.ts +11 -3
  13. package/src/review/model.ts +47 -5
  14. package/src/review/project.ts +20 -4
  15. package/src/review/routes.ts +12 -1
  16. package/src/review/store.ts +15 -6
  17. package/src/review/unit.ts +19 -1
  18. package/src/serve/hub-app/admin-svc.ts +1 -1
  19. package/src/serve/hub-app/admin-view.tsx +1 -1
  20. package/src/serve/hub-app/hash-route.ts +12 -4
  21. package/src/serve/hub-app/home-st.ts +7 -1
  22. package/src/serve/hub-app/hub.css +38 -1
  23. package/src/serve/hub-app/icons.tsx +6 -0
  24. package/src/serve/hub-app/layout-st.ts +2 -2
  25. package/src/serve/hub-app/md.ts +32 -0
  26. package/src/serve/hub-app/project-list.css +9 -7
  27. package/src/serve/hub-app/project-list.tsx +5 -2
  28. package/src/serve/hub-app/projects-svc.ts +3 -2
  29. package/src/serve/hub-app/repo-st.ts +22 -7
  30. package/src/serve/hub-app/review/clip-st.ts +57 -7
  31. package/src/serve/hub-app/review/kind-view.tsx +78 -9
  32. package/src/serve/hub-app/review/review-answer.tsx +83 -53
  33. package/src/serve/hub-app/review/review-page.tsx +9 -14
  34. package/src/serve/hub-app/review/review-st.ts +141 -18
  35. package/src/serve/hub-app/review/review-views.tsx +154 -9
  36. package/src/serve/hub-app/review.css +70 -10
  37. package/src/serve/hub-app/wiki-st.ts +190 -0
  38. package/src/serve/hub-app/wikis-view.tsx +134 -5
  39. package/src/serve/hub-runtime.ts +4 -4
  40. package/src/serve/project-gutter-route.ts +25 -1
  41. package/src/serve/project-gutter.ts +1 -1
  42. package/src/serve/serve-hub.ts +7 -0
  43. package/src/shot/client.ts +17 -5
  44. package/src/shot/shooter.ts +19 -3
  45. package/src/shot/shot-ask.ts +9 -1
  46. package/src/shot/stepped-clip.ts +53 -0
  47. package/src/trailer/adapter.ts +33 -4
  48. package/src/trailer/build.ts +170 -29
  49. package/src/trailer/fonts.ts +55 -0
  50. package/src/trailer/footage.ts +4 -4
  51. package/src/trailer/grammar.ts +429 -67
  52. package/src/trailer/graph.ts +130 -56
  53. package/src/trailer/look.ts +3 -0
  54. package/src/trailer/motion.ts +207 -0
  55. package/src/trailer/plan.ts +2 -2
  56. package/src/trailer/prune.ts +13 -2
  57. package/src/trailer/score/compose.ts +18 -2
  58. package/src/trailer/score/gm-render.ts +43 -0
  59. package/src/trailer/score/gm.ts +90 -0
  60. package/src/trailer/score/midi.ts +143 -0
  61. package/src/trailer/score/pattern.ts +13 -6
  62. package/src/trailer/score/score.ts +28 -14
  63. package/src/trailer/score/sound.ts +12 -1
  64. package/src/trailer/score/synth.ts +24 -2
  65. package/src/trailer/sound/audio.ts +148 -0
  66. package/src/trailer/sound/brand.ts +42 -0
  67. package/src/trailer/sound/comfy.ts +38 -0
  68. package/src/trailer/sound/eleven.ts +72 -0
  69. package/src/trailer/sound/fluid.ts +71 -0
  70. package/src/trailer/sound/gen.ts +139 -0
  71. package/src/trailer/sound/index.ts +60 -0
  72. package/src/trailer/sound/presets.ts +69 -0
  73. package/src/trailer/sound/run.ts +189 -0
  74. package/src/trailer/titles.ts +451 -235
  75. package/src/trailer/url.ts +18 -3
  76. package/src/trailer/vocab.ts +12 -1
  77. package/src/trailer/words.ts +29 -8
  78. package/src/wiki/graph-mount.ts +3 -0
  79. package/src/wiki/graph-page.ts +45 -9
  80. package/src/wiki/graph-pages.ts +128 -0
@@ -3,10 +3,16 @@
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,
9
13
  reworkOf,
14
+ STARS,
15
+ STATUS_LABEL,
10
16
  stateOf,
11
17
  unreadBy,
12
18
  verdictOf,
@@ -31,6 +37,8 @@ interface Draft {
31
37
  rects: Rect[]
32
38
  comment: string
33
39
  reactions?: ReviewReaction[]
40
+ /** the option he picked, on a choice card */
41
+ pick?: number
34
42
  }
35
43
 
36
44
  /** a live `sk review watch`: its process, the session or lane it keeps, when it started */
@@ -71,6 +79,10 @@ export type PhonePane = 'list' | 'card' | 'answer'
71
79
  export type PairSide = 'before' | 'after' | 'swipe'
72
80
 
73
81
  const POLL_MS = 2000
82
+ /** a sound's clock steps a tenth of a second */
83
+ const SOUND_FPS = 10
84
+ /** ← → on a sound jump this many seconds */
85
+ const SOUND_JUMP = 2
74
86
 
75
87
  const sameSpot = (r: ReviewReaction, view: number, tile: number | undefined): boolean => r.view === view && r.tile === tile
76
88
 
@@ -97,7 +109,7 @@ const draftOf = (s: string | null): Draft | null => {
97
109
  try {
98
110
  const d = s === null ? null : (JSON.parse(s) as Partial<Draft>)
99
111
  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 } : {}) }
112
+ return { rects: d.rects, comment: d.comment, ...(Array.isArray(d.reactions) ? { reactions: d.reactions } : {}), ...(typeof d.pick === 'number' ? { pick: d.pick } : {}) }
101
113
  } catch {
102
114
  return null
103
115
  }
@@ -179,6 +191,8 @@ export class PhoneSt {
179
191
  }
180
192
  }
181
193
 
194
+ const isShownStatus = (s: string): s is ShownStatus => Object.hasOwn(STATUS_LABEL, s)
195
+
182
196
  export class ReviewSt {
183
197
  list: Listing | null = null
184
198
  listError: string | null = null
@@ -206,6 +220,10 @@ export class ReviewSt {
206
220
  viewZoom: Record<number, Zoom> = {}
207
221
  /** the gallery tile shown large across the column, `<view>/<tile>` */
208
222
  bigTile: string | null = null
223
+ /** a card of options shown full, every view stacked, by id: his switch, this visit */
224
+ full: Record<string, boolean> = {}
225
+ /** the option rows unfolded to their full view, by view index */
226
+ openRows: Record<number, boolean> = {}
209
227
  replies: Record<string, string> = {}
210
228
  private reading = new Set<string>()
211
229
  /** « hide handled »: only the active threads in the list */
@@ -239,6 +257,27 @@ export class ReviewSt {
239
257
  setPlace(p: CardPlace): void {
240
258
  this.place = p
241
259
  }
260
+ /** the open card as thin option rows: a choice, or views that each play alone, unless he asked them full */
261
+ get rows(): boolean {
262
+ const x = this.open
263
+ return x !== null && asOptions(x.item) && this.full[x.item.id] !== true
264
+ }
265
+ setFull(on: boolean): void {
266
+ const id = this.open?.item.id
267
+ if (id) this.full = { ...this.full, [id]: on }
268
+ }
269
+ get choice(): boolean {
270
+ const x = this.open
271
+ return x !== null && isChoice(x.item)
272
+ }
273
+ /** the card's header (title, state, what, and in one column the verdicts) sits over its views: one column, the
274
+ * phone's card pane, a choice's options */
275
+ get headInViews(): boolean {
276
+ return this.stacked || this.phone.on || this.choice
277
+ }
278
+ toggleRow(i: number): void {
279
+ this.openRows = { ...this.openRows, [i]: !this.openRows[i] }
280
+ }
242
281
 
243
282
  constructor(private readonly st: RepoSt) {
244
283
  this.prefix = `shipkit.review.${st.key}.`
@@ -470,7 +509,14 @@ export class ReviewSt {
470
509
  if (!x) return { rects: [], comment: '' }
471
510
  const v = verdictOf(x)
472
511
  const sent = v?.reactions
473
- return this.drafts[x.item.id] ?? { rects: v?.rects ?? [], comment: v?.comment ?? '', ...(sent && sent.length > 0 ? { reactions: sent } : {}) }
512
+ return (
513
+ this.drafts[x.item.id] ?? {
514
+ rects: v?.rects ?? [],
515
+ comment: v?.comment ?? '',
516
+ ...(sent && sent.length > 0 ? { reactions: sent } : {}),
517
+ ...(v?.pick !== undefined ? { pick: v.pick } : {}),
518
+ }
519
+ )
474
520
  }
475
521
 
476
522
  get dirty(): boolean {
@@ -482,7 +528,7 @@ export class ReviewSt {
482
528
  const x = this.open
483
529
  if (!x) return
484
530
  const d = this.draft
485
- const { reactions, ...rest } = f({ rects: [...d.rects], comment: d.comment, reactions: [...(d.reactions ?? [])] })
531
+ const { reactions, ...rest } = f({ ...d, rects: [...d.rects], reactions: [...(d.reactions ?? [])] })
486
532
  const next: Draft = reactions && reactions.length > 0 ? { ...rest, reactions } : rest
487
533
  this.drafts = { ...this.drafts, [x.item.id]: next }
488
534
  store.put(`${this.prefix}draft.${x.item.id}`, JSON.stringify(next))
@@ -494,12 +540,43 @@ export class ReviewSt {
494
540
  if (this.phone.on) this.phone.show('card')
495
541
  }
496
542
 
543
+ /** what the address carries beside the card: its view and the list's filters, `view=1&subject=place`, each only
544
+ * off its default (`handled=1` shows the handled threads, `lanes=1` groups by lane) */
545
+ get address(): string {
546
+ const q = new URLSearchParams()
547
+ if (this.view > 0) q.set('view', String(this.view))
548
+ if (this.subject !== null) q.set('subject', this.subject)
549
+ if (this.whose !== null) q.set('whose', this.whose)
550
+ if (this.status !== null) q.set('status', this.status)
551
+ if (!this.hideHandled) q.set('handled', '1')
552
+ if (this.byLane) q.set('lanes', '1')
553
+ return q.toString()
554
+ }
555
+
556
+ /** an address's view and filters read back: a key it leaves out keeps what the page has; an unknown value is dropped */
557
+ applyAddress(raw: string): void {
558
+ const q = new URLSearchParams(raw)
559
+ const view = Number(q.get('view'))
560
+ if (Number.isInteger(view) && view > 0) this.view = view
561
+ const subject = q.get('subject')
562
+ if (subject) this.subject = subject
563
+ const whose = q.get('whose')
564
+ if (whose === 'owner' || whose === 'agents' || whose === 'none') this.whose = whose
565
+ const status = q.get('status')
566
+ if (status !== null && isShownStatus(status)) this.status = status
567
+ if (q.get('handled') === '1') this.hideHandled = false
568
+ if (q.get('lanes') === '1') this.byLane = true
569
+ }
570
+
497
571
  /** the card the address names; its draft is read again from storage, the truth over every window */
498
572
  select(id: string | null): void {
499
573
  if (id !== this.selectedId) {
500
574
  this.view = 0
501
575
  this.viewZoom = {}
502
576
  this.bigTile = null
577
+ this.openRows = {}
578
+ // a link to a card on the phone opens the card, never the list
579
+ if (id !== null && this.phone.on) this.phone.show('card')
503
580
  }
504
581
  this.selectedId = id
505
582
  if (id !== null) {
@@ -545,6 +622,13 @@ export class ReviewSt {
545
622
  this.rect = null
546
623
  }
547
624
 
625
+ /** the playable view an option row or a clip shows: a clip, or a sound shown as a row */
626
+ private playableAt(i: number): MediaView | null {
627
+ const v = this.open?.item.views[i]
628
+ if (!v || isGallery(v)) return null
629
+ return v.clip || (v.audio && this.rows && !isPair(v.files)) ? v : null
630
+ }
631
+
548
632
  get openView(): MediaView | null {
549
633
  const v = this.open?.item.views[this.view]
550
634
  return v && !isGallery(v) ? v : null
@@ -575,15 +659,28 @@ export class ReviewSt {
575
659
  /** a reaction's toggle: sets that status, or clears it when it is already the one */
576
660
  react(view: number, tile: number | undefined, status: ReviewStatus): void {
577
661
  const cur = this.reactionOf(view, tile)
578
- this.putReaction(view, tile, { status: cur?.status === status ? undefined : status, comment: cur?.comment ?? '' })
662
+ this.putReaction(view, tile, { ...cur, status: cur?.status === status ? undefined : status, comment: cur?.comment ?? '' })
579
663
  }
580
664
 
581
665
  setReactionComment(view: number, tile: number | undefined, comment: string): void {
582
- this.putReaction(view, tile, { status: this.reactionOf(view, tile)?.status, comment })
666
+ this.putReaction(view, tile, { ...this.reactionOf(view, tile), comment })
667
+ }
668
+
669
+ /** an option's stars; the same number again clears them */
670
+ setStars(view: number, n: number): void {
671
+ const cur = this.reactionOf(view)
672
+ this.putReaction(view, undefined, { ...cur, comment: cur?.comment ?? '', stars: cur?.stars === n ? undefined : n })
583
673
  }
584
674
 
585
675
  removeReaction(view: number, tile: number | undefined): void {
586
- this.putReaction(view, tile, { status: undefined, comment: '' })
676
+ this.putReaction(view, tile, { comment: '' })
677
+ }
678
+
679
+ /** « pick »: that option is his answer, sent at once as an approval; his stars and words go with it */
680
+ async choose(view: number): Promise<void> {
681
+ if (!this.choice) return
682
+ this.edit((d) => ({ ...d, pick: view }))
683
+ await this.submit('approved')
587
684
  }
588
685
 
589
686
  /** the reactions that go to the agent with the verdict, in the card's order */
@@ -591,19 +688,27 @@ export class ReviewSt {
591
688
  return [...(this.draft.reactions ?? [])].sort((a, b) => a.view - b.view || (a.tile ?? -1) - (b.tile ?? -1))
592
689
  }
593
690
 
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 === ''
691
+ private putReaction(view: number, tile: number | undefined, r: { status?: ReviewStatus | undefined; comment: string; stars?: number | undefined }): void {
692
+ const next: ReviewReaction = { view, ...(tile !== undefined ? { tile } : {}), ...(r.status ? { status: r.status } : {}), comment: r.comment, ...(r.stars ? { stars: r.stars } : {}) }
693
+ const empty = !r.status && r.comment === '' && !r.stars
597
694
  this.edit((d) => {
598
695
  const rest = (d.reactions ?? []).filter((q) => !sameSpot(q, view, tile))
599
696
  return { ...d, reactions: empty ? rest : [...rest, next] }
600
697
  })
601
698
  }
602
699
 
603
- /** the open view's clip identity, empty when it shows images */
700
+ /** the open view's clip identity (a clip, or a sound on a row), empty when it shows images */
604
701
  get clipKey(): string {
605
702
  const x = this.open
606
- return x && this.openView?.clip ? `${x.item.id}#${this.view}` : ''
703
+ return x && this.playableAt(this.view) ? `${x.item.id}#${this.view}` : ''
704
+ }
705
+
706
+ /** a row's ▶: that option plays with its sound, the one playing before it stops; again, it pauses */
707
+ listen(i: number): void {
708
+ this.setView(i)
709
+ const c = this.clipOf(i)
710
+ if (c.paused) c.listen()
711
+ else c.pause()
607
712
  }
608
713
 
609
714
  /** the clock of a clip view of the open card: every clip view plays and steps on its own */
@@ -616,9 +721,14 @@ export class ReviewSt {
616
721
  let c = this.clips.get(view)
617
722
  if (!c) {
618
723
  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)
724
+ const views = this.open?.item.views ?? []
725
+ const v = views[view]
726
+ const media = v && !isGallery(v) ? v : undefined
727
+ // one thing plays at a time: the first clip of a card loops on its own, the others and every option wait
728
+ const first = views.findIndex((w) => !isGallery(w) && w.clip !== undefined)
729
+ const still = this.rows || view !== first
730
+ if (media?.audio) c.load(`${id}#${view}`, media.audio.duration, SOUND_FPS, true)
731
+ else c.load(`${id}#${view}`, media?.clip?.duration ?? 0, media?.clip?.fps ?? 20, still)
622
732
  this.clips.set(view, c)
623
733
  }
624
734
  return c
@@ -740,7 +850,8 @@ export class ReviewSt {
740
850
  this.busy = true
741
851
  this.error = null
742
852
  try {
743
- const m = await postJson<Message>(this.st.url(`/api/review/${id}/feedback`), { status, comment: d.comment, rects: d.rects, reactions: d.reactions ?? [] })
853
+ const pick = isChoice(x.item) && isApproved(status) && d.pick !== undefined ? { pick: d.pick } : {}
854
+ const m = await postJson<Message>(this.st.url(`/api/review/${id}/feedback`), { status, comment: d.comment, rects: d.rects, reactions: d.reactions ?? [], ...pick })
744
855
  runInAction(() => {
745
856
  // the verdict shows at once: the list of every card comes back after it
746
857
  const r = this.list
@@ -760,10 +871,22 @@ export class ReviewSt {
760
871
  }
761
872
 
762
873
  /** 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 {
874
+ * A archives, N the next waiting card, M the rings, Delete the picked rectangle; on a clip Space plays, ← → a frame
875
+ * (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
876
+ * read from its physical key (`code`), so ⇧ and an AZERTY keyboard read the same */
877
+ key(e: Pick<KeyboardEvent, 'key' | 'shiftKey'> & { code?: string }): boolean {
765
878
  const k = e.key.toLowerCase()
766
- if (this.clipKey && e.key === ' ') this.clip.toggle()
879
+ const digit = Number(/^Digit([1-9])$/.exec(e.code ?? '')?.[1] ?? (/^[1-9]$/.test(e.key) ? e.key : 0))
880
+ const sound = this.openView?.audio !== undefined
881
+ if (this.rows && digit > 0 && !e.shiftKey) {
882
+ if (digit <= (this.open?.item.views.length ?? 0)) this.listen(digit - 1)
883
+ } else if (this.choice && this.rows && digit > 0 && e.shiftKey) {
884
+ if (digit <= STARS) this.setStars(this.view, digit)
885
+ } else if (this.choice && k === 'p') void this.choose(this.view)
886
+ else if (this.clipKey && e.key === ' ') {
887
+ if (this.clip.paused && this.rows) this.clip.listen()
888
+ else this.clip.toggle()
889
+ } 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
890
  else if (this.clipKey && (e.key === 'ArrowLeft' || e.key === 'ArrowRight')) this.clip.step(e.key === 'ArrowRight' ? 1 : -1)
768
891
  else if (e.key === 'ArrowUp' || e.key === 'ArrowDown') this.move(e.key === 'ArrowDown' ? 1 : -1)
769
892
  else if (k === 'v') void this.submit('approved')
@@ -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} verdicts={rv.stacked && !rv.phone.on} /> : 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 */
@@ -118,7 +118,7 @@
118
118
  .rv-word{margin-top:var(--s2)}
119
119
  .rv-raised{padding:var(--s2) var(--s3);border:1px solid var(--line);border-left:4px solid var(--warn);border-radius:4px;background:var(--panel)}
120
120
  .rv-raisedimg{display:block;max-width:100%;max-height:320px;margin-top:var(--s2);border-radius:4px}
121
- .rv-pairgrid{margin:0 auto;display:grid;gap:var(--s2)}
121
+ .rv-pairgrid{display:grid;gap:var(--s2)}
122
122
  .rv-cell{display:flex;flex-direction:column;align-items:center;gap:var(--s0);min-width:0}
123
123
  .rv-frame{position:relative;width:100%;max-width:100%;border-radius:4px;border:1px solid var(--line);overflow:hidden;
124
124
  background:repeating-conic-gradient(var(--chk1) 0 25%,var(--chk2) 0 50%) 0 0/16px 16px}
@@ -148,7 +148,7 @@
148
148
  .rv-tile{display:flex;flex-direction:column;gap:var(--s1);padding:var(--s1);border-radius:6px;border:2px solid var(--line)}
149
149
  .rv-tile[class*=' re-']{border-color:var(--tone);background:color-mix(in srgb,var(--tone) 8%,transparent)}
150
150
  .rv-tile.big{width:100%}
151
- .rv-tileimg{border-radius:4px;cursor:zoom-in;margin:0 auto;width:100%;background:repeating-conic-gradient(var(--chk1) 0 25%,var(--chk2) 0 50%) 0 0/12px 12px}
151
+ .rv-tileimg{border-radius:4px;cursor:zoom-in;width:100%;background:repeating-conic-gradient(var(--chk1) 0 25%,var(--chk2) 0 50%) 0 0/12px 12px}
152
152
  .rv-tile.big .rv-tileimg{cursor:zoom-out}
153
153
  .rv-tileimg img{display:block;width:100%;height:100%;object-fit:contain}
154
154
  .rv-tilelabel{font-size:11px;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
@@ -159,19 +159,62 @@
159
159
  .rv-sound button{padding:0 var(--s1);line-height:22px}
160
160
  .rv-sound input{width:72px}
161
161
  .rv-phonepair{display:flex;flex-direction:column;gap:var(--s2)}
162
+ /* options: one thin row a view, one below the other; the row playing or last touched has the line, the picked one its tone */
163
+ .rv-opts{display:flex;flex-direction:column;gap:var(--s1)}
164
+ .rv-opt{scroll-margin-top:8px;padding:var(--s1) var(--s2);border-radius:6px;border:1px solid var(--line);border-left:4px solid transparent;background:var(--panel)}
165
+ .rv-opt.active{border-left-color:var(--accent)}
166
+ .rv-opt[class*=' re-']{border-left-color:var(--tone)}
167
+ .rv-opt.picked{--tone:var(--ok);border-color:var(--tone);border-left-color:var(--tone);background:color-mix(in srgb,var(--tone) 9%,var(--panel))}
168
+ .rv-optrow{display:flex;flex-wrap:wrap;gap:var(--s1) var(--s2);align-items:center;min-height:32px}
169
+ .rv-optn{flex:none;width:16px;text-align:right;font:600 12px ui-monospace,Menlo,monospace;color:var(--dim)}
170
+ .rv-optplay{flex:none;width:32px;height:28px;padding:0;font-size:13px}
171
+ .rv-optplay.on{color:var(--bg);background:var(--accent);border-color:var(--accent)}
172
+ .rv-optlabel{flex:0 1 auto;min-width:48px;max-width:30%;font-size:13px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
173
+ .rv-optthumb{flex:none;height:32px;max-width:72px;border-radius:3px;overflow:hidden;cursor:zoom-in;border:1px solid var(--line);background:var(--panel2)}
174
+ .rv-optthumb.img{width:48px}
175
+ .rv-optthumb img{display:block;width:100%;height:100%;object-fit:cover}
176
+ .rv-strip{position:relative;flex:1 1 140px;min-width:120px;height:28px;border-radius:3px;cursor:pointer;touch-action:none;background:var(--panel2);border:1px solid var(--line);overflow:hidden}
177
+ .rv-stripdone{position:absolute;inset:0 auto 0 0;background:var(--wash)}
178
+ /* a waveform is a mask filled with tokens: --dim to play (5.0:1 on --panel2 light, 5.3:1 dark), --accent played
179
+ (4.9:1 both) */
180
+ .rv-stripwave{position:absolute;inset:0;background:var(--dim)}
181
+ .rv-stripwave.done{background:var(--accent)}
182
+ .rv-stripwave.full{background:var(--accent)}
183
+ .rv-wavebox{position:relative;width:100%;border-radius:4px;background:var(--panel2)}
184
+ .rv-striphead{position:absolute;top:0;bottom:0;width:2px;transform:translateX(-1px);background:var(--accent)}
185
+ .rv-opttime{flex:none;font-size:11px;color:var(--dim)}
186
+ .rv-optacts{flex:none;display:inline-flex;gap:var(--s1);align-items:center;margin-left:auto}
187
+ .rv-optacts > button{padding:0 var(--s1);line-height:22px}
188
+ .rv-optacts > button.on{border-color:var(--accent)}
189
+ .rv-stars{display:inline-flex}
190
+ .rv-stars button{border:0;background:none;padding:0;font-size:16px;line-height:22px;color:var(--dim)}
191
+ .rv-stars button.on{color:var(--warn)}
192
+ .rv-pick{font-weight:700;padding:0 var(--s3)!important;color:var(--ok);border-color:color-mix(in srgb,var(--ok) 55%,var(--line))}
193
+ .rv-pick.on{color:var(--bg);background:var(--ok);border-color:var(--ok)}
194
+ .rv-optnote{margin:var(--s0) 0 0 var(--s4);font-size:12.5px;color:var(--ink)}
195
+ .rv-opt .rv-word{margin:var(--s1) 0 0 var(--s4);width:calc(100% - var(--s4))}
196
+ .rv-optbody{margin-top:var(--s2)}
162
197
 
163
198
  /* ── the answer ── */
164
199
  .rv-form{display:flex;flex-direction:column;gap:var(--s2);padding:var(--s3)}
165
- .rv-status{padding:var(--s1) var(--s3);border-radius:4px;border:1px solid color-mix(in srgb,var(--tone) 45%,var(--line));border-left:4px solid var(--tone);background:color-mix(in srgb,var(--tone) 10%,var(--panel))}
166
- .rv-statushead{font-weight:800;color:var(--tone);display:flex;gap:var(--s1);align-items:center;flex-wrap:wrap}
167
- .rv-title{margin:0;font-size:17px;line-height:1.3}
168
- .rv-what{font-size:13px}
169
- .rv-what ul{margin:var(--s1) 0;padding-left:var(--s4)}
170
- .rv-what p{margin:var(--s1) 0}
200
+ .rv-title{margin:0;font-size:16px;line-height:1.3;color:var(--ink)}
201
+ /* the card's header: the title, one tight line of state, then what the agent says at full ink; --dim holds 5.6:1 or
202
+ more on --bg and --panel in both themes */
203
+ .rv-head{display:flex;flex-direction:column;gap:var(--s1)}
204
+ .rv-meta{display:flex;flex-wrap:wrap;gap:var(--s1) var(--s2);align-items:center;font-size:12px;color:var(--dim);line-height:18px}
205
+ .rv-meta .rv-badge{font-size:10px;line-height:18px;max-width:none}
206
+ .rv-copyid{font-size:11.5px;padding:0 var(--s1);line-height:18px;color:var(--dim)}
207
+ .rv-copyid:hover{color:var(--ink)}
208
+ .rv-what{font-size:13.5px;line-height:1.45;color:var(--ink)}
209
+ .rv-what ul{margin:var(--s0) 0;padding-left:var(--s4)}
210
+ .rv-what p{margin:var(--s0) 0}
211
+ .rv-why{color:var(--dim);font-size:12px}
212
+ .rv-headverdicts{display:flex;flex-wrap:wrap;gap:var(--s1);align-items:center;margin-top:var(--s1)}
213
+ .rv-headverdicts .rv-verdict{padding:0 var(--s2);line-height:24px;font-size:12.5px}
171
214
  .rv-keeps{color:var(--ok);font-size:13px}
172
215
  .rv-facts{display:flex;flex-direction:column}
173
- .rv-fact{display:flex;gap:var(--s2);align-items:center;min-height:24px;font-size:12.5px}
174
- .rv-factl{width:64px;flex:none;color:var(--dim);font-size:11px}
216
+ .rv-fact{display:flex;gap:var(--s2);align-items:center;min-height:22px;font-size:12.5px}
217
+ .rv-factl{width:64px;flex:none;color:var(--dim);font-size:12px}
175
218
  .rv-factv{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
176
219
  .rv-linked{padding:var(--s1) var(--s2);border:1px solid var(--line);border-radius:4px;background:var(--panel)}
177
220
  .rv-linkedrow{display:flex;gap:var(--s2);align-items:center;font-size:12px}
@@ -208,6 +251,16 @@
208
251
  .rv-verdictbar{flex:none;display:flex;gap:var(--s1);padding:var(--s2);border-top:1px solid var(--line);background:var(--panel)}
209
252
  .rv-verdictbar .rv-verdict{flex:1;text-align:center;font-size:14px;padding:var(--s2) var(--s1);color:var(--tone)}
210
253
  .rv-verdictbar .rv-verdict.chosen{color:var(--bg)}
254
+ .rv-phone .rv-views{gap:var(--s3)}
255
+ /* a row on a phone: ▶, the label and the time on one line, the strip and the acts under them */
256
+ .rv-phone .rv-optrow{flex-wrap:wrap;row-gap:var(--s1)}
257
+ .rv-phone .rv-optlabel{flex:1;max-width:none}
258
+ .rv-phone .rv-strip{order:5;flex:1 1 100%;height:32px}
259
+ .rv-phone .rv-optthumb{order:4}
260
+ .rv-phone .rv-optacts{order:6;flex:1 1 100%;justify-content:space-between}
261
+ .rv-phone .rv-stars button{font-size:22px;padding:0 var(--s1)}
262
+ .rv-phone .rv-optplay{width:40px;height:34px}
263
+ .rv-phone .rv-optnote,.rv-phone .rv-opt .rv-word{margin-left:0;width:100%}
211
264
 
212
265
  /* ── the trailer page ── */
213
266
  .tr-cuts{display:flex;flex-direction:column;gap:var(--s2);padding:var(--s3)}
@@ -323,6 +376,13 @@
323
376
  .rv-boardrow .rv-linkedtitle{display:block}
324
377
  .rv-kindview{display:flex;flex-direction:column;gap:var(--s2);margin:var(--s2) 0}
325
378
  .rv-kindtext{font:12px/1.5 ui-monospace,Menlo,monospace;background:var(--panel);border:1px solid var(--line);border-radius:3px;padding:var(--s2) var(--s3);margin:0;white-space:pre-wrap;overflow-wrap:anywhere}
379
+ .rv-part{border:1px solid var(--line);border-radius:4px;padding:var(--s2) var(--s3);margin-top:var(--s2);background:var(--panel)}
380
+ .rv-parthead{display:flex;align-items:center;gap:var(--s2);margin-bottom:var(--s2)}
381
+ .rv-particon{width:20px;height:20px;border-radius:5px;flex:none}
382
+ .rv-partbadge{margin-left:auto;font-size:11px;font-weight:700;border:1px solid;border-radius:3px;padding:0 var(--s1)}
383
+ .rv-partbody{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:var(--s3);align-items:start}
384
+ .rv-partbody img,.rv-partbody video{width:100%;max-height:320px;object-fit:contain;border-radius:6px;display:block}
385
+ .rv-parttext{font-size:13px;line-height:1.45;white-space:pre-wrap;overflow-wrap:anywhere}
326
386
  .rv-anim select{font-size:12px}
327
387
  /* a sound view (review-views.tsx SoundBody): its waveform over the player, a row a side on a pair */
328
388
  .rv-sound{display:flex;flex-direction:column;gap:var(--s2)}