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
@@ -0,0 +1,190 @@
1
+ // the wikis page's state, one per repo: the search bar over the open wiki
2
+ // (its title and full text, graph-pages.ts on the server), the hit under the
3
+ // arrows, and the page open in the preview panel. The graph is an iframe that
4
+ // only draws (wiki/graph-page.ts): a click on a ball arrives as a message,
5
+ // and opening a page answers with a focus message, so the ball and the
6
+ // preview always show the same page.
7
+ import { makeAutoObservable, runInAction } from 'mobx'
8
+ import type { WikiHit, WikiPage } from '../../wiki/graph-pages.ts'
9
+ import type { RepoSt } from './repo-st.ts'
10
+
11
+ const WIDTH_KEY = 'hub:wiki-preview-width'
12
+ const HEIGHT_KEY = 'hub:wiki-preview-height'
13
+ const PLACE_KEY = 'hub:wiki-preview-place'
14
+ export const PREVIEW = { min: 260, max: 1200, def: 460 }
15
+ /** the preview below the graph: its height */
16
+ export const PREVIEW_H = { min: 160, max: 1200, def: 360 }
17
+ /** where the preview sits against the graph, toggled from its own head */
18
+ export const PLACES = ['left', 'right', 'bottom'] as const
19
+ export type PreviewPlace = (typeof PLACES)[number]
20
+ /** the wait after a keystroke before the search asks the server */
21
+ const SEARCH_MS = 120
22
+
23
+ function loadSize(key: string, b: { min: number; max: number; def: number }): number {
24
+ try {
25
+ const n = Number(globalThis.localStorage?.getItem(key))
26
+ return Number.isFinite(n) && n >= b.min && n <= b.max ? n : b.def
27
+ } catch {
28
+ return b.def
29
+ }
30
+ }
31
+
32
+ function loadPlace(): PreviewPlace {
33
+ try {
34
+ const v = globalThis.localStorage?.getItem(PLACE_KEY) ?? ''
35
+ return (PLACES as readonly string[]).includes(v) ? (v as PreviewPlace) : 'left'
36
+ } catch {
37
+ return 'left'
38
+ }
39
+ }
40
+
41
+ function keep(key: string, v: string): void {
42
+ try {
43
+ globalThis.localStorage?.setItem(key, v)
44
+ } catch { /* private window: the choice lasts the tab */ }
45
+ }
46
+
47
+ export class WikiSt {
48
+ /** the open wiki's route, `/graph/<id>`: every read is under it */
49
+ href: string | null = null
50
+ query = ''
51
+ hits: WikiHit[] = []
52
+ /** the hit under the arrows */
53
+ cursor = 0
54
+ /** the dropdown shows */
55
+ listOpen = false
56
+ page: WikiPage | null = null
57
+ error = ''
58
+ width = loadSize(WIDTH_KEY, PREVIEW)
59
+ height = loadSize(HEIGHT_KEY, PREVIEW_H)
60
+ place: PreviewPlace = loadPlace()
61
+ /** the copy button said copied, for a moment */
62
+ copied = false
63
+ private frame: Window | null = null
64
+ private timer: ReturnType<typeof setTimeout> | null = null
65
+ private asked = 0
66
+
67
+ constructor(private readonly st: RepoSt) {
68
+ makeAutoObservable<this, 'st' | 'frame' | 'timer' | 'asked'>(this, { st: false, frame: false, timer: false, asked: false })
69
+ }
70
+
71
+ /** the wiki shown changed: its search starts over, and its index opens */
72
+ bind(href: string | null): void {
73
+ if (href === this.href) return
74
+ this.href = href
75
+ this.query = ''
76
+ this.hits = []
77
+ this.page = null
78
+ this.error = ''
79
+ this.listOpen = false
80
+ if (href != null) void this.open('')
81
+ }
82
+
83
+ /** the graph's iframe, to send it focus and theme */
84
+ setFrame(w: Window | null): void {
85
+ this.frame = w
86
+ }
87
+
88
+ send(msg: { type: 'wiki-graph:focus'; id: string } | { type: 'wiki-graph:theme'; theme: 'dark' | 'light' }): void {
89
+ this.frame?.postMessage(msg, '*')
90
+ }
91
+
92
+ setQuery(q: string): void {
93
+ this.query = q
94
+ this.cursor = 0
95
+ this.listOpen = q.trim() !== ''
96
+ if (this.timer != null) clearTimeout(this.timer)
97
+ if (q.trim() === '') {
98
+ this.hits = []
99
+ return
100
+ }
101
+ this.timer = setTimeout(() => void this.search(q), SEARCH_MS)
102
+ }
103
+
104
+ private async search(q: string): Promise<void> {
105
+ if (this.href == null) return
106
+ const n = ++this.asked
107
+ try {
108
+ const r = await fetch(this.st.url(`${this.href}/search?q=${encodeURIComponent(q)}`))
109
+ if (!r.ok) throw new Error(`${r.status} ${await r.text()}`)
110
+ const hits = (await r.json()) as WikiHit[]
111
+ // an answer to an older query never replaces a newer one
112
+ if (n === this.asked) runInAction(() => { this.hits = hits; this.error = '' })
113
+ } catch (e) {
114
+ if (n === this.asked) runInAction(() => (this.error = `search failed: ${(e as Error).message}`))
115
+ }
116
+ }
117
+
118
+ /** the search input's keys: up and down walk the hits, enter opens one, escape closes the list */
119
+ key(k: string): boolean {
120
+ if (k === 'Escape') {
121
+ if (!this.listOpen) return false
122
+ this.listOpen = false
123
+ return true
124
+ }
125
+ if (this.hits.length === 0) return false
126
+ if (k === 'ArrowDown') this.cursor = Math.min(this.hits.length - 1, this.cursor + 1)
127
+ else if (k === 'ArrowUp') this.cursor = Math.max(0, this.cursor - 1)
128
+ else if (k === 'Enter') void this.open(this.hits[this.cursor]!.id)
129
+ else return false
130
+ if (k !== 'Enter') this.listOpen = true
131
+ return true
132
+ }
133
+
134
+ /** a page opens in the preview, and its ball is focused in the graph; '' = the wiki's index */
135
+ async open(id: string): Promise<void> {
136
+ const href = this.href
137
+ if (href == null) return
138
+ this.listOpen = false
139
+ if (id !== '') this.send({ type: 'wiki-graph:focus', id })
140
+ try {
141
+ const r = await fetch(this.st.url(`${href}/page?node=${encodeURIComponent(id)}`))
142
+ if (!r.ok) throw new Error(`${r.status} ${await r.text()}`)
143
+ const p = (await r.json()) as WikiPage
144
+ // the wiki changed while the page was read: it is not this one's
145
+ if (href !== this.href) return
146
+ runInAction(() => { this.page = p; this.error = '' })
147
+ if (id === '') this.send({ type: 'wiki-graph:focus', id: p.id })
148
+ } catch (e) {
149
+ runInAction(() => (this.error = `${id === '' ? 'the index' : id} would not open: ${(e as Error).message}`))
150
+ }
151
+ }
152
+
153
+ setPlace(p: PreviewPlace): void {
154
+ this.place = p
155
+ keep(PLACE_KEY, p)
156
+ }
157
+
158
+ /** the open page's path on disk, to the clipboard */
159
+ async copyPath(): Promise<void> {
160
+ if (this.page == null) return
161
+ try {
162
+ await navigator.clipboard.writeText(this.page.file)
163
+ runInAction(() => (this.copied = true))
164
+ setTimeout(() => runInAction(() => (this.copied = false)), 1500)
165
+ } catch (e) {
166
+ runInAction(() => (this.error = `the path was not copied: ${(e as Error).message}`))
167
+ }
168
+ }
169
+
170
+ close(): void {
171
+ this.page = null
172
+ }
173
+
174
+ /** a drag of the preview's separator ended at this size: its width beside the graph, its height under it */
175
+ setSize(px: number): void {
176
+ if (this.place === 'bottom') {
177
+ this.height = Math.round(Math.min(PREVIEW_H.max, Math.max(PREVIEW_H.min, px)))
178
+ keep(HEIGHT_KEY, String(this.height))
179
+ } else {
180
+ this.width = Math.round(Math.min(PREVIEW.max, Math.max(PREVIEW.min, px)))
181
+ keep(WIDTH_KEY, String(this.width))
182
+ }
183
+ }
184
+
185
+ /** a message from the graph: a ball was clicked */
186
+ onMessage(data: unknown): void {
187
+ const m = data as { type?: unknown; id?: unknown } | null
188
+ if (m != null && m.type === 'wiki-graph:pick' && typeof m.id === 'string') void this.open(m.id)
189
+ }
190
+ }
@@ -1,7 +1,16 @@
1
1
  // The wikis page: one tab per mounted markdown wiki, one link graph at a
2
2
  // time, embedded from its own /graph/<id> route. A tab is an item of the
3
3
  // wikis collection, so the open wiki is in the url like any other page.
4
+ // Over the graph a search bar (title and full text, a dropdown the arrows
5
+ // walk); a hit, or a click on a ball, opens the page in a resizable panel on
6
+ // the right, whose refs open their page in turn and focus its ball (wiki-st.ts).
4
7
  import { observer } from 'mobx-react-lite'
8
+ import { useEffect, useRef, type MouseEvent } from 'react'
9
+ import { Group, Panel, Separator, type Layout } from 'react-resizable-panels'
10
+ import { renderWikiPage } from './md.ts'
11
+ import { editorHref } from './check-words.ts'
12
+ import { Icon, ICON } from './icons.tsx'
13
+ import { PLACES, PREVIEW, PREVIEW_H, type PreviewPlace, type WikiSt } from './wiki-st.ts'
5
14
  import type { RepoSt } from './repo-st.ts'
6
15
  import type { Resource } from './hub-types.ts'
7
16
 
@@ -33,8 +42,13 @@ export function openWikis(app: RepoSt): void {
33
42
  else app.showCol(WIKIS)
34
43
  }
35
44
 
45
+ const PANEL = { overflow: 'hidden' } as const
46
+
36
47
  export const WikisView = observer(({ app, id }: { app: RepoSt; id: string }) => {
37
48
  const i = app.cols.find(WIKIS, id)
49
+ const w = app.wiki
50
+ const href = i?.href ?? null
51
+ useEffect(() => w.bind(href), [w, href])
38
52
  const tabs = (
39
53
  <span className="seg">
40
54
  {wikiTabs(app).map((t) => <button key={t.id} className={t.on ? 'on' : ''} onClick={t.go}><span className={`tl ${t.state}`}>{t.label}</span></button>)}
@@ -45,13 +59,128 @@ export const WikisView = observer(({ app, id }: { app: RepoSt; id: string }) =>
45
59
  <div className="graphview">
46
60
  <div className="gvhead">
47
61
  {tabs}
62
+ {href == null ? null : <WikiSearch w={w} />}
48
63
  <span className={`gvtext tl ${i.state}`}>{i.text}</span>
49
64
  </div>
50
- {i.href == null ? <div className="ph">this wiki carries no page to embed</div> : (
51
- // the spinner sits under the frame: the graph page paints its
52
- // own background over it the moment it has something to show
53
- <div className="gvframe"><span className="gvwait"><span className="spin" />reading the wiki…</span><iframe src={app.url(i.href)} title={i.title} /></div>
54
- )}
65
+ {w.error !== '' ? <div className="hnote">{w.error}</div> : null}
66
+ {href == null ? <div className="ph">this wiki carries no page to embed</div> : <WikiBody app={app} w={w} href={href} title={i.title} />}
67
+ </div>
68
+ )
69
+ })
70
+
71
+ /** the graph and, once a page is open, its preview on the right; the preview's width is dragged and kept */
72
+ const WikiBody = observer(({ app, w, href, title }: { app: RepoSt; w: WikiSt; href: string; title: string }) => {
73
+ const el = useRef<HTMLDivElement | null>(null)
74
+ const frame = useRef<HTMLIFrameElement | null>(null)
75
+ const theme = app.home.theme.resolved
76
+ // the theme the graph first paints in rides its url; a later change is a message, never a reload
77
+ const first = useRef(theme)
78
+ useEffect(() => w.send({ type: 'wiki-graph:theme', theme }), [w, theme])
79
+ useEffect(() => {
80
+ const on = (ev: MessageEvent): void => {
81
+ if (ev.source != null && ev.source === frame.current?.contentWindow) w.onMessage(ev.data)
82
+ }
83
+ addEventListener('message', on)
84
+ return () => removeEventListener('message', on)
85
+ }, [w])
86
+ const sep = href.includes('?') ? '&' : '?'
87
+ const below = w.place === 'bottom'
88
+ const graph = (
89
+ <Panel key="graph" id="wiki-graph" minSize={below ? 160 : 280} style={PANEL}>
90
+ {/* the spinner sits under the frame: the graph page paints its own background over it the moment it has something to show */}
91
+ <div className="gvframe"><span className="gvwait"><span className="spin" />reading the wiki…</span>
92
+ <iframe ref={(f) => { frame.current = f; w.setFrame(f?.contentWindow ?? null) }} src={`${app.url(href)}${sep}theme=${first.current}`} title={title}
93
+ onLoad={() => {
94
+ w.setFrame(frame.current?.contentWindow ?? null)
95
+ w.send({ type: 'wiki-graph:theme', theme: app.home.theme.resolved })
96
+ if (w.page != null) w.send({ type: 'wiki-graph:focus', id: w.page.id })
97
+ }} />
98
+ </div>
99
+ </Panel>
100
+ )
101
+ const preview = w.page == null ? null : (
102
+ <Panel key="preview" id="wiki-preview" defaultSize={below ? w.height : w.width} minSize={below ? PREVIEW_H.min : PREVIEW.min} maxSize={below ? PREVIEW_H.max : PREVIEW.max}
103
+ groupResizeBehavior="preserve-pixel-size" style={PANEL}>
104
+ <WikiPreview w={w} />
105
+ </Panel>
106
+ )
107
+ const line = preview == null ? null : <Separator key="sep" className={below ? 'rsep hsep' : 'rsep'} />
108
+ return (
109
+ // the group is remade when the preview moves: its panels change order and axis
110
+ <Group key={w.place} className="gvbody" orientation={below ? 'vertical' : 'horizontal'} elementRef={el}
111
+ onLayoutChanged={(layout: Layout, meta) => {
112
+ const pct = layout['wiki-preview']
113
+ const r = el.current?.getBoundingClientRect()
114
+ const whole = below ? r?.height : r?.width
115
+ if (meta.isUserInteraction && pct != null && whole != null && whole > 0) w.setSize((pct / 100) * whole)
116
+ }}>
117
+ {w.place === 'left' ? [preview, line, graph] : [graph, line, preview]}
118
+ </Group>
119
+ )
120
+ })
121
+
122
+ /** the search bar: the hits drop under it, the arrows walk them, enter or a click opens one */
123
+ const WikiSearch = observer(({ w }: { w: WikiSt }) => (
124
+ <span className="wsearch">
125
+ <input type="search" placeholder="search the wiki: a title, a word…" aria-label="search the wiki" value={w.query}
126
+ onChange={(e) => w.setQuery(e.currentTarget.value)}
127
+ onFocus={() => { if (w.hits.length > 0) w.key('ArrowUp') }}
128
+ onBlur={() => w.key('Escape')}
129
+ onKeyDown={(e) => { if (w.key(e.key)) e.preventDefault() }} />
130
+ {w.listOpen && w.query.trim() !== '' ? (
131
+ <div className="wslist" role="listbox">
132
+ {w.hits.length === 0 ? <div className="wsnone">no page matches</div> : w.hits.map((h, n) => (
133
+ // mouse down, not click: the input's blur would close the list first
134
+ <div key={h.id} role="option" aria-selected={n === w.cursor} className={`wshit${n === w.cursor ? ' on' : ''}`}
135
+ onMouseDown={(e) => { e.preventDefault(); void w.open(h.id) }}>
136
+ <div className="wshead"><b>{h.title}</b><span className="wspath">{h.id}</span></div>
137
+ {h.snippet === '' || h.inTitle && h.snippet === '' ? null : <div className="wssnip">{h.snippet}</div>}
138
+ </div>
139
+ ))}
140
+ </div>
141
+ ) : null}
142
+ </span>
143
+ ))
144
+
145
+ const PLACE_ICON: Record<PreviewPlace, keyof typeof ICON> = { left: 'paneLeft', right: 'paneRight', bottom: 'paneBottom' }
146
+
147
+ /** the open page: its title and path, where the panel sits, the editor and
148
+ * the copy, the close; then the markdown, whose refs open their own page */
149
+ const WikiPreview = observer(({ w }: { w: WikiSt }) => {
150
+ const p = w.page!
151
+ const editor = editorHref('', p.file)
152
+ const click = (e: MouseEvent<HTMLDivElement>): void => {
153
+ const t = e.target as HTMLElement
154
+ const a = t.closest('a[data-wiki]')
155
+ if (a != null) {
156
+ e.preventDefault()
157
+ void w.open(a.getAttribute('data-wiki')!)
158
+ return
159
+ }
160
+ // a ref in a code span opens its page by its text
161
+ const code = t.closest('code')
162
+ const to = code == null ? undefined : p.links[code.textContent ?? '']
163
+ if (to != null) void w.open(to)
164
+ }
165
+ return (
166
+ <div className="wprev">
167
+ <div className="wphead">
168
+ <b className="wptitle">{p.title}</b>
169
+ <span className="wspath" title={p.file}>{p.id}</span>
170
+ <span className="seg wpplace" aria-label="where the preview sits">
171
+ {PLACES.map((pl) => (
172
+ <button key={pl} className={w.place === pl ? 'on' : ''} aria-label={`the preview on the ${pl}`} title={`the preview on the ${pl}`} onClick={() => w.setPlace(pl)}>
173
+ <Icon d={ICON[PLACE_ICON[pl]]} />
174
+ </button>
175
+ ))}
176
+ </span>
177
+ {editor == null ? null : (
178
+ <a className="wpbtn" href={editor} title={`open ${p.file} in VS Code`}><Icon d={ICON.editor} /><span>VS Code</span></a>
179
+ )}
180
+ <button className="wpbtn" title={`copy ${p.file}`} onClick={() => void w.copyPath()}><Icon d={ICON.copy} /><span>{w.copied ? 'copied' : 'copy path'}</span></button>
181
+ <button className="ghost wpclose" aria-label="close the preview" title="close the preview" onClick={() => w.close()}>×</button>
182
+ </div>
183
+ <div className="wpbody md readme-body" onClick={click} dangerouslySetInnerHTML={{ __html: renderWikiPage(p.md, p.links) }} />
55
184
  </div>
56
185
  )
57
186
  })
@@ -146,15 +146,15 @@ export async function resolveHubDeps(root: string, boot: RepoConfig, io: ServeIo
146
146
  // agent working here obeys that wiki, so a reader can open it.
147
147
  const graphs: GraphMount[] = []
148
148
  if (c.serve?.wikiGraph != null)
149
- graphs.push({ id: 'wiki', label: WIKI_LABEL, title: c.serve.wikiGraph.title ?? `${c.name} wiki`, load: c.serve.wikiGraph.load, lint: c.serve.wikiGraph.lint != null ? { checks: c.serve.wikiGraph.lint } : { root } })
149
+ graphs.push({ id: 'wiki', label: WIKI_LABEL, title: c.serve.wikiGraph.title ?? `${c.name} wiki`, load: c.serve.wikiGraph.load, dir: c.serve.wikiGraph.dir ?? root, lint: c.serve.wikiGraph.lint != null ? { checks: c.serve.wikiGraph.lint } : { root } })
150
150
  else if (existsSync(join(root, 'wiki')))
151
- graphs.push({ id: 'wiki', label: WIKI_LABEL, title: `${c.name} wiki`, load: () => scanMdGraph({ root }), lint: { root } })
151
+ graphs.push({ id: 'wiki', label: WIKI_LABEL, title: `${c.name} wiki`, load: () => scanMdGraph({ root }), dir: root, lint: { root } })
152
152
  const baseWiki = baseWikiRoot(root)
153
153
  if (baseWiki != null)
154
- graphs.push({ id: 'base', label: wikiLabel(baseWiki), title: wikiLabel(baseWiki), load: () => scanMdGraph({ root: baseWiki }), lint: null })
154
+ graphs.push({ id: 'base', label: wikiLabel(baseWiki), title: wikiLabel(baseWiki), load: () => scanMdGraph({ root: baseWiki }), dir: baseWiki, lint: null })
155
155
  const agentRoot = join(root, 'agent')
156
156
  if (existsSync(agentRoot))
157
- graphs.push({ id: 'agent', label: 'agent/', title: `${c.name} agent/`, load: () => scanMdGraph({ root: agentRoot, index: c.serve?.agentIndex }), lint: { root: agentRoot, index: c.serve?.agentIndex } })
157
+ graphs.push({ id: 'agent', label: 'agent/', title: `${c.name} agent/`, load: () => scanMdGraph({ root: agentRoot, index: c.serve?.agentIndex }), dir: agentRoot, lint: { root: agentRoot, index: c.serve?.agentIndex } })
158
158
  // ONE page for every repo: the studio ALWAYS
159
159
  // mounts — kit-less repos get an empty media section, never a second
160
160
  // page. A broken kit degrades to the same empty kit, loudly, and a repo
@@ -30,6 +30,8 @@ export type { Gate }
30
30
  export const POLL_MS = 5_000
31
31
  /** the app icon preview: sharp on a 2x screen at the admin page's 128px */
32
32
  const PREVIEW_PX = 256
33
+ /** the rail's app icon, cropped to the plate: 40px at 2x, the largest rail size */
34
+ const RAIL_PX = 96
33
35
  /** how long a release answer holds for a checkout whose `.git` is not a
34
36
  * folder, since no stat can say when it moved */
35
37
  export const RELEASE_TTL_MS = 60_000
@@ -74,6 +76,8 @@ export type ProjectRow = {
74
76
  live: string | null
75
77
  /** the colours its repo.config.ts declares (SK024), null when it declares none */
76
78
  identity: Identity | null
79
+ /** its app icon cropped to the plate (core/app-icon.ts), what the folded rail draws; null with no mark */
80
+ appIcon: string | null
77
81
  /** epoch ms of the newest of: the last commit, the last shipkit run, the
78
82
  * newest Claude Code transcript. null when none of them exists */
79
83
  activity: number | null
@@ -298,6 +302,15 @@ export function projectGutter(d: GutterDeps) {
298
302
  }
299
303
 
300
304
  const iconUrl = (r: WiredRepo): string | null => (r.icon == null ? null : `/api/projects/icon/${encodeURIComponent(r.name)}?v=${iconStamp(join(r.path, r.icon))}`)
305
+ // the url names the mark's stamp and both colours, so a new mark or a colour change is a new url, cached a day
306
+ const railIconUrl = (r: WiredRepo, id: Identity): string | null => {
307
+ const mark = appIconOf(r.path)
308
+ if (mark == null) return null
309
+ const q = new URLSearchParams({ plate: '1', light: id.colorLight, dark: id.colorDark, v: String(iconStamp(mark)) })
310
+ return `/api/projects/app-icon/${encodeURIComponent(r.name)}?${q}`
311
+ }
312
+ /** drawn plates, by url: a page load asks for every repo's at once */
313
+ const plates = new Map<string, Promise<Buffer>>()
301
314
 
302
315
  async function measure(): Promise<ProjectsAnswer> {
303
316
  const t0 = performance.now()
@@ -334,6 +347,7 @@ export function projectGutter(d: GutterDeps) {
334
347
  release: facts[i]!.release,
335
348
  live: liveUrlOf(config),
336
349
  identity: identityOfText(config),
350
+ appIcon: railIconUrl(r, identityOfRoot(r.path)),
337
351
  activity: lastActivity(facts[i]!.commitAt, lastRunAt(r.path), transcripts.get(r.path)),
338
352
  commit: facts[i]!.commitAt,
339
353
  }
@@ -386,12 +400,22 @@ export function projectGutter(d: GutterDeps) {
386
400
  const r = findAny(decodeURIComponent(path.slice('/api/projects/app-icon/'.length)))
387
401
  if (r == null) return new Response('not a house repo', { status: 404 })
388
402
  const icon = appIconOf(r.path)
389
- if (icon == null) return new Response('no png mark: the app wears the default icon', { status: 404 })
403
+ if (icon == null) return new Response('no mark: the app wears the default icon', { status: 404 })
390
404
  // the colours a picker is on right now, before they are saved; else the config's
391
405
  const q = new URL(req.url).searchParams
392
406
  const asked = { colorLight: q.get('light') ?? '', colorDark: q.get('dark') ?? '' }
393
407
  const id = identityProblem(asked) == null ? asked : identityOfRoot(r.path)
394
408
  try {
409
+ if (q.get('plate') === '1') {
410
+ const key = `${r.path}?${q}`
411
+ let png = plates.get(key)
412
+ if (png == null) {
413
+ png = readFile(icon).then(async (b) => (await appIconPngs(b, id, [RAIL_PX], 'plate'))[0]!)
414
+ plates.set(key, png)
415
+ png.catch(() => plates.delete(key))
416
+ }
417
+ return new Response(new Uint8Array(await png), { headers: { 'content-type': 'image/png', 'cache-control': 'max-age=86400' } })
418
+ }
395
419
  const [png] = await appIconPngs(await readFile(icon), id, [PREVIEW_PX])
396
420
  return new Response(new Uint8Array(png!), { headers: { 'content-type': 'image/png', 'cache-control': 'max-age=60' } })
397
421
  } catch (e) {
@@ -48,7 +48,7 @@ export function houseIndex(repo: string, home: string): string | null {
48
48
 
49
49
  /** a repo's own icon, first hit wins. `.shipkit/identity/icon.png` is the rail
50
50
  * icon a repo with no public surface carries, the identity kit writes public/ */
51
- export const ICON_FILES = ['.shipkit/identity/icon.png', 'public/favicon.png', 'public/favicon.svg', 'public/favicon.ico', 'favicon.ico', 'public/logo.png']
51
+ export const ICON_FILES = ['.shipkit/identity/icon.png', '.shipkit/identity/icon.svg', 'public/favicon.png', 'public/favicon.svg', 'public/favicon.ico', 'favicon.ico', 'public/logo.png']
52
52
 
53
53
  export const ICON_TYPES: Record<string, string> = { png: 'image/png', svg: 'image/svg+xml', ico: 'image/x-icon' }
54
54
 
@@ -7,6 +7,7 @@
7
7
  import { existsSync } from 'fs'
8
8
  import { join } from 'path'
9
9
  import { positionsFile } from 'rvlib-shipkit/src/wiki/graph-positions.ts'
10
+ import { pageOf, searchWiki } from 'rvlib-shipkit/src/wiki/graph-pages.ts'
10
11
  import { graphRoutes } from 'rvlib-shipkit/src/wiki/serve-graph.ts'
11
12
  import type { StudioRoutes } from '../assets/server/studio-server.ts'
12
13
  import { comfyBoxUp, type ComfyBox, type ComfyPanelUp } from '../comfy/comfy-panel.ts'
@@ -411,6 +412,12 @@ export function hubHandler(d: HubDeps, o: { base?: string; projects?: Gutter } =
411
412
  if (path === `/graph/${g.id}`) return page(routes.page())
412
413
  if (path === `/graph/${g.id}/graph.json`) return Response.json(held)
413
414
  if (path === `/graph/${g.id}/positions`) return routes.save(req)
415
+ // the wikis page's search bar and its preview panel: only a node of the graph is read
416
+ if (path === `/graph/${g.id}/search`) return g.dir == null ? new Response('this wiki names no folder to read', { status: 404 }) : Response.json(searchWiki(held, g.dir, new URL(req.url).searchParams.get('q') ?? ''))
417
+ if (path === `/graph/${g.id}/page`) {
418
+ const p = g.dir == null ? null : pageOf(held, g.dir, new URL(req.url).searchParams.get('node') ?? '')
419
+ return p == null ? new Response('not a page of this wiki', { status: 404 }) : Response.json(p)
420
+ }
414
421
  }
415
422
  // the media API namespace — the studio route table, ONE prefix strip
416
423
  if (d.studio != null && path.startsWith('/media/')) return d.studio.handle(req, path.slice('/media'.length))
@@ -8,7 +8,7 @@ import type { ShotDone } from './shooter.ts'
8
8
  /** the global process id pm knows the service by (shot.shipkit.ts) */
9
9
  export const SHOT_PROCESS = 'shot'
10
10
 
11
- export type ShotInput = { url: string; out: string; viewport?: Viewport; dpr?: number; reload?: boolean; waitMs?: number }
11
+ export type ShotInput = { url: string; out: string; viewport?: Viewport; dpr?: number; reload?: boolean; waitMs?: number; step?: string; at?: number }
12
12
  export type ClipInput = ShotInput & { seconds: number; fps?: number; dom?: boolean }
13
13
  export type ShotResult = ShotDone & { via: 'warm' | 'started' | 'local' }
14
14
 
@@ -21,13 +21,25 @@ const shotAsk = (i: ShotInput): ShotAsk => ({
21
21
  waitMs: i.waitMs ?? DEFAULT_WAIT_MS,
22
22
  })
23
23
 
24
- /** a png of `url` at `out` (absolute), through the warm browser */
25
- export const shotUrl = (i: ShotInput, log: (l: string) => void = () => {}): Promise<ShotResult> => ask('/shot', shotAsk(i), log)
24
+ /** a service of an older shipkit takes a stepped ask, ignores the step and shoots the page as it loads: never kept */
25
+ function stepped(i: { step?: string }, r: ShotResult): void {
26
+ if (i.step && r.stepped !== true) throw new Error(`the shot service on ${shotUrlBase()} did not step the page (it says: ${r.note}); it runs an older shipkit: sk gp restart ${SHOT_PROCESS}`)
27
+ }
28
+
29
+ /** a png of `url` at `out` (absolute), through the warm browser; with `step`, the page drawn at `at` first */
30
+ export async function shotUrl(i: ShotInput, log: (l: string) => void = () => {}): Promise<ShotResult> {
31
+ const r = await ask('/shot', { ...shotAsk(i), ...(i.step ? { step: i.step, at: i.at ?? 0 } : {}) }, log)
32
+ stepped(i, r)
33
+ return r
34
+ }
26
35
 
27
36
  /** an mp4 of `seconds` of `url` at `out` (absolute): a page that is a canvas through its WebCodecs, any other page (or
28
37
  * `dom`) as chromium paints it */
29
- export const clipUrl = (i: ClipInput, log: (l: string) => void = () => {}): Promise<ShotResult> =>
30
- ask('/clip', { ...shotAsk(i), seconds: i.seconds, fps: i.fps ?? DEFAULT_FPS, dom: i.dom ?? false } satisfies ClipAsk, log)
38
+ export async function clipUrl(i: ClipInput, log: (l: string) => void = () => {}): Promise<ShotResult> {
39
+ const r = await ask('/clip', { ...shotAsk(i), seconds: i.seconds, fps: i.fps ?? DEFAULT_FPS, dom: i.dom ?? false, ...(i.step ? { step: i.step } : {}) } satisfies ClipAsk, log)
40
+ stepped(i, r)
41
+ return r
42
+ }
31
43
 
32
44
  /** null when nothing listens (start it), else the service's answer; a refusal throws its reason */
33
45
  async function post(route: string, a: ShotAsk): Promise<ShotDone | null> {
@@ -59,7 +59,7 @@ export const SHOT_NICE = 10
59
59
  /** a page that paints nothing once the screencast starts is filmed from one still taken after this */
60
60
  const FIRST_FRAME_MS = 1000
61
61
 
62
- export type ShotDone = { out: string; ms: number; phases: Record<string, number>; errors: string[]; note: string }
62
+ export type ShotDone = { out: string; ms: number; phases: Record<string, number>; errors: string[]; note: string; stepped?: true }
63
63
 
64
64
  type Slot = { key: string; url: string; ctx: Context; page: Page; cdp: Cdp; errors: string[]; frozen: boolean }
65
65
 
@@ -169,6 +169,8 @@ export class Shooter {
169
169
  const phases: Record<string, number> = {}
170
170
  const t0 = performance.now()
171
171
  let move = pageMove(this.#slot == null || this.#slot.page.isClosed() ? null : { url: this.#slot.url, key: this.#slot.key }, a)
172
+ // a stepped still draws its moment on a fresh load, never on a page some other shot left at another time
173
+ if (a.step && move === 'none') move = 'reload'
172
174
  if (move === 'boot') {
173
175
  const old = this.#slot
174
176
  this.#slot = null
@@ -200,13 +202,22 @@ export class Shooter {
200
202
  let t = performance.now()
201
203
  const how = await waitReady(() => slot.page.evaluate(readMark), move === 'none' ? 0 : a.waitMs, { now: () => performance.now(), sleep: (n) => slot.page.waitForTimeout(n) })
202
204
  phases['ready'] = ms(t)
205
+ if (a.step) {
206
+ t = performance.now()
207
+ const { stepTo } = await import('./stepped-clip.ts')
208
+ if (!(await slot.page.evaluate(stepTo, [a.step, a.at ?? 0] as [string, number]))) throw new Error(`the page has no window.${a.step}(t): a stepped page draws the time it is given there`)
209
+ await waitReady(() => slot.page.evaluate(readMark), 0, { now: () => performance.now(), sleep: (n) => slot.page.waitForTimeout(n) })
210
+ // the warm page now shows that moment: the next plain shot of this url loads it again
211
+ slot.url = ''
212
+ phases['step'] = ms(t)
213
+ }
203
214
  t = performance.now()
204
215
  const r = await slot.cdp.send('Page.captureScreenshot', { format: 'png', optimizeForSpeed: true })
205
216
  phases['capture'] = ms(t)
206
217
  mkdirSync(dirname(a.out), { recursive: true })
207
218
  await Bun.write(a.out, Buffer.from(String(r['data']), 'base64'))
208
- const note = `${move === 'none' ? 'warm page' : thawed ? 'frozen page reloaded' : move}, ${how === 'ready' ? 'ready mark' : move === 'none' ? 'no wait' : `${a.waitMs} ms after load`}`
209
- return { out: a.out, ms: ms(t0), phases, errors: [...slot.errors], note }
219
+ const note = `${move === 'none' ? 'warm page' : thawed ? 'frozen page reloaded' : move}, ${how === 'ready' ? 'ready mark' : move === 'none' ? 'no wait' : `${a.waitMs} ms after load`}${a.step ? `, drawn at ${a.at ?? 0}s through window.${a.step}(t)` : ''}`
220
+ return { out: a.out, ms: ms(t0), phases, errors: [...slot.errors], note, ...(a.step ? { stepped: true as const } : {}) }
210
221
  }
211
222
 
212
223
  async #clip(a: ClipAsk): Promise<ShotDone> {
@@ -223,6 +234,11 @@ export class Shooter {
223
234
  await waitReady(() => page.evaluate(readMark), a.waitMs, { now: () => performance.now(), sleep: (n) => page.waitForTimeout(n) })
224
235
  phases['load'] = ms(t)
225
236
  t = performance.now()
237
+ if (a.step) {
238
+ const note = await (await import('./stepped-clip.ts')).stepPage(await ctx.newCDPSession(page), page, a as ClipAsk & { step: string })
239
+ phases['film'] = ms(t)
240
+ return { out: a.out, ms: ms(t0), phases, errors, note, stepped: true }
241
+ }
226
242
  const path: ClipPath = clipPathOf({ dom: a.dom === true, canvas: await page.evaluate(largestCanvas), viewport: a.viewport })
227
243
  const why = path === 'canvas' ? await page.evaluate(installClip, { seconds: a.seconds, fps: a.fps, quantizer: CLIP_QUANTIZER, keyEvery: KEY_EVERY } satisfies PageClipAsk) : null
228
244
  if (path === 'dom' || why != null) {
@@ -17,6 +17,9 @@ export type ShotAsk = {
17
17
  reload: boolean
18
18
  /** a page with no ready mark is shot this long after its load (a warm page at once) */
19
19
  waitMs: number
20
+ /** a still of a stepped page: `window[step](at)` on a fresh load, then its ready mark, then the shot */
21
+ step?: string
22
+ at?: number
20
23
  }
21
24
 
22
25
  export type ClipAsk = ShotAsk & {
@@ -24,6 +27,9 @@ export type ClipAsk = ShotAsk & {
24
27
  fps: number
25
28
  /** film the page itself even when a canvas fills it (dom-clip.ts); a page with no big canvas is filmed so anyway */
26
29
  dom?: boolean
30
+ /** step the page frame by frame (stepped-clip.ts): before each frame `window[step](t)`, t its time in seconds, then
31
+ * its ready mark; a slow page films as smooth as a fast one */
32
+ step?: string
27
33
  }
28
34
 
29
35
  export const DEFAULT_SHOT_PORT = 4796
@@ -113,7 +119,9 @@ export const isClip = (a: ShotAsk | ClipAsk): a is ClipAsk => 'seconds' in a
113
119
 
114
120
  /** a request held past this (a clip: past its own seconds) fails loud and frees the turn */
115
121
  export const REQUEST_MAX_MS = 120_000
116
- export const capMsOf = (a: ShotAsk | ClipAsk): number => REQUEST_MAX_MS + (isClip(a) ? a.seconds * 1000 : 0)
122
+ export const capMsOf = (a: ShotAsk | ClipAsk): number => REQUEST_MAX_MS + (isClip(a) ? (a.step ? a.seconds * a.fps * STEP_FRAME_MS : a.seconds * 1000) : 0)
123
+ /** a stepped frame's budget in the request's cap: a page that draws a frame slower than this is no page to step */
124
+ export const STEP_FRAME_MS = 1000
117
125
 
118
126
  // ── the ready mark ──────────────────────────────────────────────────────────
119
127
  // A page that knows when it is drawn says so: `<meta name="shot-ready"