rvlib-shipkit 1.1.0 → 1.2.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 (137) hide show
  1. package/CHANGELOG.md +67 -0
  2. package/package.json +3 -1
  3. package/src/check.ts +24 -0
  4. package/src/checks/SK022-machine-runs-the-pin.check.ts +43 -0
  5. package/src/checks/SK023-stores-under-cap.check.ts +49 -0
  6. package/src/checks/SK024-identity-colors.check.ts +49 -0
  7. package/src/cli/cmd_Check.ts +26 -1
  8. package/src/cli/cmd_Ci.ts +18 -5
  9. package/src/cli/cmd_Home.ts +170 -0
  10. package/src/cli.ts +22 -2
  11. package/src/comfy/comfy-panel.ts +1 -16
  12. package/src/core/attach.ts +14 -9
  13. package/src/core/check-file-names.ts +8 -0
  14. package/src/core/check-runner.ts +40 -18
  15. package/src/core/discover-checks.ts +36 -18
  16. package/src/core/identity-color.ts +203 -0
  17. package/src/core/identity.ts +91 -0
  18. package/src/core/job-follow.ts +102 -0
  19. package/src/core/job-run.ts +27 -0
  20. package/src/core/jobs.ts +87 -27
  21. package/src/core/legacy-shapes.ts +1 -14
  22. package/src/core/machine-db.ts +83 -0
  23. package/src/core/one-lib.ts +85 -0
  24. package/src/core/port-holders.ts +43 -0
  25. package/src/core/retention.ts +213 -0
  26. package/src/core/run-check.ts +6 -2
  27. package/src/core/run-ci.ts +3 -0
  28. package/src/core/slots.ts +5 -0
  29. package/src/core/statement-end.ts +17 -0
  30. package/src/home/attach.ts +95 -0
  31. package/src/home/autostart.ts +108 -0
  32. package/src/home/global-units.ts +86 -0
  33. package/src/home/gui-app/ShipkitGui.swift +159 -0
  34. package/src/home/gui-app.ts +99 -0
  35. package/src/home/handoff.ts +51 -0
  36. package/src/home/home-launchd.ts +104 -0
  37. package/src/home/home-paths.ts +88 -0
  38. package/src/home/home-routes.ts +208 -0
  39. package/src/home/home-start.ts +105 -0
  40. package/src/home/home-state.ts +43 -0
  41. package/src/home/home-words.ts +12 -0
  42. package/src/home/hubs.ts +286 -0
  43. package/src/home/lib-watch.ts +139 -0
  44. package/src/home/machine-stores.ts +115 -0
  45. package/src/home/open-app.ts +16 -0
  46. package/src/home/pm-config.ts +37 -0
  47. package/src/home/pm.ts +141 -0
  48. package/src/home/resources.ts +338 -0
  49. package/src/home/run-daemon.ts +137 -0
  50. package/src/home/run-pm.ts +149 -0
  51. package/src/home/run-unit-action.ts +25 -0
  52. package/src/home/run-web.ts +37 -0
  53. package/src/home/serve-daemon.ts +31 -0
  54. package/src/home/serve-web.ts +172 -0
  55. package/src/home/supervise.ts +346 -0
  56. package/src/home/via-hub.ts +142 -0
  57. package/src/home/widgets.ts +102 -0
  58. package/src/ledger/check-stats.ts +19 -45
  59. package/src/ledger/db.ts +69 -17
  60. package/src/ledger/gate-checks.ts +5 -0
  61. package/src/ledger/git-scan.ts +31 -7
  62. package/src/ledger/ledger.ts +40 -14
  63. package/src/ledger/run-log.ts +58 -23
  64. package/src/ledger/store.ts +228 -103
  65. package/src/ledger/world-cache.ts +15 -13
  66. package/src/plan/cadence.ts +237 -0
  67. package/src/plan/planner.ts +303 -0
  68. package/src/plan/repo-source.ts +100 -0
  69. package/src/problems/problem-line.ts +1 -1
  70. package/src/repo-config.ts +51 -2
  71. package/src/serve/hub-app/check-view.tsx +9 -9
  72. package/src/serve/hub-app/check-words.ts +1 -1
  73. package/src/serve/hub-app/checks-svc.ts +12 -8
  74. package/src/serve/hub-app/col-card.tsx +4 -4
  75. package/src/serve/hub-app/cols-svc.ts +92 -13
  76. package/src/serve/hub-app/comfy-svc.ts +12 -6
  77. package/src/serve/hub-app/comfy-view.tsx +3 -3
  78. package/src/serve/hub-app/floating.tsx +49 -0
  79. package/src/serve/hub-app/geometry-view.tsx +4 -4
  80. package/src/serve/hub-app/hash-route.ts +1 -1
  81. package/src/serve/hub-app/header-bar.tsx +44 -12
  82. package/src/serve/hub-app/health-svc.ts +3 -3
  83. package/src/serve/hub-app/help.tsx +10 -7
  84. package/src/serve/hub-app/home-st.ts +241 -0
  85. package/src/serve/hub-app/home-strip.css +70 -0
  86. package/src/serve/hub-app/home-strip.tsx +245 -0
  87. package/src/serve/hub-app/home.html +17 -0
  88. package/src/serve/hub-app/hub-app.tsx +201 -93
  89. package/src/serve/hub-app/hub-types.ts +0 -3
  90. package/src/serve/hub-app/hub.css +43 -23
  91. package/src/serve/hub-app/icons.tsx +14 -0
  92. package/src/serve/hub-app/index.html +3 -1
  93. package/src/serve/hub-app/keys.ts +25 -0
  94. package/src/serve/hub-app/layout-st.ts +55 -0
  95. package/src/serve/hub-app/live-svc.ts +12 -9
  96. package/src/serve/hub-app/machine-st.ts +270 -0
  97. package/src/serve/hub-app/media-svc.ts +63 -44
  98. package/src/serve/hub-app/media-view.tsx +20 -20
  99. package/src/serve/hub-app/next-run.ts +24 -30
  100. package/src/serve/hub-app/palette-st.ts +88 -0
  101. package/src/serve/hub-app/planned-st.ts +171 -0
  102. package/src/serve/hub-app/planned-view.tsx +109 -0
  103. package/src/serve/hub-app/planned.css +39 -0
  104. package/src/serve/hub-app/processes-svc.ts +63 -0
  105. package/src/serve/hub-app/project-list.css +97 -0
  106. package/src/serve/hub-app/project-list.tsx +167 -0
  107. package/src/serve/hub-app/projects-svc.ts +139 -17
  108. package/src/serve/hub-app/prose-hits-st.ts +3 -3
  109. package/src/serve/hub-app/prose-hits-view.tsx +6 -6
  110. package/src/serve/hub-app/{hub-st.ts → repo-st.ts} +155 -84
  111. package/src/serve/hub-app/runs-tabs.ts +1 -1
  112. package/src/serve/hub-app/screen-cache.ts +74 -0
  113. package/src/serve/hub-app/test-view.tsx +4 -4
  114. package/src/serve/hub-app/tests-svc.ts +3 -3
  115. package/src/serve/hub-app/tests-view.tsx +10 -10
  116. package/src/serve/hub-app/timeline-chart.tsx +24 -12
  117. package/src/serve/hub-app/timeline-svc.ts +5 -4
  118. package/src/serve/hub-app/timeline-view.tsx +13 -13
  119. package/src/serve/hub-app/tree-row.tsx +2 -2
  120. package/src/serve/hub-app/tree-svc.ts +2 -2
  121. package/src/serve/hub-app/wikis-view.tsx +10 -6
  122. package/src/serve/hub-runtime.ts +542 -0
  123. package/src/serve/project-gutter-route.ts +21 -0
  124. package/src/serve/project-gutter.ts +3 -2
  125. package/src/serve/repo-processes.ts +89 -0
  126. package/src/serve/run-serve.ts +110 -402
  127. package/src/serve/serve-hub.ts +142 -44
  128. package/src/serve/stale-restart.ts +5 -0
  129. package/src/serve/{gate-trigger.ts → tracked-watch.ts} +4 -64
  130. package/src/serve/warm-cell.ts +95 -0
  131. package/src/shipkit-files.ts +114 -0
  132. package/src/shipkit.ts +2 -1
  133. package/src/suite/kept-timings.ts +1 -1
  134. package/src/wiki/graph-positions.ts +1 -1
  135. package/src/serve/health-schedule.ts +0 -102
  136. package/src/serve/hub-app/project-gutter.css +0 -65
  137. package/src/serve/hub-app/project-gutter.tsx +0 -128
@@ -4,27 +4,15 @@
4
4
  // structure is read-only (kit.ts is code-reviewed); this edits WORKSPACE
5
5
  // state: picks, alts, prompt overlays, meta sidecars, geometry rects.
6
6
  import { makeAutoObservable, runInAction } from 'mobx'
7
- import type { HubSt } from './hub-st.ts'
7
+ import type { RepoSt } from './repo-st.ts'
8
8
  import type { AssetDetail, CatRow, Rect, SideAsset } from './hub-types.ts'
9
9
 
10
- const MEDIA = '/media'
11
10
  // thumbs change in place when a pick changes → ~8s cache buckets; roll
12
11
  // files are immutable (one name = one file forever), so their URLs carry
13
12
  // no bust — an <audio> src that never changes never restarts playback
14
13
  const V = (): number => Date.now() >> 13
15
- export const rollUrl = (id: string, name: string): string => `${MEDIA}/img?id=${encodeURIComponent(id)}&name=${encodeURIComponent(name)}`
16
- export const thumbUrl = (id: string): string => `${MEDIA}/api/thumb?id=${encodeURIComponent(id)}&t=${V()}`
17
14
  export const isAud = (n: string): boolean => ['mp3', 'wav', 'ogg', 'm4a'].includes(String(n ?? '').split('.').pop() ?? '')
18
15
 
19
- const j = async (u: string): Promise<any> => {
20
- const r = await fetch(MEDIA + u)
21
- if (!r.ok) throw new Error(`${u}: ${r.status}`)
22
- return r.json()
23
- }
24
- const post = async (u: string, b?: unknown): Promise<any> => {
25
- const r = await fetch(MEDIA + u, { method: 'POST', body: JSON.stringify(b ?? {}) })
26
- return r.json()
27
- }
28
16
 
29
17
  export class MediaSvc {
30
18
  assets: SideAsset[] = []
@@ -80,7 +68,7 @@ export class MediaSvc {
80
68
  /** stage overlay root (the image box) — registered by AssetStage, DOM only */
81
69
  overlayHost: HTMLElement | null = null
82
70
 
83
- constructor(private st: HubSt) {
71
+ constructor(private st: RepoSt) {
84
72
  makeAutoObservable<this, 'st' | 'hbTimer' | 'rectTimer' | 'promptTimer' | 'savedPrompt' | 'overlayHost'>(
85
73
  this, { st: false, hbTimer: false, rectTimer: false, promptTimer: false, savedPrompt: false, overlayHost: false })
86
74
  if (typeof localStorage !== 'undefined') {
@@ -102,15 +90,41 @@ export class MediaSvc {
102
90
  }
103
91
 
104
92
  start(): void {
93
+ if (this.hbTimer != null || this.dead != null) return
105
94
  void this.heartbeat()
106
95
  this.hbTimer = setInterval(() => void this.heartbeat(), 1500)
107
96
  }
108
- dispose(): void {
97
+ /** polling only: a pending prompt or rect save still lands */
98
+ stop(): void {
109
99
  if (this.hbTimer != null) clearInterval(this.hbTimer)
100
+ this.hbTimer = null
101
+ }
102
+ dispose(): void {
103
+ this.stop()
110
104
  if (this.rectTimer != null) clearTimeout(this.rectTimer)
111
105
  if (this.promptTimer != null) clearTimeout(this.promptTimer)
112
106
  }
113
107
 
108
+ /** the media API, under this repo's base */
109
+ private get api(): string {
110
+ return this.st.url('/media')
111
+ }
112
+ rollUrl(id: string, name: string): string {
113
+ return `${this.api}/img?id=${encodeURIComponent(id)}&name=${encodeURIComponent(name)}`
114
+ }
115
+ thumbUrl(id: string): string {
116
+ return `${this.api}/api/thumb?id=${encodeURIComponent(id)}&t=${V()}`
117
+ }
118
+ private async j(u: string): Promise<any> {
119
+ const r = await fetch(this.api + u)
120
+ if (!r.ok) throw new Error(`${u}: ${r.status}`)
121
+ return r.json()
122
+ }
123
+ private async post(u: string, b?: unknown): Promise<any> {
124
+ const r = await fetch(this.api + u, { method: 'POST', body: JSON.stringify(b ?? {}) })
125
+ return r.json()
126
+ }
127
+
114
128
  get curId(): string | null {
115
129
  return this.st.sel?.t === 'asset' ? this.st.sel.id : null
116
130
  }
@@ -145,7 +159,7 @@ export class MediaSvc {
145
159
  if (this.dead != null) return
146
160
  let s: any
147
161
  try {
148
- const r = await fetch(MEDIA + '/api/state')
162
+ const r = await fetch(this.api + '/api/state')
149
163
  if (r.status === 404) {
150
164
  // the mount failed at boot — it will not come back without a
151
165
  // restart; say so once, loudly, and stop wasting the wire
@@ -174,6 +188,7 @@ export class MediaSvc {
174
188
  })
175
189
  if (this.curId != null) await this.refreshRolls()
176
190
  if (this.catMode != null) await this.fillCat()
191
+ this.st.cols.mediaListed()
177
192
  this.st.afterFirstBeat()
178
193
  }
179
194
 
@@ -184,7 +199,7 @@ export class MediaSvc {
184
199
  this.statusLine = ''
185
200
  const a = this.assets.find((x) => x.id === id)
186
201
  if (a != null) this.openCats.add(a.cat)
187
- const r = (await j(`/api/asset?id=${encodeURIComponent(id)}`)) as AssetDetail
202
+ const r = (await this.j(`/api/asset?id=${encodeURIComponent(id)}`)) as AssetDetail
188
203
  if (this.curId !== id) return // selection changed mid-fetch — never clobber
189
204
  runInAction(() => {
190
205
  this.detail = r
@@ -206,7 +221,7 @@ export class MediaSvc {
206
221
  async refreshRolls(): Promise<void> {
207
222
  const id = this.curId
208
223
  if (id == null) return
209
- const r = (await j(`/api/asset?id=${encodeURIComponent(id)}`)) as AssetDetail
224
+ const r = (await this.j(`/api/asset?id=${encodeURIComponent(id)}`)) as AssetDetail
210
225
  if (this.curId !== id) return
211
226
  runInAction(() => {
212
227
  if (this.detail == null) this.detail = r
@@ -223,23 +238,23 @@ export class MediaSvc {
223
238
 
224
239
  async generate(): Promise<void> {
225
240
  if (this.curId == null) return
226
- await post('/api/generate', { id: this.curId, prompt: this.prompt, count: +this.n })
241
+ await this.post('/api/generate', { id: this.curId, prompt: this.prompt, count: +this.n })
227
242
  void this.heartbeat()
228
243
  }
229
244
  async genCat(cat: string, count: number): Promise<void> {
230
- const r = await post('/api/generate-cat', { cat, count })
245
+ const r = await this.post('/api/generate-cat', { cat, count })
231
246
  if (r?.error != null) runInAction(() => { this.statusLine = r.error })
232
247
  void this.heartbeat()
233
248
  }
234
249
  clearQueue(): void {
235
- void post('/api/queue/clear')
250
+ void this.post('/api/queue/clear')
236
251
  }
237
252
  stopCurrent(): void {
238
- void post('/api/interrupt')
253
+ void this.post('/api/interrupt')
239
254
  }
240
255
 
241
256
  async pick(name: string): Promise<void> {
242
- const res = await post('/api/pick', { id: this.curId, name })
257
+ const res = await this.post('/api/pick', { id: this.curId, name })
243
258
  runInAction(() => {
244
259
  this.statusLine = res.error != null ? `ship FAILED: ${res.error}` : res.shipped != null ? `shipped → ${res.shipped}` : 'picked (pick.json)'
245
260
  this.active = name
@@ -247,11 +262,11 @@ export class MediaSvc {
247
262
  await this.refreshRolls()
248
263
  }
249
264
  async alt(name: string): Promise<void> {
250
- await post('/api/alt', { id: this.curId, name })
265
+ await this.post('/api/alt', { id: this.curId, name })
251
266
  await this.refreshRolls()
252
267
  }
253
268
  async discard(name: string): Promise<void> {
254
- await post('/api/discard', { id: this.curId, name })
269
+ await this.post('/api/discard', { id: this.curId, name })
255
270
  runInAction(() => {
256
271
  if (this.active === name) this.active = null
257
272
  // the ✕ sits on the hovered tile and its unmount fires no
@@ -267,7 +282,7 @@ export class MediaSvc {
267
282
  const fd = new FormData()
268
283
  fd.append('id', this.curId)
269
284
  fd.append('file', f)
270
- const r = await fetch(`${MEDIA}/api/upload`, { method: 'POST', body: fd }).then((x) => x.json()).catch((e: Error) => ({ error: String(e) }))
285
+ const r = await fetch(`${this.api}/api/upload`, { method: 'POST', body: fd }).then((x) => x.json()).catch((e: Error) => ({ error: String(e) }))
271
286
  if (r.error != null) runInAction(() => { this.statusLine = `upload FAIL: ${r.error}` })
272
287
  }
273
288
  await this.refreshRolls()
@@ -290,7 +305,7 @@ export class MediaSvc {
290
305
  const text = this.prompt
291
306
  if (text === this.savedPrompt) return // unchanged — not a save
292
307
  void (async () => {
293
- const r = await post('/api/prompt', { id, prompt: text })
308
+ const r = await this.post('/api/prompt', { id, prompt: text })
294
309
  runInAction(() => {
295
310
  if (this.curId !== id) return // selection moved while the POST flew — a late response must not corrupt the new asset's baseline
296
311
  if (r.error != null) {
@@ -330,7 +345,7 @@ export class MediaSvc {
330
345
  }
331
346
  const id = this.curId
332
347
  const name = this.active
333
- const r = await j(`/api/rect?id=${encodeURIComponent(id)}&name=${encodeURIComponent(name)}`).catch(() => null)
348
+ const r = await this.j(`/api/rect?id=${encodeURIComponent(id)}&name=${encodeURIComponent(name)}`).catch(() => null)
334
349
  if (this.curId !== id || this.active !== name) return // roll switched mid-fetch
335
350
  runInAction(() => {
336
351
  this.rect = r?.rect ?? null
@@ -349,7 +364,7 @@ export class MediaSvc {
349
364
  if (this.rectTimer != null) clearTimeout(this.rectTimer)
350
365
  this.rectTimer = setTimeout(() => {
351
366
  void (async () => {
352
- const r = await post('/api/rect', { id, name, rect })
367
+ const r = await this.post('/api/rect', { id, name, rect })
353
368
  runInAction(() => {
354
369
  if (this.curId !== id || this.active !== name) return // selection moved on — never adopt a stale rect
355
370
  if (r.rect != null) this.rect = r.rect
@@ -360,7 +375,7 @@ export class MediaSvc {
360
375
  }
361
376
  async legsDefault(): Promise<void> {
362
377
  if (this.curId == null || this.active == null) return
363
- const r = await post('/api/legs-default', { id: this.curId, name: this.active })
378
+ const r = await this.post('/api/legs-default', { id: this.curId, name: this.active })
364
379
  if (r.rect != null) runInAction(() => { this.rect = r.rect })
365
380
  }
366
381
  removeLegs(): void {
@@ -374,7 +389,7 @@ export class MediaSvc {
374
389
  async fillCat(): Promise<void> {
375
390
  const mode = this.catMode
376
391
  if (mode == null) return
377
- const r = await j(`/api/catrects?cat=${encodeURIComponent(mode)}`)
392
+ const r = await this.j(`/api/catrects?cat=${encodeURIComponent(mode)}`)
378
393
  if (this.catMode !== mode) return // navigated away mid-fetch
379
394
  runInAction(() => { this.catRows = r.rows ?? [] })
380
395
  }
@@ -384,10 +399,10 @@ export class MediaSvc {
384
399
  const nv = Math.max(0.1, v || row.rect.size)
385
400
  if (nv === row.rect.size) return // blur without a change is not a save
386
401
  row.rect.size = nv
387
- await post('/api/rect', { id, name, rect: row.rect })
402
+ await this.post('/api/rect', { id, name, rect: row.rect })
388
403
  }
389
404
  async toggleFlip(id: string, name: string): Promise<void> {
390
- await post('/api/flip', { id, name })
405
+ await this.post('/api/flip', { id, name })
391
406
  await this.fillCat()
392
407
  }
393
408
  async applyAllSize(): Promise<void> {
@@ -397,7 +412,7 @@ export class MediaSvc {
397
412
  // one action for all mutations (post-await writes would land outside
398
413
  // it — mobx strict mode), then the POSTs
399
414
  runInAction(() => { for (const row of rows) row.rect!.size = v })
400
- for (const row of rows) await post('/api/rect', { id: row.id, name: row.name, rect: row.rect })
415
+ for (const row of rows) await this.post('/api/rect', { id: row.id, name: row.name, rect: row.rect })
401
416
  runInAction(() => { this.allMsg = 'applied' })
402
417
  setTimeout(() => runInAction(() => { this.allMsg = '' }), 1200)
403
418
  await this.fillCat()
@@ -433,12 +448,16 @@ export class MediaSvc {
433
448
  if (kind != null && typeof localStorage !== 'undefined') localStorage.setItem(`hub:tools:${kind}`, [...this.tools].join(','))
434
449
  this.syncHash()
435
450
  }
436
- /** the hash carries the working state: #<assetId>?tools=a,b — shareable, restored on load */
437
- private syncHash(): void {
438
- if (typeof location === 'undefined' || this.curId == null) return
451
+ /** the hash carries the working state: #<assetId>?tools=a,b — shareable,
452
+ * restored on load. null while no asset is open */
453
+ get hash(): string | null {
454
+ if (this.curId == null) return null
439
455
  const t = [...this.tools].join(',')
440
- const h = `${this.curId}${t === '' ? '' : `?tools=${t}`}`
441
- if (location.hash.slice(1) !== h) history.replaceState(null, '', `#${h}`)
456
+ return `${this.curId}${t === '' ? '' : `?tools=${t}`}`
457
+ }
458
+ private syncHash(): void {
459
+ const h = this.hash
460
+ if (h != null) this.st.replaceHash(h)
442
461
  }
443
462
  /** tabs = kit-declared presets first, then autos for tools no tab covers.
444
463
  * a tab swaps the enabled tool SET — the canvas underneath never moves */
@@ -493,12 +512,12 @@ export class MediaSvc {
493
512
  }
494
513
  async loadMeta(): Promise<void> {
495
514
  this.jsonMsg = ''
496
- const r = this.curId != null && this.active != null ? await post('/api/meta', { id: this.curId, name: this.active }) : { text: '' }
515
+ const r = this.curId != null && this.active != null ? await this.post('/api/meta', { id: this.curId, name: this.active }) : { text: '' }
497
516
  runInAction(() => { this.jsonText = r.text ?? '' })
498
517
  }
499
518
  async saveMeta(): Promise<void> {
500
519
  if (this.curId == null || this.active == null) return
501
- const r = await post('/api/meta', { id: this.curId, name: this.active, text: this.jsonText })
520
+ const r = await this.post('/api/meta', { id: this.curId, name: this.active, text: this.jsonText })
502
521
  runInAction(() => { this.jsonMsg = r.error != null ? `FAIL: ${r.error}` : 'saved' })
503
522
  }
504
523
  /** frozen per mount: a roll/asset switch remounts with a fresh api */
@@ -513,14 +532,14 @@ export class MediaSvc {
513
532
  /** the PICKED roll's name (null before any pick) — editors that
514
533
  * target shipped art can warn when the shown roll differs */
515
534
  picked: this.detail?.rolls.find((x) => x.picked)?.name ?? null,
516
- imgUrl: rollUrl(id, name),
535
+ imgUrl: this.rollUrl(id, name),
517
536
  getMeta: async () => {
518
- const r = await post('/api/meta', { id, name })
537
+ const r = await this.post('/api/meta', { id, name })
519
538
  if (r.error != null) throw new Error(r.error)
520
539
  return r.text ? JSON.parse(r.text) : null
521
540
  },
522
541
  saveMeta: async (data: unknown) => {
523
- const r = await post('/api/meta', { id, name, text: JSON.stringify(data) })
542
+ const r = await this.post('/api/meta', { id, name, text: JSON.stringify(data) })
524
543
  if (r.error != null) throw new Error(r.error)
525
544
  },
526
545
  // an element covering the DISPLAYED roll image, so an editor can
@@ -5,8 +5,8 @@
5
5
  import { runInAction } from 'mobx'
6
6
  import { observer } from 'mobx-react-lite'
7
7
  import { useEffect, useRef, useState, type DragEvent, type ReactNode } from 'react'
8
- import type { HubSt } from './hub-st.ts'
9
- import { isAud, rollUrl, thumbUrl } from './media-svc.ts'
8
+ import type { RepoSt } from './repo-st.ts'
9
+ import { isAud, type MediaSvc } from './media-svc.ts'
10
10
  import { GeometryLayer, Rectbar, measureBox, type Box } from './geometry-view.tsx'
11
11
  import type { CatRow, CustomPanelRef } from './hub-types.ts'
12
12
 
@@ -14,7 +14,7 @@ const TEX = 1024
14
14
  const MIX_UNIT = 24
15
15
  const MIX_GROUND = 44
16
16
 
17
- export const MediaTopbar = observer(({ app }: { app: HubSt }) => {
17
+ export const MediaTopbar = observer(({ app }: { app: RepoSt }) => {
18
18
  const m = app.media
19
19
  const fileRef = useRef<HTMLInputElement>(null)
20
20
  const taRef = useRef<HTMLTextAreaElement>(null)
@@ -71,7 +71,7 @@ export const MediaTopbar = observer(({ app }: { app: HubSt }) => {
71
71
  )
72
72
  })
73
73
 
74
- export const InfoChips = observer(({ app }: { app: HubSt }) => {
74
+ export const InfoChips = observer(({ app }: { app: RepoSt }) => {
75
75
  const info = app.media.detail?.info
76
76
  if (info == null) return null
77
77
  const chips: { k: string; v: ReactNode; warn?: boolean }[] = [{ k: 'backend', v: info.backend }]
@@ -91,7 +91,7 @@ export const InfoChips = observer(({ app }: { app: HubSt }) => {
91
91
  )
92
92
  })
93
93
 
94
- export const AssetStage = observer(({ app }: { app: HubSt }) => {
94
+ export const AssetStage = observer(({ app }: { app: RepoSt }) => {
95
95
  const m = app.media
96
96
  const stageRef = useRef<HTMLDivElement>(null)
97
97
  const imgRef = useRef<HTMLImageElement>(null)
@@ -116,9 +116,9 @@ export const AssetStage = observer(({ app }: { app: HubSt }) => {
116
116
  else if (isAud(shown)) content = (
117
117
  // key = the roll identity: heartbeats re-render but never recreate
118
118
  // the element, so playback survives; the src carries no cache-bust
119
- <div className="stageaud" key={`${m.curId}/${shown}`}>🔊<audio controls src={rollUrl(m.curId!, shown)} /></div>
119
+ <div className="stageaud" key={`${m.curId}/${shown}`}>🔊<audio controls src={m.rollUrl(m.curId!, shown)} /></div>
120
120
  )
121
- else content = <img className="roll" src={rollUrl(m.curId!, shown)} ref={imgRef} onLoad={remeasure} />
121
+ else content = <img className="roll" src={m.rollUrl(m.curId!, shown)} ref={imgRef} onLoad={remeasure} />
122
122
  const imgShown = shown != null && !isAud(shown)
123
123
  const measured = imgShown && box != null
124
124
  return (
@@ -137,7 +137,7 @@ export const AssetStage = observer(({ app }: { app: HubSt }) => {
137
137
  )
138
138
  })
139
139
 
140
- export const ToolsBar = observer(({ app }: { app: HubSt }) => {
140
+ export const ToolsBar = observer(({ app }: { app: RepoSt }) => {
141
141
  const m = app.media
142
142
  const d = m.detail
143
143
  if (d == null || m.curId == null) return null
@@ -177,7 +177,7 @@ export const ToolsBar = observer(({ app }: { app: HubSt }) => {
177
177
 
178
178
  // variants live in a THIN resizable side bar — the stage keeps the room.
179
179
  // closed = a slim rail with the count; the drag handle resizes; drop = upload
180
- export const VariantsBar = observer(({ app }: { app: HubSt }) => {
180
+ export const VariantsBar = observer(({ app }: { app: RepoSt }) => {
181
181
  const m = app.media
182
182
  const rolls = m.detail?.rolls ?? []
183
183
  const genHere = m.curId != null && m.pending.includes(m.curId)
@@ -213,7 +213,7 @@ export const VariantsBar = observer(({ app }: { app: HubSt }) => {
213
213
  onMouseEnter={() => { if (!isAud(x.name)) runInAction(() => { m.hover = x.name }) }}
214
214
  onMouseLeave={() => runInAction(() => { m.hover = null })}
215
215
  onClick={() => m.setActive(x.name)}>
216
- {isAud(x.name) ? <>🔊<audio controls src={rollUrl(m.curId!, x.name)} /></> : <img src={rollUrl(m.curId!, x.name)} />}
216
+ {isAud(x.name) ? <>🔊<audio controls src={m.rollUrl(m.curId!, x.name)} /></> : <img src={m.rollUrl(m.curId!, x.name)} />}
217
217
  {x.picked ? <span className="st">★</span> : null}
218
218
  <div className="btns">
219
219
  <button className="b-pick" onClick={(e) => { e.stopPropagation(); void m.pick(x.name) }}>pick</button>
@@ -228,7 +228,7 @@ export const VariantsBar = observer(({ app }: { app: HubSt }) => {
228
228
  })
229
229
 
230
230
  // ── category grid + size-true lineup ─────────────────────────────────────
231
- const MixLineup = observer(({ rows }: { rows: CatRow[] }) => {
231
+ const MixLineup = observer(({ m, rows }: { m: MediaSvc; rows: CatRow[] }) => {
232
232
  const items = rows.filter((r) => !r.icon && r.rect != null).sort((a, b) => a.rect!.size - b.rect!.size || a.label.localeCompare(b.label))
233
233
  const boxH = 420
234
234
  const baseY = boxH - MIX_GROUND
@@ -241,7 +241,7 @@ const MixLineup = observer(({ rows }: { rows: CatRow[] }) => {
241
241
  cx += slot / 2
242
242
  const fx = rect.flip === true ? TEX - rect.x - rect.w : rect.x
243
243
  sprites.push(
244
- <img key={it.id} className="mixsprite" src={thumbUrl(it.id)} style={{
244
+ <img key={it.id} className="mixsprite" src={m.thumbUrl(it.id)} style={{
245
245
  width: TEX * ts, height: TEX * ts,
246
246
  left: cx - (fx + rect.w / 2) * ts, top: baseY - (rect.y + rect.h / 2) * ts,
247
247
  transform: rect.flip === true ? 'scaleX(-1)' : undefined, zIndex: Math.round(cx),
@@ -262,7 +262,7 @@ const MixLineup = observer(({ rows }: { rows: CatRow[] }) => {
262
262
  )
263
263
  })
264
264
 
265
- export const CatGrid = observer(({ app }: { app: HubSt }) => {
265
+ export const CatGrid = observer(({ app }: { app: RepoSt }) => {
266
266
  const m = app.media
267
267
  const cat = m.catMode
268
268
  if (cat == null) return null
@@ -288,9 +288,9 @@ export const CatGrid = observer(({ app }: { app: HubSt }) => {
288
288
  // inner controls stopPropagation
289
289
  <div className="card" key={row.id} onClick={() => app.selectAsset(row.id)}>
290
290
  {isAud(row.name)
291
- ? <div className="aud">🔊<audio controls src={rollUrl(row.id, row.name)} onClick={(e) => e.stopPropagation()} />{m.pending.includes(row.id) ? <div className="spin" /> : null}</div>
291
+ ? <div className="aud">🔊<audio controls src={m.rollUrl(row.id, row.name)} onClick={(e) => e.stopPropagation()} />{m.pending.includes(row.id) ? <div className="spin" /> : null}</div>
292
292
  : <div className="th">
293
- <img src={thumbUrl(row.id)} style={fl ? { transform: 'scaleX(-1)' } : undefined} />
293
+ <img src={m.thumbUrl(row.id)} style={fl ? { transform: 'scaleX(-1)' } : undefined} />
294
294
  {row.rect != null ? <div className="ovmini" style={{
295
295
  left: pc(fl ? TEX - row.rect.x - row.rect.w : row.rect.x), top: pc(row.rect.y),
296
296
  width: pc(row.rect.w), height: pc(row.rect.h),
@@ -314,13 +314,13 @@ export const CatGrid = observer(({ app }: { app: HubSt }) => {
314
314
  )
315
315
  })}
316
316
  </div>
317
- <MixLineup rows={m.catRows} />
317
+ <MixLineup m={m} rows={m.catRows} />
318
318
  </div>
319
319
  )
320
320
  })
321
321
 
322
322
  // ── dock: the active tools' panels, side by side under the stage ─────────
323
- const PanelHost = observer(({ app, c }: { app: HubSt; c: CustomPanelRef }) => {
323
+ const PanelHost = observer(({ app, c }: { app: RepoSt; c: CustomPanelRef }) => {
324
324
  const m = app.media
325
325
  const ref = useRef<HTMLDivElement>(null)
326
326
  const key = `${m.curId}|${m.active}|${c.id}`
@@ -337,7 +337,7 @@ const PanelHost = observer(({ app, c }: { app: HubSt; c: CustomPanelRef }) => {
337
337
  void (async () => {
338
338
  try {
339
339
  // repo-defined editor module, served by the media API
340
- mod = (await import(`/media/${c.url}`)) as Record<string, unknown>
340
+ mod = (await import(app.url(`/media/${c.url}`))) as Record<string, unknown>
341
341
  if (typeof mod.mount !== 'function') throw new Error(`${c.url} must export mount(el, api)`)
342
342
  if (!dead) await (mod.mount as (el: HTMLElement, api: unknown) => unknown)(host, api)
343
343
  } catch (e) {
@@ -357,7 +357,7 @@ const PanelHost = observer(({ app, c }: { app: HubSt; c: CustomPanelRef }) => {
357
357
  // floating panels sit ON the canvas (drag by the title bar), so toggling
358
358
  // them never reflows the stage — canvas size/position survive tab switches
359
359
  const FloatPanel = observer(({ app, id, title, dx, dy, anchor, children }: {
360
- app: HubSt; id: string; title: string; dx: number; dy: number; anchor?: 'tl' | 'bl'; children: ReactNode
360
+ app: RepoSt; id: string; title: string; dx: number; dy: number; anchor?: 'tl' | 'bl'; children: ReactNode
361
361
  }) => {
362
362
  const m = app.media
363
363
  const ref = useRef<HTMLDivElement>(null)
@@ -408,7 +408,7 @@ const FloatPanel = observer(({ app, id, title, dx, dy, anchor, children }: {
408
408
  )
409
409
  })
410
410
 
411
- export const FloatPanels = observer(({ app }: { app: HubSt }) => {
411
+ export const FloatPanels = observer(({ app }: { app: RepoSt }) => {
412
412
  const m = app.media
413
413
  const customs = (m.detail?.asset.custom ?? []).filter((c) => m.tools.has(`c:${c.id}`))
414
414
  const showJson = m.tools.has('json')
@@ -1,8 +1,9 @@
1
- // WHEN A CHECK RUNS NEXT, as a true statement. Nothing runs a check on a clock
2
- // except the hub's daily health pass (serve/health-schedule.ts), so the answer
3
- // is read off where the check runs (`when`), what it remembers, and that one
4
- // timer. Pure, so every case is pinned by a test rather than a screenshot.
5
- import type { Schedule, CheckFacts } from './hub-types.ts'
1
+ // WHEN A CHECK RUNS NEXT, as a true statement. Nothing runs a check nobody
2
+ // asked for but the plan (plan/planner.ts), so the answer is where the check
3
+ // runs (`when`) and its row of that plan. Pure, so every case is pinned by a
4
+ // test rather than a screenshot.
5
+ import type { PlanRow } from '../../plan/planner.ts'
6
+ import type { CheckFacts } from './hub-types.ts'
6
7
 
7
8
  export type NextRun = {
8
9
  /** the value the page shows */
@@ -37,32 +38,25 @@ export function spanWords(ms: number): string {
37
38
  return `${Math.round(ms / 1000)}s`
38
39
  }
39
40
 
40
- export type NextRunFacts = Pick<CheckFacts, 'when' | 'whenReason'> & { keepsUntil?: number | null }
41
+ export type NextRunFacts = Pick<CheckFacts, 'when' | 'whenReason'>
41
42
 
42
- export function nextRun(u: NextRunFacts, s: Schedule | null, now: number): NextRun {
43
- if (u.when === 'always')
44
- return { words: 'next shipkit ci', at: null, help: 'it runs on every `shipkit ci` and in every health pass. A cached answer is used when nothing it depends on moved' }
45
- if (u.when === 'release')
46
- return { words: 'before a release', at: null, help: '`shipkit ci --full` and the release path run it, and so does the health pass. The quick gate you run while working does not' }
43
+ /** a plan row in a few words: why it is due, or what makes it due */
44
+ export function planWords(row: PlanRow, now: number): { words: string; at: number | null } {
45
+ if (row.state === 'running') return { words: 'running now', at: row.dueAt }
46
+ if (row.state === 'due') return { words: row.reason === 'asked' ? 'due now, asked' : row.reason === 'changed' ? 'due now, a save moved it' : 'due now', at: row.dueAt }
47
+ if (row.state === 'late') return { words: 'late, waits for the repo to be active', at: row.dueAt }
48
+ if (row.state === 'next' && row.dueAt != null) return { words: `in ${aheadWords(row.dueAt - now)}`, at: row.dueAt }
49
+ return { words: 'on the next save that moves what it reads', at: null }
50
+ }
51
+
52
+ export function nextRun(u: NextRunFacts, row: PlanRow | null, now: number): NextRun {
47
53
  if (u.when === 'off')
48
54
  return { words: u.whenReason != null && u.whenReason !== '' ? `never: ${u.whenReason}` : 'never', at: null, help: 'it is off: nothing runs it until the switch below puts it somewhere' }
49
- if (s == null)
50
- return { words: 'next health pass', at: null, help: 'this hub runs no daily timer, so it runs when `shipkit check` runs or when you press run the health pass' }
51
- if (s.running) return { words: 'running now', at: now, help: 'the daily health pass is running right now' }
52
- const pass = Math.max(now, s.nextAt)
53
- const keeps = u.keepsUntil ?? null
54
- if (keeps != null && keeps > pass) {
55
- const k = Math.ceil((keeps - pass) / s.everyMs)
56
- const at = pass + k * s.everyMs
57
- return {
58
- words: `in ${aheadWords(at - now)}`,
59
- at,
60
- help: `the health pass runs every ${spanWords(s.everyMs)}, and uses this check's cached answer until it runs out. The first pass after that runs it again`,
61
- }
62
- }
63
- return {
64
- words: pass <= now ? 'due now' : `in ${aheadWords(pass - now)}`,
65
- at: pass,
66
- help: `the next health pass: the hub runs it every ${spanWords(s.everyMs)} while it serves, and at startup when the last one is older than that`,
67
- }
55
+ if (u.when === 'release')
56
+ return { words: 'before a release', at: null, help: '`shipkit ci --full` and the release path run it. The plan and the quick gate you run while working do not' }
57
+ if (row == null)
58
+ return { words: u.when === 'always' ? 'next shipkit ci' : 'next shipkit check', at: null, help: 'no plan covers it here, so it runs when a terminal or a button runs it' }
59
+ const w = planWords(row, now)
60
+ const inactive = row.state === 'late' || !row.active ? ` While the repo is inactive: ${row.inactive}.` : ''
61
+ return { words: w.words, at: w.at, help: `planned by: ${row.rule}.${inactive}${row.error != null ? ` Error: ${row.error}.` : ''} A shipkit ci or check from a terminal runs it too` }
68
62
  }
@@ -0,0 +1,88 @@
1
+ // the one picker of the page (cmd+k, keys.ts), a node of the root: the
2
+ // home's places (home, planned) first, then the shown repo's own jump
3
+ // targets, then every other house repo as a place to go. Another repo's
4
+ // resources are never listed: a jump to a repo selects it in place, and its
5
+ // own targets are one more cmd+k away.
6
+ import { makeAutoObservable } from 'mobx'
7
+ import type { HomeSt } from './home-st.ts'
8
+ import type { Jump } from './repo-st.ts'
9
+
10
+ /** how many rows the picker shows */
11
+ export const PAL_ROWS = 12
12
+
13
+ export class PaletteSt {
14
+ open = false
15
+ query = ''
16
+ idx = 0
17
+
18
+ constructor(private st: HomeSt) {
19
+ makeAutoObservable<this, 'st'>(this, { st: false })
20
+ }
21
+
22
+ /** the home's places, first in the list: a hub of its own has none */
23
+ get places(): Jump[] {
24
+ const st = this.st
25
+ if (st.machine == null) return []
26
+ return [
27
+ { id: 'place:home', label: 'home', section: 'place', run: () => st.navigate('/') },
28
+ { id: 'place:planned', label: 'planned', section: 'place', run: () => st.navigate('/planned') },
29
+ ]
30
+ }
31
+
32
+ /** every other house repo, as a place to go */
33
+ get others(): Jump[] {
34
+ const st = this.st
35
+ if (st.machine == null) return []
36
+ return st.projects.list.filter((p) => p.name !== st.selected).map((p) => ({ id: `repo:${p.name}`, label: p.name, section: 'repo', run: () => st.select(p.name) }))
37
+ }
38
+
39
+ get jumps(): Jump[] {
40
+ return [...this.places, ...(this.st.shown?.jumps ?? []), ...this.others]
41
+ }
42
+
43
+ /** ranked: prefix matches first, then substring. With no query, home and
44
+ * planned, then the shown repo's first targets, then every other repo,
45
+ * so a jump elsewhere is always in reach */
46
+ get results(): Jump[] {
47
+ const q = this.query.trim().toLowerCase()
48
+ if (q === '') {
49
+ const own = this.st.shown?.jumps ?? []
50
+ const places = this.places
51
+ const others = this.others
52
+ return [...places, ...own.slice(0, Math.max(4, PAL_ROWS - places.length - others.length)), ...others]
53
+ }
54
+ const pre: Jump[] = []
55
+ const sub: Jump[] = []
56
+ for (const j of this.jumps) {
57
+ const l = j.label.toLowerCase()
58
+ if (l.startsWith(q)) pre.push(j)
59
+ else if (l.includes(q)) sub.push(j)
60
+ }
61
+ return [...pre, ...sub].slice(0, PAL_ROWS * 2)
62
+ }
63
+
64
+ toggle(): void {
65
+ this.open = !this.open
66
+ this.query = ''
67
+ this.idx = 0
68
+ }
69
+ close(): void {
70
+ this.open = false
71
+ }
72
+ setQuery(v: string): void {
73
+ this.query = v
74
+ this.idx = 0
75
+ }
76
+ setIdx(n: number): void {
77
+ this.idx = n
78
+ }
79
+ move(d: number): void {
80
+ const n = this.results.length
81
+ if (n > 0) this.idx = (this.idx + d + n) % n
82
+ }
83
+ run(j: Jump | undefined = this.results[this.idx]): void {
84
+ if (j == null) return
85
+ this.open = false
86
+ j.run()
87
+ }
88
+ }