rvlib-shipkit 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,38 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.7.0 — 2026-07-22
4
+
5
+ New (the hub becomes THE dashboard; autonomy over nagging):
6
+
7
+ - hub UI rebuilt as a **Bun-native React app** (Bun.serve + HTML import, no
8
+ vite, no build step; MobX store). The old template-string landing +
9
+ status pages are DELETED.
10
+ - `/` IS the **attention dashboard**: failing checks (STATUS.md), open
11
+ asks, missing/unpicked/stale assets, contract gaps, planned posts — every
12
+ line links to its owning surface. Data via `/api/attention.json` +
13
+ `/api/status.json`; sources are failure-isolated (a broken collector
14
+ reports itself, never blanks the page).
15
+ - a thin left **rail** (⚡🕸🤖🖼📊, active-tab marked, red dot when
16
+ anything red) on EVERY hub page — React pages natively, legacy pages
17
+ (graph pages, studio SPA) via server-side injection until they migrate.
18
+ - **derived assets**: `from: ['parentId']` on an AssetSpec + the
19
+ `gemini({ model? })` backend (plain fetch, key from env GEMINI_API_KEY —
20
+ unset dies loud). The asset's prompt is the EDIT instruction; parents'
21
+ PICKED rolls feed generation as context images. Unknown refs, cycles and
22
+ non-gemini `from` die at defineKit. Each roll records its parent picks in
23
+ its meta sidecar.
24
+ - **autonomy contract — machine-clearable red clears itself**: re-picking a
25
+ parent AUTO-enqueues every now-stale child (no button, no nag; spend
26
+ gated on the intentful pick). Identity self-heals the same way: brand.ts
27
+ content hash in `.rv-identity/brand.hash`; `assets check` and the
28
+ attention collector detect drift and RE-RENDER public/* themselves —
29
+ only a missing pick still reaches a human. The old
30
+ "WARN outputs missing — run identity" line is gone.
31
+ - fill/add walk parents-first (topo) so derived children never generate
32
+ before their parents; a blocked derived asset FAILs naming the parent.
33
+ - studio: stale = orange sidebar dot; `/api/state` gains
34
+ `derived`/`stale` per asset.
35
+
3
36
  ## 0.6.0 — 2026-07-22
4
37
 
5
38
  New (wave 3 of the shipkit expansion — media + manual assets):
@@ -70,7 +103,7 @@ New (wave 1 of the shipkit expansion — uniform repo setup):
70
103
 
71
104
  - fix: `react-dom` was missing from dependencies AND the CLI entry loaded
72
105
  the react/ink chain eagerly — every command crashed in a fresh consumer
73
- repo without react-dom (found by the rabbits-vs-sheeps swap in the field,
106
+ repo without react-dom (found by a consumer-repo swap in the field,
74
107
  confirmed against the 0.3.0 tarball). tui now lazy-loads its body; deps
75
108
  carry react-dom. Proven by a fresh-repo install test (init → ci green in
76
109
  a react-less repo).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rvlib-shipkit",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "All-in-one repo companion: ci gate (task DAG), auto-discovered health checks, release-contract fulfillness, asks queue, md-wiki tools, asset atelier, live tui",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -20,7 +20,8 @@
20
20
  },
21
21
  "devDependencies": {
22
22
  "@types/bun": "^1.3.14",
23
- "@types/react": "^19.2.17"
23
+ "@types/react": "^19.2.17",
24
+ "@types/react-dom": "^19.2.3"
24
25
  },
25
26
  "dependencies": {
26
27
  "@napi-rs/canvas": "^1.0.2",
@@ -0,0 +1,83 @@
1
+ // Derived assets — `from: ['parentId']` on an AssetSpec: the parents'
2
+ // PICKED rolls feed generation as context images; each generated roll
3
+ // records the parent picks in its meta sidecar; a parent re-pick flips
4
+ // children to STALE. Shared by the studio queue and the fill/gen CLI.
5
+ import { join } from 'path'
6
+ import { findAsset, type FoundAsset, type KitConfig } from '../kit-config.ts'
7
+ import { assetDir, listRolls, metaPath, readPick } from '../roll-workspace.ts'
8
+ import { readJson, updateJson } from '../json-store.ts'
9
+ import type { ContextImage } from './gemini-gen.ts'
10
+
11
+ const MIME: Record<string, string> = { png: 'image/png', webp: 'image/webp' }
12
+
13
+ export type ParentContext = { images: ContextImage[]; parents: Record<string, string> }
14
+
15
+ export const parentIdsOf = (f: FoundAsset): string[] => f.asset.from ?? []
16
+
17
+ /** Parents' picked rolls as base64 context — or the list of blockers
18
+ * (unpicked/unknown parents), never a silent partial. */
19
+ export async function resolveParentContext(kit: KitConfig, workspace: string, f: FoundAsset): Promise<ParentContext | { missing: string[] }> {
20
+ const missing: string[] = []
21
+ const images: ContextImage[] = []
22
+ const parents: Record<string, string> = {}
23
+ for (const id of parentIdsOf(f)) {
24
+ const parent = findAsset(kit, id)
25
+ if (parent == null) { missing.push(`${id} (not in kit)`); continue }
26
+ const dir = assetDir(workspace, id)
27
+ const pick = (await readPick(dir)).pick
28
+ if (pick == null) { missing.push(`${id} (no pick)`); continue }
29
+ const ext = pick.split('.').at(-1)!
30
+ const file = Bun.file(join(dir, pick))
31
+ if (!(await file.exists())) { missing.push(`${id} (picked roll ${pick} vanished)`); continue }
32
+ images.push({ mime: MIME[ext] ?? 'image/png', base64: Buffer.from(await file.arrayBuffer()).toString('base64') })
33
+ parents[id] = pick
34
+ }
35
+ if (missing.length > 0) return { missing }
36
+ return { images, parents }
37
+ }
38
+
39
+ /** Stamp the parent picks onto every roll of this seed (v<seed>.*) — called
40
+ * after a successful derived generation. */
41
+ export async function recordParents(destDir: string, seed: number, parents: Record<string, string>): Promise<void> {
42
+ if (Object.keys(parents).length === 0) return
43
+ for (const roll of listRolls(destDir).filter((r) => r.startsWith(`v${seed}.`) || r.startsWith(`v${seed}-`)))
44
+ await updateJson<{ parents?: Record<string, string> }>(metaPath(destDir, roll), {}, (m) => { m.parents = parents })
45
+ }
46
+
47
+ /** A derived asset is STALE when its PICKED roll's recorded parent picks
48
+ * differ from the parents' CURRENT picks (or were never recorded). An
49
+ * unpicked asset is never stale — it's just unpicked. */
50
+ export async function isStale(kit: KitConfig, workspace: string, f: FoundAsset): Promise<boolean> {
51
+ const parentIds = parentIdsOf(f)
52
+ if (parentIds.length === 0) return false
53
+ const dir = assetDir(workspace, f.asset.id)
54
+ const pick = (await readPick(dir)).pick
55
+ if (pick == null) return false
56
+ const meta = await readJson<{ parents?: Record<string, string> }>(metaPath(dir, pick), {})
57
+ for (const id of parentIds) {
58
+ const current = (await readPick(assetDir(workspace, id))).pick
59
+ if (current == null) continue // parent lost its pick — child can't regen anyway; blocker surfaces at generate
60
+ if (meta.parents?.[id] !== current) return true
61
+ }
62
+ return false
63
+ }
64
+
65
+ /** Parents-first order for batch generation (fill/add, regen-stale).
66
+ * Cycles are rejected at defineKit; a stray one here throws loud. */
67
+ export function topoSort(assets: FoundAsset[]): FoundAsset[] {
68
+ const byId = new Map(assets.map((f) => [f.asset.id, f]))
69
+ const done = new Set<string>()
70
+ const visiting = new Set<string>()
71
+ const out: FoundAsset[] = []
72
+ const visit = (f: FoundAsset): void => {
73
+ if (done.has(f.asset.id)) return
74
+ if (visiting.has(f.asset.id)) throw new Error(`derived-asset cycle through '${f.asset.id}'`)
75
+ visiting.add(f.asset.id)
76
+ for (const id of parentIdsOf(f)) { const p = byId.get(id); if (p != null) visit(p) }
77
+ visiting.delete(f.asset.id)
78
+ done.add(f.asset.id)
79
+ out.push(f)
80
+ }
81
+ for (const f of assets) visit(f)
82
+ return out
83
+ }
@@ -0,0 +1,77 @@
1
+ // Gemini image backend — plain fetch, no SDK. THE backend for
2
+ // edit-from-parent flows (context images = parents' picked rolls); comfy
3
+ // stays for from-scratch art. Key from env (default GEMINI_API_KEY) — a
4
+ // missing key dies loud at generation, never silently.
5
+ import { mkdirSync } from 'fs'
6
+ import { join } from 'path'
7
+
8
+ export type GeminiModel = 'flash' | 'pro'
9
+ export const GEMINI_MODELS: Record<GeminiModel, string> = {
10
+ flash: 'gemini-2.5-flash-image',
11
+ pro: 'gemini-3-pro-image-preview',
12
+ }
13
+
14
+ export type GeminiOpts = {
15
+ /** env var carrying the API key (default GEMINI_API_KEY) */
16
+ apiKeyEnv?: string
17
+ /** test seam / self-host — default the real Google endpoint */
18
+ host?: string
19
+ }
20
+
21
+ export type ContextImage = { mime: string; base64: string }
22
+
23
+ const HOST = 'https://generativelanguage.googleapis.com'
24
+
25
+ export function geminiKey(o: GeminiOpts): string {
26
+ const env = o.apiKeyEnv ?? 'GEMINI_API_KEY'
27
+ const key = process.env[env]
28
+ if (key == null || key === '')
29
+ throw new Error(`env ${env} unset — the gemini() backend needs it (mint: aistudio.google.com/apikey, export it in the repo's scripts)`)
30
+ return key
31
+ }
32
+
33
+ type GenerateContentResponse = {
34
+ candidates?: { content?: { parts?: { inlineData?: { mimeType?: string; data?: string }; text?: string }[] } }[]
35
+ error?: { message?: string }
36
+ }
37
+
38
+ /** One generation: prompt + context images → image roll(s) saved as
39
+ * <destDir>/<name>.png (suffix -2… on multi-image). Returns saved paths. */
40
+ export async function generateGemini(
41
+ o: GeminiOpts,
42
+ args: { prompt: string; model?: GeminiModel; destDir: string; name: string; context: ContextImage[] },
43
+ ): Promise<string[]> {
44
+ const key = geminiKey(o)
45
+ const model = GEMINI_MODELS[args.model ?? 'flash']
46
+ const res = await fetch(`${o.host ?? HOST}/v1beta/models/${model}:generateContent`, {
47
+ method: 'POST',
48
+ headers: { 'Content-Type': 'application/json', 'x-goog-api-key': key },
49
+ body: JSON.stringify({
50
+ contents: [{ parts: [{ text: args.prompt }, ...args.context.map((c) => ({ inlineData: { mimeType: c.mime, data: c.base64 } }))] }],
51
+ }),
52
+ })
53
+ if (!res.ok) throw new Error(`gemini ${model} → ${res.status}: ${(await res.text()).slice(0, 200)}`)
54
+ const body = (await res.json()) as GenerateContentResponse
55
+ const parts = body.candidates?.[0]?.content?.parts ?? []
56
+ mkdirSync(args.destDir, { recursive: true })
57
+ const saved: string[] = []
58
+ for (const p of parts) {
59
+ if (p.inlineData?.data == null) continue
60
+ // roll names must satisfy the workspace regex (v<seed>.png|webp) or
61
+ // the studio never lists them — an invisible paid roll is the worst
62
+ // failure mode. Unexpected mimes die loud; extra images get their
63
+ // own seed-valid names instead of an unlistable -2 suffix.
64
+ const mime = p.inlineData.mimeType ?? 'image/png'
65
+ const ext = mime === 'image/webp' ? 'webp' : mime === 'image/png' ? 'png' : null
66
+ if (ext == null) throw new Error(`gemini returned mime '${mime}' — only png/webp land as rolls (got ${parts.length} part(s))`)
67
+ const name = saved.length === 0 ? args.name : `v${Math.floor(Math.random() * 2_000_000_000) + 1}`
68
+ const dest = join(args.destDir, `${name}.${ext}`)
69
+ await Bun.write(dest, Buffer.from(p.inlineData.data, 'base64'))
70
+ saved.push(dest)
71
+ }
72
+ if (saved.length === 0) {
73
+ const text = parts.find((p) => p.text != null)?.text?.slice(0, 160)
74
+ throw new Error(`gemini ${model} returned no image${text != null ? ` — model said: ${text}` : ''} (blocked prompt? wrong model?)`)
75
+ }
76
+ return saved
77
+ }
@@ -8,6 +8,8 @@ import { mkdirSync } from 'fs'
8
8
  import { join } from 'path'
9
9
  import type { FoundAsset } from '../kit-config.ts'
10
10
  import { generate, type OnProgress } from './comfy-gen.ts'
11
+ import { recordParents, type ParentContext } from './derived.ts'
12
+ import { generateGemini } from './gemini-gen.ts'
11
13
  import { loadWorkflows, workflowFor, type Workflow } from './workflow-loader.ts'
12
14
 
13
15
  /** roll canvas master size (square) — favicon/logo/sprites derive from it */
@@ -20,12 +22,21 @@ export type ComfyResolver = () => Promise<ComfyRuntime>
20
22
  * v<seed>.<ext> (comfy). `resolveComfy` is only awaited for comfy backends. */
21
23
  export async function generateRoll(
22
24
  found: FoundAsset,
23
- args: { prompt: string; seed: number; destDir: string; onProgress?: OnProgress },
25
+ args: { prompt: string; seed: number; destDir: string; onProgress?: OnProgress; context?: ParentContext },
24
26
  resolveComfy: ComfyResolver,
25
27
  ): Promise<void> {
26
28
  const gen = found.kind.generate
27
29
  if (gen.backend === 'manual')
28
30
  throw new Error(`'${found.asset.id}' is a manual asset — upload a file in the studio (button or drag-drop), nothing generates it`)
31
+ if (gen.backend === 'gemini') {
32
+ // derived assets NEED their parents' picked rolls — the caller
33
+ // resolves them (queue/CLI) so a blocker names the parent, loud
34
+ if ((found.asset.from?.length ?? 0) > 0 && args.context == null)
35
+ throw new Error(`'${found.asset.id}' derives from ${found.asset.from!.join(', ')} — parent context missing (are the parents picked?)`)
36
+ await generateGemini({}, { prompt: args.prompt, model: gen.model, destDir: args.destDir, name: `v${args.seed}`, context: args.context?.images ?? [] })
37
+ if (args.context != null) await recordParents(args.destDir, args.seed, args.context.parents)
38
+ return
39
+ }
29
40
  if (gen.backend === 'canvas') {
30
41
  const { createCanvas } = await import('@napi-rs/canvas')
31
42
  const c = createCanvas(CANVAS_SIZE, CANVAS_SIZE)
@@ -10,9 +10,13 @@ import type { GroundRect } from './panels/geometry-rects.ts'
10
10
 
11
11
  export type AssetSpec = {
12
12
  id: string
13
- /** FULL prompt text incl. style — the studio shows/edits exactly what generates */
13
+ /** FULL prompt text incl. style — the studio shows/edits exactly what
14
+ * generates. On a DERIVED asset it is the EDIT instruction. */
14
15
  prompt: string
15
16
  label?: string
17
+ /** derived asset: parent asset ids whose PICKED rolls feed generation as
18
+ * context images (gemini kinds only). Parent re-pick auto-regens. */
19
+ from?: string[]
16
20
  /** freeform data passed through to the ship hook */
17
21
  ship?: unknown
18
22
  }
@@ -23,10 +27,13 @@ export type CanvasGen = { backend: 'canvas'; draw: (ctx: unknown, asset: AssetSp
23
27
  * manual asset with no rolls is RED + FAILs `shipkit assets check`. The
24
28
  * asset's `prompt` is the curation brief. */
25
29
  export type ManualGen = { backend: 'manual' }
26
- export type GeneratorSpec = ComfyGen | CanvasGen | ManualGen
30
+ /** Gemini image backend (edit-from-parent flows; comfy stays for from-scratch). */
31
+ export type GeminiGen = { backend: 'gemini'; model?: 'flash' | 'pro' }
32
+ export type GeneratorSpec = ComfyGen | CanvasGen | ManualGen | GeminiGen
27
33
  export const comfy = (p?: { workflow?: string; bg?: string }): ComfyGen => ({ backend: 'comfy', workflow: p?.workflow, bg: p?.bg })
28
34
  export const canvas = (draw: CanvasGen['draw']): CanvasGen => ({ backend: 'canvas', draw })
29
35
  export const manual = (): ManualGen => ({ backend: 'manual' })
36
+ export const gemini = (p?: { model?: 'flash' | 'pro' }): GeminiGen => ({ backend: 'gemini', model: p?.model })
30
37
 
31
38
  export type Media = 'image' | 'audio'
32
39
  export const mediaOf = (kind: Kind): Media => kind.media ?? 'image'
@@ -130,6 +137,9 @@ export function validateKit(k: KitConfig): string[] {
130
137
  errs.push(`kind '${kind.id}': media 'audio' supports only the manual() backend today (generation backends are image-only)`)
131
138
  if (kind.media === 'audio' && (kind.meta?.panels ?? []).some((p) => p.panel === 'geometry'))
132
139
  errs.push(`kind '${kind.id}': audio kinds take no geometry panel`)
140
+ for (const a of kind.assets ?? [])
141
+ if ((a.from?.length ?? 0) > 0 && kind.generate?.backend !== 'gemini')
142
+ errs.push(`kind '${kind.id}': asset '${a.id}' derives (from:) but the backend is '${kind.generate?.backend}' — only gemini() takes context images`)
133
143
  if (kind.ship?.fn != null && kind.ship?.via != null) errs.push(`kind '${kind.id}': ship.fn and ship.via are two hooks for one pick — keep one`)
134
144
  if (!Array.isArray(kind.assets) || kind.assets.length === 0) errs.push(`kind '${kind.id}' has no assets — a silent no-op kind is a config mistake`)
135
145
  for (const a of kind.assets ?? []) {
@@ -148,6 +158,19 @@ export function validateKit(k: KitConfig): string[] {
148
158
  errs.push(`kind '${kind.id}' panel '${p.id}': script must be repo-root-relative without '..' (got '${p.script}')`)
149
159
  }
150
160
  }
161
+ // derived-asset graph: every from-ref must exist, and the graph is a DAG
162
+ const fromOf = new Map<string, string[]>()
163
+ for (const kind of k.kinds ?? []) for (const a of kind.assets ?? []) fromOf.set(a.id, a.from ?? [])
164
+ for (const [id, refs] of fromOf) for (const r of refs) if (!fromOf.has(r)) errs.push(`asset '${id}' derives from unknown asset '${r}'`)
165
+ const state = new Map<string, 'visiting' | 'done'>()
166
+ const visit = (id: string, path: string[]): void => {
167
+ if (state.get(id) === 'done') return
168
+ if (state.get(id) === 'visiting') { errs.push(`derived-asset cycle: ${[...path, id].join(' → ')}`); return }
169
+ state.set(id, 'visiting')
170
+ for (const r of fromOf.get(id) ?? []) if (fromOf.has(r)) visit(r, [...path, id])
171
+ state.set(id, 'done')
172
+ }
173
+ for (const id of fromOf.keys()) visit(id, [])
151
174
  return errs
152
175
  }
153
176
 
@@ -5,6 +5,7 @@ import { existsSync } from 'fs'
5
5
  import { join, relative } from 'path'
6
6
  import { randomSeed } from './gen/comfy-gen.ts'
7
7
  import { comfyResolver, generateRoll, type ComfyResolver } from './gen/generate-roll.ts'
8
+ import { parentIdsOf, resolveParentContext, topoSort, type ParentContext } from './gen/derived.ts'
8
9
  import { loadWorkflows } from './gen/workflow-loader.ts'
9
10
  import { allAssets, findAsset, loadKit, type ResolvedKit } from './kit-config.ts'
10
11
  import { NO_PICK_FIX, renderIdentity, type Brand } from './kits/identity-kit.ts'
@@ -28,11 +29,19 @@ async function rollOnce(root: string, io: AssetsIo, kit: ResolvedKit, resolveCom
28
29
  if (f == null) die(`unknown asset '${assetId}'`)
29
30
  const dir = assetDir(join(root, kit.workspace), assetId)
30
31
  const prompt = (await readPromptOverlay(dir)).prompt ?? f!.asset.prompt
32
+ // derived assets need their parents' picks as context — a blocker names
33
+ // the parent and fails the whole asset, never a partial
34
+ let context: ParentContext | undefined
35
+ if (parentIdsOf(f!).length > 0) {
36
+ const r = await resolveParentContext(kit, join(root, kit.workspace), f!)
37
+ if ('missing' in r) { io.printErr(`FAIL: ${assetId} blocked — parents not ready: ${r.missing.join(', ')}`); return count }
38
+ context = r
39
+ }
31
40
  let failed = 0
32
41
  for (let i = 0; i < count; i++) {
33
42
  const seed = randomSeed()
34
43
  try {
35
- await generateRoll(f!, { prompt, seed, destDir: dir }, resolveComfy)
44
+ await generateRoll(f!, { prompt, seed, destDir: dir, context }, resolveComfy)
36
45
  io.print(`ok: ${assetId} v${seed}`)
37
46
  } catch (e) {
38
47
  failed++
@@ -70,12 +79,12 @@ export const runAssetsTopUp = (root: string, io: AssetsIo, o: { mode: 'fill' | '
70
79
  const inScope = allAssets(kit).filter((f) => o.categories.length === 0 || o.categories.includes(f.kind.category))
71
80
  const manualCount = inScope.filter((f) => f.kind.generate.backend === 'manual').length
72
81
  if (manualCount > 0) io.print(`skip: ${manualCount} manual asset(s) — upload in the studio`)
73
- const targets = inScope
74
- .filter((f) => f.kind.generate.backend !== 'manual')
82
+ // topo (parents-first) replaced sparsest-first: derived children need
83
+ // their parents' rolls to exist before they can ever generate
84
+ const targets = topoSort(inScope.filter((f) => f.kind.generate.backend !== 'manual'))
75
85
  .map((f) => ({ f, have: listRolls(assetDir(join(root, kit.workspace), f.asset.id)).length }))
76
86
  .map((t) => ({ ...t, want: o.mode === 'add' ? o.n : Math.max(0, o.n - t.have) }))
77
87
  .filter((t) => t.want > 0)
78
- .sort((a, b) => a.have - b.have) // sparsest first
79
88
  let failed = 0
80
89
  for (const t of targets) failed += await rollOnce(root, io, kit, resolveComfy, t.f.asset.id, t.want)
81
90
  io.print(`${o.mode}: ${targets.length} asset(s) topped up${failed > 0 ? ` — ${failed} roll(s) FAILED` : ''}`)
@@ -89,26 +98,57 @@ export const runAssetsGen = (root: string, io: AssetsIo, o: { assetId: string; n
89
98
  return failed > 0 ? 1 : 0
90
99
  })
91
100
 
101
+ // ── identity: ONE render path + a brand.ts content hash so drift is
102
+ // machine-detectable — and machine-CLEARED (auto re-render), never a nag.
103
+ // Only a missing PICK still asks a human (taste).
104
+ const brandHashPath = (root: string): string => join(root, '.rv-identity/brand.hash')
105
+ const brandHash = async (root: string): Promise<string> =>
106
+ new Bun.CryptoHasher('sha256').update(await Bun.file(join(root, '.rv-identity/brand.ts')).text()).digest('hex').slice(0, 16)
107
+
108
+ export type IdentityRender = { written: string[] } | { blocked: string }
109
+
110
+ export async function renderIdentityNow(root: string): Promise<IdentityRender> {
111
+ const brandPath = join(root, '.rv-identity/brand.ts')
112
+ if (!existsSync(brandPath)) return { blocked: 'no .rv-identity/brand.ts — create one exporting a Brand (name, palette, mark)' }
113
+ // cache-bust: bun caches dynamic imports for the process lifetime — a
114
+ // long-running hub must re-render the EDITED brand, not the cached one
115
+ const mod = (await import(`${brandPath}?v=${await brandHash(root)}`)) as { default?: Brand }
116
+ if (mod.default == null) return { blocked: '.rv-identity/brand.ts must default-export the Brand' }
117
+ const brand = mod.default
118
+ let markFile: string | undefined
119
+ if (brand.mark.backend === 'comfy') {
120
+ // comfy marks render from the PICKED identity-logo roll — the kit
121
+ // owns the workspace where picks live
122
+ let kit: ResolvedKit
123
+ try { kit = await loadKit(root) } catch (e) { return { blocked: `comfy mark needs the kit's roll workspace — ${(e as Error).message}` } }
124
+ const dir = assetDir(join(root, kit.workspace), 'identity-logo')
125
+ const pick = await readPick(dir)
126
+ if (pick.pick == null) return { blocked: `brand '${brand.name}' uses a comfy mark and none is picked — ${NO_PICK_FIX}` }
127
+ markFile = join(dir, pick.pick)
128
+ if (!existsSync(markFile)) return { blocked: `picked roll ${pick.pick} vanished from ${dir} — re-pick in the studio` }
129
+ }
130
+ const written = await renderIdentity(brand, root, { markFile })
131
+ await Bun.write(brandHashPath(root), (await brandHash(root)) + '\n')
132
+ return { written }
133
+ }
134
+
135
+ /** brand.ts drifted since the last render → re-render NOW (autonomous).
136
+ * Returns what happened; 'blocked' = only a human pick can clear it. */
137
+ export async function healIdentity(root: string): Promise<{ state: 'fresh' | 'rendered' | 'blocked' | 'absent'; detail?: string }> {
138
+ if (!existsSync(join(root, '.rv-identity/brand.ts'))) return { state: 'absent' }
139
+ const outputsMissing = ['public/favicon.png', 'public/og.png', 'public/logo.png'].some((p) => !existsSync(join(root, p)))
140
+ const stored = existsSync(brandHashPath(root)) ? (await Bun.file(brandHashPath(root)).text()).trim() : null
141
+ if (!outputsMissing && stored === (await brandHash(root))) return { state: 'fresh' }
142
+ const r = await renderIdentityNow(root)
143
+ if ('blocked' in r) return { state: 'blocked', detail: r.blocked }
144
+ return { state: 'rendered', detail: r.written.join(', ') }
145
+ }
146
+
92
147
  export const runAssetsIdentity = (root: string, io: AssetsIo): Promise<number> =>
93
148
  guarded(io, async () => {
94
- const brandPath = join(root, '.rv-identity/brand.ts')
95
- if (!existsSync(brandPath)) die('no .rv-identity/brand.ts — create one exporting a Brand (name, palette, mark)')
96
- const mod = (await import(brandPath)) as { default?: Brand }
97
- if (mod.default == null) die('.rv-identity/brand.ts must default-export the Brand')
98
- const brand = mod.default!
99
- let markFile: string | undefined
100
- if (brand.mark.backend === 'comfy') {
101
- // comfy marks render from the PICKED identity-logo roll — the kit
102
- // owns the workspace where picks live
103
- const kit = await loadKit(root).catch((e: Error) => die(`comfy mark needs the kit's roll workspace — ${e.message}`))
104
- const dir = assetDir(join(root, kit.workspace), 'identity-logo')
105
- const pick = await readPick(dir)
106
- if (pick.pick == null) die(`brand '${brand.name}' uses a comfy mark and none is picked — ${NO_PICK_FIX}`)
107
- markFile = join(dir, pick.pick!)
108
- if (!existsSync(markFile)) die(`picked roll ${pick.pick} vanished from ${dir} — re-pick in the studio`)
109
- }
110
- const written = await renderIdentity(brand, root, { markFile })
111
- for (const w of written) io.print(`ok: ${w}`)
149
+ const r = await renderIdentityNow(root)
150
+ if ('blocked' in r) die(r.blocked)
151
+ for (const w of (r as { written: string[] }).written) io.print(`ok: ${w}`)
112
152
  return 0
113
153
  })
114
154
 
@@ -137,11 +177,12 @@ export const runAssetsCheck = (root: string, io: AssetsIo): Promise<number> =>
137
177
  const up = await fetch(`${kit.comfy.host}/system_stats`, { signal: AbortSignal.timeout(3000) }).then((r) => r.ok).catch(() => false)
138
178
  io.print(up ? `ok: comfy backend reachable (${kit.comfy.host})` : `WARN comfy backend unreachable (${kit.comfy.host})${kit.comfy.heal != null ? ` — ${kit.comfy.heal}` : ''}`)
139
179
  }
140
- if (existsSync(join(root, '.rv-identity/brand.ts'))) {
141
- const outs = ['public/favicon.png', 'public/og.png', 'public/logo.png'].filter((p) => !existsSync(join(root, p)))
142
- if (outs.length > 0) io.print(`WARN identity outputs missing: ${outs.join(', ')} — run: shipkit assets identity`)
143
- else io.print('ok: identity outputs present')
144
- }
180
+ // machine-clearable red clears itself: drifted/missing identity
181
+ // outputs re-render HERE; only a missing pick (taste) reaches a human
182
+ const idn = await healIdentity(root)
183
+ if (idn.state === 'fresh') io.print('ok: identity outputs present + brand.ts unchanged')
184
+ else if (idn.state === 'rendered') io.print(`ok: identity re-rendered (brand.ts changed) — ${idn.detail}`)
185
+ else if (idn.state === 'blocked') { io.print(`FAIL: identity blocked — ${idn.detail}`); bad++ }
145
186
  // manual assets: no rolls = nothing to ship = RED (the missing list
146
187
  // is the shopping list — build/audio doctrine); rolls-but-no-pick = WARN
147
188
  const manuals = allAssets(kit).filter((f) => f.kind.generate.backend === 'manual')
@@ -49,6 +49,7 @@ select,input[type=number]{background:var(--bg);color:var(--ink);border:1px solid
49
49
  .vig.gen{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:8px;color:var(--dim);font-size:11px}
50
50
  .card .th .spin{position:absolute;top:8px;right:8px}
51
51
  .dot.miss{background:#e5484d;border-color:#e5484d}
52
+ .dot.stale{background:#f5a524;border-color:#f5a524}
52
53
  .vig.aud{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:6px;font-size:20px}
53
54
  .vig.aud audio{width:100%;height:24px}
54
55
  .stageaud{display:flex;flex-direction:column;align-items:center;gap:14px;font-size:48px;padding:40px}
@@ -225,7 +226,7 @@ window.addEventListener('hashchange',restoreFromHash)
225
226
  function toggleFold(c){openCats.has(c)?openCats.delete(c):openCats.add(c);lastSideSig=null;renderSide(assets)}
226
227
  function renderSide(list){
227
228
  assets=list;$('#count').textContent=list.length
228
- const sig=JSON.stringify(list.map(a=>[a.id,a.rolls,a.picked]))+'|'+cur+'|'+catMode+'|'+[...openCats].join(',')+'|'+pending.join(',')
229
+ const sig=JSON.stringify(list.map(a=>[a.id,a.rolls,a.picked,a.stale]))+'|'+cur+'|'+catMode+'|'+[...openCats].join(',')+'|'+pending.join(',')
229
230
  if(sig===lastSideSig&&$('#list').innerHTML!=='')return; lastSideSig=sig
230
231
  const cats=[...new Set(list.map(a=>a.cat))]
231
232
  $('#list').innerHTML=cats.map(c=>{
@@ -236,7 +237,7 @@ function renderSide(list){
236
237
  if(!open)return head
237
238
  return head+list.filter(a=>a.cat===c).map(a=>
238
239
  '<div class="arow'+(a.id===cur?' sel':'')+'" data-id="'+esc(a.id)+'" onclick="selectAsset(\\''+jsq(a.id)+'\\')"><div class=lb><b>'+esc(a.label)+
239
- '</b><span>'+a.rolls+' rolls</span></div>'+(pending.includes(a.id)?'<div class=spin></div>':'<div class="dot '+(a.picked?'done':(a.rolls?'some':(a.manual?'miss':'')))+'"'+(a.manual&&!a.rolls?' title="manual asset — nothing uploaded yet"':'')+'></div>')+'</div>').join('')
240
+ '</b><span>'+a.rolls+' rolls</span></div>'+(pending.includes(a.id)?'<div class=spin></div>':'<div class="dot '+(a.stale?'stale':(a.picked?'done':(a.rolls?'some':(a.manual?'miss':''))))+'"'+(a.manual&&!a.rolls?' title="manual asset — nothing uploaded yet"':'')+'></div>')+'</div>').join('')
240
241
  }).join('')
241
242
  }
242
243
  // ── per-asset info surface (W2-2): backend, workflow provenance, ship target,
@@ -8,6 +8,7 @@ import { existsSync, mkdirSync, unlinkSync } from 'fs'
8
8
  import { join, relative } from 'path'
9
9
  import { findAsset, allAssets, mediaOf, missingPanelScripts, MEDIA_EXTS, type CustomPanelSpec, type FoundAsset, type GeometryPanelSpec, type ResolvedKit, type ShipFn } from '../kit-config.ts'
10
10
  import { interrupt, randomSeed, type OnProgress } from '../gen/comfy-gen.ts'
11
+ import { isStale, parentIdsOf, resolveParentContext, type ParentContext } from '../gen/derived.ts'
11
12
  import { generateRoll, type ComfyRuntime } from '../gen/generate-roll.ts'
12
13
  import { loadWorkflows, type Workflow } from '../gen/workflow-loader.ts'
13
14
  import { alphaHeight, toPng } from '../image-ops.ts'
@@ -20,15 +21,16 @@ export type StudioDeps = {
20
21
  kit: ResolvedKit
21
22
  root: string
22
23
  page: () => string
23
- /** test seam — defaults to the comfy engine */
24
- generateOne?: (found: FoundAsset, prompt: string, seed: number, destDir: string, onProgress?: OnProgress) => Promise<void>
24
+ /** test seam — defaults to the real engines. `context` = a derived
25
+ * asset's resolved parent picks (queue resolves it at run time) */
26
+ generateOne?: (found: FoundAsset, prompt: string, seed: number, destDir: string, onProgress?: OnProgress, context?: ParentContext) => Promise<void>
25
27
  }
26
28
 
27
29
  export type Studio = { server: ReturnType<typeof Bun.serve>; queue: StudioQueue; stop: () => void }
28
30
 
29
31
  // what the studio detail view shows about an asset's plumbing (W2-2)
30
32
  export type AssetInfo = {
31
- backend: 'canvas' | 'comfy' | 'manual'
33
+ backend: 'canvas' | 'comfy' | 'manual' | 'gemini'
32
34
  /** canvas rolls have no seed input — every roll is identical */
33
35
  deterministic: boolean
34
36
  /** comfy only: resolved workflow id + its .rv-comfy path (note = why unresolved) */
@@ -100,15 +102,36 @@ export async function studioRoutes(deps: StudioDeps): Promise<StudioRoutes> {
100
102
  if (kit.comfy == null || workflows == null) throw new Error('kit has no comfy config — only canvas kinds can generate')
101
103
  return { workflows, host: kit.comfy.host, heal: kit.comfy.heal }
102
104
  }
103
- const generateOne = deps.generateOne ?? ((found, prompt, seed, destDir, onProgress) =>
104
- generateRoll(found, { prompt, seed, destDir, onProgress }, resolveComfy))
105
+ const generateOne = deps.generateOne ?? ((found, prompt, seed, destDir, onProgress, context?: ParentContext) =>
106
+ generateRoll(found, { prompt, seed, destDir, onProgress, context }, resolveComfy))
105
107
 
106
108
  const queue = new StudioQueue(async (job, seed, onProgress) => {
107
109
  const found = findAsset(kit, job.assetId)
108
110
  if (found == null) throw new Error(`asset '${job.assetId}' not in the kit — job dropped`)
109
- await generateOne(found, job.prompt, seed, dirOf(job.assetId), onProgress)
111
+ // derived assets: parents' picks resolved at RUN time (a pick made
112
+ // while the job queued is honored); a blocker names the parent
113
+ let context: ParentContext | undefined
114
+ if (parentIdsOf(found).length > 0) {
115
+ const r = await resolveParentContext(kit, workspace, found)
116
+ if ('missing' in r) throw new Error(`'${job.assetId}' blocked — parents not ready: ${r.missing.join(', ')}`)
117
+ context = r
118
+ }
119
+ await generateOne(found, job.prompt, seed, dirOf(job.assetId), onProgress, context)
110
120
  }, randomSeed)
111
121
 
122
+ // machine-clearable red clears itself: re-picking a PARENT auto-enqueues
123
+ // every now-stale child (1 roll each) — no button, no nag. Generation
124
+ // spend is gated on the intentful pick, never on passive page loads.
125
+ const regenStaleChildren = async (parentId: string): Promise<void> => {
126
+ for (const f of allAssets(kit)) {
127
+ if (!parentIdsOf(f).includes(parentId)) continue
128
+ if (!(await isStale(kit, workspace, f))) continue
129
+ if (queue.pendingIds().includes(f.asset.id)) continue // already queued — never double-spend
130
+ queue.enqueue([{ assetId: f.asset.id, prompt: await promptOf(f), count: 1 }])
131
+ console.log(`ok: ${f.asset.id} auto-regen queued (parent '${parentId}' re-picked)`)
132
+ }
133
+ }
134
+
112
135
  // ── session/live rect plumbing ─────────────────────────────────────────
113
136
  const rectPath = (id: string, roll: string): string => join(dirOf(id), `${roll}.rect.json`)
114
137
  const sessionRect = async (f: FoundAsset, roll: string): Promise<GroundRect> => {
@@ -207,6 +230,7 @@ export async function studioRoutes(deps: StudioDeps): Promise<StudioRoutes> {
207
230
  id: f.asset.id, cat: f.kind.category, label: f.asset.label ?? f.asset.id,
208
231
  prompt: await promptOf(f), rolls: listRolls(dir).length, picked: pick.pick != null,
209
232
  icon: f.kind.mode === 'icon', media: mediaOf(f.kind), manual: f.kind.generate.backend === 'manual',
233
+ derived: parentIdsOf(f).length > 0, stale: await isStale(kit, workspace, f),
210
234
  }
211
235
  }))
212
236
  return json({ ...queue.state, queued: queue.queuedCount, pending: queue.pendingIds(), errors: queue.errors.slice(-5), assets })
@@ -355,6 +379,7 @@ export async function studioRoutes(deps: StudioDeps): Promise<StudioRoutes> {
355
379
  s.pick = name
356
380
  s.shippedAt = res.shipped != null ? new Date().toISOString() : s.shippedAt
357
381
  })
382
+ void regenStaleChildren(f.asset.id).catch((e: Error) => queue.pushError(`auto-regen after '${f.asset.id}' pick: ${e.message}`))
358
383
  return json(res)
359
384
  } catch (e) {
360
385
  queue.pushError(`ship ${String(b.id)}: ${(e as Error).message}`)
@@ -0,0 +1,156 @@
1
+ // The hub's React app (Bun.serve HTML import — no vite, no build step).
2
+ // Two views routed by pathname: / = attention dashboard (everything red),
3
+ // /status = STATUS.md + streamed check runs. The rail lists every mounted
4
+ // tool (from /hub.json) — legacy pages (graphs, studio) get the same rail
5
+ // injected server-side until they migrate.
6
+ import { makeAutoObservable, runInAction } from 'mobx'
7
+ import { observer } from 'mobx-react-lite'
8
+ import { StrictMode } from 'react'
9
+ import { createRoot } from 'react-dom/client'
10
+
11
+ type HubTool = { path: string; label: string; desc: string }
12
+ type AttentionLine = { text: string; href?: string }
13
+ type AttentionSection = { title: string; tone: 'red' | 'warn'; lines: AttentionLine[]; href?: string }
14
+
15
+ const RAIL_ICONS: Record<string, string> = { '/': '⚡', '/graph/wiki': '🕸', '/graph/agent': '🤖', '/assets/': '🖼', '/status': '📊' }
16
+
17
+ class HubStore {
18
+ name = ''
19
+ tools: HubTool[] = []
20
+ sections: AttentionSection[] | null = null
21
+ statusMd: string | null = null
22
+ running = false
23
+ note = ''
24
+ error: string | null = null
25
+
26
+ constructor() {
27
+ makeAutoObservable(this)
28
+ }
29
+
30
+ get redCount(): number {
31
+ return (this.sections ?? []).filter((s) => s.tone === 'red').reduce((n, s) => n + s.lines.length, 0)
32
+ }
33
+
34
+ async load(): Promise<void> {
35
+ try {
36
+ const hub = (await (await fetch('/hub.json')).json()) as { name: string; tools: HubTool[] }
37
+ const att = (await (await fetch('/api/attention.json')).json()) as { sections: AttentionSection[] }
38
+ runInAction(() => {
39
+ this.name = hub.name
40
+ this.tools = hub.tools
41
+ this.sections = att.sections
42
+ })
43
+ if (location.pathname === '/status') {
44
+ const s = (await (await fetch('/api/status.json')).json()) as { statusMd: string | null }
45
+ runInAction(() => { this.statusMd = s.statusMd })
46
+ }
47
+ } catch (e) {
48
+ runInAction(() => { this.error = (e as Error).message })
49
+ }
50
+ }
51
+
52
+ async runCheck(): Promise<void> {
53
+ this.running = true
54
+ this.note = 'running…'
55
+ let text = ''
56
+ try {
57
+ const r = await fetch('/status/run', { method: 'POST' })
58
+ const rd = r.body!.getReader()
59
+ const dec = new TextDecoder()
60
+ for (;;) {
61
+ const { done, value } = await rd.read()
62
+ if (done) break
63
+ text += dec.decode(value, { stream: true })
64
+ runInAction(() => { this.statusMd = text })
65
+ }
66
+ runInAction(() => { this.note = 'done — STATUS.md refreshed on disk' })
67
+ void this.load() // red counts moved — refresh the dashboard data
68
+ } catch (e) {
69
+ runInAction(() => {
70
+ this.statusMd = `${text}\nFAIL stream broke: ${(e as Error).message}`
71
+ this.note = 'stream broke'
72
+ })
73
+ }
74
+ runInAction(() => { this.running = false })
75
+ }
76
+ }
77
+
78
+ const store = new HubStore()
79
+ void store.load()
80
+
81
+ const Rail = observer(() => {
82
+ const items = [{ path: '/', label: 'attention', desc: 'everything red' }, ...store.tools.filter((t) => t.path !== '/')]
83
+ return (
84
+ <nav className="skrail">
85
+ {items.map((t) => {
86
+ const on = location.pathname === t.path || (t.path !== '/' && location.pathname.startsWith(t.path))
87
+ return (
88
+ <a key={t.path} className={on ? 'on' : ''} href={t.path} title={`${t.label} — ${t.desc}`}>
89
+ {RAIL_ICONS[t.path] ?? '·'}
90
+ {t.path === '/' && store.redCount > 0 ? <i /> : null}
91
+ </a>
92
+ )
93
+ })}
94
+ </nav>
95
+ )
96
+ })
97
+
98
+ const Attention = observer(() => {
99
+ const live = (store.sections ?? []).filter((s) => s.lines.length > 0)
100
+ return (
101
+ <div className="wrap">
102
+ <div className="crumb">shipkit hub · attention</div>
103
+ <h1>{store.name}<small>everything that needs you, one page</small></h1>
104
+ {store.sections == null ? <div className="sub">loading…</div>
105
+ : live.length === 0 ? <div className="clear">✅ all clear — nothing needs you</div>
106
+ : live.map((s) => (
107
+ <section key={s.title} className={s.tone}>
108
+ <h2>{s.href != null ? <a href={s.href}>{s.title}</a> : s.title}<em>{s.lines.length}</em></h2>
109
+ {s.lines.map((l, i) => (
110
+ <div key={i} className="line">{l.href != null ? <a href={l.href}>{l.text}</a> : l.text}</div>
111
+ ))}
112
+ </section>
113
+ ))}
114
+ <div className="sub">refreshes on reload · checks refresh via <a href="/status">status → run check</a></div>
115
+ </div>
116
+ )
117
+ })
118
+
119
+ const wireClass = (l: string): string => {
120
+ const t = l.trimStart()
121
+ if (t.startsWith('FAIL') || t.startsWith('✗')) return 'fail'
122
+ if (t.startsWith('WARN') || t.startsWith('⚠')) return 'warn'
123
+ if (t.startsWith('ok') || t.startsWith('✓')) return 'ok'
124
+ if (t.startsWith('skip') || t.startsWith('fix:') || t.startsWith('#')) return 'dim'
125
+ return ''
126
+ }
127
+
128
+ const Status = observer(() => (
129
+ <div className="wrap">
130
+ <div className="crumb">shipkit hub · status</div>
131
+ <h1>{store.name}<small>STATUS.md</small></h1>
132
+ <div className="bar">
133
+ <button disabled={store.running} onClick={() => void store.runCheck()}>run check</button>
134
+ <span className="note">{store.note !== '' ? store.note : <>shells <code>shipkit check</code>, wire lines stream in below</>}</span>
135
+ </div>
136
+ <pre>
137
+ {(store.statusMd ?? 'no STATUS.md yet — hit "run check" (or run: shipkit check)')
138
+ .split('\n')
139
+ .map((l, i) => <span key={i} className={wireClass(l)}>{l}{'\n'}</span>)}
140
+ </pre>
141
+ </div>
142
+ ))
143
+
144
+ const App = observer(() => (
145
+ <div className="app">
146
+ <Rail />
147
+ {store.error != null ? <div className="wrap err">hub api unreachable: {store.error}</div>
148
+ : location.pathname === '/status' ? <Status /> : <Attention />}
149
+ </div>
150
+ ))
151
+
152
+ createRoot(document.getElementById('root')!).render(
153
+ <StrictMode>
154
+ <App />
155
+ </StrictMode>,
156
+ )
@@ -0,0 +1,41 @@
1
+ /* house palette — matches the studio/graph family. Plain CSS on purpose:
2
+ a published lib must not impose bunfig plugins (tailwind) on consumers. */
3
+ :root{--bg:#141210;--panel:#1e1a15;--panel2:#262019;--line:#332b22;--ink:#f3ebe0;--dim:#a89a88;
4
+ --accent:#e88c28;--gold:#ffcf5c;--teal:#58c0b0;--red:#e05252}
5
+ *{box-sizing:border-box}
6
+ body{margin:0;background:var(--bg);color:var(--ink);font:15px/1.6 system-ui,sans-serif;min-height:100vh}
7
+ .app{display:flex;min-height:100vh}
8
+ .wrap{max-width:900px;margin:0 auto;padding:34px 26px;flex:1}
9
+ h1{font-size:20px;margin:0}
10
+ h1 small{color:var(--dim);font-weight:400;font-size:13px;margin-left:10px}
11
+ .crumb{font-size:12px;letter-spacing:.14em;text-transform:uppercase;color:var(--accent);margin-bottom:6px}
12
+
13
+ .skrail{position:sticky;top:0;align-self:flex-start;height:100vh;width:46px;display:flex;flex-direction:column;
14
+ align-items:center;padding-top:10px;gap:4px;background:#17140f;border-right:1px solid var(--line);flex:none}
15
+ .skrail a{display:flex;align-items:center;justify-content:center;width:36px;height:36px;border-radius:9px;
16
+ font-size:17px;text-decoration:none;border-left:2px solid transparent;filter:grayscale(35%);opacity:.75;position:relative}
17
+ .skrail a:hover{background:var(--panel2);filter:none;opacity:1}
18
+ .skrail a.on{background:var(--panel2);border-left-color:var(--accent);filter:none;opacity:1}
19
+ .skrail a i{position:absolute;top:4px;right:4px;width:8px;height:8px;border-radius:50%;background:var(--red)}
20
+
21
+ .clear{margin-top:30px;font-size:17px;color:var(--teal)}
22
+ section{background:var(--panel);border:1px solid var(--line);border-left:3px solid var(--gold);border-radius:12px;
23
+ padding:14px 18px;margin-top:16px}
24
+ section.red{border-left-color:var(--red)}
25
+ h2{font-size:14px;margin:0 0 8px;display:flex;align-items:center;gap:8px}
26
+ h2 a{color:var(--ink);text-decoration:none}h2 a:hover{color:var(--gold)}
27
+ h2 em{font-style:normal;font-size:12px;color:var(--dim);background:var(--panel2);border-radius:8px;padding:1px 8px}
28
+ .line{font:12.5px/1.7 ui-monospace,Menlo,monospace;color:var(--dim);white-space:pre-wrap}
29
+ .line a{color:var(--dim);text-decoration:underline dotted}.line a:hover{color:var(--gold)}
30
+ section.red .line{color:#e8a0a0}
31
+ .sub{margin-top:10px;font-size:12px;color:var(--dim)}
32
+ .sub a{color:var(--dim)}
33
+
34
+ .bar{display:flex;align-items:center;gap:12px;margin:18px 0 12px}
35
+ button{background:var(--accent);color:#1a1206;border:0;border-radius:9px;padding:8px 14px;font-weight:650;cursor:pointer}
36
+ button:disabled{opacity:.45;cursor:default}
37
+ .note{font-size:12px;color:var(--dim)}
38
+ pre{background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:16px;overflow-x:auto;
39
+ font:12.5px/1.55 ui-monospace,Menlo,monospace;white-space:pre-wrap}
40
+ .ok{color:var(--teal)}.warn{color:var(--gold)}.fail{color:var(--red)}.dim{color:var(--dim)}
41
+ .err{margin-top:26px;color:var(--red)}
@@ -0,0 +1,13 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>shipkit hub</title>
7
+ <link rel="stylesheet" href="./hub.css" />
8
+ </head>
9
+ <body>
10
+ <div id="root"></div>
11
+ <script type="module" src="./hub-app.tsx"></script>
12
+ </body>
13
+ </html>
@@ -1,84 +1,45 @@
1
- // The hub's two no-build pages (served by serve-hub.ts): the landing page
2
- // whose links ARE the toolbox, and the /status page (STATUS.md + a
3
- // "run check" button streaming wire lines). Same style family as the
4
- // studio: one template string, vanilla JS, no build step, no CDN.
1
+ // Shared hub-page plumbing. The hub UI itself is the Bun-native React app
2
+ // in hub-app/ (HTML import, served by serve-hub.ts — build/webapp
3
+ // doctrine). This module keeps what the LEGACY pages (graph pages, studio
4
+ // SPA) still need until they migrate: the server-injected rail, and the
5
+ // attention section types the collector + React app share.
5
6
  import type { HubTool } from './serve-hub.ts'
6
7
 
7
- const esc = (s: string): string => s.replace(/&/g, '&amp;').replace(/</g, '&lt;')
8
+ const esc = (s: string): string => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/"/g, '&quot;')
8
9
 
9
- const SHELL = `
10
- :root{--bg:#141210;--panel:#1e1a15;--panel2:#262019;--line:#332b22;--ink:#f3ebe0;--dim:#a89a88;
11
- --accent:#e88c28;--gold:#ffcf5c;--teal:#58c0b0;--red:#e05252}
12
- *{box-sizing:border-box}
13
- body{margin:0;background:var(--bg);color:var(--ink);font:15px/1.6 system-ui,sans-serif;min-height:100vh}
14
- .wrap{max-width:860px;margin:0 auto;padding:40px 24px}
15
- h1{font-size:20px;margin:0}
16
- h1 small{color:var(--dim);font-weight:400;font-size:13px;margin-left:10px}
17
- .crumb{font-size:12px;letter-spacing:.14em;text-transform:uppercase;color:var(--accent);margin-bottom:6px}
18
- .crumb a{color:var(--accent);text-decoration:none}.crumb a:hover{color:var(--gold)}
19
- `
10
+ // ── the rail: thin fixed tab bar, injected into legacy hub-served pages ───
11
+ const RAIL_ICONS: Record<string, string> = { '/': '⚡', '/graph/wiki': '🕸', '/graph/agent': '🤖', '/assets/': '🖼', '/status': '📊' }
20
12
 
21
- export function landingPage(o: { name: string; port: number; tools: HubTool[] }): string {
22
- const cards = o.tools
23
- .map((t) => `<a class=card href="${esc(t.path)}"><b>${esc(t.label)}</b><span>${esc(t.desc)}</span><code>${esc(t.path)}</code></a>`)
24
- .join('\n')
25
- return `<!doctype html><html><head><meta charset=utf-8>
26
- <meta name=viewport content="width=device-width,initial-scale=1"><title>${esc(o.name)} · shipkit</title>
27
- <style>${SHELL}
28
- .cards{display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:14px;margin-top:26px}
29
- .card{display:flex;flex-direction:column;gap:6px;background:var(--panel);border:1px solid var(--line);border-radius:12px;
30
- padding:16px;text-decoration:none;color:var(--ink)}
31
- .card:hover{border-color:var(--accent);background:var(--panel2)}
32
- .card b{font-size:15px}.card span{font-size:13px;color:var(--dim)}
33
- .card code{font:11px ui-monospace,Menlo,monospace;color:var(--teal)}
34
- </style></head><body><div class=wrap>
35
- <div class=crumb>shipkit hub</div>
36
- <h1>${esc(o.name)}<small>http://localhost:${o.port}</small></h1>
37
- <div class=cards>
38
- ${cards}
39
- </div>
40
- </div></body></html>`
41
- }
13
+ const RAIL_CSS = `
14
+ .skrail{position:fixed;left:0;top:0;bottom:0;width:46px;z-index:9999;display:flex;flex-direction:column;
15
+ align-items:center;padding-top:10px;gap:4px;background:#17140f;border-right:1px solid #332b22}
16
+ .skrail a{display:flex;align-items:center;justify-content:center;width:36px;height:36px;border-radius:9px;
17
+ font-size:17px;text-decoration:none;border-left:2px solid transparent;filter:grayscale(35%);opacity:.75;position:relative}
18
+ .skrail a:hover{background:#262019;filter:none;opacity:1}
19
+ .skrail a.on{background:#262019;border-left-color:#e88c28;filter:none;opacity:1}
20
+ .skrail a i{position:absolute;top:4px;right:4px;width:8px;height:8px;border-radius:50%;background:#e05252}
21
+ body{padding-left:46px}`
42
22
 
43
- export function statusPage(o: { name: string; statusMd: string | null }): string {
44
- const body = o.statusMd ?? 'no STATUS.md yet — hit "run check" (or run: shipkit check)'
45
- return `<!doctype html><html><head><meta charset=utf-8>
46
- <meta name=viewport content="width=device-width,initial-scale=1"><title>${esc(o.name)} · status</title>
47
- <style>${SHELL}
48
- .bar{display:flex;align-items:center;gap:12px;margin:18px 0 12px}
49
- button{background:var(--accent);color:#1a1206;border:0;border-radius:9px;padding:8px 14px;font-weight:650;cursor:pointer}
50
- button:disabled{opacity:.45;cursor:default}
51
- #note{font-size:12px;color:var(--dim)}
52
- pre{background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:16px;overflow-x:auto;
53
- font:12.5px/1.55 ui-monospace,Menlo,monospace;white-space:pre-wrap}
54
- .ok{color:var(--teal)}.warn{color:var(--gold)}.fail{color:var(--red)}.dim{color:var(--dim)}
55
- </style></head><body><div class=wrap>
56
- <div class=crumb><a href="/">shipkit hub</a> / status</div>
57
- <h1>${esc(o.name)}<small>STATUS.md</small></h1>
58
- <div class=bar><button id=run>run check</button><span id=note>shells <code>shipkit check</code>, wire lines stream in below</span></div>
59
- <pre id=out></pre>
60
- <script>
61
- const out=document.getElementById('out'),btn=document.getElementById('run'),note=document.getElementById('note')
62
- // wire-line coloring: ok/WARN/FAIL/skip prefixes → the house palette
63
- const esc=s=>s.replace(/&/g,'&amp;').replace(/</g,'&lt;')
64
- const cls=l=>{const t=l.trimStart()
65
- if(t.startsWith('FAIL')||t.startsWith('✗'))return 'fail'
66
- if(t.startsWith('WARN')||t.startsWith('⚠'))return 'warn'
67
- if(t.startsWith('ok')||t.startsWith('✓'))return 'ok'
68
- if(t.startsWith('skip')||t.startsWith('fix:')||t.startsWith('#'))return 'dim'
69
- return ''}
70
- const paint=text=>{out.innerHTML=text.split('\\n').map(l=>'<span class="'+cls(l)+'">'+esc(l)+'</span>').join('\\n')}
71
- paint(${JSON.stringify(body).replaceAll('<', '\\u003c')})
72
- btn.onclick=async()=>{
73
- btn.disabled=true;note.textContent='running…';let text=''
74
- try{
75
- const r=await fetch('status/run',{method:'POST'})
76
- const rd=r.body.getReader(),dec=new TextDecoder()
77
- for(;;){const{done,value}=await rd.read();if(done)break;text+=dec.decode(value,{stream:true});paint(text)}
78
- note.textContent='done — STATUS.md refreshed on disk'
79
- }catch(e){text+='\\nFAIL stream broke: '+e;paint(text);note.textContent='stream broke'}
80
- btn.disabled=false
81
- }
82
- </script>
83
- </div></body></html>`
23
+ /** Inject the rail into a legacy hub-served HTML document (post-<body>,
24
+ * fixed position — the page needs no layout knowledge). The React app
25
+ * renders its own identical rail from /hub.json. */
26
+ export function withRail(html: string, o: { tools: HubTool[]; active: string }): string {
27
+ const items = [{ path: '/', label: 'attention', desc: 'everything red' }, ...o.tools.filter((t) => t.path !== '/')]
28
+ .map((t) => {
29
+ const on = o.active === t.path || (t.path !== '/' && o.active.startsWith(t.path))
30
+ const dot = t.path === '/' ? '<i id=skdot style="display:none"></i>' : ''
31
+ return `<a class="${on ? 'on' : ''}" href="${esc(t.path)}" title="${esc(t.label)} — ${esc(t.desc)}">${RAIL_ICONS[t.path] ?? '·'}${dot}</a>`
32
+ })
33
+ .join('')
34
+ // the red dot fetches client-side: a passive page nav must never run the
35
+ // attention collector server-side (cost + heal side-effects)
36
+ const dotScript = `<script>fetch('/api/attention.json').then(r=>r.json()).then(j=>{const n=(j.sections||[]).filter(s=>s.tone==='red').reduce((a,s)=>a+s.lines.length,0);if(n>0)document.getElementById('skdot').style.display=''}).catch(()=>{})</script>`
37
+ return html.replace(/<body([^>]*)>/, `<body$1><style>${RAIL_CSS}</style><nav class=skrail>${items}</nav>${dotScript}`)
84
38
  }
39
+
40
+ // ── attention data shapes (collector in run-serve, rendering in hub-app) ──
41
+ export type AttentionLine = { text: string; href?: string }
42
+ export type AttentionSection = { title: string; tone: 'red' | 'warn'; lines: AttentionLine[]; href?: string }
43
+
44
+ export const attentionRedCount = (sections: AttentionSection[]): number =>
45
+ sections.filter((s) => s.tone === 'red').reduce((n, s) => n + s.lines.length, 0)
@@ -5,14 +5,20 @@
5
5
  // kit.ts loads. Port: --port > serve.port > a deterministic house-range
6
6
  // default (4800-4979, hashed from the repo name — stable across launches;
7
7
  // pin serve.port when two repos collide).
8
- import { existsSync } from 'fs'
8
+ import { existsSync, readdirSync, statSync } from 'fs'
9
9
  import { join } from 'path'
10
10
  import { fileURLToPath } from 'url'
11
- import { loadKit } from '../assets/kit-config.ts'
11
+ import { loadKit, allAssets, type ResolvedKit } from '../assets/kit-config.ts'
12
+ import { isStale } from '../assets/gen/derived.ts'
13
+ import { assetDir, listRolls, readPick } from '../assets/roll-workspace.ts'
14
+ import { healIdentity } from '../assets/run-assets.ts'
12
15
  import { page as studioPage } from '../assets/server/studio-page.ts'
13
16
  import { studioRoutes, type StudioRoutes } from '../assets/server/studio-server.ts'
14
- import { loadRepoConfig, type RepoConfig } from '../repo-config.ts'
17
+ import { openAsks } from '../asks/asks-queue.ts'
18
+ import { evaluateContracts } from '../contracts/contracts-engine.ts'
19
+ import { activeContracts, contractsConfigFrom, loadRepoConfig, type RepoConfig } from '../repo-config.ts'
15
20
  import { scanMdGraph } from '../wiki/scan-md.ts'
21
+ import type { AttentionSection } from './hub-pages.ts'
16
22
  import { serveHub, type HubDeps } from './serve-hub.ts'
17
23
 
18
24
  export type ServeIo = { print: (l: string) => void; printErr: (l: string) => void }
@@ -32,12 +38,15 @@ export async function resolveHubDeps(root: string, c: RepoConfig, io: ServeIo, o
32
38
  ? { title: `${c.name} agent/`, load: () => scanMdGraph({ root: agentRoot, index: c.serve?.agentIndex }) }
33
39
  : null
34
40
  let studio: StudioRoutes | null = null
41
+ let kit: ResolvedKit | null = null
35
42
  if (existsSync(join(root, '.rv-assets/kit.ts'))) {
36
43
  // a broken kit must not take the graphs and status down with it —
37
44
  // WARN + mount everything else
38
45
  try {
39
- studio = await studioRoutes({ kit: await loadKit(root), root, page: studioPage })
46
+ kit = await loadKit(root)
47
+ studio = await studioRoutes({ kit, root, page: studioPage })
40
48
  } catch (e) {
49
+ kit = null
41
50
  io.print(`WARN assets studio not mounted: ${(e as Error).message}`)
42
51
  }
43
52
  }
@@ -50,6 +59,76 @@ export async function resolveHubDeps(root: string, c: RepoConfig, io: ServeIo, o
50
59
  agentGraph,
51
60
  studio,
52
61
  checkCmd: [process.execPath, cli, 'check'],
62
+ attention: attentionCollector(root, c, kit),
63
+ }
64
+ }
65
+
66
+ // ── the / dashboard's data: everything red, one collector per source, each
67
+ // failure-isolated (a broken source reports itself, never blanks the page)
68
+ export function attentionCollector(root: string, c: RepoConfig, kit: ResolvedKit | null): () => Promise<AttentionSection[]> {
69
+ const guard = async (title: string, tone: 'red' | 'warn', body: () => Promise<AttentionSection['lines']>): Promise<AttentionSection> => {
70
+ try { return { title, tone, lines: await body() } } catch (e) { return { title, tone: 'red', lines: [{ text: `collector broke: ${(e as Error).message}` }] } }
71
+ }
72
+ return async () => {
73
+ const sections = await Promise.all([
74
+ guard('failing checks', 'red', async () => {
75
+ const p = join(root, 'STATUS.md')
76
+ if (!existsSync(p)) return []
77
+ // a cred FAIL whose ask already sits in the asks section is
78
+ // ONE action shown twice — the ask (his hands) wins
79
+ const askDir = join(root, c.asks?.dir ?? 'asks')
80
+ const askText = existsSync(askDir) ? openAsks(askDir).map((a) => a.title.toLowerCase()).join(' ') : ''
81
+ return (await Bun.file(p).text()).split('\n')
82
+ .filter((l) => l.trimStart().startsWith('FAIL'))
83
+ .filter((l) => {
84
+ const cred = l.match(/cred rv\/([a-z0-9-]+)\/[a-z0-9-]+ missing/)?.[1]
85
+ return cred == null || !askText.includes(cred)
86
+ })
87
+ .map((text) => ({ text, href: '/status' }))
88
+ }),
89
+ guard('asks — waiting on you', 'red', async () => {
90
+ const dir = join(root, c.asks?.dir ?? 'asks')
91
+ if (!existsSync(dir)) return [] // read path must not mint an asks/ dir
92
+ return openAsks(dir).map((a) => ({ text: `${a.title}${a.kind !== '' ? ` (${a.kind})` : ''}` }))
93
+ }),
94
+ guard('assets', 'red', async () => {
95
+ // self-heal first: identity re-renders itself on brand.ts
96
+ // drift; only pick-blocked (taste) surfaces below
97
+ const idn = await healIdentity(root)
98
+ const lines: AttentionSection['lines'] = []
99
+ if (idn.state === 'blocked') lines.push({ text: `identity — ${idn.detail ?? 'blocked'}`, href: '/assets/#identity-logo' })
100
+ if (kit == null) return lines
101
+ for (const f of allAssets(kit)) {
102
+ const dir = assetDir(join(root, kit.workspace), f.asset.id)
103
+ const rolls = listRolls(dir)
104
+ const manual = f.kind.generate.backend === 'manual'
105
+ if (manual && rolls.length === 0) lines.push({ text: `${f.asset.id} — manual, nothing uploaded`, href: `/assets/#${f.asset.id}` })
106
+ else if (rolls.length > 0 && (await readPick(dir)).pick == null) lines.push({ text: `${f.asset.id} — ${rolls.length} roll(s), none picked`, href: `/assets/#${f.asset.id}` })
107
+ else if (await isStale(kit, join(root, kit.workspace), f)) lines.push({ text: `${f.asset.id} — stale (a parent was re-picked)`, href: `/assets/#${f.asset.id}` })
108
+ }
109
+ return lines
110
+ }),
111
+ guard('contracts', 'red', async () => {
112
+ const r = await evaluateContracts(activeContracts(c), contractsConfigFrom(c, root))
113
+ return r.lines.filter((l) => l.kind === 'fail').map((l) => ({ text: l.text }))
114
+ }),
115
+ guard('contract warnings', 'warn', async () => {
116
+ const r = await evaluateContracts(activeContracts(c), contractsConfigFrom(c, root))
117
+ return r.lines.filter((l) => l.kind === 'warn').map((l) => ({ text: l.text }))
118
+ }),
119
+ guard('posts to write', 'warn', async () => {
120
+ const social = join(root, '.rv-social')
121
+ if (!existsSync(social)) return []
122
+ const lines: AttentionSection['lines'] = []
123
+ for (const ch of readdirSync(social)) {
124
+ const d = join(social, ch)
125
+ if (!statSync(d).isDirectory()) continue
126
+ for (const f of readdirSync(d).filter((x) => x.endsWith('.md'))) lines.push({ text: `${ch}/${f}` })
127
+ }
128
+ return lines
129
+ }),
130
+ ])
131
+ return sections
53
132
  }
54
133
  }
55
134
 
@@ -9,7 +9,11 @@ import { join } from 'path'
9
9
  import { graphHtml } from 'rvlib-shipkit/src/wiki/graph-page.ts'
10
10
  import type { MdGraph } from 'rvlib-shipkit/src/wiki/md-graph.ts'
11
11
  import type { StudioRoutes } from '../assets/server/studio-server.ts'
12
- import { landingPage, statusPage } from './hub-pages.ts'
12
+ import { withRail, type AttentionSection } from './hub-pages.ts'
13
+ // the hub UI is a Bun-native React app (HTML import, no vite, no build) —
14
+ // build/webapp doctrine; legacy pages (graphs, studio SPA) keep the
15
+ // server-injected rail until they migrate
16
+ import hubIndex from './hub-app/index.html'
13
17
 
14
18
  export type HubGraph = { title: string; load: () => MdGraph }
15
19
  export type HubTool = { path: string; label: string; desc: string }
@@ -25,6 +29,9 @@ export type HubDeps = {
25
29
  studio: StudioRoutes | null
26
30
  /** argv shelling the check run streamed by /status/run (test seam) */
27
31
  checkCmd: string[]
32
+ /** the / dashboard's data — everything red, gathered per request
33
+ * (checks · asks · assets · contracts · posts; run-serve builds it) */
34
+ attention: () => Promise<AttentionSection[]>
28
35
  }
29
36
  export type Hub = { server: ReturnType<typeof Bun.serve>; tools: HubTool[]; stop: () => void }
30
37
 
@@ -40,24 +47,33 @@ export function hubTools(d: Pick<HubDeps, 'wikiGraph' | 'agentGraph' | 'studio'>
40
47
 
41
48
  export function serveHub(d: HubDeps): Hub {
42
49
  const tools = hubTools(d)
43
- const html = (s: string): Response => new Response(s, { headers: { 'content-type': 'text/html; charset=utf-8' } })
50
+ // every HTML page carries the rail — one keystroke between surfaces;
51
+ // the red dot self-fetches client-side (no collector on passive navs)
52
+ const shell = (s: string, active: string): Response =>
53
+ new Response(withRail(s, { tools, active }), { headers: { 'content-type': 'text/html; charset=utf-8' } })
44
54
  const server: ReturnType<typeof Bun.serve> = Bun.serve({
45
55
  hostname: '127.0.0.1',
46
56
  port: d.port,
57
+ // / (attention dashboard) and /status are the React app — one HTML
58
+ // bundle, view picked by pathname client-side
59
+ routes: { '/': hubIndex, '/status': hubIndex },
47
60
  async fetch(req): Promise<Response> {
48
61
  const path = new URL(req.url).pathname
49
- if (path === '/') return html(landingPage({ name: d.name, port: server.port ?? d.port, tools }))
50
62
  // identity probe: lets a second launch tell "same repo, another
51
63
  // window" from "a DIFFERENT repo hashed onto my port"
52
64
  if (path === '/hub.json') return Response.json({ hub: 'shipkit', name: d.name, tools })
53
- if (path === '/graph/wiki') return html(graphHtml(d.wikiGraph.load(), { title: d.wikiGraph.title }))
54
- if (path === '/graph/wiki/graph.json') return Response.json(d.wikiGraph.load())
55
- if (d.agentGraph != null && path === '/graph/agent') return html(graphHtml(d.agentGraph.load(), { title: d.agentGraph.title }))
56
- if (d.agentGraph != null && path === '/graph/agent/graph.json') return Response.json(d.agentGraph.load())
57
- if (path === '/status') {
65
+ if (path === '/api/attention.json') {
66
+ const sections = await d.attention().catch((e: Error) => [{ title: 'attention collector broke', tone: 'red' as const, lines: [{ text: e.message }] }])
67
+ return Response.json({ sections })
68
+ }
69
+ if (path === '/api/status.json') {
58
70
  const p = join(d.root, 'STATUS.md')
59
- return html(statusPage({ name: d.name, statusMd: existsSync(p) ? await Bun.file(p).text() : null }))
71
+ return Response.json({ statusMd: existsSync(p) ? await Bun.file(p).text() : null })
60
72
  }
73
+ if (path === '/graph/wiki') return shell(graphHtml(d.wikiGraph.load(), { title: d.wikiGraph.title }), path)
74
+ if (path === '/graph/wiki/graph.json') return Response.json(d.wikiGraph.load())
75
+ if (d.agentGraph != null && path === '/graph/agent') return shell(graphHtml(d.agentGraph.load(), { title: d.agentGraph.title }), path)
76
+ if (d.agentGraph != null && path === '/graph/agent/graph.json') return Response.json(d.agentGraph.load())
61
77
  if (path === '/status/run' && req.method === 'POST') {
62
78
  // stream wire lines as the check emits them; sh merges stderr
63
79
  // so FAIL lines reach the page, not just the terminal
@@ -68,6 +84,11 @@ export function serveHub(d: HubDeps): Hub {
68
84
  // the studio page fetches RELATIVE urls — it must live under
69
85
  // the slash-terminated prefix for them to resolve
70
86
  if (path === '/assets') return new Response(null, { status: 307, headers: { location: '/assets/' } })
87
+ if (path === '/assets/' && req.method === 'GET') {
88
+ // the SPA document gets the rail too; API routes untouched
89
+ const res = await d.studio.handle(req, '/')
90
+ return shell(await res.text(), '/assets/')
91
+ }
71
92
  if (path.startsWith('/assets/')) return d.studio.handle(req, path.slice('/assets'.length))
72
93
  }
73
94
  return new Response('not found', { status: 404 })
package/src/shipkit.ts CHANGED
@@ -6,7 +6,8 @@ export type { RepoConfig, ServeConfig, FloorResolution, FloorSource } from './re
6
6
  export { serveHub, hubTools } from './serve/serve-hub.ts'
7
7
  export type { Hub, HubDeps, HubGraph, HubTool } from './serve/serve-hub.ts'
8
8
  export { runServe, resolveHubDeps, defaultHubPort } from './serve/run-serve.ts'
9
- export { landingPage, statusPage } from './serve/hub-pages.ts'
9
+ export { withRail, attentionRedCount } from './serve/hub-pages.ts'
10
+ export type { AttentionSection, AttentionLine } from './serve/hub-pages.ts'
10
11
  export { discoverChecks } from './core/discover-checks.ts'
11
12
  export type { DiscoveredCheck } from './core/discover-checks.ts'
12
13
  export { runTasks, selectTasks } from './core/task-runner.ts'
@@ -33,8 +34,8 @@ export type { Ask, CreateOpts } from './asks/asks-queue.ts'
33
34
  // assets surface (folded in from the standalone atelier lib; that npm
34
35
  // package is frozen). A repo's .rv-assets/kit.ts imports defineKit and
35
36
  // declares its kinds; `shipkit assets …` derives the rest.
36
- export { defineKit, defineKind, comfy, canvas, manual, mediaOf, MEDIA_EXTS, geometryPanel, gridPanel, customPanel, validateKit, missingPanelScripts, loadKit, findAsset, allAssets, categoriesOf } from './assets/kit-config.ts'
37
- export type { KitConfig, ResolvedKit, Kind, KindMeta, Media, AssetSpec, GeneratorSpec, ComfyGen, CanvasGen, ManualGen, PanelSpec, GeometryPanelSpec, GridPanelSpec, CustomPanelSpec, FoundAsset, ShipContext, ShipFn } from './assets/kit-config.ts'
37
+ export { defineKit, defineKind, comfy, canvas, manual, gemini, mediaOf, MEDIA_EXTS, geometryPanel, gridPanel, customPanel, validateKit, missingPanelScripts, loadKit, findAsset, allAssets, categoriesOf } from './assets/kit-config.ts'
38
+ export type { KitConfig, ResolvedKit, Kind, KindMeta, Media, AssetSpec, GeneratorSpec, ComfyGen, CanvasGen, ManualGen, GeminiGen, PanelSpec, GeometryPanelSpec, GridPanelSpec, CustomPanelSpec, FoundAsset, ShipContext, ShipFn } from './assets/kit-config.ts'
38
39
  export { identityKinds, renderIdentity, renderIdentityFromMark, NO_PICK_FIX, FAVICON_SIZE, OG_W, OG_H, LOGO_SIZE } from './assets/kits/identity-kit.ts'
39
40
  export type { Brand } from './assets/kits/identity-kit.ts'
40
41
  export { serveStudio, studioRoutes } from './assets/server/studio-server.ts'