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,7 +3,7 @@
3
3
  // the shown repo's row marked in its identity colour. Its width is the
4
4
  // panel the page gives it (hub-app.tsx, react-resizable-panels). A row says
5
5
  // three things and no more (faceOf): the open asks as its one badge, a dim
6
- // icon while VS Code does not have the folder open, a thin edge breathing
6
+ // name (never the icon) while VS Code does not have the folder open, a thin edge breathing
7
7
  // while Claude sessions run in it; on the home, a dot while its hub starts
8
8
  // or crashed and a mark while a global process it uses is off. Everything
9
9
  // else is the hover card, placed by floating-ui beside the row. A click
@@ -72,6 +72,7 @@ export const Row = observer(({ app, p, i }: { app: ListHost; p: Project; i: numb
72
72
  const warn = s.warnOf(p.name)
73
73
  // the shown repo's row is marked in its own colour
74
74
  const mark = cur ? (idVars(s.identityOf(p.name)) as CSSProperties) : undefined
75
+ // folded, the rail wears the repo's app icon: its mark on its identity plate
75
76
  return (
76
77
  <>
77
78
  <button ref={f.refs.setReference} className={cls} aria-label={p.name} style={mark} draggable
@@ -95,7 +96,9 @@ export const Row = observer(({ app, p, i }: { app: ListHost; p: Project; i: numb
95
96
  },
96
97
  onDragEnd: () => s.dragEnd(),
97
98
  })}>
98
- <Face p={p} px={ROW_ICON} />
99
+ {app.layout?.listRail === true && p.appIcon != null
100
+ ? <img className="pjapp" src={p.appIcon} alt="" width={ROW_ICON} height={ROW_ICON} draggable={false} />
101
+ : <Face p={p} px={ROW_ICON} />}
99
102
  <span className="pjname">{p.name}</span>
100
103
  {face.badge != null ? <b className="pga">{face.badge}</b> : null}
101
104
  {face.busy ? <i className="pgs" /> : null}
@@ -134,8 +134,9 @@ export function sortNames(list: Project[], mode: SortMode): string[] {
134
134
  export type HubDot = 'starting' | 'crashed' | null
135
135
 
136
136
  /** what the icon face shows, and nothing more: at most ONE badge (the open
137
- * asks, the only fact that needs a human's hands), dim while VS Code does
138
- * not have the folder open, and a pulsing edge while Claude sessions run */
137
+ * asks, the only fact that needs a human's hands), a dim name while VS Code
138
+ * does not have the folder open (the icon stays whole), and a pulsing edge
139
+ * while Claude sessions run */
139
140
  export type Face = { badge: number | null; dim: boolean; busy: boolean }
140
141
  export const faceOf = (p: Project): Face => ({
141
142
  badge: p.asks > 0 ? p.asks : null,
@@ -5,7 +5,7 @@
5
5
  // (hash-route.ts), and setSel is the one place both move together. A repo
6
6
  // that is not shown keeps all of it and polls nothing (resume, pause), and
7
7
  // only the shown one writes the url.
8
- import { makeAutoObservable } from 'mobx'
8
+ import { makeAutoObservable, reaction } from 'mobx'
9
9
  import { ClockSvc } from './clock-svc.ts'
10
10
  import { ColsSvc } from './cols-svc.ts'
11
11
  import { canonicalSel, currentHash, hashFor, parseHash, selOf, type CheckOwner, type HashSel } from './hash-route.ts'
@@ -23,6 +23,7 @@ import { TestsSvc } from './tests-svc.ts'
23
23
  import { AdminSvc } from './admin-svc.ts'
24
24
  import { ReviewSt } from './review/review-st.ts'
25
25
  import { TrailerSt } from './review/trailer-st.ts'
26
+ import { WikiSt } from './wiki-st.ts'
26
27
  import { repoSections } from './repo-sections.ts'
27
28
  import { isTreeWalk, sectionKeyLabel } from './keys.ts'
28
29
  import type { Resource } from './hub-types.ts'
@@ -72,6 +73,8 @@ export class RepoSt {
72
73
  review: ReviewSt
73
74
  /** the trailer page: a cut played with its timeline and his frame notes (review/trailer-st.ts) */
74
75
  trailer: TrailerSt
76
+ /** the wikis page's search and preview */
77
+ wiki: WikiSt
75
78
  sel: Sel = null
76
79
  filter: Filter = 'all'
77
80
  /** sidebar search — live substring filter over asset + collection rows */
@@ -111,7 +114,16 @@ export class RepoSt {
111
114
  this.admin = new AdminSvc(this)
112
115
  this.review = new ReviewSt(this)
113
116
  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 })
117
+ this.wiki = new WikiSt(this)
118
+ 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, wiki: false, searchEl: false, beat: false })
119
+ // a view or a filter changed on the review page: its address follows, the card unchanged
120
+ reaction(
121
+ () => (this.sel?.t === 'review' ? this.review.address : null),
122
+ (q) => {
123
+ const s = this.sel
124
+ if (q !== null && s?.t === 'review' && (s.q ?? '') !== q) this.setSel({ t: 'review', id: s.id, ...(q ? { q } : {}) })
125
+ },
126
+ )
115
127
  }
116
128
 
117
129
  /** an absolute hub path ('/api/…', '/media/…', '/hub.json') as this
@@ -298,8 +310,9 @@ export class RepoSt {
298
310
  // the tool set rides after the asset id (media-svc syncHash), so an
299
311
  // address is the same page whatever tools it carries
300
312
  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}`)
313
+ // the trailer's note and filter, the review's view and filters ride on their page, as the tool set does on an
314
+ // asset: no history entry
315
+ if ((next.kind === 'trailer' || next.kind === 'review') && currentHash() !== h) history.replaceState(null, '', `#${h}`)
303
316
  return
304
317
  }
305
318
  if (this.walking && this.walked) history.replaceState(null, '', `#${h}`)
@@ -361,9 +374,11 @@ export class RepoSt {
361
374
  this.setSel({ t: 'processes', id })
362
375
  }
363
376
  /** the review page, `id` the card it opens, null for the first the list shows */
364
- showReview(id: string | null): void {
377
+ showReview(id: string | null, q?: string): void {
365
378
  this.review.select(id)
366
- this.setSel({ t: 'review', id })
379
+ if (q !== undefined) this.review.applyAddress(q)
380
+ const query = this.review.address
381
+ this.setSel({ t: 'review', id, ...(query ? { q: query } : {}) })
367
382
  if (this.polling) this.review.start()
368
383
  }
369
384
  /** 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
@@ -400,7 +415,7 @@ export class RepoSt {
400
415
  case 'git': return this.showGit()
401
416
  case 'when': return this.showWhen()
402
417
  case 'checks': return this.showChecks(sel.from)
403
- case 'review': return this.showReview(sel.id)
418
+ case 'review': return this.showReview(sel.id, sel.q ?? '')
404
419
  case 'trailer': return this.showTrailer(sel.trailer, sel.cut, sel.note ?? null, sel.filter ?? null)
405
420
  }
406
421
  }
@@ -1,7 +1,8 @@
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
1
+ // the one clock of a clip on screen (a card's clip view, a sound, a trailer cut): it plays, pauses, seeks and steps
2
+ // every video of the view at once, so a pair of clips stays in sync. A paused time is always the middle of a frame, so
3
+ // both videos of a pair and every rectangle drawn on it agree on the frame. The sound choice and the volume are the
4
+ // page's, kept in the browser. One thing plays at a time on the whole page (`solo`): any clock or player that starts
5
+ // pauses the one playing
5
6
  import { makeAutoObservable, runInAction } from 'mobx'
6
7
 
7
8
  /** the part of a video element the clock drives */
@@ -52,6 +53,45 @@ class Heard {
52
53
 
53
54
  export const heard = new Heard()
54
55
 
56
+ /** what plays now, page wide: anything that starts pauses it first */
57
+ interface Playing {
58
+ pause(): void
59
+ }
60
+ let playing: Playing | null = null
61
+ /** the clock each media element of the page plays under; an element no clock drives is its own */
62
+ const owners = new WeakMap<object, Playing>()
63
+
64
+ export const solo = {
65
+ /** `p` starts: the one playing before it pauses */
66
+ claim(p: Playing): void {
67
+ const before = playing
68
+ playing = p
69
+ if (before && before !== p) before.pause()
70
+ },
71
+ /** `p` stopped or left the page */
72
+ release(p: Playing): void {
73
+ if (playing === p) playing = null
74
+ },
75
+ }
76
+
77
+ // every audio or video of the page that starts (a sound's own player, the atelier's rolls) claims under its clock, so
78
+ // the rule holds for players no clock drives
79
+ if (typeof document !== 'undefined')
80
+ document.addEventListener(
81
+ 'play',
82
+ (e) => {
83
+ const el = e.target
84
+ if (!(el instanceof HTMLMediaElement)) return
85
+ let me = owners.get(el)
86
+ if (!me) {
87
+ me = { pause: () => el.pause() }
88
+ owners.set(el, me)
89
+ }
90
+ solo.claim(me)
91
+ },
92
+ true,
93
+ )
94
+
55
95
  export class ClipSt {
56
96
  /** which clip is on screen, empty when none */
57
97
  key = ''
@@ -79,14 +119,14 @@ export class ClipSt {
79
119
  return heard.level
80
120
  }
81
121
 
82
- /** another clip on screen: from the start, playing; the same one again changes nothing */
83
- load(key: string, duration: number, fps: number): void {
122
+ /** another clip on screen: from the start, playing unless `still`; the same one again changes nothing */
123
+ load(key: string, duration: number, fps: number, still = false): void {
84
124
  if (key === this.key) return
85
125
  this.key = key
86
126
  this.duration = duration
87
127
  this.fps = fps > 0 ? fps : 20
88
128
  this.time = 0
89
- this.paused = false
129
+ this.paused = still
90
130
  this.error = null
91
131
  }
92
132
 
@@ -104,6 +144,7 @@ export class ClipSt {
104
144
  /** a video of the view on screen; it returns its release */
105
145
  attach(el: ClipEl): () => void {
106
146
  this.els.push(el)
147
+ owners.set(el, this)
107
148
  el.muted = !this.sound || this.els[0] !== el
108
149
  el.volume = heard.level
109
150
  el.playbackRate = this.rate
@@ -113,9 +154,16 @@ export class ClipSt {
113
154
  if (this.raf === 0 && typeof requestAnimationFrame === 'function') this.raf = requestAnimationFrame(this.follow)
114
155
  return () => {
115
156
  this.els = this.els.filter((e) => e !== el)
157
+ if (this.els.length === 0) solo.release(this)
116
158
  }
117
159
  }
118
160
 
161
+ /** played to be heard: its sound on, from where it stands */
162
+ listen(): void {
163
+ this.hear(true)
164
+ this.play()
165
+ }
166
+
119
167
  toggleSound(): void {
120
168
  this.hear(!this.sound)
121
169
  heard.setOn(this.sound)
@@ -142,6 +190,7 @@ export class ClipSt {
142
190
  play(): void {
143
191
  this.paused = false
144
192
  this.error = null
193
+ solo.claim(this)
145
194
  for (const el of this.els) this.start(el)
146
195
  }
147
196
 
@@ -181,6 +230,7 @@ export class ClipSt {
181
230
  }
182
231
 
183
232
  private start(el: ClipEl): void {
233
+ solo.claim(this)
184
234
  const p = el.play()
185
235
  if (p)
186
236
  p.catch((e: Error) => {
@@ -1,11 +1,13 @@
1
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
2
+ // kind's facts, links, a text or its change as a diff, its parts (a card each: logo, media beside the text, its change),
3
+ // and « live », the kind's url in a frame, off until he opens it; and a pair's animation to download. The page never
4
+ // runs the repo's code
4
5
  import { makeAutoObservable, runInAction } from 'mobx'
5
6
  import { observer } from 'mobx-react-lite'
6
7
  import { useEffect } from 'react'
7
8
  import { isGallery, isPair, viewName, type ReviewItem } from '../../../review/model.ts'
8
- import type { Fact, Link, ViewText } from '../../../review/unit.ts'
9
+ import type { PartData } from '../../../review/project.ts'
10
+ import type { Fact, Link, PartTone, ViewText } from '../../../review/unit.ts'
9
11
 
10
12
  /** where an item's files and routes answer under the repo's base: the review page's own url maker */
11
13
  interface Urls {
@@ -17,6 +19,7 @@ export interface KindData {
17
19
  links: Link[]
18
20
  live: string | null
19
21
  text: ViewText | null
22
+ parts: PartData[]
20
23
  }
21
24
 
22
25
  /** each item's data view, read once an item opens and again when its thread moves; « live » stays as he left it */
@@ -40,7 +43,7 @@ export class KindViewSt {
40
43
  read(x: ReviewItem): void {
41
44
  const key = `${x.item.id}@${x.thread.at(-1)?.id ?? ''}`
42
45
  if (this.views.has(key)) return
43
- this.views.set(key, { facts: [], links: [], live: null, text: null })
46
+ this.views.set(key, { facts: [], links: [], live: null, text: null, parts: [] })
44
47
  void fetch(this.st.fileUrl(x.item.id, 'view'))
45
48
  .then(async (r) => (r.ok ? ((await r.json()) as KindData) : { error: ((await r.json().catch(() => ({}))) as { error?: string }).error ?? `HTTP ${r.status}` }))
46
49
  .catch((e: Error) => ({ error: e.message }))
@@ -82,10 +85,8 @@ const kindViewOf = (st: Urls): KindViewSt => {
82
85
  return k
83
86
  }
84
87
 
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')
88
+ /** a diff of two token lists: each token kept, gone or new */
89
+ function diffSeq(a: readonly string[], b: readonly string[]): { t: ' ' | '-' | '+'; line: string }[] {
89
90
  const n = a.length
90
91
  const m = b.length
91
92
  const lcs: number[][] = Array.from({ length: n + 1 }, () => new Array<number>(m + 1).fill(0))
@@ -103,6 +104,12 @@ function diffLines(before: string, after: string): { t: ' ' | '-' | '+'; line: s
103
104
  return out
104
105
  }
105
106
 
107
+ /** a line diff of two texts: each line kept, gone or new */
108
+ const diffLines = (before: string, after: string) => diffSeq(before.split('\n'), after.split('\n'))
109
+
110
+ /** a word diff of two texts, the spaces kept as tokens so each side reads as written */
111
+ const diffWords = (before: string, after: string) => diffSeq(before.split(/(\s+)/), after.split(/(\s+)/))
112
+
106
113
  const TONE = { ' ': 'var(--dim)', '-': 'var(--red, #e5484d)', '+': 'var(--green, #46a758)' } as const
107
114
 
108
115
  /** the kind's data view of the open item, under its facts */
@@ -125,7 +132,7 @@ export const KindView = observer(({ app, x }: { app: Urls; x: ReviewItem }) => {
125
132
  ) : null}
126
133
  {v.links.length > 0 ? (
127
134
  <div className="rv-fact">
128
- {v.links.map((l) => <a key={l.url} href={l.url} target="_blank" rel="noreferrer">{l.label}</a>)}
135
+ {v.links.map((l) => <LinkTo key={l.url} l={l} />)}
129
136
  </div>
130
137
  ) : null}
131
138
  {t !== null && 'text' in t ? <pre className="rv-pre rv-kindtext">{t.text}</pre> : null}
@@ -136,6 +143,7 @@ export const KindView = observer(({ app, x }: { app: Urls; x: ReviewItem }) => {
136
143
  ))}
137
144
  </pre>
138
145
  ) : null}
146
+ {v.parts.map((p, i) => <PartCard key={i} app={app} x={x} n={i} p={p} />)}
139
147
  {v.live !== null ? (
140
148
  <div>
141
149
  <button className="ghost" onClick={() => k.toggleLive()}>{k.live ? 'hide live' : 'live'}</button>
@@ -146,6 +154,67 @@ export const KindView = observer(({ app, x }: { app: Urls; x: ReviewItem }) => {
146
154
  )
147
155
  })
148
156
 
157
+ /** a link: a page of the tool that frames the hub is posted to it, any other opens in a new tab */
158
+ const LinkTo = ({ l }: { l: Link }) => {
159
+ const framed = l.host === true && window.parent !== window
160
+ return (
161
+ <a
162
+ href={l.url}
163
+ target="_blank"
164
+ rel="noreferrer"
165
+ onClick={(e) => {
166
+ if (!framed) return
167
+ e.preventDefault()
168
+ window.parent.postMessage({ shipkit: 'open', url: l.url }, location.origin)
169
+ }}
170
+ >
171
+ {l.label}
172
+ </a>
173
+ )
174
+ }
175
+
176
+ const BADGE: Record<PartTone, string> = { new: 'var(--green, #46a758)', changed: 'var(--orange, #e5932d)', gone: 'var(--red, #e5484d)', same: 'var(--dim)' }
177
+ const GONE = { background: 'color-mix(in srgb, var(--red, #e5484d) 18%, transparent)', textDecoration: 'line-through' }
178
+ const ADDED = { background: 'color-mix(in srgb, var(--green, #46a758) 20%, transparent)' }
179
+
180
+ /** one side of a word diff: what stayed and what that side alone has, marked */
181
+ const Side = ({ before, after, side }: { before: string; after: string; side: '-' | '+' }) => (
182
+ <div className="rv-pre rv-parttext">
183
+ {diffWords(before, after)
184
+ .filter((d) => d.t === ' ' || d.t === side)
185
+ .map((d, i) => <span key={i} style={d.t === ' ' ? undefined : side === '-' ? GONE : ADDED}>{d.line}</span>)}
186
+ </div>
187
+ )
188
+
189
+ /** a part of the subject as a card: its logo and name, its change, its media beside its text or the text's change */
190
+ const PartCard = ({ app, x, n, p }: { app: Urls; x: ReviewItem; n: number; p: PartData }) => {
191
+ const t = p.text
192
+ const src = app.fileUrl(x.item.id, `part-${n}`)
193
+ return (
194
+ <div className="rv-part">
195
+ <div className="rv-parthead">
196
+ {p.icon ? <img className="rv-particon" src={p.icon} alt="" /> : null}
197
+ <b>{p.label}</b>
198
+ {p.detail ? <span className="rv-dim"> · {p.detail}</span> : null}
199
+ {p.badge ? <span className="rv-partbadge" style={{ color: BADGE[p.badge.tone], borderColor: BADGE[p.badge.tone] }}>{p.badge.text}</span> : null}
200
+ </div>
201
+ <div className="rv-partbody">
202
+ {p.media === 'video' ? <video src={src} muted loop autoPlay playsInline controls /> : p.media === 'image' ? <img src={src} alt="" /> : null}
203
+ {'text' in t ? (
204
+ <div className="rv-pre rv-parttext">{t.text}</div>
205
+ ) : t.before === t.after ? (
206
+ <div className="rv-pre rv-parttext">{t.after}</div>
207
+ ) : (
208
+ <>
209
+ <div><div className="rv-factl">before</div><Side before={t.before} after={t.after} side="-" /></div>
210
+ <div><div className="rv-factl">after</div><Side before={t.before} after={t.after} side="+" /></div>
211
+ </>
212
+ )}
213
+ </div>
214
+ </div>
215
+ )
216
+ }
217
+
149
218
  /** a pair of images as a looping animation to download: the mode, the format, the corner labels */
150
219
  export const AnimPanel = observer(({ app, x }: { app: Urls; x: ReviewItem }) => {
151
220
  const pairs = x.item.views.flatMap((v, i) => (!isGallery(v) && isPair(v.files) && !v.clip && !v.audio ? [{ i, name: viewName(v, i) }] : []))
@@ -1,9 +1,11 @@
1
- // the review page's right column: the status line, the card's facts, the linked cards, what goes to the agent, the
2
- // verdicts (the phone's in a bar under the thumb), then the thread's conversation and his reply box
1
+ // the review page's right column: the card's header (title, state pill, what), a kind's facts, the linked cards, what
2
+ // goes to the agent, the verdicts (the phone's in a bar under the thumb), then the thread's conversation and his reply
3
+ // box. When the page reads in one column the header sits over the views instead (review-views.tsx)
3
4
  import { observer } from 'mobx-react-lite'
4
- import { aboutText, placeText, STATUS_LABEL, stateOf, verdictOf, viewName, withdrawnOf, type ReviewItem, type ReviewStatus, type ShownStatus } from '../../../review/model.ts'
5
+ import { useState } from 'react'
6
+ import { laneOf, placeText, STATUS_LABEL, stateOf, verdictOf, viewName, withdrawnOf, type ReviewItem, type ReviewStatus } from '../../../review/model.ts'
5
7
  import { renderMd } from '../md.ts'
6
- import { AutoTag, blurOnEscape, cap, Empty, LaneTag, Listeners, REACTIONS, Rework, secs, short, SIDE_LABEL, ThumbOf, toneOf, VERDICTS, when } from './kit.tsx'
8
+ import { AutoTag, blurOnEscape, Empty, LaneTag, Listeners, REACTIONS, Rework, secs, short, SIDE_LABEL, ThumbOf, toneOf, VERDICTS } from './kit.tsx'
7
9
  import { AnimPanel, KindView } from './kind-view.tsx'
8
10
  import type { ReviewSt } from './review-st.ts'
9
11
 
@@ -11,21 +13,14 @@ export const ReviewForm = observer(({ rv }: { rv: ReviewSt }) => {
11
13
  const x = rv.open
12
14
  if (!x) return <Empty text="The open card, its comment and its answer." />
13
15
  const r = x.item
14
- const status = rv.statusOf(x)
15
16
  const draft = rv.draft
16
17
  const sent = verdictOf(x)
18
+ // in one column the header and the verdicts sit over the views
19
+ const headAbove = rv.headInViews
20
+ const verdictsAbove = rv.stacked && !rv.phone.on
17
21
  return (
18
22
  <div className="rv-form">
19
- <StatusLine rv={rv} x={x} status={status} />
20
- <h3 className="rv-title">{r.title}</h3>
21
- {r.views.length > 0 && r.what ? <div className="md rv-what" dangerouslySetInnerHTML={{ __html: renderMd(r.what) }} /> : null}
22
- {r.keeps ? <div className="rv-keeps"><b>keeps:</b> {r.keeps}</div> : null}
23
- {r.why ? <div className="rv-dim rv-pre">another subject: {r.why}</div> : null}
24
- <div className="rv-facts">
25
- <Fact label="subject">{aboutText(r, rv.spec ?? undefined)}</Fact>
26
- {r.commit ? <Fact label="commit"><span className="rv-mono">{r.commit}</span></Fact> : null}
27
- <Fact label="id"><span className="rv-mono rv-dim">{r.id}</span></Fact>
28
- </div>
23
+ {headAbove ? null : <CardHead rv={rv} x={x} verdicts={false} />}
29
24
  <KindView app={rv} x={x} />
30
25
  <AnimPanel app={rv} x={x} />
31
26
  {rv.newer.map((y) => <Linked key={y.item.id} rv={rv} label="replaced by" id={y.item.id} item={y} />)}
@@ -37,8 +32,8 @@ export const ReviewForm = observer(({ rv }: { rv: ReviewSt }) => {
37
32
  <Outgoing rv={rv} />
38
33
  <label className="rv-label">Comment</label>
39
34
  <textarea className="rv-comment" rows={3} placeholder="the overall word" value={draft.comment} onKeyDown={blurOnEscape} onChange={(e) => rv.setComment(e.currentTarget.value)} />
40
- <div className="rv-listenrow"><span className="rv-dim">who reads your answer</span><Listeners rv={rv} cards={[x]} /></div>
41
- {!rv.phone.on && !r.wip && r.origin.by === 'agent' ? (
35
+ {verdictsAbove ? null : <div className="rv-listenrow"><span className="rv-dim">who reads your answer</span><Listeners rv={rv} cards={[x]} /></div>}
36
+ {!rv.phone.on && !verdictsAbove && !r.wip && r.origin.by === 'agent' ? (
42
37
  <div className="rv-verdicts">
43
38
  {VERDICTS.map((v) => <VerdictButton key={v.status} v={v} sent={sent?.status} busy={rv.busy} send={() => void rv.submit(v.status)} />)}
44
39
  </div>
@@ -55,60 +50,84 @@ export const ReviewForm = observer(({ rv }: { rv: ReviewSt }) => {
55
50
  )
56
51
  })
57
52
 
58
- const Fact = ({ label, children }: { label: string; children: React.ReactNode }) => (
59
- <div className="rv-fact"><span className="rv-factl">{label}</span><span className="rv-factv">{children}</span></div>
60
- )
61
-
62
53
  const RAISED_NOW: Record<string, string> = {
63
54
  new: 'waiting for a dispatcher',
64
55
  question: 'a question for you',
65
56
  blocked: 'blocked: it waits on your decision',
66
57
  }
67
58
 
68
- /** the answer at a glance, in its status colour: the verdict sent and its time, or that it waits */
69
- const StatusLine = observer(({ rv, x, status }: { rv: ReviewSt; x: ReviewItem; status: ShownStatus }) => {
70
- const fb = verdictOf(x)
71
- const withdrawn = withdrawnOf(x)
59
+ /** where the item stands, in a few words beside its pill: when it was sent or answered, who holds a raised item */
60
+ function stateWords(x: ReviewItem): string {
72
61
  const o = x.item.origin
73
62
  if (o.by !== 'agent') {
74
63
  const st = stateOf(x, false)
75
- const by = o.by === 'player' ? `by ${o.player}` : o.by === 'auto' ? `by the auto rule ${o.rule}` : 'by you'
64
+ const by = o.by === 'player' ? `by ${o.player}` : o.by === 'auto' ? `by the rule ${o.rule}` : 'by you'
76
65
  const now =
77
- st.state === 'open' ? `sent to ${st.topic ?? 'a topic'}, waiting for an agent`
66
+ st.state === 'open' ? `sent to ${st.topic ?? 'a topic'}`
78
67
  : st.state === 'claimed' ? `taken by ${st.worker ?? 'an agent'}`
79
68
  : st.state === 'closed' ? (st.why === 'wontfix' ? 'closed, not done' : st.why === 'superseded' ? 'replaced by a card' : st.why === 'rated' ? 'rated' : 'done')
80
69
  : RAISED_NOW[st.state] ?? st.state
81
- return (
82
- <div className={`rv-status ${toneOf(status)}`}>
83
- <div className="rv-statushead">{cap(`raised ${by} · ${now}`)} {o.by === 'auto' ? <><LaneTag lane={o.lane} /><AutoTag rule={o.rule} /></> : null}</div>
84
- <div className="rv-dim">on {when(x.item.created)}</div>
85
- <Rework rv={rv} x={x} />
86
- </div>
87
- )
70
+ return `raised ${by} ${short(x.item.created)} · ${now}`
88
71
  }
89
- const head = fb
90
- ? `Your answer: ${cap(STATUS_LABEL[fb.status])}, ${short(fb.at)}`
91
- : status === 'superseded'
92
- ? 'Replaced by a newer version'
93
- : withdrawn !== undefined
94
- ? `Withdrawn by the agent: ${withdrawn}`
95
- : x.item.wip
96
- ? 'In progress: the agent works on it'
97
- : 'Waiting for your answer'
98
- const sub = fb ? (fb.seen ? 'read by the agent' : 'not read by the agent yet') : `sent on ${when(x.item.created)}`
72
+ const fb = verdictOf(x)
73
+ if (fb) return `answered ${short(fb.at)} · ${fb.seen ? 'read by the agent' : 'not read yet'}`
74
+ const withdrawn = withdrawnOf(x)
75
+ if (withdrawn !== undefined) return `withdrawn: ${withdrawn}`
76
+ return x.item.wip ? 'the agent works on it' : `sent ${short(x.item.created)}`
77
+ }
78
+
79
+ /** the card's header, for every card: the title, one tight line (the state as the list's pill, the lane, when, the
80
+ * commit, the id to copy), then what the agent says. No subject row: the list's row names it, a kind's facts say the
81
+ * rest; no id row: the url carries it. With `verdicts`, the four verdicts under it (the page in one column) */
82
+ export const CardHead = observer(({ rv, x, verdicts }: { rv: ReviewSt; x: ReviewItem; verdicts: boolean }) => {
83
+ const r = x.item
84
+ const status = rv.statusOf(x)
85
+ const sent = verdictOf(x)
86
+ const lane = laneOf(x)
99
87
  return (
100
- <div className={`rv-status ${toneOf(status)}`}>
101
- <div className="rv-statushead">{head}</div>
102
- <div className="rv-dim">{sub}</div>
88
+ <div className="rv-head">
89
+ <h3 className="rv-title">{r.title}</h3>
90
+ <div className="rv-meta">
91
+ <span className={`rv-badge ${toneOf(status)}`}>{STATUS_LABEL[status]}</span>
92
+ {lane ? <LaneTag lane={lane} /> : null}
93
+ {r.origin.by === 'auto' ? <AutoTag rule={r.origin.rule} /> : null}
94
+ <span>{stateWords(x)}</span>
95
+ {r.commit ? <span className="rv-mono" title={`commit ${r.commit}`}>@{r.commit.slice(0, 9)}</span> : null}
96
+ <CopyId id={r.id} />
97
+ </div>
103
98
  <Rework rv={rv} x={x} />
99
+ {r.views.length > 0 && r.what ? <div className="md rv-what" dangerouslySetInnerHTML={{ __html: renderMd(r.what) }} /> : null}
100
+ {r.keeps ? <div className="rv-keeps"><b>keeps:</b> {r.keeps}</div> : null}
101
+ {r.why ? <div className="rv-pre rv-why">another subject: {r.why}</div> : null}
102
+ {verdicts && !r.wip && r.origin.by === 'agent' ? (
103
+ <div className="rv-headverdicts">
104
+ {VERDICTS.map((v) => <VerdictButton key={v.status} v={v} sent={sent?.status} busy={rv.busy} send={() => void rv.submit(v.status)} short />)}
105
+ <Listeners rv={rv} cards={[x]} />
106
+ </div>
107
+ ) : null}
104
108
  </div>
105
109
  )
106
110
  })
107
111
 
112
+ /** the id, copied on a click: an agent names a card by it */
113
+ const CopyId = ({ id }: { id: string }) => {
114
+ const [done, setDone] = useState(false)
115
+ return (
116
+ <button className="rv-copyid ghost" title={`${id}: click to copy`} onClick={() => {
117
+ void navigator.clipboard?.writeText(id).then(() => {
118
+ setDone(true)
119
+ setTimeout(() => setDone(false), 1200)
120
+ })
121
+ }}>
122
+ {done ? '✓ copied' : '⧉ id'}
123
+ </button>
124
+ )
125
+ }
126
+
108
127
  /** one verdict: plain until one is sent, then the chosen one filled and the others dimmed */
109
- const VerdictButton = ({ v, sent, busy, send }: { v: (typeof VERDICTS)[number]; sent: ReviewStatus | undefined; busy: boolean; send: () => void }) => (
110
- <button className={`rv-verdict ${toneOf(v.status)}${sent === v.status ? ' chosen' : ''}${sent && sent !== v.status ? ' off' : ''}`} disabled={busy} onClick={send}>
111
- <span className="rv-vglyph">{sent === v.status ? '✓' : v.glyph}</span> {v.label} ({v.key}){sent === v.status ? ' · chosen' : ''}
128
+ const VerdictButton = ({ v, sent, busy, send, short: brief }: { v: (typeof VERDICTS)[number]; sent: ReviewStatus | undefined; busy: boolean; send: () => void; short?: boolean }) => (
129
+ <button className={`rv-verdict ${toneOf(v.status)}${sent === v.status ? ' chosen' : ''}${sent && sent !== v.status ? ' off' : ''}`} disabled={busy} onClick={send} title={brief ? `${v.label} (${v.key})` : undefined}>
130
+ <span className="rv-vglyph">{sent === v.status ? '✓' : v.glyph}</span> {brief ? v.short : v.label} ({v.key}){sent === v.status && !brief ? ' · chosen' : ''}
112
131
  </button>
113
132
  )
114
133
 
@@ -163,25 +182,36 @@ const Outgoing = observer(({ rv }: { rv: ReviewSt }) => {
163
182
  const views = x.item.views
164
183
  const draft = rv.draft
165
184
  const reactions = rv.outgoingReactions
166
- const empty = reactions.length === 0 && draft.rects.length === 0
185
+ const empty = reactions.length === 0 && draft.rects.length === 0 && draft.pick === undefined
167
186
  return (
168
187
  <div className="rv-outgoing">
169
188
  <div className="rv-divider">what goes to the agent</div>
170
189
  {empty ? (
171
190
  <div className="rv-dim rv-small">
172
- {rv.clipKey
191
+ {rv.choice && rv.rows
192
+ ? 'Score the options (stars, or ⇧1 to ⇧5 on the one playing), a word on one with ✎, then pick one (P): the pick is your answer.'
193
+ : rv.rows
194
+ ? 'Play each one (1 to 9), then OK, fix or no on a row, or a word with ✎.'
195
+ : rv.clipKey
173
196
  ? 'React to a view (OK, fix, no, a word), or drag on the clip to circle a detail: it pauses, the rectangle keeps its moment.'
174
197
  : rv.phone.on
175
198
  ? 'React to a view (OK, fix, no, a word), or turn « Circle » on above the image, then drag to circle a detail.'
176
199
  : 'React to a view (OK, fix, no, a word), or drag on the image to circle a detail.'}
177
200
  </div>
178
201
  ) : null}
202
+ {draft.pick !== undefined ? (
203
+ <div className="rv-outrow">
204
+ <button className="rv-outbadge re-approved on" title="see the option you picked" onClick={() => { rv.setView(draft.pick ?? 0); jumpTo(draft.pick ?? 0) }}>
205
+ ✓ picked {viewName(views[draft.pick], draft.pick)}
206
+ </button>
207
+ </div>
208
+ ) : null}
179
209
  {reactions.map((q) => {
180
210
  const o = REACTIONS.find((r) => r.status === q.status)
181
211
  return (
182
212
  <div key={`${q.view}/${q.tile ?? ''}`} className="rv-outrow">
183
213
  <button className={`rv-outbadge${q.status ? ` re-${q.status} on` : ''}`} title="see this image" onClick={() => { rv.setView(q.view); jumpTo(q.view, q.tile) }}>
184
- {o?.glyph ?? '·'} {placeText(q, views).slice(1, -1)}
214
+ {o?.glyph ?? (q.stars ? '' : '·')}{q.stars ? `★${q.stars}` : ''} {placeText(q, views).slice(1, -1)}
185
215
  </button>
186
216
  <textarea rows={1} placeholder={o ? `${o.label}, with no word` : 'a word'} value={q.comment} onKeyDown={blurOnEscape}
187
217
  onChange={(e) => rv.setReactionComment(q.view, q.tile, e.currentTarget.value)} />
@@ -1,6 +1,6 @@
1
1
  // a repo's review page (`#page:review`, wiki build/shipkit/review.md, The review page): the cards on the left, the open
2
- // one in the middle with every view stacked, the answer on the right; narrower than STACK_PX, the card over the answer
3
- // in one column. On a phone one pane at a time, the verdicts in a bar under the thumb. The list is read while the page
2
+ // one in the middle with every view stacked, the answer on the right; `top` (or `auto` narrower than STACK_PX) reads in
3
+ // one column from the top: the header and the verdicts, the views, the answer. On a phone one pane at a time, the verdicts in a bar under the thumb. The list is read while the page
4
4
  // shows
5
5
  import { observer } from 'mobx-react-lite'
6
6
  import { useEffect, useRef } from 'react'
@@ -60,17 +60,12 @@ export const ReviewPage = observer(({ app }: { app: RepoSt }) => {
60
60
  </Panel>
61
61
  <Separator className="rsep" />
62
62
  {rv.stacked ? (
63
- // one column: the card over its answer, the boundary dragged
63
+ // one column read from the top: the header and the verdicts, the views, then the answer
64
64
  <Panel id="review-stack" defaultSize="68%" minSize={300} className="rv-pane" style={PANEL}>
65
- <Group className="rv-stack" orientation="vertical">
66
- <Panel id="review-image" defaultSize="62%" minSize={160} className="rv-pane" style={PANEL}>
67
- <div className="rv-scroll"><ReviewImage rv={rv} /></div>
68
- </Panel>
69
- <Separator className="rsep hsep" />
70
- <Panel id="review-form" defaultSize="38%" minSize={140} className="rv-pane" style={PANEL}>
71
- <div className="rv-scroll"><ReviewForm rv={rv} /></div>
72
- </Panel>
73
- </Group>
65
+ <div className="rv-scroll">
66
+ <ReviewImage rv={rv} />
67
+ <ReviewForm rv={rv} />
68
+ </div>
74
69
  </Panel>
75
70
  ) : (
76
71
  <>
@@ -87,11 +82,11 @@ export const ReviewPage = observer(({ app }: { app: RepoSt }) => {
87
82
  )
88
83
  })
89
84
 
90
- /** the phone's top line: the way back to the hub, and the repo the page is of. On a phone the page covers the hub's
85
+ /** the phone's top line: the way back to the hub (none in another tool's frame), and the repo the page is of. On a phone the page covers the hub's
91
86
  * panes, which leave it no room */
92
87
  export const PhoneTop = observer(({ app }: { app: RepoSt }) => (
93
88
  <div className="rv-phonetop">
94
- <button className="ghost" onClick={() => app.showReadme()}>◂ hub</button>
89
+ {app.home.embedded ? null : <button className="ghost" onClick={() => app.showReadme()}>◂ hub</button>}
95
90
  <b>{app.name}</b>
96
91
  <span className="rv-dim">{app.sel?.t === 'trailer' ? 'trailer' : 'review'}</span>
97
92
  </div>