rvlib-shipkit 0.28.0 → 0.30.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,17 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.30.0
4
+
5
+ - **The studio asset page is tools on one shared canvas, not tabs on a drawer.** Every metadata family is a toggleable TOOL: `oval`, `legs`, `size` (the geometry editor split into its parts), each repo `customPanel`, `asset json`, `roll meta`. Several can be on at once, the URL records the set (`#<assetId>?tools=a,b`, shareable, restored on load), and the last set per kind is remembered. Kit-declared TABS (`meta.tabs: [{ id, tools }]`) are one-click presets, validated at kit load (an undeclared panel OR an unknown tool id refuses, loud). Panel tools float as draggable cards ON the canvas (a drag clamps inside the stage), `customPanel({ anchor: 'bl' })` starts a card bottom-left, so switching a tab never reflows the stage, and the drawer, its tab row and the bottom json body are deleted. Toggling a custom editor on does fold the variants bar, the one deliberate reflow. Custom panels can draw on the art in place: `api.stageOverlay()` returns an element covering the displayed roll (the tint-mask painter paints THERE, controls stay in its floating card), `api.dropOverlays()` cleans up on unmount, and the host calls it. Variants (rolls) move to a thin resizable right bar (drag handle, width remembered, slim-rail collapse, auto-folds when a custom editor turns on). The prompt keeps its fold. The stage remeasures through a ResizeObserver, so overlays track the image through any layout change, not only window resizes.
6
+ - **Legs are editable again, named, and depth-true.** The tools arc left every leg marker painted over its own drag box (the color pill intercepted the pointer and hid the slot label) and raised the whole family above the sprite. Restored to the original studio scheme: leg DRAG boxes all ride above the sprite and the mask host, so every leg is grabbable even with a mask canvas on; the pills alone carry the game depth preview (far pills behind the sprite, near pills over it) and take no pointer; slot labels (L·front … R·front) are bold and readable while dragging; the one-size resize handle sits on all four legs, not only L·front.
7
+ - **A dedicated `legs` auto tab.** A kit tab that covers legs inside a wider preset suppressed any legs-only view. The studio now always offers a `legs` tab when legs are valid (unless the kit declares its own), because leg names read best with the oval out of the way. An uncovered geometry rest of exactly legs becomes the legs tab itself, never a twin pair.
8
+ - **Floating card positions persist.** Per panel id in localStorage (`hub:panelpos`), re-clamped to the stage on first layout and whenever the stage resizes, so a spot saved on a bigger monitor stays reachable; the kit anchor stays the default for a card never dragged. Per panel rather than per kind: a card's spot is muscle memory about screen room, and resetting it on every kind switch helps nobody. Cards have no resize or collapse affordance, so position is the whole state.
9
+ - **Floating cards are dense.** The hub's chunky default button leaked into panel bodies, which read as oversized controls with fat padding. Card bodies now compact buttons, inputs and labels, and the card frame lost its oversized minimum width and padding.
10
+
11
+ ## 0.29.0
12
+
13
+ - **Creation output states facts instead of listing chores.** A fresh `start` ended on five lines that meant nothing to the person reading them: `skip: lint/typecheck/test (no … script)` about scripts a repo born one second ago has no use for, plus `next:` nags to fill venues and mint identity assets, neither of which anybody has decided on yet. `init` now reports the gate floor as ONE line naming what resolved, and `start` closes with one measured summary (`<name> is ready — N file(s) committed, pushed to <remote>`). Venues raise themselves through the contract checker the day one is declared, and identity through rule SK002 the day the repo grows a public surface. An output that lists undecided work trains the reader to skip all of it.
14
+
3
15
  ## 0.28.0
4
16
 
5
17
  - **The `.claude/` policy files are OPT-IN.** 0.27.0 wrote a pre-approved shell allowlist (`curl *`, `git push*`, `./deploy.sh`) and an attribution silencer into every started repo, gitignored and therefore invisible to review. Those are decisions about what an agent can do in a repo and what its git history says, so they now require the caller to declare a house (`claudePolicy`, which `start` enables when it seeds CLAUDE.md include lines), and the grant prints once by name when it lands. The verify skill still lands for everyone.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rvlib-shipkit",
3
- "version": "0.28.0",
3
+ "version": "0.30.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",
@@ -66,16 +66,24 @@ export type CustomPanelSpec = {
66
66
  id: string
67
67
  /** repo-root-relative ES module path (e.g. .rv-assets/panels/legs-editor.js) */
68
68
  script: string
69
+ /** default corner of the floating card on the canvas (drag overrides) */
70
+ anchor?: 'tl' | 'bl'
69
71
  }
70
72
  export type PanelSpec = GeometryPanelSpec | GridPanelSpec | CustomPanelSpec
71
73
  export const geometryPanel = (p: Omit<GeometryPanelSpec, 'panel'>): GeometryPanelSpec => ({ panel: 'geometry', ...p })
72
74
  export const gridPanel = (p: Omit<GridPanelSpec, 'panel'>): GridPanelSpec => ({ panel: 'grid', ...p })
73
75
  export const customPanel = (p: Omit<CustomPanelSpec, 'panel'>): CustomPanelSpec => ({ panel: 'custom', ...p })
74
76
 
77
+ /** a named preset of studio tools — the studio shows tabs, a tab enables
78
+ * its tool set on one shared canvas */
79
+ export type KindTabTool = 'oval' | 'legs' | 'size' | 'json' | 'meta' | `c:${string}`
80
+ export type KindTab = { id: string; tools: KindTabTool[] }
81
+
75
82
  export type KindMeta = {
76
83
  /** live write-through target, repo-root-relative (e.g. assets/heroes/ground-rects.json) */
77
84
  file?: string
78
85
  panels?: PanelSpec[]
86
+ tabs?: KindTab[]
79
87
  /** ships to an art-normalized folder: sizes are in art-height units */
80
88
  artNormalized?: boolean
81
89
  /** repo-owned semantics gate, run before any metadata write-through —
@@ -164,6 +172,24 @@ export function validateKit(k: KitConfig): string[] {
164
172
  if (typeof p.script !== 'string' || p.script === '') errs.push(`kind '${kind.id}' panel '${p.id}': script path required (repo-root-relative ES module)`)
165
173
  else if (p.script.startsWith('/') || p.script.split('/').includes('..'))
166
174
  errs.push(`kind '${kind.id}' panel '${p.id}': script must be repo-root-relative without '..' (got '${p.script}')`)
175
+ if (p.anchor != null && !['tl', 'bl'].includes(p.anchor)) errs.push(`kind '${kind.id}' panel '${p.id}': anchor must be tl or bl (got '${p.anchor}')`)
176
+ }
177
+ const tabIds = new Set<string>()
178
+ for (const t of kind.meta?.tabs ?? []) {
179
+ if (!/^[a-z0-9][a-z0-9-]*$/.test(t.id ?? '')) errs.push(`kind '${kind.id}': tab id must be url-safe kebab-case (got '${t.id}')`)
180
+ if (tabIds.has(t.id)) errs.push(`kind '${kind.id}': duplicate tab id '${t.id}'`)
181
+ tabIds.add(t.id)
182
+ if (!Array.isArray(t.tools) || t.tools.length === 0 || t.tools.some((x) => typeof x !== 'string'))
183
+ errs.push(`kind '${kind.id}' tab '${t.id}': tools must be a non-empty string array`)
184
+ for (const x of t.tools ?? []) {
185
+ if (typeof x !== 'string') continue
186
+ // a typo must refuse LOUD — a silently dropped tool id makes the whole tab vanish
187
+ if (x.startsWith('c:')) {
188
+ if (!panelIds.has(x.slice(2))) errs.push(`kind '${kind.id}' tab '${t.id}': names custom panel '${x.slice(2)}' which the kind does not declare`)
189
+ } else if (!['oval', 'legs', 'size', 'json', 'meta'].includes(x)) {
190
+ errs.push(`kind '${kind.id}' tab '${t.id}': unknown tool '${x}' (oval | legs | size | json | meta | c:<panelId>)`)
191
+ }
192
+ }
167
193
  }
168
194
  }
169
195
  // derived-asset graph: every from-ref must exist, and the graph is a DAG
@@ -251,8 +251,8 @@ export async function studioRoutes(deps: StudioDeps): Promise<StudioRoutes> {
251
251
  const dir = dirOf(f.asset.id)
252
252
  const pick = await readPick(dir)
253
253
  const rolls = listRolls(dir).map((name) => ({ name, picked: name === pick.pick, alt: pick.alts.includes(name), hasMeta: existsSync(metaPath(dir, name)) }))
254
- const custom = customPanelsOf(f).map((p) => ({ id: p.id, kind: f.kind.id, url: `panel/${encodeURIComponent(f.kind.id)}/${encodeURIComponent(p.id)}.js` }))
255
- return json({ asset: { ...f.asset, prompt: await promptOf(f), cat: f.kind.category, geometry: geometryOf(f) != null, custom }, info: infoOf(f), rolls, pick })
254
+ const custom = customPanelsOf(f).map((p) => ({ id: p.id, kind: f.kind.id, url: `panel/${encodeURIComponent(f.kind.id)}/${encodeURIComponent(p.id)}.js`, anchor: p.anchor }))
255
+ return json({ asset: { ...f.asset, prompt: await promptOf(f), cat: f.kind.category, geometry: geometryOf(f) != null, custom, tabs: f.kind.meta?.tabs ?? [] }, info: infoOf(f), rolls, pick })
256
256
  }
257
257
 
258
258
  if (pathname.startsWith('/panel/')) {
package/src/init-repo.ts CHANGED
@@ -99,11 +99,15 @@ export async function initRepo(root: string, opts: InitOpts = {}): Promise<numbe
99
99
  // `shipkit check --fix SK004`, one code path
100
100
  const g = await gitignorePolicy.fix!({ root, config: defineRepo({ name }) })
101
101
  p(`ok: .gitignore policy block ${g.changed ? 'written' : 'already current'}`)
102
- // show what the gate actually is — a thin floor must be seen at init
103
- // time, not discovered after a bad commit slipped through
102
+ // what the gate actually runs, in ONE line: a thin floor must be seen at
103
+ // init time rather than discovered after a bad commit slipped through.
104
+ // The per-task resolution is diagnostics, and a `skip:` naming a script
105
+ // this repo has no use for yet reads as a chore list on a repo that was
106
+ // born a second ago. `shipkit ci` reports the same facts when they
107
+ // matter, and reports them about a repo that has code in it
104
108
  const floor = resolveFloor(defineRepo({ name }), root)
105
- for (const t of floor.tasks) p(`ok: floor ${t.id} ← ${floor.sources[t.id] ?? 'unknown'}`)
106
- for (const u of floor.unfilled) p(`skip: ${u.id} (${u.reason})`)
109
+ if (floor.tasks.length > 0) p(`ok: gate floor: ${floor.tasks.map((t) => t.id).join(', ')}`)
110
+ else p(`WARN the gate would check NOTHING here (${floor.unfilled.map((u) => u.id).join(', ')} all unresolved) — see \`shipkit rules\``)
107
111
  // every verb comes from the `shipkit` bin — dead on arrival unless the
108
112
  // package is actually installed (bunx runs without adding the dep)
109
113
  if (opts.bin == null) {
@@ -116,7 +120,11 @@ export async function initRepo(root: string, opts: InitOpts = {}): Promise<numbe
116
120
  }
117
121
  // last, so the first commit carries repo.config.ts and the scripts above
118
122
  if (wired && opts.noPublish !== true) await publishRepo(root, { github: opts.github, gh: opts.gh, print: p })
119
- p('next: fill venues/social in repo.config.ts, then `shipkit ci`')
120
- p('next: identity+assets — mint .rv-identity/brand.ts + .rv-assets/kit.ts, `shipkit assets identity` (the identity contract expects public/ favicon+og+logo)')
123
+ // no chore list here. Venues belong in repo.config the day a venue is
124
+ // CHOSEN, and the contract checker fails the gate from that day on.
125
+ // Identity is needed once the repo has a public surface, and rule SK002
126
+ // warns exactly then. Both told at creation time are noise about a
127
+ // decision nobody has made yet. `start` owns the one closing line
128
+ if (opts.noPublish !== true) p('next: `shipkit ci` is the gate — run it to check your work')
121
129
  return 0
122
130
  }
@@ -10,7 +10,7 @@ import { useRef } from 'react'
10
10
  import type { HubSt } from './hub-st.ts'
11
11
  import type { Leg } from './hub-types.ts'
12
12
 
13
- export type Box = { scale: number; x: number; y: number }
13
+ export type Box = { scale: number; x: number; y: number; w: number; h: number }
14
14
 
15
15
  /** vanilla imgBox(): where the letterboxed image actually sits in the stage */
16
16
  export function measureBox(stage: HTMLElement | null, img: HTMLImageElement | null): Box | null {
@@ -20,7 +20,7 @@ export function measureBox(stage: HTMLElement | null, img: HTMLImageElement | nu
20
20
  const scale = Math.min(b.width / img.naturalWidth, b.height / img.naturalHeight)
21
21
  const w = img.naturalWidth * scale
22
22
  const h = img.naturalHeight * scale
23
- return { scale, x: b.left - st.left + (b.width - w) / 2, y: b.top - st.top + (b.height - h) / 2 }
23
+ return { scale, x: b.left - st.left + (b.width - w) / 2, y: b.top - st.top + (b.height - h) / 2, w, h }
24
24
  }
25
25
 
26
26
  const LEG_TAGS = ['L·front', 'L·back', 'R·back', 'R·front']
@@ -69,7 +69,8 @@ const LegBox = observer(({ app, box, i, leg }: { app: HubSt; box: Box; i: number
69
69
  <div className="hip" />
70
70
  <div className="foot" />
71
71
  <div className="tag">{LEG_TAGS[i]}</div>
72
- {i === 0 ? <div className="lh" /> : null}
72
+ {/* one-size-for-all resize, reachable from ANY leg */}
73
+ <div className="lh" />
73
74
  </div>
74
75
  <div
75
76
  className={`pill${far ? '' : ' near'}`}
@@ -83,8 +84,11 @@ export const GeometryLayer = observer(({ app, box }: { app: HubSt; box: Box | nu
83
84
  const m = app.media
84
85
  const start = useRef<DragStart | null>(null)
85
86
  const rect = m.rect
86
- if (!m.hasGeom || rect == null || box == null) return null
87
+ const showOval = m.tools.has('oval')
88
+ const showLegs = m.tools.has('legs')
89
+ if (!m.hasGeom || rect == null || box == null || (!showOval && !showLegs)) return null
87
90
  const px = (v: number): number => v * box.scale
91
+ if (!showOval) return <>{(rect.legs ?? []).map((l, i) => <LegBox key={i} app={app} box={box} i={i} leg={l} />)}</>
88
92
  return (
89
93
  <>
90
94
  <div className="plumb" style={{ display: 'block', left: box.x + px(rect.x) }} />
@@ -128,7 +132,7 @@ export const GeometryLayer = observer(({ app, box }: { app: HubSt; box: Box | nu
128
132
  <div className="arrow" />
129
133
  <div className="sz">{+rect.size.toFixed(2)}m</div>
130
134
  </div>
131
- {(rect.legs ?? []).map((l, i) => <LegBox key={i} app={app} box={box} i={i} leg={l} />)}
135
+ {showLegs ? (rect.legs ?? []).map((l, i) => <LegBox key={i} app={app} box={box} i={i} leg={l} />) : null}
132
136
  </>
133
137
  )
134
138
  })
@@ -136,19 +140,23 @@ export const GeometryLayer = observer(({ app, box }: { app: HubSt; box: Box | nu
136
140
  export const Rectbar = observer(({ app }: { app: HubSt }) => {
137
141
  const m = app.media
138
142
  const rect = m.rect
139
- if (!m.hasGeom || rect == null) return null
143
+ const showSize = m.tools.has('size')
144
+ const showLegs = m.tools.has('legs')
145
+ if (!m.hasGeom || rect == null || (!showSize && !showLegs)) return null
140
146
  const cx = Math.round(rect.x + rect.w / 2)
141
147
  const cy = Math.round(rect.y + rect.h / 2)
142
148
  return (
143
149
  <div id="rectbar" style={{ display: 'flex' }}>
144
- <label>size <input type="number" step={0.1} min={0.1} value={rect.size}
145
- onChange={(e) => { runInAction(() => { rect.size = Math.max(0.1, +e.target.value || rect.size) }); m.saveRect() }} /></label>
146
- <button className="ghost" onClick={() => void m.legsDefault()}>legs</button>
147
- <input type="color" title="leg color" value={/^#[0-9a-f]{6}$/i.test(rect.legColor ?? '') ? rect.legColor : '#c8a070'}
148
- onChange={(e) => { runInAction(() => { rect.legColor = e.target.value }); m.saveRect() }} />
149
- {rect.legs != null
150
- ? <button className="ghost" title="remove legs" onClick={() => { if (confirm('remove all 4 legs?')) m.removeLegs() }}>✕</button>
151
- : null}
150
+ {showSize ? <label>size <input type="number" step={0.1} min={0.1} value={rect.size}
151
+ onChange={(e) => { runInAction(() => { rect.size = Math.max(0.1, +e.target.value || rect.size) }); m.saveRect() }} /></label> : null}
152
+ {showLegs ? <>
153
+ <button className="ghost" onClick={() => void m.legsDefault()}>legs</button>
154
+ <input type="color" title="leg color" value={/^#[0-9a-f]{6}$/i.test(rect.legColor ?? '') ? rect.legColor : '#c8a070'}
155
+ onChange={(e) => { runInAction(() => { rect.legColor = e.target.value }); m.saveRect() }} />
156
+ {rect.legs != null
157
+ ? <button className="ghost" title="remove legs" onClick={() => { if (confirm('remove all 4 legs?')) m.removeLegs() }}>✕</button>
158
+ : null}
159
+ </> : null}
152
160
  <span className="ro">cx{cx} cy{cy} w{Math.round(rect.w)}px</span>
153
161
  </div>
154
162
  )
@@ -10,7 +10,7 @@ import { StrictMode, useEffect } from 'react'
10
10
  import { createRoot } from 'react-dom/client'
11
11
  import { ColCard, ColGrid, GraphView, StatusCard } from './col-card.tsx'
12
12
  import { HubSt } from './hub-st.ts'
13
- import { AssetStage, CatGrid, Drawer, InfoChips, MediaTopbar, RollStrip } from './media-view.tsx'
13
+ import { AssetStage, CatGrid, InfoChips, MediaTopbar, ToolsBar, VariantsBar } from './media-view.tsx'
14
14
  import type { SideAsset } from './hub-types.ts'
15
15
 
16
16
  const app = new HubSt()
@@ -212,10 +212,12 @@ const Main = observer(() => {
212
212
  {errs}
213
213
  <MediaTopbar app={app} />
214
214
  <InfoChips app={app} />
215
- <AssetStage app={app} />
216
- <RollStrip app={app} />
215
+ <ToolsBar app={app} />
216
+ <div className="centerrow">
217
+ <AssetStage app={app} />
218
+ <VariantsBar app={app} />
219
+ </div>
217
220
  <AssetRemarks />
218
- <Drawer app={app} />
219
221
  <div className="help"><kbd>←</kbd><kbd>→</kbd> roll · <kbd>Enter</kbd> pick · <kbd>A</kbd> alt · <kbd>X</kbd> discard — picks ship via the kit's ship hook when present · category title = grid view</div>
220
222
  </main>
221
223
  )
@@ -148,14 +148,26 @@ export class HubSt {
148
148
  if (location.hash.slice(1) !== h) location.hash = h
149
149
  }
150
150
  applyHash(): void {
151
- const h = decodeURIComponent(location.hash.slice(1))
152
- if (h === '') return
151
+ const raw = decodeURIComponent(location.hash.slice(1))
152
+ if (raw === '') return
153
+ // #<assetId>?tools=a,b — the query half is the media tool state
154
+ const qi = raw.indexOf('?')
155
+ const h = qi === -1 ? raw : raw.slice(0, qi)
153
156
  if (h.startsWith('cat-')) {
154
157
  const c = h.slice(4)
155
158
  if (this.media.assets.some((a) => a.cat === c) && this.media.catMode !== c) this.showCat(c, false)
156
159
  return
157
160
  }
158
- if (this.media.assets.some((a) => a.id === h) && this.media.curId !== h) this.selectAsset(h, false)
161
+ const tools = qi === -1 ? null : new URLSearchParams(raw.slice(qi + 1)).get('tools')
162
+ const list = tools == null ? null : tools === '' ? [] : tools.split(',')
163
+ if (this.media.assets.some((a) => a.id === h) && this.media.curId !== h) {
164
+ this.media.pendingTools = list
165
+ this.selectAsset(h, false)
166
+ } else if (this.media.curId === h && list != null) {
167
+ // same asset, new tool set (a pasted share link) — apply NOW; a
168
+ // parked pendingTools would hijack the next selection instead
169
+ this.media.applyTab(list)
170
+ }
159
171
  }
160
172
  /** first heartbeat has the asset list — only then can a hash resolve */
161
173
  afterFirstBeat(): void {
@@ -28,10 +28,12 @@ export type AssetInfo = {
28
28
  kitSource: string
29
29
  }
30
30
 
31
- export type CustomPanelRef = { id: string; kind: string; url: string }
31
+ export type CustomPanelRef = { id: string; kind: string; url: string; anchor?: 'tl' | 'bl' }
32
+ /** a kit-declared preset of tools shown as a studio tab */
33
+ export type KindTabRef = { id: string; tools: string[] }
32
34
 
33
35
  export type AssetDetail = {
34
- asset: { id: string; label?: string; prompt: string; cat: string; geometry: boolean; custom: CustomPanelRef[] } & Record<string, unknown>
36
+ asset: { id: string; label?: string; prompt: string; cat: string; geometry: boolean; custom: CustomPanelRef[]; tabs: KindTabRef[] } & Record<string, unknown>
35
37
  info: AssetInfo
36
38
  rolls: RollRow[]
37
39
  }
@@ -118,14 +118,21 @@ kbd{background:var(--panel2);border:1px solid var(--line);border-radius:4px;padd
118
118
  #ov .sz{position:absolute;left:50%;top:50%;transform:translate(-50%,-120%);font-size:11px;color:var(--gold);
119
119
  background:#000a;padding:0 5px;border-radius:4px;white-space:nowrap}
120
120
  .plumb{position:absolute;width:0;border-left:1px dashed rgba(255,207,92,.5);top:-2000px;bottom:-2000px;z-index:9}
121
+ /* leg DRAG boxes all ride at 10, above the sprite (5) and the mask host
122
+ (.ovlhost 6) — every leg grabbable even with a mask canvas on. depth is
123
+ the PILLS' job, never theirs */
121
124
  .leg{position:absolute;z-index:10;border:1px solid;border-radius:3px}
122
- .leg.far{border-color:var(--blue);z-index:3}
123
- .leg.near{border-color:var(--red);z-index:7}
125
+ .leg.far{border-color:var(--blue)}
126
+ .leg.near{border-color:var(--red)}
124
127
  .leg .hip{position:absolute;top:-3px;left:50%;transform:translateX(-50%);width:6px;height:6px;border-radius:50%;background:currentColor}
125
128
  .leg .foot{position:absolute;bottom:-4px;left:50%;transform:translateX(-50%);width:12px;height:7px;border-radius:50%;background:var(--gold)}
126
129
  .leg .lh{position:absolute;right:-6px;top:-6px;width:11px;height:11px;background:currentColor;border-radius:3px;cursor:nesw-resize}
127
- .leg .tag{position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);font-size:9px;opacity:.85;white-space:nowrap}
128
- .pill{position:absolute;border-radius:999px;z-index:3;opacity:.9}
130
+ .leg .tag{position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);font-size:10px;font-weight:700;color:#eaf2ff;
131
+ text-shadow:0 1px 2px #000c;pointer-events:none;white-space:nowrap}
132
+ .leg.near .tag{color:#ffecec}
133
+ /* resting-pose pill preview, game z-order sandwich: far pills (3) behind
134
+ the sprite (5), near pills (7) over it — never intercept a drag */
135
+ .pill{position:absolute;border-radius:999px;z-index:3;opacity:.9;pointer-events:none}
129
136
  .pill.near{z-index:7}
130
137
  #rectbar{position:absolute;left:14px;bottom:14px;z-index:11;gap:8px;align-items:center;
131
138
  background:#000b;border:1px solid var(--line);border-radius:10px;padding:7px 10px;font-size:12px}
@@ -166,8 +173,7 @@ kbd{background:var(--panel2);border:1px solid var(--line);border-radius:4px;padd
166
173
  .rlabel{position:absolute;left:4px;font-size:9px;color:var(--dim)}
167
174
  .rlabel.b{font-weight:700;color:var(--ink)}
168
175
 
169
- /* ── roll strip ── */
170
- .strip{height:150px;flex:none;border-top:1px solid var(--line);background:var(--panel);display:flex;gap:10px;padding:12px;overflow-x:auto}
176
+ /* ── roll vignettes (shared by the variants bar + category cards) ── */
171
177
  .vig{position:relative;height:100%;aspect-ratio:1;flex:none;border:2px solid var(--line);border-radius:10px;overflow:hidden;cursor:pointer;
172
178
  background:conic-gradient(var(--chk1) 90deg,var(--chk2) 0 180deg,var(--chk1) 0 270deg,var(--chk2) 0) 0/12px 12px}
173
179
  .vig img{width:100%;height:100%;object-fit:contain}
@@ -183,18 +189,40 @@ kbd{background:var(--panel2);border:1px solid var(--line);border-radius:4px;padd
183
189
  .vig.aud{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:6px;font-size:20px}
184
190
  .vig.aud audio{width:100%;height:24px}
185
191
  .vig.gen{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:8px;color:var(--dim);font-size:11px}
186
- .strip .ph{color:var(--dim);align-self:center;padding:0 12px}
187
192
 
188
- /* ── drawer ── */
189
- .drawer{flex:none;border-top:1px solid var(--line);background:var(--panel)}
190
- .tabs{display:flex;gap:2px;padding:6px 12px 0}
191
- .tabs button{background:var(--panel2);color:var(--dim);border:1px solid var(--line);border-bottom:0;border-radius:8px 8px 0 0;
192
- padding:5px 12px;font-size:12px;font-weight:400}
193
- .tabs button.on{color:var(--ink);background:var(--bg)}
194
- .tabs .sp{flex:1}
195
- .tabs .ghost{border:1px solid var(--line);border-radius:8px;font-size:12px;padding:5px 12px}
196
- #drawerbody{padding:10px 12px}
197
- #jsonta{min-height:140px}
193
+ /* ── tools bar + center row (stage column + variants side bar) ── */
194
+ .toolsbar{display:flex;gap:6px;align-items:center;padding:7px 16px;border-bottom:1px solid var(--line);background:var(--panel)}
195
+ .toolsbar .tab{background:transparent;color:var(--dim);border:0;border-bottom:2px solid transparent;border-radius:0;padding:4px 10px;font-size:12.5px;font-weight:650}
196
+ .toolsbar .tab.on{color:var(--ink);border-bottom-color:var(--accent)}
197
+ .toolsbar .tdiv{width:1px;align-self:stretch;background:var(--line);margin:0 8px}
198
+ .toolsbar .tlab{font-size:11px;color:var(--dim);text-transform:uppercase;letter-spacing:.06em;margin-right:4px}
199
+ .toolsbar .chip{background:var(--panel2);color:var(--dim);border:1px solid var(--line);border-radius:999px;padding:4px 13px;font-size:12px;font-weight:600}
200
+ .toolsbar .chip.on{color:var(--ink);background:var(--bg);border-color:var(--accent)}
201
+ .toolsbar .sp{flex:1}
202
+ .toolsbar .ghost{font-size:12px;padding:4px 11px}
203
+ .centerrow{display:flex;flex:1;min-height:0}
204
+ .ovlhost{position:absolute;z-index:6;pointer-events:none}
205
+ .fpanel{position:absolute;z-index:12;min-width:230px;max-width:520px;background:var(--panel);border:1px solid var(--line);
206
+ border-radius:10px;box-shadow:0 10px 30px #000a;display:flex;flex-direction:column}
207
+ .fhead{display:flex;align-items:center;justify-content:space-between;gap:8px;padding:4px 9px;border-bottom:1px solid var(--line);
208
+ cursor:grab;font-size:10.5px;color:var(--dim);text-transform:uppercase;letter-spacing:.06em;user-select:none}
209
+ .fhead .x{background:transparent;color:var(--dim);border:0;padding:0 2px;font-size:12px}
210
+ .fhead .x:hover{color:var(--ink)}
211
+ .fbody{padding:6px 9px;max-height:46vh;overflow:auto}
212
+ /* panel content inherits the hub's chunky default button — inside a card
213
+ everything is DENSE, repo panel scripts get this for free */
214
+ .fbody button{padding:3px 9px;font-size:11.5px;font-weight:600;border-radius:6px}
215
+ .fbody input:not([type=color]),.fbody select{padding:3px 6px;font-size:11.5px}
216
+ .fbody label{font-size:11.5px}
217
+ .fbody .jsonta{width:100%;min-height:160px}
218
+ .vbar{flex:none;border-left:1px solid var(--line);background:var(--panel);display:flex;min-height:0;position:relative}
219
+ .vbar.closed{width:26px;flex-direction:column;align-items:center;justify-content:center;gap:6px;cursor:pointer;color:var(--dim);font-size:11px}
220
+ .vbar .vcount{background:var(--panel2);border:1px solid var(--line);border-radius:999px;padding:2px 6px;font-size:10px}
221
+ .vhandle{position:absolute;left:0;top:0;bottom:0;width:6px;cursor:col-resize;z-index:3}
222
+ .vhandle:hover{background:var(--accent);opacity:.4}
223
+ .vscroll{flex:1;overflow-y:auto;display:flex;flex-direction:column;gap:8px;padding:10px 8px 10px 12px}
224
+ .vbar .vig{width:100%;height:auto}
225
+ .vbar .ph{color:var(--dim);font-size:12px;padding:8px}
198
226
 
199
227
  /* ── asset remark row ── */
200
228
  .assetrem{flex:none;border-top:1px solid var(--line);background:var(--panel);padding:8px 12px}
@@ -54,25 +54,51 @@ export class MediaSvc {
54
54
  allMsg = ''
55
55
 
56
56
  openCats = new Set<string>()
57
- tab: string = 'asset'
58
- drawerOpen = false
57
+ /** active TOOLS — 'oval' | 'legs' | 'size' | 'json' | 'meta' | 'c:<panelId>'.
58
+ * several can be on at once; recorded in the URL (?tools=) and remembered
59
+ * per kind in localStorage */
60
+ tools = new Set<string>()
61
+ /** tools requested by the URL hash, applied by the next select() */
62
+ pendingTools: string[] | null = null
63
+ /** variants side bar: open flag + width (drag-resizable) */
64
+ variantsOpen = true
65
+ variantsW = 150
66
+ /** floating panel positions ON the canvas — per panel id, persisted
67
+ * (hub:panelpos): a card's spot is muscle memory about screen room,
68
+ * shared across kinds. the kit anchor is only the never-dragged default */
69
+ panelPos = new Map<string, { x: number; y: number }>()
59
70
  jsonText = ''
60
71
  jsonMsg = ''
61
72
  /** prompt folded to one ellipsized line (session state, click toggles) */
62
73
  promptOpen = false
63
74
 
64
- /** latched by picking a NON-default tab, until then a sole custom panel is the default */
65
- private tabPicked = false
66
-
67
75
  private hbTimer: ReturnType<typeof setInterval> | null = null
68
76
  private rectTimer: ReturnType<typeof setTimeout> | null = null
69
77
  private promptTimer: ReturnType<typeof setTimeout> | null = null
70
78
  /** last text known saved for the CURRENT asset — an equal edit is not a save */
71
79
  private savedPrompt = ''
80
+ /** stage overlay root (the image box) — registered by AssetStage, DOM only */
81
+ overlayHost: HTMLElement | null = null
72
82
 
73
83
  constructor(private st: HubSt) {
74
- makeAutoObservable<this, 'st' | 'hbTimer' | 'rectTimer' | 'promptTimer' | 'savedPrompt' | 'tabPicked'>(
75
- this, { st: false, hbTimer: false, rectTimer: false, promptTimer: false, savedPrompt: false, tabPicked: false })
84
+ makeAutoObservable<this, 'st' | 'hbTimer' | 'rectTimer' | 'promptTimer' | 'savedPrompt' | 'overlayHost'>(
85
+ this, { st: false, hbTimer: false, rectTimer: false, promptTimer: false, savedPrompt: false, overlayHost: false })
86
+ if (typeof localStorage !== 'undefined') {
87
+ const w = +(localStorage.getItem('hub:variantsW') ?? '')
88
+ if (w > 0) this.variantsW = Math.min(420, Math.max(90, w))
89
+ const pp = localStorage.getItem('hub:panelpos')
90
+ if (pp != null) {
91
+ // no clamp here: positions are STAGE-relative and the stage is not
92
+ // measured yet — FloatPanel re-clamps through movePanel on layout
93
+ try {
94
+ for (const [k, p] of Object.entries(JSON.parse(pp) as Record<string, { x: number; y: number }>))
95
+ if (typeof p?.x === 'number' && typeof p?.y === 'number')
96
+ this.panelPos.set(k, { x: Math.max(0, p.x), y: Math.max(0, p.y) })
97
+ } catch {
98
+ localStorage.removeItem('hub:panelpos') // corrupt blob: drop it, cards fall back to the anchor cascade
99
+ }
100
+ }
101
+ }
76
102
  }
77
103
 
78
104
  start(): void {
@@ -166,15 +192,10 @@ export class MediaSvc {
166
192
  this.savedPrompt = r.asset.prompt
167
193
  this.promptMsg = ''
168
194
  if (this.deterministic) this.n = '1'
169
- // custom panels are the working surfaces, a kind with exactly one
170
- // gets it as the default tab (setTab latches an explicit divergence)
171
- const sole = r.asset.custom.length === 1 ? `c:${r.asset.custom[0]!.id}` : null
172
- if (this.tab.startsWith('c:') && !r.asset.custom.some((c) => `c:${c.id}` === this.tab)) this.tab = sole ?? 'asset'
173
- else if (!this.tabPicked && sole != null) this.tab = sole
195
+ this.applyTools(r)
174
196
  this.pickActive(r)
175
197
  })
176
198
  if (this.hasGeom) await this.loadRect()
177
- void this.loadTab()
178
199
  }
179
200
 
180
201
  private pickActive(r: AssetDetail): void {
@@ -197,7 +218,7 @@ export class MediaSvc {
197
218
  setActive(name: string): void {
198
219
  this.active = name
199
220
  if (this.hasGeom) void this.loadRect()
200
- if (this.tab !== 'asset') void this.loadTab()
221
+ if (this.tools.has('meta')) void this.loadMeta()
201
222
  }
202
223
 
203
224
  async generate(): Promise<void> {
@@ -382,28 +403,101 @@ export class MediaSvc {
382
403
  await this.fillCat()
383
404
  }
384
405
 
385
- // ── drawer: custom panels · asset json (read-only) · roll meta sidecar ─
386
- setTab(tab: string): void {
387
- this.tab = tab
388
- // clicking the sole panel IS the default, only a different pick latches
389
- const c = this.detail?.asset.custom ?? []
390
- if (tab !== (c.length === 1 ? `c:${c[0]!.id}` : null)) this.tabPicked = true
391
- this.drawerOpen = true
392
- void this.loadTab()
393
- }
394
- async loadTab(): Promise<void> {
406
+ // ── tools: which editors are ON — several at once, per-kind memory ────
407
+ private applyTools(r: AssetDetail): void {
408
+ const valid = this.validTools(r)
409
+ const want = this.pendingTools ?? this.prefTools(r.info.kindId) ?? this.defaultTools(r)
410
+ this.pendingTools = null
411
+ this.tools = new Set(want.filter((t) => valid.includes(t)))
412
+ this.syncHash()
413
+ if (this.tools.has('meta')) void this.loadMeta()
414
+ }
415
+ validTools(r: AssetDetail): string[] {
416
+ const geom = r.asset.geometry === true && this.cur?.icon !== true
417
+ return [...(geom ? ['oval', 'legs', 'size'] : []), ...r.asset.custom.map((c) => `c:${c.id}`), 'json', 'meta']
418
+ }
419
+ private defaultTools(r: AssetDetail): string[] {
420
+ return r.asset.geometry === true ? ['oval', 'legs', 'size'] : []
421
+ }
422
+ private prefTools(kindId: string): string[] | null {
423
+ if (typeof localStorage === 'undefined') return null
424
+ const v = localStorage.getItem(`hub:tools:${kindId}`)
425
+ return v == null ? null : v === '' ? [] : v.split(',')
426
+ }
427
+ toggleTool(id: string): void {
428
+ this.tools.has(id) ? this.tools.delete(id) : this.tools.add(id)
429
+ // a custom editor takes the room: variants fold away (reopen by hand)
430
+ if (id.startsWith('c:') && this.tools.has(id)) this.variantsOpen = false
431
+ if (id === 'meta' && this.tools.has('meta')) void this.loadMeta()
432
+ const kind = this.detail?.info.kindId
433
+ if (kind != null && typeof localStorage !== 'undefined') localStorage.setItem(`hub:tools:${kind}`, [...this.tools].join(','))
434
+ this.syncHash()
435
+ }
436
+ /** the hash carries the working state: #<assetId>?tools=a,b — shareable, restored on load */
437
+ private syncHash(): void {
438
+ if (typeof location === 'undefined' || this.curId == null) return
439
+ const t = [...this.tools].join(',')
440
+ const h = `${this.curId}${t === '' ? '' : `?tools=${t}`}`
441
+ if (location.hash.slice(1) !== h) history.replaceState(null, '', `#${h}`)
442
+ }
443
+ /** tabs = kit-declared presets first, then autos for tools no tab covers.
444
+ * a tab swaps the enabled tool SET — the canvas underneath never moves */
445
+ tabsFor(r: AssetDetail): { id: string; tools: string[] }[] {
446
+ const valid = this.validTools(r)
447
+ const declared = r.asset.tabs
448
+ .map((t) => ({ id: t.id, tools: t.tools.filter((x) => valid.includes(x)) }))
449
+ .filter((t) => t.tools.length > 0)
450
+ const covered = new Set(declared.flatMap((t) => t.tools))
451
+ const auto: { id: string; tools: string[] }[] = []
452
+ const geo = ['oval', 'legs', 'size'].filter((t) => valid.includes(t) && !covered.has(t))
453
+ // an uncovered rest of exactly [legs] IS the legs tab — never two identical tabs
454
+ if (geo.length > 0) auto.push({ id: geo.length === 1 && geo[0] === 'legs' ? 'legs' : 'geometry', tools: geo })
455
+ // legs-only view even when a declared preset covers legs — leg names
456
+ // read best with the oval out of the way. a kit-declared legs tab wins
457
+ if (valid.includes('legs') && !declared.some((t) => t.id === 'legs') && !auto.some((t) => t.id === 'legs'))
458
+ auto.push({ id: 'legs', tools: ['legs'] })
459
+ for (const c of r.asset.custom) if (!covered.has(`c:${c.id}`)) auto.push({ id: c.id, tools: [`c:${c.id}`] })
460
+ const data = ['json', 'meta'].filter((t) => !covered.has(t))
461
+ if (data.length > 0) auto.push({ id: 'data', tools: data })
462
+ return [...declared, ...auto]
463
+ }
464
+ applyTab(tools: string[]): void {
465
+ const d = this.detail
466
+ if (d == null) return
467
+ const valid = this.validTools(d)
468
+ this.tools = new Set(tools.filter((t) => valid.includes(t)))
469
+ if (this.tools.has('meta')) void this.loadMeta()
470
+ if (typeof localStorage !== 'undefined') localStorage.setItem(`hub:tools:${d.info.kindId}`, [...this.tools].join(','))
471
+ this.syncHash()
472
+ }
473
+ movePanel(id: string, x: number, y: number): void {
474
+ // clamp INSIDE the stage — a card dragged off-screen has no recovery
475
+ const st = this.overlayHost?.parentElement
476
+ const mx = st != null ? Math.max(0, st.clientWidth - 80) : Number.MAX_SAFE_INTEGER
477
+ const my = st != null ? Math.max(0, st.clientHeight - 40) : Number.MAX_SAFE_INTEGER
478
+ this.panelPos.set(id, { x: Math.min(mx, Math.max(0, x)), y: Math.min(my, Math.max(0, y)) })
479
+ // same per-move write the variants width uses — tiny blob, no debounce needed
480
+ if (typeof localStorage !== 'undefined') localStorage.setItem('hub:panelpos', JSON.stringify(Object.fromEntries(this.panelPos)))
481
+ }
482
+ toggleVariants(): void {
483
+ this.variantsOpen = !this.variantsOpen
484
+ }
485
+ setVariantsW(w: number): void {
486
+ this.variantsW = Math.min(420, Math.max(90, w))
487
+ if (typeof localStorage !== 'undefined') localStorage.setItem('hub:variantsW', String(this.variantsW))
488
+ }
489
+
490
+ // ── docked panels: asset json (read-only) · roll meta sidecar ─────────
491
+ get assetJson(): string {
492
+ return this.detail != null ? JSON.stringify(this.detail.asset, null, 2) : ''
493
+ }
494
+ async loadMeta(): Promise<void> {
395
495
  this.jsonMsg = ''
396
- if (this.tab.startsWith('c:')) return // panel host owns that surface
397
- if (this.tab === 'asset') {
398
- const text = this.detail != null ? JSON.stringify(this.detail.asset, null, 2) : ''
399
- runInAction(() => { this.jsonText = text })
400
- } else {
401
- const r = this.curId != null && this.active != null ? await post('/api/meta', { id: this.curId, name: this.active }) : { text: '' }
402
- runInAction(() => { this.jsonText = r.text ?? '' })
403
- }
496
+ const r = this.curId != null && this.active != null ? await post('/api/meta', { id: this.curId, name: this.active }) : { text: '' }
497
+ runInAction(() => { this.jsonText = r.text ?? '' })
404
498
  }
405
499
  async saveMeta(): Promise<void> {
406
- if (this.tab !== 'meta' || this.curId == null || this.active == null) return
500
+ if (this.curId == null || this.active == null) return
407
501
  const r = await post('/api/meta', { id: this.curId, name: this.active, text: this.jsonText })
408
502
  runInAction(() => { this.jsonMsg = r.error != null ? `FAIL: ${r.error}` : 'saved' })
409
503
  }
@@ -411,10 +505,14 @@ export class MediaSvc {
411
505
  panelApi(c: { kind: string }): Record<string, unknown> {
412
506
  const id = this.curId!
413
507
  const name = this.active!
508
+ const made: HTMLElement[] = []
414
509
  return {
415
510
  assetId: id,
416
511
  kindId: c.kind,
417
512
  roll: name,
513
+ /** the PICKED roll's name (null before any pick) — editors that
514
+ * target shipped art can warn when the shown roll differs */
515
+ picked: this.detail?.rolls.find((x) => x.picked)?.name ?? null,
418
516
  imgUrl: rollUrl(id, name),
419
517
  getMeta: async () => {
420
518
  const r = await post('/api/meta', { id, name })
@@ -425,6 +523,24 @@ export class MediaSvc {
425
523
  const r = await post('/api/meta', { id, name, text: JSON.stringify(data) })
426
524
  if (r.error != null) throw new Error(r.error)
427
525
  },
526
+ // an element covering the DISPLAYED roll image, so an editor can
527
+ // draw ON the art in place while its card keeps the controls.
528
+ // null when no image is on stage (audio, empty)
529
+ stageOverlay: (): HTMLElement | null => {
530
+ if (this.overlayHost == null || this.active == null || isAud(this.active)) return null
531
+ const d = document.createElement('div')
532
+ d.style.position = 'absolute'
533
+ d.style.inset = '0'
534
+ d.style.pointerEvents = 'auto'
535
+ this.overlayHost.appendChild(d)
536
+ made.push(d)
537
+ return d
538
+ },
539
+ /** host calls it on unmount — panels never leak stage overlays */
540
+ dropOverlays: (): void => {
541
+ for (const d of made) d.remove()
542
+ made.length = 0
543
+ },
428
544
  }
429
545
  }
430
546
 
@@ -1,14 +1,14 @@
1
- // Media views — topbar, info chips, stage (image/audio + geometry), roll
2
- // strip, category grid + size-true lineup, drawer (repo-defined custom
3
- // panels · asset json · roll meta). Feature parity with the deleted vanilla
4
- // studio SPA; state lives in MediaSvc (components read and call).
1
+ // Media views — topbar, info chips, tools bar (toggleable editor chips),
2
+ // stage (image/audio + tool overlays), variants side bar (resizable), dock
3
+ // (custom panels · asset json · roll meta), category grid + size-true
4
+ // lineup. State lives in MediaSvc (components read and call).
5
5
  import { runInAction } from 'mobx'
6
6
  import { observer } from 'mobx-react-lite'
7
7
  import { useEffect, useRef, useState, type DragEvent, type ReactNode } from 'react'
8
8
  import type { HubSt } from './hub-st.ts'
9
9
  import { isAud, rollUrl, thumbUrl } from './media-svc.ts'
10
10
  import { GeometryLayer, Rectbar, measureBox, type Box } from './geometry-view.tsx'
11
- import type { CatRow } from './hub-types.ts'
11
+ import type { CatRow, CustomPanelRef } from './hub-types.ts'
12
12
 
13
13
  const TEX = 1024
14
14
  const MIX_UNIT = 24
@@ -53,7 +53,7 @@ export const MediaTopbar = observer(({ app }: { app: HubSt }) => {
53
53
  </select>
54
54
  </label>
55
55
  ) : null}
56
- <button className="ghost" title="add files as rolls (or drag-drop onto the strip)" onClick={() => fileRef.current?.click()}>upload</button>
56
+ <button className="ghost" title="add files as rolls (or drag-drop onto the variants bar)" onClick={() => fileRef.current?.click()}>upload</button>
57
57
  <input type="file" ref={fileRef} multiple style={{ display: 'none' }}
58
58
  accept={m.cur?.media === 'audio' ? '.mp3,.wav,.ogg,.m4a' : '.png,.webp'}
59
59
  onChange={(e) => { if (e.target.files != null) void m.upload(e.target.files); e.target.value = '' }} />
@@ -99,7 +99,14 @@ export const AssetStage = observer(({ app }: { app: HubSt }) => {
99
99
  const remeasure = (): void => setBox(measureBox(stageRef.current, imgRef.current))
100
100
  useEffect(() => {
101
101
  window.addEventListener('resize', remeasure)
102
- return () => window.removeEventListener('resize', remeasure)
102
+ // the stage resizes without a window resize too (dock toggles,
103
+ // variants bar drag) — overlays must follow the letterboxed image
104
+ const ro = new ResizeObserver(remeasure)
105
+ if (stageRef.current != null) ro.observe(stageRef.current)
106
+ return () => {
107
+ window.removeEventListener('resize', remeasure)
108
+ ro.disconnect()
109
+ }
103
110
  }, [])
104
111
  const rolls = m.detail?.rolls ?? []
105
112
  const genHere = m.curId != null && m.pending.includes(m.curId)
@@ -112,46 +119,100 @@ export const AssetStage = observer(({ app }: { app: HubSt }) => {
112
119
  <div className="stageaud" key={`${m.curId}/${shown}`}>🔊<audio controls src={rollUrl(m.curId!, shown)} /></div>
113
120
  )
114
121
  else content = <img className="roll" src={rollUrl(m.curId!, shown)} ref={imgRef} onLoad={remeasure} />
122
+ const imgShown = shown != null && !isAud(shown)
123
+ const measured = imgShown && box != null
115
124
  return (
116
125
  <div className="stage" ref={stageRef}>
117
126
  {content}
118
- {shown != null && !isAud(shown) ? <GeometryLayer app={app} box={box} /> : null}
127
+ {/* overlay root over the letterboxed image — custom panels draw
128
+ editors HERE via api.stageOverlay() (mask painting in place).
129
+ ALWAYS mounted so a panel mounting before the image measures
130
+ still gets its host; it snaps into place on measure */}
131
+ <div className="ovlhost" ref={(el) => { m.overlayHost = el }}
132
+ style={measured ? { left: box.x, top: box.y, width: box.w, height: box.h } : { display: 'none' }} />
133
+ {imgShown ? <GeometryLayer app={app} box={box} /> : null}
119
134
  <Rectbar app={app} />
135
+ <FloatPanels app={app} />
120
136
  </div>
121
137
  )
122
138
  })
123
139
 
124
- export const RollStrip = observer(({ app }: { app: HubSt }) => {
140
+ export const ToolsBar = observer(({ app }: { app: HubSt }) => {
141
+ const m = app.media
142
+ const d = m.detail
143
+ if (d == null || m.curId == null) return null
144
+ const label = (id: string): string =>
145
+ id === 'json' ? 'asset json' : id === 'meta' ? 'roll meta' : id.startsWith('c:') ? id.slice(2) : id
146
+ const cur = [...m.tools].sort().join(',')
147
+ return (
148
+ <div className="toolsbar">
149
+ {/* tabs = tool presets (kit-declared first) — switching swaps the
150
+ enabled set, the canvas underneath never moves */}
151
+ {m.tabsFor(d).map((t) => (
152
+ <button key={t.id} className={`tab${[...t.tools].sort().join(',') === cur ? ' on' : ''}`}
153
+ onClick={() => m.applyTab(t.tools)}>{t.id}</button>
154
+ ))}
155
+ <span className="tdiv" />
156
+ <span className="tlab">tools</span>
157
+ {m.validTools(d).map((id) => (
158
+ <button key={id} className={`chip${m.tools.has(id) ? ' on' : ''}`} onClick={() => m.toggleTool(id)}>{label(id)}</button>
159
+ ))}
160
+ <span className="sp" />
161
+ <button className="ghost" onClick={() => m.toggleVariants()}>
162
+ {m.variantsOpen ? 'variants ▸' : `variants (${d.rolls.length}) ◂`}
163
+ </button>
164
+ </div>
165
+ )
166
+ })
167
+
168
+ // variants live in a THIN resizable side bar — the stage keeps the room.
169
+ // closed = a slim rail with the count; the drag handle resizes; drop = upload
170
+ export const VariantsBar = observer(({ app }: { app: HubSt }) => {
125
171
  const m = app.media
126
172
  const rolls = m.detail?.rolls ?? []
127
173
  const genHere = m.curId != null && m.pending.includes(m.curId)
174
+ const drag = useRef<{ px: number; w: number } | null>(null)
128
175
  const onDrop = (e: DragEvent<HTMLDivElement>): void => { e.preventDefault(); void m.upload(e.dataTransfer.files) }
129
- if (rolls.length === 0) return (
130
- <div className="strip" onDragOver={(e) => e.preventDefault()} onDrop={onDrop}>
131
- {genHere ? <div className="vig gen"><div className="spin" />generating…</div>
132
- : <div className="ph">{m.cur?.manual === true ? 'no files — upload (or drag-drop here)' : 'no rolls — hit Generate'}</div>}
176
+ if (m.curId == null) return null
177
+ if (!m.variantsOpen) return (
178
+ <div className="vbar closed" title="open the variants bar" onClick={() => m.toggleVariants()}>
179
+ <span className="vcount">{rolls.length}</span>◂
133
180
  </div>
134
181
  )
135
182
  return (
136
- <div className="strip" onDragOver={(e) => e.preventDefault()} onDrop={onDrop}>
137
- {/* rolls are newest-first; generation lands where the eye is */}
138
- {genHere ? <div className="vig gen"><div className="spin" />generating…</div> : null}
139
- {rolls.map((x) => (
140
- <div key={x.name}
141
- className={`vig${isAud(x.name) ? ' aud' : ''}${x.name === m.active ? ' active' : ''}${x.picked ? ' picked' : ''}${x.alt ? ' alt' : ''}`}
142
- // hover-preview stays image-only — swapping an <audio> mid-playback would cut the sound
143
- onMouseEnter={() => { if (!isAud(x.name)) runInAction(() => { m.hover = x.name }) }}
144
- onMouseLeave={() => runInAction(() => { m.hover = null })}
145
- onClick={() => m.setActive(x.name)}>
146
- {isAud(x.name) ? <>🔊<audio controls src={rollUrl(m.curId!, x.name)} /></> : <img src={rollUrl(m.curId!, x.name)} />}
147
- {x.picked ? <span className="st">★</span> : null}
148
- <div className="btns">
149
- <button className="b-pick" onClick={(e) => { e.stopPropagation(); void m.pick(x.name) }}>pick</button>
150
- <button className="b-alt" onClick={(e) => { e.stopPropagation(); void m.alt(x.name) }}>alt</button>
151
- <button className="b-del" onClick={(e) => { e.stopPropagation(); void m.discard(x.name) }}>✕</button>
183
+ <div className="vbar" style={{ width: m.variantsW }} onDragOver={(e) => e.preventDefault()} onDrop={onDrop}>
184
+ <div className="vhandle"
185
+ onPointerDown={(e) => {
186
+ drag.current = { px: e.clientX, w: m.variantsW }
187
+ ;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)
188
+ }}
189
+ onPointerMove={(e) => {
190
+ const s = drag.current
191
+ if (s != null) m.setVariantsW(s.w + (s.px - e.clientX))
192
+ }}
193
+ onPointerUp={() => { drag.current = null }} />
194
+ <div className="vscroll">
195
+ {genHere ? <div className="vig gen"><div className="spin" />generating…</div> : null}
196
+ {rolls.length === 0 && !genHere
197
+ ? <div className="ph">{m.cur?.manual === true ? 'no files — drop here' : 'no rolls'}</div> : null}
198
+ {/* rolls are newest-first; generation lands where the eye is */}
199
+ {rolls.map((x) => (
200
+ <div key={x.name}
201
+ className={`vig${isAud(x.name) ? ' aud' : ''}${x.name === m.active ? ' active' : ''}${x.picked ? ' picked' : ''}${x.alt ? ' alt' : ''}`}
202
+ // hover-preview stays image-only — swapping an <audio> mid-playback would cut the sound
203
+ onMouseEnter={() => { if (!isAud(x.name)) runInAction(() => { m.hover = x.name }) }}
204
+ onMouseLeave={() => runInAction(() => { m.hover = null })}
205
+ onClick={() => m.setActive(x.name)}>
206
+ {isAud(x.name) ? <>🔊<audio controls src={rollUrl(m.curId!, x.name)} /></> : <img src={rollUrl(m.curId!, x.name)} />}
207
+ {x.picked ? <span className="st">★</span> : null}
208
+ <div className="btns">
209
+ <button className="b-pick" onClick={(e) => { e.stopPropagation(); void m.pick(x.name) }}>pick</button>
210
+ <button className="b-alt" onClick={(e) => { e.stopPropagation(); void m.alt(x.name) }}>alt</button>
211
+ <button className="b-del" onClick={(e) => { e.stopPropagation(); void m.discard(x.name) }}>✕</button>
212
+ </div>
152
213
  </div>
153
- </div>
154
- ))}
214
+ ))}
215
+ </div>
155
216
  </div>
156
217
  )
157
218
  })
@@ -243,70 +304,135 @@ export const CatGrid = observer(({ app }: { app: HubSt }) => {
243
304
  )
244
305
  })
245
306
 
246
- // ── drawer: custom panels · asset json (read-only) · roll meta ───────────
247
- const PanelHost = observer(({ app }: { app: HubSt }) => {
307
+ // ── dock: the active tools' panels, side by side under the stage ─────────
308
+ const PanelHost = observer(({ app, c }: { app: HubSt; c: CustomPanelRef }) => {
248
309
  const m = app.media
249
310
  const ref = useRef<HTMLDivElement>(null)
250
- const c = m.detail?.asset.custom.find((x) => `c:${x.id}` === m.tab) ?? null
251
- const key = `${m.curId}|${m.active}|${c?.id ?? ''}`
311
+ const key = `${m.curId}|${m.active}|${c.id}`
252
312
  useEffect(() => {
253
313
  const host = ref.current
254
314
  if (host == null) return
255
- if (c == null || m.curId == null || m.active == null) {
256
- host.textContent = c == null ? 'panel not on this asset' : 'no roll selected — generate or pick one first'
315
+ if (m.curId == null || m.active == null) {
316
+ host.textContent = 'no roll selected — generate or pick one first'
257
317
  return
258
318
  }
259
319
  let dead = false
260
320
  let mod: Record<string, unknown> | null = null
321
+ const api = m.panelApi(c)
261
322
  void (async () => {
262
323
  try {
263
324
  // repo-defined editor module, served by the media API
264
325
  mod = (await import(`/media/${c.url}`)) as Record<string, unknown>
265
326
  if (typeof mod.mount !== 'function') throw new Error(`${c.url} must export mount(el, api)`)
266
- if (!dead) await (mod.mount as (el: HTMLElement, api: unknown) => unknown)(host, m.panelApi(c))
327
+ if (!dead) await (mod.mount as (el: HTMLElement, api: unknown) => unknown)(host, api)
267
328
  } catch (e) {
268
- host.textContent = `panel FAILED: ${(e as Error).message}` // loud in the drawer, never blank
329
+ host.textContent = `panel FAILED: ${(e as Error).message}` // loud in the dock, never blank
269
330
  }
270
331
  })()
271
332
  return () => {
272
333
  dead = true
273
334
  if (typeof mod?.unmount === 'function') void (mod.unmount as () => unknown)()
335
+ ;(api.dropOverlays as () => void)()
274
336
  host.innerHTML = ''
275
337
  }
276
338
  }, [key])
277
339
  return <div ref={ref} />
278
340
  })
279
341
 
280
- export const Drawer = observer(({ app }: { app: HubSt }) => {
342
+ // floating panels sit ON the canvas (drag by the title bar), so toggling
343
+ // them never reflows the stage — canvas size/position survive tab switches
344
+ const FloatPanel = observer(({ app, id, title, dx, dy, anchor, children }: {
345
+ app: HubSt; id: string; title: string; dx: number; dy: number; anchor?: 'tl' | 'bl'; children: ReactNode
346
+ }) => {
281
347
  const m = app.media
282
- const customs = m.detail?.asset.custom ?? []
283
- const isCustom = m.tab.startsWith('c:')
348
+ const ref = useRef<HTMLDivElement>(null)
349
+ const drag = useRef<{ px: number; py: number; x: number; y: number } | null>(null)
350
+ const pos = m.panelPos.get(id) ?? null
351
+ // a restored position may come from a bigger stage — re-clamp through
352
+ // movePanel on first layout and on every stage resize, so a card is
353
+ // never restored off screen (RO fires once at observe)
354
+ useEffect(() => {
355
+ const st = ref.current?.parentElement
356
+ if (st == null) return
357
+ const ro = new ResizeObserver(() => {
358
+ const p = m.panelPos.get(id)
359
+ if (p != null) m.movePanel(id, p.x, p.y)
360
+ })
361
+ ro.observe(st)
362
+ return () => ro.disconnect()
363
+ }, [id])
364
+ // no stored position: the anchor decides the corner (bl = bottom-left),
365
+ // both corners keep the anti-collision cascade; the first drag converts
366
+ // to a stored left/top seeded from the measured rect
367
+ const style = pos != null ? { left: pos.x, top: pos.y }
368
+ : anchor === 'bl' ? { left: dx, bottom: dy } : { left: dx, top: dy }
284
369
  return (
285
- <div className="drawer">
286
- <div className="tabs">
287
- {/* customs lead: they are the working surfaces, json tabs are diagnostics */}
288
- {[...customs.map((c) => ({ id: `c:${c.id}`, label: c.id })), { id: 'asset', label: 'asset' }, { id: 'meta', label: 'roll meta' }].map((t) => (
289
- <button key={t.id} className={m.tab === t.id ? 'on' : ''} onClick={() => m.setTab(t.id)}>{t.label}</button>
290
- ))}
291
- <span className="sp" />
292
- {/* names the drawer, not the tab set (a sole custom panel is the default content) */}
293
- <button className="ghost" onClick={() => runInAction(() => { m.drawerOpen = !m.drawerOpen })}>drawer ▾</button>
370
+ <div className="fpanel" style={style} ref={ref}>
371
+ <div className="fhead"
372
+ onPointerDown={(e) => {
373
+ const el = ref.current
374
+ const st = el?.parentElement
375
+ if (el == null || st == null) return
376
+ const cur = pos ?? {
377
+ x: el.getBoundingClientRect().left - st.getBoundingClientRect().left,
378
+ y: el.getBoundingClientRect().top - st.getBoundingClientRect().top,
379
+ }
380
+ drag.current = { px: e.clientX, py: e.clientY, x: cur.x, y: cur.y }
381
+ ;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)
382
+ }}
383
+ onPointerMove={(e) => {
384
+ const s = drag.current
385
+ if (s != null) m.movePanel(id, s.x + e.clientX - s.px, s.y + e.clientY - s.py)
386
+ }}
387
+ onPointerUp={() => { drag.current = null }}>
388
+ <span>{title}</span>
389
+ <button className="x" title="disable this tool" onClick={() => m.toggleTool(id)}>✕</button>
294
390
  </div>
295
- {m.drawerOpen ? (
296
- <div id="drawerbody" className="on">
297
- {isCustom ? <PanelHost app={app} /> : (
298
- <div>
299
- <textarea id="jsonta" spellCheck={false} readOnly={m.tab === 'asset'} value={m.jsonText}
300
- onChange={(e) => runInAction(() => { m.jsonText = e.target.value })} />
301
- <div className="jrow">
302
- {/* kind/asset structure is git-reviewed TS — read-only here */}
303
- <button disabled={m.tab === 'asset'} onClick={() => void m.saveMeta()}>save</button>
304
- <span className="jmsg">{m.jsonMsg}</span>
305
- </div>
306
- </div>
307
- )}
308
- </div>
309
- ) : null}
391
+ <div className="fbody">{children}</div>
310
392
  </div>
311
393
  )
312
394
  })
395
+
396
+ export const FloatPanels = observer(({ app }: { app: HubSt }) => {
397
+ const m = app.media
398
+ const customs = (m.detail?.asset.custom ?? []).filter((c) => m.tools.has(`c:${c.id}`))
399
+ const showJson = m.tools.has('json')
400
+ const showMeta = m.tools.has('meta')
401
+ if (m.curId == null) return null
402
+ let i = -1
403
+ const at = (): { dx: number; dy: number } => { i += 1; return { dx: 14 + i * 34, dy: 14 + i * 34 } }
404
+ return (
405
+ <>
406
+ {customs.map((c) => {
407
+ const p = at()
408
+ return (
409
+ <FloatPanel app={app} key={c.id} id={`c:${c.id}`} title={c.id} dx={p.dx} dy={p.dy} anchor={c.anchor}>
410
+ <PanelHost app={app} c={c} />
411
+ </FloatPanel>
412
+ )
413
+ })}
414
+ {showJson ? (() => {
415
+ const p = at()
416
+ return (
417
+ <FloatPanel app={app} id="json" title="asset json (read-only)" dx={p.dx} dy={p.dy}>
418
+ {/* kind/asset structure is git-reviewed TS — read-only here */}
419
+ <textarea className="jsonta" spellCheck={false} readOnly value={m.assetJson} />
420
+ </FloatPanel>
421
+ )
422
+ })() : null}
423
+ {showMeta ? (() => {
424
+ const p = at()
425
+ return (
426
+ <FloatPanel app={app} id="meta" title="roll meta" dx={p.dx} dy={p.dy}>
427
+ <textarea className="jsonta" spellCheck={false} value={m.jsonText}
428
+ onChange={(e) => runInAction(() => { m.jsonText = e.target.value })} />
429
+ <div className="jrow">
430
+ <button onClick={() => void m.saveMeta()}>save</button>
431
+ <span className="jmsg">{m.jsonMsg}</span>
432
+ </div>
433
+ </FloatPanel>
434
+ )
435
+ })() : null}
436
+ </>
437
+ )
438
+ })
package/src/start-repo.ts CHANGED
@@ -148,6 +148,7 @@ export async function startRepo(name: string, opts: StartOpts = {}): Promise<num
148
148
  }
149
149
  // last line, so what to do next is the last thing read
150
150
  if (unscaffolded) console.error(`next: scaffold the app in ${dir} (package.json), then \`shipkit init\` inside it — the commit gates arm on the first ci run`)
151
+ else console.error(`ok: ${name} is ready — ${await summarize(dir)}`)
151
152
  }
152
153
  // a child process cannot cd its parent shell, so the dir goes to stdout
153
154
  // and a sourced wrapper does the move. Without one, say the move out
@@ -157,3 +158,20 @@ export async function startRepo(name: string, opts: StartOpts = {}): Promise<num
157
158
  console.log(dir)
158
159
  return 0
159
160
  }
161
+
162
+ /** the one closing fact: what this repo IS now, measured rather than
163
+ * narrated. Everything a repo still needs is reported by the gate, about a
164
+ * repo with code in it, on the day it matters */
165
+ async function summarize(dir: string): Promise<string> {
166
+ const git = runner('git', dir, 30_000)
167
+ const files = (await git(['ls-files'])).out.trim().split('\n').filter((l) => l !== '').length
168
+ const head = await git(['log', '-1', '--format=%h'])
169
+ // git merges stderr into stdout, so a missing remote answers with its
170
+ // error text: only a zero exit means that output is a url
171
+ const origin = await git(['remote', 'get-url', 'origin'])
172
+ const pushed = (await git(['rev-parse', '--verify', '@{upstream}'])).code === 0
173
+ const bits = [head.code === 0 ? `${files} file(s) committed` : 'not committed yet']
174
+ if (origin.code === 0) bits.push(pushed ? `pushed to ${origin.out.trim()}` : `remote wired, nothing pushed yet`)
175
+ else bits.push('local only, no remote')
176
+ return bits.join(', ')
177
+ }