rvlib-shipkit 0.2.0 → 0.3.1

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,76 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.1 — 2026-07-22
4
+
5
+ - fix: `react-dom` was missing from dependencies AND the CLI entry loaded
6
+ the react/ink chain eagerly — every command crashed in a fresh consumer
7
+ repo without react-dom (found by the rabbits-vs-sheeps swap in the field,
8
+ confirmed against the 0.3.0 tarball). tui now lazy-loads its body; deps
9
+ carry react-dom. Proven by a fresh-repo install test (init → ci green in
10
+ a react-less repo).
11
+ - fix: `init` warns loudly when rvlib-shipkit is not installed in the repo
12
+ (the minted scripts call its bin — previously exit 127 with no hint).
13
+
14
+ ## 0.3.0 — 2026-07-22
15
+
16
+ New:
17
+
18
+ - identity from a PICKED comfy mark (W2-1): `renderIdentityFromMark` is THE
19
+ identity writer (mark png → logo 1024² master → favicon 512² → og card
20
+ compositing bg + mark left + name/tagline right, fonts shrink-to-fit);
21
+ canvas marks and picked comfy rolls both feed it — one render path.
22
+ `identityKinds(brand)` now carries a BUILT-IN ship hook (`Kind.ship.fn`,
23
+ new — a lib-side ShipFn; `ship.fn` + `ship.via` on one kind is a kit
24
+ error), so picking a logo roll in the studio re-renders the whole set
25
+ automatically. `shipkit assets identity` with a comfy mark renders from
26
+ the current pick and FAILs loud with the next step when nothing is picked
27
+ ("roll + pick in the studio: bun run assets → #identity-logo") — never a
28
+ placeholder. `Brand.markPrompt` = the full roll prompt for the mark.
29
+ Exports: `renderIdentityFromMark`, `NO_PICK_FIX`, `ShipContext`, `ShipFn`.
30
+
31
+ - `shipkit serve` — ONE hub server per repo, ONE port: a no-build landing
32
+ page whose links are the whole toolbox. Routes: `/graph/wiki` (md link
33
+ graph — repo.config `serve.wikiGraph` plugs a custom provider, generic
34
+ scan otherwise) · `/graph/agent` (when `agent/` exists; `serve.agentIndex`
35
+ picks the entrypoint) · `/assets/` (the studio, mounted IN-PROCESS on the
36
+ same port) · `/status` (STATUS.md rendered + a "run check" button that
37
+ shells `shipkit check` and streams the wire lines). The same URLs print
38
+ to stdout as clickable `ok:` lines — the intended wiring is ONE vscode
39
+ folder-open task. Port: `serve.port`, else hashed from the repo name into
40
+ 4800-4979; a taken port is probed via `/hub.json` — the same repo in
41
+ another window exits 0, a DIFFERENT repo fails loud with a fix pointer.
42
+ - studio routes are mountable: `studioRoutes(deps)` returns the route table
43
+ (`handle(req, pathname)`); `serveStudio` (standalone `shipkit assets
44
+ serve`, own port) and the hub's `/assets/` mount share it — one route
45
+ table, two front doors. The studio page now resolves its urls RELATIVE to
46
+ the page (works at `/` and under `/assets/`); custom panel module urls
47
+ are `./panel/…` accordingly.
48
+ - repo.config gains `serve?: { port?, wikiGraph?, agentIndex? }`
49
+ (`ServeConfig`).
50
+ - studio per-asset info surface (W2-2): the detail view shows where every
51
+ roll comes from and where a pick goes — backend, resolved workflow id +
52
+ its `.rv-comfy/` path (or WHY it's unresolved), ship target (built-in /
53
+ hook / record), meta write-through file, kit source. `/api/asset` gains
54
+ an additive `info` block. Deterministic canvas kinds (draw takes no seed
55
+ → identical rolls) are labelled next to Generate and capped to one roll
56
+ instead of pretending to be a slot machine.
57
+ - `shipkit assets check` prints workflow provenance (W2-3): one `ok:` line
58
+ per bound pair with its repo-relative `.{json,ts}` path — there is no
59
+ global workflow store, git owns each repo's pairs; the sharing doctrine
60
+ lives in build/comfyui.md.
61
+ - a second `shipkit serve` launch on an already-owned port now prints the
62
+ full clickable tool URLs (from the owner's `/hub.json`), not just the hub
63
+ root — a re-run terminal is as clickable as a first run.
64
+
65
+ Fixed:
66
+
67
+ - `/status` page: STATUS.md content is `<`-escaped inside the inline
68
+ script — a literal `</script>` in a check line can no longer break the
69
+ page.
70
+
71
+ Breaking: none (0.2.0 consumers upgrade clean; `Kind.ship.fn` and `serve`
72
+ config are additive).
73
+
3
74
  ## 0.2.0 — 2026-07-22
4
75
 
5
76
  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.1",
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",
@@ -29,6 +29,7 @@
29
29
  "mobx": "^6.16.1",
30
30
  "mobx-react-lite": "^4.1.1",
31
31
  "react": "^19.2.7",
32
+ "react-dom": "^19.2.7",
32
33
  "rvlib-ts-tools": "^0.2.0",
33
34
  "sharp": "^0.35.3"
34
35
  }
@@ -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)