rvlib-shipkit 2.1.1 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (142) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/package.json +2 -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 +7 -3
  12. package/src/assets/run-assets.ts +18 -4
  13. package/src/checks/SK004-gitignore-policy.check.ts +11 -6
  14. package/src/checks/SK013-one-way-in-policy.check.ts +13 -8
  15. package/src/cli/cmd_Calls.ts +53 -0
  16. package/src/cli/cmd_Commands.ts +163 -43
  17. package/src/cli/cmd_Review.ts +948 -0
  18. package/src/cli/cmd_Shot.ts +63 -0
  19. package/src/cli/cmd_Start.ts +8 -4
  20. package/src/cli/cmd_Time.ts +112 -0
  21. package/src/cli/cmd_Trailer.ts +361 -0
  22. package/src/cli/kit/builtins.ts +40 -0
  23. package/src/cli/kit/calls.ts +177 -0
  24. package/src/cli/kit/families.ts +67 -0
  25. package/src/cli/kit/index.ts +8 -0
  26. package/src/cli/kit/pages-check.ts +36 -0
  27. package/src/cli/kit/project-cli.ts +73 -0
  28. package/src/cli/kit/verb.ts +167 -0
  29. package/src/cli.ts +15 -2
  30. package/src/collections/collection-types.ts +6 -1
  31. package/src/collections/providers.ts +3 -0
  32. package/src/collections/review.ts +70 -0
  33. package/src/core/layout.ts +12 -2
  34. package/src/core/migrate-layout.ts +31 -8
  35. package/src/core/one-way-in-policy.ts +12 -4
  36. package/src/reflect/prompt-corpus.ts +15 -114
  37. package/src/reflect/transcript.ts +254 -0
  38. package/src/review/anim.ts +228 -0
  39. package/src/review/auto.ts +141 -0
  40. package/src/review/format.ts +150 -0
  41. package/src/review/http.ts +23 -0
  42. package/src/review/marks.ts +43 -0
  43. package/src/review/media.ts +45 -0
  44. package/src/review/model.ts +854 -0
  45. package/src/review/project.ts +110 -0
  46. package/src/review/role.ts +48 -0
  47. package/src/review/routes.ts +228 -0
  48. package/src/review/store.ts +1002 -0
  49. package/src/review/threads.ts +237 -0
  50. package/src/review/trailer-desk.ts +385 -0
  51. package/src/review/trailer-model.ts +183 -0
  52. package/src/review/trailer-routes.ts +136 -0
  53. package/src/review/unit.ts +165 -0
  54. package/src/review/watchers.ts +54 -0
  55. package/src/scaffold/cli-skeleton.ts +149 -0
  56. package/src/scaffold/files/agent-index.md +2 -2
  57. package/src/scaffold/files/tsconfig.json +3 -0
  58. package/src/scaffold/files/verify-skill.md +1 -1
  59. package/src/scaffold/project-scaffold.ts +90 -12
  60. package/src/serve/embed.ts +35 -0
  61. package/src/serve/hub-app/activity-view.tsx +1 -1
  62. package/src/serve/hub-app/activity.css +0 -4
  63. package/src/serve/hub-app/dock-view.tsx +5 -4
  64. package/src/serve/hub-app/hash-route.ts +28 -1
  65. package/src/serve/hub-app/header-bar.tsx +3 -1
  66. package/src/serve/hub-app/home-st.ts +10 -2
  67. package/src/serve/hub-app/home-strip.tsx +3 -2
  68. package/src/serve/hub-app/home.html +1 -0
  69. package/src/serve/hub-app/hub-app.tsx +80 -11
  70. package/src/serve/hub-app/hub.css +45 -13
  71. package/src/serve/hub-app/icons.tsx +9 -0
  72. package/src/serve/hub-app/index.html +1 -0
  73. package/src/serve/hub-app/insights-view.tsx +4 -3
  74. package/src/serve/hub-app/keys.ts +14 -10
  75. package/src/serve/hub-app/layout-st.ts +63 -2
  76. package/src/serve/hub-app/planned-view.tsx +3 -2
  77. package/src/serve/hub-app/planned.css +0 -1
  78. package/src/serve/hub-app/project-list.css +22 -4
  79. package/src/serve/hub-app/project-list.tsx +41 -8
  80. package/src/serve/hub-app/projects-svc.ts +4 -0
  81. package/src/serve/hub-app/repo-sections.ts +15 -3
  82. package/src/serve/hub-app/repo-st.ts +45 -3
  83. package/src/serve/hub-app/review/clip-st.ts +208 -0
  84. package/src/serve/hub-app/review/kind-view.tsx +171 -0
  85. package/src/serve/hub-app/review/kit.tsx +113 -0
  86. package/src/serve/hub-app/review/review-answer.tsx +239 -0
  87. package/src/serve/hub-app/review/review-board.tsx +46 -0
  88. package/src/serve/hub-app/review/review-list.tsx +156 -0
  89. package/src/serve/hub-app/review/review-page.tsx +101 -0
  90. package/src/serve/hub-app/review/review-st.ts +779 -0
  91. package/src/serve/hub-app/review/review-views.tsx +503 -0
  92. package/src/serve/hub-app/review/trailer-page.tsx +416 -0
  93. package/src/serve/hub-app/review/trailer-st.ts +602 -0
  94. package/src/serve/hub-app/review/trailer-timeline.tsx +207 -0
  95. package/src/serve/hub-app/review.css +329 -0
  96. package/src/serve/hub-app/theme-st.ts +56 -0
  97. package/src/serve/hub-app/tree-build.ts +4 -0
  98. package/src/serve/hub-app/tree-row.tsx +1 -1
  99. package/src/serve/hub-app/tree-svc.ts +4 -0
  100. package/src/serve/hub-runtime.ts +5 -3
  101. package/src/serve/same-origin.ts +5 -1
  102. package/src/serve/serve-hub.ts +8 -0
  103. package/src/shipkit-files.ts +12 -4
  104. package/src/shot/client.ts +67 -0
  105. package/src/shot/clip-encoder.ts +146 -0
  106. package/src/shot/dom-clip.ts +124 -0
  107. package/src/shot/serve-shot.ts +54 -0
  108. package/src/shot/shooter.ts +343 -0
  109. package/src/shot/shot-ask.ts +189 -0
  110. package/src/shot/shot.shipkit.ts +14 -0
  111. package/src/smoke/run-smoke.ts +2 -2
  112. package/src/start-repo.ts +29 -14
  113. package/src/suite/judge-suites.ts +56 -10
  114. package/src/suite/select.ts +2 -5
  115. package/src/suite/suite-cli.ts +3 -2
  116. package/src/trailer/adapter.ts +78 -0
  117. package/src/trailer/build.ts +603 -0
  118. package/src/trailer/ffmpeg.ts +72 -0
  119. package/src/trailer/footage.ts +173 -0
  120. package/src/trailer/grammar.ts +1290 -0
  121. package/src/trailer/graph.ts +508 -0
  122. package/src/trailer/holes.ts +72 -0
  123. package/src/trailer/load-unit.ts +31 -0
  124. package/src/trailer/look.ts +124 -0
  125. package/src/trailer/passage.ts +129 -0
  126. package/src/trailer/plan.ts +192 -0
  127. package/src/trailer/prune.ts +193 -0
  128. package/src/trailer/score/compose.ts +240 -0
  129. package/src/trailer/score/dsp.ts +272 -0
  130. package/src/trailer/score/palette.ts +188 -0
  131. package/src/trailer/score/pattern.ts +91 -0
  132. package/src/trailer/score/score.ts +214 -0
  133. package/src/trailer/score/sound.ts +112 -0
  134. package/src/trailer/score/synth.ts +361 -0
  135. package/src/trailer/states.ts +76 -0
  136. package/src/trailer/stitch.ts +233 -0
  137. package/src/trailer/titles.ts +379 -0
  138. package/src/trailer/url.ts +108 -0
  139. package/src/trailer/vertical-draw.ts +286 -0
  140. package/src/trailer/vertical.ts +148 -0
  141. package/src/trailer/vocab.ts +59 -0
  142. package/src/trailer/words.ts +254 -0
@@ -13,7 +13,10 @@
13
13
  // labeled sorts (a pick sorts once, the button names the order the list is
14
14
  // in), archived shows or hides the archived repos just above the foot
15
15
  // (names only, no ⌘ number, a row opens the archived page, nothing loads),
16
- // the foot never moving. On the home a section of four lines on top, in the
16
+ // the foot never moving. ⌘B (or the foot's fold button, or a drag of the
17
+ // separator below the list's min) folds the list to its icon rail: the
18
+ // names, the ⌘ hints and the step hint hide, the badge rides the icon's
19
+ // corner, the hover card still names the row. On the home a section of four lines on top, in the
17
20
  // repo rows' style, opens the picker, the overview, the activity and the
18
21
  // plan, each with its shortcut at the line's end, the page shown lit. It
19
22
  // renders from the first paint with the list this browser kept, so a load
@@ -24,14 +27,17 @@ import { useEffect, type CSSProperties } from 'react'
24
27
  import { middleware, OPEN_MS } from './floating.tsx'
25
28
  import { Icon, ICON } from './icons.tsx'
26
29
  import { plainClick } from './home-st.ts'
27
- import { PICKER_KEY, PLACE_KEY, STEP_KEYS } from './keys.ts'
28
- import { cardModel, faceOf, hueOf, SORTS, type Place, type Project, type ProjectsSvc } from './projects-svc.ts'
30
+ import { PICKER_KEY, PLACE_KEY, RAIL_KEY, STEP_KEYS } from './keys.ts'
31
+ import { cardModel, faceOf, hueOf, idVars, SORTS, type Place, type Project, type ProjectsSvc } from './projects-svc.ts'
32
+ import { THEMES, type ThemePick } from './theme-st.ts'
33
+ import { RAIL_ICONS, type RailIcon } from './layout-st.ts'
29
34
 
30
- /** what the list needs of its page: the hub and the home both carry one */
31
- export type ListHost = { projects: ProjectsSvc }
35
+ /** what the list needs of its page: the hub and the home both carry one;
36
+ * the layout, when the page can fold the list to its rail */
37
+ export type ListHost = { projects: ProjectsSvc; layout?: { listRail: boolean; toggleRail(): void; railIcon: RailIcon; cycleRailIcon(): void }; theme?: { pick: ThemePick; cycle(): void } }
32
38
 
33
39
  /** the lines on top of the list, in their order: the picker, the overview,
34
- * then the places, their shortcuts ⌘J ⌘K ⌘L ⌘' top to bottom */
40
+ * then the places, each with its shortcut (keys.ts) */
35
41
  const PLACES: { id: Place | 'picker'; label: string; title: string }[] = [
36
42
  { id: 'picker', label: 'jump to…', title: 'jump to a page, a repo or one of its resources' },
37
43
  { id: 'overview', label: 'overview', title: 'the overview: shipkit, every global process and every repo on this machine' },
@@ -65,7 +71,7 @@ export const Row = observer(({ app, p, i }: { app: ListHost; p: Project; i: numb
65
71
  const dot = s.dotOf(p.name)
66
72
  const warn = s.warnOf(p.name)
67
73
  // the shown repo's row is marked in its own colour
68
- const mark = cur ? ({ '--pjc': s.identityOf(p.name).colorDark } as CSSProperties) : undefined
74
+ const mark = cur ? (idVars(s.identityOf(p.name)) as CSSProperties) : undefined
69
75
  return (
70
76
  <>
71
77
  <button ref={f.refs.setReference} className={cls} aria-label={p.name} style={mark} draggable
@@ -155,8 +161,10 @@ export const Card = ({ p, now }: { p: Project; now: number }) => {
155
161
  export const ProjectList = observer(({ app }: { app: ListHost }) => {
156
162
  useEffect(() => app.projects.start(), [app])
157
163
  const s = app.projects
164
+ const lay = app.layout
165
+ const rail = lay?.listRail === true
158
166
  return (
159
- <nav className="pjlist" aria-label="projects">
167
+ <nav className={`pjlist${rail ? ' rail' : ''}`} aria-label="projects" style={rail ? ({ '--ri': `${RAIL_ICONS[lay!.railIcon]}px` } as CSSProperties) : undefined}>
160
168
  {s.onPlace == null && s.onPicker == null && s.error === '' ? null : (
161
169
  <div className="pjhead">
162
170
  {PLACES.map((pl) => {
@@ -195,6 +203,17 @@ export const ProjectList = observer(({ app }: { app: ListHost }) => {
195
203
  </div>
196
204
  ) : null}
197
205
  <div className="pjfoot">
206
+ {lay == null ? null : (
207
+ <button className="pjfold" aria-pressed={rail} title={`${rail ? 'show the names' : 'fold the list to its icons'} (${RAIL_KEY})`} onClick={() => lay.toggleRail()}>
208
+ <Icon d={ICON.rail} />
209
+ </button>
210
+ )}
211
+ {lay == null || !rail ? null : (
212
+ <button className="pjfold pjsize" aria-label={`icon size: ${lay.railIcon}`} title={`the icons: ${SIZE_WORDS[lay.railIcon]}; click for the next size`} onClick={() => lay.cycleRailIcon()}>
213
+ {lay.railIcon.toUpperCase()}
214
+ </button>
215
+ )}
216
+ {app.theme == null ? null : <ThemeButton t={app.theme} />}
198
217
  <SortButton s={s} />
199
218
  <span className="pjstep" title={`${STEP_KEYS[0]} and ${STEP_KEYS[1]} open the repo above or below, from any page`}>
200
219
  {STEP_KEYS.map((k) => <kbd key={k}>{k}</kbd>)}
@@ -211,6 +230,20 @@ export const ProjectList = observer(({ app }: { app: ListHost }) => {
211
230
  )
212
231
  })
213
232
 
233
+ const SIZE_WORDS: Record<RailIcon, string> = { s: 'small', m: 'medium', l: 'large' }
234
+
235
+ const THEME_ICON: Record<ThemePick, keyof typeof ICON> = { dark: 'moon', light: 'sun', auto: 'auto' }
236
+
237
+ /** the theme: dark, light or auto (the system's), one click to the next */
238
+ const ThemeButton = observer(({ t }: { t: { pick: ThemePick; cycle(): void } }) => {
239
+ const next = THEMES[(THEMES.indexOf(t.pick) + 1) % THEMES.length]
240
+ return (
241
+ <button className="pjfold" aria-label={`theme: ${t.pick}`} title={`theme: ${t.pick}${t.pick === 'auto' ? ' (follows the system)' : ''}; click for ${next}`} onClick={() => t.cycle()}>
242
+ <Icon d={ICON[THEME_ICON[t.pick]]} />
243
+ </button>
244
+ )
245
+ })
246
+
214
247
  /** the words for the order the list is in */
215
248
  const orderWords = (s: ProjectsSvc): string => {
216
249
  const m = s.sortMode
@@ -7,6 +7,10 @@ import type { ArchivedRow, ProjectRow as Project, Release } from '../project-gut
7
7
  import { identityOfName, type Identity } from '../../core/identity-color.ts'
8
8
  import { ownTrouble, reach } from './reach.ts'
9
9
 
10
+ /** a repo's two colours as the vars a `.idc` element or a list row reads,
11
+ * hub.css picking the one for the page's theme */
12
+ export const idVars = (id: Pick<Identity, 'colorDark' | 'colorLight'>): Record<'--idd' | '--idl', string> => ({ '--idd': id.colorDark, '--idl': id.colorLight })
13
+
10
14
  /** the list never says the page's server is gone, even where no machine
11
15
  * card is (a hub on its own): that hub's trouble card says it */
12
16
  const SAID_ELSEWHERE = true
@@ -8,7 +8,7 @@
8
8
  // own has none of the three. The counts read the severity filter, like everything under them,
9
9
  // and come from what the page already holds (the kept screen on a reload), so
10
10
  // none arrives late and moves the tree.
11
- import { RUNS_ID } from '../../collections/collection-types.ts'
11
+ import { REVIEW_COL, RUNS_ID, TRAILER_COL } from '../../collections/collection-types.ts'
12
12
  import type { CheckOwner, HashSel } from './hash-route.ts'
13
13
  import { sectionLabel, type SectionId } from './keys.ts'
14
14
  import type { RepoSt } from './repo-st.ts'
@@ -45,6 +45,8 @@ export function sectionOfSel(sel: HashSel | null, ownerOf: (checkItemId: string)
45
45
  case 'when': return 'when'
46
46
  case 'checks': return sel.from
47
47
  case 'admin': return 'admin'
48
+ case 'review': return 'review'
49
+ case 'trailer': return 'trailer'
48
50
  case 'tests': return 'tests'
49
51
  case 'asset': case 'cat': return 'media'
50
52
  case 'status': return null
@@ -54,6 +56,8 @@ export function sectionOfSel(sel: HashSel | null, ownerOf: (checkItemId: string)
54
56
  if (col === WIKIS) return 'wikis'
55
57
  if (col === 'contracts') return 'contracts'
56
58
  if (col === 'creds') return 'secrets'
59
+ if (col === REVIEW_COL) return 'review'
60
+ if (col === TRAILER_COL) return 'trailer'
57
61
  if (col === 'checks') return sel.t === 'item' ? ownerOf(sel.id) : null
58
62
  return col === 'asks' || col === 'tests' || col === 'reflections' ? col : null
59
63
  }
@@ -87,11 +91,14 @@ export function openSection(app: RepoSt, id: SectionId): void {
87
91
  }
88
92
  case 'wikis': return openWikis(app)
89
93
  case 'admin': return app.showAdmin()
94
+ case 'review': return app.showReview(app.review.selectedId)
95
+ case 'trailer': return app.showTrailer(app.trailer.trailerId, app.trailer.cutId)
90
96
  }
91
97
  }
92
98
 
93
- /** the sections whose page says it is empty, so an empty row still opens it */
94
- const EMPTY_PAGES = new Set<SectionId>(['processes'])
99
+ /** the sections whose page says it is empty, so an empty row still opens it: the review and trailer pages say how a
100
+ * first card or a first cut comes */
101
+ const EMPTY_PAGES = new Set<SectionId>(['processes', 'review', 'trailer'])
95
102
 
96
103
  export function repoSections(app: RepoSt): RepoSection[] {
97
104
  const has = (col: string): boolean => app.cols.colMeta.some((c) => c.id === col)
@@ -106,6 +113,11 @@ export function repoSections(app: RepoSt): RepoSection[] {
106
113
  const out: RepoSection[] = [line('readme', 'the README, what the repo is', none)]
107
114
  const ownReds = (from: CheckOwner): number => res.filter((r) => r.check?.from === from && r.state === 'red').length
108
115
  const unless = (on: boolean, why: string): string | null => (on ? null : why)
116
+ // review and trailer sit right under the readme: the pages he answers
117
+ out.push(line('review', 'the review cards agents send you: approve, fix, reject, circle a detail', counted(res.filter((r) => r.type === 'card').length, 'warn'),
118
+ unless(has(REVIEW_COL), 'no review card yet: an agent sends one with sk review add')))
119
+ out.push(line('trailer', 'the trailer cuts: play one with its timeline, pin a note on any frame', none,
120
+ unless(has(TRAILER_COL), 'no trailer: write trailer/<name>/main.trailer, then sk trailer')))
109
121
  out.push(line('contracts', 'what a release venue asks of this repo, and what it still lacks', counted(reds('contracts'), 'red'), unless(has('contracts'), 'no contract: no release venue applies to this repo')))
110
122
  const noChecks = unless(has('checks'), 'no checks yet: its hub has not answered')
111
123
  out.push(line('shipkit', 'the checks shipkit ships to every repo, one page each', counted(ownReds('shipkit'), 'red'), noChecks))
@@ -21,8 +21,10 @@ import { GitSt } from './git-st.ts'
21
21
  import type { ProcessRow } from '../../home/process-rows.ts'
22
22
  import { TestsSvc } from './tests-svc.ts'
23
23
  import { AdminSvc } from './admin-svc.ts'
24
+ import { ReviewSt } from './review/review-st.ts'
25
+ import { TrailerSt } from './review/trailer-st.ts'
24
26
  import { repoSections } from './repo-sections.ts'
25
- import { sectionKeyLabel } from './keys.ts'
27
+ import { isTreeWalk, sectionKeyLabel } from './keys.ts'
26
28
  import type { Resource } from './hub-types.ts'
27
29
  import type { HomeSt } from './home-st.ts'
28
30
 
@@ -66,6 +68,10 @@ export class RepoSt {
66
68
  tests: TestsSvc
67
69
  /** the admin page: archive behind its confirmation, reload, keep active */
68
70
  admin: AdminSvc
71
+ /** the review page: the cards of the one store and his answers (review/review-st.ts) */
72
+ review: ReviewSt
73
+ /** the trailer page: a cut played with its timeline and his frame notes (review/trailer-st.ts) */
74
+ trailer: TrailerSt
69
75
  sel: Sel = null
70
76
  filter: Filter = 'all'
71
77
  /** sidebar search — live substring filter over asset + collection rows */
@@ -103,7 +109,9 @@ export class RepoSt {
103
109
  this.git = new GitSt(this)
104
110
  this.tests = new TestsSvc(this)
105
111
  this.admin = new AdminSvc(this)
106
- makeAutoObservable<this, 'beat' | 'onceRead'>(this, { onceRead: false, home: false, key: false, base: false, clock: false, media: false, cols: false, tree: false, health: false, readme: false, checks: false, comfy: false, timeline: false, live: false, git: false, tests: false, admin: false, searchEl: false, beat: false })
112
+ this.review = new ReviewSt(this)
113
+ this.trailer = new TrailerSt(this)
114
+ makeAutoObservable<this, 'beat' | 'onceRead'>(this, { onceRead: false, home: false, key: false, base: false, clock: false, media: false, cols: false, tree: false, health: false, readme: false, checks: false, comfy: false, timeline: false, live: false, git: false, tests: false, admin: false, review: false, trailer: false, searchEl: false, beat: false })
107
115
  }
108
116
 
109
117
  /** an absolute hub path ('/api/…', '/media/…', '/hub.json') as this
@@ -175,6 +183,8 @@ export class RepoSt {
175
183
  this.media.start()
176
184
  this.cols.start()
177
185
  this.live.start()
186
+ if (this.sel?.t === 'review') this.review.start()
187
+ if (this.sel?.t === 'trailer') this.trailer.start()
178
188
  }
179
189
 
180
190
  /** stop polling, keep every state: a resume shows the same page */
@@ -185,6 +195,8 @@ export class RepoSt {
185
195
  this.media.stop()
186
196
  this.cols.stop()
187
197
  this.live.stop()
198
+ this.review.stop()
199
+ this.trailer.stop()
188
200
  }
189
201
 
190
202
  dispose(): void {
@@ -285,7 +297,11 @@ export class RepoSt {
285
297
  const next = parseHash(h)
286
298
  // the tool set rides after the asset id (media-svc syncHash), so an
287
299
  // address is the same page whatever tools it carries
288
- if (cur.kind === next.kind && cur.col === next.col && cur.id === next.id) return
300
+ if (cur.kind === next.kind && cur.col === next.col && cur.id === next.id) {
301
+ // the trailer's note and filter ride on its page, as the tool set does on an asset: no history entry
302
+ if (next.kind === 'trailer' && currentHash() !== h) history.replaceState(null, '', `#${h}`)
303
+ return
304
+ }
289
305
  if (this.walking && this.walked) history.replaceState(null, '', `#${h}`)
290
306
  else history.pushState(null, '', `#${h}`)
291
307
  this.walked = this.walking
@@ -295,6 +311,11 @@ export class RepoSt {
295
311
  * lands on the raw wall of text unless it is asked for by name */
296
312
  openResource(r: Resource): void {
297
313
  if (r.type === 'process') return this.showProcesses(r.id.replace(/^process:/, ''))
314
+ if (r.type === 'card') return this.showReview(r.id.replace(/^card:/, ''))
315
+ if (r.type === 'cut') {
316
+ const [trailer = null, cut = null] = r.id.replace(/^cut:/, '').split('/')
317
+ return this.showTrailer(trailer, cut)
318
+ }
298
319
  const assetId = r.id.replace(/^media:/, '')
299
320
  if (r.collection === 'media' && this.media.assets.some((a) => a.id === assetId)) {
300
321
  this.selectAsset(assetId)
@@ -339,6 +360,25 @@ export class RepoSt {
339
360
  showProcesses(id: string | null): void {
340
361
  this.setSel({ t: 'processes', id })
341
362
  }
363
+ /** the review page, `id` the card it opens, null for the first the list shows */
364
+ showReview(id: string | null): void {
365
+ this.review.select(id)
366
+ this.setSel({ t: 'review', id })
367
+ if (this.polling) this.review.start()
368
+ }
369
+ /** a plain key the page shown claims for itself: the review page (V F R A N M, a clip's Space and arrows) and
370
+ * the trailer page (its player keys), as the atelier takes its roll keys. True when the page used it. ↑ ↓ stay
371
+ * the tree's while its cursor walks it, so a walk crosses these sections; a click in the page hands them back */
372
+ pageKey(e: Pick<KeyboardEvent, 'key' | 'shiftKey'>): boolean {
373
+ if (isTreeWalk(e.key) && !e.shiftKey && this.tree.cursor != null) return false
374
+ return (this.sel?.t === 'review' && this.review.key(e)) || (this.sel?.t === 'trailer' && this.trailer.key(e))
375
+ }
376
+ /** the trailer page on a trailer folder and one of its cuts, null for the first */
377
+ showTrailer(trailer: string | null, cut: string | null, note: string | null = null, filter: 'open' | 'all' | null = null): void {
378
+ this.trailer.select(trailer, cut, note, filter)
379
+ this.setSel({ t: 'trailer', trailer, cut, note, filter })
380
+ if (this.polling) this.trailer.start()
381
+ }
342
382
 
343
383
  /** collection view — one card per instance */
344
384
  showCol(id: string): void {
@@ -360,6 +400,8 @@ export class RepoSt {
360
400
  case 'git': return this.showGit()
361
401
  case 'when': return this.showWhen()
362
402
  case 'checks': return this.showChecks(sel.from)
403
+ case 'review': return this.showReview(sel.id)
404
+ case 'trailer': return this.showTrailer(sel.trailer, sel.cut, sel.note ?? null, sel.filter ?? null)
363
405
  }
364
406
  }
365
407
  /** the url decides the page: a reload, back, forward and a pasted link
@@ -0,0 +1,208 @@
1
+ // the one clock of a clip on screen (a card's clip view, a trailer cut): it plays, pauses, seeks and steps every video
2
+ // of the view at once, so a pair of clips stays in sync. A paused time is always the middle of a frame, so both videos
3
+ // of a pair and every rectangle drawn on it agree on the frame. The sound choice and the volume are the page's, kept
4
+ // in the browser
5
+ import { makeAutoObservable, runInAction } from 'mobx'
6
+
7
+ /** the part of a video element the clock drives */
8
+ interface ClipEl {
9
+ currentTime: number
10
+ muted: boolean
11
+ volume: number
12
+ playbackRate: number
13
+ readonly paused: boolean
14
+ play(): Promise<void> | void
15
+ pause(): void
16
+ }
17
+
18
+ /** a follower further than this from the first video, in seconds, is put back on it */
19
+ const DRIFT = 0.06
20
+ const KEY = 'shipkit.review.sound'
21
+
22
+ /** the page's one sound choice and volume, kept for the next visit */
23
+ class Heard {
24
+ on = false
25
+ level = 0.8
26
+ constructor() {
27
+ try {
28
+ const v = JSON.parse(localStorage.getItem(KEY) ?? '{}') as { on?: boolean; level?: number }
29
+ if (typeof v.on === 'boolean') this.on = v.on
30
+ if (typeof v.level === 'number' && v.level > 0 && v.level <= 1) this.level = v.level
31
+ } catch {
32
+ // a blocked storage only loses the kept choice
33
+ }
34
+ makeAutoObservable(this)
35
+ }
36
+ private save(): void {
37
+ try {
38
+ localStorage.setItem(KEY, JSON.stringify({ on: this.on, level: this.level }))
39
+ } catch {
40
+ // as above
41
+ }
42
+ }
43
+ setOn(on: boolean): void {
44
+ this.on = on
45
+ this.save()
46
+ }
47
+ set(level: number): void {
48
+ this.level = Math.max(0, Math.min(1, level))
49
+ this.save()
50
+ }
51
+ }
52
+
53
+ export const heard = new Heard()
54
+
55
+ export class ClipSt {
56
+ /** which clip is on screen, empty when none */
57
+ key = ''
58
+ /** the clip's length in seconds, its frames a second */
59
+ duration = 0
60
+ fps = 20
61
+ paused = false
62
+ /** the audio track on: only the first video is heard */
63
+ sound: boolean
64
+ /** the time on screen, in seconds: exact while paused, the first video's while it plays */
65
+ time = 0
66
+ /** a play the browser refused, shown under the clip */
67
+ error: string | null = null
68
+ /** the playback speed, 1 the clip's own */
69
+ rate = 1
70
+ private els: ClipEl[] = []
71
+ private raf = 0
72
+
73
+ constructor() {
74
+ this.sound = heard.on
75
+ makeAutoObservable<this, 'els' | 'raf' | 'follow'>(this, { els: false, raf: false, follow: false })
76
+ }
77
+
78
+ get volume(): number {
79
+ return heard.level
80
+ }
81
+
82
+ /** another clip on screen: from the start, playing; the same one again changes nothing */
83
+ load(key: string, duration: number, fps: number): void {
84
+ if (key === this.key) return
85
+ this.key = key
86
+ this.duration = duration
87
+ this.fps = fps > 0 ? fps : 20
88
+ this.time = 0
89
+ this.paused = false
90
+ this.error = null
91
+ }
92
+
93
+ /** the frame index of a time */
94
+ frameOf(t: number): number {
95
+ const last = Math.max(0, Math.round(this.duration * this.fps) - 1)
96
+ return Math.max(0, Math.min(last, Math.floor(t * this.fps + 1e-6)))
97
+ }
98
+
99
+ /** the middle of frame `k`, where a seek lands on that frame for sure */
100
+ private centerOf(k: number): number {
101
+ return (k + 0.5) / this.fps
102
+ }
103
+
104
+ /** a video of the view on screen; it returns its release */
105
+ attach(el: ClipEl): () => void {
106
+ this.els.push(el)
107
+ el.muted = !this.sound || this.els[0] !== el
108
+ el.volume = heard.level
109
+ el.playbackRate = this.rate
110
+ el.currentTime = this.paused ? this.time : (this.els[0]?.currentTime ?? this.time)
111
+ if (this.paused) el.pause()
112
+ else this.start(el)
113
+ if (this.raf === 0 && typeof requestAnimationFrame === 'function') this.raf = requestAnimationFrame(this.follow)
114
+ return () => {
115
+ this.els = this.els.filter((e) => e !== el)
116
+ }
117
+ }
118
+
119
+ toggleSound(): void {
120
+ this.hear(!this.sound)
121
+ heard.setOn(this.sound)
122
+ }
123
+
124
+ /** this clip's sound on or off, the kept choice left as it is */
125
+ hear(on: boolean): void {
126
+ this.sound = on
127
+ this.els.forEach((el, i) => (el.muted = !this.sound || i > 0))
128
+ }
129
+
130
+ /** a volume asked is a sound wanted, and none is the sound off */
131
+ setVolume(v: number): void {
132
+ if (v > 0) heard.set(v)
133
+ for (const el of this.els) el.volume = heard.level
134
+ if (this.sound !== v > 0) this.toggleSound()
135
+ }
136
+
137
+ toggle(): void {
138
+ if (this.paused) this.play()
139
+ else this.pause()
140
+ }
141
+
142
+ play(): void {
143
+ this.paused = false
144
+ this.error = null
145
+ for (const el of this.els) this.start(el)
146
+ }
147
+
148
+ /** stops on the frame the first video shows */
149
+ pause(): void {
150
+ this.seek(this.els[0]?.currentTime ?? this.time)
151
+ }
152
+
153
+ /** paused on the frame at `t` */
154
+ seek(t: number): void {
155
+ this.paused = true
156
+ this.time = this.centerOf(this.frameOf(t))
157
+ for (const el of this.els) {
158
+ el.pause()
159
+ el.currentTime = this.time
160
+ }
161
+ }
162
+
163
+ /** to `t`, playing on when it played: a jump while it plays never stops it */
164
+ jump(t: number): void {
165
+ if (this.paused) return this.seek(t)
166
+ const to = Math.max(0, Math.min(this.duration, t))
167
+ this.time = to
168
+ for (const el of this.els) el.currentTime = to
169
+ }
170
+
171
+ setRate(r: number): void {
172
+ this.rate = r
173
+ for (const el of this.els) el.playbackRate = r
174
+ }
175
+
176
+ /** `n` frames on (or back), paused, wrapping round the loop */
177
+ step(n: number): void {
178
+ const count = Math.max(1, Math.round(this.duration * this.fps))
179
+ const from = this.paused ? this.time : (this.els[0]?.currentTime ?? this.time)
180
+ this.seek(this.centerOf((((this.frameOf(from) + n) % count) + count) % count))
181
+ }
182
+
183
+ private start(el: ClipEl): void {
184
+ const p = el.play()
185
+ if (p)
186
+ p.catch((e: Error) => {
187
+ // a pause that lands before the play resolves aborts it: that is the pause working
188
+ if (e.name === 'AbortError') return
189
+ runInAction(() => {
190
+ this.paused = true
191
+ this.error = `play refused: ${e.message}`
192
+ })
193
+ })
194
+ }
195
+
196
+ /** each frame while it plays: the time read from the first video, the others put back on it */
197
+ private follow = (): void => {
198
+ this.raf = 0
199
+ if (this.els.length === 0) return
200
+ const lead = this.els[0]
201
+ if (lead && !this.paused) {
202
+ const t = lead.currentTime
203
+ runInAction(() => (this.time = t))
204
+ for (const el of this.els.slice(1)) if (Math.abs(el.currentTime - t) > DRIFT) el.currentTime = t
205
+ }
206
+ this.raf = requestAnimationFrame(this.follow)
207
+ }
208
+ }
@@ -0,0 +1,171 @@
1
+ // what the repo's review unit says of the open item, drawn from DATA the hub computed (review/project.ts viewOfItem): its
2
+ // kind's facts, links, a text or its change as a diff, and « live », the kind's url in a frame, off until he opens it;
3
+ // and a pair's animation to download. The page never runs the repo's code
4
+ import { makeAutoObservable, runInAction } from 'mobx'
5
+ import { observer } from 'mobx-react-lite'
6
+ import { useEffect } from 'react'
7
+ import { isGallery, isPair, viewName, type ReviewItem } from '../../../review/model.ts'
8
+ import type { Fact, Link, ViewText } from '../../../review/unit.ts'
9
+
10
+ /** where an item's files and routes answer under the repo's base: the review page's own url maker */
11
+ interface Urls {
12
+ fileUrl(id: string, name: string): string
13
+ }
14
+
15
+ export interface KindData {
16
+ facts: Fact[]
17
+ links: Link[]
18
+ live: string | null
19
+ text: ViewText | null
20
+ }
21
+
22
+ /** each item's data view, read once an item opens and again when its thread moves; « live » stays as he left it */
23
+ export class KindViewSt {
24
+ views = new Map<string, KindData | { error: string }>()
25
+ live = false
26
+ anim = { view: 0, mode: 'fade', format: 'mp4', labels: 'en' }
27
+
28
+ constructor(private readonly st: Urls) {
29
+ try {
30
+ this.live = localStorage.getItem('rv.kindLive') === '1'
31
+ const a = JSON.parse(localStorage.getItem('rv.anim') ?? 'null') as Partial<KindViewSt['anim']> | null
32
+ if (a) this.anim = { ...this.anim, ...a, view: 0 }
33
+ } catch {
34
+ // a private window: the defaults
35
+ }
36
+ makeAutoObservable<this, 'st'>(this, { st: false })
37
+ }
38
+
39
+ /** the view of item `x`, read when its stamp (its last message) moved */
40
+ read(x: ReviewItem): void {
41
+ const key = `${x.item.id}@${x.thread.at(-1)?.id ?? ''}`
42
+ if (this.views.has(key)) return
43
+ this.views.set(key, { facts: [], links: [], live: null, text: null })
44
+ void fetch(this.st.fileUrl(x.item.id, 'view'))
45
+ .then(async (r) => (r.ok ? ((await r.json()) as KindData) : { error: ((await r.json().catch(() => ({}))) as { error?: string }).error ?? `HTTP ${r.status}` }))
46
+ .catch((e: Error) => ({ error: e.message }))
47
+ .then((v) => runInAction(() => this.views.set(key, v)))
48
+ }
49
+
50
+ of(x: ReviewItem): KindData | { error: string } | undefined {
51
+ return this.views.get(`${x.item.id}@${x.thread.at(-1)?.id ?? ''}`)
52
+ }
53
+
54
+ toggleLive(): void {
55
+ this.live = !this.live
56
+ try {
57
+ localStorage.setItem('rv.kindLive', this.live ? '1' : '0')
58
+ } catch {
59
+ // kept for this page only
60
+ }
61
+ }
62
+
63
+ setAnim(k: 'view' | 'mode' | 'format' | 'labels', v: string): void {
64
+ this.anim = { ...this.anim, [k]: k === 'view' ? Number(v) : v }
65
+ try {
66
+ localStorage.setItem('rv.anim', JSON.stringify(this.anim))
67
+ } catch {
68
+ // kept for this page only
69
+ }
70
+ }
71
+
72
+ animUrl(x: ReviewItem, view: number): string {
73
+ const a = this.anim
74
+ return `${this.st.fileUrl(x.item.id, 'anim')}?view=${view}&mode=${a.mode}&format=${a.format}&labels=${a.labels}`
75
+ }
76
+ }
77
+
78
+ const held = new WeakMap<Urls, KindViewSt>()
79
+ const kindViewOf = (st: Urls): KindViewSt => {
80
+ let k = held.get(st)
81
+ if (!k) held.set(st, (k = new KindViewSt(st)))
82
+ return k
83
+ }
84
+
85
+ /** a line diff of two texts: each line kept, gone or new */
86
+ function diffLines(before: string, after: string): { t: ' ' | '-' | '+'; line: string }[] {
87
+ const a = before.split('\n')
88
+ const b = after.split('\n')
89
+ const n = a.length
90
+ const m = b.length
91
+ const lcs: number[][] = Array.from({ length: n + 1 }, () => new Array<number>(m + 1).fill(0))
92
+ for (let i = n - 1; i >= 0; i--) for (let j = m - 1; j >= 0; j--) lcs[i]![j] = a[i] === b[j] ? lcs[i + 1]![j + 1]! + 1 : Math.max(lcs[i + 1]![j]!, lcs[i]![j + 1]!)
93
+ const out: { t: ' ' | '-' | '+'; line: string }[] = []
94
+ let i = 0
95
+ let j = 0
96
+ while (i < n && j < m) {
97
+ if (a[i] === b[j]) out.push({ t: ' ', line: a[i++]! }), j++
98
+ else if (lcs[i + 1]![j]! >= lcs[i]![j + 1]!) out.push({ t: '-', line: a[i++]! })
99
+ else out.push({ t: '+', line: b[j++]! })
100
+ }
101
+ while (i < n) out.push({ t: '-', line: a[i++]! })
102
+ while (j < m) out.push({ t: '+', line: b[j++]! })
103
+ return out
104
+ }
105
+
106
+ const TONE = { ' ': 'var(--dim)', '-': 'var(--red, #e5484d)', '+': 'var(--green, #46a758)' } as const
107
+
108
+ /** the kind's data view of the open item, under its facts */
109
+ export const KindView = observer(({ app, x }: { app: Urls; x: ReviewItem }) => {
110
+ const k = kindViewOf(app)
111
+ const last = x.thread.at(-1)?.id
112
+ useEffect(() => k.read(x), [k, x, last])
113
+ const v = k.of(x)
114
+ if (v === undefined) return null
115
+ if ('error' in v) return <div className="rv-err rv-pre">the repo's view of this item: {v.error}</div>
116
+ const t = v.text
117
+ return (
118
+ <div className="rv-kindview">
119
+ {v.facts.length > 0 ? (
120
+ <div className="rv-facts">
121
+ {v.facts.map((f, i) => (
122
+ <div key={i} className="rv-fact"><span className="rv-factl">{f.label}</span><span className="rv-factv">{f.value}</span></div>
123
+ ))}
124
+ </div>
125
+ ) : null}
126
+ {v.links.length > 0 ? (
127
+ <div className="rv-fact">
128
+ {v.links.map((l) => <a key={l.url} href={l.url} target="_blank" rel="noreferrer">{l.label}</a>)}
129
+ </div>
130
+ ) : null}
131
+ {t !== null && 'text' in t ? <pre className="rv-pre rv-kindtext">{t.text}</pre> : null}
132
+ {t !== null && 'before' in t ? (
133
+ <pre className="rv-kindtext">
134
+ {diffLines(t.before, t.after).map((d, i) => (
135
+ <div key={i} style={{ color: TONE[d.t] }}>{d.t} {d.line}</div>
136
+ ))}
137
+ </pre>
138
+ ) : null}
139
+ {v.live !== null ? (
140
+ <div>
141
+ <button className="ghost" onClick={() => k.toggleLive()}>{k.live ? 'hide live' : 'live'}</button>
142
+ {k.live ? <iframe title="live" src={v.live} style={{ width: '100%', aspectRatio: '16 / 10', border: '1px solid var(--line)', borderRadius: 3, marginTop: 6 }} /> : null}
143
+ </div>
144
+ ) : null}
145
+ </div>
146
+ )
147
+ })
148
+
149
+ /** a pair of images as a looping animation to download: the mode, the format, the corner labels */
150
+ export const AnimPanel = observer(({ app, x }: { app: Urls; x: ReviewItem }) => {
151
+ const pairs = x.item.views.flatMap((v, i) => (!isGallery(v) && isPair(v.files) && !v.clip && !v.audio ? [{ i, name: viewName(v, i) }] : []))
152
+ if (pairs.length === 0) return null
153
+ const k = kindViewOf(app)
154
+ const a = k.anim
155
+ const view = pairs.some((p) => p.i === a.view) ? a.view : (pairs[0]?.i ?? 0)
156
+ const pick = (key: 'view' | 'mode' | 'format' | 'labels', options: [string, string][], value: string) => (
157
+ <select value={value} onChange={(e) => k.setAnim(key, e.currentTarget.value)}>
158
+ {options.map(([v, l]) => <option key={v} value={v}>{l}</option>)}
159
+ </select>
160
+ )
161
+ return (
162
+ <div className="rv-fact rv-anim" style={{ flexWrap: 'wrap' }}>
163
+ <span className="rv-factl">animation</span>
164
+ {pairs.length > 1 ? pick('view', pairs.map((p) => [String(p.i), p.name]), String(view)) : null}
165
+ {pick('mode', [['cut', 'cut'], ['fade', 'fade'], ['wipe', 'wipe']], a.mode)}
166
+ {pick('format', [['mp4', 'MP4'], ['gif', 'GIF']], a.format)}
167
+ {pick('labels', [['en', 'EN'], ['fr', 'FR'], ['none', 'no labels']], a.labels)}
168
+ <a href={k.animUrl(x, view)} download>download</a>
169
+ </div>
170
+ )
171
+ })