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
@@ -1,17 +1,20 @@
1
- // THE page (Bun.serve HTML import — no vite, no build step). One surface
2
- // per repo: ONE tree of every repo resource, by path or by type, coloured by
3
- // state in the label itself, over one severity filter persisted at
4
- // /?f=needs · /?f=red. A row opens its item view, an asset row opens the
5
- // atelier. mobx+react per build/app-state-tree: HubSt is the single root
6
- // (window.app), services own state, components read and call.
1
+ // THE page (Bun.serve HTML import — no vite, no build step), one single page
2
+ // app for every page the home serves ('/', '/planned', '/r/<repo>/') and for a
3
+ // hub serving on its own at '/'. mobx+react per build/app-state-tree: HomeSt
4
+ // is the single root (window.app), each repo a RepoSt under it, services own
5
+ // state, components read and call. A repo's page is ONE tree of every repo
6
+ // resource, by path or by type, coloured by state in the label itself, over
7
+ // one severity filter persisted at ?f=needs · ?f=red. A row opens its item
8
+ // view, an asset row opens the atelier.
7
9
  import { runInAction } from 'mobx'
8
10
  import { observer } from 'mobx-react-lite'
9
- import { StrictMode, useEffect } from 'react'
11
+ import { StrictMode, useEffect, useRef } from 'react'
10
12
  import { createRoot } from 'react-dom/client'
11
13
  import { ColCard, ColGrid, StatusCard } from './col-card.tsx'
12
14
  import { WIKIS, WikisView } from './wikis-view.tsx'
13
15
  import { ComfyOverlay, ComfyView } from './comfy-view.tsx'
14
- import { HubSt, type Filter } from './hub-st.ts'
16
+ import type { Filter, RepoSt } from './repo-st.ts'
17
+ import { HomeSt } from './home-st.ts'
15
18
  import { TimelineView } from './timeline-view.tsx'
16
19
  import { HeaderBar, LIVE_KEY, LiveStrip } from './header-bar.tsx'
17
20
  import { FOLLOW_KEY } from './timeline-chart.tsx'
@@ -22,24 +25,32 @@ import { AssetStage, CatGrid, InfoChips, MediaTopbar, ToolsBar, VariantsBar } fr
22
25
  import { TYPE_MARK } from './tree-build.ts'
23
26
  import { TreeRowView } from './tree-row.tsx'
24
27
  import { Help } from './help.tsx'
25
- import { ProjectGutter } from './project-gutter.tsx'
28
+ import { ProjectList } from './project-list.tsx'
29
+ import { Group, Panel, Separator, type Layout } from 'react-resizable-panels'
30
+ import { LIST, SIDEBAR } from './layout-st.ts'
31
+ import { HomeStrip, NotUp, Overview } from './home-strip.tsx'
32
+ import { PlannedView } from './planned-view.tsx'
33
+ import { SCREEN_WAIT_MS } from './screen-cache.ts'
34
+ import { pageKey } from './keys.ts'
26
35
 
27
- const app = new HubSt()
36
+ // home.html marks the home's page; index.html is a hub serving on its own
37
+ const home = new HomeSt({ home: document.body.dataset.home != null })
28
38
  // the root as a global — app code, console debugging (build/app-state-tree)
29
- ;(window as unknown as { app: HubSt }).app = app
30
- app.boot()
39
+ ;(window as unknown as { app: HomeSt }).app = home
40
+ home.boot()
31
41
 
32
42
  // ── the tree: every resource of the repo, one row deep in its path ──────
33
43
  // the row itself is tree-row.tsx, so a test renders it headless
34
44
 
35
- const Tree = observer(() => {
45
+ const Tree = observer(({ app }: { app: RepoSt }) => {
36
46
  const t = app.tree
37
47
  return (
38
48
  // roving focus: the arrows belong to the tree only while it HAS the
39
49
  // focus, so the atelier keeps its own keys
40
50
  <div className="tree" tabIndex={0} onKeyDown={(e) => { if (t.key(e.key)) e.preventDefault() }}>
41
51
  {t.rows.map((r) => <TreeRowView key={r.key} app={app} row={r} />)}
42
- {t.rows.length === 0 ? <div className="tempty">{app.filter === 'red' ? 'no reds — nothing is blocked ✨' : app.filter === 'needs' ? 'nothing needs you ✨' : 'no resources yet'}</div> : null}
52
+ {!app.cols.loaded && !app.cols.cached ? <div className="tempty twait"><span className="spin" />reading the repo…</div> : null}
53
+ {app.cols.loaded && t.rows.length === 0 ? <div className="tempty">{app.filter === 'red' ? 'no reds — nothing is blocked ✨' : app.filter === 'needs' ? 'nothing needs you ✨' : 'no resources yet'}</div> : null}
43
54
  </div>
44
55
  )
45
56
  })
@@ -55,7 +66,7 @@ const TypeLegend = () => (
55
66
  } />
56
67
  )
57
68
 
58
- const TreeTabs = observer(() => (
69
+ const TreeTabs = observer(({ app }: { app: RepoSt }) => (
59
70
  <span className="seg">
60
71
  {(['path', 'type'] as const).map((t) => (
61
72
  <button key={t} className={app.tree.tab === t ? 'on' : ''} onClick={() => app.tree.setTab(t)}>by {t}</button>
@@ -68,7 +79,7 @@ const TreeTabs = observer(() => (
68
79
  // the shortcut letter rides INSIDE its own button, so the binding is
69
80
  // discoverable without a legend
70
81
  const FILTER_KEY = { all: 'a', needs: 'n', red: 'r' } as const
71
- const SegFilter = observer(() => (
82
+ const SegFilter = observer(({ app }: { app: RepoSt }) => (
72
83
  <span className="seg">
73
84
  {(['all', 'needs', 'red'] as const).map((f) => (
74
85
  <button key={f} className={app.filter === f ? 'on' : ''} onClick={() => app.setFilter(f)}>
@@ -78,53 +89,56 @@ const SegFilter = observer(() => (
78
89
  </span>
79
90
  ))
80
91
 
81
- const Sidebar = observer(() => (
92
+ const Sidebar = observer(({ app }: { app: RepoSt }) => (
82
93
  <aside>
83
94
  <div className="shead">
84
95
  resources
85
- <SegFilter />
96
+ <SegFilter app={app} />
86
97
  </div>
87
98
  <div className="ssearch">
88
- <input id="treeq" placeholder="filter… (/ to focus, ⌘J to jump)" value={app.query}
99
+ <input ref={(el) => { app.searchEl = el }} placeholder="filter… (/ to focus, ⌘K to jump)" value={app.query}
89
100
  onChange={(e) => app.setQuery(e.target.value)}
90
101
  onKeyDown={(e) => { if (e.key === 'Escape') app.setQuery('') }} />
91
102
  </div>
92
103
  <div className="stabs">
93
- <TreeTabs />
104
+ <TreeTabs app={app} />
94
105
  <span className="sp" />
95
106
  <TypeLegend />
96
107
  <span className="cnt">{app.tree.visible.length}</span>
97
108
  </div>
98
- {app.tree.whenLine === '' ? null : <div className="swhen">{app.tree.whenLine}</div>}
109
+ {/* its line is held while the repo is read, so the tree never drops under it */}
110
+ {app.tree.whenLine === '' && app.cols.loaded ? null : <div className="swhen">{app.tree.whenLine === '' ? '\u00a0' : app.tree.whenLine}</div>}
99
111
  {app.media.dead != null ? <div className="tempty"><span className="warnline">media API down: {app.media.dead}</span></div> : null}
100
- <Tree />
112
+ <Tree app={app} />
101
113
  </aside>
102
114
  ))
103
115
 
104
- // ── cmd+j palette: every jump target behind one input ────────────────────
105
- const Palette = observer(() => {
106
- if (!app.palOpen) return null
116
+ // ── the one picker, cmd+k: home and planned, the shown repo's targets, then
117
+ // every other repo ──────────────────────────────────────────────────────
118
+ const Palette = observer(({ app }: { app: HomeSt }) => {
119
+ const p = app.palette
120
+ if (!p.open) return null
107
121
  return (
108
- <div className="palov" onClick={() => app.palClose()}>
122
+ <div className="palov" onClick={() => p.close()}>
109
123
  <div className="pal" onClick={(e) => e.stopPropagation()}>
110
- <input autoFocus placeholder="jump to…" value={app.palQuery}
111
- onChange={(e) => app.setPalQuery(e.target.value)}
124
+ <input autoFocus placeholder="jump to…" value={p.query}
125
+ onChange={(e) => p.setQuery(e.target.value)}
112
126
  onKeyDown={(e) => {
113
- if (e.key === 'ArrowDown') { e.preventDefault(); app.palMove(1) }
114
- else if (e.key === 'ArrowUp') { e.preventDefault(); app.palMove(-1) }
115
- else if (e.key === 'Enter') app.palRun()
116
- else if (e.key === 'Escape') app.palClose()
127
+ if (e.key === 'ArrowDown') { e.preventDefault(); p.move(1) }
128
+ else if (e.key === 'ArrowUp') { e.preventDefault(); p.move(-1) }
129
+ else if (e.key === 'Enter') p.run()
130
+ else if (e.key === 'Escape') p.close()
117
131
  }} />
118
132
  <div className="prows">
119
- {app.palResults.map((jp, n) => (
120
- <div key={jp.id} className={`prow${n === app.palIdx ? ' on' : ''}`}
121
- onMouseEnter={() => app.setPalIdx(n)}
122
- onClick={() => { app.palClose(); jp.run() }}>
133
+ {p.results.map((jp, n) => (
134
+ <div key={jp.id} className={`prow${n === p.idx ? ' on' : ''}`}
135
+ onMouseEnter={() => p.setIdx(n)}
136
+ onClick={() => p.run(jp)}>
123
137
  <span className="plabel">{jp.label}</span>
124
138
  <span className="pchip">{jp.section}</span>
125
139
  </div>
126
140
  ))}
127
- {app.palResults.length === 0 ? <div className="prow none">no match</div> : null}
141
+ {p.results.length === 0 ? <div className="prow none">no match</div> : null}
128
142
  </div>
129
143
  </div>
130
144
  </div>
@@ -133,7 +147,7 @@ const Palette = observer(() => {
133
147
 
134
148
  // remark box on the ASSET view too: remarks land in .shipkit/assets/notes.md
135
149
  // and come back as `remarks` (exchange both ways, media included)
136
- const AssetRemarks = observer(() => {
150
+ const AssetRemarks = observer(({ app }: { app: RepoSt }) => {
137
151
  const m = app.media
138
152
  const c = app.cols
139
153
  if (m.curId == null) return null
@@ -151,7 +165,7 @@ const AssetRemarks = observer(() => {
151
165
  )
152
166
  })
153
167
 
154
- const Main = observer(() => {
168
+ const Main = observer(({ app }: { app: RepoSt }) => {
155
169
  const m = app.media
156
170
  const errs = m.errors.length > 0 ? <div className="errs">{m.errors.join('\n')}</div> : null
157
171
  if (app.sel?.t === 'item') return (
@@ -184,7 +198,7 @@ const Main = observer(() => {
184
198
  <AssetStage app={app} />
185
199
  <VariantsBar app={app} />
186
200
  </div>
187
- <AssetRemarks />
201
+ <AssetRemarks app={app} />
188
202
  <div className="help"><kbd>←</kbd><kbd>→</kbd> roll · <kbd>Enter</kbd> pick · <kbd>⇧A</kbd> alt · <kbd>X</kbd> discard — picks ship via the kit's ship hook when present · category title = grid view</div>
189
203
  </main>
190
204
  )
@@ -203,68 +217,162 @@ const Main = observer(() => {
203
217
 
204
218
  const FILTER_BY_KEY: Record<string, Filter | undefined> = { a: 'all', n: 'needs', r: 'red' }
205
219
 
220
+ /** a panel never scrolls itself: its content scrolls where it means to (the
221
+ * list's rows, the tree, the page), and a panel's own scrollbar is a dark
222
+ * band in WebKit, the macOS app's engine, which draws classic scrollbars.
223
+ * overflow is the one inline style the library lets a panel set */
224
+ const PANEL = { overflow: 'hidden' } as const
225
+
226
+ /** a drag of a separator ended: the width of the panel on its left, in px,
227
+ * from the group's layout (a percentage of the group's width) */
228
+ const leftPx = (el: HTMLElement | null, layout: Layout, id: string): number | null => {
229
+ const pct = layout[id]
230
+ const w = el?.getBoundingClientRect().width
231
+ return pct == null || w == null || w === 0 ? null : (pct / 100) * w
232
+ }
233
+
234
+ /** a repo's sidebar and its main area, the sidebar's width dragged by the
235
+ * separator between them and kept per browser */
236
+ const Body = observer(({ app }: { app: RepoSt }) => {
237
+ const l = app.home.layout
238
+ const el = useRef<HTMLDivElement | null>(null)
239
+ return (
240
+ <Group className="appbody" orientation="horizontal" elementRef={el}
241
+ onLayoutChanged={(layout, meta) => {
242
+ if (!meta.isUserInteraction) return
243
+ const px = leftPx(el.current, layout, 'sidebar')
244
+ if (px != null) l.setSidebarWidth(px)
245
+ }}>
246
+ <Panel id="sidebar" defaultSize={l.sidebarWidth} minSize={SIDEBAR.min} maxSize={SIDEBAR.max} groupResizeBehavior="preserve-pixel-size" className="pside" style={PANEL}>
247
+ <Sidebar app={app} />
248
+ </Panel>
249
+ <Separator className="rsep" />
250
+ <Panel id="main" minSize={320} className="pmain" style={PANEL}>
251
+ <Main app={app} />
252
+ </Panel>
253
+ </Group>
254
+ )
255
+ })
256
+
257
+ /** the repo's hub has not answered on the home: its reason and its fix show
258
+ * instead of the page. A screen this browser kept shows meanwhile, until
259
+ * the machine says the hub is not up */
260
+ const notUp = (app: RepoSt): boolean => {
261
+ const m = app.home.machine
262
+ if (m == null || app.started) return false
263
+ if (!app.cols.cached) return true
264
+ return m.fresh && m.worker(app.key)?.status !== 'up'
265
+ }
266
+
267
+ /** one repo's page. Every repo visited keeps its region mounted, hidden while
268
+ * another one shows, so its scroll, its frames and its component state are
269
+ * there when it comes back; a hidden repo polls nothing, so it repaints
270
+ * nothing */
271
+ const RepoView = observer(({ app }: { app: RepoSt }) => {
272
+ const on = app.isShown
273
+ return (
274
+ <div className="repo" hidden={!on}>
275
+ {notUp(app) ? <NotUp app={app.home} /> : (
276
+ <>
277
+ <HeaderBar app={app} />
278
+ <LiveStrip app={app} />
279
+ <Body app={app} />
280
+ {on ? <ComfyOverlay app={app} /> : null}
281
+ </>
282
+ )}
283
+ </div>
284
+ )
285
+ })
286
+
287
+ /** the keys of the page: they act on the repo shown, and on nothing else */
288
+ function onKey(e: KeyboardEvent): void {
289
+ // the command keys beat the focus check: they work from anywhere, any page
290
+ const pk = pageKey(e)
291
+ if (pk != null) {
292
+ e.preventDefault()
293
+ if (pk.t === 'picker') home.palette.toggle()
294
+ else if (pk.t === 'home' || pk.t === 'planned') {
295
+ home.palette.close()
296
+ if (home.machine != null) home.navigate(pk.t === 'home' ? '/' : '/planned')
297
+ } else {
298
+ const p = home.projects.list[pk.n - 1]
299
+ home.palette.close()
300
+ if (p != null) home.projects.click(p.name)
301
+ }
302
+ return
303
+ }
304
+ if (home.palette.open) return // the picker's input owns its keys
305
+ const app = home.shown
306
+ if (app == null) return
307
+ const t = e.target as HTMLElement
308
+ if (t.tagName === 'TEXTAREA' || t.tagName === 'INPUT') return
309
+ // the atelier owns its roll keys while an asset is open, and
310
+ // says so by consuming them
311
+ if (app.sel?.t === 'asset' && app.media.key(e.key)) {
312
+ e.preventDefault()
313
+ return
314
+ }
315
+ if (e.key === '/') {
316
+ e.preventDefault()
317
+ app.searchEl?.focus()
318
+ return
319
+ }
320
+ if (e.metaKey || e.ctrlKey || e.altKey) return
321
+ if (e.key === LIVE_KEY) {
322
+ e.preventDefault()
323
+ app.live.toggle()
324
+ return
325
+ }
326
+ if (e.key === FOLLOW_KEY && !app.live.chart.follow) {
327
+ e.preventDefault()
328
+ app.live.chart.attach()
329
+ return
330
+ }
331
+ const f = FILTER_BY_KEY[e.key.toLowerCase()]
332
+ if (f != null) {
333
+ e.preventDefault()
334
+ app.setFilter(f)
335
+ }
336
+ }
337
+
206
338
  const App = observer(() => {
207
339
  useEffect(() => {
208
- const onKey = (e: KeyboardEvent): void => {
209
- // ⌘J/^J beats the focus check — the palette must open from anywhere
210
- if ((e.metaKey || e.ctrlKey) && !e.shiftKey && e.key.toLowerCase() === 'j') {
211
- e.preventDefault()
212
- app.palToggle()
213
- return
214
- }
215
- const t = e.target as HTMLElement
216
- if (t.tagName === 'TEXTAREA' || t.tagName === 'INPUT') return
217
- if (app.palOpen) return // the palette input owns its keys
218
- // the atelier owns its roll keys while an asset is open, and
219
- // says so by consuming them
220
- if (app.sel?.t === 'asset' && app.media.key(e.key)) {
221
- e.preventDefault()
222
- return
223
- }
224
- if (e.key === '/') {
225
- e.preventDefault()
226
- document.getElementById('treeq')?.focus()
227
- return
228
- }
229
- if (e.metaKey || e.ctrlKey || e.altKey) return
230
- if (e.key === LIVE_KEY) {
231
- e.preventDefault()
232
- app.live.toggle()
233
- return
234
- }
235
- if (e.key === FOLLOW_KEY && !app.live.chart.follow) {
236
- e.preventDefault()
237
- app.live.chart.attach()
238
- return
239
- }
240
- const f = FILTER_BY_KEY[e.key.toLowerCase()]
241
- if (f != null) {
242
- e.preventDefault()
243
- app.setFilter(f)
244
- }
245
- }
246
340
  document.addEventListener('keydown', onKey)
247
341
  return () => document.removeEventListener('keydown', onKey)
248
342
  }, [])
343
+ const l = home.layout
344
+ const el = useRef<HTMLDivElement | null>(null)
249
345
  return (
250
- // a flex ROW: a column mounted before `.appcol` sits left of the
251
- // header and everything under it
252
- <div className="app">
253
- <ProjectGutter app={app} />
254
- <div className="appcol">
255
- <HeaderBar app={app} />
256
- <LiveStrip app={app} />
257
- <div className="appbody">
258
- <Sidebar />
259
- <Main />
260
- </div>
261
- </div>
262
- <Palette />
263
- <ComfyOverlay app={app} />
264
- </div>
346
+ // two panels: the project list, its width dragged by the separator
347
+ // and kept per browser, then the page. The list and the strip stay
348
+ // mounted across pages; only the region under the strip changes
349
+ <>
350
+ <Group className="app" orientation="horizontal" elementRef={el}
351
+ onLayoutChanged={(layout, meta) => {
352
+ if (!meta.isUserInteraction) return
353
+ const px = leftPx(el.current, layout, 'projects')
354
+ if (px != null) l.setListWidth(px)
355
+ }}>
356
+ <Panel id="projects" defaultSize={l.listWidth} minSize={LIST.min} maxSize={LIST.max} groupResizeBehavior="preserve-pixel-size" className="pjpanel" style={PANEL}>
357
+ <ProjectList app={home} />
358
+ </Panel>
359
+ <Separator className="rsep" />
360
+ <Panel id="page" minSize={320} className="appcol" style={PANEL}>
361
+ {home.machine == null ? null : <HomeStrip app={home} />}
362
+ {home.page === 'overview' ? <Overview app={home} /> : null}
363
+ {home.page === 'planned' ? <PlannedView app={home} /> : null}
364
+ {home.visited.map((r) => <RepoView key={r.key} app={r} />)}
365
+ </Panel>
366
+ </Group>
367
+ <Palette app={home} />
368
+ </>
265
369
  )
266
370
  })
267
371
 
372
+ // the shown repo's kept screen is read before the first paint, so a refresh
373
+ // paints it at once; a slow or absent store costs at most SCREEN_WAIT_MS
374
+ await Promise.race([home.shown?.cols.restore(), new Promise((ok) => setTimeout(ok, SCREEN_WAIT_MS))])
375
+
268
376
  createRoot(document.getElementById('root')!).render(
269
377
  <StrictMode>
270
378
  <App />
@@ -126,9 +126,6 @@ export type CheckFacts = {
126
126
  * (serve/hub-problems.ts): the header shows it, never the tree */
127
127
  export type HubProblem = { id: string; text: string; severity: 'red' | 'yellow' }
128
128
 
129
- /** the hub's daily health pass timer, as the resources feed sends it
130
- * (serve/health-schedule.ts). null = this hub runs no timer */
131
- export type Schedule = { everyMs: number; lastPassAt: number | null; nextAt: number; running: boolean }
132
129
 
133
130
  /** one declared slow suite, with the case test-speed doctrine demands */
134
131
  export type SlowSuite = { file: string; catches: string; whyNotFast: string }
@@ -21,18 +21,34 @@
21
21
  --s0:2px;--s1:4px;--s2:8px;--s3:12px;--s4:16px}
22
22
  *{box-sizing:border-box}html,body,#root{height:100%}
23
23
  body{margin:0;background:var(--bg);color:var(--ink);font:14px/1.5 system-ui,sans-serif;overflow:hidden}
24
- /* the page is a flex ROW: a column mounted before .appcol sits left of the
25
- header and everything under it, and .appcol stacks the header, the live
26
- strip and the body */
27
- .app{display:flex;height:100vh}
28
- .appcol{flex:1;min-width:0;display:flex;flex-direction:column}
29
- .appbody{flex:1;min-height:0;display:grid;grid-template-columns:330px 1fr}
24
+ /* the page is two panels (hub-app.tsx): the project list, then .appcol,
25
+ which stacks the strip, the header, the live strip and the body */
26
+ .app{height:100vh}
27
+ /* a panel's class lands on the div inside the panel (react-resizable-panels) */
28
+ .pjpanel,.appcol,.pside,.pmain{height:100%;min-width:0}
29
+ .pjpanel{display:flex}
30
+ .appcol{display:flex;flex-direction:column}
31
+ /* one region per repo visited, the shown one alone displayed */
32
+ .repo{flex:1;min-height:0;display:flex;flex-direction:column}
33
+ .repo[hidden]{display:none}
34
+ .appbody{flex:1;min-height:0}
35
+ .pside{display:flex;flex-direction:column}
36
+ .pside > aside{flex:1}
37
+ .pmain{display:flex;flex-direction:column}
38
+ .pmain > main{flex:1}
30
39
 
31
40
  /* ── the header: repo name, the core pages, the two runs and the run state ── */
41
+ /* the repo's processes (serve.processes): a dot, its id, a link once up */
42
+ .hprocs{display:inline-flex;gap:var(--s1);align-items:center}
43
+ .hproc{display:inline-flex;align-items:center;gap:var(--s0)}
44
+ .hproc button{padding:var(--s0) var(--s2);font-size:12px}
45
+ .hproc a{color:var(--accent);text-decoration:none;font-size:12px}
46
+ .pdot{display:inline-block;width:7px;height:7px;border-radius:4px;margin-right:var(--s1);background:var(--line)}
47
+ .pdot.up{background:var(--ok)}.pdot.starting,.pdot.stopping{background:var(--warn)}.pdot.crashed{background:var(--red)}
32
48
  .hdr{display:flex;gap:var(--s2);align-items:center;height:36px;flex:none;padding:0 var(--s3);border-bottom:1px solid var(--line);background:var(--panel)}
33
49
  .hdr .sp{flex:1}
34
50
  .hname{font-size:12px;letter-spacing:.14em;text-transform:uppercase;color:var(--ink);font-weight:600;margin-right:var(--s2);white-space:nowrap}
35
- .hnav{display:flex;gap:var(--s1);overflow:hidden}
51
+ .hnav{display:flex;gap:var(--s1);overflow:hidden;align-items:center}
36
52
  .hnav button{border-color:transparent;color:var(--dim);border-radius:0;border-bottom:1px solid transparent;white-space:nowrap}
37
53
  .hnav button:hover{color:var(--ink);border-color:transparent;border-bottom-color:var(--line)}
38
54
  .hnav button.on{color:var(--accent);border-bottom-color:var(--accent)}
@@ -40,17 +56,17 @@ body{margin:0;background:var(--bg);color:var(--ink);font:14px/1.5 system-ui,sans
40
56
  .hstatus{max-width:420px;flex:0 1 auto;font-size:11.5px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
41
57
  .hstatus.red{color:var(--red)}
42
58
  .hstatus.warn{color:var(--warn)}
43
- .hstate{display:inline-flex;gap:var(--s1);align-items:center;width:260px;min-width:260px;flex:none;justify-content:flex-end;font-size:11.5px;color:var(--dim);overflow:hidden}
59
+ .hstate{display:inline-flex;gap:var(--s1);align-items:center;width:260px;min-width:260px;flex:none;justify-content:flex-start;font-size:11.5px;color:var(--dim);overflow:hidden}
44
60
  .hstate.on{color:var(--ink)}
45
61
  .hstate .htext{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
62
+ .hstate .spin.idle,.hnav .spin.idle{visibility:hidden;animation:none}
46
63
 
47
64
  /* ── the live strip: the one run chart. Folded, one thin row of ONE height
48
65
  whatever runs; unfolded, the chart at the height its lanes hold, set
49
66
  inline from a held value that grows at once and shrinks only after a
50
- while, so it never jumps every second. The strip clips nothing itself, so
51
- its (?) opens below it over the page; the chart box clips the chart ── */
67
+ while, so it never jumps every second. Its (?) and the chart tooltip float
68
+ in a portal (floating.tsx), so no box of the strip clips them ── */
52
69
  .live{display:flex;flex-direction:column;height:30px;flex:none;border-bottom:1px solid var(--line);background:var(--bg);font-size:11px;color:var(--dim)}
53
- .live .hbub{top:calc(100% + 6px);bottom:auto;z-index:30}
54
70
  .lrow{display:flex;gap:var(--s2);align-items:center;height:29px;flex:none;padding:0 var(--s3)}
55
71
  .lrow .sp{flex:1}
56
72
  .lfold{display:inline-flex;gap:var(--s1);align-items:center;border-color:transparent;color:var(--dim);padding:var(--s0) var(--s1)}
@@ -135,10 +151,11 @@ aside{border-right:1px solid var(--line);background:var(--panel);overflow-y:auto
135
151
  .tnote b{font-weight:600;color:var(--red)}
136
152
  .stabs{display:flex;gap:var(--s2);align-items:center;padding:var(--s1) var(--s3);border-bottom:1px solid var(--line)}
137
153
  .stabs .sp{flex:1}
138
- .stabs .cnt{font-size:11px;color:var(--dim)}
154
+ .stabs .cnt{min-width:4ch;text-align:right;font-size:11px;color:var(--dim);font-variant-numeric:tabular-nums}
139
155
  .catgen{display:inline-flex;gap:var(--s1)}
140
156
  .catgen button{padding:var(--s0) var(--s2);font-size:11px;font-weight:700}
141
157
  .tempty{padding:var(--s2) var(--s3);font-size:12px;color:var(--dim);white-space:pre-wrap}
158
+ .twait{display:flex;align-items:center;gap:var(--s2)}
142
159
  .warnline{color:var(--red)}
143
160
  .fold{cursor:pointer;color:var(--dim);font-size:15px;display:inline-block;width:22px;text-align:center;
144
161
  padding:var(--s0);border-radius:3px;user-select:none}
@@ -388,9 +405,9 @@ kbd{background:var(--panel2);border:1px solid var(--line);border-radius:4px;padd
388
405
  .tlgap.live text{fill:var(--ink)}
389
406
  .tlgap text{font-size:10px;pointer-events:none}
390
407
  .tlnow{stroke:var(--accent)}
391
- /* fixed to the viewport, placed from the pointer: every box of the strip
392
- clips its overflow, and a tooltip inside them is cut to a sliver */
393
- .tlaxtip{position:fixed;z-index:30;display:flex;flex-direction:column;gap:0;padding:var(--s1) var(--s2);pointer-events:none;
408
+ /* placed by floating-ui under the pointer, in a portal: every box of the
409
+ strip clips its overflow, and a tooltip inside them is cut to a sliver */
410
+ .tlaxtip{z-index:30;display:flex;flex-direction:column;gap:0;padding:var(--s1) var(--s2);pointer-events:none;
394
411
  background:var(--panel);border:1px solid var(--line);border-radius:3px;box-shadow:0 2px 12px var(--shadow);font-size:11px;white-space:nowrap}
395
412
  .tlaxtip .dim{color:var(--dim)}
396
413
  /* the runs page: one tab bar, then one list. Every line on a grid of fixed
@@ -542,17 +559,18 @@ a.tok{color:var(--accent)}
542
559
  height, so a streaming run never jumps the card it sits in */
543
560
  .wlog{margin:0;min-height:120px;max-height:320px;overflow-y:auto;background:var(--panel2);border:1px solid var(--line);
544
561
  border-radius:3px;padding:var(--s2);font:11.5px/1.5 ui-monospace,Menlo,monospace;color:var(--ink);white-space:pre-wrap}
545
- /* THE HELP: a `(?)` that says its one sentence at once, on hover and on
546
- focus. No delay, no library, no state */
562
+ /* THE HELP: a `(?)` that says its one sentence on hover and on focus, the
563
+ bubble placed by floating-ui (floating.tsx) */
547
564
  .hlp{display:inline-flex;align-items:center;justify-content:center;width:13px;height:13px;flex:none;
548
565
  border:1px solid var(--line);border-radius:50%;color:var(--dim);font-size:9px;line-height:1;cursor:help;
549
- position:relative;outline:none;user-select:none}
566
+ outline:none;user-select:none}
550
567
  .hlp:hover,.hlp:focus{color:var(--accent);border-color:var(--accent)}
551
- .hbub{display:none;position:absolute;left:50%;bottom:calc(100% + 6px);transform:translateX(-50%);width:max-content;
568
+ /* out of sight, still read by a screen reader and found by a page search */
569
+ .vh{position:absolute;width:1px;height:1px;padding:0;overflow:hidden;clip-path:inset(50%);white-space:nowrap;border:0}
570
+ .hbub,.ftip{z-index:40;width:max-content;
552
571
  max-width:260px;background:var(--panel2);border:1px solid var(--line);border-radius:3px;box-shadow:0 2px 10px var(--shadow);
553
572
  padding:var(--s1) var(--s2);font:11.5px/1.4 system-ui,sans-serif;text-transform:none;letter-spacing:0;
554
- color:var(--ink);white-space:normal;z-index:9}
555
- .hlp:hover .hbub,.hlp:focus .hbub{display:block}
573
+ color:var(--ink);white-space:normal;pointer-events:none}
556
574
  /* the type legend: every glyph of the tree with its word, in one bubble */
557
575
  .legend{display:flex;flex-direction:column;gap:var(--s1)}
558
576
  .legend .lrow{display:flex;gap:var(--s2);align-items:baseline;white-space:normal}
@@ -596,7 +614,9 @@ a.tok{color:var(--accent)}
596
614
  .graphview{flex:1;min-height:0;display:flex;flex-direction:column;gap:var(--s2);padding:var(--s3) var(--s4) var(--s4)}
597
615
  .gvhead{display:flex;gap:var(--s2);align-items:center;font-size:13px;min-width:0}
598
616
  .gvhead .gvtext{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
599
- .graphview iframe{flex:1;min-height:0;width:100%;border:1px solid var(--line);border-radius:3px;background:var(--bg)}
617
+ .gvframe{flex:1;min-height:0;position:relative;display:flex;border:1px solid var(--line);border-radius:3px;background:var(--bg)}
618
+ .gvwait{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;gap:var(--s2);font-size:12px;color:var(--dim)}
619
+ .graphview iframe{position:relative;flex:1;min-height:0;width:100%;border:0;background:transparent}
600
620
 
601
621
  /* ── comfy item view (comfy-ts's own panel fills the stage) ── */
602
622
  .comfyview{flex:1;min-height:0;display:flex;flex-direction:column;gap:var(--s2);padding:var(--s3) var(--s4) var(--s4)}
@@ -646,7 +666,7 @@ a.mint{color:var(--accent);font-size:12px;text-decoration:none}
646
666
  a.mint:hover{text-decoration:underline}
647
667
  .colcard h3 .stchip,.colcard h3 a.mint{margin-left:var(--s2)}
648
668
 
649
- /* ── cmd+j palette ── */
669
+ /* ── the cmd+k picker ── */
650
670
  .palov{position:fixed;inset:0;background:var(--scrim);z-index:100;display:flex;justify-content:center;align-items:flex-start;
651
671
  padding-top:14vh}
652
672
  .pal{width:min(560px,90vw);background:var(--panel);border:1px solid var(--line);border-radius:3px;
@@ -0,0 +1,14 @@
1
+ // the few line icons of the home (the overview's section headers, the two
2
+ // places on top of the project list): one 20 unit path each, stroked in the text colour, so
3
+ // the page's tokens colour them and nothing else does.
4
+ export const ICON = {
5
+ shipkit: 'M3 7.5 10 4l7 3.5v5L10 16l-7-3.5zM3 7.5 10 11l7-3.5M10 11v5',
6
+ process: 'M2 10h3.5l2-5 5 10 2-5H18',
7
+ monitor: 'M3 4h14v9H3zM7 16.5h6M10 13v3.5',
8
+ home: 'M3 9.5 10 3.5l7 6M5 8v8.5h10V8M8.5 16.5v-4.5h3v4.5',
9
+ planned: 'M5.5 3h9M5.5 17h9M6.5 3c0 4 7 4 7 7s-7 3-7 7M13.5 3c0 4-7 4-7 7s7 3 7 7',
10
+ } as const
11
+
12
+ export const Icon = ({ d }: { d: string }) => (
13
+ <svg className="hico" viewBox="0 0 20 20" width="16" height="16" aria-hidden="true" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d={d} /></svg>
14
+ )
@@ -5,8 +5,10 @@
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
6
  <title>hub</title>
7
7
  <link rel="stylesheet" href="./hub.css" />
8
- <link rel="stylesheet" href="./project-gutter.css" />
8
+ <link rel="stylesheet" href="./project-list.css" />
9
9
  <link rel="stylesheet" href="./prose-hits.css" />
10
+ <link rel="stylesheet" href="./home-strip.css" />
11
+ <link rel="stylesheet" href="./planned.css" />
10
12
  </head>
11
13
  <body>
12
14
  <div id="root"></div>
@@ -0,0 +1,25 @@
1
+ // the page's command keys, read the same on every layout: ⌘1 to ⌘9 select the
2
+ // nth repo of the project list, ⌘J shows the overview, ⌘P the plan, ⌘K opens
3
+ // the picker.
4
+ // A digit is read from the physical key (`code`), so an AZERTY keyboard,
5
+ // where the key under 1 types `&`, selects the same repo. Ctrl stands in for
6
+ // ⌘ off a Mac. The macOS app binds none of these to a menu item, so they
7
+ // reach the page (tests/home-strip.test.tsx reads ShipkitGui.swift).
8
+ export type PageKey = { t: 'repo'; n: number } | { t: 'home' } | { t: 'planned' } | { t: 'picker' }
9
+
10
+ type KeyLike = { key: string; code: string; metaKey: boolean; ctrlKey: boolean; shiftKey: boolean; altKey: boolean }
11
+
12
+ export function pageKey(e: KeyLike): PageKey | null {
13
+ if (!(e.metaKey || e.ctrlKey) || e.altKey) return null
14
+ const digit = /^Digit([1-9])$/.exec(e.code)
15
+ if (digit != null) return { t: 'repo', n: Number(digit[1]) }
16
+ if (e.shiftKey) return null
17
+ const k = e.key.toLowerCase()
18
+ if (k === 'j') return { t: 'home' }
19
+ if (k === 'p') return { t: 'planned' }
20
+ if (k === 'k') return { t: 'picker' }
21
+ return null
22
+ }
23
+
24
+ /** the shortcut each place of the home carries on its button */
25
+ export const PLACE_KEY = { overview: '⌘J', planned: '⌘P' } as const