rvlib-shipkit 0.11.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 (60) hide show
  1. package/CHANGELOG.md +166 -0
  2. package/README.md +17 -3
  3. package/package.json +5 -2
  4. package/src/asks/asks-queue.ts +8 -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_Reflect.ts +116 -0
  12. package/src/cli/cmd_Rules.ts +32 -0
  13. package/src/cli/cmd_Serve.ts +1 -1
  14. package/src/cli.ts +5 -2
  15. package/src/collections/actions.ts +19 -0
  16. package/src/collections/collection-types.ts +13 -0
  17. package/src/collections/graphs.ts +34 -0
  18. package/src/collections/pings.ts +5 -1
  19. package/src/collections/posts.ts +20 -1
  20. package/src/collections/providers.ts +163 -50
  21. package/src/contracts/apply-contracts.ts +47 -0
  22. package/src/contracts/run-contracts.ts +8 -3
  23. package/src/core/canonical-scripts.ts +46 -0
  24. package/src/core/check-suite.ts +6 -3
  25. package/src/core/locked-write.ts +39 -0
  26. package/src/core/motherhome.ts +87 -0
  27. package/src/core/run-check.ts +28 -15
  28. package/src/core/run-ci.ts +4 -0
  29. package/src/core/write-status.ts +7 -26
  30. package/src/init-repo.ts +24 -11
  31. package/src/reflect/prompt-corpus.ts +305 -0
  32. package/src/repo-config.ts +20 -38
  33. package/src/rules/SK001-package-json-shape.ts +156 -0
  34. package/src/rules/SK002-contracts-fulfillness.ts +19 -0
  35. package/src/rules/SK003-claude-md-shape.ts +64 -0
  36. package/src/rules/SK004-gitignore-policy.ts +105 -0
  37. package/src/rules/SK005-rvlib-versions.ts +99 -0
  38. package/src/rules/SK006-npm-publish-safety.ts +42 -0
  39. package/src/rules/SK007-github-visibility.ts +63 -0
  40. package/src/rules/rule-registry.ts +32 -0
  41. package/src/rules/rule.ts +62 -0
  42. package/src/rules/run-fix.ts +57 -0
  43. package/src/serve/hub-app/col-card.tsx +192 -0
  44. package/src/serve/hub-app/cols-svc.ts +136 -0
  45. package/src/serve/hub-app/geometry-view.tsx +155 -0
  46. package/src/serve/hub-app/health-svc.ts +49 -0
  47. package/src/serve/hub-app/hub-app.tsx +236 -215
  48. package/src/serve/hub-app/hub-st.ts +169 -0
  49. package/src/serve/hub-app/hub-types.ts +72 -0
  50. package/src/serve/hub-app/hub.css +286 -59
  51. package/src/serve/hub-app/index.html +1 -1
  52. package/src/serve/hub-app/md.ts +24 -0
  53. package/src/serve/hub-app/media-svc.ts +429 -0
  54. package/src/serve/hub-app/media-view.tsx +299 -0
  55. package/src/serve/run-serve.ts +23 -9
  56. package/src/serve/serve-hub.ts +62 -47
  57. package/src/shipkit.ts +9 -5
  58. package/src/assets/server/studio-page.ts +0 -687
  59. package/src/core/package-json-check.ts +0 -88
  60. package/src/serve/hub-pages.ts +0 -38
@@ -1,245 +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 NeedItem = {
13
- collection: string
14
- id: string
15
- text: string
16
- severity?: 'red' | 'yellow'
17
- href?: string
18
- artifact?: { copy?: string; url?: string; steps?: string[] }
19
- verbs?: string[]
20
- candidates?: { id: string; label: string; hint?: string }[]
21
- }
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()
22
21
 
23
- const RAIL_ICONS: Record<string, string> = { '/': '⚡', '/graph/wiki': '🕸', '/graph/agent': '🤖', '/assets/': '🖼', '/status': '📊' }
24
-
25
- class HubStore {
26
- name = ''
27
- tools: HubTool[] = []
28
- needs: NeedItem[] | null = null
29
- redsOnly = false
30
- statusMd: string | null = null
31
- running = false
32
- note = ''
33
- error: string | null = null
34
-
35
- constructor() {
36
- makeAutoObservable(this)
37
- }
38
-
39
- get redCount(): number {
40
- return (this.needs ?? []).filter((n) => n.severity === 'red').length
41
- }
42
-
43
- get visible(): NeedItem[] {
44
- const all = this.needs ?? []
45
- const sorted = [...all].sort((a, b) => (a.severity === b.severity ? 0 : a.severity === 'red' ? -1 : 1))
46
- return this.redsOnly ? sorted.filter((n) => n.severity === 'red') : sorted
47
- }
48
-
49
- async load(): Promise<void> {
50
- try {
51
- const hub = (await (await fetch('/hub.json')).json()) as { name: string; tools: HubTool[] }
52
- const nd = (await (await fetch('/api/needs.json')).json()) as { needs: NeedItem[] }
53
- runInAction(() => {
54
- this.name = hub.name
55
- this.tools = hub.tools
56
- this.needs = nd.needs
57
- })
58
- if (location.pathname === '/status') {
59
- const s = (await (await fetch('/api/status.json')).json()) as { statusMd: string | null }
60
- runInAction(() => { this.statusMd = s.statusMd })
61
- }
62
- } catch (e) {
63
- runInAction(() => { this.error = (e as Error).message })
64
- }
65
- }
66
-
67
- /** per-tile act errors — feedback lands WHERE he clicked, never only in
68
- * a scrolled-away bar */
69
- actErr: Record<string, string> = {}
70
- busy = false
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
+ ))
71
32
 
72
- /** POST an act-in-place verb, then refetch */
73
- async act(id: string, path: string, body: Record<string, string>): Promise<void> {
74
- runInAction(() => { this.busy = true; delete this.actErr[id] })
75
- try {
76
- const r = await fetch(`/api/act/${path}`, { method: 'POST', body: JSON.stringify(body) })
77
- if (!r.ok) throw new Error(await r.text())
78
- runInAction(() => { this.note = 'done' })
79
- await this.load()
80
- } catch (e) {
81
- runInAction(() => { this.actErr[id] = (e as Error).message })
82
- }
83
- runInAction(() => { this.busy = false })
84
- }
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
+ })
85
61
 
86
- tileErr(id: string, msg: string): void {
87
- runInAction(() => { this.actErr[id] = msg })
88
- }
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
+ })
89
97
 
90
- async runCheck(): Promise<void> {
91
- this.running = true
92
- this.note = 'running…'
93
- let text = ''
94
- try {
95
- const r = await fetch('/status/run', { method: 'POST' })
96
- const rd = r.body!.getReader()
97
- const dec = new TextDecoder()
98
- for (;;) {
99
- const { done, value } = await rd.read()
100
- if (done) break
101
- text += dec.decode(value, { stream: true })
102
- runInAction(() => { this.statusMd = text })
103
- }
104
- runInAction(() => { this.note = 'done — STATUS.md refreshed on disk' })
105
- void this.load() // red counts moved — refresh the dashboard data
106
- } catch (e) {
107
- runInAction(() => {
108
- this.statusMd = `${text}\nFAIL stream broke: ${(e as Error).message}`
109
- this.note = 'stream broke'
110
- })
111
- }
112
- runInAction(() => { this.running = false })
113
- }
114
- }
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
+ ))
115
107
 
116
- const store = new HubStore()
117
- 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
+ ))
118
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: '🕸' }
119
127
  const Rail = observer(() => {
120
- 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) }
121
132
  return (
122
133
  <nav className="skrail">
123
- {items.map((t) => {
124
- const on = location.pathname === t.path || (t.path !== '/' && location.pathname.startsWith(t.path))
125
- return (
126
- <a key={t.path} className={on ? 'on' : ''} href={t.path} title={`${t.label} — ${t.desc}`}>
127
- {RAIL_ICONS[t.path] ?? '·'}
128
- {t.path === '/' && store.redCount > 0 ? <i /> : null}
129
- </a>
130
- )
131
- })}
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
+ ))}
132
144
  </nav>
133
145
  )
134
146
  })
135
147
 
136
- const copy = (s: string): void =>
137
- void navigator.clipboard.writeText(s).then(
138
- () => runInAction(() => { store.note = 'copied' }),
139
- () => runInAction(() => { store.note = 'copy failed (clipboard needs a secure context)' }),
140
- )
141
-
142
- /** one act-in-place tile per need — the artifact is IN HAND (copy buttons,
143
- * mint links, proof inputs), never a pointer to a flow */
144
- const NeedTile = observer(({ n }: { n: NeedItem }) => {
145
- const askFile = n.collection === 'asks' ? n.id.replace(/^ask:/, '') : null
146
- const post = n.collection === 'posts' ? n.id.match(/^post:([^/]+)\/(.+?):(pick|topost|invalid)$/) : null
147
- const pickedFile = n.collection === 'posts' ? n.candidates?.[0]?.id : null
148
+ // ── cmd+j palette: every jump target behind one input ────────────────────
149
+ const Palette = observer(() => {
150
+ if (!app.palOpen) return null
148
151
  return (
149
- <div className={`tile ${n.severity ?? ''}`}>
150
- <div className="tline">
151
- <span className="chip">{n.collection}</span>
152
- {n.href != null ? <a href={n.href}>{n.text}</a> : <span>{n.text}</span>}
153
- </div>
154
- {n.artifact?.copy != null ? (
155
- <div className="tact">
156
- <button onClick={() => copy(n.artifact!.copy!)}>copy {n.collection === 'creds' ? 'command' : n.collection === 'posts' ? 'post' : 'brief'}</button>
157
- <code>{n.artifact.copy.length > 120 ? `${n.artifact.copy.slice(0, 120)}…` : n.artifact.copy}</code>
158
- </div>
159
- ) : null}
160
- {n.artifact?.url != null ? <div className="tact"><a href={n.artifact.url} target="_blank" rel="noreferrer">mint ↗</a></div> : null}
161
- {n.artifact?.steps != null ? <details className="tact"><summary>steps</summary>{n.artifact.steps.map((s, i) => <div key={i}>{i + 1}. {s}</div>)}</details> : null}
162
- {post?.[3] === 'pick' ? (
163
- // the CORE taste interaction: candidates comparable at a
164
- // glance, never a blind collapse
165
- <div className="tact picks">
166
- {n.candidates?.map((c) => (
167
- <div key={c.id} className="cand">
168
- <div className="candhead">
169
- <span className="chip">{c.label}</span>
170
- <button disabled={store.busy} onClick={() => void store.act(n.id, 'post-pick', { channel: post[1]!, file: c.id })}>pick</button>
171
- </div>
172
- <div className="hint">{c.hint}</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>
173
169
  </div>
174
170
  ))}
171
+ {app.palResults.length === 0 ? <div className="prow none">no match</div> : null}
175
172
  </div>
176
- ) : null}
177
- {post?.[3] === 'topost' && pickedFile != null ? (
178
- <form className="tact" onSubmit={(e) => { e.preventDefault(); const url = new FormData(e.currentTarget).get('url') as string; if (url === '') store.tileErr(n.id, 'paste the posted URL first — the proof is the point'); else void store.act(n.id, 'post-posted', { channel: post[1]!, file: pickedFile, url }) }}>
179
- <input name="url" placeholder="posted URL = proof" />
180
- <button type="submit" disabled={store.busy}>posted ✓</button>
181
- </form>
182
- ) : null}
183
- {askFile != null ? (
184
- <form className="tact" onSubmit={(e) => { e.preventDefault(); const url = new FormData(e.currentTarget).get('url') as string; void (url !== '' ? store.act(n.id, 'ask-proof', { file: askFile, url }) : store.act(n.id, 'ask-done', { file: askFile })) }}>
185
- <input name="url" placeholder="proof URL (optional)" />
186
- <button type="submit" disabled={store.busy}>done ✓</button>
187
- </form>
188
- ) : null}
189
- {store.actErr[n.id] != null ? <div className="tileerr">✗ {store.actErr[n.id]}</div> : null}
173
+ </div>
190
174
  </div>
191
175
  )
192
176
  })
193
177
 
194
- const Attention = observer(() => (
195
- <div className="wrap">
196
- <div className="crumb">shipkit hub · needs</div>
197
- <h1>{store.name}<small>everything that needs you, artifact in hand</small></h1>
198
- {store.needs == null ? <div className="sub">loading…</div>
199
- : store.needs.length === 0 ? <div className="clear">✅ all clear — nothing needs you</div>
200
- : <>
201
- <div className="bar">
202
- <label><input type="checkbox" checked={store.redsOnly} onChange={(e) => runInAction(() => { store.redsOnly = e.target.checked })} /> reds only</label>
203
- <span className="note">{store.redCount} red · {(store.needs.length - store.redCount)} yellow{store.note !== '' ? ` · ${store.note}` : ''}</span>
204
- </div>
205
- {store.visible.map((n) => <NeedTile key={n.id} n={n} />)}
206
- </>}
207
- <div className="sub">refreshes on reload · checks refresh via <a href="/status">status → run check</a></div>
208
- </div>
209
- ))
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}
194
+ </div>
195
+ )
196
+ })
210
197
 
211
- const wireClass = (l: string): string => {
212
- const t = l.trimStart()
213
- if (t.startsWith('FAIL') || t.startsWith('✗')) return 'fail'
214
- if (t.startsWith('WARN') || t.startsWith('⚠')) return 'warn'
215
- if (t.startsWith('ok') || t.startsWith('✓')) return 'ok'
216
- if (t.startsWith('skip') || t.startsWith('fix:') || t.startsWith('#')) return 'dim'
217
- return ''
218
- }
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
+ })
219
236
 
220
- const Status = observer(() => (
221
- <div className="wrap">
222
- <div className="crumb">shipkit hub · status</div>
223
- <h1>{store.name}<small>STATUS.md</small></h1>
224
- <div className="bar">
225
- <button disabled={store.running} onClick={() => void store.runCheck()}>run check</button>
226
- <span className="note">{store.note !== '' ? store.note : <>shells <code>shipkit check</code>, wire lines stream in below</>}</span>
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 />
227
261
  </div>
228
- <pre>
229
- {(store.statusMd ?? 'no STATUS.md yet — hit "run check" (or run: shipkit check)')
230
- .split('\n')
231
- .map((l, i) => <span key={i} className={wireClass(l)}>{l}{'\n'}</span>)}
232
- </pre>
233
- </div>
234
- ))
235
-
236
- const App = observer(() => (
237
- <div className="app">
238
- <Rail />
239
- {store.error != null ? <div className="wrap err">hub api unreachable: {store.error}</div>
240
- : location.pathname === '/status' ? <Status /> : <Attention />}
241
- </div>
242
- ))
262
+ )
263
+ })
243
264
 
244
265
  createRoot(document.getElementById('root')!).render(
245
266
  <StrictMode>