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.
- package/CHANGELOG.md +204 -0
- package/README.md +17 -3
- package/package.json +5 -2
- package/src/asks/asks-queue.ts +42 -0
- package/src/assets/kit-config.ts +2 -3
- package/src/assets/roll-workspace.ts +2 -1
- package/src/assets/run-assets.ts +0 -11
- package/src/assets/server/studio-server.ts +7 -24
- package/src/cli/cmd_Assets.ts +3 -14
- package/src/cli/cmd_Check.ts +22 -4
- package/src/cli/cmd_Needs.ts +47 -0
- package/src/cli/cmd_Reflect.ts +116 -0
- package/src/cli/cmd_Rules.ts +32 -0
- package/src/cli/cmd_Serve.ts +1 -1
- package/src/cli.ts +7 -2
- package/src/collections/actions.ts +85 -0
- package/src/collections/collection-types.ts +65 -0
- package/src/collections/graphs.ts +34 -0
- package/src/collections/pings.ts +72 -0
- package/src/collections/posts.ts +125 -0
- package/src/collections/providers.ts +275 -0
- package/src/contracts/apply-contracts.ts +47 -0
- package/src/contracts/run-contracts.ts +8 -3
- package/src/core/canonical-scripts.ts +46 -0
- package/src/core/check-suite.ts +6 -3
- package/src/core/locked-write.ts +39 -0
- package/src/core/motherhome.ts +87 -0
- package/src/core/run-check.ts +28 -15
- package/src/core/run-ci.ts +4 -0
- package/src/core/write-status.ts +7 -26
- package/src/init-repo.ts +24 -11
- package/src/reflect/prompt-corpus.ts +305 -0
- package/src/repo-config.ts +26 -38
- package/src/rules/SK001-package-json-shape.ts +156 -0
- package/src/rules/SK002-contracts-fulfillness.ts +19 -0
- package/src/rules/SK003-claude-md-shape.ts +64 -0
- package/src/rules/SK004-gitignore-policy.ts +105 -0
- package/src/rules/SK005-rvlib-versions.ts +99 -0
- package/src/rules/SK006-npm-publish-safety.ts +42 -0
- package/src/rules/SK007-github-visibility.ts +63 -0
- package/src/rules/rule-registry.ts +32 -0
- package/src/rules/rule.ts +62 -0
- package/src/rules/run-fix.ts +57 -0
- package/src/serve/hub-app/col-card.tsx +192 -0
- package/src/serve/hub-app/cols-svc.ts +136 -0
- package/src/serve/hub-app/geometry-view.tsx +155 -0
- package/src/serve/hub-app/health-svc.ts +49 -0
- package/src/serve/hub-app/hub-app.tsx +240 -127
- package/src/serve/hub-app/hub-st.ts +169 -0
- package/src/serve/hub-app/hub-types.ts +72 -0
- package/src/serve/hub-app/hub.css +286 -35
- package/src/serve/hub-app/index.html +1 -1
- package/src/serve/hub-app/md.ts +24 -0
- package/src/serve/hub-app/media-svc.ts +429 -0
- package/src/serve/hub-app/media-view.tsx +299 -0
- package/src/serve/run-serve.ts +46 -91
- package/src/serve/serve-hub.ts +99 -49
- package/src/shipkit.ts +13 -6
- package/src/assets/server/studio-page.ts +0 -687
- package/src/core/package-json-check.ts +0 -88
- package/src/serve/hub-pages.ts +0 -45
|
@@ -1,153 +1,266 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
|
|
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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
31
|
-
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
const
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
|
79
|
-
|
|
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
|
|
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
|
-
{
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
|
|
99
|
-
|
|
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="
|
|
102
|
-
<div className="
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
if (
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
-
|
|
137
|
-
|
|
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
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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[] }
|