rvlib-shipkit 0.2.0 → 0.3.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,65 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.0 — 2026-07-22
4
+
5
+ New:
6
+
7
+ - identity from a PICKED comfy mark (W2-1): `renderIdentityFromMark` is THE
8
+ identity writer (mark png → logo 1024² master → favicon 512² → og card
9
+ compositing bg + mark left + name/tagline right, fonts shrink-to-fit);
10
+ canvas marks and picked comfy rolls both feed it — one render path.
11
+ `identityKinds(brand)` now carries a BUILT-IN ship hook (`Kind.ship.fn`,
12
+ new — a lib-side ShipFn; `ship.fn` + `ship.via` on one kind is a kit
13
+ error), so picking a logo roll in the studio re-renders the whole set
14
+ automatically. `shipkit assets identity` with a comfy mark renders from
15
+ the current pick and FAILs loud with the next step when nothing is picked
16
+ ("roll + pick in the studio: bun run assets → #identity-logo") — never a
17
+ placeholder. `Brand.markPrompt` = the full roll prompt for the mark.
18
+ Exports: `renderIdentityFromMark`, `NO_PICK_FIX`, `ShipContext`, `ShipFn`.
19
+
20
+ - `shipkit serve` — ONE hub server per repo, ONE port: a no-build landing
21
+ page whose links are the whole toolbox. Routes: `/graph/wiki` (md link
22
+ graph — repo.config `serve.wikiGraph` plugs a custom provider, generic
23
+ scan otherwise) · `/graph/agent` (when `agent/` exists; `serve.agentIndex`
24
+ picks the entrypoint) · `/assets/` (the studio, mounted IN-PROCESS on the
25
+ same port) · `/status` (STATUS.md rendered + a "run check" button that
26
+ shells `shipkit check` and streams the wire lines). The same URLs print
27
+ to stdout as clickable `ok:` lines — the intended wiring is ONE vscode
28
+ folder-open task. Port: `serve.port`, else hashed from the repo name into
29
+ 4800-4979; a taken port is probed via `/hub.json` — the same repo in
30
+ another window exits 0, a DIFFERENT repo fails loud with a fix pointer.
31
+ - studio routes are mountable: `studioRoutes(deps)` returns the route table
32
+ (`handle(req, pathname)`); `serveStudio` (standalone `shipkit assets
33
+ serve`, own port) and the hub's `/assets/` mount share it — one route
34
+ table, two front doors. The studio page now resolves its urls RELATIVE to
35
+ the page (works at `/` and under `/assets/`); custom panel module urls
36
+ are `./panel/…` accordingly.
37
+ - repo.config gains `serve?: { port?, wikiGraph?, agentIndex? }`
38
+ (`ServeConfig`).
39
+ - studio per-asset info surface (W2-2): the detail view shows where every
40
+ roll comes from and where a pick goes — backend, resolved workflow id +
41
+ its `.rv-comfy/` path (or WHY it's unresolved), ship target (built-in /
42
+ hook / record), meta write-through file, kit source. `/api/asset` gains
43
+ an additive `info` block. Deterministic canvas kinds (draw takes no seed
44
+ → identical rolls) are labelled next to Generate and capped to one roll
45
+ instead of pretending to be a slot machine.
46
+ - `shipkit assets check` prints workflow provenance (W2-3): one `ok:` line
47
+ per bound pair with its repo-relative `.{json,ts}` path — there is no
48
+ global workflow store, git owns each repo's pairs; the sharing doctrine
49
+ lives in build/comfyui.md.
50
+ - a second `shipkit serve` launch on an already-owned port now prints the
51
+ full clickable tool URLs (from the owner's `/hub.json`), not just the hub
52
+ root — a re-run terminal is as clickable as a first run.
53
+
54
+ Fixed:
55
+
56
+ - `/status` page: STATUS.md content is `<`-escaped inside the inline
57
+ script — a literal `</script>` in a check line can no longer break the
58
+ page.
59
+
60
+ Breaking: none (0.2.0 consumers upgrade clean; `Kind.ship.fn` and `serve`
61
+ config are additive).
62
+
3
63
  ## 0.2.0 — 2026-07-22
4
64
 
5
65
  The one-runner release: shipkit becomes the all-in-one companion
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rvlib-shipkit",
3
- "version": "0.2.0",
3
+ "version": "0.3.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",
@@ -5,6 +5,8 @@
5
5
  // Kit edits need a studio restart; prompt tweaks don't (overlay).
6
6
  import { existsSync } from 'fs'
7
7
  import { join } from 'path'
8
+ import type { alphaHeight, toPng } from './image-ops.ts'
9
+ import type { GroundRect } from './panels/geometry-rects.ts'
8
10
 
9
11
  export type AssetSpec = {
10
12
  id: string
@@ -61,6 +63,19 @@ export type KindMeta = {
61
63
  validate?: (data: unknown) => string[] | void
62
64
  }
63
65
 
66
+ /** What a ship hook receives when a roll is picked (studio /api/pick). */
67
+ export type ShipContext = {
68
+ assetId: string
69
+ file: string
70
+ asset: AssetSpec
71
+ kind: Kind
72
+ meta: unknown
73
+ rect: GroundRect | null
74
+ projectRoot: string
75
+ img: { toPng: typeof toPng; alphaHeight: typeof alphaHeight }
76
+ }
77
+ export type ShipFn = (ctx: ShipContext) => Promise<string | void>
78
+
64
79
  export type Kind = {
65
80
  id: string
66
81
  category: string
@@ -68,7 +83,9 @@ export type Kind = {
68
83
  generate: GeneratorSpec
69
84
  /** icon kinds get no geometry, thumbnails only */
70
85
  mode?: 'sprite' | 'icon'
71
- ship?: { dir?: string; via?: string }
86
+ /** via = repo hook module path (default .rv-assets/ship.ts) · fn = a
87
+ * built-in lib hook (e.g. identityKinds re-rendering the set) — never both */
88
+ ship?: { dir?: string; via?: string; fn?: ShipFn }
72
89
  meta?: KindMeta
73
90
  }
74
91
 
@@ -92,6 +109,7 @@ export function validateKit(k: KitConfig): string[] {
92
109
  if (kindIds.has(kind.id)) errs.push(`duplicate kind id '${kind.id}'`)
93
110
  kindIds.add(kind.id)
94
111
  if (kind.generate?.backend === 'comfy' && k.comfy == null) errs.push(`kind '${kind.id}' uses the comfy backend but the kit has no comfy config`)
112
+ 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`)
95
113
  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`)
96
114
  for (const a of kind.assets ?? []) {
97
115
  if (!a.id || typeof a.prompt !== 'string') errs.push(`kind '${kind.id}': asset needs id + prompt (got ${JSON.stringify(a).slice(0, 80)})`)
@@ -1,12 +1,16 @@
1
- // kits/identity — favicon / logo / OG as asset kinds. Two paths:
2
- // - canvas mark: deterministic, instant — `renderIdentity` writes the whole
3
- // set headlessly (the CLI `identity` command).
4
- // - comfy mark: the kinds go through the normal roll-and-pick studio loop
5
- // (a placeholder-grade logo auto-accepted is a non-goal).
1
+ // kits/identity — favicon / logo / OG as asset kinds. One render path
2
+ // (renderIdentityFromMark: mark png → logo master 1024², favicon 512², og
3
+ // card), two ways to get the mark:
4
+ // - canvas mark: deterministic, instant — `shipkit assets identity` draws it
5
+ // headlessly and renders the set.
6
+ // - comfy mark: roll-and-pick in the studio; the kind carries a BUILT-IN ship
7
+ // hook, so picking a logo roll re-renders the whole identity set from the
8
+ // picked png. Headless `identity` uses the current pick — no pick yet is a
9
+ // loud FAIL, never a placeholder.
6
10
  // Outputs are COMMITTED: public/favicon.png (512² master), public/og.png
7
11
  // (1200×630), public/logo.png. Brand data is per-repo and never defaults
8
12
  // across repos.
9
- import { createCanvas, type SKRSContext2D } from '@napi-rs/canvas'
13
+ import { createCanvas, loadImage, type Image, type SKRSContext2D } from '@napi-rs/canvas'
10
14
  import { mkdirSync } from 'fs'
11
15
  import { dirname, join } from 'path'
12
16
  import sharp from 'sharp'
@@ -19,7 +23,11 @@ export type Brand = {
19
23
  palette: { bg: string; accent: string; ink: string }
20
24
  /** the mark: a canvas draw (deterministic) or a comfy generator (roll-and-pick) */
21
25
  mark: CanvasGen | ComfyGen
22
- /** og layout hook — default: bg fill, mark left, name+tagline right */
26
+ /** comfy marks: the FULL generation prompt for identity-logo rolls
27
+ * (style included — the studio shows/edits exactly this) */
28
+ markPrompt?: string
29
+ /** og layout hook — replaces the WHOLE default card (bg + mark left +
30
+ * name/tagline right); the hook owns compositing the mark if it wants one */
23
31
  drawOg?: (ctx: SKRSContext2D, brand: Brand, size: { w: number; h: number }) => void
24
32
  }
25
33
 
@@ -28,36 +36,54 @@ export const OG_W = 1200
28
36
  export const OG_H = 630
29
37
  export const LOGO_SIZE = 1024
30
38
 
39
+ /** the one next step when a comfy mark has no picked roll yet */
40
+ export const NO_PICK_FIX = 'roll + pick in the studio: bun run assets → #identity-logo'
41
+
31
42
  export function identityKinds(brand: Brand): Kind[] {
32
43
  return [
33
44
  defineKind({
34
45
  id: 'identity-logo', category: 'Identity', mode: 'icon',
35
- assets: [{ id: 'identity-logo', prompt: `logo mark for ${brand.name}`, label: 'logo' }],
46
+ assets: [{ id: 'identity-logo', prompt: brand.markPrompt ?? `logo mark for ${brand.name}`, label: 'logo' }],
36
47
  generate: brand.mark,
48
+ // built-in ship: picking a logo roll re-renders the WHOLE identity set
49
+ ship: { fn: async (ctx) => (await renderIdentityFromMark(brand, ctx.projectRoot, await sharp(ctx.file).png().toBuffer())).join(' + ') },
37
50
  }),
38
51
  ]
39
52
  }
40
53
 
41
- const defaultOg = (ctx: SKRSContext2D, brand: Brand, size: { w: number; h: number }): void => {
54
+ // shrink px until text fits maxW (og cards must never clip the tagline)
55
+ const fitFont = (ctx: SKRSContext2D, text: string, px: number, maxW: number, bold = false): void => {
56
+ for (; px > 12; px -= 2) {
57
+ ctx.font = `${bold ? 'bold ' : ''}${px}px sans-serif`
58
+ if (ctx.measureText(text).width <= maxW) return
59
+ }
60
+ }
61
+
62
+ const defaultOg = (ctx: SKRSContext2D, brand: Brand, size: { w: number; h: number }, mark: Image): void => {
42
63
  ctx.fillStyle = brand.palette.bg
43
64
  ctx.fillRect(0, 0, size.w, size.h)
44
65
  ctx.fillStyle = brand.palette.accent
45
66
  ctx.fillRect(0, size.h - 14, size.w, 14)
67
+ const pad = 72
68
+ const markSize = size.h - pad * 2
69
+ ctx.drawImage(mark, pad, pad, markSize, markSize)
70
+ const textX = pad + markSize + 64
71
+ const maxW = size.w - textX - 48
46
72
  ctx.fillStyle = brand.palette.ink
47
- ctx.font = `bold ${Math.round(size.h / 6)}px sans-serif`
73
+ fitFont(ctx, brand.name, Math.round(size.h / 6), maxW, true)
48
74
  ctx.textBaseline = 'middle'
49
- ctx.fillText(brand.name, 64, size.h / 2 - (brand.tagline != null ? size.h / 12 : 0))
75
+ ctx.fillText(brand.name, textX, size.h / 2 - (brand.tagline != null ? size.h / 12 : 0))
50
76
  if (brand.tagline != null) {
51
- ctx.font = `${Math.round(size.h / 14)}px sans-serif`
52
77
  ctx.fillStyle = brand.palette.accent
53
- ctx.fillText(brand.tagline, 66, size.h / 2 + size.h / 9)
78
+ fitFont(ctx, brand.tagline, Math.round(size.h / 14), maxW)
79
+ ctx.fillText(brand.tagline, textX + 2, size.h / 2 + size.h / 9)
54
80
  }
55
81
  }
56
82
 
57
- /** Headless render of the full identity set — canvas marks only (a comfy
58
- * mark goes through the studio; this errors loud instead of guessing). */
59
- export async function renderIdentity(brand: Brand, root: string): Promise<string[]> {
60
- if (brand.mark.backend !== 'canvas') throw new Error(`brand '${brand.name}' uses a comfy mark — roll + pick it in the studio; 'identity' renders canvas marks only`)
83
+ /** THE identity writer — any mark png (canvas draw or picked comfy roll) →
84
+ * public/logo.png (1024² master) + public/favicon.png (512² resize) +
85
+ * public/og.png (default card: bg + mark left + name/tagline right). */
86
+ export async function renderIdentityFromMark(brand: Brand, root: string, markPng: Buffer): Promise<string[]> {
61
87
  const out: string[] = []
62
88
  const write = async (rel: string, buf: Buffer): Promise<void> => {
63
89
  const path = join(root, rel)
@@ -65,16 +91,29 @@ export async function renderIdentity(brand: Brand, root: string): Promise<string
65
91
  await Bun.write(path, buf)
66
92
  out.push(rel)
67
93
  }
68
- // logo master — the mark draws into a square canvas
69
- const logo = createCanvas(LOGO_SIZE, LOGO_SIZE)
70
- await brand.mark.draw(logo.getContext('2d'), { id: 'identity-logo', prompt: '' })
71
- const logoPng = logo.toBuffer('image/png')
94
+ const logoPng = await sharp(markPng)
95
+ .resize(LOGO_SIZE, LOGO_SIZE, { fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 0 } })
96
+ .png().toBuffer()
72
97
  await write('public/logo.png', logoPng)
73
98
  // favicon = logo master resized to 512² (browsers derive the rest)
74
99
  await write('public/favicon.png', await sharp(logoPng).resize(FAVICON_SIZE, FAVICON_SIZE).png().toBuffer())
75
- // og card
76
100
  const og = createCanvas(OG_W, OG_H)
77
- ;(brand.drawOg ?? defaultOg)(og.getContext('2d'), brand, { w: OG_W, h: OG_H })
101
+ const ctx = og.getContext('2d')
102
+ if (brand.drawOg != null) brand.drawOg(ctx, brand, { w: OG_W, h: OG_H })
103
+ else defaultOg(ctx, brand, { w: OG_W, h: OG_H }, await loadImage(logoPng))
78
104
  await write('public/og.png', og.toBuffer('image/png'))
79
105
  return out
80
106
  }
107
+
108
+ /** Headless render of the full identity set. Canvas marks draw themselves;
109
+ * comfy marks need `markFile` = the PICKED roll (the caller resolves the
110
+ * pick — run-assets does) and FAIL loud without one. */
111
+ export async function renderIdentity(brand: Brand, root: string, opts?: { markFile?: string }): Promise<string[]> {
112
+ if (brand.mark.backend === 'canvas') {
113
+ const logo = createCanvas(LOGO_SIZE, LOGO_SIZE)
114
+ await brand.mark.draw(logo.getContext('2d'), { id: 'identity-logo', prompt: '' })
115
+ return renderIdentityFromMark(brand, root, logo.toBuffer('image/png'))
116
+ }
117
+ if (opts?.markFile == null) throw new Error(`brand '${brand.name}' uses a comfy mark and no roll is picked — ${NO_PICK_FIX}`)
118
+ return renderIdentityFromMark(brand, root, await sharp(opts.markFile).png().toBuffer())
119
+ }
@@ -2,13 +2,13 @@
2
2
  // parsing; these plain functions own the logic, print through injected
3
3
  // sinks, and return exit codes — 0 ok · 1 fail).
4
4
  import { existsSync } from 'fs'
5
- import { join } from 'path'
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
8
  import { loadWorkflows } from './gen/workflow-loader.ts'
9
9
  import { allAssets, findAsset, loadKit, type ResolvedKit } from './kit-config.ts'
10
- import { renderIdentity, type Brand } from './kits/identity-kit.ts'
11
- import { assetDir, listRolls, readPromptOverlay } from './roll-workspace.ts'
10
+ import { NO_PICK_FIX, renderIdentity, type Brand } from './kits/identity-kit.ts'
11
+ import { assetDir, listRolls, readPick, readPromptOverlay } from './roll-workspace.ts'
12
12
  import { page } from './server/studio-page.ts'
13
13
  import { serveStudio } from './server/studio-server.ts'
14
14
 
@@ -91,7 +91,19 @@ export const runAssetsIdentity = (root: string, io: AssetsIo): Promise<number> =
91
91
  if (!existsSync(brandPath)) die('no .rv-identity/brand.ts — create one exporting a Brand (name, palette, mark)')
92
92
  const mod = (await import(brandPath)) as { default?: Brand }
93
93
  if (mod.default == null) die('.rv-identity/brand.ts must default-export the Brand')
94
- const written = await renderIdentity(mod.default!, root)
94
+ const brand = mod.default!
95
+ let markFile: string | undefined
96
+ if (brand.mark.backend === 'comfy') {
97
+ // comfy marks render from the PICKED identity-logo roll — the kit
98
+ // owns the workspace where picks live
99
+ const kit = await loadKit(root).catch((e: Error) => die(`comfy mark needs the kit's roll workspace — ${e.message}`))
100
+ const dir = assetDir(join(root, kit.workspace), 'identity-logo')
101
+ const pick = await readPick(dir)
102
+ if (pick.pick == null) die(`brand '${brand.name}' uses a comfy mark and none is picked — ${NO_PICK_FIX}`)
103
+ markFile = join(dir, pick.pick!)
104
+ if (!existsSync(markFile)) die(`picked roll ${pick.pick} vanished from ${dir} — re-pick in the studio`)
105
+ }
106
+ const written = await renderIdentity(brand, root, { markFile })
95
107
  for (const w of written) io.print(`ok: ${w}`)
96
108
  return 0
97
109
  })
@@ -105,7 +117,15 @@ export const runAssetsCheck = (root: string, io: AssetsIo): Promise<number> =>
105
117
  if (kit.comfy != null) {
106
118
  try {
107
119
  const wfs = await loadWorkflows(join(root, kit.comfy.workflows))
108
- io.print(`ok: ${wfs.size} workflow pair(s) bound (${[...wfs.keys()].join(', ')})`)
120
+ io.print(`ok: ${wfs.size} workflow pair(s) bound`)
121
+ // provenance: where each pair lives + that both halves validated
122
+ // (loadWorkflows throws on any invalid pair, so reaching here = valid).
123
+ // There is no global workflow store — git owns each repo's pairs; the
124
+ // house library (wiki/build/comfyui/references) is the copy-from source.
125
+ for (const wf of wfs.values()) {
126
+ const rel = relative(root, wf.jsonPath).replace(/\.json$/, '')
127
+ io.print(`ok: workflow '${wf.id}' → ${rel}.{json,ts} (pair valid)`)
128
+ }
109
129
  } catch (e) {
110
130
  io.print(`FAIL: ${(e as Error).message}`)
111
131
  bad++
@@ -42,6 +42,14 @@ button.ghost{background:var(--panel2);color:var(--ink);border:1px solid var(--li
42
42
  button:disabled{opacity:.45;cursor:default}
43
43
  select,input[type=number]{background:var(--bg);color:var(--ink);border:1px solid var(--line);border-radius:8px;padding:7px}
44
44
  .status{font-size:12px;color:var(--dim);min-height:16px}
45
+ .detnote{font-size:11px;color:var(--teal);line-height:1.25;max-width:150px}
46
+ /* ── per-asset info surface (W2-2) ── */
47
+ .info{display:none;flex-wrap:wrap;gap:6px;align-items:center;padding:6px 16px;border-bottom:1px solid var(--line);background:var(--panel2)}
48
+ .info .chip{display:inline-flex;align-items:center;gap:5px;background:var(--bg);border:1px solid var(--line);border-radius:6px;
49
+ padding:2px 8px;font-size:11px;color:var(--ink);max-width:360px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
50
+ .info .chip b{color:var(--accent);font-weight:650;letter-spacing:.05em;text-transform:uppercase;font-size:10px}
51
+ .info .chip.warn{border-color:var(--red);color:#f0c0c0}.info .chip.warn b{color:var(--red)}
52
+ .info .chip code{font:11px ui-monospace,Menlo,monospace;color:var(--dim)}
45
53
  .errs{background:#3a1717;border-bottom:1px solid #5a2222;color:#f0c0c0;font:12px/1.5 ui-monospace,Menlo,monospace;
46
54
  padding:6px 16px;white-space:pre-wrap;display:none}
47
55
  .stage{position:relative;flex:1;min-height:0;display:flex;align-items:center;justify-content:center;padding:18px;overflow:hidden;
@@ -130,6 +138,7 @@ kbd{background:var(--panel2);border:1px solid var(--line);border-radius:4px;padd
130
138
  <div class=ctl>
131
139
  <select id=n><option>1</option><option selected>3</option><option>6</option><option>9</option><option>12</option></select>
132
140
  <button id=go disabled>Generate</button>
141
+ <div id=detnote class=detnote style="display:none">deterministic — rolls identical</div>
133
142
  <button id=qclear class=ghost title="drop queued jobs">clear queue</button>
134
143
  </div>
135
144
  <div class=promptwrap>
@@ -139,6 +148,7 @@ kbd{background:var(--panel2);border:1px solid var(--line);border-radius:4px;padd
139
148
  </div>
140
149
  <div class=ctl><div class=status id=status></div></div>
141
150
  </div>
151
+ <div class=info id=info></div>
142
152
  <div class=stage id=stage><div class=ph>Pick an asset on the left, then Generate.</div>
143
153
  <div class=plumb id=plumbL></div><div class=plumb id=plumbR></div>
144
154
  <div id=ov><div class=hdl></div><div class=arrow></div><div class=sz id=ovsz></div></div>
@@ -180,10 +190,11 @@ let lastSideSig=null,lastRollSig=null,catMode=null,lastCatSig=null,catRows=[]
180
190
  let rect=null,rectRatio=0.6,hasGeom=false,saveTimer=null
181
191
  const TEX=1024,MIX_UNIT=24,MIX_GROUND=44
182
192
  const V=()=>Date.now()>>13 // ~8s cache buckets: thumbs refresh without unique-URL spam
183
- const j=async(u,o)=>{const r=await fetch(u,o);return r.json()}
193
+ const R=u=>u.replace(/^\\//,'') // resolve against the page URL: '/' standalone AND '/assets/' under the shipkit hub
194
+ const j=async(u,o)=>{const r=await fetch(R(u),o);return r.json()}
184
195
  const post=(u,b)=>j(u,{method:'POST',body:JSON.stringify(b)})
185
- const imgUrl=(id,n)=>'/img?id='+encodeURIComponent(id)+'&name='+encodeURIComponent(n)+'&t='+V()
186
- const thumbUrl=id=>'/api/thumb?id='+encodeURIComponent(id)+'&t='+V()
196
+ const imgUrl=(id,n)=>R('/img?id='+encodeURIComponent(id)+'&name='+encodeURIComponent(n)+'&t='+V())
197
+ const thumbUrl=id=>R('/api/thumb?id='+encodeURIComponent(id)+'&t='+V())
187
198
  const esc=s=>String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/"/g,'&quot;')
188
199
  const jsq=s=>String(s).replace(/\\\\/g,'\\\\\\\\').replace(/'/g,"\\\\'")
189
200
  // ── hash deep-links: #<asset-id> or #cat-<Category> ──
@@ -212,6 +223,24 @@ function renderSide(list){
212
223
  '</b><span>'+a.rolls+' rolls</span></div><div class="dot '+(a.picked?'done':(a.rolls?'some':''))+'"></div></div>').join('')
213
224
  }).join('')
214
225
  }
226
+ // ── per-asset info surface (W2-2): backend, workflow provenance, ship target,
227
+ // meta file, kit source — so 'bun assets' is never a blank stare ──
228
+ function renderInfo(info){
229
+ const box=$('#info')
230
+ if(!info){box.style.display='none';box.innerHTML='';return}
231
+ const chip=(k,v,cls)=>'<span class="chip'+(cls?' '+cls:'')+'"><b>'+esc(k)+'</b> '+v+'</span>'
232
+ const chips=[chip('backend',esc(info.backend))]
233
+ if(info.workflow){const w=info.workflow
234
+ if(w.id)chips.push(chip('workflow',esc(w.id)+(w.path?' <code>'+esc(w.path)+'</code>':'')))
235
+ else chips.push(chip('workflow',esc(w.note||'unresolved'),'warn'))}
236
+ const sh=info.ship
237
+ const shv=sh.mode==='hook'?'<code>'+esc(sh.path)+'</code>':
238
+ sh.mode==='built-in'?'built-in hook — '+esc(sh.note||''):esc(sh.note||'pick.json only')
239
+ chips.push(chip('ship',shv))
240
+ if(info.metaFile)chips.push(chip('meta','<code>'+esc(info.metaFile)+'</code>'))
241
+ chips.push(chip('kit','<code>'+esc(info.kitSource)+'</code> · '+esc(info.kindId)))
242
+ box.innerHTML=chips.join('');box.style.display='flex'
243
+ }
215
244
  // ── asset view ──
216
245
  async function selectAsset(id,writeHash=true){
217
246
  exitCat()
@@ -230,6 +259,10 @@ async function selectAsset(id,writeHash=true){
230
259
  renderCustomTabs()
231
260
  $('#prompt').value=r.asset?r.asset.prompt:a.prompt
232
261
  $('#go').disabled=false;$('#saveprompt').disabled=false;$('#promptmsg').textContent=''
262
+ // deterministic canvas kinds: a slot machine with one outcome — cap rolls at 1
263
+ const det=!!(r.info&&r.info.deterministic),n=$('#n')
264
+ n.disabled=det;if(det)n.value='1';$('#detnote').style.display=det?'':'none'
265
+ renderInfo(r.info)
233
266
  await renderRolls(true);loadTab()
234
267
  }
235
268
  function setStage(src){
@@ -420,7 +453,7 @@ window.addEventListener('resize',positionOverlay)
420
453
  async function showCat(c,writeHash=true){
421
454
  catMode=c;cur=null;lastSideSig=null;lastCatSig=null
422
455
  if(writeHash)setHash('cat-'+c)
423
- $('#topbar').style.display='none';$('#stage').style.display='none';$('#strip').style.display='none'
456
+ $('#topbar').style.display='none';$('#stage').style.display='none';$('#strip').style.display='none';$('#info').style.display='none'
424
457
  $('#catgrid').style.display='block';$('#allcat').textContent=c
425
458
  openCats.add(c);renderSide(assets)
426
459
  await fillCat(true)