rvlib-shipkit 0.10.0 → 0.13.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 (61) hide show
  1. package/CHANGELOG.md +204 -0
  2. package/README.md +17 -3
  3. package/package.json +5 -2
  4. package/src/asks/asks-queue.ts +42 -0
  5. package/src/assets/kit-config.ts +2 -3
  6. package/src/assets/roll-workspace.ts +2 -1
  7. package/src/assets/run-assets.ts +0 -11
  8. package/src/assets/server/studio-server.ts +7 -24
  9. package/src/cli/cmd_Assets.ts +3 -14
  10. package/src/cli/cmd_Check.ts +22 -4
  11. package/src/cli/cmd_Needs.ts +47 -0
  12. package/src/cli/cmd_Reflect.ts +116 -0
  13. package/src/cli/cmd_Rules.ts +32 -0
  14. package/src/cli/cmd_Serve.ts +1 -1
  15. package/src/cli.ts +7 -2
  16. package/src/collections/actions.ts +85 -0
  17. package/src/collections/collection-types.ts +65 -0
  18. package/src/collections/graphs.ts +34 -0
  19. package/src/collections/pings.ts +72 -0
  20. package/src/collections/posts.ts +125 -0
  21. package/src/collections/providers.ts +275 -0
  22. package/src/contracts/apply-contracts.ts +47 -0
  23. package/src/contracts/run-contracts.ts +8 -3
  24. package/src/core/canonical-scripts.ts +46 -0
  25. package/src/core/check-suite.ts +6 -3
  26. package/src/core/locked-write.ts +39 -0
  27. package/src/core/motherhome.ts +87 -0
  28. package/src/core/run-check.ts +28 -15
  29. package/src/core/run-ci.ts +4 -0
  30. package/src/core/write-status.ts +7 -26
  31. package/src/init-repo.ts +24 -11
  32. package/src/reflect/prompt-corpus.ts +305 -0
  33. package/src/repo-config.ts +26 -38
  34. package/src/rules/SK001-package-json-shape.ts +156 -0
  35. package/src/rules/SK002-contracts-fulfillness.ts +19 -0
  36. package/src/rules/SK003-claude-md-shape.ts +64 -0
  37. package/src/rules/SK004-gitignore-policy.ts +105 -0
  38. package/src/rules/SK005-rvlib-versions.ts +99 -0
  39. package/src/rules/SK006-npm-publish-safety.ts +42 -0
  40. package/src/rules/SK007-github-visibility.ts +63 -0
  41. package/src/rules/rule-registry.ts +32 -0
  42. package/src/rules/rule.ts +62 -0
  43. package/src/rules/run-fix.ts +57 -0
  44. package/src/serve/hub-app/col-card.tsx +192 -0
  45. package/src/serve/hub-app/cols-svc.ts +136 -0
  46. package/src/serve/hub-app/geometry-view.tsx +155 -0
  47. package/src/serve/hub-app/health-svc.ts +49 -0
  48. package/src/serve/hub-app/hub-app.tsx +240 -127
  49. package/src/serve/hub-app/hub-st.ts +169 -0
  50. package/src/serve/hub-app/hub-types.ts +72 -0
  51. package/src/serve/hub-app/hub.css +286 -35
  52. package/src/serve/hub-app/index.html +1 -1
  53. package/src/serve/hub-app/md.ts +24 -0
  54. package/src/serve/hub-app/media-svc.ts +429 -0
  55. package/src/serve/hub-app/media-view.tsx +299 -0
  56. package/src/serve/run-serve.ts +46 -91
  57. package/src/serve/serve-hub.ts +99 -49
  58. package/src/shipkit.ts +13 -6
  59. package/src/assets/server/studio-page.ts +0 -687
  60. package/src/core/package-json-check.ts +0 -88
  61. package/src/serve/hub-pages.ts +0 -45
@@ -1,153 +1,266 @@
1
- // The hub's React app (Bun.serve HTML import — no vite, no build step).
2
- // Two views routed by pathname: / = attention dashboard (everything red),
3
- // /status = STATUS.md + streamed check runs. The rail lists every mounted
4
- // tool (from /hub.json) — legacy pages (graphs, studio) get the same rail
5
- // injected server-side until they migrate.
6
- import { makeAutoObservable, runInAction } from 'mobx'
1
+ // THE page (Bun.serve HTML import — no vite, no build step). One surface
2
+ // per repo (his rules 2026-07-23): media atelier + asks + creds + posts +
3
+ // health + graphs as foldable sections, one severity filter persisted at
4
+ // /?f=needs · /?f=red (legacy /?needs=1 reads as needs). Section title =
5
+ // collection view (grid of cards), row = item view. mobx+react per
6
+ // build/app-state-tree: HubSt is the single root (window.app), services
7
+ // own state, components read and call.
8
+ import { runInAction } from 'mobx'
7
9
  import { observer } from 'mobx-react-lite'
8
- import { StrictMode } from 'react'
10
+ import { StrictMode, useEffect } from 'react'
9
11
  import { createRoot } from 'react-dom/client'
12
+ import { ColCard, ColGrid, GraphView, StatusCard } from './col-card.tsx'
13
+ import { HubSt } from './hub-st.ts'
14
+ import { AssetStage, CatGrid, Drawer, InfoChips, MediaTopbar, RollStrip } from './media-view.tsx'
15
+ import type { SideAsset } from './hub-types.ts'
10
16
 
11
- type HubTool = { path: string; label: string; desc: string }
12
- type AttentionLine = { text: string; href?: string }
13
- type AttentionSection = { title: string; tone: 'red' | 'warn'; lines: AttentionLine[]; href?: string }
17
+ const app = new HubSt()
18
+ // the root as a global — app code, console debugging (build/app-state-tree)
19
+ ;(window as unknown as { app: HubSt }).app = app
20
+ app.boot()
14
21
 
15
- const RAIL_ICONS: Record<string, string> = { '/': '⚡', '/graph/wiki': '🕸', '/graph/agent': '🤖', '/assets/': '🖼', '/status': '📊' }
16
-
17
- class HubStore {
18
- name = ''
19
- tools: HubTool[] = []
20
- sections: AttentionSection[] | null = null
21
- statusMd: string | null = null
22
- running = false
23
- note = ''
24
- error: string | null = null
25
-
26
- constructor() {
27
- makeAutoObservable(this)
28
- }
22
+ // ── sidebar: media categories + one foldable section per collection ──────
23
+ const AssetRow = observer(({ a }: { a: SideAsset }) => (
24
+ <div className={`arow${a.id === app.media.curId ? ' sel' : ''}`} onClick={() => app.selectAsset(a.id)}>
25
+ <div className="lb"><b>{a.label}</b><span>{a.rolls} rolls</span></div>
26
+ {app.media.pending.includes(a.id)
27
+ ? <div className="spin" />
28
+ : <div className={`dot ${a.stale ? 'stale' : a.picked ? 'done' : a.rolls > 0 ? 'some' : a.manual ? 'miss' : ''}`}
29
+ title={a.manual && a.rolls === 0 ? 'manual asset — nothing uploaded yet' : undefined} />}
30
+ </div>
31
+ ))
29
32
 
30
- get redCount(): number {
31
- return (this.sections ?? []).filter((s) => s.tone === 'red').reduce((n, s) => n + s.lines.length, 0)
32
- }
33
+ const MediaSection = observer(() => {
34
+ const m = app.media
35
+ if (m.dead != null) return <div className="gname"><span className="warnline">media API down: {m.dead}</span></div>
36
+ if (m.assets.length === 0) return null // kit-less repo — no media section, same page
37
+ return (
38
+ <>
39
+ {m.cats.map((c) => {
40
+ // a live search must SHOW its matches — folds yield while it runs
41
+ const open = m.openCats.has(c) || app.query.trim() !== ''
42
+ const inCat = m.visibleAssets.filter((a) => a.cat === c)
43
+ return (
44
+ <div key={c}>
45
+ <div className="gname" id={`sec-cat-${c}`}>
46
+ <span>
47
+ <span className="fold" onClick={() => m.toggleFold(c)}>{open ? '▾' : '▸'}</span>{' '}
48
+ <span className="cn" title="grid view" onClick={() => app.showCat(c)}>{c}</span>
49
+ </span>
50
+ {inCat.every((a) => a.manual) ? null : (
51
+ <span className="catgen">{[1, 2, 3].map((n) => <button key={n} onClick={() => void m.genCat(c, n)}>+{n}</button>)}</span>
52
+ )}
53
+ </div>
54
+ {open ? inCat.map((a) => <AssetRow key={a.id} a={a} />) : null}
55
+ </div>
56
+ )
57
+ })}
58
+ </>
59
+ )
60
+ })
33
61
 
34
- async load(): Promise<void> {
35
- try {
36
- const hub = (await (await fetch('/hub.json')).json()) as { name: string; tools: HubTool[] }
37
- const att = (await (await fetch('/api/attention.json')).json()) as { sections: AttentionSection[] }
38
- runInAction(() => {
39
- this.name = hub.name
40
- this.tools = hub.tools
41
- this.sections = att.sections
42
- })
43
- if (location.pathname === '/status') {
44
- const s = (await (await fetch('/api/status.json')).json()) as { statusMd: string | null }
45
- runInAction(() => { this.statusMd = s.statusMd })
46
- }
47
- } catch (e) {
48
- runInAction(() => { this.error = (e as Error).message })
49
- }
50
- }
62
+ const ColSections = observer(() => {
63
+ const c = app.cols
64
+ return (
65
+ <>
66
+ {c.sidebarCols.map((col) => {
67
+ const items = c.visibleInstances(col)
68
+ // every section header ALWAYS renders, 0 items included — a
69
+ // quiet section must stay reachable (his order 2026-07-23:
70
+ // done asks, present secrets)
71
+ const open = c.isOpen(col)
72
+ const health = col.id === 'health'
73
+ return (
74
+ <div key={col.id}>
75
+ <div className="gname" id={`sec-${col.id}`}>
76
+ <span>
77
+ <span className="fold" onClick={() => c.toggleFold(col)}>{open ? '▾' : '▸'}</span>{' '}
78
+ <span className="cn" onClick={() => (health ? app.showStatus() : app.showCol(col.id))}
79
+ title={health ? 'STATUS.md + run check' : 'collection view'}>{col.title}</span>
80
+ </span>
81
+ <span className="cnt">{items.length}</span>
82
+ </div>
83
+ {open ? items.map((i) => (
84
+ // a health line's action IS the runner — an echo card would be a dead end
85
+ <div key={i.id} className={`arow${app.sel?.t === 'item' && app.sel.id === i.id ? ' sel' : ''}`}
86
+ onClick={() => (health ? app.showStatus() : c.select(col.id, i.id))}>
87
+ <div className="lb"><b>{i.text}</b>{i.file != null ? <span>{i.file}</span> : null}</div>
88
+ <div className={`cdot ${i.severity ?? ''}`} />
89
+ </div>
90
+ )) : null}
91
+ </div>
92
+ )
93
+ })}
94
+ </>
95
+ )
96
+ })
51
97
 
52
- async runCheck(): Promise<void> {
53
- this.running = true
54
- this.note = 'running…'
55
- let text = ''
56
- try {
57
- const r = await fetch('/status/run', { method: 'POST' })
58
- const rd = r.body!.getReader()
59
- const dec = new TextDecoder()
60
- for (;;) {
61
- const { done, value } = await rd.read()
62
- if (done) break
63
- text += dec.decode(value, { stream: true })
64
- runInAction(() => { this.statusMd = text })
65
- }
66
- runInAction(() => { this.note = 'done — STATUS.md refreshed on disk' })
67
- void this.load() // red counts moved — refresh the dashboard data
68
- } catch (e) {
69
- runInAction(() => {
70
- this.statusMd = `${text}\nFAIL stream broke: ${(e as Error).message}`
71
- this.note = 'stream broke'
72
- })
73
- }
74
- runInAction(() => { this.running = false })
75
- }
76
- }
98
+ // segmented severity filter — all · needs (yellow+red) · red, the address
99
+ // rides at /?f=… so a filtered view is shareable
100
+ const SegFilter = observer(() => (
101
+ <span className="seg">
102
+ {(['all', 'needs', 'red'] as const).map((f) => (
103
+ <button key={f} className={app.filter === f ? 'on' : ''} onClick={() => app.setFilter(f)}>{f}</button>
104
+ ))}
105
+ </span>
106
+ ))
77
107
 
78
- const store = new HubStore()
79
- void store.load()
108
+ const Sidebar = observer(() => (
109
+ <aside>
110
+ <div className="shead">
111
+ {app.name !== '' ? app.name : 'hub'}
112
+ <SegFilter />
113
+ </div>
114
+ <div className="ssearch">
115
+ <input placeholder="filter… (⌘J to jump)" value={app.query}
116
+ onChange={(e) => app.setQuery(e.target.value)}
117
+ onKeyDown={(e) => { if (e.key === 'Escape') app.setQuery('') }} />
118
+ </div>
119
+ <MediaSection />
120
+ <ColSections />
121
+ </aside>
122
+ ))
80
123
 
124
+ // ── the icon rail: quick jump to a section header (his order 2026-07-23:
125
+ // scroll + unfold, NEVER a navigation) — red dot = the section holds a red ──
126
+ const RAIL_ICON: Record<string, string> = { asks: '🙋', creds: '🔑', posts: '📣', health: '📊', graphs: '🕸' }
81
127
  const Rail = observer(() => {
82
- const items = [{ path: '/', label: 'attention', desc: 'everything red' }, ...store.tools.filter((t) => t.path !== '/')]
128
+ const c = app.cols
129
+ const m = app.media
130
+ // unfold flips state; the scroll waits for react to paint the rows
131
+ const jump = (id: string): void => { setTimeout(() => document.getElementById(id)?.scrollIntoView({ block: 'start' }), 0) }
83
132
  return (
84
133
  <nav className="skrail">
85
- {items.map((t) => {
86
- const on = location.pathname === t.path || (t.path !== '/' && location.pathname.startsWith(t.path))
87
- return (
88
- <a key={t.path} className={on ? 'on' : ''} href={t.path} title={`${t.label} — ${t.desc}`}>
89
- {RAIL_ICONS[t.path] ?? '·'}
90
- {t.path === '/' && store.redCount > 0 ? <i /> : null}
91
- </a>
92
- )
93
- })}
134
+ {m.assets.length > 0 ? (
135
+ <span className="ric" title="assets" onClick={() => { m.openAllCats(); if (m.cats[0] != null) jump(`sec-cat-${m.cats[0]}`) }}>
136
+ 🖼{c.mediaHasRed ? <i /> : null}
137
+ </span>
138
+ ) : null}
139
+ {c.sidebarCols.map((col) => (
140
+ <span key={col.id} className="ric" title={col.title} onClick={() => { c.openSection(col); jump(`sec-${col.id}`) }}>
141
+ {RAIL_ICON[col.id] ?? '•'}{c.hasRed(col) ? <i /> : null}
142
+ </span>
143
+ ))}
94
144
  </nav>
95
145
  )
96
146
  })
97
147
 
98
- const Attention = observer(() => {
99
- const live = (store.sections ?? []).filter((s) => s.lines.length > 0)
148
+ // ── cmd+j palette: every jump target behind one input ────────────────────
149
+ const Palette = observer(() => {
150
+ if (!app.palOpen) return null
100
151
  return (
101
- <div className="wrap">
102
- <div className="crumb">shipkit hub · attention</div>
103
- <h1>{store.name}<small>everything that needs you, one page</small></h1>
104
- {store.sections == null ? <div className="sub">loading…</div>
105
- : live.length === 0 ? <div className="clear">✅ all clear — nothing needs you</div>
106
- : live.map((s) => (
107
- <section key={s.title} className={s.tone}>
108
- <h2>{s.href != null ? <a href={s.href}>{s.title}</a> : s.title}<em>{s.lines.length}</em></h2>
109
- {s.lines.map((l, i) => (
110
- <div key={i} className="line">{l.href != null ? <a href={l.href}>{l.text}</a> : l.text}</div>
111
- ))}
112
- </section>
113
- ))}
114
- <div className="sub">refreshes on reload · checks refresh via <a href="/status">status → run check</a></div>
152
+ <div className="palov" onClick={() => app.palClose()}>
153
+ <div className="pal" onClick={(e) => e.stopPropagation()}>
154
+ <input autoFocus placeholder="jump to…" value={app.palQuery}
155
+ onChange={(e) => app.setPalQuery(e.target.value)}
156
+ onKeyDown={(e) => {
157
+ if (e.key === 'ArrowDown') { e.preventDefault(); app.palMove(1) }
158
+ else if (e.key === 'ArrowUp') { e.preventDefault(); app.palMove(-1) }
159
+ else if (e.key === 'Enter') app.palRun()
160
+ else if (e.key === 'Escape') app.palClose()
161
+ }} />
162
+ <div className="prows">
163
+ {app.palResults.map((jp, n) => (
164
+ <div key={jp.id} className={`prow${n === app.palIdx ? ' on' : ''}`}
165
+ onMouseEnter={() => app.setPalIdx(n)}
166
+ onClick={() => { app.palClose(); jp.run() }}>
167
+ <span className="plabel">{jp.label}</span>
168
+ <span className="pchip">{jp.section}</span>
169
+ </div>
170
+ ))}
171
+ {app.palResults.length === 0 ? <div className="prow none">no match</div> : null}
172
+ </div>
173
+ </div>
115
174
  </div>
116
175
  )
117
176
  })
118
177
 
119
- const wireClass = (l: string): string => {
120
- const t = l.trimStart()
121
- if (t.startsWith('FAIL') || t.startsWith('✗')) return 'fail'
122
- if (t.startsWith('WARN') || t.startsWith('⚠')) return 'warn'
123
- if (t.startsWith('ok') || t.startsWith('✓')) return 'ok'
124
- if (t.startsWith('skip') || t.startsWith('fix:') || t.startsWith('#')) return 'dim'
125
- return ''
126
- }
127
-
128
- const Status = observer(() => (
129
- <div className="wrap">
130
- <div className="crumb">shipkit hub · status</div>
131
- <h1>{store.name}<small>STATUS.md</small></h1>
132
- <div className="bar">
133
- <button disabled={store.running} onClick={() => void store.runCheck()}>run check</button>
134
- <span className="note">{store.note !== '' ? store.note : <>shells <code>shipkit check</code>, wire lines stream in below</>}</span>
178
+ // remark box on the ASSET view too — his words land in .rv-assets/notes.md
179
+ // and come back as `remarks` (exchange both ways, media included)
180
+ const AssetRemarks = observer(() => {
181
+ const m = app.media
182
+ const c = app.cols
183
+ if (m.curId == null) return null
184
+ const remarks = c.mediaRemarks(m.curId)
185
+ return (
186
+ <div className="assetrem">
187
+ {remarks.length > 0 ? <div className="rem">{remarks.join('\n')}</div> : null}
188
+ <div className="row">
189
+ <input placeholder="remark → lands in .rv-assets/notes.md, agents read it" value={c.remarkDraft}
190
+ onChange={(e) => runInAction(() => { c.remarkDraft = e.target.value })} />
191
+ <button className="ghost" onClick={() => void c.remark({ kind: 'media', id: m.curId })}>remark</button>
192
+ </div>
193
+ {c.actErr !== '' ? <div className="err">✗ {c.actErr}</div> : null}
135
194
  </div>
136
- <pre>
137
- {(store.statusMd ?? 'no STATUS.md yet — hit "run check" (or run: shipkit check)')
138
- .split('\n')
139
- .map((l, i) => <span key={i} className={wireClass(l)}>{l}{'\n'}</span>)}
140
- </pre>
141
- </div>
142
- ))
195
+ )
196
+ })
143
197
 
144
- const App = observer(() => (
145
- <div className="app">
146
- <Rail />
147
- {store.error != null ? <div className="wrap err">hub api unreachable: {store.error}</div>
148
- : location.pathname === '/status' ? <Status /> : <Attention />}
149
- </div>
150
- ))
198
+ const Main = observer(() => {
199
+ const m = app.media
200
+ const errs = m.errors.length > 0 ? <div className="errs">{m.errors.join('\n')}</div> : null
201
+ if (app.sel?.t === 'item') return (
202
+ <main>
203
+ {errs}
204
+ {app.sel.col === 'graphs'
205
+ ? <GraphView app={app} id={app.sel.id} />
206
+ : <div className="stagewrap"><ColCard app={app} col={app.sel.col} id={app.sel.id} /></div>}
207
+ </main>
208
+ )
209
+ if (app.sel?.t === 'col') return <main>{errs}<ColGrid app={app} id={app.sel.id} /></main>
210
+ if (app.sel?.t === 'status') return <main>{errs}<div className="stagewrap"><StatusCard app={app} /></div></main>
211
+ if (app.sel?.t === 'cat') return <main>{errs}<CatGrid app={app} /></main>
212
+ if (app.sel?.t === 'asset') return (
213
+ <main>
214
+ {errs}
215
+ <MediaTopbar app={app} />
216
+ <InfoChips app={app} />
217
+ <AssetStage app={app} />
218
+ <RollStrip app={app} />
219
+ <AssetRemarks />
220
+ <Drawer app={app} />
221
+ <div className="help"><kbd>←</kbd><kbd>→</kbd> roll · <kbd>Enter</kbd> pick · <kbd>A</kbd> alt · <kbd>X</kbd> discard — picks ship via the kit's ship hook when present · category title = grid view</div>
222
+ </main>
223
+ )
224
+ // a filtered view with zero hits is the WIN state — say so, never point at a void
225
+ const allClear = app.filter !== 'all' && m.visibleAssets.length === 0 && app.cols.sidebarCols.every((col) => app.cols.visibleInstances(col).length === 0)
226
+ return (
227
+ <main>
228
+ {errs}
229
+ <div className="stagewrap"><div className="ph">
230
+ {allClear ? (app.filter === 'red' ? 'no reds — nothing is blocked ✨' : 'all clear — nothing needs you ✨')
231
+ : m.assets.length > 0 ? 'Pick an asset or an item on the left.' : 'Pick an item on the left.'}
232
+ </div></div>
233
+ </main>
234
+ )
235
+ })
236
+
237
+ const App = observer(() => {
238
+ useEffect(() => {
239
+ const onKey = (e: KeyboardEvent): void => {
240
+ // ⌘J/^J beats the focus check — the palette must open from anywhere
241
+ if ((e.metaKey || e.ctrlKey) && !e.shiftKey && e.key.toLowerCase() === 'j') {
242
+ e.preventDefault()
243
+ app.palToggle()
244
+ return
245
+ }
246
+ const t = e.target as HTMLElement
247
+ if (t.tagName === 'TEXTAREA' || t.tagName === 'INPUT') return
248
+ if (app.palOpen) return // the palette input owns its keys
249
+ if (app.sel?.t !== 'asset') return
250
+ if (app.media.key(e.key)) e.preventDefault()
251
+ }
252
+ document.addEventListener('keydown', onKey)
253
+ return () => document.removeEventListener('keydown', onKey)
254
+ }, [])
255
+ return (
256
+ <div className="app">
257
+ <Rail />
258
+ <Sidebar />
259
+ <Main />
260
+ <Palette />
261
+ </div>
262
+ )
263
+ })
151
264
 
152
265
  createRoot(document.getElementById('root')!).render(
153
266
  <StrictMode>
@@ -0,0 +1,169 @@
1
+ // HubSt — the ONE root store (build/app-state-tree: one central tree,
2
+ // instantiated once at boot, services with backrefs, exposed as
3
+ // window.app). Owns cross-service state: selection, the severity filter
4
+ // (an ADDRESS: /?f=needs · /?f=red; legacy /?needs=1 reads as needs),
5
+ // hash deep-links (#<asset-id> · #cat-<Category>).
6
+ import { makeAutoObservable } from 'mobx'
7
+ import { ColsSvc } from './cols-svc.ts'
8
+ import { HealthSvc } from './health-svc.ts'
9
+ import { MediaSvc } from './media-svc.ts'
10
+
11
+ export type Sel =
12
+ | { t: 'asset'; id: string }
13
+ | { t: 'cat'; cat: string }
14
+ | { t: 'col'; id: string }
15
+ | { t: 'item'; col: string; id: string }
16
+ | { t: 'status' }
17
+ | null
18
+
19
+ /** one cmd+j target — run() fires the SAME action clicking would */
20
+ export type Jump = { id: string; label: string; section: string; run: () => void }
21
+
22
+ /** all · needs (yellow+red) · red — one severity ladder for every section */
23
+ export type Filter = 'all' | 'needs' | 'red'
24
+ const parseFilter = (search: string): Filter => {
25
+ const sp = new URLSearchParams(search)
26
+ const f = sp.get('f')
27
+ if (f === 'needs' || f === 'red') return f
28
+ return sp.get('needs') === '1' ? 'needs' : 'all' // legacy address keeps working
29
+ }
30
+
31
+ export class HubSt {
32
+ name = ''
33
+ media = new MediaSvc(this)
34
+ cols = new ColsSvc(this)
35
+ health = new HealthSvc(this)
36
+ sel: Sel = null
37
+ filter: Filter = parseFilter(location.search)
38
+ /** sidebar search — live substring filter over asset + collection rows */
39
+ query = ''
40
+ palOpen = false
41
+ palQuery = ''
42
+ palIdx = 0
43
+ private booted = false
44
+
45
+ constructor() {
46
+ makeAutoObservable<this, 'booted'>(this, { media: false, cols: false, health: false, booted: false })
47
+ }
48
+
49
+ boot(): void {
50
+ void fetch('/hub.json')
51
+ .then((r) => r.json() as Promise<{ name: string }>)
52
+ .then((h) => { this.setName(h.name) })
53
+ .catch(() => {})
54
+ this.media.start()
55
+ this.cols.start()
56
+ void this.health.load()
57
+ window.addEventListener('hashchange', () => this.applyHash())
58
+ }
59
+ private setName(n: string): void {
60
+ this.name = n
61
+ document.title = `${n} · hub`
62
+ }
63
+
64
+ setFilter(v: Filter): void {
65
+ this.filter = v
66
+ const u = new URL(location.href)
67
+ u.searchParams.delete('needs') // legacy param never survives a click
68
+ v === 'all' ? u.searchParams.delete('f') : u.searchParams.set('f', v)
69
+ history.replaceState(null, '', u)
70
+ }
71
+ setQuery(v: string): void {
72
+ this.query = v
73
+ }
74
+
75
+ // ── cmd+j palette ─────────────────────────────────────────────────────
76
+ palToggle(): void {
77
+ this.palOpen = !this.palOpen
78
+ this.palQuery = ''
79
+ this.palIdx = 0
80
+ }
81
+ palClose(): void {
82
+ this.palOpen = false
83
+ }
84
+ setPalQuery(v: string): void {
85
+ this.palQuery = v
86
+ this.palIdx = 0
87
+ }
88
+ setPalIdx(n: number): void {
89
+ this.palIdx = n
90
+ }
91
+ palMove(d: number): void {
92
+ const n = this.palResults.length
93
+ if (n > 0) this.palIdx = (this.palIdx + d + n) % n
94
+ }
95
+ palRun(): void {
96
+ const j = this.palResults[this.palIdx]
97
+ if (j == null) return
98
+ this.palOpen = false
99
+ j.run()
100
+ }
101
+ /** every jump target: section titles, media assets, collection instances */
102
+ get jumps(): Jump[] {
103
+ const out: Jump[] = []
104
+ for (const cat of new Set(this.media.assets.map((a) => a.cat)))
105
+ out.push({ id: `cat:${cat}`, label: cat, section: 'media', run: () => this.showCat(cat) })
106
+ for (const a of this.media.assets)
107
+ out.push({ id: `asset:${a.id}`, label: a.label, section: a.cat, run: () => this.selectAsset(a.id) })
108
+ for (const col of this.cols.sidebarCols) {
109
+ const health = col.id === 'health'
110
+ out.push({ id: `col:${col.id}`, label: col.title, section: 'section', run: () => (health ? this.showStatus() : this.showCol(col.id)) })
111
+ for (const i of col.instances)
112
+ out.push({ id: `item:${i.id}`, label: i.text, section: col.title, run: () => (health ? this.showStatus() : this.cols.select(col.id, i.id)) })
113
+ }
114
+ return out
115
+ }
116
+ /** ranked: prefix matches first, then substring; capped at 12 */
117
+ get palResults(): Jump[] {
118
+ const q = this.palQuery.trim().toLowerCase()
119
+ if (q === '') return this.jumps.slice(0, 12)
120
+ const pre: Jump[] = []
121
+ const sub: Jump[] = []
122
+ for (const j of this.jumps) {
123
+ const l = j.label.toLowerCase()
124
+ if (l.startsWith(q)) pre.push(j)
125
+ else if (l.includes(q)) sub.push(j)
126
+ }
127
+ return [...pre, ...sub].slice(0, 12)
128
+ }
129
+
130
+ // ── selection + hash deep-links ───────────────────────────────────────
131
+ selectAsset(id: string, writeHash = true): void {
132
+ this.sel = { t: 'asset', id }
133
+ if (writeHash) this.setHash(id)
134
+ void this.media.select(id)
135
+ }
136
+ showCat(cat: string, writeHash = true): void {
137
+ this.sel = { t: 'cat', cat }
138
+ this.media.openCats.add(cat)
139
+ if (writeHash) this.setHash(`cat-${cat}`)
140
+ void this.media.fillCat()
141
+ }
142
+ showStatus(): void {
143
+ this.sel = { t: 'status' }
144
+ void this.health.load()
145
+ }
146
+ /** collection view — one card per instance (health has StatusCard instead) */
147
+ showCol(id: string): void {
148
+ this.sel = { t: 'col', id }
149
+ }
150
+ private setHash(h: string): void {
151
+ if (location.hash.slice(1) !== h) location.hash = h
152
+ }
153
+ applyHash(): void {
154
+ const h = decodeURIComponent(location.hash.slice(1))
155
+ if (h === '') return
156
+ if (h.startsWith('cat-')) {
157
+ const c = h.slice(4)
158
+ if (this.media.assets.some((a) => a.cat === c) && this.media.catMode !== c) this.showCat(c, false)
159
+ return
160
+ }
161
+ if (this.media.assets.some((a) => a.id === h) && this.media.curId !== h) this.selectAsset(h, false)
162
+ }
163
+ /** first heartbeat has the asset list — only then can a hash resolve */
164
+ afterFirstBeat(): void {
165
+ if (this.booted) return
166
+ this.booted = true
167
+ this.applyHash()
168
+ }
169
+ }
@@ -0,0 +1,72 @@
1
+ // Wire types the hub app reads — mirrors of the server shapes
2
+ // (studio-server.ts, collection-types.ts). Kept structural on purpose: the
3
+ // page renders what the API sends, the file is the truth.
4
+
5
+ export type SideAsset = {
6
+ id: string
7
+ cat: string
8
+ label: string
9
+ prompt: string
10
+ rolls: number
11
+ picked: boolean
12
+ icon: boolean
13
+ media: 'image' | 'audio'
14
+ manual: boolean
15
+ derived: boolean
16
+ stale: boolean
17
+ }
18
+
19
+ export type RollRow = { name: string; picked: boolean; alt: boolean; hasMeta: boolean }
20
+
21
+ export type AssetInfo = {
22
+ backend: string
23
+ deterministic: boolean
24
+ workflow: { id: string | null; path: string | null; note?: string } | null
25
+ ship: { mode: string; path?: string; note?: string }
26
+ metaFile: string | null
27
+ kindId: string
28
+ kitSource: string
29
+ }
30
+
31
+ export type CustomPanelRef = { id: string; kind: string; url: string }
32
+
33
+ export type AssetDetail = {
34
+ asset: { id: string; label?: string; prompt: string; cat: string; geometry: boolean; custom: CustomPanelRef[] } & Record<string, unknown>
35
+ info: AssetInfo
36
+ rolls: RollRow[]
37
+ }
38
+
39
+ export type Leg = { x: number; y: number; w: number; h: number }
40
+ export type Rect = { x: number; y: number; w: number; h: number; size: number; flip?: boolean; legs?: Leg[]; legColor?: string; art?: unknown }
41
+
42
+ export type CatRow = { id: string; label: string; name: string; rect: Rect | null; prop: boolean; icon: boolean }
43
+
44
+ export type QueueState = {
45
+ generating: boolean
46
+ current: string | null
47
+ done: number
48
+ total: number
49
+ queued: number
50
+ progress: { value: number; max: number } | null
51
+ pending: string[]
52
+ errors: string[]
53
+ assets: SideAsset[]
54
+ }
55
+
56
+ export type Candidate = { id: string; label: string; hint?: string }
57
+ export type Artifact = { copy?: string; url?: string; steps?: string[] }
58
+ export type ColInstance = {
59
+ id: string
60
+ text: string
61
+ severity?: 'red' | 'yellow'
62
+ href?: string
63
+ artifact?: Artifact
64
+ candidates?: Candidate[]
65
+ file?: string
66
+ img?: string
67
+ remarks?: string[]
68
+ /** the truth file's markdown BODY (frontmatter stripped) — clamped card
69
+ * in the grid, full in the item view */
70
+ md?: string
71
+ }
72
+ export type Col = { id: string; title: string; media: string; instances: ColInstance[] }