rvlib-shipkit 2.2.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 (140) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/package.json +1 -1
  3. package/src/agent-time/mask.ts +43 -0
  4. package/src/agent-time/render.ts +187 -0
  5. package/src/agent-time/report.ts +335 -0
  6. package/src/agent-time/shape.ts +271 -0
  7. package/src/agent-time/signals.ts +84 -0
  8. package/src/agent-time/store.ts +379 -0
  9. package/src/agent-time/timeline.ts +218 -0
  10. package/src/agent-time/turns.ts +135 -0
  11. package/src/assets/kits/identity-kit.ts +5 -2
  12. package/src/cli/cmd_Commands.ts +9 -0
  13. package/src/cli/cmd_Review.ts +974 -0
  14. package/src/cli/cmd_Sound.ts +209 -0
  15. package/src/cli/cmd_Time.ts +112 -0
  16. package/src/cli/cmd_Trailer.ts +264 -6
  17. package/src/cli/kit/verb.ts +22 -4
  18. package/src/cli.ts +8 -4
  19. package/src/collections/collection-types.ts +6 -1
  20. package/src/collections/providers.ts +3 -0
  21. package/src/collections/review.ts +70 -0
  22. package/src/core/app-icon.ts +10 -6
  23. package/src/core/layout.ts +2 -0
  24. package/src/reflect/prompt-corpus.ts +15 -119
  25. package/src/reflect/transcript.ts +254 -0
  26. package/src/repo-config.ts +3 -0
  27. package/src/review/anim.ts +228 -0
  28. package/src/review/auto.ts +141 -0
  29. package/src/review/format.ts +158 -0
  30. package/src/review/http.ts +23 -0
  31. package/src/review/marks.ts +43 -0
  32. package/src/review/media.ts +45 -0
  33. package/src/review/model.ts +896 -0
  34. package/src/review/project.ts +126 -0
  35. package/src/review/role.ts +48 -0
  36. package/src/review/routes.ts +239 -0
  37. package/src/review/store.ts +1011 -0
  38. package/src/review/threads.ts +237 -0
  39. package/src/review/trailer-desk.ts +385 -0
  40. package/src/review/trailer-model.ts +183 -0
  41. package/src/review/trailer-routes.ts +136 -0
  42. package/src/review/unit.ts +183 -0
  43. package/src/review/watchers.ts +54 -0
  44. package/src/serve/embed.ts +35 -0
  45. package/src/serve/hub-app/activity-view.tsx +1 -1
  46. package/src/serve/hub-app/activity.css +0 -4
  47. package/src/serve/hub-app/admin-svc.ts +1 -1
  48. package/src/serve/hub-app/admin-view.tsx +1 -1
  49. package/src/serve/hub-app/dock-view.tsx +5 -4
  50. package/src/serve/hub-app/hash-route.ts +36 -1
  51. package/src/serve/hub-app/header-bar.tsx +3 -1
  52. package/src/serve/hub-app/home-st.ts +17 -3
  53. package/src/serve/hub-app/home-strip.tsx +3 -2
  54. package/src/serve/hub-app/home.html +1 -0
  55. package/src/serve/hub-app/hub-app.tsx +80 -11
  56. package/src/serve/hub-app/hub.css +83 -14
  57. package/src/serve/hub-app/icons.tsx +15 -0
  58. package/src/serve/hub-app/index.html +1 -0
  59. package/src/serve/hub-app/insights-view.tsx +4 -3
  60. package/src/serve/hub-app/keys.ts +14 -10
  61. package/src/serve/hub-app/layout-st.ts +63 -2
  62. package/src/serve/hub-app/md.ts +32 -0
  63. package/src/serve/hub-app/planned-view.tsx +3 -2
  64. package/src/serve/hub-app/planned.css +0 -1
  65. package/src/serve/hub-app/project-list.css +26 -6
  66. package/src/serve/hub-app/project-list.tsx +46 -10
  67. package/src/serve/hub-app/projects-svc.ts +7 -2
  68. package/src/serve/hub-app/repo-sections.ts +15 -3
  69. package/src/serve/hub-app/repo-st.ts +61 -4
  70. package/src/serve/hub-app/review/clip-st.ts +258 -0
  71. package/src/serve/hub-app/review/kind-view.tsx +240 -0
  72. package/src/serve/hub-app/review/kit.tsx +113 -0
  73. package/src/serve/hub-app/review/review-answer.tsx +269 -0
  74. package/src/serve/hub-app/review/review-board.tsx +46 -0
  75. package/src/serve/hub-app/review/review-list.tsx +156 -0
  76. package/src/serve/hub-app/review/review-page.tsx +96 -0
  77. package/src/serve/hub-app/review/review-st.ts +902 -0
  78. package/src/serve/hub-app/review/review-views.tsx +648 -0
  79. package/src/serve/hub-app/review/trailer-page.tsx +416 -0
  80. package/src/serve/hub-app/review/trailer-st.ts +602 -0
  81. package/src/serve/hub-app/review/trailer-timeline.tsx +207 -0
  82. package/src/serve/hub-app/review.css +389 -0
  83. package/src/serve/hub-app/theme-st.ts +56 -0
  84. package/src/serve/hub-app/tree-build.ts +4 -0
  85. package/src/serve/hub-app/tree-row.tsx +1 -1
  86. package/src/serve/hub-app/tree-svc.ts +4 -0
  87. package/src/serve/hub-app/wiki-st.ts +190 -0
  88. package/src/serve/hub-app/wikis-view.tsx +134 -5
  89. package/src/serve/hub-runtime.ts +9 -7
  90. package/src/serve/project-gutter-route.ts +25 -1
  91. package/src/serve/project-gutter.ts +1 -1
  92. package/src/serve/same-origin.ts +5 -1
  93. package/src/serve/serve-hub.ts +15 -0
  94. package/src/shipkit-files.ts +9 -5
  95. package/src/shot/client.ts +17 -5
  96. package/src/shot/shooter.ts +19 -3
  97. package/src/shot/shot-ask.ts +9 -1
  98. package/src/shot/stepped-clip.ts +53 -0
  99. package/src/trailer/adapter.ts +90 -6
  100. package/src/trailer/build.ts +459 -59
  101. package/src/trailer/ffmpeg.ts +22 -0
  102. package/src/trailer/fonts.ts +55 -0
  103. package/src/trailer/footage.ts +112 -43
  104. package/src/trailer/grammar.ts +864 -127
  105. package/src/trailer/graph.ts +308 -49
  106. package/src/trailer/holes.ts +72 -0
  107. package/src/trailer/load-unit.ts +20 -3
  108. package/src/trailer/look.ts +20 -3
  109. package/src/trailer/motion.ts +207 -0
  110. package/src/trailer/passage.ts +129 -0
  111. package/src/trailer/plan.ts +82 -23
  112. package/src/trailer/prune.ts +204 -0
  113. package/src/trailer/score/compose.ts +256 -0
  114. package/src/trailer/score/dsp.ts +272 -0
  115. package/src/trailer/score/gm-render.ts +43 -0
  116. package/src/trailer/score/gm.ts +90 -0
  117. package/src/trailer/score/midi.ts +143 -0
  118. package/src/trailer/score/palette.ts +188 -0
  119. package/src/trailer/score/pattern.ts +98 -0
  120. package/src/trailer/score/score.ts +228 -0
  121. package/src/trailer/score/sound.ts +123 -0
  122. package/src/trailer/score/synth.ts +383 -0
  123. package/src/trailer/sound/audio.ts +148 -0
  124. package/src/trailer/sound/brand.ts +42 -0
  125. package/src/trailer/sound/comfy.ts +38 -0
  126. package/src/trailer/sound/eleven.ts +72 -0
  127. package/src/trailer/sound/fluid.ts +71 -0
  128. package/src/trailer/sound/gen.ts +139 -0
  129. package/src/trailer/sound/index.ts +60 -0
  130. package/src/trailer/sound/presets.ts +69 -0
  131. package/src/trailer/sound/run.ts +189 -0
  132. package/src/trailer/states.ts +76 -0
  133. package/src/trailer/stitch.ts +92 -9
  134. package/src/trailer/titles.ts +471 -150
  135. package/src/trailer/url.ts +18 -3
  136. package/src/trailer/vocab.ts +34 -4
  137. package/src/trailer/words.ts +275 -0
  138. package/src/wiki/graph-mount.ts +3 -0
  139. package/src/wiki/graph-page.ts +45 -9
  140. package/src/wiki/graph-pages.ts +128 -0
@@ -0,0 +1,902 @@
1
+ // a repo's review page as state (wiki build/shipkit/review.md, The review page): the cards on disk, the open one, the
2
+ // filters, and his draft per card (rectangles, reactions and comments not sent yet), kept in the browser until it is
3
+ // sent. The list is read while the page shows, every 2 s, answered 304 when the store did not move
4
+ import { makeAutoObservable, reaction, runInAction } from 'mobx'
5
+ import {
6
+ asOptions,
7
+ isApproved,
8
+ isChoice,
9
+ isGallery,
10
+ isPair,
11
+ laneOf,
12
+ moveOf,
13
+ reworkOf,
14
+ STARS,
15
+ STATUS_LABEL,
16
+ stateOf,
17
+ unreadBy,
18
+ verdictOf,
19
+ type MediaView,
20
+ type Message,
21
+ type Move,
22
+ type Rect,
23
+ type ReviewItem,
24
+ type ReviewReaction,
25
+ type ReviewStatus,
26
+ type ShownStatus,
27
+ type Side,
28
+ type Spec,
29
+ } from '../../../review/model.ts'
30
+ import { answererOf, chainOf, childrenOf, conversationOf, followedIds, isActive, membersOf, orderedThreads, statusOf, threadOfItem, waits, type Thread } from '../../../review/threads.ts'
31
+ import { reach } from '../reach.ts'
32
+ import type { RepoSt } from '../repo-st.ts'
33
+ import type { Role } from '../../../review/role.ts'
34
+ import { ClipSt } from './clip-st.ts'
35
+
36
+ interface Draft {
37
+ rects: Rect[]
38
+ comment: string
39
+ reactions?: ReviewReaction[]
40
+ /** the option he picked, on a choice card */
41
+ pick?: number
42
+ }
43
+
44
+ /** a live `sk review watch`: its process, the session or lane it keeps, when it started */
45
+ export interface Watcher {
46
+ pid: number
47
+ session: string | null
48
+ lane: string | null
49
+ started: string
50
+ }
51
+
52
+ export interface Listing {
53
+ items: ReviewItem[]
54
+ broken: { id: string; error: string }[]
55
+ }
56
+
57
+ /** the zoom of a view: fit the column (and the window's height), or a share of the image's own pixels */
58
+ export const ZOOMS = ['fit', '1', '1.5', '2'] as const
59
+ export type Zoom = (typeof ZOOMS)[number]
60
+
61
+ /** how a pair of images shows: side by side, the curtain, one over the other, the after alone */
62
+ export type CompareMode = 'side' | 'swipe' | 'onion' | 'mod'
63
+ export const COMPARE_MODES: readonly CompareMode[] = ['side', 'swipe', 'onion', 'mod']
64
+ /** the side a rectangle lands on: the after alone draws on the after */
65
+ export const drawnSide = (mode: CompareMode, drawn: Side): Side => (mode === 'mod' ? 'after' : drawn)
66
+
67
+ /** below this width the page is the phone's: one column at a time, the verdicts in a bar under the thumb */
68
+ /** a repo's review unit as the page reads it: the words of its kinds and topics, its dispatcher and who holds the role */
69
+ export type RepoSpec = Spec & { role: Role | null; sources: string[]; auto: { id: string; every: string }[] }
70
+
71
+ export const PHONE_PX = 768
72
+ /** below this width of the review page itself (the project list and the sidebar already took theirs), `auto` stacks
73
+ * the card over the answer in one column beside the list */
74
+ export const STACK_PX = 900
75
+ /** where the card sits against its answer: on top of it, left of it, or by the page's width */
76
+ export const CARD_PLACES = ['top', 'left', 'auto'] as const
77
+ export type CardPlace = (typeof CARD_PLACES)[number]
78
+ export type PhonePane = 'list' | 'card' | 'answer'
79
+ export type PairSide = 'before' | 'after' | 'swipe'
80
+
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
86
+
87
+ const sameSpot = (r: ReviewReaction, view: number, tile: number | undefined): boolean => r.view === view && r.tile === tile
88
+
89
+ /** what a reload must not lose, under keys of this repo: a blocked storage only loses persistence */
90
+ const store = {
91
+ get(k: string): string | null {
92
+ try {
93
+ return localStorage.getItem(k)
94
+ } catch {
95
+ return null
96
+ }
97
+ },
98
+ put(k: string, v: string | null): void {
99
+ try {
100
+ if (v === null) localStorage.removeItem(k)
101
+ else localStorage.setItem(k, v)
102
+ } catch {
103
+ // as above
104
+ }
105
+ },
106
+ }
107
+
108
+ const draftOf = (s: string | null): Draft | null => {
109
+ try {
110
+ const d = s === null ? null : (JSON.parse(s) as Partial<Draft>)
111
+ if (!d || !Array.isArray(d.rects) || typeof d.comment !== 'string') return null
112
+ return { rects: d.rects, comment: d.comment, ...(Array.isArray(d.reactions) ? { reactions: d.reactions } : {}), ...(typeof d.pick === 'number' ? { pick: d.pick } : {}) }
113
+ } catch {
114
+ return null
115
+ }
116
+ }
117
+
118
+ /** a loader for a polled list: it sends the tag of its last answer, and on « not modified » gives that answer again */
119
+ function unchangedAware<T>(url: () => string): () => Promise<T> {
120
+ let last: { tag: string; value: T } | null = null
121
+ return async () => {
122
+ const res = await reach(url(), { cache: 'no-store', headers: last ? { 'If-None-Match': last.tag } : {} })
123
+ if (res.status === 304 && last) return last.value
124
+ if (!res.ok) throw new Error(`${url()}: HTTP ${res.status} ${await res.text().catch(() => '')}`.trim())
125
+ const value = (await res.json()) as T
126
+ const tag = res.headers.get('ETag')
127
+ last = tag ? { tag, value } : null
128
+ return value
129
+ }
130
+ }
131
+
132
+ async function postJson<T>(url: string, body: unknown): Promise<T> {
133
+ const res = await reach(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) })
134
+ const text = await res.text()
135
+ if (!res.ok) {
136
+ let msg = text
137
+ try {
138
+ msg = (JSON.parse(text) as { error?: string }).error ?? text
139
+ } catch {
140
+ // a plain text answer says it already
141
+ }
142
+ throw new Error(msg || `HTTP ${res.status}`)
143
+ }
144
+ return JSON.parse(text) as T
145
+ }
146
+
147
+ /** the pair's compare mode, the curtain's line and the onion's opacity, shared by every pair of the page */
148
+ export class CompareSt {
149
+ mode: CompareMode = 'side'
150
+ split = 0.5
151
+ opacity = 0.5
152
+ constructor(private readonly key: string) {
153
+ const m = store.get(key)
154
+ if (m && (COMPARE_MODES as readonly string[]).includes(m)) this.mode = m as CompareMode
155
+ makeAutoObservable<this, 'key'>(this, { key: false })
156
+ }
157
+ setMode(m: CompareMode): void {
158
+ this.mode = m
159
+ store.put(this.key, m)
160
+ }
161
+ setSplit(v: number): void {
162
+ this.split = Math.max(0, Math.min(1, v))
163
+ }
164
+ setOpacity(v: number): void {
165
+ this.opacity = Math.max(0, Math.min(1, v))
166
+ }
167
+ }
168
+
169
+ /** the phone's state: which pane shows, « circle » drawing on, the side of a pair */
170
+ export class PhoneSt {
171
+ on = false
172
+ pane: PhonePane = 'list'
173
+ draw = false
174
+ pair: PairSide = 'after'
175
+ constructor() {
176
+ makeAutoObservable(this)
177
+ if (typeof window !== 'undefined' && typeof window.matchMedia === 'function') {
178
+ const mq = window.matchMedia(`(max-width: ${PHONE_PX - 1}px)`)
179
+ this.on = mq.matches
180
+ mq.addEventListener('change', (e) => runInAction(() => (this.on = e.matches)))
181
+ }
182
+ }
183
+ show(p: PhonePane): void {
184
+ this.pane = p
185
+ }
186
+ toggleDraw(): void {
187
+ this.draw = !this.draw
188
+ }
189
+ setPair(s: PairSide): void {
190
+ this.pair = s
191
+ }
192
+ }
193
+
194
+ const isShownStatus = (s: string): s is ShownStatus => Object.hasOwn(STATUS_LABEL, s)
195
+
196
+ export class ReviewSt {
197
+ list: Listing | null = null
198
+ listError: string | null = null
199
+ /** the repo's review unit in words (its kinds, topics, dispatcher and its holder), read when the page starts */
200
+ spec: RepoSpec | null = null
201
+ /** the dispatcher's board in place of the cards */
202
+ board = false
203
+ watchers: Watcher[] = []
204
+ /** each clip view's own clock on the open card, by view index, made on first use */
205
+ private readonly clips = new Map<number, ClipSt>()
206
+ private clipsCard = ''
207
+ selectedId: string | null = null
208
+ query = ''
209
+ subject: string | null = null
210
+ whose: Move | null = null
211
+ status: ShownStatus | null = null
212
+ drafts: Record<string, Draft> = {}
213
+ /** the rectangle of the open card picked on the image, its index */
214
+ rect: number | null = null
215
+ /** the subject rings over the image (M) */
216
+ marksOn = true
217
+ /** the open card's active view, its index: the one last drawn on or picked, whose clip the keys drive */
218
+ view = 0
219
+ zoom: Zoom = 'fit'
220
+ viewZoom: Record<number, Zoom> = {}
221
+ /** the gallery tile shown large across the column, `<view>/<tile>` */
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> = {}
227
+ replies: Record<string, string> = {}
228
+ private reading = new Set<string>()
229
+ /** « hide handled »: only the active threads in the list */
230
+ hideHandled = true
231
+ /** « by lane »: the list grouped under one header per lane */
232
+ byLane = false
233
+ /** the threads whose older versions are unfolded, by head id */
234
+ unfolded: Record<string, boolean> = {}
235
+ busy = false
236
+ error: string | null = null
237
+ /** the card against its answer, his pick, kept with the prefs */
238
+ place: CardPlace = 'auto'
239
+ /** the page is narrower than STACK_PX */
240
+ narrow = false
241
+ readonly compare: CompareSt
242
+ readonly phone = new PhoneSt()
243
+ private timer: ReturnType<typeof setInterval> | null = null
244
+ private readonly loadList: () => Promise<Listing>
245
+ private readonly loadWatchers: () => Promise<Watcher[]>
246
+ private readonly prefix: string
247
+
248
+ /** the page measured its width */
249
+ fit(px: number): void {
250
+ const on = px > 0 && px < STACK_PX
251
+ if (this.narrow !== on) this.narrow = on
252
+ }
253
+ /** the card over its answer in one column: picked, or `auto` on a narrow page */
254
+ get stacked(): boolean {
255
+ return this.place === 'top' || (this.place === 'auto' && this.narrow)
256
+ }
257
+ setPlace(p: CardPlace): void {
258
+ this.place = p
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
+ }
281
+
282
+ constructor(private readonly st: RepoSt) {
283
+ this.prefix = `shipkit.review.${st.key}.`
284
+ this.loadList = unchangedAware(() => st.url('/api/review'))
285
+ this.loadWatchers = unchangedAware(() => st.url('/api/review/watchers'))
286
+ this.compare = new CompareSt(`${this.prefix}compare`)
287
+ try {
288
+ const p = JSON.parse(store.get(`${this.prefix}prefs`) ?? '{}') as { hideHandled?: boolean; byLane?: boolean; zoom?: string; marks?: boolean; place?: string }
289
+ if (p.hideHandled === false) this.hideHandled = false
290
+ if (p.byLane === true) this.byLane = true
291
+ if (p.marks === false) this.marksOn = false
292
+ if ((ZOOMS as readonly string[]).includes(p.zoom ?? '')) this.zoom = p.zoom as Zoom
293
+ if ((CARD_PLACES as readonly string[]).includes(p.place ?? '')) this.place = p.place as CardPlace
294
+ } catch {
295
+ // a broken pref is the default
296
+ }
297
+ makeAutoObservable<this, 'st' | 'clips' | 'clipsCard' | 'reading' | 'timer' | 'loadList' | 'loadWatchers' | 'prefix'>(this, {
298
+ st: false,
299
+ clips: false,
300
+ clipsCard: false,
301
+ reading: false,
302
+ timer: false,
303
+ loadList: false,
304
+ loadWatchers: false,
305
+ prefix: false,
306
+ compare: false,
307
+ phone: false,
308
+ })
309
+ // opening a card reads the agent's messages on its whole thread
310
+ reaction(
311
+ () => (this.openThread ? membersOf(this.openThread) : []).filter((x) => unreadBy(x, 'owner').length > 0).map((x) => x.item.id),
312
+ (ids) => {
313
+ for (const id of ids) void this.markRead(id)
314
+ },
315
+ )
316
+ reaction(
317
+ () => JSON.stringify({ hideHandled: this.hideHandled, byLane: this.byLane, zoom: this.zoom, marks: this.marksOn, place: this.place }),
318
+ (s) => store.put(`${this.prefix}prefs`, s),
319
+ )
320
+ }
321
+
322
+ /** the list and the watchers, read now and every 2 s while the page shows; the unit's words once a start */
323
+ start(): void {
324
+ void this.refresh()
325
+ void fetch(this.st.url('/api/review/spec'))
326
+ .then((r) => (r.ok ? (r.json() as Promise<RepoSpec>) : null))
327
+ .catch(() => null)
328
+ .then((spec) => runInAction(() => (this.spec = spec)))
329
+ if (this.timer === null) this.timer = setInterval(() => void this.refresh(), POLL_MS)
330
+ }
331
+
332
+ stop(): void {
333
+ if (this.timer !== null) clearInterval(this.timer)
334
+ this.timer = null
335
+ }
336
+
337
+ async refresh(): Promise<void> {
338
+ try {
339
+ const [list, watchers] = await Promise.all([this.loadList(), this.loadWatchers().catch(() => this.watchers)])
340
+ runInAction(() => {
341
+ if (list !== this.list) this.list = list
342
+ if (watchers !== this.watchers) this.watchers = watchers
343
+ this.listError = null
344
+ })
345
+ } catch (e) {
346
+ runInAction(() => (this.listError = (e as Error).message))
347
+ }
348
+ }
349
+
350
+ /** the card's folder, or one of its files, shown in the Finder of the machine the hub runs on */
351
+ async reveal(id: string, file?: string): Promise<void> {
352
+ try {
353
+ await postJson(this.st.url(`/api/review/${id}/reveal`), file ? { file } : {})
354
+ } catch (e) {
355
+ runInAction(() => (this.error = (e as Error).message))
356
+ }
357
+ }
358
+
359
+ setBoard(on: boolean): void {
360
+ this.board = on
361
+ }
362
+
363
+ fileUrl(id: string, name: string): string {
364
+ return this.st.url(`/api/review/${id}/${name}`)
365
+ }
366
+
367
+ thumbUrl(x: ReviewItem, name: string): string {
368
+ return /\.(png|jpe?g|webp)$/.test(name) ? this.st.url(`/api/review/${x.item.id}/thumb/${name}`) : this.fileUrl(x.item.id, name)
369
+ }
370
+
371
+ threadImageUrl(id: string, name: string): string {
372
+ return this.st.url(`/api/review/${id}/thread/${name}`)
373
+ }
374
+
375
+ get items(): ReviewItem[] {
376
+ return this.list?.items ?? []
377
+ }
378
+
379
+ get broken(): Listing['broken'] {
380
+ return this.list?.broken ?? []
381
+ }
382
+
383
+ statusOf(x: ReviewItem): ShownStatus {
384
+ return statusOf(x, this.items)
385
+ }
386
+
387
+ get followed(): Set<string> {
388
+ return followedIds(this.items)
389
+ }
390
+
391
+ reworkOf(x: ReviewItem): ReturnType<typeof reworkOf> {
392
+ return reworkOf(x, this.followed.has(x.item.id), new Date())
393
+ }
394
+
395
+ /** the live watches that read his answer on any of these cards */
396
+ listenersOf(cards: readonly ReviewItem[]): Watcher[] {
397
+ return this.watchers.filter((w) =>
398
+ cards.some((x) => (w.session === null || (x.item.origin.by === 'agent' && x.item.origin.session === w.session)) && (w.lane === null || laneOf(x) === w.lane)),
399
+ )
400
+ }
401
+
402
+ get threads(): Thread[] {
403
+ return orderedThreads(this.items)
404
+ }
405
+
406
+ /** what waits on him: the cards waiting for the owner's verdict, and what he raised that asks him */
407
+ get pendingCount(): number {
408
+ return this.threads.filter((t) => waits(t.head) || (t.head.item.origin.by !== 'agent' && this.moveOf(t.head) === 'owner')).length
409
+ }
410
+
411
+ get activeCount(): number {
412
+ return this.threads.filter((t) => isActive(t)).length
413
+ }
414
+
415
+ /** the subject kinds this store holds, for the filter */
416
+ get subjects(): string[] {
417
+ return [...new Set(this.items.map((x) => x.item.subject.kind))].sort()
418
+ }
419
+
420
+ /** the list as the switch, the filters and the search show it: one thread per head */
421
+ get visible(): Thread[] {
422
+ const q = this.query.trim().toLowerCase()
423
+ return this.threads.filter(
424
+ (t) =>
425
+ (!this.hideHandled || isActive(t)) &&
426
+ (this.subject === null || t.head.item.subject.kind === this.subject) &&
427
+ (this.whose === null || this.moveOf(t.head) === this.whose) &&
428
+ (this.status === null || this.statusOf(t.head) === this.status) &&
429
+ (q === '' || membersOf(t).some((x) => `${x.item.title} ${x.item.what ?? ''} ${x.item.id}`.toLowerCase().includes(q))),
430
+ )
431
+ }
432
+
433
+ /** the visible threads under one header per lane; null: the cards with no lane */
434
+ get laneGroups(): { lane: string | null; threads: Thread[] }[] {
435
+ const groups = new Map<string | null, Thread[]>()
436
+ for (const t of this.visible) {
437
+ const lane = laneOf(t.head) ?? null
438
+ groups.set(lane, [...(groups.get(lane) ?? []), t])
439
+ }
440
+ const all = [...groups].map(([lane, threads]) => ({ lane, threads }))
441
+ return [...all.filter((g) => g.lane !== null), ...all.filter((g) => g.lane === null)]
442
+ }
443
+
444
+ get open(): ReviewItem | null {
445
+ return this.items.find((x) => x.item.id === this.selectedId) ?? this.visible[0]?.head ?? this.threads[0]?.head ?? null
446
+ }
447
+
448
+ get openThread(): Thread | null {
449
+ const id = this.open?.item.id
450
+ return id === undefined ? null : threadOfItem(id, this.threads)
451
+ }
452
+
453
+ /** the reports and notes nested in the open card */
454
+ get answered(): ReviewItem[] {
455
+ const t = this.openThread
456
+ const id = this.open?.item.id
457
+ return t && t.versions.some((x) => x.item.id === id) ? t.answers : []
458
+ }
459
+
460
+ get answeredBy(): ReviewItem | null {
461
+ const x = this.open
462
+ return x && x.item.origin.by !== 'agent' ? answererOf(x.item.id, this.items) : null
463
+ }
464
+
465
+ /** the older versions of the open card, the newest of them first */
466
+ get history(): ReviewItem[] {
467
+ const x = this.open
468
+ return x ? chainOf(x, this.items).slice(1) : []
469
+ }
470
+
471
+ get newer(): ReviewItem[] {
472
+ const x = this.open
473
+ return x ? childrenOf(x.item.id, this.items) : []
474
+ }
475
+
476
+ showsVersions(t: Thread): boolean {
477
+ return this.unfolded[t.head.item.id] === true || t.versions.slice(1).some((x) => x.item.id === this.selectedId)
478
+ }
479
+
480
+ fold(headId: string): void {
481
+ this.unfolded = { ...this.unfolded, [headId]: !this.unfolded[headId] }
482
+ }
483
+
484
+ setHideHandled(on: boolean): void {
485
+ this.hideHandled = on
486
+ }
487
+
488
+ setByLane(on: boolean): void {
489
+ this.byLane = on
490
+ }
491
+
492
+ setQuery(q: string): void {
493
+ this.query = q
494
+ }
495
+
496
+ setFilter(f: { subject?: string | null; whose?: Move | null; status?: ShownStatus | null }): void {
497
+ if (f.subject !== undefined) this.subject = f.subject
498
+ if (f.whose !== undefined) this.whose = f.whose
499
+ if (f.status !== undefined) this.status = f.status
500
+ }
501
+
502
+ moveOf(x: ReviewItem): Move {
503
+ return moveOf(stateOf(x, this.followed.has(x.item.id)).state)
504
+ }
505
+
506
+ /** what the form edits: the draft, else the sent feedback, else nothing yet */
507
+ get draft(): Draft {
508
+ const x = this.open
509
+ if (!x) return { rects: [], comment: '' }
510
+ const v = verdictOf(x)
511
+ const sent = v?.reactions
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
+ )
520
+ }
521
+
522
+ get dirty(): boolean {
523
+ const x = this.open
524
+ return x !== null && this.drafts[x.item.id] !== undefined
525
+ }
526
+
527
+ private edit(f: (d: Draft) => Draft): void {
528
+ const x = this.open
529
+ if (!x) return
530
+ const d = this.draft
531
+ const { reactions, ...rest } = f({ ...d, rects: [...d.rects], reactions: [...(d.reactions ?? [])] })
532
+ const next: Draft = reactions && reactions.length > 0 ? { ...rest, reactions } : rest
533
+ this.drafts = { ...this.drafts, [x.item.id]: next }
534
+ store.put(`${this.prefix}draft.${x.item.id}`, JSON.stringify(next))
535
+ }
536
+
537
+ /** opens a card from the page: its address follows */
538
+ pick(id: string): void {
539
+ this.st.showReview(id)
540
+ if (this.phone.on) this.phone.show('card')
541
+ }
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
+
571
+ /** the card the address names; its draft is read again from storage, the truth over every window */
572
+ select(id: string | null): void {
573
+ if (id !== this.selectedId) {
574
+ this.view = 0
575
+ this.viewZoom = {}
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')
580
+ }
581
+ this.selectedId = id
582
+ if (id !== null) {
583
+ const stored = draftOf(store.get(`${this.prefix}draft.${id}`))
584
+ if (stored) this.drafts = { ...this.drafts, [id]: stored }
585
+ else if (this.drafts[id]) {
586
+ const { [id]: _, ...rest } = this.drafts
587
+ this.drafts = rest
588
+ }
589
+ }
590
+ this.rect = null
591
+ this.error = null
592
+ }
593
+
594
+ /** ↑↓ on the page: the next thread head the list shows */
595
+ move(delta: 1 | -1): void {
596
+ const list = this.visible
597
+ const at = list.findIndex((t) => t.head.item.id === this.openThread?.head.item.id)
598
+ const next = list[Math.max(0, Math.min(list.length - 1, at + delta))]
599
+ if (next) this.pick(next.head.item.id)
600
+ }
601
+
602
+ /** N: the next pending thread head after the open one, wrapping */
603
+ nextPending(): void {
604
+ const pend = this.threads.filter((t) => waits(t.head))
605
+ if (pend.length === 0) return
606
+ const at = pend.findIndex((t) => t.head.item.id === this.open?.item.id)
607
+ const next = pend[(at + 1) % pend.length]
608
+ if (next) this.pick(next.head.item.id)
609
+ }
610
+
611
+ /** a rectangle on a view, which becomes the active one; on a clip it keeps the time of the frame shown */
612
+ addRect(r: { x: number; y: number; w: number; h: number }, side: Side | undefined, on: number = this.view): void {
613
+ if (on !== this.view) this.setView(on)
614
+ const view = this.view > 0 ? { view: this.view } : {}
615
+ const t = this.clipKey ? { t: Math.round(this.clip.time * 1000) / 1000 } : {}
616
+ this.edit((d) => ({ ...d, rects: [...d.rects, { ...r, comment: '', ...view, ...(side ? { side } : {}), ...t }] }))
617
+ this.rect = this.draft.rects.length - 1
618
+ }
619
+
620
+ setView(i: number): void {
621
+ this.view = i
622
+ this.rect = null
623
+ }
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
+
632
+ get openView(): MediaView | null {
633
+ const v = this.open?.item.views[this.view]
634
+ return v && !isGallery(v) ? v : null
635
+ }
636
+
637
+ zoomOf(i: number): Zoom {
638
+ return this.viewZoom[i] ?? this.zoom
639
+ }
640
+
641
+ setZoom(z: Zoom): void {
642
+ this.zoom = z
643
+ this.viewZoom = {}
644
+ }
645
+
646
+ setViewZoom(i: number, z: Zoom): void {
647
+ this.viewZoom = { ...this.viewZoom, [i]: z }
648
+ }
649
+
650
+ toggleTile(view: number, tile: number): void {
651
+ const k = `${view}/${tile}`
652
+ this.bigTile = this.bigTile === k ? null : k
653
+ }
654
+
655
+ reactionOf(view: number, tile?: number): ReviewReaction | null {
656
+ return (this.draft.reactions ?? []).find((r) => sameSpot(r, view, tile)) ?? null
657
+ }
658
+
659
+ /** a reaction's toggle: sets that status, or clears it when it is already the one */
660
+ react(view: number, tile: number | undefined, status: ReviewStatus): void {
661
+ const cur = this.reactionOf(view, tile)
662
+ this.putReaction(view, tile, { ...cur, status: cur?.status === status ? undefined : status, comment: cur?.comment ?? '' })
663
+ }
664
+
665
+ setReactionComment(view: number, tile: number | undefined, comment: string): void {
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 })
673
+ }
674
+
675
+ removeReaction(view: number, tile: number | undefined): void {
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')
684
+ }
685
+
686
+ /** the reactions that go to the agent with the verdict, in the card's order */
687
+ get outgoingReactions(): ReviewReaction[] {
688
+ return [...(this.draft.reactions ?? [])].sort((a, b) => a.view - b.view || (a.tile ?? -1) - (b.tile ?? -1))
689
+ }
690
+
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
694
+ this.edit((d) => {
695
+ const rest = (d.reactions ?? []).filter((q) => !sameSpot(q, view, tile))
696
+ return { ...d, reactions: empty ? rest : [...rest, next] }
697
+ })
698
+ }
699
+
700
+ /** the open view's clip identity (a clip, or a sound on a row), empty when it shows images */
701
+ get clipKey(): string {
702
+ const x = this.open
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()
712
+ }
713
+
714
+ /** the clock of a clip view of the open card: every clip view plays and steps on its own */
715
+ clipOf(view: number): ClipSt {
716
+ const id = this.open?.item.id ?? ''
717
+ if (id !== this.clipsCard) {
718
+ this.clips.clear()
719
+ this.clipsCard = id
720
+ }
721
+ let c = this.clips.get(view)
722
+ if (!c) {
723
+ c = new ClipSt()
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)
732
+ this.clips.set(view, c)
733
+ }
734
+ return c
735
+ }
736
+
737
+ get clip(): ClipSt {
738
+ return this.clipOf(this.view)
739
+ }
740
+
741
+ get conversation(): ReturnType<typeof conversationOf> {
742
+ const t = this.openThread
743
+ return t ? conversationOf(t) : []
744
+ }
745
+
746
+ /** a thread holds an agent message he has not read: its card shows a dot */
747
+ unread(t: Thread): boolean {
748
+ return membersOf(t).some((x) => unreadBy(x, 'owner').length > 0)
749
+ }
750
+
751
+ get reply(): string {
752
+ const id = this.open?.item.id
753
+ return id ? (this.replies[id] ?? store.get(`${this.prefix}reply.${id}`) ?? '') : ''
754
+ }
755
+
756
+ setReply(text: string): void {
757
+ const id = this.open?.item.id
758
+ if (!id) return
759
+ this.replies = { ...this.replies, [id]: text }
760
+ store.put(`${this.prefix}reply.${id}`, text || null)
761
+ }
762
+
763
+ /** ⌘Enter or « reply »: his message goes to the open card */
764
+ async sendReply(): Promise<void> {
765
+ const x = this.open
766
+ const text = this.reply.trim()
767
+ if (!x || !text || this.busy) return
768
+ const id = x.item.id
769
+ this.busy = true
770
+ this.error = null
771
+ try {
772
+ await postJson(this.st.url(`/api/review/${id}/reply`), { text })
773
+ runInAction(() => {
774
+ const { [id]: _, ...rest } = this.replies
775
+ this.replies = rest
776
+ store.put(`${this.prefix}reply.${id}`, null)
777
+ })
778
+ void this.refresh()
779
+ } catch (e) {
780
+ runInAction(() => (this.error = (e as Error).message))
781
+ } finally {
782
+ runInAction(() => (this.busy = false))
783
+ }
784
+ }
785
+
786
+ private async markRead(id: string): Promise<void> {
787
+ if (this.reading.has(id)) return
788
+ this.reading.add(id)
789
+ try {
790
+ await postJson(this.st.url(`/api/review/${id}/read`), {})
791
+ void this.refresh()
792
+ } catch (e) {
793
+ runInAction(() => (this.error = (e as Error).message))
794
+ } finally {
795
+ this.reading.delete(id)
796
+ }
797
+ }
798
+
799
+ toggleMarks(): void {
800
+ this.marksOn = !this.marksOn
801
+ }
802
+
803
+ /** a rectangle picked: its view comes on screen, and on a clip its frame, paused */
804
+ pickRect(i: number | null): void {
805
+ this.rect = i
806
+ if (i === null) return
807
+ const r = this.draft.rects[i]
808
+ this.view = r?.view ?? 0
809
+ if (r?.t === undefined || !this.clipKey) return
810
+ this.clip.seek(r.t)
811
+ }
812
+
813
+ setRectComment(i: number, comment: string): void {
814
+ this.edit((d) => ({ ...d, rects: d.rects.map((r, j) => (j === i ? { ...r, comment } : r)) }))
815
+ }
816
+
817
+ removeRect(i: number): void {
818
+ this.edit((d) => ({ ...d, rects: d.rects.filter((_, j) => j !== i) }))
819
+ this.rect = null
820
+ }
821
+
822
+ setComment(comment: string): void {
823
+ this.edit((d) => ({ ...d, comment }))
824
+ }
825
+
826
+ /** back to what was sent */
827
+ dropDraft(): void {
828
+ const x = this.open
829
+ if (!x) return
830
+ const { [x.item.id]: _, ...rest } = this.drafts
831
+ this.drafts = rest
832
+ store.put(`${this.prefix}draft.${x.item.id}`, null)
833
+ this.rect = null
834
+ }
835
+
836
+ /** V, F, R, A: the draft goes to the card's thread as the owner's verdict, through the server */
837
+ async submit(status: ReviewStatus): Promise<void> {
838
+ const x = this.open
839
+ if (!x || this.busy) return
840
+ if (x.item.origin.by !== 'agent') {
841
+ this.error = 'this item was raised, not shown by an agent: a word on it goes in a reply'
842
+ return
843
+ }
844
+ if (x.item.wip) {
845
+ this.error = 'a draft card: the verdict comes once its agent makes it ready; a word goes in a reply'
846
+ return
847
+ }
848
+ const id = x.item.id
849
+ const d = this.draft
850
+ this.busy = true
851
+ this.error = null
852
+ try {
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 })
855
+ runInAction(() => {
856
+ // the verdict shows at once: the list of every card comes back after it
857
+ const r = this.list
858
+ if (r) this.list = { ...r, items: r.items.map((y) => (y.item.id === id ? { ...y, thread: [...y.thread, m] } : y)) }
859
+ const { [id]: _, ...rest } = this.drafts
860
+ this.drafts = rest
861
+ store.put(`${this.prefix}draft.${id}`, null)
862
+ })
863
+ void this.refresh()
864
+ // the tree's count of what waits on him moves at once
865
+ void this.st.cols.poll(true)
866
+ } catch (e) {
867
+ runInAction(() => (this.error = (e as Error).message))
868
+ } finally {
869
+ runInAction(() => (this.busy = false))
870
+ }
871
+ }
872
+
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,
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 {
878
+ const k = e.key.toLowerCase()
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))
890
+ else if (this.clipKey && (e.key === 'ArrowLeft' || e.key === 'ArrowRight')) this.clip.step(e.key === 'ArrowRight' ? 1 : -1)
891
+ else if (e.key === 'ArrowUp' || e.key === 'ArrowDown') this.move(e.key === 'ArrowDown' ? 1 : -1)
892
+ else if (k === 'v') void this.submit('approved')
893
+ else if (k === 'f') void this.submit('approved-with-fix')
894
+ else if (k === 'r') void this.submit('rejected')
895
+ else if (k === 'a') void this.submit('archived')
896
+ else if (k === 'n') this.nextPending()
897
+ else if (k === 'm') this.toggleMarks()
898
+ else if ((e.key === 'Delete' || e.key === 'Backspace') && this.rect !== null) this.removeRect(this.rect)
899
+ else return false
900
+ return true
901
+ }
902
+ }