rvlib-shipkit 2.3.0 → 2.5.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 (104) hide show
  1. package/CHANGELOG.md +19 -2
  2. package/package.json +2 -2
  3. package/src/assets/kits/identity-kit.ts +5 -2
  4. package/src/cli/cmd_Commands.ts +30 -5
  5. package/src/cli/cmd_FleetPush.ts +47 -0
  6. package/src/cli/cmd_GlobalProcesses.ts +5 -5
  7. package/src/cli/cmd_Review.ts +52 -11
  8. package/src/cli/cmd_Sound.ts +209 -0
  9. package/src/cli/cmd_Trailer.ts +11 -3
  10. package/src/cli/family-pages.ts +49 -0
  11. package/src/cli/kit/builtins.ts +1 -0
  12. package/src/cli/kit/verb.ts +11 -3
  13. package/src/cli/registry.ts +137 -0
  14. package/src/cli.ts +47 -133
  15. package/src/core/app-icon.ts +10 -6
  16. package/src/core/last-good.ts +217 -0
  17. package/src/core/load-probe.ts +71 -0
  18. package/src/core/reexec.ts +5 -3
  19. package/src/fleet/push-fleet.ts +80 -0
  20. package/src/home/home-routes.ts +12 -2
  21. package/src/home/lib-watch.ts +110 -20
  22. package/src/home/process-rows.ts +4 -2
  23. package/src/home/run-daemon.ts +1 -1
  24. package/src/repo-config.ts +3 -0
  25. package/src/review/format.ts +11 -3
  26. package/src/review/model.ts +66 -5
  27. package/src/review/paste.ts +30 -0
  28. package/src/review/project.ts +20 -4
  29. package/src/review/routes.ts +22 -4
  30. package/src/review/store.ts +15 -6
  31. package/src/review/unit.ts +19 -1
  32. package/src/scaffold/files/agent-index.md +2 -2
  33. package/src/scaffold/project-scaffold.ts +2 -2
  34. package/src/serve/hub-app/activity-st.ts +31 -0
  35. package/src/serve/hub-app/activity-view.tsx +9 -0
  36. package/src/serve/hub-app/activity.css +6 -0
  37. package/src/serve/hub-app/admin-svc.ts +1 -1
  38. package/src/serve/hub-app/admin-view.tsx +1 -1
  39. package/src/serve/hub-app/hash-route.ts +12 -4
  40. package/src/serve/hub-app/home-st.ts +7 -1
  41. package/src/serve/hub-app/home-strip.tsx +3 -1
  42. package/src/serve/hub-app/hub.css +38 -1
  43. package/src/serve/hub-app/icons.tsx +6 -0
  44. package/src/serve/hub-app/layout-st.ts +2 -2
  45. package/src/serve/hub-app/machine-st.ts +1 -1
  46. package/src/serve/hub-app/md.ts +32 -0
  47. package/src/serve/hub-app/project-list.css +9 -7
  48. package/src/serve/hub-app/project-list.tsx +5 -2
  49. package/src/serve/hub-app/projects-svc.ts +3 -2
  50. package/src/serve/hub-app/repo-st.ts +22 -7
  51. package/src/serve/hub-app/review/clip-st.ts +57 -7
  52. package/src/serve/hub-app/review/kind-view.tsx +78 -9
  53. package/src/serve/hub-app/review/review-answer.tsx +67 -58
  54. package/src/serve/hub-app/review/review-page.tsx +17 -15
  55. package/src/serve/hub-app/review/review-st.ts +191 -20
  56. package/src/serve/hub-app/review/review-views.tsx +154 -9
  57. package/src/serve/hub-app/review.css +73 -13
  58. package/src/serve/hub-app/wiki-st.ts +190 -0
  59. package/src/serve/hub-app/wikis-view.tsx +134 -5
  60. package/src/serve/hub-runtime.ts +4 -4
  61. package/src/serve/project-gutter-route.ts +25 -1
  62. package/src/serve/project-gutter.ts +1 -1
  63. package/src/serve/run-serve.ts +25 -0
  64. package/src/serve/serve-hub.ts +15 -1
  65. package/src/serve/stale-restart.ts +19 -2
  66. package/src/shot/client.ts +17 -5
  67. package/src/shot/shooter.ts +19 -3
  68. package/src/shot/shot-ask.ts +9 -1
  69. package/src/shot/stepped-clip.ts +53 -0
  70. package/src/trailer/adapter.ts +33 -4
  71. package/src/trailer/build.ts +182 -33
  72. package/src/trailer/ffmpeg.ts +21 -1
  73. package/src/trailer/fonts.ts +55 -0
  74. package/src/trailer/footage.ts +4 -4
  75. package/src/trailer/grammar.ts +429 -67
  76. package/src/trailer/graph.ts +130 -56
  77. package/src/trailer/look.ts +3 -0
  78. package/src/trailer/motion.ts +207 -0
  79. package/src/trailer/plan.ts +2 -2
  80. package/src/trailer/prune.ts +13 -2
  81. package/src/trailer/score/compose.ts +18 -2
  82. package/src/trailer/score/gm-render.ts +43 -0
  83. package/src/trailer/score/gm.ts +90 -0
  84. package/src/trailer/score/midi.ts +143 -0
  85. package/src/trailer/score/pattern.ts +13 -6
  86. package/src/trailer/score/score.ts +28 -14
  87. package/src/trailer/score/sound.ts +12 -1
  88. package/src/trailer/score/synth.ts +24 -2
  89. package/src/trailer/sound/audio.ts +148 -0
  90. package/src/trailer/sound/brand.ts +42 -0
  91. package/src/trailer/sound/comfy.ts +38 -0
  92. package/src/trailer/sound/eleven.ts +72 -0
  93. package/src/trailer/sound/fluid.ts +71 -0
  94. package/src/trailer/sound/gen.ts +139 -0
  95. package/src/trailer/sound/index.ts +60 -0
  96. package/src/trailer/sound/presets.ts +69 -0
  97. package/src/trailer/sound/run.ts +189 -0
  98. package/src/trailer/titles.ts +451 -235
  99. package/src/trailer/url.ts +18 -3
  100. package/src/trailer/vocab.ts +12 -1
  101. package/src/trailer/words.ts +29 -8
  102. package/src/wiki/graph-mount.ts +3 -0
  103. package/src/wiki/graph-page.ts +45 -9
  104. package/src/wiki/graph-pages.ts +128 -0
@@ -90,6 +90,8 @@ export interface ViewInput {
90
90
  after?: string
91
91
  /** a list of small images shown as a wrapping grid, each with its label; never the first view */
92
92
  gallery?: { file: string; label: string }[]
93
+ /** a line on this view alone: an option's description on a choice card */
94
+ note?: string
93
95
  }
94
96
 
95
97
  export interface AddInput {
@@ -123,6 +125,8 @@ export interface AddInput {
123
125
  light?: 'review'
124
126
  /** a draft card on a live shot: its image a live frame, no verdict until it is ready */
125
127
  wip?: { live: string }
128
+ /** every view is an option he picks one of */
129
+ choice?: boolean
126
130
  now?: Date
127
131
  }
128
132
 
@@ -228,12 +232,13 @@ function soundLength(file: string): number {
228
232
  /** the waveform a sound view shows, one size for every sound so a pair reads side by side */
229
233
  const WAVE_SIZE = { w: 1600, h: 240 }
230
234
 
231
- /** view `n`'s waveforms, drawn into `folder` beside their place and renamed */
235
+ /** view `n`'s waveforms, drawn into `folder` beside their place and renamed: each column's peak on a square root scale,
236
+ * so a voice's quiet syllables show; drawn white on transparent, the page fills it with its own colour (a mask) */
232
237
  async function writeWaves(v: Loaded, n: number, folder: string): Promise<void> {
233
238
  if (!v.audio) return
234
239
  const draw = async (from: string, name: string): Promise<void> => {
235
240
  const to = join(folder, `${prefix(n)}${name}.png`)
236
- await ffmpeg(['-i', from, '-filter_complex', `showwavespic=s=${WAVE_SIZE.w}x${WAVE_SIZE.h}:split_channels=0:colors=0x8fb3ff`, '-frames:v', '1', '-f', 'image2', '-c:v', 'png', `${to}.tmp`])
241
+ await ffmpeg(['-i', from, '-filter_complex', `showwavespic=s=${WAVE_SIZE.w}x${WAVE_SIZE.h}:split_channels=0:scale=sqrt:filter=peak:colors=white`, '-frames:v', '1', '-f', 'image2', '-c:v', 'png', `${to}.tmp`])
237
242
  renameSync(`${to}.tmp`, to)
238
243
  }
239
244
  await draw(v.audio.from, 'wave')
@@ -245,10 +250,10 @@ const prefix = (n: number): string => (n > 0 ? `v${n}-` : '')
245
250
  /** the file name of tile `k` of gallery view `n` */
246
251
  const tileName = (n: number, k: number, path: string): string => `v${n}-g${k}${extname(path).toLowerCase()}`
247
252
 
248
- function viewOf(v: Loaded, n: number, marks: readonly ReviewMark[] = []): ReviewView {
253
+ function viewOf(v: Loaded, n: number, marks: readonly ReviewMark[] = [], note?: string): ReviewView {
249
254
  if (v.tiles) return { label: v.label, gallery: v.tiles.map((t, k): GalleryTile => ({ file: tileName(n, k, t.path), label: t.label, size: t.size })) }
250
255
  const audio = v.audio ? { audio: { duration: v.audio.duration, wave: `${prefix(n)}wave.png`, ...(v.audio.before ? { waveBefore: `${prefix(n)}before-wave.png` } : {}) } } : {}
251
- return { label: v.label, files: filesOf(v, n), size: v.size, ...clipOf(v, n), ...audio, ...(marks.length > 0 ? { marks: [...marks] } : {}) }
256
+ return { label: v.label, files: filesOf(v, n), size: v.size, ...clipOf(v, n), ...audio, ...(marks.length > 0 ? { marks: [...marks] } : {}), ...(note?.trim() ? { note: note.trim() } : {}) }
252
257
  }
253
258
 
254
259
  /** the file names of view `n` in the folder: `image.png` on the first, `v2-before.png` on a later one */
@@ -295,6 +300,8 @@ export async function addItem(dir: string, input: AddInput): Promise<Item> {
295
300
  if (given.length === 0) throw new Error('give --image, --video or --audio, or --before and --after, or --view "<label>" <image>')
296
301
  for (const [i, v] of given.entries()) if (i > 0 && !v.label.trim()) throw new Error('--view: every view after the first needs its label')
297
302
  if (given[0]?.gallery) throw new Error('a gallery is never the first view: give the card an --image (or a pair, or a clip) first')
303
+ if (input.choice && given.length < 2) throw new Error('--choice: a choice has two options or more, one --view each')
304
+ for (const v of given) if (v.note !== undefined && v.gallery) throw new Error(`--view "${v.label}": a note goes on an image, a clip or a sound, never a gallery`)
298
305
  const loaded = await Promise.all(given.map((v, i) => loadView(v, i === 0 ? 'the first view' : `--view "${v.label}"`)))
299
306
  if (input.supersedes !== undefined && !existsSync(join(dir, input.supersedes, 'item.json'))) throw new Error(`--supersedes ${input.supersedes}: no such card in ${dir}`)
300
307
  if (input.supersedes !== undefined && readItem(dir, input.supersedes)?.item.origin.by !== 'agent')
@@ -347,13 +354,14 @@ export async function addItem(dir: string, input: AddInput): Promise<Item> {
347
354
  what: input.what.trim(),
348
355
  ...(topic ? { topic } : {}),
349
356
  ...(input.commit ? { commit: input.commit } : {}),
350
- views: loaded.map((v, n) => viewOf(v, n, input.marks?.[n])),
357
+ views: loaded.map((v, n) => viewOf(v, n, input.marks?.[n], given[n]?.note)),
351
358
  ...(input.supersedes ? { supersedes: input.supersedes } : {}),
352
359
  ...(answers.length > 0 ? { answers } : {}),
353
360
  ...(input.fresh && input.why ? { why: input.why.trim() } : {}),
354
361
  ...(input.supersedes && input.keeps?.trim() ? { keeps: input.keeps.trim() } : {}),
355
362
  ...(input.light ? { light: input.light } : {}),
356
363
  ...(input.wip ? { wip: { live: input.wip.live, frame: 1 } } : {}),
364
+ ...(input.choice ? { choice: true as const } : {}),
357
365
  }
358
366
  const problems = itemProblems(item)
359
367
  if (problems.length > 0) throw new Error(problems.join('; '))
@@ -383,6 +391,7 @@ export async function addView(dir: string, id: string, v: ViewInput, o: { marks?
383
391
  const x = readItem(dir, id)
384
392
  if (!x) throw new Error(`no card ${id}`)
385
393
  if (verdictOf(x)) throw new Error(`${id} has his answer already: a new version passes --supersedes ${id}`)
394
+ if (v.note !== undefined && v.gallery) throw new Error(`"${v.label}": a note goes on an image, a clip or a sound, never a gallery`)
386
395
  if (!v.label.trim()) throw new Error('add-view: the view needs its label')
387
396
  const loaded = await loadView(v, `the view "${v.label}"`)
388
397
  const n = x.item.views.length
@@ -391,7 +400,7 @@ export async function addView(dir: string, id: string, v: ViewInput, o: { marks?
391
400
  copyFiles(loaded, n, folder)
392
401
  await writePoster(loaded, n, folder)
393
402
  await writeWaves(loaded, n, folder)
394
- const item: Item = { ...x.item, views: [...x.item.views, viewOf(loaded, n, o.marks)], ...(x.item.light || o.light ? { light: 'review' as const } : {}) }
403
+ const item: Item = { ...x.item, views: [...x.item.views, viewOf(loaded, n, o.marks, v.note)], ...(x.item.light || o.light ? { light: 'review' as const } : {}) }
395
404
  writeJson(join(folder, 'item.json'), item)
396
405
  return item
397
406
  }
@@ -11,10 +11,26 @@ export interface Fact {
11
11
  value: string
12
12
  }
13
13
 
14
- /** a link the hub opens */
14
+ /** a link the hub opens. `host`: a page of the tool that frames the hub (pkm-multi's admin), its url relative to it:
15
+ * in a frame it is posted to the host (`{ shipkit: 'open', url }`), alone it opens in a new tab */
15
16
  export interface Link {
16
17
  label: string
17
18
  url: string
19
+ host?: boolean
20
+ }
21
+
22
+ /** how a part's change reads: new, changed, gone or the same */
23
+ export type PartTone = 'new' | 'changed' | 'gone' | 'same'
24
+
25
+ /** one part of a subject the hub draws as a card of its own: a message of a post, a page of a script. `icon` an image
26
+ * url (a data url carries a logo), `media` a repo relative file the hub serves beside the text, `badge` its change */
27
+ export interface ViewPart {
28
+ label: string
29
+ detail?: string
30
+ icon?: string
31
+ badge?: { text: string; tone: PartTone }
32
+ media?: string
33
+ text: ViewText
18
34
  }
19
35
 
20
36
  /** a text the hub shows, or a change of one shown as a diff */
@@ -30,6 +46,8 @@ export interface KindDef<S extends Subject = Subject> extends SpecKind {
30
46
  /** a url the hub opens in a frame: the subject live */
31
47
  live?(x: ReviewItem): string | null
32
48
  text?(x: ReviewItem): ViewText | null
49
+ /** the subject in parts, each a card: a post's messages, with their logo, media and change */
50
+ parts?(x: ReviewItem): ViewPart[]
33
51
  /** the `sk review add` flags this kind reads, each with its help line: `--where <zone>:<x>,<y>` */
34
52
  flags?: Record<string, string>
35
53
  /** the subject (and its topic) a card of this kind gets from those flags, every flag as the list of its values;
@@ -18,8 +18,8 @@ Read the matching page before touching a surface:
18
18
 
19
19
  | read when touching… | page |
20
20
  |---|---|
21
- | any source file | `agent/coding.md` |
22
- | recent changes and why | `agent/changelog.md` |{{CLI_ROW}}
21
+ | any source file | → agent/coding.md |
22
+ | recent changes and why | → agent/changelog.md |{{CLI_ROW}}
23
23
 
24
24
  Grow this table: a subsystem, a service, a runbook each earn their own page,
25
25
  cross-linked from here.
@@ -236,7 +236,7 @@ export function installedLib(root: string): { version?: string; exports?: Record
236
236
  /** the agent index lines that load the essentials page and list the full one */
237
237
  const cliIndexVars = (name: string, cli: boolean): { CLI_INCLUDE: string; CLI_ROW: string } => ({
238
238
  CLI_INCLUDE: cli ? `\n@${name}-essentials.md` : '',
239
- CLI_ROW: cli ? `\n| every command of the project CLI | \`${cliPages(name).all}\` |` : '',
239
+ CLI_ROW: cli ? `\n| every command of the project CLI | → ${cliPages(name).all} |` : '',
240
240
  })
241
241
 
242
242
  /** an ADOPTED repo asking for the project CLI (`sk start <name> --cli`): the
@@ -257,7 +257,7 @@ export function adoptCli(root: string, opts: { name: string; print?: (line: stri
257
257
  addScripts(root, written, p, opts.name, false)
258
258
  const index = join(root, 'agent/index.md')
259
259
  const include = `@${opts.name}-essentials.md`
260
- if (existsSync(index) && !readFileSync(index, 'utf8').split('\n').includes(include)) p(`next: in agent/index.md, load the everyday commands with the line ${include} and list \`${cliPages(opts.name).all}\` in its table`)
260
+ if (existsSync(index) && !readFileSync(index, 'utf8').split('\n').includes(include)) p(`next: in agent/index.md, load the everyday commands with the line ${include} and list → ${cliPages(opts.name).all} in its table`)
261
261
  const deps = { ...(pkg as { dependencies?: object }).dependencies, ...(pkg as { devDependencies?: object }).devDependencies }
262
262
  p(`ok: project CLI written: ${written.join(', ')} — run \`bun run cli\``)
263
263
  if (!('rvlib-shipkit' in deps)) p('next: bun add -d rvlib-shipkit (the CLI imports rvlib-shipkit/cli-kit)')
@@ -10,6 +10,11 @@ import { clampCap, DEFAULT_CAP, DEFAULT_MINUTES, idleRow, WINDOW_MINUTES, type A
10
10
  export { ageShort, CAP_MAX, CAP_MIN, clampCap, DEFAULT_CAP, dirtWords, filesWords, upstreamWords, windowWords } from '../../home/activity-shape.ts'
11
11
  import type { HomeSt } from './home-st.ts'
12
12
  import { ownTrouble, reach } from './reach.ts'
13
+ import type { PushResult } from '../../fleet/push-fleet.ts'
14
+
15
+ /** the repos holding commits no remote has: ahead of their upstream, or on a branch with none */
16
+ export const unpushedCount = (rows: Pick<ActivityRow, 'ahead' | 'upstream' | 'branch'>[]): number =>
17
+ rows.filter((r) => (r.ahead ?? 0) > 0 || (r.upstream == null && r.branch != null)).length
13
18
 
14
19
  export const ACTIVITY_POLL_MS = 60_000
15
20
  const MINUTES_KEY = 'home:activity:minutes'
@@ -38,6 +43,10 @@ function keep(key: string, n: number): void {
38
43
  export class ActivitySt {
39
44
  data: ActivityAnswer | null = null
40
45
  error = ''
46
+ /** the push button is running */
47
+ pushing = false
48
+ /** what the last push said, one line a repo that needed it */
49
+ pushed: PushResult[] | null = null
41
50
  minutes: number = keptMinutes()
42
51
  /** the most commits a repo shows before a click shows the rest */
43
52
  cap: number = clampCap(kept(CAP_KEY) ?? DEFAULT_CAP)
@@ -118,6 +127,28 @@ export class ActivitySt {
118
127
  this.st.select(name)
119
128
  }
120
129
 
130
+ /** repos holding commits no remote has */
131
+ get unpushed(): number {
132
+ return unpushedCount(this.data?.rows ?? [])
133
+ }
134
+
135
+ /** push every house repo that needs it, through the daemon (fleet/push-fleet.ts), then read git again */
136
+ async pushAll(): Promise<void> {
137
+ if (this.pushing) return
138
+ runInAction(() => { this.pushing = true; this.pushed = null })
139
+ try {
140
+ const r = await reach('/api/home/push-all', { method: 'POST', signal: AbortSignal.timeout(180_000) })
141
+ if (!r.ok) throw new Error(`${r.status} ${await r.text()}`)
142
+ const res = (await r.json()) as PushResult[]
143
+ runInAction(() => { this.pushed = res.filter((x) => x.action !== 'none'); this.error = '' })
144
+ } catch (e) {
145
+ runInAction(() => { this.error = ownTrouble(e, this.st.machine, (m) => `push every repo: ${m}`) })
146
+ } finally {
147
+ runInAction(() => { this.pushing = false })
148
+ void this.poll(true)
149
+ }
150
+ }
151
+
121
152
  /** the refresh button: a new git pass now, whatever the countdown says */
122
153
  refresh(): void {
123
154
  if (!this.loading) void this.poll(true)
@@ -189,6 +189,12 @@ export const ActivityView = observer(({ app }: { app: HomeSt }) => {
189
189
  <span className="acdim">commits</span>
190
190
  </label>
191
191
  <Next app={app} />
192
+ {st.unpushed > 0 || st.pushing ? (
193
+ <button type="button" className="acpill acpush" disabled={st.pushing} onClick={() => void st.pushAll()}
194
+ title="git push in every house repo with unpushed commits; never a force, a repo that fell behind waits for a pull (sk fleet push does the same)">
195
+ {st.pushing ? 'pushing…' : `↑ push ${st.unpushed} unpushed`}
196
+ </button>
197
+ ) : null}
192
198
  {d == null ? <span className="acdim">reading every repo…</span> : (
193
199
  <>
194
200
  <span className="acpill">{busy.length} busy</span>
@@ -198,6 +204,9 @@ export const ActivityView = observer(({ app }: { app: HomeSt }) => {
198
204
  )}
199
205
  </div>
200
206
  {st.error !== '' ? <div className="hnote">{st.error}</div> : null}
207
+ {st.pushed == null || st.pushed.length === 0 ? null : (
208
+ <div className="acpushed">{st.pushed.map((p) => <div key={p.name} className={p.ok ? 'ok' : p.action === 'skip' ? 'skip' : 'fail'}>{p.line}</div>)}</div>
209
+ )}
201
210
  {d != null && busy.length === 0 ? <div className="acdim">nothing committed or uncommitted in the last {windowWords(st.minutes)}</div> : null}
202
211
  {busy.length + idle.length === 0 ? null : (
203
212
  <table className={`actable${st.stale ? ' stale' : ''}`}>
@@ -72,3 +72,9 @@ button.acpill:disabled{cursor:default}
72
72
  .acfork .hico{width:13px;height:13px}
73
73
  .acgrp:hover .acfork{opacity:1}
74
74
  .acfork:hover{color:var(--accent)}
75
+ .acpush{color:var(--ink);border-color:var(--accent)}
76
+ /* what the last push said: a line a repo, pushed in the ok colour, skipped dim, failed red */
77
+ .acpushed{margin:0 0 var(--s3);font:12px/1.5 ui-monospace,Menlo,monospace}
78
+ .acpushed .ok{color:var(--ok)}
79
+ .acpushed .skip{color:var(--dim)}
80
+ .acpushed .fail{color:var(--red)}
@@ -16,7 +16,7 @@ export class AdminSvc {
16
16
  /** the colours the preview keeps while a picker moves, so a drag draws a
17
17
  * few icons, not one per frame; null follows the repo's colours */
18
18
  held: Identity | null = null
19
- /** the repo has no png mark, or it would not draw: the app wears the default icon */
19
+ /** the repo has no mark, or it would not draw: the app wears the default icon */
20
20
  noIcon = false
21
21
  private rest: ReturnType<typeof setTimeout> | null = null
22
22
 
@@ -27,7 +27,7 @@ const IdentityBox = observer(({ app }: { app: RepoSt }) => {
27
27
  <div className="adicon">
28
28
  <img src={a.previewSrc} alt={`${name} app icon`} width={128} height={128} hidden={a.noIcon}
29
29
  onLoad={() => a.previewLoaded()} onError={() => a.previewFailed()} />
30
- {a.noIcon ? <span className="dim">no png mark (.shipkit/identity/icon.png, public/logo.png or public/favicon.png): its app wears the default icon</span> : null}
30
+ {a.noIcon ? <span className="dim">no mark (.shipkit/identity/icon.png or icon.svg, public/logo.png or public/favicon.png): its app wears the default icon</span> : null}
31
31
  </div>
32
32
  <div className="adtext">
33
33
  <b>identity</b>
@@ -36,7 +36,7 @@ export type HashSel =
36
36
  /** the checks one owner ships: shipkit's, or the repo's own */
37
37
  | { t: 'checks'; from: CheckOwner }
38
38
  /** the review cards, `id` the open one, null for the first the list shows */
39
- | { t: 'review'; id: string | null }
39
+ | { t: 'review'; id: string | null; q?: string }
40
40
  /** a trailer's cuts, played with their notes: a trailer folder and one of its cuts, null for the first; the note
41
41
  * open on it, and the notes filter when it is not the page's default (all) */
42
42
  | { t: 'trailer'; trailer: string | null; cut: string | null; note?: string | null; filter?: 'open' | 'all' | null }
@@ -55,6 +55,8 @@ export type ParsedHash = {
55
55
  /** a trailer address only: the note open, the notes filter */
56
56
  note?: string
57
57
  filter?: 'open' | 'all'
58
+ /** a review address only: its view and list filters, as `view=1&subject=place` */
59
+ q?: string
58
60
  }
59
61
 
60
62
  const ITEM = 'item:'
@@ -91,7 +93,8 @@ export function hashFor(sel: HashSel | null): string | null {
91
93
  case 'git': return GIT
92
94
  case 'when': return WHEN
93
95
  case 'checks': return `${CHECKS}${sel.from}`
94
- case 'review': return sel.id == null ? REVIEW : `${REVIEW}:${sel.id}`
96
+ // page:review:<id>?view=1&subject=place, the query only when it says something
97
+ case 'review': return `${sel.id == null ? REVIEW : `${REVIEW}:${sel.id}`}${sel.q ? `?${sel.q}` : ''}`
95
98
  case 'trailer': {
96
99
  // page:trailer:<trailer>/<cut>/<note>?filter=all, each part only when it says something
97
100
  const note = sel.trailer != null && sel.note ? `/${sel.cut ?? ''}/${sel.note}` : sel.cut == null ? '' : `/${sel.cut}`
@@ -111,7 +114,12 @@ export function parseHash(raw: string): ParsedHash {
111
114
  if (raw === PLAN) return { kind: 'plan', col: null, id: '', tools: null }
112
115
  if (raw === GIT) return { kind: 'git', col: null, id: '', tools: null }
113
116
  if (raw === WHEN) return { kind: 'when', col: null, id: '', tools: null }
114
- if (raw === REVIEW || raw.startsWith(`${REVIEW}:`)) return { kind: 'review', col: null, id: raw.slice(REVIEW.length + 1), tools: null }
117
+ if (raw === REVIEW || raw.startsWith(`${REVIEW}:`) || raw.startsWith(`${REVIEW}?`)) {
118
+ const qi = raw.indexOf('?')
119
+ const path = qi === -1 ? raw : raw.slice(0, qi)
120
+ const q = qi === -1 ? '' : raw.slice(qi + 1)
121
+ return { kind: 'review', col: null, id: path.slice(REVIEW.length + 1), tools: null, ...(q ? { q } : {}) }
122
+ }
115
123
  // the trailer folder rides in `col`, its cut in `id`
116
124
  if (raw === TRAILER || raw.startsWith(`${TRAILER}:`) || raw.startsWith(`${TRAILER}?`)) {
117
125
  const qi = raw.indexOf('?')
@@ -173,7 +181,7 @@ export function selOf(p: ParsedHash): HashSel | null {
173
181
  case 'git': return { t: 'git' }
174
182
  case 'when': return { t: 'when' }
175
183
  case 'checks': return { t: 'checks', from: p.id as CheckOwner }
176
- case 'review': return { t: 'review', id: p.id === '' ? null : p.id }
184
+ case 'review': return { t: 'review', id: p.id === '' ? null : p.id, ...(p.q ? { q: p.q } : {}) }
177
185
  case 'trailer': return { t: 'trailer', trailer: p.col, cut: p.id === '' ? null : p.id, ...(p.note ? { note: p.note } : {}), ...(p.filter ? { filter: p.filter } : {}) }
178
186
  }
179
187
  }
@@ -159,9 +159,15 @@ export class HomeSt {
159
159
  return r
160
160
  }
161
161
 
162
+ /** the page is the embed page: one repo's page in another tool's frame (embed.ts) */
163
+ get embedded(): boolean {
164
+ return this.base !== undefined
165
+ }
166
+
162
167
  boot(): void {
163
168
  this.machine?.boot()
164
- this.dock.boot()
169
+ // the embed page shows one repo's page alone: no dock, so no request for the house's runs
170
+ if (this.base === undefined) this.dock.boot()
165
171
  this.disposers.push(reaction(() => this.title, (t) => { document.title = t }, { fireImmediately: true }))
166
172
  // back, forward, a pasted link and a hand edited hash all arrive
167
173
  // here; fromUrl is idempotent, so the two events firing together
@@ -256,7 +256,7 @@ const downCard = (m: MachineSt): Card[] => {
256
256
 
257
257
  /** what is wrong with the machine itself, one card each: the machine not
258
258
  * answering, a crashed child of pm, a crashed global process (its switch
259
- * starts it again), a failed poll. While pm is silent its last answer says
259
+ * starts it again), a failed poll, the last good code running. While pm is silent its last answer says
260
260
  * nothing here */
261
261
  const machineCards = (m: MachineSt): Card[] => {
262
262
  const pm = m.pmDown !== '' ? null : m.pm
@@ -265,6 +265,8 @@ const machineCards = (m: MachineSt): Card[] => {
265
265
  ...(pm?.children ?? []).filter((c) => c.status === 'crashed' || c.status === 'down').map((c): Card => ({ key: `child:${c.id}`, tone: 'red', head: `${c.id} ${STATUS_WORD[c.status]}`, body: c.note ?? c.tail.at(-1) ?? '' })),
266
266
  ...(pm?.resources ?? []).filter((r) => r.status === 'crashed').map((r): Card => ({ key: `res:${r.id}`, tone: 'red', head: 'crashed', body: <ResourceChip m={m} r={r} /> })),
267
267
  ...(m.error !== '' ? [{ key: 'poll', tone: 'red' as const, body: m.error }] : []),
268
+ // the tree fails to load and the machine runs its last good code (core/last-good.ts)
269
+ ...(m.data?.held != null ? [{ key: 'held', tone: 'warn' as const, head: 'last good code', body: m.data.held }] : []),
268
270
  ]
269
271
  }
270
272
 
@@ -20,6 +20,8 @@
20
20
  --k1:#8ca8c7;--k2:#a892c9;--k3:#6fb0b8;--k4:#c792b4;--k5:#9198a1;--k6:#8f95cf;
21
21
  /* the review section's own hue: the one row of the tree meant to be seen first */
22
22
  --review:#e879d4;
23
+ /* a wiki page's refs (wikis-view.tsx): read on demand, loaded with the page */
24
+ --ref-lazy:#e8984a;--ref-eager:#6fa8f0;
23
25
  --wash:rgba(107,143,179,.15);--wash2:rgba(107,143,179,.11);--hair:rgba(107,143,179,.5);
24
26
  --errbg:#2a1a1a;--errline:#4a2c2c;--errink:#e3bebe;
25
27
  /* a running thing's wash, the green twin of errbg and errline */
@@ -39,6 +41,7 @@
39
41
  --ground:#e3ede6;--groundline:#c6d6cb;--tag:#eef2f6;
40
42
  --k1:#3f6690;--k2:#7055a3;--k3:#2b7c86;--k4:#a34e80;--k5:#5d646d;--k6:#4f56a8;
41
43
  --review:#c0279d;
44
+ --ref-lazy:#b8600c;--ref-eager:#1f5fc8;
42
45
  --wash:rgba(59,104,150,.12);--wash2:rgba(59,104,150,.08);--hair:rgba(59,104,150,.45);
43
46
  --errbg:#fbe9e8;--errline:#efc4c2;--errink:#8a2520;
44
47
  --okbg:#e6f3eb;--okline:#9ccdaf}
@@ -271,7 +274,8 @@ kbd{background:var(--panel2);border:1px solid var(--line);border-radius:4px;padd
271
274
  background:conic-gradient(var(--chk1) 90deg,var(--chk2) 0 180deg,var(--chk1) 0 270deg,var(--chk2) 0) 0/34px 34px}
272
275
  .stage img.roll{max-width:100%;max-height:100%;object-fit:contain;filter:drop-shadow(0 6px 20px var(--shadow));z-index:5;position:relative}
273
276
  .stage .ph,.stagewrap .ph{color:var(--dim)}
274
- .stagewrap{flex:1;min-height:0;overflow-y:auto;display:flex;align-items:flex-start;justify-content:center;padding:var(--s4)}
277
+ /* every page card of a repo sits in this column, read from the left, never centred */
278
+ .stagewrap{flex:1;min-height:0;overflow-y:auto;display:flex;align-items:flex-start;justify-content:flex-start;padding:var(--s4)}
275
279
  .stageaud{display:flex;flex-direction:column;align-items:center;gap:var(--s3);font-size:48px;padding:var(--s4)}
276
280
  .stageaud audio{width:min(480px,80vw)}
277
281
 
@@ -703,6 +707,39 @@ a.tok{color:var(--accent)}
703
707
  .gvframe{flex:1;min-height:0;position:relative;display:flex;border:1px solid var(--line);border-radius:3px;background:var(--bg)}
704
708
  .gvwait{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;gap:var(--s2);font-size:12px;color:var(--dim)}
705
709
  .graphview iframe{position:relative;flex:1;min-height:0;width:100%;border:0;background:transparent}
710
+ /* the graph and its preview panel (wikis-view.tsx) */
711
+ .gvbody{flex:1;min-height:0}
712
+ .gvbody .gvframe{height:100%}
713
+ /* the wiki's search bar, its hits dropped under it over the graph */
714
+ .wsearch{position:relative;flex:0 1 360px;min-width:160px;display:flex}
715
+ .wsearch input{flex:1;min-width:0;height:22px;background:var(--bg);color:var(--ink);border:1px solid var(--line);border-radius:999px;padding:0 var(--s3);font:inherit;font-size:12px}
716
+ .wsearch input:focus{outline:none;border-color:var(--accent)}
717
+ .wslist{position:absolute;top:calc(100% + 4px);left:0;z-index:30;width:min(560px,80vw);max-height:60vh;overflow-y:auto;padding:var(--s1) 0;background:var(--panel);border:1px solid var(--line);border-radius:6px;box-shadow:0 6px 20px var(--shadow)}
718
+ .wshit{padding:var(--s1) var(--s3);cursor:pointer}
719
+ .wshit.on,.wshit:hover{background:var(--sel)}
720
+ .wshead{display:flex;align-items:baseline;gap:var(--s2);min-width:0}
721
+ .wshead b{flex:none;font-size:12.5px}
722
+ .wspath{min-width:0;color:var(--dim);font:11px/1.4 ui-monospace,Menlo,monospace;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
723
+ .wssnip{color:var(--dim);font-size:11.5px;line-height:1.4;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
724
+ .wsnone{padding:var(--s1) var(--s3);color:var(--dim);font-size:12px}
725
+ /* the page open beside the graph */
726
+ .wprev{height:100%;display:flex;flex-direction:column;min-width:0;background:var(--panel);border:1px solid var(--line);border-radius:3px}
727
+ .wphead{flex:none;display:flex;align-items:center;gap:var(--s2);padding:var(--s2) var(--s3);border-bottom:1px solid var(--line);min-width:0}
728
+ .wptitle{flex:0 1 auto;min-width:0;font-size:13px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
729
+ .wphead .wspath{flex:1}
730
+ .wpplace{flex:none}
731
+ .wpplace button{padding:0 var(--s1)}
732
+ .wpplace .hico{width:14px;height:14px}
733
+ .wpbtn{flex:none;display:inline-flex;align-items:center;gap:var(--s1);height:22px;padding:0 var(--s2);border:1px solid var(--line);border-radius:999px;color:var(--dim);font-size:11.5px;text-decoration:none;background:transparent;cursor:pointer}
734
+ .wpbtn:hover{color:var(--ink);border-color:var(--accent)}
735
+ .wpbtn .hico{width:13px;height:13px}
736
+ .wpclose{flex:none;padding:0 var(--s2);font-size:14px;line-height:1.2}
737
+ /* the refs of a page: on demand (→, a link, a path) orange, loaded with it (@) blue and bold */
738
+ .wpbody a[data-ref=lazy]{color:var(--ref-lazy)}
739
+ .wpbody a[data-ref=lazy] code{color:var(--ref-lazy)}
740
+ .wpbody a[data-ref=eager]{color:var(--ref-eager);font-weight:700}
741
+ .wpbody{flex:1;min-height:0;overflow-y:auto;padding:var(--s2) var(--s4) var(--s4)}
742
+ .wpbody a[data-wiki]{text-decoration:underline;text-decoration-style:dotted;text-underline-offset:2px;cursor:pointer}
706
743
 
707
744
  /* ── comfy item view (comfy-ts's own panel fills the stage) ── */
708
745
  .comfyview{flex:1;min-height:0;display:flex;flex-direction:column;gap:var(--s2);padding:var(--s3) var(--s4) var(--s4)}
@@ -44,6 +44,12 @@ export const ICON = {
44
44
  sun: 'M10 6.5a3.5 3.5 0 1 0 .01 0M10 2v2M10 16v2M2 10h2M16 10h2M4.3 4.3l1.4 1.4M14.3 14.3l1.4 1.4M4.3 15.7l1.4-1.4M14.3 5.7l1.4-1.4',
45
45
  moon: 'M15.5 12.5A6.5 6.5 0 0 1 7.5 4.5a6.5 6.5 0 1 0 8 8z',
46
46
  auto: 'M10 3a7 7 0 1 0 .01 0M10 3v14M10 5.5h4M10 8.5h6M10 11.5h6M10 14.5h4',
47
+ // where the wiki preview sits: a frame with its panel's edge
48
+ paneLeft: 'M3 4h14v12H3zM8 4v12M4.5 7h2M4.5 9.5h2',
49
+ paneRight: 'M3 4h14v12H3zM12 4v12M13.5 7h2M13.5 9.5h2',
50
+ paneBottom: 'M3 4h14v12H3zM3 11h14M6 13.5h8',
51
+ copy: 'M7 7h9v10H7zM4 13V3h9',
52
+ editor: 'M13 3l4 3v8l-4 3-7-6 7-5zM3 7.5l3 2.5-3 2.5',
47
53
  // the project list's fold: a panel with its rail marked
48
54
  rail: 'M3 4h14v12H3zM7.5 4v12',
49
55
  // a process's own switches on the overview
@@ -16,10 +16,10 @@ export const LIST: Bounds = { key: 'hub:list-width', min: 140, max: 360, def: 20
16
16
  export const SIDEBAR: Bounds = { key: 'hub:sidebar-width', min: 150, max: 640, def: 330 }
17
17
 
18
18
  /** the rail's icon sizes, in px: the foot's button cycles them */
19
- export const RAIL_ICONS = { s: 18, m: 24, l: 32 } as const
19
+ export const RAIL_ICONS = { s: 24, m: 32, l: 40 } as const
20
20
  export type RailIcon = keyof typeof RAIL_ICONS
21
21
  /** the rail is its icon and this much on each side */
22
- const RAIL_PAD = 13
22
+ const RAIL_PAD = 6
23
23
  /** the project list folded: its icons only, a row's name in its hover card */
24
24
  export const railPx = (size: RailIcon): number => RAIL_ICONS[size] + 2 * RAIL_PAD
25
25
  const RAIL_KEY = 'hub:list-rail'
@@ -110,7 +110,7 @@ export class MachineSt {
110
110
  * `shipkit global-processes` prints) */
111
111
  get processGroups(): ProcessGroup[] {
112
112
  const silent = { ...(this.down === '' ? {} : { [this.downWho]: this.down }), ...(this.pmDown === '' ? {} : { pm: this.pmDown }) }
113
- return processGroups(this.pm, this.st.projects.list.map((p) => p.name), { daemonVersion: this.data?.version ?? null, silent })
113
+ return processGroups(this.pm, this.st.projects.list.map((p) => p.name), { daemonVersion: this.data?.version ?? null, daemonHeld: this.data?.held ?? null, silent })
114
114
  }
115
115
 
116
116
  resource(id: string): ResourceView | null {
@@ -70,3 +70,35 @@ export const renderReadme = (src: string, fileUrl: (rel: string) => string): str
70
70
  },
71
71
  exclusiveFilter: (frame) => frame.tag === 'img' && (frame.attribs.src ?? '') === '',
72
72
  })
73
+
74
+ /** a wiki page in the wikis page's preview: the card allowlist, and every ref
75
+ * the server resolved (`links`, token → node, graph-pages.ts) as an anchor
76
+ * that opens that page in place instead of a new tab, marked by its kind so
77
+ * the sheet tells them apart: `@` loads with the page (eager), `→`, a link or
78
+ * a path in a code span is read on demand (lazy, written `→ path` here
79
+ * whatever the page wrote) */
80
+ export const renderWikiPage = (src: string, links: Record<string, string>): string => {
81
+ const to = (t: string, eager = false): string | null => (links[t] == null ? null : `#wiki${eager ? '-eager' : ''}:${encodeURIComponent(links[t]!)}`)
82
+ // fences stay as written: a ref in a code block is an example, not a link
83
+ const linked = src.split(/(^```[\s\S]*?^```\s*$)/m).map((part, i) => i % 2 === 1 ? part : part
84
+ .replace(/\]\(([^)\s:]+\.md)\)/g, (m, t: string) => (to(t) == null ? m : `](${to(t)})`))
85
+ .replace(/→(\s*)([\w][\w./-]*\.md)\b/g, (m, sp: string, t: string) => (to(t) == null ? m : `→${sp}[${t}](${to(t)})`))
86
+ .replace(/(^|[\s(])@((?:\.{1,2}\/)*[\w][\w./-]*\.md)/gm, (m, pre: string, t: string) => (to(t, true) == null ? m : `${pre}[@${t}](${to(t, true)})`))
87
+ // a code span that is exactly a page's path: a lazy ref, its arrow added unless the page wrote one
88
+ .replace(/(→\s*)?(?<!\[)`([\w.][\w./-]*\.md)`/g, (m, arrow: string | undefined, t: string) => (to(t) == null ? m : `${arrow ?? '→ '}[\`${t}\`](${to(t)})`)),
89
+ ).join('')
90
+ return sanitizeHtml(marked.parse(linked) as string, {
91
+ allowedTags: [...CARD_TAGS, 'h5', 'h6', 'del'],
92
+ allowedAttributes: { a: ['href', 'target', 'rel', 'data-wiki', 'data-ref'] },
93
+ allowedSchemes: ['http', 'https'],
94
+ allowProtocolRelative: false,
95
+ transformTags: {
96
+ a: (tagName, attribs): sanitizeHtml.Tag => {
97
+ const href = attribs.href ?? ''
98
+ const ref = /^#wiki(-eager)?:(.*)$/.exec(href)
99
+ if (ref != null) return { tagName, attribs: { href, 'data-wiki': decodeURIComponent(ref[2]!), 'data-ref': ref[1] != null ? 'eager' : 'lazy' } }
100
+ return { tagName, attribs: { ...attribs, target: '_blank', rel: 'noreferrer' } }
101
+ },
102
+ },
103
+ })
104
+ }
@@ -69,8 +69,8 @@
69
69
  .pgi.h5{--c:var(--k5)}
70
70
  .pgi.h6{--c:var(--k6)}
71
71
  /* the row says three things and no more (projects-svc faceOf) */
72
- /* closed in VS Code: the icon and the name dim, the badge does not */
73
- .pjrow.dim img,.pjrow.dim .pgi,.pjrow.dim .pjname{opacity:.5}
72
+ /* closed in VS Code: the name dims, never the icon (the icon is how a repo is found, and the rail has no name) */
73
+ .pjrow.dim .pjname{opacity:.5}
74
74
  /* ⌘1 to ⌘9 select the first nine rows (keys.ts): the hint sits at the row's end */
75
75
  .pjkey{flex:none;font:10px/1 ui-monospace,Menlo,monospace;color:var(--dim);opacity:.6;background:none;border:0;padding:0}
76
76
  .pjrow:hover .pjkey,.pjrow.cur .pjkey,.pjpl:hover .pjkey,.pjpl.cur .pjkey{opacity:1}
@@ -128,12 +128,14 @@
128
128
  .pjlist.rail .pjname,.pjlist.rail .pjkey,.pjlist.rail .pjstep,.pjlist.rail .pgsortl,.pjlist.rail .pjarow span,.pjlist.rail .pgerr{display:none}
129
129
  .pjlist.rail .pjrow,.pjlist.rail .pjpl,.pjlist.rail .pjarow{justify-content:center;padding:0}
130
130
  /* the rail's icon size (layout-st RAIL_ICONS, the foot's S M L button): the repo icons and the row under them grow */
131
- .pjlist.rail .pjrow{height:calc(var(--ri,18px) + 10px)}
132
- .pjlist.rail .pjrow img,.pjlist.rail .pjrow .pgi{width:var(--ri,18px);height:var(--ri,18px)}
133
- .pjlist.rail .pjrow .pgi{font-size:calc(var(--ri,18px) * .55)}
131
+ .pjlist.rail .pjrow{height:calc(var(--ri,24px) + 6px)}
132
+ .pjlist.rail .pjrow img,.pjlist.rail .pjrow .pgi{width:var(--ri,24px);height:var(--ri,24px);border-radius:6px}
133
+ .pjlist.rail .pjrow .pgi{font-size:calc(var(--ri,24px) * .5)}
134
+ /* the app icon is a rounded plate already (core/app-icon.ts) */
135
+ .pjlist.rail .pjrow img.pjapp{border-radius:0}
134
136
  .pjsize{font:600 10px/1 ui-monospace,Menlo,monospace}
135
- .pjlist.rail .pga{position:absolute;top:var(--s0);left:50%;margin-left:var(--s1);min-width:14px;height:14px;padding:0 var(--s0);border-radius:7px;font-size:9px;line-height:14px}
136
- .pjlist.rail .pgd,.pjlist.rail .pgw{position:absolute;bottom:var(--s1);left:50%;margin-left:var(--s1)}
137
+ .pjlist.rail .pga{position:absolute;top:0;right:calc(50% - var(--ri,24px) / 2 - 4px);min-width:14px;height:14px;padding:0 var(--s0);border-radius:7px;font-size:9px;line-height:14px}
138
+ .pjlist.rail .pgd,.pjlist.rail .pgw{position:absolute;bottom:var(--s0);right:calc(50% - var(--ri,24px) / 2 - 2px)}
137
139
  .pjlist.rail .pjfoot{flex-direction:column;height:auto;padding:var(--s1) 0}
138
140
  .pjlist.rail .pjfoot button{justify-content:center;width:28px;padding:0}
139
141
  .pjlist.rail .pjarbtn span{display:none}
@@ -3,7 +3,7 @@
3
3
  // the shown repo's row marked in its identity colour. Its width is the
4
4
  // panel the page gives it (hub-app.tsx, react-resizable-panels). A row says
5
5
  // three things and no more (faceOf): the open asks as its one badge, a dim
6
- // icon while VS Code does not have the folder open, a thin edge breathing
6
+ // name (never the icon) while VS Code does not have the folder open, a thin edge breathing
7
7
  // while Claude sessions run in it; on the home, a dot while its hub starts
8
8
  // or crashed and a mark while a global process it uses is off. Everything
9
9
  // else is the hover card, placed by floating-ui beside the row. A click
@@ -72,6 +72,7 @@ export const Row = observer(({ app, p, i }: { app: ListHost; p: Project; i: numb
72
72
  const warn = s.warnOf(p.name)
73
73
  // the shown repo's row is marked in its own colour
74
74
  const mark = cur ? (idVars(s.identityOf(p.name)) as CSSProperties) : undefined
75
+ // folded, the rail wears the repo's app icon: its mark on its identity plate
75
76
  return (
76
77
  <>
77
78
  <button ref={f.refs.setReference} className={cls} aria-label={p.name} style={mark} draggable
@@ -95,7 +96,9 @@ export const Row = observer(({ app, p, i }: { app: ListHost; p: Project; i: numb
95
96
  },
96
97
  onDragEnd: () => s.dragEnd(),
97
98
  })}>
98
- <Face p={p} px={ROW_ICON} />
99
+ {app.layout?.listRail === true && p.appIcon != null
100
+ ? <img className="pjapp" src={p.appIcon} alt="" width={ROW_ICON} height={ROW_ICON} draggable={false} />
101
+ : <Face p={p} px={ROW_ICON} />}
99
102
  <span className="pjname">{p.name}</span>
100
103
  {face.badge != null ? <b className="pga">{face.badge}</b> : null}
101
104
  {face.busy ? <i className="pgs" /> : null}
@@ -134,8 +134,9 @@ export function sortNames(list: Project[], mode: SortMode): string[] {
134
134
  export type HubDot = 'starting' | 'crashed' | null
135
135
 
136
136
  /** what the icon face shows, and nothing more: at most ONE badge (the open
137
- * asks, the only fact that needs a human's hands), dim while VS Code does
138
- * not have the folder open, and a pulsing edge while Claude sessions run */
137
+ * asks, the only fact that needs a human's hands), a dim name while VS Code
138
+ * does not have the folder open (the icon stays whole), and a pulsing edge
139
+ * while Claude sessions run */
139
140
  export type Face = { badge: number | null; dim: boolean; busy: boolean }
140
141
  export const faceOf = (p: Project): Face => ({
141
142
  badge: p.asks > 0 ? p.asks : null,