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
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
// Collection views: the per-collection card grid (ColGrid), the item card
|
|
2
|
+
// (asks · creds · posts), the graph item view (GraphView — embeds the
|
|
3
|
+
// /graph/* page itself), and the health/status card with the streaming
|
|
4
|
+
// check runner. Every verb posts to /api/act/* and lands in a git-tracked
|
|
5
|
+
// truth file; the truth chip names the file the item IS (on disagreement
|
|
6
|
+
// the file wins).
|
|
7
|
+
import { runInAction } from 'mobx'
|
|
8
|
+
import { observer } from 'mobx-react-lite'
|
|
9
|
+
import type { HubSt } from './hub-st.ts'
|
|
10
|
+
import type { ColInstance } from './hub-types.ts'
|
|
11
|
+
import { renderMd } from './md.ts'
|
|
12
|
+
|
|
13
|
+
// creds carry no truth file — name/service/state parse out of the instance
|
|
14
|
+
const credOf = (i: ColInstance): { name: string; service: string | null; missing: boolean } => ({
|
|
15
|
+
name: i.id.replace(/^cred:/, ''),
|
|
16
|
+
service: i.text.includes(' — ') ? i.text.split(' — ').slice(1).join(' — ') : null,
|
|
17
|
+
missing: i.severity != null,
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
const CredHead = ({ cred }: { cred: ReturnType<typeof credOf> }) => (
|
|
21
|
+
<>
|
|
22
|
+
<span className="credname">{cred.name}</span>
|
|
23
|
+
<span className={`stchip ${cred.missing ? 'miss' : 'ok'}`}>{cred.missing ? 'missing' : 'present'}</span>
|
|
24
|
+
</>
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
export const ColCard = observer(({ app, col, id }: { app: HubSt; col: string; id: string }) => {
|
|
28
|
+
const c = app.cols
|
|
29
|
+
const i = c.find(col, id)
|
|
30
|
+
if (i == null) return <div className="ph">item vanished — the truth file moved on</div>
|
|
31
|
+
const askFile = col === 'asks' ? id.replace(/^ask:/, '') : null
|
|
32
|
+
const postM = col === 'posts' ? id.match(/^post:([^/]+)\/(.+?):(pick|topost|invalid|posted|rejected)$/) : null
|
|
33
|
+
const postFile = (i.file ?? '').split('/').pop() ?? ''
|
|
34
|
+
if (col === 'creds') {
|
|
35
|
+
// no remark box, no generic dump — a cred has no truth file; the
|
|
36
|
+
// copy block + steps only matter while it is MISSING
|
|
37
|
+
const cred = credOf(i)
|
|
38
|
+
return (
|
|
39
|
+
<div className="colcard">
|
|
40
|
+
<div className="back" onClick={() => app.showCol(col)}>← {c.colTitle(col)}</div>
|
|
41
|
+
<h3>
|
|
42
|
+
<span className={`cdot ${i.severity ?? ''}`} /> <CredHead cred={cred} />
|
|
43
|
+
{i.artifact?.url != null ? <a className="mint" href={i.artifact.url} target="_blank" rel="noreferrer">mint ↗</a> : null}
|
|
44
|
+
</h3>
|
|
45
|
+
{cred.service != null ? <div className="creddesc">{cred.service}</div> : null}
|
|
46
|
+
{/* probe FAIL + fix: pointers ride the instance md — for an
|
|
47
|
+
ask-covered cred (no verbs, no artifact) it is the ONLY detail */}
|
|
48
|
+
{i.md != null ? <div className="md" dangerouslySetInnerHTML={{ __html: renderMd(i.md) }} /> : null}
|
|
49
|
+
{cred.missing && i.artifact?.copy != null ? (
|
|
50
|
+
<>
|
|
51
|
+
<code>{i.artifact.copy}</code>
|
|
52
|
+
<div className="row"><button onClick={() => void navigator.clipboard.writeText(i.artifact!.copy!)}>copy</button></div>
|
|
53
|
+
</>
|
|
54
|
+
) : null}
|
|
55
|
+
{cred.missing && i.artifact?.steps != null ? <div className="row"><div>{i.artifact.steps.map((s, n) => <span key={n}>{n + 1}. {s}<br /></span>)}</div></div> : null}
|
|
56
|
+
</div>
|
|
57
|
+
)
|
|
58
|
+
}
|
|
59
|
+
return (
|
|
60
|
+
<div className="colcard">
|
|
61
|
+
<div className="back" onClick={() => app.showCol(col)}>← {c.colTitle(col)}</div>
|
|
62
|
+
<h3>
|
|
63
|
+
<span className={`cdot ${i.severity ?? ''}`} /> {i.text}
|
|
64
|
+
{i.file != null ? <span className="truth">{i.file}</span> : null}
|
|
65
|
+
</h3>
|
|
66
|
+
{i.md != null ? <div className="md" dangerouslySetInnerHTML={{ __html: renderMd(i.md) }} /> : null}
|
|
67
|
+
{i.remarks != null && i.remarks.length > 0 ? <div className="rem">{i.remarks.join('\n')}</div> : null}
|
|
68
|
+
{i.artifact?.copy != null ? (
|
|
69
|
+
<>
|
|
70
|
+
<code>{i.artifact.copy}</code>
|
|
71
|
+
<div className="row"><button onClick={() => void navigator.clipboard.writeText(i.artifact!.copy!)}>copy</button></div>
|
|
72
|
+
</>
|
|
73
|
+
) : null}
|
|
74
|
+
{i.artifact?.url != null ? <div className="row"><a href={i.artifact.url} target="_blank" rel="noreferrer">mint ↗</a></div> : null}
|
|
75
|
+
{i.artifact?.steps != null ? <div className="row"><div>{i.artifact.steps.map((s, n) => <span key={n}>{n + 1}. {s}<br /></span>)}</div></div> : null}
|
|
76
|
+
{postM?.[3] === 'pick' && i.candidates != null ? i.candidates.map((cd) => (
|
|
77
|
+
<div className="cand" key={cd.id}>
|
|
78
|
+
<div className="row"><b>{cd.label}</b><button onClick={() => void c.postPick(postM[1]!, cd.id)}>pick</button></div>
|
|
79
|
+
<div className="candhint">{cd.hint ?? ''}</div>
|
|
80
|
+
</div>
|
|
81
|
+
)) : null}
|
|
82
|
+
{postM?.[3] === 'topost' ? (
|
|
83
|
+
<div className="row">
|
|
84
|
+
<input placeholder="posted URL = proof" value={c.proofDraft} onChange={(e) => runInAction(() => { c.proofDraft = e.target.value })} />
|
|
85
|
+
<button onClick={() => void c.postPosted(postM[1]!, postFile)}>posted ✓</button>
|
|
86
|
+
</div>
|
|
87
|
+
) : null}
|
|
88
|
+
{askFile != null && i.severity != null ? (
|
|
89
|
+
<div className="row">
|
|
90
|
+
<input placeholder="proof URL (optional)" value={c.proofDraft} onChange={(e) => runInAction(() => { c.proofDraft = e.target.value })} />
|
|
91
|
+
<button onClick={() => void c.askDone(askFile)}>done ✓</button>
|
|
92
|
+
</div>
|
|
93
|
+
) : null}
|
|
94
|
+
{askFile != null || postM != null ? (
|
|
95
|
+
<div className="row">
|
|
96
|
+
<input placeholder="remark → lands in the file, agents read it" value={c.remarkDraft}
|
|
97
|
+
onChange={(e) => runInAction(() => { c.remarkDraft = e.target.value })} />
|
|
98
|
+
<button className="ghost" onClick={() => void c.remark(askFile != null ? { kind: 'ask', file: askFile } : { kind: 'post', channel: postM![1], file: postFile })}>remark</button>
|
|
99
|
+
</div>
|
|
100
|
+
) : null}
|
|
101
|
+
{c.actErr !== '' ? <div className="err">✗ {c.actErr}</div> : null}
|
|
102
|
+
</div>
|
|
103
|
+
)
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
// collection view — one card per visible instance, the stage-side twin of a
|
|
107
|
+
// sidebar section (his rule 2026-07-23: every section gets both views)
|
|
108
|
+
export const ColGrid = observer(({ app, id }: { app: HubSt; id: string }) => {
|
|
109
|
+
const c = app.cols
|
|
110
|
+
const col = c.sidebarCols.find((x) => x.id === id)
|
|
111
|
+
if (col == null) return <div className="stagewrap"><div className="ph">collection vanished — the truth moved on</div></div>
|
|
112
|
+
const items = c.visibleInstances(col)
|
|
113
|
+
return (
|
|
114
|
+
<div className="colgrid">
|
|
115
|
+
<div className="cghead">{col.title}<span className="cnt">{items.length}</span></div>
|
|
116
|
+
{items.length === 0 ? <div className="ph">{app.filter === 'red' ? 'no reds here' : app.filter === 'needs' ? 'nothing here needs you' : 'nothing here yet'}</div> : (
|
|
117
|
+
<div className="cgcards">
|
|
118
|
+
{items.map((i) => {
|
|
119
|
+
if (id === 'creds') {
|
|
120
|
+
const cred = credOf(i)
|
|
121
|
+
return (
|
|
122
|
+
<div key={i.id} className="cgcard" onClick={() => c.select(col.id, i.id)}>
|
|
123
|
+
<div className="cgtop"><span className={`cdot ${i.severity ?? ''}`} /><CredHead cred={cred} /></div>
|
|
124
|
+
{cred.service != null ? <div className="creddesc">{cred.service}</div> : null}
|
|
125
|
+
{i.md != null ? <div className="md clamp" dangerouslySetInnerHTML={{ __html: renderMd(i.md) }} /> : null}
|
|
126
|
+
{i.artifact?.url != null ? (
|
|
127
|
+
<div className="credrow"><a className="mint" href={i.artifact.url} target="_blank" rel="noreferrer" onClick={(e) => e.stopPropagation()}>mint ↗</a></div>
|
|
128
|
+
) : null}
|
|
129
|
+
</div>
|
|
130
|
+
)
|
|
131
|
+
}
|
|
132
|
+
return (
|
|
133
|
+
<div key={i.id} className="cgcard" onClick={() => c.select(col.id, i.id)}>
|
|
134
|
+
<div className="cgtop"><span className={`cdot ${i.severity ?? ''}`} /><b>{i.text}</b></div>
|
|
135
|
+
{/* a link click opens its tab — it must not ALSO yank into the item view */}
|
|
136
|
+
{i.md != null ? <div className="md clamp" onClick={(e) => { if ((e.target as HTMLElement).tagName === 'A') e.stopPropagation() }} dangerouslySetInnerHTML={{ __html: renderMd(i.md) }} /> : null}
|
|
137
|
+
{i.file != null ? <span className="truth">{i.file}</span> : null}
|
|
138
|
+
{i.remarks != null && i.remarks.length > 0 ? <div className="cgrem">{i.remarks[i.remarks.length - 1]}</div> : null}
|
|
139
|
+
</div>
|
|
140
|
+
)
|
|
141
|
+
})}
|
|
142
|
+
</div>
|
|
143
|
+
)}
|
|
144
|
+
</div>
|
|
145
|
+
)
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
// graph item view — the graph ITSELF fills the stage (his order 2026-07-23:
|
|
149
|
+
// "if I select a graph, I want to see it"), lint line as a slim header
|
|
150
|
+
export const GraphView = observer(({ app, id }: { app: HubSt; id: string }) => {
|
|
151
|
+
const c = app.cols
|
|
152
|
+
const i = c.find('graphs', id)
|
|
153
|
+
if (i == null) return <div className="stagewrap"><div className="ph">graph vanished — the truth moved on</div></div>
|
|
154
|
+
if (i.href == null) return <div className="stagewrap"><div className="ph">this graph carries no page to embed</div></div>
|
|
155
|
+
return (
|
|
156
|
+
<div className="graphview">
|
|
157
|
+
<div className="gvhead">
|
|
158
|
+
<span className="back" onClick={() => app.showCol('graphs')}>← {c.colTitle('graphs')}</span>
|
|
159
|
+
<span className={`cdot ${i.severity ?? ''}`} />
|
|
160
|
+
<span className="gvtext">{i.text}</span>
|
|
161
|
+
</div>
|
|
162
|
+
<iframe src={i.href} title={i.text} />
|
|
163
|
+
</div>
|
|
164
|
+
)
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
const wireClass = (l: string): string => {
|
|
168
|
+
const t = l.trimStart()
|
|
169
|
+
if (t.startsWith('FAIL') || t.startsWith('✗')) return 'fail'
|
|
170
|
+
if (t.startsWith('WARN') || t.startsWith('⚠')) return 'warn'
|
|
171
|
+
if (t.startsWith('ok') || t.startsWith('✓')) return 'ok'
|
|
172
|
+
if (t.startsWith('skip') || t.startsWith('fix:') || t.startsWith('#')) return 'dim'
|
|
173
|
+
return ''
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export const StatusCard = observer(({ app }: { app: HubSt }) => {
|
|
177
|
+
const h = app.health
|
|
178
|
+
return (
|
|
179
|
+
<div className="wrap">
|
|
180
|
+
<h1>{app.name}<small>STATUS.md</small></h1>
|
|
181
|
+
<div className="hbar">
|
|
182
|
+
<button disabled={h.running} onClick={() => void h.runCheck()}>run check</button>
|
|
183
|
+
<span className="note">{h.note !== '' ? h.note : <>shells <code>shipkit check</code>, wire lines stream in below</>}</span>
|
|
184
|
+
</div>
|
|
185
|
+
<pre>
|
|
186
|
+
{(h.statusMd ?? 'no STATUS.md yet — hit "run check" (or run: shipkit check)')
|
|
187
|
+
.split('\n')
|
|
188
|
+
.map((l, i) => <span key={i} className={wireClass(l)}>{l}{'\n'}</span>)}
|
|
189
|
+
</pre>
|
|
190
|
+
</div>
|
|
191
|
+
)
|
|
192
|
+
})
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// Collections service — asks · creds · posts · health · graphs sections +
|
|
2
|
+
// the act-in-place verbs. Every act hits a git-tracked truth file through
|
|
3
|
+
// /api/act/*; errors come back as 400 text shown verbatim on the card.
|
|
4
|
+
import { makeAutoObservable, runInAction } from 'mobx'
|
|
5
|
+
import type { HubSt } from './hub-st.ts'
|
|
6
|
+
import type { Col, ColInstance } from './hub-types.ts'
|
|
7
|
+
|
|
8
|
+
export class ColsSvc {
|
|
9
|
+
cols: Col[] = []
|
|
10
|
+
actErr = ''
|
|
11
|
+
remarkDraft = ''
|
|
12
|
+
proofDraft = ''
|
|
13
|
+
/** user fold choices win; default = open when the section holds needs */
|
|
14
|
+
foldOverride = new Map<string, boolean>()
|
|
15
|
+
private timer: ReturnType<typeof setInterval> | null = null
|
|
16
|
+
|
|
17
|
+
constructor(private st: HubSt) {
|
|
18
|
+
makeAutoObservable<this, 'st' | 'timer'>(this, { st: false, timer: false })
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
start(): void {
|
|
22
|
+
void this.poll()
|
|
23
|
+
this.timer = setInterval(() => void this.poll(), 10_000)
|
|
24
|
+
}
|
|
25
|
+
dispose(): void {
|
|
26
|
+
if (this.timer != null) clearInterval(this.timer)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async poll(): Promise<void> {
|
|
30
|
+
try {
|
|
31
|
+
const r = await fetch('/api/collections.json')
|
|
32
|
+
if (!r.ok) throw new Error(await r.text())
|
|
33
|
+
const j = (await r.json()) as { collections: Col[] }
|
|
34
|
+
runInAction(() => { this.cols = j.collections })
|
|
35
|
+
} catch (e) {
|
|
36
|
+
runInAction(() => {
|
|
37
|
+
this.cols = [{ id: 'hub', title: 'hub', media: 'health', instances: [{ id: 'hub:collections-broken', text: `collections fetch broke: ${(e as Error).message}`, severity: 'red' }] }]
|
|
38
|
+
})
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** media instances render richer through the media sidebar — the
|
|
43
|
+
* collection twin would be a duplicate surface (kept in `cols` so the
|
|
44
|
+
* asset card can show its remarks) */
|
|
45
|
+
get sidebarCols(): Col[] {
|
|
46
|
+
return this.cols.filter((c) => c.id !== 'media')
|
|
47
|
+
}
|
|
48
|
+
mediaRemarks(assetId: string): string[] {
|
|
49
|
+
return this.cols.find((c) => c.id === 'media')?.instances.find((i) => i.id === `media:${assetId}`)?.remarks ?? []
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
needsItem = (i: ColInstance): boolean => i.severity != null
|
|
53
|
+
isOpen(c: Col): boolean {
|
|
54
|
+
// a live search must SHOW its matches — folds yield while it runs
|
|
55
|
+
if (this.st.query.trim() !== '') return true
|
|
56
|
+
return this.foldOverride.get(c.id) ?? c.instances.some(this.needsItem)
|
|
57
|
+
}
|
|
58
|
+
toggleFold(c: Col): void {
|
|
59
|
+
// folds yield to a live search — toggling then would do nothing
|
|
60
|
+
// visibly and snap shut when the query clears
|
|
61
|
+
if (this.st.query.trim() !== '') return
|
|
62
|
+
this.foldOverride.set(c.id, !this.isOpen(c))
|
|
63
|
+
}
|
|
64
|
+
openSection(c: Col): void {
|
|
65
|
+
this.foldOverride.set(c.id, true)
|
|
66
|
+
}
|
|
67
|
+
/** rail red dot — the section holds at least one red item */
|
|
68
|
+
hasRed(c: Col): boolean {
|
|
69
|
+
return c.instances.some((i) => i.severity === 'red')
|
|
70
|
+
}
|
|
71
|
+
get mediaHasRed(): boolean {
|
|
72
|
+
return this.cols.find((c) => c.id === 'media')?.instances.some((i) => i.severity === 'red') === true
|
|
73
|
+
}
|
|
74
|
+
visibleInstances(c: Col): ColInstance[] {
|
|
75
|
+
const f = this.st.filter
|
|
76
|
+
const base = f === 'all' ? c.instances
|
|
77
|
+
: c.instances.filter((i) => (f === 'red' ? i.severity === 'red' : this.needsItem(i)))
|
|
78
|
+
const q = this.st.query.trim().toLowerCase()
|
|
79
|
+
if (q === '') return base
|
|
80
|
+
return base.filter((i) => i.text.toLowerCase().includes(q) || (i.file ?? '').toLowerCase().includes(q))
|
|
81
|
+
}
|
|
82
|
+
get redCount(): number {
|
|
83
|
+
return this.cols.flatMap((c) => c.instances).filter((i) => i.severity === 'red').length
|
|
84
|
+
}
|
|
85
|
+
find(colId: string, id: string): ColInstance | null {
|
|
86
|
+
return this.cols.find((c) => c.id === colId)?.instances.find((i) => i.id === id) ?? null
|
|
87
|
+
}
|
|
88
|
+
colTitle(colId: string): string {
|
|
89
|
+
return this.cols.find((c) => c.id === colId)?.title ?? colId
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
select(colId: string, id: string): void {
|
|
93
|
+
this.st.sel = { t: 'item', col: colId, id }
|
|
94
|
+
this.actErr = ''
|
|
95
|
+
this.remarkDraft = ''
|
|
96
|
+
this.proofDraft = ''
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
private async act(path: string, body: Record<string, unknown>): Promise<boolean> {
|
|
100
|
+
this.actErr = ''
|
|
101
|
+
try {
|
|
102
|
+
const r = await fetch(`/api/act/${path}`, { method: 'POST', body: JSON.stringify(body) })
|
|
103
|
+
if (!r.ok) throw new Error(await r.text())
|
|
104
|
+
await this.poll()
|
|
105
|
+
return true
|
|
106
|
+
} catch (e) {
|
|
107
|
+
runInAction(() => { this.actErr = (e as Error).message })
|
|
108
|
+
return false
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
askDone(file: string): Promise<boolean> {
|
|
113
|
+
const url = this.proofDraft.trim()
|
|
114
|
+
return url !== '' ? this.act('ask-proof', { file, url }) : this.act('ask-done', { file })
|
|
115
|
+
}
|
|
116
|
+
postPick(channel: string, file: string): Promise<boolean> {
|
|
117
|
+
return this.act('post-pick', { channel, file })
|
|
118
|
+
}
|
|
119
|
+
async postPosted(channel: string, file: string): Promise<boolean> {
|
|
120
|
+
const url = this.proofDraft.trim()
|
|
121
|
+
if (url === '') {
|
|
122
|
+
this.actErr = 'paste the posted URL first — the proof is the point'
|
|
123
|
+
return false
|
|
124
|
+
}
|
|
125
|
+
const ok = await this.act('post-posted', { channel, file, url })
|
|
126
|
+
if (ok) runInAction(() => { this.proofDraft = '' })
|
|
127
|
+
return ok
|
|
128
|
+
}
|
|
129
|
+
async remark(body: Record<string, unknown>): Promise<void> {
|
|
130
|
+
const text = this.remarkDraft.trim()
|
|
131
|
+
if (text === '') return
|
|
132
|
+
// draft survives a failed send — his words are never erased
|
|
133
|
+
const ok = await this.act('remark', { ...body, text })
|
|
134
|
+
if (ok) runInAction(() => { this.remarkDraft = '' })
|
|
135
|
+
}
|
|
136
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// Geometry layer — the ground-rect oval + legs overlay on the stage image,
|
|
2
|
+
// plus the rectbar. Ported 1:1 from the deleted vanilla studio SPA: same
|
|
3
|
+
// drag math (move 1:1; resize anchored left-tip + center-y, legs scale),
|
|
4
|
+
// same 250ms debounced save through MediaSvc. Pointer-capture drags mutate
|
|
5
|
+
// the observable rect inside runInAction; the render derives every style
|
|
6
|
+
// from (rect × measured image box).
|
|
7
|
+
import { runInAction } from 'mobx'
|
|
8
|
+
import { observer } from 'mobx-react-lite'
|
|
9
|
+
import { useRef } from 'react'
|
|
10
|
+
import type { HubSt } from './hub-st.ts'
|
|
11
|
+
import type { Leg } from './hub-types.ts'
|
|
12
|
+
|
|
13
|
+
export type Box = { scale: number; x: number; y: number }
|
|
14
|
+
|
|
15
|
+
/** vanilla imgBox(): where the letterboxed image actually sits in the stage */
|
|
16
|
+
export function measureBox(stage: HTMLElement | null, img: HTMLImageElement | null): Box | null {
|
|
17
|
+
if (stage == null || img == null || !img.naturalWidth) return null
|
|
18
|
+
const b = img.getBoundingClientRect()
|
|
19
|
+
const st = stage.getBoundingClientRect()
|
|
20
|
+
const scale = Math.min(b.width / img.naturalWidth, b.height / img.naturalHeight)
|
|
21
|
+
const w = img.naturalWidth * scale
|
|
22
|
+
const h = img.naturalHeight * scale
|
|
23
|
+
return { scale, x: b.left - st.left + (b.width - w) / 2, y: b.top - st.top + (b.height - h) / 2 }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const LEG_TAGS = ['L·front', 'L·back', 'R·back', 'R·front']
|
|
27
|
+
|
|
28
|
+
type DragStart = { px: number; py: number; x: number; y: number; w: number; legs: Leg[]; cy: number; mode: 'move' | 'resize' }
|
|
29
|
+
|
|
30
|
+
const LegBox = observer(({ app, box, i, leg }: { app: HubSt; box: Box; i: number; leg: Leg }) => {
|
|
31
|
+
const m = app.media
|
|
32
|
+
const start = useRef<{ px: number; py: number; legs: Leg[]; mode: 'move' | 'resize' } | null>(null)
|
|
33
|
+
const px = (v: number): number => v * box.scale
|
|
34
|
+
const far = i < 2
|
|
35
|
+
const rect = m.rect!
|
|
36
|
+
return (
|
|
37
|
+
<>
|
|
38
|
+
<div
|
|
39
|
+
className={`leg ${far ? 'far' : 'near'}`}
|
|
40
|
+
style={{ color: far ? 'var(--blue)' : 'var(--red)', left: box.x + px(leg.x), top: box.y + px(leg.y), width: px(leg.w), height: px(leg.h) }}
|
|
41
|
+
onPointerDown={(e) => {
|
|
42
|
+
if (rect.legs == null) return
|
|
43
|
+
start.current = { px: e.clientX, py: e.clientY, legs: rect.legs.map((l) => ({ ...l })), mode: (e.target as HTMLElement).classList.contains('lh') ? 'resize' : 'move' }
|
|
44
|
+
;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)
|
|
45
|
+
e.preventDefault()
|
|
46
|
+
e.stopPropagation()
|
|
47
|
+
}}
|
|
48
|
+
onPointerMove={(e) => {
|
|
49
|
+
const s = start.current
|
|
50
|
+
if (s == null || rect.legs == null) return
|
|
51
|
+
const dx = (e.clientX - s.px) / box.scale
|
|
52
|
+
const dy = (e.clientY - s.py) / box.scale
|
|
53
|
+
runInAction(() => {
|
|
54
|
+
if (s.mode === 'move') {
|
|
55
|
+
const o = s.legs[i]!
|
|
56
|
+
rect.legs![i] = { ...o, x: o.x + dx, y: o.y + dy }
|
|
57
|
+
} else {
|
|
58
|
+
// one size for all 4 — every leg keeps its own FOOT planted
|
|
59
|
+
const o = s.legs[i]!
|
|
60
|
+
const w = Math.max(6, o.w + dx)
|
|
61
|
+
const h = Math.max(6, o.h - dy)
|
|
62
|
+
rect.legs = s.legs.map((l) => ({ x: l.x + (l.w - w) / 2, y: l.y + l.h - h, w, h }))
|
|
63
|
+
}
|
|
64
|
+
})
|
|
65
|
+
m.saveRect()
|
|
66
|
+
}}
|
|
67
|
+
onPointerUp={() => { start.current = null; m.saveRect() }}
|
|
68
|
+
>
|
|
69
|
+
<div className="hip" />
|
|
70
|
+
<div className="foot" />
|
|
71
|
+
<div className="tag">{LEG_TAGS[i]}</div>
|
|
72
|
+
{i === 0 ? <div className="lh" /> : null}
|
|
73
|
+
</div>
|
|
74
|
+
<div
|
|
75
|
+
className={`pill${far ? '' : ' near'}`}
|
|
76
|
+
style={{ left: box.x + px(leg.x), top: box.y + px(leg.y), width: px(leg.w), height: px(leg.h), background: rect.legColor ?? '#c8a070' }}
|
|
77
|
+
/>
|
|
78
|
+
</>
|
|
79
|
+
)
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
export const GeometryLayer = observer(({ app, box }: { app: HubSt; box: Box | null }) => {
|
|
83
|
+
const m = app.media
|
|
84
|
+
const start = useRef<DragStart | null>(null)
|
|
85
|
+
const rect = m.rect
|
|
86
|
+
if (!m.hasGeom || rect == null || box == null) return null
|
|
87
|
+
const px = (v: number): number => v * box.scale
|
|
88
|
+
return (
|
|
89
|
+
<>
|
|
90
|
+
<div className="plumb" style={{ display: 'block', left: box.x + px(rect.x) }} />
|
|
91
|
+
<div className="plumb" style={{ display: 'block', left: box.x + px(rect.x + rect.w) }} />
|
|
92
|
+
<div
|
|
93
|
+
id="ov"
|
|
94
|
+
style={{ display: 'block', left: box.x + px(rect.x), top: box.y + px(rect.y), width: px(rect.w), height: px(rect.h) }}
|
|
95
|
+
onPointerDown={(e) => {
|
|
96
|
+
start.current = {
|
|
97
|
+
px: e.clientX, py: e.clientY, x: rect.x, y: rect.y, w: rect.w,
|
|
98
|
+
legs: (rect.legs ?? []).map((l) => ({ ...l })), cy: rect.y + rect.h / 2,
|
|
99
|
+
mode: (e.target as HTMLElement).classList.contains('hdl') ? 'resize' : 'move',
|
|
100
|
+
}
|
|
101
|
+
;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)
|
|
102
|
+
e.preventDefault()
|
|
103
|
+
}}
|
|
104
|
+
onPointerMove={(e) => {
|
|
105
|
+
const s = start.current
|
|
106
|
+
if (s == null) return
|
|
107
|
+
const dx = (e.clientX - s.px) / box.scale
|
|
108
|
+
const dy = (e.clientY - s.py) / box.scale
|
|
109
|
+
runInAction(() => {
|
|
110
|
+
if (s.mode === 'move') {
|
|
111
|
+
rect.x = Math.max(0, s.x + dx)
|
|
112
|
+
rect.y = Math.max(0, s.y + dy)
|
|
113
|
+
if (s.legs.length > 0) rect.legs = s.legs.map((l) => ({ ...l, x: l.x + (rect.x - s.x), y: l.y + (rect.y - s.y) }))
|
|
114
|
+
} else {
|
|
115
|
+
// anchored: left edge + center-y; legs scale around the same anchor
|
|
116
|
+
rect.w = Math.max(20, s.w + dx)
|
|
117
|
+
rect.h = rect.w * m.rectRatio
|
|
118
|
+
rect.y = s.cy - rect.h / 2
|
|
119
|
+
const f = rect.w / s.w
|
|
120
|
+
if (s.legs.length > 0) rect.legs = s.legs.map((l) => ({ x: s.x + (l.x - s.x) * f, y: s.cy + (l.y - s.cy) * f, w: l.w * f, h: l.h * f }))
|
|
121
|
+
}
|
|
122
|
+
})
|
|
123
|
+
m.saveRect()
|
|
124
|
+
}}
|
|
125
|
+
onPointerUp={() => { start.current = null; m.saveRect() }}
|
|
126
|
+
>
|
|
127
|
+
<div className="hdl" />
|
|
128
|
+
<div className="arrow" />
|
|
129
|
+
<div className="sz">{+rect.size.toFixed(2)}m</div>
|
|
130
|
+
</div>
|
|
131
|
+
{(rect.legs ?? []).map((l, i) => <LegBox key={i} app={app} box={box} i={i} leg={l} />)}
|
|
132
|
+
</>
|
|
133
|
+
)
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
export const Rectbar = observer(({ app }: { app: HubSt }) => {
|
|
137
|
+
const m = app.media
|
|
138
|
+
const rect = m.rect
|
|
139
|
+
if (!m.hasGeom || rect == null) return null
|
|
140
|
+
const cx = Math.round(rect.x + rect.w / 2)
|
|
141
|
+
const cy = Math.round(rect.y + rect.h / 2)
|
|
142
|
+
return (
|
|
143
|
+
<div id="rectbar" style={{ display: 'flex' }}>
|
|
144
|
+
<label>size <input type="number" step={0.1} min={0.1} value={rect.size}
|
|
145
|
+
onChange={(e) => { runInAction(() => { rect.size = Math.max(0.1, +e.target.value || rect.size) }); m.saveRect() }} /></label>
|
|
146
|
+
<button className="ghost" onClick={() => void m.legsDefault()}>legs</button>
|
|
147
|
+
<input type="color" title="leg color" value={/^#[0-9a-f]{6}$/i.test(rect.legColor ?? '') ? rect.legColor : '#c8a070'}
|
|
148
|
+
onChange={(e) => { runInAction(() => { rect.legColor = e.target.value }); m.saveRect() }} />
|
|
149
|
+
{rect.legs != null
|
|
150
|
+
? <button className="ghost" title="remove legs" onClick={() => { if (confirm('remove all 4 legs?')) m.removeLegs() }}>✕</button>
|
|
151
|
+
: null}
|
|
152
|
+
<span className="ro">cx{cx} cy{cy} w{Math.round(rect.w)}px</span>
|
|
153
|
+
</div>
|
|
154
|
+
)
|
|
155
|
+
})
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// Health service — STATUS.md + the streaming check runner (the health
|
|
2
|
+
// section's card in THE page).
|
|
3
|
+
import { makeAutoObservable, runInAction } from 'mobx'
|
|
4
|
+
import type { HubSt } from './hub-st.ts'
|
|
5
|
+
|
|
6
|
+
export class HealthSvc {
|
|
7
|
+
statusMd: string | null = null
|
|
8
|
+
running = false
|
|
9
|
+
note = ''
|
|
10
|
+
|
|
11
|
+
constructor(private st: HubSt) {
|
|
12
|
+
makeAutoObservable<this, 'st'>(this, { st: false })
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async load(): Promise<void> {
|
|
16
|
+
try {
|
|
17
|
+
const s = (await (await fetch('/api/status.json')).json()) as { statusMd: string | null }
|
|
18
|
+
runInAction(() => { this.statusMd = s.statusMd })
|
|
19
|
+
} catch (e) {
|
|
20
|
+
runInAction(() => { this.statusMd = `FAIL status fetch broke: ${(e as Error).message}` })
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async runCheck(): Promise<void> {
|
|
25
|
+
this.running = true
|
|
26
|
+
this.note = 'running…'
|
|
27
|
+
let text = ''
|
|
28
|
+
try {
|
|
29
|
+
const r = await fetch('/api/check/run', { method: 'POST' })
|
|
30
|
+
const rd = r.body!.getReader()
|
|
31
|
+
const dec = new TextDecoder()
|
|
32
|
+
for (;;) {
|
|
33
|
+
const { done, value } = await rd.read()
|
|
34
|
+
if (done) break
|
|
35
|
+
text += dec.decode(value, { stream: true })
|
|
36
|
+
runInAction(() => { this.statusMd = text })
|
|
37
|
+
}
|
|
38
|
+
runInAction(() => { this.note = 'done — STATUS.md refreshed on disk' })
|
|
39
|
+
void this.load()
|
|
40
|
+
void this.st.cols.poll()
|
|
41
|
+
} catch (e) {
|
|
42
|
+
runInAction(() => {
|
|
43
|
+
this.statusMd = `${text}\nFAIL stream broke: ${(e as Error).message}`
|
|
44
|
+
this.note = 'stream broke'
|
|
45
|
+
})
|
|
46
|
+
}
|
|
47
|
+
runInAction(() => { this.running = false })
|
|
48
|
+
}
|
|
49
|
+
}
|