rvlib-shipkit 2.2.0 → 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 (107) hide show
  1. package/CHANGELOG.md +38 -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/cli/cmd_Commands.ts +5 -0
  12. package/src/cli/cmd_Review.ts +948 -0
  13. package/src/cli/cmd_Time.ts +112 -0
  14. package/src/cli/cmd_Trailer.ts +259 -6
  15. package/src/cli/kit/verb.ts +14 -4
  16. package/src/cli.ts +6 -4
  17. package/src/collections/collection-types.ts +6 -1
  18. package/src/collections/providers.ts +3 -0
  19. package/src/collections/review.ts +70 -0
  20. package/src/core/layout.ts +2 -0
  21. package/src/reflect/prompt-corpus.ts +15 -119
  22. package/src/reflect/transcript.ts +254 -0
  23. package/src/review/anim.ts +228 -0
  24. package/src/review/auto.ts +141 -0
  25. package/src/review/format.ts +150 -0
  26. package/src/review/http.ts +23 -0
  27. package/src/review/marks.ts +43 -0
  28. package/src/review/media.ts +45 -0
  29. package/src/review/model.ts +854 -0
  30. package/src/review/project.ts +110 -0
  31. package/src/review/role.ts +48 -0
  32. package/src/review/routes.ts +228 -0
  33. package/src/review/store.ts +1002 -0
  34. package/src/review/threads.ts +237 -0
  35. package/src/review/trailer-desk.ts +385 -0
  36. package/src/review/trailer-model.ts +183 -0
  37. package/src/review/trailer-routes.ts +136 -0
  38. package/src/review/unit.ts +165 -0
  39. package/src/review/watchers.ts +54 -0
  40. package/src/serve/embed.ts +35 -0
  41. package/src/serve/hub-app/activity-view.tsx +1 -1
  42. package/src/serve/hub-app/activity.css +0 -4
  43. package/src/serve/hub-app/dock-view.tsx +5 -4
  44. package/src/serve/hub-app/hash-route.ts +28 -1
  45. package/src/serve/hub-app/header-bar.tsx +3 -1
  46. package/src/serve/hub-app/home-st.ts +10 -2
  47. package/src/serve/hub-app/home-strip.tsx +3 -2
  48. package/src/serve/hub-app/home.html +1 -0
  49. package/src/serve/hub-app/hub-app.tsx +80 -11
  50. package/src/serve/hub-app/hub.css +45 -13
  51. package/src/serve/hub-app/icons.tsx +9 -0
  52. package/src/serve/hub-app/index.html +1 -0
  53. package/src/serve/hub-app/insights-view.tsx +4 -3
  54. package/src/serve/hub-app/keys.ts +14 -10
  55. package/src/serve/hub-app/layout-st.ts +63 -2
  56. package/src/serve/hub-app/planned-view.tsx +3 -2
  57. package/src/serve/hub-app/planned.css +0 -1
  58. package/src/serve/hub-app/project-list.css +22 -4
  59. package/src/serve/hub-app/project-list.tsx +41 -8
  60. package/src/serve/hub-app/projects-svc.ts +4 -0
  61. package/src/serve/hub-app/repo-sections.ts +15 -3
  62. package/src/serve/hub-app/repo-st.ts +45 -3
  63. package/src/serve/hub-app/review/clip-st.ts +208 -0
  64. package/src/serve/hub-app/review/kind-view.tsx +171 -0
  65. package/src/serve/hub-app/review/kit.tsx +113 -0
  66. package/src/serve/hub-app/review/review-answer.tsx +239 -0
  67. package/src/serve/hub-app/review/review-board.tsx +46 -0
  68. package/src/serve/hub-app/review/review-list.tsx +156 -0
  69. package/src/serve/hub-app/review/review-page.tsx +101 -0
  70. package/src/serve/hub-app/review/review-st.ts +779 -0
  71. package/src/serve/hub-app/review/review-views.tsx +503 -0
  72. package/src/serve/hub-app/review/trailer-page.tsx +416 -0
  73. package/src/serve/hub-app/review/trailer-st.ts +602 -0
  74. package/src/serve/hub-app/review/trailer-timeline.tsx +207 -0
  75. package/src/serve/hub-app/review.css +329 -0
  76. package/src/serve/hub-app/theme-st.ts +56 -0
  77. package/src/serve/hub-app/tree-build.ts +4 -0
  78. package/src/serve/hub-app/tree-row.tsx +1 -1
  79. package/src/serve/hub-app/tree-svc.ts +4 -0
  80. package/src/serve/hub-runtime.ts +5 -3
  81. package/src/serve/same-origin.ts +5 -1
  82. package/src/serve/serve-hub.ts +8 -0
  83. package/src/shipkit-files.ts +9 -5
  84. package/src/trailer/adapter.ts +58 -3
  85. package/src/trailer/build.ts +300 -41
  86. package/src/trailer/ffmpeg.ts +22 -0
  87. package/src/trailer/footage.ts +109 -40
  88. package/src/trailer/grammar.ts +444 -69
  89. package/src/trailer/graph.ts +199 -14
  90. package/src/trailer/holes.ts +72 -0
  91. package/src/trailer/load-unit.ts +20 -3
  92. package/src/trailer/look.ts +17 -3
  93. package/src/trailer/passage.ts +129 -0
  94. package/src/trailer/plan.ts +80 -21
  95. package/src/trailer/prune.ts +193 -0
  96. package/src/trailer/score/compose.ts +240 -0
  97. package/src/trailer/score/dsp.ts +272 -0
  98. package/src/trailer/score/palette.ts +188 -0
  99. package/src/trailer/score/pattern.ts +91 -0
  100. package/src/trailer/score/score.ts +214 -0
  101. package/src/trailer/score/sound.ts +112 -0
  102. package/src/trailer/score/synth.ts +361 -0
  103. package/src/trailer/states.ts +76 -0
  104. package/src/trailer/stitch.ts +92 -9
  105. package/src/trailer/titles.ts +123 -18
  106. package/src/trailer/vocab.ts +23 -4
  107. package/src/trailer/words.ts +254 -0
@@ -35,18 +35,26 @@ export type HashSel =
35
35
  | { t: 'when' }
36
36
  /** the checks one owner ships: shipkit's, or the repo's own */
37
37
  | { t: 'checks'; from: CheckOwner }
38
+ /** the review cards, `id` the open one, null for the first the list shows */
39
+ | { t: 'review'; id: string | null }
40
+ /** a trailer's cuts, played with their notes: a trailer folder and one of its cuts, null for the first; the note
41
+ * open on it, and the notes filter when it is not the page's default (all) */
42
+ | { t: 'trailer'; trailer: string | null; cut: string | null; note?: string | null; filter?: 'open' | 'all' | null }
38
43
 
39
44
  /** whose a gate check is */
40
45
  export type CheckOwner = 'shipkit' | 'repo'
41
46
 
42
47
  export type ParsedHash = {
43
- kind: 'asset' | 'cat' | 'col' | 'item' | 'status' | 'tests' | 'admin' | 'readme' | 'processes' | 'plan' | 'git' | 'when' | 'checks' | 'none'
48
+ kind: 'asset' | 'cat' | 'col' | 'item' | 'status' | 'tests' | 'admin' | 'readme' | 'processes' | 'plan' | 'git' | 'when' | 'checks' | 'review' | 'trailer' | 'none'
44
49
  /** the collection, for an item. null for everything else */
45
50
  col: string | null
46
51
  id: string
47
52
  /** the media tool set, when the address carried one. An empty array is a
48
53
  * choice (every tool off), null is no statement at all */
49
54
  tools: string[] | null
55
+ /** a trailer address only: the note open, the notes filter */
56
+ note?: string
57
+ filter?: 'open' | 'all'
50
58
  }
51
59
 
52
60
  const ITEM = 'item:'
@@ -62,6 +70,8 @@ const PLAN = 'page:plan'
62
70
  const GIT = 'page:git'
63
71
  const WHEN = 'page:when'
64
72
  const CHECKS = 'page:checks:'
73
+ const REVIEW = 'page:review'
74
+ const TRAILER = 'page:trailer'
65
75
 
66
76
  /** what this selection puts in the url. null only for no selection at all,
67
77
  * and then the hash is left alone rather than wiped */
@@ -81,6 +91,12 @@ export function hashFor(sel: HashSel | null): string | null {
81
91
  case 'git': return GIT
82
92
  case 'when': return WHEN
83
93
  case 'checks': return `${CHECKS}${sel.from}`
94
+ case 'review': return sel.id == null ? REVIEW : `${REVIEW}:${sel.id}`
95
+ case 'trailer': {
96
+ // page:trailer:<trailer>/<cut>/<note>?filter=all, each part only when it says something
97
+ const note = sel.trailer != null && sel.note ? `/${sel.cut ?? ''}/${sel.note}` : sel.cut == null ? '' : `/${sel.cut}`
98
+ return `${sel.trailer == null ? TRAILER : `${TRAILER}:${sel.trailer}${note}`}${sel.filter ? `?filter=${sel.filter}` : ''}`
99
+ }
84
100
  }
85
101
  }
86
102
 
@@ -95,6 +111,15 @@ export function parseHash(raw: string): ParsedHash {
95
111
  if (raw === PLAN) return { kind: 'plan', col: null, id: '', tools: null }
96
112
  if (raw === GIT) return { kind: 'git', col: null, id: '', tools: null }
97
113
  if (raw === WHEN) return { kind: 'when', col: null, id: '', tools: null }
114
+ if (raw === REVIEW || raw.startsWith(`${REVIEW}:`)) return { kind: 'review', col: null, id: raw.slice(REVIEW.length + 1), tools: null }
115
+ // the trailer folder rides in `col`, its cut in `id`
116
+ if (raw === TRAILER || raw.startsWith(`${TRAILER}:`) || raw.startsWith(`${TRAILER}?`)) {
117
+ const qi = raw.indexOf('?')
118
+ const path = qi === -1 ? raw : raw.slice(0, qi)
119
+ const [trailer = '', cut = '', note = ''] = path.slice(TRAILER.length + 1).split('/')
120
+ const f = qi === -1 ? null : new URLSearchParams(raw.slice(qi + 1)).get('filter')
121
+ return { kind: 'trailer', col: trailer === '' ? null : trailer, id: cut, tools: null, ...(note ? { note } : {}), ...(f === 'open' || f === 'all' ? { filter: f } : {}) }
122
+ }
98
123
  if (raw === `${CHECKS}shipkit` || raw === `${CHECKS}repo`) return { kind: 'checks', col: null, id: raw.slice(CHECKS.length), tools: null }
99
124
  if (raw.startsWith(`${PROCESSES}:`)) {
100
125
  const id = raw.slice(PROCESSES.length + 1)
@@ -148,6 +173,8 @@ export function selOf(p: ParsedHash): HashSel | null {
148
173
  case 'git': return { t: 'git' }
149
174
  case 'when': return { t: 'when' }
150
175
  case 'checks': return { t: 'checks', from: p.id as CheckOwner }
176
+ case 'review': return { t: 'review', id: p.id === '' ? null : p.id }
177
+ case 'trailer': return { t: 'trailer', trailer: p.col, cut: p.id === '' ? null : p.id, ...(p.note ? { note: p.note } : {}), ...(p.filter ? { filter: p.filter } : {}) }
151
178
  }
152
179
  }
153
180
 
@@ -14,6 +14,8 @@ import { Icon, ICON } from './icons.tsx'
14
14
  import { Pop, Tip } from './floating.tsx'
15
15
  import { FloatCards, type Card } from './float-cards.tsx'
16
16
  import type { RepoSt } from './repo-st.ts'
17
+ import type { CSSProperties } from 'react'
18
+ import { idVars } from './projects-svc.ts'
17
19
 
18
20
  /** the repo's hub has not answered on the home: its reason and its fix show
19
21
  * instead of the page. A screen this browser kept shows meanwhile, until
@@ -113,7 +115,7 @@ export const RepoTools = observer(({ app }: { app: RepoSt }) => (
113
115
  export const HeaderBar = observer(({ app }: { app: RepoSt }) => (
114
116
  <>
115
117
  <header className="hdr">
116
- <span className="hname" style={app.name === '' ? undefined : { color: app.home.projects.identityOf(app.name).colorDark }}>{app.name !== '' ? app.name : 'hub'}</span>
118
+ <span className={app.name === '' ? 'hname' : 'hname idc'} style={app.name === '' ? undefined : (idVars(app.home.projects.identityOf(app.name)) as CSSProperties)}>{app.name !== '' ? app.name : 'hub'}</span>
117
119
  <span className="sp" />
118
120
  <RepoTools app={app} />
119
121
  <RepoRun app={app} />
@@ -14,6 +14,7 @@ import { makeAutoObservable, observableShallow, reaction, type IReactionDisposer
14
14
  import { currentHash, hashFor } from './hash-route.ts'
15
15
  import { MachineSt, type MachineOpts } from './machine-st.ts'
16
16
  import { LayoutSt } from './layout-st.ts'
17
+ import { ThemeSt } from './theme-st.ts'
17
18
  import { DockSt } from './dock-st.ts'
18
19
  import { PaletteSt } from './palette-st.ts'
19
20
  import { PlannedSt } from './planned-st.ts'
@@ -60,6 +61,8 @@ export const plainClick = (e: { button: number; metaKey: boolean; ctrlKey: boole
60
61
  export type HomeOpts = MachineOpts & {
61
62
  /** true on the home's web; false on a hub serving on its own */
62
63
  home: boolean
64
+ /** the one repo's base, read from where the page sits: the embed page under a proxy's prefix (embed.ts) */
65
+ base?: string
63
66
  }
64
67
 
65
68
  export class HomeSt {
@@ -78,6 +81,8 @@ export class HomeSt {
78
81
  layout: LayoutSt
79
82
  /** the run chart docked under the whole page */
80
83
  dock: DockSt
84
+ /** dark, light or the system's */
85
+ readonly theme = new ThemeSt()
81
86
  page: Page
82
87
  /** the key of the repo picked last: its name on the home, '' for the one
83
88
  * repo of a hub of its own. A global page keeps it (only `shown` says
@@ -90,7 +95,10 @@ export class HomeSt {
90
95
  private prefetching = false
91
96
  private readonly onUrl = (): void => this.fromUrl()
92
97
 
98
+ private readonly base: string | undefined
99
+
93
100
  constructor(o: HomeOpts) {
101
+ this.base = o.base
94
102
  this.projects = new ProjectsSvc()
95
103
  this.planned = new PlannedSt(this)
96
104
  this.activity = new ActivitySt(this)
@@ -100,7 +108,7 @@ export class HomeSt {
100
108
  this.dock = new DockSt(this)
101
109
  this.machine = o.home ? new MachineSt(this, o) : null
102
110
  this.page = o.home ? 'overview' : 'repo'
103
- makeAutoObservable<this, 'disposers' | 'onUrl' | 'prefetching'>(this, { machine: false, projects: false, planned: false, activity: false, insights: false, palette: false, layout: false, dock: false, repos: observableShallow, disposers: false, onUrl: false, prefetching: false })
111
+ makeAutoObservable<this, 'disposers' | 'onUrl' | 'prefetching' | 'base'>(this, { base: false, machine: false, projects: false, planned: false, activity: false, insights: false, palette: false, layout: false, theme: false, dock: false, repos: observableShallow, disposers: false, onUrl: false, prefetching: false })
104
112
  const m = this.machine
105
113
  if (m != null) {
106
114
  this.projects.onSelect = (name) => this.select(name)
@@ -143,7 +151,7 @@ export class HomeSt {
143
151
  repo(key: string): RepoSt {
144
152
  let r = this.repos.get(key)
145
153
  if (r == null) {
146
- r = new RepoSt(this, key, this.machine == null ? '' : repoBase(key))
154
+ r = new RepoSt(this, key, this.base ?? (this.machine == null ? '' : repoBase(key)))
147
155
  this.repos.set(key, r)
148
156
  // what this browser kept of it paints before its hub answers
149
157
  void r.cols.restore()
@@ -15,7 +15,8 @@
15
15
  // while its hub cannot answer, with the fix. Every link of the home is a
16
16
  // page of the same app: a plain click moves it in place.
17
17
  import { observer } from 'mobx-react-lite'
18
- import type { ReactNode } from 'react'
18
+ import type { CSSProperties, ReactNode } from 'react'
19
+ import { idVars } from './projects-svc.ts'
19
20
  import type { OpenHow, ProcessIcon, ResourceView } from '../../home/resources.ts'
20
21
  import type { WorkerView } from '../../home/hubs.ts'
21
22
  import { NOT_INSTALLED } from '../../home/home-words.ts'
@@ -238,7 +239,7 @@ const ShownRepo = observer(({ app, name }: { app: HomeSt; name: string }) => {
238
239
  <span className="hrepo">
239
240
  {/* the icon's box is there before the project list is, so the name never moves */}
240
241
  {p?.icon != null ? <img src={p.icon} alt="" width={18} height={18} /> : <i className="hrico" />}
241
- <b className="hrname" style={{ color: id.colorDark }}>{name}</b>
242
+ <b className="hrname idc" style={idVars(id) as CSSProperties}>{name}</b>
242
243
  </span>
243
244
  )
244
245
  })
@@ -14,6 +14,7 @@
14
14
  <link rel="stylesheet" href="./readme.css" />
15
15
  <link rel="stylesheet" href="./dock.css" />
16
16
  <link rel="stylesheet" href="./controls.css" />
17
+ <link rel="stylesheet" href="./review.css" />
17
18
  </head>
18
19
  <body data-home>
19
20
  <div id="root"></div>
@@ -7,7 +7,7 @@
7
7
  // (sidebar.tsx), coloured by state in the label itself, under one filter
8
8
  // input and one severity filter persisted at
9
9
  // ?f=needs · ?f=red. A row opens its item view, an asset row opens the atelier.
10
- import { runInAction } from 'mobx'
10
+ import { reaction, runInAction } from 'mobx'
11
11
  import { observer } from 'mobx-react-lite'
12
12
  import { StrictMode, useEffect, useLayoutEffect, useRef } from 'react'
13
13
  import { createRoot } from 'react-dom/client'
@@ -17,6 +17,7 @@ import { WIKIS, WikisView } from './wikis-view.tsx'
17
17
  import { ComfyOverlay, ComfyView } from './comfy-view.tsx'
18
18
  import type { Filter, RepoSt } from './repo-st.ts'
19
19
  import { HomeSt, PLACE_PATH } from './home-st.ts'
20
+ import { hashFor } from './hash-route.ts'
20
21
  import { TimelineView } from './timeline-view.tsx'
21
22
  import { HeaderBar, notUp } from './header-bar.tsx'
22
23
  import { FOLLOW_KEY } from './timeline-chart.tsx'
@@ -31,6 +32,8 @@ import { WhenPage } from './when-view.tsx'
31
32
  import { GitPage } from './git-view.tsx'
32
33
  import { AdminPage } from './admin-view.tsx'
33
34
  import { ReadmeView } from './readme-view.tsx'
35
+ import { ReviewPage } from './review/review-page.tsx'
36
+ import { TrailerPage } from './review/trailer-page.tsx'
34
37
  import { ProjectList } from './project-list.tsx'
35
38
  import { Group, Panel, Separator, usePanelRef, type Layout } from 'react-resizable-panels'
36
39
  import { LIST, SIDEBAR } from './layout-st.ts'
@@ -46,8 +49,11 @@ import { Dock } from './dock-view.tsx'
46
49
  import { DOCK } from './dock-st.ts'
47
50
  import { animateDock } from './dock-anim.ts'
48
51
 
49
- // home.html marks the home's page; index.html is a hub serving on its own
50
- const home = new HomeSt({ home: document.body.dataset.home != null })
52
+ // home.html marks the home's page; index.html is a hub serving on its own; `<base>/embed/?page=review` is one repo's
53
+ // review or trailer page alone, for another tool's frame (embed.ts), its base read from where it sits
54
+ const embedAt = /^(.*)\/embed\/?$/.exec(location.pathname)
55
+ const embed: 'review' | 'trailer' | null = embedAt == null ? null : new URLSearchParams(location.search).get('page') === 'trailer' ? 'trailer' : 'review'
56
+ const home = new HomeSt(embedAt == null ? { home: document.body.dataset.home != null } : { home: false, base: embedAt[1] ?? '' })
51
57
  // the root as a global — app code, console debugging (build/app-state-tree)
52
58
  ;(window as unknown as { app: HomeSt }).app = home
53
59
  home.boot()
@@ -97,6 +103,8 @@ const Main = observer(({ app }: { app: RepoSt }) => {
97
103
  {app.sel.id === 'checks' ? <CheckTable app={app} id={app.sel.id} /> : app.sel.id === 'asks' ? <AskTable app={app} id={app.sel.id} /> : app.sel.id === 'creds' ? <SecretTable app={app} id={app.sel.id} /> : <ItemTable app={app} id={app.sel.id} />}
98
104
  </main>
99
105
  )
106
+ if (app.sel?.t === 'review') return <main>{errs}<ReviewPage app={app} /></main>
107
+ if (app.sel?.t === 'trailer') return <main>{errs}<TrailerPage app={app} /></main>
100
108
  if (app.sel?.t === 'tests') return <main>{errs}<div className="stagewrap"><TestsPage app={app} check={app.sel.check} /></div></main>
101
109
  if (app.sel?.t === 'admin') return <main>{errs}<div className="stagewrap"><AdminPage app={app} /></div></main>
102
110
  if (app.sel?.t === 'plan') return <main>{errs}<div className="stagewrap"><PlanPage app={app} /></div></main>
@@ -165,7 +173,7 @@ const Body = observer(({ app }: { app: RepoSt }) => {
165
173
  <Sidebar app={app} />
166
174
  </Panel>
167
175
  <Separator className="rsep" />
168
- <Panel id="main" minSize={320} className="pmain" style={PANEL}>
176
+ <Panel id="main" minSize={320} className="pmain" style={PANEL} onPointerDownCapture={() => app.tree.leave()}>
169
177
  <Main app={app} />
170
178
  </Panel>
171
179
  </Group>
@@ -194,6 +202,13 @@ const RepoView = observer(({ app }: { app: RepoSt }) => {
194
202
 
195
203
  /** the keys of the page: they act on the repo shown, and on nothing else */
196
204
  function onKey(e: KeyboardEvent): void {
205
+ // the embed page has its page's own keys and nothing else
206
+ if (embed != null) {
207
+ const app = home.shown
208
+ const t = e.target as HTMLElement
209
+ if (app != null && t.tagName !== 'TEXTAREA' && t.tagName !== 'INPUT' && !e.metaKey && !e.ctrlKey && !e.altKey && app.pageKey(e)) e.preventDefault()
210
+ return
211
+ }
197
212
  // the command keys beat the focus check: they work from anywhere, any page
198
213
  const pk = pageKey(e)
199
214
  // in a text field ⌘↑ and ⌘↓ keep moving the caret to its ends
@@ -202,7 +217,8 @@ function onKey(e: KeyboardEvent): void {
202
217
  e.preventDefault()
203
218
  if (pk.t === 'picker') home.palette.toggle()
204
219
  else if (pk.t === 'dock') home.dock.cycle()
205
- else if (pk.t === 'home' || pk.t === 'planned' || pk.t === 'activity') {
220
+ else if (pk.t === 'rail') home.layout.toggleRail()
221
+ else if (pk.t === 'home' || pk.t === 'planned' || pk.t === 'activity' || pk.t === 'insights') {
206
222
  home.palette.close()
207
223
  if (home.machine != null) home.navigate(PLACE_PATH[pk.t === 'home' ? 'overview' : pk.t])
208
224
  } else if (pk.t === 'step') {
@@ -226,6 +242,12 @@ function onKey(e: KeyboardEvent): void {
226
242
  e.preventDefault()
227
243
  return
228
244
  }
245
+ // so do the review and trailer pages (RepoSt.pageKey)
246
+ const plain = !e.metaKey && !e.ctrlKey && !e.altKey
247
+ if (plain && app.pageKey(e)) {
248
+ e.preventDefault()
249
+ return
250
+ }
229
251
  const sk = sectionKey(e)
230
252
  if (sk != null) {
231
253
  const s = repoSections(app).find((x) => x.id === sk)
@@ -280,6 +302,21 @@ const App = observer(() => {
280
302
  const el = useRef<HTMLDivElement | null>(null)
281
303
  const vel = useRef<HTMLDivElement | null>(null)
282
304
  const dockRef = usePanelRef()
305
+ const listRef = usePanelRef()
306
+ // ⌘B folds the list to its rail and back; a drag that already did it finds it there
307
+ const rail = l.listRail
308
+ const railW = l.railPx
309
+ useLayoutEffect(() => {
310
+ const p = listRef.current
311
+ if (p == null) return
312
+ if (p.isCollapsed() !== rail) {
313
+ if (rail) p.collapse()
314
+ else {
315
+ p.expand()
316
+ p.resize(l.listWidth)
317
+ }
318
+ } else if (rail && Math.abs(p.getSize().inPixels - railW) >= 1) p.resize(railW)
319
+ }, [rail, railW, listRef, l])
283
320
  // the dock's size moves the boundary, and only the boundary: its first
284
321
  // height is the panel's default, read before the first render. After the
285
322
  // render, so the panel already holds the bounds of its new size, and
@@ -309,10 +346,15 @@ const App = observer(() => {
309
346
  <Group className="app" orientation="horizontal" elementRef={el}
310
347
  onLayoutChanged={(layout, meta) => {
311
348
  if (!meta.isUserInteraction) return
349
+ // a drag below the list's min folds it to its rail, a drag out unfolds it
350
+ const folded = listRef.current?.isCollapsed() === true
351
+ l.setRail(folded)
352
+ if (folded) return
312
353
  const px = leftPx(el.current, layout, 'projects')
313
354
  if (px != null) l.setListWidth(px)
314
355
  }}>
315
- <Panel id="projects" defaultSize={l.listWidth} minSize={LIST.min} maxSize={LIST.max} groupResizeBehavior="preserve-pixel-size" className="pjpanel" style={PANEL}>
356
+ <Panel id="projects" panelRef={listRef} defaultSize={rail ? railW : l.listWidth} minSize={LIST.min} maxSize={LIST.max} collapsible collapsedSize={railW}
357
+ groupResizeBehavior="preserve-pixel-size" className="pjpanel" style={PANEL}>
316
358
  <ProjectList app={home} />
317
359
  </Panel>
318
360
  <Separator className="rsep" />
@@ -337,12 +379,39 @@ const App = observer(() => {
337
379
  )
338
380
  })
339
381
 
382
+ /** the embed page: the one page, no tree, no list, no dock. Its address and the frame's host follow each other: every
383
+ * page move is posted to the parent, and the parent's `{ shipkit: 'go', hash }` moves the page; both only on this origin */
384
+ const EmbedApp = observer(({ page }: { page: 'review' | 'trailer' }) => {
385
+ const app = home.shown
386
+ useEffect(() => {
387
+ document.addEventListener('keydown', onKey)
388
+ const onMessage = (e: MessageEvent): void => {
389
+ const m = e.data as { shipkit?: unknown; hash?: unknown } | null
390
+ if (e.source !== window.parent || e.origin !== location.origin || m?.shipkit !== 'go' || typeof m.hash !== 'string') return
391
+ location.hash = m.hash.replace(/^#?/, '#')
392
+ }
393
+ window.addEventListener('message', onMessage)
394
+ return () => {
395
+ document.removeEventListener('keydown', onKey)
396
+ window.removeEventListener('message', onMessage)
397
+ }
398
+ }, [])
399
+ useEffect(() => {
400
+ if (app == null) return
401
+ if (app.sel?.t !== page) app.go(page === 'review' ? { t: 'review', id: null } : { t: 'trailer', trailer: null, cut: null })
402
+ if (window.parent === window) return
403
+ return reaction(
404
+ () => (app.sel == null ? '' : (hashFor(app.sel) ?? '')),
405
+ (hash) => window.parent.postMessage({ shipkit: 'route', page, hash: `#${hash}` }, location.origin),
406
+ { fireImmediately: true },
407
+ )
408
+ }, [app, page])
409
+ if (app == null) return null
410
+ return <main className="embed" style={{ position: 'fixed', inset: 0 }}>{page === 'review' ? <ReviewPage app={app} /> : <TrailerPage app={app} />}</main>
411
+ })
412
+
340
413
  // the shown repo's kept screen is read before the first paint, so a refresh
341
414
  // paints it at once; a slow or absent store costs at most SCREEN_WAIT_MS
342
415
  await Promise.race([home.shown?.cols.restore(), new Promise((ok) => setTimeout(ok, SCREEN_WAIT_MS))])
343
416
 
344
- createRoot(document.getElementById('root')!).render(
345
- <StrictMode>
346
- <App />
347
- </StrictMode>,
348
- )
417
+ createRoot(document.getElementById('root')!).render(<StrictMode>{embed == null ? <App /> : <EmbedApp page={embed} />}</StrictMode>)
@@ -6,8 +6,8 @@
6
6
  that does. */
7
7
  :root{--bg:#0d0e10;--panel:#141619;
8
8
  /* the surfaces of the regions, each a small step from the page so the rail,
9
- the header and the dock read apart at a glance (no light theme: the page
10
- is dark only, and colorDark is what it draws) */
9
+ the header and the dock read apart at a glance; the light values are the
10
+ next block) */
11
11
  --bg-side:#131519;--bg-top:#171a1f;--bg-dock:#10151b;--panel2:#1b1e22;--line:#282c31;--ink:#e2e4e7;--dim:#8d9299;
12
12
  --accent:#6b8fb3;--sel:#1a2430;--chk1:#16181b;--chk2:#121417;
13
13
  /* state, and nothing else: red broken · orange taste pending · green measured passing */
@@ -18,6 +18,8 @@
18
18
  --ground:#1b2521;--groundline:#2a3630;--tag:#eef2f6;
19
19
  /* the KIND hues: a row's type, never its state (tree-build TYPE_MARK) */
20
20
  --k1:#8ca8c7;--k2:#a892c9;--k3:#6fb0b8;--k4:#c792b4;--k5:#9198a1;--k6:#8f95cf;
21
+ /* the review section's own hue: the one row of the tree meant to be seen first */
22
+ --review:#e879d4;
21
23
  --wash:rgba(107,143,179,.15);--wash2:rgba(107,143,179,.11);--hair:rgba(107,143,179,.5);
22
24
  --errbg:#2a1a1a;--errline:#4a2c2c;--errink:#e3bebe;
23
25
  /* a running thing's wash, the green twin of errbg and errline */
@@ -25,6 +27,26 @@
25
27
  /* the ONE spacing scale: every padding, margin and gap in this sheet is a
26
28
  value from it or 0, and tests/hub-css-invariants.test.ts fails the rest */
27
29
  --s0:2px;--s1:4px;--s2:8px;--s3:12px;--s4:16px}
30
+ /* THE LIGHT TOKENS: the same names, read on a light page (theme-st.ts sets
31
+ <html data-theme>). Every token of the block above has its light value
32
+ here, and tests/hub-css-invariants.test.ts fails a missing one */
33
+ :root[data-theme=light]{--bg:#f6f7f9;--panel:#ffffff;
34
+ --bg-side:#eef0f3;--bg-top:#f2f4f7;--bg-dock:#eceff3;--panel2:#e9ecf0;--line:#d6dbe1;--ink:#1c2026;--dim:#5d646d;
35
+ --accent:#3b6896;--sel:#dbe6f2;--chk1:#e9ecf0;--chk2:#f7f8fa;
36
+ --red:#c03f3a;--warn:#b0700f;--ok:#2d8754;--blue:#2b69b0;
37
+ --scrim:rgba(20,24,30,.35);--shadow:rgba(20,24,30,.16);--solid:#000;
38
+ --hi1:rgba(0,0,0,.2);--hi2:rgba(0,0,0,.11);--hi3:rgba(0,0,0,.045);
39
+ --ground:#e3ede6;--groundline:#c6d6cb;--tag:#eef2f6;
40
+ --k1:#3f6690;--k2:#7055a3;--k3:#2b7c86;--k4:#a34e80;--k5:#5d646d;--k6:#4f56a8;
41
+ --review:#c0279d;
42
+ --wash:rgba(59,104,150,.12);--wash2:rgba(59,104,150,.08);--hair:rgba(59,104,150,.45);
43
+ --errbg:#fbe9e8;--errline:#efc4c2;--errink:#8a2520;
44
+ --okbg:#e6f3eb;--okline:#9ccdaf}
45
+ /* a repo's own colour, the one fit for the theme (projects-svc idVars) */
46
+ .idc{color:var(--idd)}
47
+ :root[data-theme=light] .idc{color:var(--idl)}
48
+ .idb{background:var(--idd)}
49
+ :root[data-theme=light] .idb{background:var(--idl)}
28
50
  *{box-sizing:border-box}html,body,#root{height:100%}
29
51
  body{margin:0;background:var(--bg);color:var(--ink);font:14px/1.5 system-ui,sans-serif;overflow:hidden}
30
52
  /* a link with no class of its own: the browser default is dark blue on this dark page */
@@ -43,7 +65,9 @@ a,a:visited{color:var(--accent)}
43
65
  .repo{flex:1;min-height:0;display:flex;flex-direction:column}
44
66
  .repo[hidden]{display:none}
45
67
  .appbody{flex:1;min-height:0}
46
- .pside{display:flex;flex-direction:column}
68
+ .pside{display:flex;flex-direction:column;container-type:inline-size}
69
+ /* a narrow sidebar keeps the names: the key hints and the filter's letters go first */
70
+ @container (max-width:200px){.tsec .pjkey,.ssearch .seg .sk{display:none}.ssearch{padding:var(--s2)}}
47
71
  .pside > aside{flex:1}
48
72
  .pmain{display:flex;flex-direction:column}
49
73
  .pmain > main{flex:1}
@@ -101,14 +125,21 @@ aside > .tscroll::-webkit-scrollbar-thumb{background:var(--line);border-radius:3
101
125
  .rsecn.nred{background:var(--red)}
102
126
  .rsecn.nwarn{background:var(--warn)}
103
127
  .tsec:hover .pjkey,.tsec.cur .pjkey{opacity:1}
104
- /* segmented severity filter — all · needs · red. A thin border and an
105
- underline on the live one: no fill, no pill */
106
- .seg{display:inline-flex;border:1px solid var(--line);border-radius:3px;overflow:hidden;flex:none}
107
- .seg button{background:transparent;color:var(--dim);border:0;border-bottom:1px solid transparent;border-radius:0;
108
- padding:var(--s0) var(--s2);font-size:11px;font-weight:500;text-transform:lowercase;letter-spacing:0;cursor:pointer}
128
+ /* review: its own hue on the icon, the label and the mark, empty or not, so the eye finds it */
129
+ .tsec.s-review,.tsec.s-review:hover,.tsec.s-review.cur{color:var(--review)}
130
+ .tsec.s-review.empty{opacity:.75}
131
+ .tsec.s-review.cur{box-shadow:inset 3px 0 0 var(--review)}
132
+ .tsec.s-review .rsecn.nwarn{background:var(--review)}
133
+ /* THE SELECT SHAPE: every pick on the page, a native select or a segmented
134
+ bar of exclusive buttons (.seg), is one 22px pill with a 1px line border;
135
+ the live segment is the accent on the selection wash. Nothing else
136
+ draws a select: a new one takes .seg or a plain select */
137
+ .seg{display:inline-flex;align-items:stretch;height:22px;border:1px solid var(--line);border-radius:999px;overflow:hidden;flex:none}
138
+ .seg button{display:inline-flex;align-items:center;background:transparent;color:var(--dim);border:0;border-radius:0;
139
+ padding:0 var(--s2);font-size:12px;font-weight:500;line-height:1;text-transform:lowercase;letter-spacing:0;cursor:pointer}
109
140
  .seg button+button{border-left:1px solid var(--line)}
110
- .seg button:hover{color:var(--ink)}
111
- .seg button.on{color:var(--accent);border-bottom-color:var(--accent)}
141
+ .seg button:hover{color:var(--ink);background:var(--panel2)}
142
+ .seg button.on{color:var(--accent);background:var(--sel)}
112
143
  .seg button .sk{margin-left:var(--s1);font-size:9px;opacity:.55}
113
144
  /* a narrow pane puts the severity filter under the input, never over it */
114
145
  .ssearch{display:flex;flex-wrap:wrap;align-items:center;gap:var(--s2);padding:var(--s2) var(--s3);border-bottom:1px solid var(--line)}
@@ -201,7 +232,6 @@ textarea{width:100%;min-height:64px;background:var(--bg);color:var(--ink);border
201
232
  .toolbar button{padding:var(--s1) var(--s3);font-size:12px}
202
233
  .toolbar .sp{flex:1}
203
234
  .toolbar .xn{display:flex;gap:var(--s0);align-items:center;font-size:12px;color:var(--dim)}
204
- .toolbar .xn select{padding:var(--s1);font-size:12px}
205
235
  /* BUTTONS: small, flat, one pixel border on the panel colour, a 12px
206
236
  lowercase label. The accent is a border and an ink colour on the primary
207
237
  action, never a fill, and nothing here is a big rounded rectangle */
@@ -212,7 +242,9 @@ button.primary{color:var(--accent);border-color:var(--accent)}
212
242
  button.ghost{color:var(--dim)}
213
243
  button.ghost:hover{color:var(--ink)}
214
244
  button:disabled{opacity:.4;cursor:default;border-color:var(--line);color:var(--dim)}
215
- select,input[type=number]{background:var(--bg);color:var(--ink);border:1px solid var(--line);border-radius:4px;padding:var(--s2)}
245
+ select{height:22px;background:var(--bg);color:var(--ink);border:1px solid var(--line);border-radius:999px;padding:0 var(--s2);font-size:12px;cursor:pointer}
246
+ select:hover:not(:disabled){border-color:var(--accent)}
247
+ input[type=number]{background:var(--bg);color:var(--ink);border:1px solid var(--line);border-radius:4px;padding:var(--s2)}
216
248
  .status{font-size:12px;color:var(--dim);text-align:right;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:46vw;flex:none}
217
249
  @keyframes rot{to{transform:rotate(360deg)}}
218
250
  @keyframes pulse{0%,100%{opacity:1}50%{opacity:.25}}
@@ -343,7 +375,7 @@ kbd{background:var(--panel2);border:1px solid var(--line);border-radius:4px;padd
343
375
  /* panel content inherits the hub's chunky default button — inside a card
344
376
  everything is DENSE, repo panel scripts get this for free */
345
377
  .fbody button{padding:var(--s0) var(--s2);font-size:11.5px;font-weight:600;border-radius:3px}
346
- .fbody input:not([type=color]),.fbody select{padding:var(--s0) var(--s1);font-size:11.5px}
378
+ .fbody input:not([type=color]){padding:var(--s0) var(--s1);font-size:11.5px}
347
379
  .fbody label{font-size:11.5px}
348
380
  .fbody .jsonta{width:100%;min-height:160px}
349
381
  .vbar{flex:none;border-left:1px solid var(--line);background:var(--panel);display:flex;min-height:0;position:relative}
@@ -28,8 +28,11 @@ export const ICON = {
28
28
  secrets: 'M7 7a3 3 0 1 0 0 6a3 3 0 1 0 0-6M10 10h7.5M15 10v2.5M17.5 10v2',
29
29
  runs: 'M3 5h8M3 10h12M3 15h6',
30
30
  asks: 'M3 4h14v9H8l-4 3.5V13H3z',
31
+ // pkm-multi's rail marks: rate_review, a bubble with a pen, and movie, a clapper
32
+ review: 'M3 3.5h14v10H7.5L3 17zM7 10.5l4.5-4.5 1.5 1.5L8.5 12H7z',
31
33
  tests: 'M8 3h4M8.5 3v5L4 16.5h12L11.5 8V3M6.2 12.5h7.6',
32
34
  media: 'M3 4h14v12H3zM3 13l4-4 4 4 2-2 4 4M13 7.5h.01',
35
+ trailer: 'M2.5 4.5h15v11h-15zM2.5 8.5h15M6 4.5 7.5 8.5M10 4.5l1.5 4M14 4.5l1.5 4',
33
36
  wikis: 'M10 5.5C8.5 4 6 3.5 3 4v11c3-.5 5.5 0 7 1.5c1.5-1.5 4-2 7-1.5V4c-3-.5-5.5 0-7 1.5zM10 5.5v11',
34
37
  admin: 'M4 5h12M4 10h12M4 15h12M7 3.5v3M13 8.5v3M9 13.5v3',
35
38
  health: 'M2 10h3.5l2-5 5 10 2-5H18',
@@ -37,6 +40,12 @@ export const ICON = {
37
40
  // the VS Code mark, its ribbon drawn as one outline
38
41
  vscode: 'M14 2.5l3.5 1.7v11.6L14 17.5zM14 2.5 4.5 12.3 2.5 11V9l2-1.3L14 17.5M14 6.8 10.2 10l3.8 3.2',
39
42
  sort: 'M6 3.5v13M3 13.5l3 3 3-3M14 16.5v-13M11 6.5l3-3 3 3',
43
+ // the theme button: a sun, a moon, and a half filled disc for the system's
44
+ sun: 'M10 6.5a3.5 3.5 0 1 0 .01 0M10 2v2M10 16v2M2 10h2M16 10h2M4.3 4.3l1.4 1.4M14.3 14.3l1.4 1.4M4.3 15.7l1.4-1.4M14.3 5.7l1.4-1.4',
45
+ moon: 'M15.5 12.5A6.5 6.5 0 0 1 7.5 4.5a6.5 6.5 0 1 0 8 8z',
46
+ auto: 'M10 3a7 7 0 1 0 .01 0M10 3v14M10 5.5h4M10 8.5h6M10 11.5h6M10 14.5h4',
47
+ // the project list's fold: a panel with its rail marked
48
+ rail: 'M3 4h14v12H3zM7.5 4v12',
40
49
  // a process's own switches on the overview
41
50
  play: 'M6.5 4.5v11l9-5.5z',
42
51
  stop: 'M5.5 5.5h9v9h-9z',
@@ -14,6 +14,7 @@
14
14
  <link rel="stylesheet" href="./readme.css" />
15
15
  <link rel="stylesheet" href="./dock.css" />
16
16
  <link rel="stylesheet" href="./controls.css" />
17
+ <link rel="stylesheet" href="./review.css" />
17
18
  </head>
18
19
  <body>
19
20
  <div id="root"></div>
@@ -80,16 +80,17 @@ export const InsightsView = observer(({ app }: { app: HomeSt }) => {
80
80
  return (
81
81
  <div className="hall activity insights">
82
82
  <div className="actop">
83
- <div className="acwin" role="radiogroup" aria-label="which repos">
83
+ <div className="seg" role="radiogroup" aria-label="which repos">
84
84
  {(Object.keys(SCOPE_WORDS) as Scope[]).map((s) => (
85
85
  <button key={s} role="radio" aria-checked={st.scope === s} className={st.scope === s ? 'on' : ''} title={SCOPE_WORDS[s].title} onClick={() => st.setScope(s)}>{SCOPE_WORDS[s].label}</button>
86
86
  ))}
87
87
  </div>
88
- <div className="acwin" role="radiogroup" aria-label="which boxes">
88
+ <div className="seg" role="radiogroup" aria-label="which boxes">
89
89
  <button role="radio" aria-checked={st.tag == null} className={st.tag == null ? 'on' : ''} onClick={() => st.setTag(null)}>all</button>
90
90
  {st.tags.map((t) => <button key={t} role="radio" aria-checked={st.tag === t} className={st.tag === t ? 'on' : ''} onClick={() => st.setTag(t)}>{t}</button>)}
91
91
  </div>
92
- <input className="inquery" type="search" placeholder="filter: a package, a script…" aria-label="filter every box" value={st.query} onChange={(e) => st.setQuery(e.currentTarget.value)} />
92
+ {/* focused as the page opens (⌘; then type a package name) */}
93
+ <input className="inquery" type="search" autoFocus placeholder="filter: a package, a script…" aria-label="filter every box" value={st.query} onChange={(e) => st.setQuery(e.currentTarget.value)} />
93
94
  <button type="button" className="acpill" disabled={st.loading} onClick={() => st.refresh()} title="read every package.json again now · an answer is kept 5 minutes">
94
95
  <span className={`acring${st.loading ? ' on' : ''}`}>↻</span>
95
96
  </button>
@@ -1,12 +1,13 @@
1
1
  // the page's command keys, read the same on every layout: ⌘1 to ⌘9 select the
2
2
  // nth repo of the project list, ⌘↑ and ⌘↓ the repo above or below, then the home row under the right hand: ⌘J
3
- // the picker, ⌘K the overview, ⌘L the activity, ⌘' the plan. ⌘B cycles the
4
- // dock at the bottom of the page (dock-st.ts): the line, slim, open.
5
- // A digit and the quote are read from the physical key (`code`), so an
3
+ // the picker, ⌘K the overview, ⌘L the activity, ⌘; the insights, ⌘' the plan.
4
+ // ⌘B folds the project list to its icon rail and back (layout-st.ts), ⌘⇧B
5
+ // cycles the dock at the bottom of the page (dock-st.ts): the line, slim, open.
6
+ // A digit, the semicolon and the quote are read from the physical key (`code`), so an
6
7
  // AZERTY keyboard, where the key under 1 types `&`, selects the same repo. Ctrl stands in for
7
8
  // ⌘ off a Mac. The macOS app binds none of these to a menu item, so they
8
9
  // reach the page (tests/home-strip.test.tsx reads ShipkitGui.swift).
9
- export type PageKey = { t: 'repo'; n: number } | { t: 'step'; by: -1 | 1 } | { t: 'home' } | { t: 'planned' } | { t: 'activity' } | { t: 'picker' } | { t: 'dock' }
10
+ export type PageKey = { t: 'repo'; n: number } | { t: 'step'; by: -1 | 1 } | { t: 'home' } | { t: 'planned' } | { t: 'activity' } | { t: 'insights' } | { t: 'picker' } | { t: 'dock' } | { t: 'rail' }
10
11
 
11
12
  type KeyLike = { key: string; code: string; metaKey: boolean; ctrlKey: boolean; shiftKey: boolean; altKey: boolean }
12
13
 
@@ -14,26 +15,29 @@ export function pageKey(e: KeyLike): PageKey | null {
14
15
  if (!(e.metaKey || e.ctrlKey) || e.altKey) return null
15
16
  const digit = /^Digit([1-9])$/.exec(e.code)
16
17
  if (digit != null) return { t: 'repo', n: Number(digit[1]) }
17
- if (e.shiftKey) return null
18
+ if (e.shiftKey) return e.code === 'KeyB' || e.key.toLowerCase() === 'b' ? { t: 'dock' } : null
18
19
  if (e.key === 'ArrowUp') return { t: 'step', by: -1 }
19
20
  if (e.key === 'ArrowDown') return { t: 'step', by: 1 }
20
21
  if (e.code === 'Quote' || e.key === "'") return { t: 'planned' }
22
+ if (e.code === 'Semicolon' || e.key === ';') return { t: 'insights' }
21
23
  const k = e.key.toLowerCase()
22
24
  if (k === 'j') return { t: 'picker' }
23
25
  if (k === 'k') return { t: 'home' }
24
26
  if (k === 'l') return { t: 'activity' }
25
- if (k === 'b') return { t: 'dock' }
27
+ if (k === 'b') return { t: 'rail' }
26
28
  return null
27
29
  }
28
30
 
29
31
  /** the shortcut each place of the home carries on its button */
30
- export const PLACE_KEY: Record<'overview' | 'planned' | 'activity', string> & Partial<Record<string, string>> = { overview: '⌘K', planned: "⌘'", activity: '⌘L' }
32
+ export const PLACE_KEY: Record<'overview' | 'planned' | 'activity' | 'insights', string> & Partial<Record<string, string>> = { overview: '⌘K', planned: "⌘'", activity: '⌘L', insights: '⌘;' }
31
33
  /** the picker's shortcut, on the first button, before home */
32
34
  export const PICKER_KEY = '⌘J'
33
35
  /** the repo above and below, hinted at the project list's foot */
34
36
  export const STEP_KEYS = ['⌘↑', '⌘↓'] as const
35
37
  /** the dock's shortcut, on the dock's own header */
36
- export const DOCK_KEY = '⌘B'
38
+ export const DOCK_KEY = '⌘⇧B'
39
+ /** the project list between its full width and its icon rail */
40
+ export const RAIL_KEY = '⌘B'
37
41
 
38
42
  /** the tree's filter, on a repo page: a plain `/` focuses it, and its
39
43
  * badge at the input's right prints this */
@@ -54,8 +58,8 @@ export function isSearchKey(e: Pick<KeyLike, 'key' | 'metaKey' | 'ctrlKey' | 'al
54
58
  * plus a letter only types a character inside a text field, where the page
55
59
  * leaves the key alone. Read from the physical key (`code`): on a Mac ⌥C
56
60
  * types ç, and AZERTY moves the letters */
57
- export type SectionId = 'readme' | 'contracts' | 'shipkit' | 'repo' | 'secrets' | 'when' | 'runs' | 'plan' | 'processes' | 'git' | 'asks' | 'reflections' | 'tests' | 'media' | 'wikis' | 'admin'
58
- export const SECTION_LETTER: Record<SectionId, string> = { readme: 'E', contracts: 'K', shipkit: 'S', repo: 'C', secrets: 'X', when: 'O', runs: 'R', plan: 'N', processes: 'P', git: 'G', asks: 'A', reflections: 'F', tests: 'T', media: 'M', wikis: 'W', admin: 'D' }
61
+ export type SectionId = 'readme' | 'contracts' | 'shipkit' | 'repo' | 'secrets' | 'when' | 'runs' | 'plan' | 'processes' | 'git' | 'asks' | 'review' | 'reflections' | 'tests' | 'media' | 'trailer' | 'wikis' | 'admin'
62
+ export const SECTION_LETTER: Record<SectionId, string> = { readme: 'E', review: 'V', trailer: 'L', contracts: 'K', shipkit: 'S', repo: 'C', secrets: 'X', when: 'O', runs: 'R', plan: 'N', processes: 'P', git: 'G', asks: 'A', reflections: 'F', tests: 'T', media: 'M', wikis: 'W', admin: 'D' }
59
63
  /** a section's words on its row, where the id alone would not say it */
60
64
  export const SECTION_LABEL: Partial<Record<SectionId, string>> = { shipkit: 'shipkit checks', repo: 'repo checks' }
61
65
  export const sectionLabel = (id: SectionId): string => SECTION_LABEL[id] ?? id