rvlib-shipkit 0.11.0 → 0.14.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 +200 -0
- package/README.md +17 -3
- package/package.json +6 -3
- package/src/asks/asks-queue.ts +8 -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_Reflect.ts +116 -0
- package/src/cli/cmd_Rules.ts +32 -0
- package/src/cli/cmd_Serve.ts +1 -1
- package/src/cli.ts +5 -2
- package/src/collections/actions.ts +19 -0
- package/src/collections/collection-types.ts +13 -0
- package/src/collections/graphs.ts +34 -0
- package/src/collections/pings.ts +5 -1
- package/src/collections/posts.ts +20 -1
- package/src/collections/providers.ts +163 -50
- package/src/contracts/apply-contracts.ts +47 -0
- package/src/contracts/contract-types.ts +5 -2
- package/src/contracts/contracts-engine.ts +13 -3
- package/src/contracts/manifests/discord.contract.ts +1 -1
- package/src/contracts/manifests/identity.contract.ts +1 -1
- package/src/contracts/manifests/itch.contract.ts +19 -2
- 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 +27 -11
- package/src/reflect/prompt-corpus.ts +305 -0
- package/src/repo-config.ts +30 -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/SK008-reviewer-agent.ts +98 -0
- package/src/rules/rule-registry.ts +34 -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 +236 -215
- 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 -59
- 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 +23 -9
- package/src/serve/serve-hub.ts +62 -47
- package/src/shipkit.ts +9 -5
- 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 -38
|
@@ -1,245 +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
|
-
|
|
14
|
-
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
87
|
-
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
|
117
|
-
|
|
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
|
|
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
|
-
{
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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=
|
|
150
|
-
<div className="
|
|
151
|
-
<
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
-
|
|
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
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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
|
|
212
|
-
const
|
|
213
|
-
|
|
214
|
-
if (t
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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
|
-
|
|
229
|
-
|
|
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>
|