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.
Files changed (66) hide show
  1. package/CHANGELOG.md +200 -0
  2. package/README.md +17 -3
  3. package/package.json +6 -3
  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/contract-types.ts +5 -2
  23. package/src/contracts/contracts-engine.ts +13 -3
  24. package/src/contracts/manifests/discord.contract.ts +1 -1
  25. package/src/contracts/manifests/identity.contract.ts +1 -1
  26. package/src/contracts/manifests/itch.contract.ts +19 -2
  27. package/src/contracts/run-contracts.ts +8 -3
  28. package/src/core/canonical-scripts.ts +46 -0
  29. package/src/core/check-suite.ts +6 -3
  30. package/src/core/locked-write.ts +39 -0
  31. package/src/core/motherhome.ts +87 -0
  32. package/src/core/run-check.ts +28 -15
  33. package/src/core/run-ci.ts +4 -0
  34. package/src/core/write-status.ts +7 -26
  35. package/src/init-repo.ts +27 -11
  36. package/src/reflect/prompt-corpus.ts +305 -0
  37. package/src/repo-config.ts +30 -38
  38. package/src/rules/SK001-package-json-shape.ts +156 -0
  39. package/src/rules/SK002-contracts-fulfillness.ts +19 -0
  40. package/src/rules/SK003-claude-md-shape.ts +64 -0
  41. package/src/rules/SK004-gitignore-policy.ts +105 -0
  42. package/src/rules/SK005-rvlib-versions.ts +99 -0
  43. package/src/rules/SK006-npm-publish-safety.ts +42 -0
  44. package/src/rules/SK007-github-visibility.ts +63 -0
  45. package/src/rules/SK008-reviewer-agent.ts +98 -0
  46. package/src/rules/rule-registry.ts +34 -0
  47. package/src/rules/rule.ts +62 -0
  48. package/src/rules/run-fix.ts +57 -0
  49. package/src/serve/hub-app/col-card.tsx +192 -0
  50. package/src/serve/hub-app/cols-svc.ts +136 -0
  51. package/src/serve/hub-app/geometry-view.tsx +155 -0
  52. package/src/serve/hub-app/health-svc.ts +49 -0
  53. package/src/serve/hub-app/hub-app.tsx +236 -215
  54. package/src/serve/hub-app/hub-st.ts +169 -0
  55. package/src/serve/hub-app/hub-types.ts +72 -0
  56. package/src/serve/hub-app/hub.css +286 -59
  57. package/src/serve/hub-app/index.html +1 -1
  58. package/src/serve/hub-app/md.ts +24 -0
  59. package/src/serve/hub-app/media-svc.ts +429 -0
  60. package/src/serve/hub-app/media-view.tsx +299 -0
  61. package/src/serve/run-serve.ts +23 -9
  62. package/src/serve/serve-hub.ts +62 -47
  63. package/src/shipkit.ts +9 -5
  64. package/src/assets/server/studio-page.ts +0 -687
  65. package/src/core/package-json-check.ts +0 -88
  66. 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
+ }