rvlib-shipkit 2.2.0 → 2.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (140) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/package.json +1 -1
  3. package/src/agent-time/mask.ts +43 -0
  4. package/src/agent-time/render.ts +187 -0
  5. package/src/agent-time/report.ts +335 -0
  6. package/src/agent-time/shape.ts +271 -0
  7. package/src/agent-time/signals.ts +84 -0
  8. package/src/agent-time/store.ts +379 -0
  9. package/src/agent-time/timeline.ts +218 -0
  10. package/src/agent-time/turns.ts +135 -0
  11. package/src/assets/kits/identity-kit.ts +5 -2
  12. package/src/cli/cmd_Commands.ts +9 -0
  13. package/src/cli/cmd_Review.ts +974 -0
  14. package/src/cli/cmd_Sound.ts +209 -0
  15. package/src/cli/cmd_Time.ts +112 -0
  16. package/src/cli/cmd_Trailer.ts +264 -6
  17. package/src/cli/kit/verb.ts +22 -4
  18. package/src/cli.ts +8 -4
  19. package/src/collections/collection-types.ts +6 -1
  20. package/src/collections/providers.ts +3 -0
  21. package/src/collections/review.ts +70 -0
  22. package/src/core/app-icon.ts +10 -6
  23. package/src/core/layout.ts +2 -0
  24. package/src/reflect/prompt-corpus.ts +15 -119
  25. package/src/reflect/transcript.ts +254 -0
  26. package/src/repo-config.ts +3 -0
  27. package/src/review/anim.ts +228 -0
  28. package/src/review/auto.ts +141 -0
  29. package/src/review/format.ts +158 -0
  30. package/src/review/http.ts +23 -0
  31. package/src/review/marks.ts +43 -0
  32. package/src/review/media.ts +45 -0
  33. package/src/review/model.ts +896 -0
  34. package/src/review/project.ts +126 -0
  35. package/src/review/role.ts +48 -0
  36. package/src/review/routes.ts +239 -0
  37. package/src/review/store.ts +1011 -0
  38. package/src/review/threads.ts +237 -0
  39. package/src/review/trailer-desk.ts +385 -0
  40. package/src/review/trailer-model.ts +183 -0
  41. package/src/review/trailer-routes.ts +136 -0
  42. package/src/review/unit.ts +183 -0
  43. package/src/review/watchers.ts +54 -0
  44. package/src/serve/embed.ts +35 -0
  45. package/src/serve/hub-app/activity-view.tsx +1 -1
  46. package/src/serve/hub-app/activity.css +0 -4
  47. package/src/serve/hub-app/admin-svc.ts +1 -1
  48. package/src/serve/hub-app/admin-view.tsx +1 -1
  49. package/src/serve/hub-app/dock-view.tsx +5 -4
  50. package/src/serve/hub-app/hash-route.ts +36 -1
  51. package/src/serve/hub-app/header-bar.tsx +3 -1
  52. package/src/serve/hub-app/home-st.ts +17 -3
  53. package/src/serve/hub-app/home-strip.tsx +3 -2
  54. package/src/serve/hub-app/home.html +1 -0
  55. package/src/serve/hub-app/hub-app.tsx +80 -11
  56. package/src/serve/hub-app/hub.css +83 -14
  57. package/src/serve/hub-app/icons.tsx +15 -0
  58. package/src/serve/hub-app/index.html +1 -0
  59. package/src/serve/hub-app/insights-view.tsx +4 -3
  60. package/src/serve/hub-app/keys.ts +14 -10
  61. package/src/serve/hub-app/layout-st.ts +63 -2
  62. package/src/serve/hub-app/md.ts +32 -0
  63. package/src/serve/hub-app/planned-view.tsx +3 -2
  64. package/src/serve/hub-app/planned.css +0 -1
  65. package/src/serve/hub-app/project-list.css +26 -6
  66. package/src/serve/hub-app/project-list.tsx +46 -10
  67. package/src/serve/hub-app/projects-svc.ts +7 -2
  68. package/src/serve/hub-app/repo-sections.ts +15 -3
  69. package/src/serve/hub-app/repo-st.ts +61 -4
  70. package/src/serve/hub-app/review/clip-st.ts +258 -0
  71. package/src/serve/hub-app/review/kind-view.tsx +240 -0
  72. package/src/serve/hub-app/review/kit.tsx +113 -0
  73. package/src/serve/hub-app/review/review-answer.tsx +269 -0
  74. package/src/serve/hub-app/review/review-board.tsx +46 -0
  75. package/src/serve/hub-app/review/review-list.tsx +156 -0
  76. package/src/serve/hub-app/review/review-page.tsx +96 -0
  77. package/src/serve/hub-app/review/review-st.ts +902 -0
  78. package/src/serve/hub-app/review/review-views.tsx +648 -0
  79. package/src/serve/hub-app/review/trailer-page.tsx +416 -0
  80. package/src/serve/hub-app/review/trailer-st.ts +602 -0
  81. package/src/serve/hub-app/review/trailer-timeline.tsx +207 -0
  82. package/src/serve/hub-app/review.css +389 -0
  83. package/src/serve/hub-app/theme-st.ts +56 -0
  84. package/src/serve/hub-app/tree-build.ts +4 -0
  85. package/src/serve/hub-app/tree-row.tsx +1 -1
  86. package/src/serve/hub-app/tree-svc.ts +4 -0
  87. package/src/serve/hub-app/wiki-st.ts +190 -0
  88. package/src/serve/hub-app/wikis-view.tsx +134 -5
  89. package/src/serve/hub-runtime.ts +9 -7
  90. package/src/serve/project-gutter-route.ts +25 -1
  91. package/src/serve/project-gutter.ts +1 -1
  92. package/src/serve/same-origin.ts +5 -1
  93. package/src/serve/serve-hub.ts +15 -0
  94. package/src/shipkit-files.ts +9 -5
  95. package/src/shot/client.ts +17 -5
  96. package/src/shot/shooter.ts +19 -3
  97. package/src/shot/shot-ask.ts +9 -1
  98. package/src/shot/stepped-clip.ts +53 -0
  99. package/src/trailer/adapter.ts +90 -6
  100. package/src/trailer/build.ts +459 -59
  101. package/src/trailer/ffmpeg.ts +22 -0
  102. package/src/trailer/fonts.ts +55 -0
  103. package/src/trailer/footage.ts +112 -43
  104. package/src/trailer/grammar.ts +864 -127
  105. package/src/trailer/graph.ts +308 -49
  106. package/src/trailer/holes.ts +72 -0
  107. package/src/trailer/load-unit.ts +20 -3
  108. package/src/trailer/look.ts +20 -3
  109. package/src/trailer/motion.ts +207 -0
  110. package/src/trailer/passage.ts +129 -0
  111. package/src/trailer/plan.ts +82 -23
  112. package/src/trailer/prune.ts +204 -0
  113. package/src/trailer/score/compose.ts +256 -0
  114. package/src/trailer/score/dsp.ts +272 -0
  115. package/src/trailer/score/gm-render.ts +43 -0
  116. package/src/trailer/score/gm.ts +90 -0
  117. package/src/trailer/score/midi.ts +143 -0
  118. package/src/trailer/score/palette.ts +188 -0
  119. package/src/trailer/score/pattern.ts +98 -0
  120. package/src/trailer/score/score.ts +228 -0
  121. package/src/trailer/score/sound.ts +123 -0
  122. package/src/trailer/score/synth.ts +383 -0
  123. package/src/trailer/sound/audio.ts +148 -0
  124. package/src/trailer/sound/brand.ts +42 -0
  125. package/src/trailer/sound/comfy.ts +38 -0
  126. package/src/trailer/sound/eleven.ts +72 -0
  127. package/src/trailer/sound/fluid.ts +71 -0
  128. package/src/trailer/sound/gen.ts +139 -0
  129. package/src/trailer/sound/index.ts +60 -0
  130. package/src/trailer/sound/presets.ts +69 -0
  131. package/src/trailer/sound/run.ts +189 -0
  132. package/src/trailer/states.ts +76 -0
  133. package/src/trailer/stitch.ts +92 -9
  134. package/src/trailer/titles.ts +471 -150
  135. package/src/trailer/url.ts +18 -3
  136. package/src/trailer/vocab.ts +34 -4
  137. package/src/trailer/words.ts +275 -0
  138. package/src/wiki/graph-mount.ts +3 -0
  139. package/src/wiki/graph-page.ts +45 -9
  140. package/src/wiki/graph-pages.ts +128 -0
@@ -0,0 +1,56 @@
1
+ // the page's theme, a node of the root (home-st.ts): his pick (dark, light or
2
+ // auto, which follows the system) kept per browser, resolved to dark or light
3
+ // on <html data-theme>, where hub.css swaps its token block. Applied at
4
+ // construction, before the first render, so a reload paints in the theme.
5
+ import { autorun, makeAutoObservable, runInAction } from 'mobx'
6
+
7
+ export const THEMES = ['dark', 'light', 'auto'] as const
8
+ export type ThemePick = (typeof THEMES)[number]
9
+ export type Theme = 'dark' | 'light'
10
+
11
+ const KEY = 'hub:theme'
12
+
13
+ function loadPick(): ThemePick {
14
+ try {
15
+ const v = globalThis.localStorage?.getItem(KEY)
16
+ return (THEMES as readonly string[]).includes(v ?? '') ? (v as ThemePick) : 'auto'
17
+ } catch {
18
+ return 'auto'
19
+ }
20
+ }
21
+
22
+ export class ThemeSt {
23
+ pick: ThemePick = loadPick()
24
+ /** what the system asks for, read live */
25
+ system: Theme = 'dark'
26
+
27
+ constructor() {
28
+ makeAutoObservable(this)
29
+ const mq = typeof window !== 'undefined' && typeof window.matchMedia === 'function' ? window.matchMedia('(prefers-color-scheme: light)') : null
30
+ if (mq != null) {
31
+ this.system = mq.matches ? 'light' : 'dark'
32
+ mq.addEventListener('change', (e) => runInAction(() => (this.system = e.matches ? 'light' : 'dark')))
33
+ }
34
+ const root = typeof document !== 'undefined' ? document.documentElement : null
35
+ if (root != null) autorun(() => {
36
+ root.dataset.theme = this.resolved
37
+ root.style.colorScheme = this.resolved
38
+ })
39
+ }
40
+
41
+ get resolved(): Theme {
42
+ return this.pick === 'auto' ? this.system : this.pick
43
+ }
44
+
45
+ set(p: ThemePick): void {
46
+ this.pick = p
47
+ try {
48
+ globalThis.localStorage?.setItem(KEY, p)
49
+ } catch { /* private window or blocked site data: the pick lasts the tab */ }
50
+ }
51
+
52
+ /** the foot's button: dark, light, auto, then dark again */
53
+ cycle(): void {
54
+ this.set(THEMES[(THEMES.indexOf(this.pick) + 1) % THEMES.length]!)
55
+ }
56
+ }
@@ -74,6 +74,8 @@ export const KIND_SECTION: Record<ResourceType, SectionId> = {
74
74
  post: 'media',
75
75
  wiki: 'wikis',
76
76
  process: 'processes',
77
+ card: 'review',
78
+ cut: 'trailer',
77
79
  }
78
80
 
79
81
  /** a process's colour: up reads green, crashed red, on its way orange,
@@ -392,6 +394,8 @@ export const TYPE_MARK: Record<string, TypeMark> = {
392
394
  test: { glyph: '\u2263', hue: 'k5', word: 'a test file: the check that runs it, its last runs against its budget, the command that runs it alone' },
393
395
  reflection: { glyph: '\u203b', hue: 'k2', word: 'a reflection: friction this repo logged against the wiki, waiting to be folded in' },
394
396
  process: { glyph: '\u223f', hue: 'k1', word: 'a global process this repo declares: its state, its menu, its log' },
397
+ card: { glyph: '\u25a3', hue: 'k2', word: 'a review card waiting for your answer: approve, fix, reject, circle a detail' },
398
+ cut: { glyph: '\u25b6', hue: 'k3', word: 'a trailer cut: play it with its timeline, pin a note on any frame' },
395
399
  done: { glyph: '\u2713', hue: 'k5', word: 'a done ask: answered, filed under done, out of every count above it' },
396
400
  }
397
401
 
@@ -45,7 +45,7 @@ export const SectionRowView = observer(({ app, row }: { app: RepoSt; row: TreeRo
45
45
  const key = s == null ? null : sectionKeyLabel(id)
46
46
  const el = useInSight(t.cursor === row.key)
47
47
  return (
48
- <div ref={el} className={`tsec${s?.empty === true ? ' empty' : ''}${s?.opens === false ? ' noop' : ''}${s?.on === true ? ' cur' : ''}${t.cursor === row.key ? ' on' : ''}`} aria-current={s?.on === true ? 'page' : undefined}
48
+ <div ref={el} className={`tsec s-${id}${s?.empty === true ? ' empty' : ''}${s?.opens === false ? ' noop' : ''}${s?.on === true ? ' cur' : ''}${t.cursor === row.key ? ' on' : ''}`} aria-current={s?.on === true ? 'page' : undefined}
49
49
  title={s == null ? row.label : `${s.title} (${key})`} onClick={() => t.click(row)}>
50
50
  <Fold app={app} row={row} />
51
51
  <Icon d={ICON[id]} />
@@ -144,6 +144,10 @@ export class TreeSvc {
144
144
  this.cursor = row.key
145
145
  this.activate(row)
146
146
  }
147
+ /** a click in the page: the arrows are the page's again until the tree is walked or clicked */
148
+ leave(): void {
149
+ this.cursor = null
150
+ }
147
151
  /** the chevron: fold or unfold, never open */
148
152
  fold(row: TreeRow): void {
149
153
  this.cursor = row.key
@@ -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
  })
@@ -58,6 +58,7 @@ import { hubHandler, type CheckTap, type HubDeps, type HubHandler } from './serv
58
58
  import type { projectGutter } from './project-gutter-route.ts'
59
59
  import { defaultHubPort } from './hub-port.ts'
60
60
  import { LAYOUT } from '../core/layout.ts'
61
+ import { storeDir, storeStamp } from '../review/store.ts'
61
62
  export { defaultHubPort }
62
63
 
63
64
  export type ServeIo = { print: (l: string) => void; printErr: (l: string) => void }
@@ -145,15 +146,15 @@ export async function resolveHubDeps(root: string, boot: RepoConfig, io: ServeIo
145
146
  // agent working here obeys that wiki, so a reader can open it.
146
147
  const graphs: GraphMount[] = []
147
148
  if (c.serve?.wikiGraph != null)
148
- 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 } })
149
150
  else if (existsSync(join(root, 'wiki')))
150
- 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 } })
151
152
  const baseWiki = baseWikiRoot(root)
152
153
  if (baseWiki != null)
153
- 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 })
154
155
  const agentRoot = join(root, 'agent')
155
156
  if (existsSync(agentRoot))
156
- 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 } })
157
158
  // ONE page for every repo: the studio ALWAYS
158
159
  // mounts — kit-less repos get an empty media section, never a second
159
160
  // page. A broken kit degrades to the same empty kit, loudly, and a repo
@@ -347,9 +348,10 @@ export async function resolveHubDeps(root: string, boot: RepoConfig, io: ServeIo
347
348
  graphs,
348
349
  studio,
349
350
  refresh,
350
- // the config and the run record, by their stats: what the warm rows
351
- // and the runs page read besides the tracked files the watch sees
352
- stamp: () => `${configStamp(root)}|${record === '' ? '' : recordStamp(record)}`,
351
+ // the config, the run record and the review store (gitignored, its
352
+ // own watch counts its writes): what the warm rows and the runs page
353
+ // read besides the tracked files the watch sees
354
+ stamp: () => `${configStamp(root)}|${record === '' ? '' : recordStamp(record)}|${storeStamp(storeDir(root))}`,
353
355
  runCli,
354
356
  // the run happens IN THIS PROCESS, so every check result reaches the
355
357
  // page as it finishes. A shelled run could only ever deliver one wall
@@ -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
 
@@ -10,5 +10,9 @@ export function sameOrigin(req: Request): boolean {
10
10
  if (site != null && site !== 'same-origin' && site !== 'none') return false
11
11
  const origin = req.headers.get('origin')
12
12
  if (origin == null) return true
13
- try { return new URL(origin).host === new URL(req.url).host } catch { return false }
13
+ // behind a proxy that frames the page under its own origin (pkm-multi's admin), the host the browser asked is the
14
+ // proxy's, which it passes on. A browser cannot forge the header: a page of another site that sets it is held at
15
+ // its preflight, which this server never answers
16
+ const host = req.headers.get('x-forwarded-host') ?? new URL(req.url).host
17
+ try { return new URL(origin).host === host } catch { return false }
14
18
  }
@@ -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'
@@ -32,6 +33,8 @@ import { sameOrigin } from './same-origin.ts'
32
33
  import { LAYOUT } from '../core/layout.ts'
33
34
  import { WarmCell } from './warm-cell.ts'
34
35
  import { readmeRoutes } from './readme-route.ts'
36
+ import { reviewRoutes } from '../review/routes.ts'
37
+ import { embedRoute } from './embed.ts'
35
38
 
36
39
  export { sameOrigin }
37
40
 
@@ -338,6 +341,12 @@ export function hubHandler(d: HubDeps, o: { base?: string; projects?: Gutter } =
338
341
  // the empty page shows the README, its images served from the repo
339
342
  const readme = await readmeRoutes(d.root, path)
340
343
  if (readme != null) return readme
344
+ // the review or trailer page alone, for another tool's frame (embed.ts)
345
+ const embed = await embedRoute(path)
346
+ if (embed != null) return embed
347
+ // the one feedback store and the trailer view (review/routes.ts)
348
+ const review = await reviewRoutes(d.root, req, path)
349
+ if (review != null) return review
341
350
  if (path === '/api/status.json') {
342
351
  const p = join(d.root, LAYOUT.status)
343
352
  return Response.json({ statusMd: existsSync(p) ? await Bun.file(p).text() : null })
@@ -403,6 +412,12 @@ export function hubHandler(d: HubDeps, o: { base?: string; projects?: Gutter } =
403
412
  if (path === `/graph/${g.id}`) return page(routes.page())
404
413
  if (path === `/graph/${g.id}/graph.json`) return Response.json(held)
405
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
+ }
406
421
  }
407
422
  // the media API namespace — the studio route table, ONE prefix strip
408
423
  if (d.studio != null && path.startsWith('/media/')) return d.studio.handle(req, path.slice('/media'.length))