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 +12 -0
- package/package.json +1 -1
- package/src/assets/kit-config.ts +26 -0
- package/src/assets/server/studio-server.ts +2 -2
- package/src/init-repo.ts +14 -6
- package/src/serve/hub-app/geometry-view.tsx +22 -14
- package/src/serve/hub-app/hub-app.tsx +6 -4
- package/src/serve/hub-app/hub-st.ts +15 -3
- package/src/serve/hub-app/hub-types.ts +4 -2
- package/src/serve/hub-app/hub.css +45 -17
- package/src/serve/hub-app/media-svc.ts +149 -33
- package/src/serve/hub-app/media-view.tsx +192 -66
- package/src/start-repo.ts +18 -0
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.
|
|
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",
|
package/src/assets/kit-config.ts
CHANGED
|
@@ -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
|
-
//
|
|
103
|
-
// time
|
|
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
|
-
|
|
106
|
-
|
|
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
|
-
|
|
120
|
-
|
|
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
|
-
{
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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,
|
|
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
|
-
<
|
|
216
|
-
<
|
|
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
|
|
152
|
-
if (
|
|
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
|
-
|
|
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)
|
|
123
|
-
.leg.near{border-color:var(--red)
|
|
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:
|
|
128
|
-
|
|
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
|
|
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
|
-
/* ──
|
|
189
|
-
.
|
|
190
|
-
.
|
|
191
|
-
.
|
|
192
|
-
|
|
193
|
-
.
|
|
194
|
-
.
|
|
195
|
-
.
|
|
196
|
-
|
|
197
|
-
|
|
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
|
-
|
|
58
|
-
|
|
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' | '
|
|
75
|
-
this, { st: false, hbTimer: false, rectTimer: false, promptTimer: false, savedPrompt: 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
|
-
|
|
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.
|
|
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
|
-
// ──
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
this.
|
|
392
|
-
void this.
|
|
393
|
-
}
|
|
394
|
-
|
|
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
|
-
|
|
397
|
-
|
|
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.
|
|
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,
|
|
2
|
-
//
|
|
3
|
-
// panels · asset json · roll meta)
|
|
4
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
-
{
|
|
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
|
|
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 (
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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="
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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
|
-
|
|
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
|
-
// ──
|
|
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
|
|
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 (
|
|
256
|
-
host.textContent =
|
|
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,
|
|
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
|
|
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
|
-
|
|
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
|
|
283
|
-
const
|
|
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="
|
|
286
|
-
<div className="
|
|
287
|
-
{
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
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
|
-
{
|
|
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
|
+
}
|