rvlib-shipkit 0.10.0 → 0.13.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.
Files changed (61) hide show
  1. package/CHANGELOG.md +204 -0
  2. package/README.md +17 -3
  3. package/package.json +5 -2
  4. package/src/asks/asks-queue.ts +42 -0
  5. package/src/assets/kit-config.ts +2 -3
  6. package/src/assets/roll-workspace.ts +2 -1
  7. package/src/assets/run-assets.ts +0 -11
  8. package/src/assets/server/studio-server.ts +7 -24
  9. package/src/cli/cmd_Assets.ts +3 -14
  10. package/src/cli/cmd_Check.ts +22 -4
  11. package/src/cli/cmd_Needs.ts +47 -0
  12. package/src/cli/cmd_Reflect.ts +116 -0
  13. package/src/cli/cmd_Rules.ts +32 -0
  14. package/src/cli/cmd_Serve.ts +1 -1
  15. package/src/cli.ts +7 -2
  16. package/src/collections/actions.ts +85 -0
  17. package/src/collections/collection-types.ts +65 -0
  18. package/src/collections/graphs.ts +34 -0
  19. package/src/collections/pings.ts +72 -0
  20. package/src/collections/posts.ts +125 -0
  21. package/src/collections/providers.ts +275 -0
  22. package/src/contracts/apply-contracts.ts +47 -0
  23. package/src/contracts/run-contracts.ts +8 -3
  24. package/src/core/canonical-scripts.ts +46 -0
  25. package/src/core/check-suite.ts +6 -3
  26. package/src/core/locked-write.ts +39 -0
  27. package/src/core/motherhome.ts +87 -0
  28. package/src/core/run-check.ts +28 -15
  29. package/src/core/run-ci.ts +4 -0
  30. package/src/core/write-status.ts +7 -26
  31. package/src/init-repo.ts +24 -11
  32. package/src/reflect/prompt-corpus.ts +305 -0
  33. package/src/repo-config.ts +26 -38
  34. package/src/rules/SK001-package-json-shape.ts +156 -0
  35. package/src/rules/SK002-contracts-fulfillness.ts +19 -0
  36. package/src/rules/SK003-claude-md-shape.ts +64 -0
  37. package/src/rules/SK004-gitignore-policy.ts +105 -0
  38. package/src/rules/SK005-rvlib-versions.ts +99 -0
  39. package/src/rules/SK006-npm-publish-safety.ts +42 -0
  40. package/src/rules/SK007-github-visibility.ts +63 -0
  41. package/src/rules/rule-registry.ts +32 -0
  42. package/src/rules/rule.ts +62 -0
  43. package/src/rules/run-fix.ts +57 -0
  44. package/src/serve/hub-app/col-card.tsx +192 -0
  45. package/src/serve/hub-app/cols-svc.ts +136 -0
  46. package/src/serve/hub-app/geometry-view.tsx +155 -0
  47. package/src/serve/hub-app/health-svc.ts +49 -0
  48. package/src/serve/hub-app/hub-app.tsx +240 -127
  49. package/src/serve/hub-app/hub-st.ts +169 -0
  50. package/src/serve/hub-app/hub-types.ts +72 -0
  51. package/src/serve/hub-app/hub.css +286 -35
  52. package/src/serve/hub-app/index.html +1 -1
  53. package/src/serve/hub-app/md.ts +24 -0
  54. package/src/serve/hub-app/media-svc.ts +429 -0
  55. package/src/serve/hub-app/media-view.tsx +299 -0
  56. package/src/serve/run-serve.ts +46 -91
  57. package/src/serve/serve-hub.ts +99 -49
  58. package/src/shipkit.ts +13 -6
  59. package/src/assets/server/studio-page.ts +0 -687
  60. package/src/core/package-json-check.ts +0 -88
  61. package/src/serve/hub-pages.ts +0 -45
@@ -1,687 +0,0 @@
1
- // The studio's single-page UI (served by studio-server.ts). No build step,
2
- // vanilla JS on purpose — proven twice; the whole page is one template
3
- // string. Kind/asset structure is read-only here (kit.ts is code-reviewed);
4
- // the page edits WORKSPACE state: picks, alts, prompt overlays, meta
5
- // sidecars, geometry rects. Repo-defined custom panels (kit customPanel)
6
- // mount as extra drawer tabs: dynamic import of /panel/<kind>/<id>.js,
7
- // mount(el, api) with the meta-sidecar transport. Look & feel changes are
8
- // playtested by a human, never self-verified.
9
- export const page = (): string => `<!doctype html><html><head><meta charset=utf-8>
10
- <meta name=viewport content="width=device-width,initial-scale=1"><title>atelier studio</title>
11
- <style>
12
- :root{--bg:#141210;--panel:#1e1a15;--panel2:#262019;--line:#332b22;--ink:#f3ebe0;--dim:#a89a88;
13
- --accent:#e88c28;--gold:#ffcf5c;--teal:#58c0b0;--red:#e05252;--blue:#6ca0e8;--chk1:#221d17;--chk2:#1a1611}
14
- *{box-sizing:border-box}html,body{height:100%}
15
- body{margin:0;background:var(--bg);color:var(--ink);font:14px/1.5 system-ui,sans-serif;
16
- display:grid;grid-template-columns:270px 1fr;overflow:hidden}
17
- aside{border-right:1px solid var(--line);background:var(--panel);overflow-y:auto;height:100vh}
18
- .shead{position:sticky;top:0;background:var(--panel);padding:12px 14px;border-bottom:1px solid var(--line);
19
- font-size:12px;letter-spacing:.14em;text-transform:uppercase;color:var(--dim);z-index:2}
20
- .gname{padding:8px 14px 4px;font-size:11px;letter-spacing:.12em;text-transform:uppercase;color:var(--accent);
21
- display:flex;justify-content:space-between;align-items:center}
22
- .gname .cn{cursor:pointer}.gname .cn:hover{color:var(--gold)}
23
- .fold{cursor:pointer;color:var(--dim);width:12px}.fold:hover{color:var(--ink)}
24
- .catgen button{background:var(--panel2);color:var(--dim);border:1px solid var(--line);border-radius:6px;
25
- padding:1px 6px;font-size:11px;font-weight:700;margin-left:3px;cursor:pointer}
26
- .catgen button:hover{color:var(--ink);border-color:var(--accent)}
27
- .arow{display:flex;align-items:center;gap:8px;padding:3px 12px;cursor:pointer;border-left:3px solid transparent}
28
- .arow:hover{background:var(--panel2)}
29
- .arow.sel{background:rgba(232,140,40,.18);border-left-color:var(--accent)}
30
- .arow .lb{flex:1;min-width:0}.arow .lb b{display:block;font-size:13px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
31
- .arow .lb span{font-size:11px;color:var(--dim)}
32
- .dot{width:8px;height:8px;border-radius:50%;flex:none;background:var(--line)}
33
- .dot.done{background:var(--gold)}.dot.some{background:var(--accent)}
34
- main{display:flex;flex-direction:column;height:100vh;overflow:hidden}
35
- .bar{display:flex;gap:12px;align-items:flex-start;padding:12px 16px;border-bottom:1px solid var(--line);background:var(--panel)}
36
- .promptwrap{flex:1}
37
- textarea{width:100%;min-height:64px;background:var(--bg);color:var(--ink);border:1px solid var(--line);
38
- border-radius:9px;padding:9px 11px;font:12px/1.5 ui-monospace,Menlo,monospace;resize:vertical}
39
- .ctl{display:flex;flex-direction:column;gap:7px;min-width:150px}
40
- button{background:var(--accent);color:#1a1206;border:0;border-radius:9px;padding:8px 13px;font-weight:650;cursor:pointer}
41
- button.ghost{background:var(--panel2);color:var(--ink);border:1px solid var(--line)}
42
- button:disabled{opacity:.45;cursor:default}
43
- select,input[type=number]{background:var(--bg);color:var(--ink);border:1px solid var(--line);border-radius:8px;padding:7px}
44
- .status{font-size:12px;color:var(--dim);min-height:16px}
45
- .spin{width:12px;height:12px;border:2px solid var(--line);border-top-color:var(--gold);border-radius:50%;animation:rot 1s linear infinite;flex:none}
46
- @keyframes rot{to{transform:rotate(360deg)}}
47
- .progbar{width:120px;height:4px;background:var(--panel2);border-radius:2px;overflow:hidden;display:none}
48
- .progbar i{display:block;height:100%;width:0;background:var(--gold);transition:width .3s}
49
- .vig.gen{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:8px;color:var(--dim);font-size:11px}
50
- .card .th .spin{position:absolute;top:8px;right:8px}
51
- .dot.miss{background:#e5484d;border-color:#e5484d}
52
- .dot.stale{background:#f5a524;border-color:#f5a524}
53
- .vig.aud{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:6px;font-size:20px}
54
- .vig.aud audio{width:100%;height:24px}
55
- .stageaud{display:flex;flex-direction:column;align-items:center;gap:14px;font-size:48px;padding:40px}
56
- .stageaud audio{width:min(480px,80%)}
57
- .card .aud{aspect-ratio:1;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:8px;font-size:28px}
58
- .card .aud audio{width:92%;height:24px}
59
- .detnote{font-size:11px;color:var(--teal);line-height:1.25;max-width:150px}
60
- /* ── per-asset info surface (W2-2) ── */
61
- .info{display:none;flex-wrap:wrap;gap:6px;align-items:center;padding:6px 16px;border-bottom:1px solid var(--line);background:var(--panel2)}
62
- .info .chip{display:inline-flex;align-items:center;gap:5px;background:var(--bg);border:1px solid var(--line);border-radius:6px;
63
- padding:2px 8px;font-size:11px;color:var(--ink);max-width:360px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
64
- .info .chip b{color:var(--accent);font-weight:650;letter-spacing:.05em;text-transform:uppercase;font-size:10px}
65
- .info .chip.warn{border-color:var(--red);color:#f0c0c0}.info .chip.warn b{color:var(--red)}
66
- .info .chip code{font:11px ui-monospace,Menlo,monospace;color:var(--dim)}
67
- .errs{background:#3a1717;border-bottom:1px solid #5a2222;color:#f0c0c0;font:12px/1.5 ui-monospace,Menlo,monospace;
68
- padding:6px 16px;white-space:pre-wrap;display:none}
69
- .stage{position:relative;flex:1;min-height:0;display:flex;align-items:center;justify-content:center;padding:18px;overflow:hidden;
70
- background:conic-gradient(var(--chk1) 90deg,var(--chk2) 0 180deg,var(--chk1) 0 270deg,var(--chk2) 0) 0/34px 34px}
71
- .stage img.roll{max-width:100%;max-height:100%;object-fit:contain;filter:drop-shadow(0 6px 20px #0008);z-index:5;position:relative}
72
- .stage .ph{color:var(--dim)}
73
- /* ── geometry overlay ── */
74
- #ov{position:absolute;border:2px solid var(--gold);border-radius:50%;background:rgba(255,207,92,.14);cursor:move;z-index:10;display:none}
75
- #ov .hdl{position:absolute;right:-7px;top:-7px;width:14px;height:14px;background:var(--gold);border-radius:3px;cursor:nesw-resize}
76
- #ov .arrow{position:absolute;left:0;right:0;top:50%;border-top:1px dashed var(--gold);opacity:.8}
77
- #ov .sz{position:absolute;left:50%;top:50%;transform:translate(-50%,-120%);font-size:11px;color:var(--gold);
78
- background:#000a;padding:0 5px;border-radius:4px;white-space:nowrap}
79
- .plumb{position:absolute;width:0;border-left:1px dashed rgba(255,207,92,.5);top:-2000px;bottom:-2000px;z-index:9;display:none}
80
- .leg{position:absolute;z-index:10;border:1px solid;border-radius:3px}
81
- .leg.far{border-color:var(--blue);z-index:3}
82
- .leg.near{border-color:var(--red);z-index:7}
83
- .leg .hip{position:absolute;top:-3px;left:50%;transform:translateX(-50%);width:6px;height:6px;border-radius:50%;background:currentColor}
84
- .leg .foot{position:absolute;bottom:-4px;left:50%;transform:translateX(-50%);width:12px;height:7px;border-radius:50%;background:var(--gold)}
85
- .leg .lh{position:absolute;right:-6px;top:-6px;width:11px;height:11px;background:currentColor;border-radius:3px;cursor:nesw-resize}
86
- .leg .tag{position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);font-size:9px;opacity:.85;white-space:nowrap}
87
- .pill{position:absolute;border-radius:999px;z-index:3;opacity:.9}
88
- .pill.near{z-index:7}
89
- #rectbar{position:absolute;left:14px;bottom:14px;z-index:11;display:none;gap:8px;align-items:center;
90
- background:#000b;border:1px solid var(--line);border-radius:10px;padding:7px 10px;font-size:12px}
91
- #rectbar input[type=number]{width:64px;padding:4px}
92
- #rectbar .ro{color:var(--dim);font:11px ui-monospace,Menlo,monospace}
93
- /* ── category grid + lineup ── */
94
- #catgrid{flex:1;min-height:0;overflow-y:auto;display:none;padding:14px}
95
- .cards{display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:12px}
96
- .card{background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:8px}
97
- .card .th{position:relative;aspect-ratio:1;border-radius:8px;overflow:hidden;
98
- background:conic-gradient(var(--chk1) 90deg,var(--chk2) 0 180deg,var(--chk1) 0 270deg,var(--chk2) 0) 0/14px 14px}
99
- .card .th img{width:100%;height:100%;object-fit:contain}
100
- .card .th .ovmini{position:absolute;border:1.5px solid var(--gold);border-radius:50%;background:rgba(255,207,92,.12)}
101
- .card .nm{font-size:12px;margin:6px 0 4px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;cursor:pointer}
102
- .card .nm:hover{color:var(--gold)}
103
- .card .row{display:flex;gap:6px;align-items:center;font-size:11px;color:var(--dim)}
104
- .card .row input{width:58px;padding:3px}
105
- #allbar{display:flex;gap:8px;align-items:center;margin:0 0 12px;font-size:12px;color:var(--dim)}
106
- #mixsec{margin-top:16px}
107
- #mixbox{position:relative;height:420px;overflow-x:auto;overflow-y:hidden;background:var(--panel);
108
- border:1px solid var(--line);border-radius:10px}
109
- #mixinner{position:relative;height:100%}
110
- #mixground{position:absolute;left:0;right:0;bottom:0;height:44px;background:#1d2b1a;border-top:1px solid #2c4527}
111
- .mixsprite{position:absolute;image-rendering:auto;pointer-events:none}
112
- .mixlabel{position:absolute;font-size:10px;color:var(--dim);text-align:center;transform:translateX(-50%)}
113
- .ruler{position:absolute;left:0;right:0;bottom:44px;top:0;pointer-events:none;
114
- background:
115
- repeating-linear-gradient(to top,rgba(255,255,255,.28) 0 1px,transparent 1px 240px),
116
- repeating-linear-gradient(to top,rgba(255,255,255,.16) 0 1px,transparent 1px 120px),
117
- repeating-linear-gradient(to top,rgba(255,255,255,.07) 0 1px,transparent 1px 24px)}
118
- .rlabel{position:absolute;left:4px;font-size:9px;color:var(--dim)}
119
- .rlabel.b{font-weight:700;color:var(--ink)}
120
- .strip{height:150px;flex:none;border-top:1px solid var(--line);background:var(--panel);display:flex;gap:10px;padding:12px;overflow-x:auto}
121
- .vig{position:relative;height:100%;aspect-ratio:1;flex:none;border:2px solid var(--line);border-radius:10px;overflow:hidden;cursor:pointer;
122
- background:conic-gradient(var(--chk1) 90deg,var(--chk2) 0 180deg,var(--chk1) 0 270deg,var(--chk2) 0) 0/12px 12px}
123
- .vig img{width:100%;height:100%;object-fit:contain}
124
- .vig.active{border-color:var(--ink)}
125
- .vig.picked{border-color:var(--gold);box-shadow:0 0 0 2px var(--gold) inset}
126
- .vig.alt{border-color:var(--teal)}
127
- .vig .st{position:absolute;top:4px;right:6px;font-size:16px;filter:drop-shadow(0 1px 2px #000)}
128
- .vig .btns{position:absolute;bottom:4px;left:4px;right:4px;display:flex;gap:4px;justify-content:center;opacity:0;transition:opacity .15s}
129
- .vig:hover .btns{opacity:1}
130
- .vig .btns button{padding:2px 7px;font-size:11px;border-radius:6px}
131
- .vig .btns .b-alt{background:var(--teal);color:#06342c}
132
- .vig .btns .b-del{background:var(--red);color:#fff}
133
- .strip .ph{color:var(--dim);align-self:center;padding:0 12px}
134
- .drawer{flex:none;border-top:1px solid var(--line);background:var(--panel)}
135
- .tabs{display:flex;gap:2px;padding:6px 12px 0}
136
- .tabs button{background:var(--panel2);color:var(--dim);border:1px solid var(--line);border-bottom:0;border-radius:8px 8px 0 0;
137
- padding:5px 12px;font-size:12px}
138
- .tabs button.on{color:var(--ink);background:var(--bg)}
139
- .tabs .sp{flex:1}
140
- #drawerbody{display:none;padding:10px 12px}
141
- #drawerbody.on{display:block}
142
- #jsonta{min-height:140px}
143
- .jrow{display:flex;gap:8px;margin-top:6px;align-items:center}
144
- .jmsg{font-size:12px;color:var(--dim)}
145
- kbd{background:var(--panel2);border:1px solid var(--line);border-radius:4px;padding:0 5px;font-size:11px}
146
- .help{font-size:11px;color:var(--dim);padding:4px 16px;border-top:1px solid var(--line)}
147
- </style></head><body>
148
- <aside><div class=shead>Assets · <span id=count></span></div><div id=list></div></aside>
149
- <main>
150
- <div class=errs id=errs></div>
151
- <div class=bar id=topbar>
152
- <div class=ctl>
153
- <select id=n><option>1</option><option selected>3</option><option>6</option><option>9</option><option>12</option></select>
154
- <button id=go disabled>Generate</button>
155
- <div id=detnote class=detnote style="display:none">deterministic — rolls identical</div>
156
- <button id=upload class=ghost style="display:none" title="add files as rolls (or drag-drop onto the strip)">upload</button>
157
- <input type=file id=upfile multiple style="display:none">
158
- <button id=qclear class=ghost title="drop queued jobs">clear queue</button>
159
- <button id=qcancel class=ghost style="display:none" title="interrupt the current job (queued jobs stay)">stop current</button>
160
- </div>
161
- <div class=promptwrap>
162
- <textarea id=prompt placeholder="select an asset ←"></textarea>
163
- <div class=jrow><button id=saveprompt class=ghost disabled>save prompt → overlay</button>
164
- <button id=copyprompt class=ghost>copy</button><span class=jmsg id=promptmsg></span></div>
165
- </div>
166
- <div class=ctl><div class=status id=status></div><div class=progbar id=prog><i></i></div></div>
167
- </div>
168
- <div class=info id=info></div>
169
- <div class=stage id=stage><div class=ph>Pick an asset on the left, then Generate.</div>
170
- <div class=plumb id=plumbL></div><div class=plumb id=plumbR></div>
171
- <div id=ov><div class=hdl></div><div class=arrow></div><div class=sz id=ovsz></div></div>
172
- <div id=rectbar>
173
- <label>size <input type=number id=rsize step=0.1 min=0.1></label>
174
- <button id=rlegs class=ghost>legs</button>
175
- <input type=color id=rcolor title="leg color">
176
- <button id=rlegsx class=ghost title="remove legs">✕</button>
177
- <span class=ro id=rreadout></span>
178
- </div>
179
- </div>
180
- <div id=catgrid>
181
- <div id=allbar><label>size for ALL <span id=allcat></span> <input type=number id=allsize step=0.1 min=0.1></label>
182
- <button id=allapply class=ghost>apply</button><span class=jmsg id=allmsg></span></div>
183
- <div class=cards id=cards></div>
184
- <div id=mixsec><div id=mixbox><div id=mixinner><div class=ruler></div><div id=mixground></div></div></div></div>
185
- </div>
186
- <div class=strip id=strip><div class=ph>no rolls yet</div></div>
187
- <div class=drawer>
188
- <div class=tabs>
189
- <button data-tab=asset>asset</button><button data-tab=meta>roll meta</button><span id=ctabs></span>
190
- <span class=sp></span><button id=drawertoggle class=ghost>JSON ▾</button>
191
- </div>
192
- <div id=drawerbody>
193
- <div id=jsonwrap>
194
- <textarea id=jsonta spellcheck=false></textarea>
195
- <div class=jrow><button id=jsonsave>save</button><span class=jmsg id=jsonmsg></span></div>
196
- </div>
197
- <div id=panelhost style="display:none"></div>
198
- </div>
199
- </div>
200
- <div class=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>
201
- </main>
202
- <script>
203
- const $=s=>document.querySelector(s)
204
- let assets=[],cur=null,rolls=[],active=null,tab='asset',openCats=new Set(),pending=[]
205
- let customs=[],panelMod=null,panelKey=null // repo-defined editor panels (custom tabs)
206
- let lastSideSig=null,lastRollSig=null,catMode=null,lastCatSig=null,catRows=[]
207
- let rect=null,rectRatio=0.6,hasGeom=false,saveTimer=null
208
- const TEX=1024,MIX_UNIT=24,MIX_GROUND=44
209
- const V=()=>Date.now()>>13 // ~8s cache buckets: thumbs refresh without unique-URL spam
210
- const R=u=>u.replace(/^\\//,'') // resolve against the page URL: '/' standalone AND '/assets/' under the shipkit hub
211
- const j=async(u,o)=>{const r=await fetch(R(u),o);return r.json()}
212
- const post=(u,b)=>j(u,{method:'POST',body:JSON.stringify(b)})
213
- const imgUrl=(id,n)=>R('/img?id='+encodeURIComponent(id)+'&name='+encodeURIComponent(n)+'&t='+V())
214
- const thumbUrl=id=>R('/api/thumb?id='+encodeURIComponent(id)+'&t='+V())
215
- const esc=s=>String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/"/g,'&quot;')
216
- const jsq=s=>String(s).replace(/\\\\/g,'\\\\\\\\').replace(/'/g,"\\\\'")
217
- // ── hash deep-links: #<asset-id> or #cat-<Category> ──
218
- function setHash(h){if(location.hash.slice(1)!==h)location.hash=h}
219
- function restoreFromHash(){
220
- const h=decodeURIComponent(location.hash.slice(1))
221
- if(!h)return
222
- if(h.startsWith('cat-')){const c=h.slice(4);if(assets.some(a=>a.cat===c)&&catMode!==c)showCat(c,false);return}
223
- if(assets.some(a=>a.id===h)&&cur!==h)selectAsset(h,false)
224
- }
225
- window.addEventListener('hashchange',restoreFromHash)
226
- function toggleFold(c){openCats.has(c)?openCats.delete(c):openCats.add(c);lastSideSig=null;renderSide(assets)}
227
- function renderSide(list){
228
- assets=list;$('#count').textContent=list.length
229
- const sig=JSON.stringify(list.map(a=>[a.id,a.rolls,a.picked,a.stale]))+'|'+cur+'|'+catMode+'|'+[...openCats].join(',')+'|'+pending.join(',')
230
- if(sig===lastSideSig&&$('#list').innerHTML!=='')return; lastSideSig=sig
231
- const cats=[...new Set(list.map(a=>a.cat))]
232
- $('#list').innerHTML=cats.map(c=>{
233
- const open=openCats.has(c)
234
- const head='<div class=gname><span><span class=fold onclick="toggleFold(\\''+jsq(c)+'\\')">'+(open?'▾':'▸')+'</span> '+
235
- '<span class=cn onclick="showCat(\\''+jsq(c)+'\\')" title="grid view">'+esc(c)+'</span>'+
236
- '</span>'+(list.filter(a=>a.cat===c).every(a=>a.manual)?'':'<span class=catgen>'+[1,2,3].map(n=>'<button onclick="genCat(\\''+jsq(c)+'\\','+n+')">+'+n+'</button>').join('')+'</span>')+'</div>'
237
- if(!open)return head
238
- return head+list.filter(a=>a.cat===c).map(a=>
239
- '<div class="arow'+(a.id===cur?' sel':'')+'" data-id="'+esc(a.id)+'" onclick="selectAsset(\\''+jsq(a.id)+'\\')"><div class=lb><b>'+esc(a.label)+
240
- '</b><span>'+a.rolls+' rolls</span></div>'+(pending.includes(a.id)?'<div class=spin></div>':'<div class="dot '+(a.stale?'stale':(a.picked?'done':(a.rolls?'some':(a.manual?'miss':''))))+'"'+(a.manual&&!a.rolls?' title="manual asset — nothing uploaded yet"':'')+'></div>')+'</div>').join('')
241
- }).join('')
242
- }
243
- // ── per-asset info surface (W2-2): backend, workflow provenance, ship target,
244
- // meta file, kit source — so 'bun assets' is never a blank stare ──
245
- function renderInfo(info){
246
- const box=$('#info')
247
- if(!info){box.style.display='none';box.innerHTML='';return}
248
- const chip=(k,v,cls)=>'<span class="chip'+(cls?' '+cls:'')+'"><b>'+esc(k)+'</b> '+v+'</span>'
249
- const chips=[chip('backend',esc(info.backend))]
250
- if(info.workflow){const w=info.workflow
251
- if(w.id)chips.push(chip('workflow',esc(w.id)+(w.path?' <code>'+esc(w.path)+'</code>':'')))
252
- else chips.push(chip('workflow',esc(w.note||'unresolved'),'warn'))}
253
- const sh=info.ship
254
- const shv=sh.mode==='hook'?'<code>'+esc(sh.path)+'</code>':
255
- sh.mode==='built-in'?'built-in hook — '+esc(sh.note||''):esc(sh.note||'pick.json only')
256
- chips.push(chip('ship',shv))
257
- if(info.metaFile)chips.push(chip('meta','<code>'+esc(info.metaFile)+'</code>'))
258
- chips.push(chip('kit','<code>'+esc(info.kitSource)+'</code> · '+esc(info.kindId)))
259
- box.innerHTML=chips.join('');box.style.display='flex'
260
- }
261
- // ── asset view ──
262
- async function selectAsset(id,writeHash=true){
263
- exitCat()
264
- cur=id;active=null;lastSideSig=null;lastRollSig=null;rect=null
265
- const a=assets.find(x=>x.id===id)
266
- if(!a)return
267
- openCats.add(a.cat);renderSide(assets)
268
- if(writeHash)setHash(id)
269
- const row=document.querySelector('.arow[data-id="'+CSS.escape(id)+'"]')
270
- if(row)row.scrollIntoView({block:'center'})
271
- const r=await j('/api/asset?id='+encodeURIComponent(id))
272
- if(cur!==id)return // selection changed mid-fetch — a late response must not clobber it
273
- hasGeom=!!(r.asset&&r.asset.geometry)&&!a.icon
274
- customs=(r.asset&&r.asset.custom)||[]
275
- if(tab.indexOf('c:')===0&&!customs.some(c=>'c:'+c.id===tab))tab='asset' // new asset may not carry the open panel
276
- renderCustomTabs()
277
- $('#prompt').value=r.asset?r.asset.prompt:a.prompt
278
- $('#saveprompt').disabled=false;$('#promptmsg').textContent=''
279
- // manual kinds: nothing generates them — upload is the only roll source
280
- const man=!!a.manual
281
- $('#go').disabled=man;$('#go').style.display=man?'none':''
282
- $('#upload').style.display=''
283
- $('#upfile').accept=(a.media==='audio'?'.mp3,.wav,.ogg,.m4a':'.png,.webp')
284
- // deterministic canvas kinds: a slot machine with one outcome — cap rolls at 1
285
- const det=!!(r.info&&r.info.deterministic),n=$('#n')
286
- n.disabled=det||man;if(det)n.value='1';n.style.display=man?'none':''
287
- $('#detnote').textContent=man?'manual — upload files (drag-drop works)':'deterministic — rolls identical'
288
- $('#detnote').style.display=(det||man)?'':'none'
289
- renderInfo(r.info)
290
- await renderRolls(true);loadTab()
291
- }
292
- const isAud=n=>['mp3','wav','ogg','m4a'].includes(String(n||'').split('.').pop())
293
- function setStage(src){
294
- const st=$('#stage');st.querySelector('.stageaud')?.remove()
295
- let img=st.querySelector('img.roll')
296
- if(!img){st.querySelector('.ph')?.remove();img=document.createElement('img');img.className='roll';st.appendChild(img);img.onload=positionOverlay}
297
- img.src=src
298
- }
299
- function setStageAud(n){
300
- const st=$('#stage')
301
- const curAud=st.querySelector('.stageaud')
302
- if(curAud&&curAud.dataset.n===n)return // keep playback alive across heartbeats
303
- st.querySelector('img.roll')?.remove();st.querySelector('.ph')?.remove();curAud?.remove()
304
- const d=document.createElement('div');d.className='stageaud';d.dataset.n=n
305
- d.innerHTML='🔊<audio controls src="'+imgUrl(cur,n)+'"></audio>'
306
- st.appendChild(d);hideOverlay()
307
- }
308
- const showRoll=n=>isAud(n)?setStageAud(n):setStage(imgUrl(cur,n))
309
- async function renderRolls(reset){
310
- if(!cur)return
311
- const r=await j('/api/asset?id='+encodeURIComponent(cur))
312
- rolls=r.rolls
313
- const genHere=pending.includes(cur)
314
- const sig=cur+'|'+rolls.map(x=>x.name+(x.picked?'*':'')+(x.alt?'~':'')).join(',')+'|'+genHere
315
- if(!reset&&sig===lastRollSig)return; lastRollSig=sig
316
- const strip=$('#strip')
317
- if(!rolls.length&&genHere){strip.innerHTML='<div class="vig gen"><div class=spin></div>generating…</div>';hideOverlay()
318
- const gst=$('#stage');gst.querySelector('img.roll')?.remove();gst.querySelector('.stageaud')?.remove()
319
- if(!gst.querySelector('.ph')){const ph=document.createElement('div');ph.className='ph';ph.textContent='generating…';gst.appendChild(ph)}
320
- active=null;return}
321
- if(!rolls.length){const a0=assets.find(x=>x.id===cur)
322
- strip.innerHTML='<div class=ph>'+(a0&&a0.manual?'no files — upload (or drag-drop here)':'no rolls — hit Generate')+'</div>';hideOverlay()
323
- const st=$('#stage');st.querySelector('img.roll')?.remove();st.querySelector('.stageaud')?.remove()
324
- if(!st.querySelector('.ph')){const ph=document.createElement('div');ph.className='ph';ph.textContent='no rolls yet';st.appendChild(ph)}
325
- active=null;return}
326
- if(reset||!rolls.some(x=>x.name===active))active=(rolls.find(x=>x.picked)||rolls[0]).name
327
- showRoll(active)
328
- if(hasGeom)await loadRect()
329
- const btns='<div class=btns><button class=b-pick>pick</button><button class=b-alt>alt</button><button class=b-del>✕</button></div>'
330
- strip.innerHTML=rolls.map(x=>
331
- '<div class="vig'+(isAud(x.name)?' aud':'')+(x.name===active?' active':'')+(x.picked?' picked':'')+(x.alt?' alt':'')+'" data-n="'+x.name+'">'+
332
- (isAud(x.name)?'🔊<audio controls src="'+imgUrl(cur,x.name)+'"></audio>':'<img src="'+imgUrl(cur,x.name)+'">')+(x.picked?'<span class=st>★</span>':'')+
333
- btns+'</div>').join('')
334
- +(genHere?'<div class="vig gen"><div class=spin></div>generating…</div>':'')
335
- strip.querySelectorAll('.vig[data-n]').forEach(v=>{
336
- const n=v.dataset.n
337
- // hover-preview stays image-only — recreating an <audio> mid-playback on hover would cut the sound
338
- v.onmouseenter=()=>{if(!isAud(n))setStage(imgUrl(cur,n))}
339
- v.onmouseleave=()=>showRoll(active) // setStageAud keepalive avoids audio recreate when unchanged
340
- v.onclick=()=>{active=n;lastRollSig=null;renderRolls(false);if(tab!=='asset')loadTab()} // meta + custom panels are per-roll
341
- v.querySelector('.b-pick').onclick=e=>{e.stopPropagation();pickRoll(n)}
342
- v.querySelector('.b-alt').onclick=e=>{e.stopPropagation();post('/api/alt',{id:cur,name:n}).then(()=>renderRolls(false))}
343
- v.querySelector('.b-del').onclick=e=>{e.stopPropagation();discard(n)}
344
- })
345
- }
346
- async function pickRoll(n){
347
- const res=await post('/api/pick',{id:cur,name:n})
348
- $('#status').textContent=res.error?('ship FAILED: '+res.error):(res.shipped?('shipped → '+res.shipped):'picked (pick.json)')
349
- active=n;lastRollSig=null;lastSideSig=null;await renderRolls(false)
350
- }
351
- async function discard(n){
352
- await post('/api/discard',{id:cur,name:n})
353
- if(active===n)active=null
354
- lastRollSig=null;lastSideSig=null;await renderRolls(true)
355
- }
356
- async function genCat(c,n){const r=await post('/api/generate-cat',{cat:c,count:n});if(r&&r.error)$('#status').textContent=r.error;heartbeat()}
357
- $('#go').onclick=async()=>{if(!cur)return;await post('/api/generate',{id:cur,prompt:$('#prompt').value,count:+$('#n').value});heartbeat()}
358
- $('#qclear').onclick=()=>post('/api/queue/clear')
359
- $('#qcancel').onclick=()=>post('/api/interrupt')
360
- // ── upload: button, file input, drag-drop on the strip (manual kinds' only roll source) ──
361
- async function uploadFiles(files){
362
- if(!cur||!files||!files.length)return
363
- for(const f of files){
364
- const fd=new FormData();fd.append('id',cur);fd.append('file',f)
365
- const r=await fetch(R('/api/upload'),{method:'POST',body:fd}).then(x=>x.json()).catch(e=>({error:String(e)}))
366
- if(r.error)$('#status').textContent='upload FAIL: '+r.error
367
- }
368
- lastRollSig=null;lastSideSig=null;await renderRolls(false)
369
- }
370
- $('#upload').onclick=()=>$('#upfile').click()
371
- $('#upfile').onchange=async e=>{await uploadFiles(e.target.files);e.target.value=''}
372
- $('#strip').addEventListener('dragover',e=>e.preventDefault())
373
- $('#strip').addEventListener('drop',e=>{e.preventDefault();uploadFiles(e.dataTransfer.files)})
374
- $('#saveprompt').onclick=async()=>{
375
- const r=await post('/api/prompt',{id:cur,prompt:$('#prompt').value})
376
- $('#promptmsg').textContent=r.error?('FAIL: '+r.error):'saved (overlay)'
377
- setTimeout(()=>$('#promptmsg').textContent='',1500)
378
- }
379
- $('#copyprompt').onclick=()=>{navigator.clipboard.writeText($('#prompt').value);$('#promptmsg').textContent='copied'
380
- setTimeout(()=>$('#promptmsg').textContent='',900)}
381
- $('#prompt').addEventListener('input',e=>{e.target.style.height='auto';e.target.style.height=e.target.scrollHeight+'px'})
382
- // ── geometry overlay ──
383
- async function loadRect(){
384
- if(!hasGeom||!cur||!active){hideOverlay();return}
385
- const r=await j('/api/rect?id='+encodeURIComponent(cur)+'&name='+encodeURIComponent(active))
386
- if(r.error){hideOverlay();return}
387
- rect=r.rect
388
- rectRatio=rect.w>0?rect.h/rect.w:0.6 // client re-locks with the server's ratio
389
- positionOverlay();renderRectbar()
390
- }
391
- function imgBox(){
392
- const img=$('#stage img.roll')
393
- if(!img||!img.naturalWidth)return null
394
- const b=img.getBoundingClientRect(),st=$('#stage').getBoundingClientRect()
395
- const scale=Math.min(b.width/img.naturalWidth,b.height/img.naturalHeight)
396
- const w=img.naturalWidth*scale,h=img.naturalHeight*scale
397
- return{scale,x:b.left-st.left+(b.width-w)/2,y:b.top-st.top+(b.height-h)/2}
398
- }
399
- function hideOverlay(){$('#ov').style.display='none';$('#rectbar').style.display='none'
400
- $('#plumbL').style.display='none';$('#plumbR').style.display='none'
401
- document.querySelectorAll('.leg,.pill').forEach(e=>e.remove())}
402
- function positionOverlay(){
403
- if(!hasGeom||!rect){hideOverlay();return}
404
- const bx=imgBox();if(!bx){hideOverlay();return}
405
- const ov=$('#ov'),px=v=>v*bx.scale
406
- ov.style.display='block'
407
- ov.style.left=(bx.x+px(rect.x))+'px';ov.style.top=(bx.y+px(rect.y))+'px'
408
- ov.style.width=px(rect.w)+'px';ov.style.height=px(rect.h)+'px'
409
- $('#ovsz').textContent=(+rect.size.toFixed(2))+'m'
410
- const pl=$('#plumbL'),pr=$('#plumbR')
411
- pl.style.display=pr.style.display='block'
412
- pl.style.left=(bx.x+px(rect.x))+'px';pr.style.left=(bx.x+px(rect.x+rect.w))+'px'
413
- document.querySelectorAll('.leg,.pill').forEach(e=>e.remove())
414
- const tags=['L·front','L·back','R·back','R·front']
415
- ;(rect.legs||[]).forEach((l,i)=>{
416
- const far=i<2,el=document.createElement('div')
417
- el.className='leg '+(far?'far':'near');el.dataset.i=i
418
- el.style.color=far?'var(--blue)':'var(--red)'
419
- el.style.left=(bx.x+px(l.x))+'px';el.style.top=(bx.y+px(l.y))+'px'
420
- el.style.width=px(l.w)+'px';el.style.height=px(l.h)+'px'
421
- el.innerHTML='<div class=hip></div><div class=foot></div><div class=tag>'+tags[i]+'</div>'+(i===0?'<div class=lh></div>':'')
422
- $('#stage').appendChild(el)
423
- const pill=document.createElement('div')
424
- pill.className='pill'+(far?'':' near')
425
- pill.style.left=(bx.x+px(l.x))+'px';pill.style.top=(bx.y+px(l.y))+'px'
426
- pill.style.width=px(l.w)+'px';pill.style.height=px(l.h)+'px'
427
- pill.style.background=rect.legColor||'#c8a070'
428
- $('#stage').appendChild(pill)
429
- dragLeg(el,i)
430
- })
431
- }
432
- function renderRectbar(){
433
- if(!hasGeom||!rect){$('#rectbar').style.display='none';return}
434
- $('#rectbar').style.display='flex'
435
- $('#rsize').value=rect.size
436
- $('#rcolor').value=/^#[0-9a-f]{6}$/i.test(rect.legColor||'')?rect.legColor:'#c8a070'
437
- $('#rlegsx').style.display=rect.legs?'':'none'
438
- const cx=Math.round(rect.x+rect.w/2),cy=Math.round(rect.y+rect.h/2)
439
- $('#rreadout').textContent='cx'+cx+' cy'+cy+' w'+Math.round(rect.w)+'px'
440
- }
441
- function saveRect(){ // 250ms trailing debounce → POST; server re-cleans + measures art
442
- clearTimeout(saveTimer)
443
- saveTimer=setTimeout(async()=>{
444
- if(!cur||!active||!rect)return
445
- const r=await post('/api/rect',{id:cur,name:active,rect})
446
- if(r.rect){rect=r.rect;positionOverlay();renderRectbar()}
447
- else if(r.error)$('#status').textContent='rect FAIL: '+r.error
448
- },250)
449
- }
450
- $('#rsize').oninput=()=>{if(!rect)return;rect.size=Math.max(.1,+$('#rsize').value||rect.size);positionOverlay();saveRect()}
451
- $('#rcolor').oninput=()=>{if(!rect)return;rect.legColor=$('#rcolor').value;positionOverlay();saveRect()}
452
- $('#rlegs').onclick=async()=>{ // non-destructive: server creates defaults only when absent
453
- if(!cur||!active)return
454
- const r=await post('/api/legs-default',{id:cur,name:active})
455
- if(r.rect){rect=r.rect;positionOverlay();renderRectbar()}
456
- }
457
- $('#rlegsx').onclick=()=>{
458
- if(!rect||!rect.legs||!confirm('remove all 4 legs?'))return
459
- delete rect.legs;delete rect.legColor;positionOverlay();renderRectbar();saveRect()
460
- }
461
- // oval drag/resize (legs follow: move 1:1, resize scales around left-tip + center-y anchor)
462
- ;(()=>{
463
- const ov=$('#ov')
464
- let mode=null,start=null
465
- ov.addEventListener('pointerdown',e=>{
466
- if(!rect)return
467
- mode=e.target.classList.contains('hdl')?'resize':'move'
468
- const legs=(rect.legs||[]).map(l=>({...l}))
469
- start={px:e.clientX,py:e.clientY,x:rect.x,y:rect.y,w:rect.w,legs,cy:rect.y+rect.h/2}
470
- ov.setPointerCapture(e.pointerId);e.preventDefault()
471
- })
472
- ov.addEventListener('pointermove',e=>{
473
- if(!mode||!start||!rect)return
474
- const bx=imgBox();if(!bx)return
475
- const dx=(e.clientX-start.px)/bx.scale,dy=(e.clientY-start.py)/bx.scale
476
- if(mode==='move'){
477
- rect.x=Math.max(0,start.x+dx);rect.y=Math.max(0,start.y+dy)
478
- rect.legs=(start.legs||[]).length?start.legs.map(l=>({...l,x:l.x+(rect.x-start.x),y:l.y+(rect.y-start.y)})):rect.legs
479
- }else{
480
- rect.w=Math.max(20,start.w+dx)
481
- rect.h=rect.w*rectRatio
482
- rect.y=start.cy-rect.h/2 // anchored: left edge + center-y
483
- const f=rect.w/start.w
484
- if((start.legs||[]).length)rect.legs=start.legs.map(l=>({x:start.x+(l.x-start.x)*f,y:start.cy+(l.y-start.cy)*f,w:l.w*f,h:l.h*f}))
485
- }
486
- positionOverlay();renderRectbar();saveRect()
487
- })
488
- ov.addEventListener('pointerup',()=>{mode=null;start=null;saveRect()})
489
- })()
490
- function dragLeg(el,i){
491
- let mode=null,start=null
492
- el.addEventListener('pointerdown',e=>{
493
- if(!rect||!rect.legs)return
494
- mode=e.target.classList.contains('lh')?'resize':'move'
495
- start={px:e.clientX,py:e.clientY,legs:rect.legs.map(l=>({...l}))}
496
- el.setPointerCapture(e.pointerId);e.preventDefault();e.stopPropagation()
497
- })
498
- el.addEventListener('pointermove',e=>{
499
- if(!mode||!start||!rect||!rect.legs)return
500
- const bx=imgBox();if(!bx)return
501
- const dx=(e.clientX-start.px)/bx.scale,dy=(e.clientY-start.py)/bx.scale
502
- if(mode==='move'){const o=start.legs[i];rect.legs[i]={...o,x:o.x+dx,y:o.y+dy}}
503
- else{ // one size for all 4 — every leg keeps its own FOOT planted
504
- const o=start.legs[i],w=Math.max(6,o.w+dx),h=Math.max(6,o.h-dy)
505
- rect.legs=start.legs.map(l=>({x:l.x+(l.w-w)/2,y:l.y+l.h-h,w,h}))
506
- }
507
- positionOverlay();saveRect()
508
- })
509
- el.addEventListener('pointerup',()=>{mode=null;start=null;saveRect()})
510
- }
511
- window.addEventListener('resize',positionOverlay)
512
- // ── category grid + size-true lineup ──
513
- async function showCat(c,writeHash=true){
514
- catMode=c;cur=null;lastSideSig=null;lastCatSig=null
515
- if(writeHash)setHash('cat-'+c)
516
- $('#topbar').style.display='none';$('#stage').style.display='none';$('#strip').style.display='none';$('#info').style.display='none'
517
- $('#catgrid').style.display='block';$('#allcat').textContent=c
518
- openCats.add(c);renderSide(assets)
519
- await fillCat(true)
520
- }
521
- function exitCat(){
522
- if(catMode===null)return
523
- catMode=null;lastCatSig=null
524
- $('#topbar').style.display='';$('#stage').style.display='';$('#strip').style.display=''
525
- $('#catgrid').style.display='none'
526
- }
527
- async function fillCat(force){
528
- if(!catMode)return
529
- const mode=catMode
530
- const r=await j('/api/catrects?cat='+encodeURIComponent(mode))
531
- if(catMode!==mode)return // user navigated away mid-fetch
532
- catRows=r.rows||[]
533
- const sig=JSON.stringify(catRows)+'|'+pending.join(',')
534
- if(!force&&sig===lastCatSig)return; lastCatSig=sig
535
- const scroll=$('#catgrid').scrollTop
536
- const pc=v=>(v/(TEX/100))+'%'
537
- $('#cards').innerHTML=catRows.map(row=>{
538
- const fl=row.rect&&row.rect.flip
539
- const ovm=row.rect?('<div class=ovmini style="left:'+pc(fl?TEX-row.rect.x-row.rect.w:row.rect.x)+';top:'+pc(row.rect.y)+
540
- ';width:'+pc(row.rect.w)+';height:'+pc(row.rect.h)+'"></div>'):''
541
- return '<div class=card>'+(isAud(row.name)
542
- ?'<div class=aud>🔊<audio controls src="'+imgUrl(row.id,row.name)+'"></audio>'+(pending.includes(row.id)?'<div class=spin></div>':'')+'</div>'
543
- :'<div class=th><img src="'+thumbUrl(row.id)+'"'+(fl?' style="transform:scaleX(-1)"':'')+'>'+ovm+(pending.includes(row.id)?'<div class=spin></div>':'')+'</div>')+
544
- '<div class=nm onclick="selectAsset(\\''+jsq(row.id)+'\\')">'+esc(row.label)+'</div>'+
545
- ((row.icon||isAud(row.name))?'':('<div class=row><label>size <input type=number step=0.1 min=0.1 value="'+(row.rect?row.rect.size:1)+'"'+
546
- ' onchange="setSize(\\''+jsq(row.id)+'\\',\\''+jsq(row.name)+'\\',this.value)"></label>'+
547
- '<button class=ghost onclick="toggleFlip(\\''+jsq(row.id)+'\\',\\''+jsq(row.name)+'\\')">⇆ flip</button></div>'))+'</div>'
548
- }).join('')
549
- $('#catgrid').scrollTop=scroll
550
- renderMix()
551
- }
552
- async function setSize(id,name,v){
553
- const row=catRows.find(r=>r.id===id)
554
- if(!row||!row.rect)return
555
- row.rect.size=Math.max(.1,+v||row.rect.size)
556
- await post('/api/rect',{id,name,rect:row.rect})
557
- renderMix() // lineup only — no grid refetch
558
- }
559
- async function toggleFlip(id,name){
560
- await post('/api/flip',{id,name})
561
- lastCatSig=null;await fillCat(true)
562
- }
563
- $('#allapply').onclick=async()=>{
564
- const v=Math.max(.1,+$('#allsize').value||0)
565
- if(!v)return
566
- for(const row of catRows.filter(r=>!r.icon&&r.rect)){row.rect.size=v;await post('/api/rect',{id:row.id,name:row.name,rect:row.rect})}
567
- $('#allmsg').textContent='applied';setTimeout(()=>$('#allmsg').textContent='',1200)
568
- lastCatSig=null;await fillCat(true)
569
- }
570
- function renderMix(){
571
- const inner=$('#mixinner')
572
- inner.querySelectorAll('.mixsprite,.mixlabel,.rlabel').forEach(e=>e.remove())
573
- const items=catRows.filter(r=>!r.icon&&r.rect).sort((a,b)=>(a.rect.size-b.rect.size)||a.label.localeCompare(b.label))
574
- const boxH=420,baseY=boxH-MIX_GROUND
575
- let cx=30
576
- for(const it of items){
577
- const ts=it.rect.size*MIX_UNIT/it.rect.w
578
- const slot=Math.max(it.rect.size*MIX_UNIT*1.35,TEX*ts*.62,40)
579
- cx+=slot/2
580
- const img=document.createElement('img')
581
- img.className='mixsprite';img.src=thumbUrl(it.id)
582
- const fx=it.rect.flip?TEX-it.rect.x-it.rect.w:it.rect.x
583
- img.style.width=img.style.height=(TEX*ts)+'px'
584
- img.style.left=(cx-(fx+it.rect.w/2)*ts)+'px'
585
- img.style.top=(baseY-(it.rect.y+it.rect.h/2)*ts)+'px'
586
- if(it.rect.flip)img.style.transform='scaleX(-1)'
587
- img.style.zIndex=Math.round(cx)
588
- inner.appendChild(img)
589
- const lb=document.createElement('div')
590
- lb.className='mixlabel';lb.textContent=it.label+' · '+(+it.rect.size.toFixed(2))
591
- lb.style.left=cx+'px';lb.style.bottom='14px'
592
- inner.appendChild(lb)
593
- cx+=slot/2+10
594
- }
595
- inner.style.width=Math.max(cx+30,($('#mixbox').clientWidth||600))+'px'
596
- for(let u=5;u*MIX_UNIT<baseY;u+=5){
597
- const l=document.createElement('div')
598
- l.className='rlabel'+(u%10===0?' b':'');l.textContent=u
599
- l.style.bottom=(MIX_GROUND+u*MIX_UNIT-5)+'px'
600
- inner.appendChild(l)
601
- }
602
- }
603
- // ── JSON drawer (asset = read-only kit view; meta = roll sidecar;
604
- // c:<id> tabs = repo-defined custom panels, dynamic-imported modules) ──
605
- $('#drawertoggle').onclick=()=>{$('#drawerbody').classList.toggle('on')}
606
- document.querySelectorAll('.tabs [data-tab]').forEach(b=>b.onclick=()=>{tab=b.dataset.tab;loadTab();$('#drawerbody').classList.add('on')})
607
- function renderCustomTabs(){
608
- $('#ctabs').innerHTML=customs.map(c=>'<button data-tab="c:'+esc(c.id)+'">'+esc(c.id)+'</button>').join('')
609
- $('#ctabs').querySelectorAll('[data-tab]').forEach(b=>b.onclick=()=>{tab=b.dataset.tab;loadTab();$('#drawerbody').classList.add('on')})
610
- document.querySelectorAll('.tabs [data-tab]').forEach(b=>b.classList.toggle('on',b.dataset.tab===tab))
611
- }
612
- async function unmountPanel(){
613
- if(panelMod&&typeof panelMod.unmount==='function'){try{await panelMod.unmount()}catch(e){$('#status').textContent='panel unmount FAILED: '+(e&&e.message||e)}}
614
- panelMod=null;panelKey=null;$('#panelhost').innerHTML=''
615
- }
616
- function panelApi(c){ // frozen per mount: a roll/asset switch remounts with a fresh api
617
- const id=cur,name=active
618
- return{assetId:id,kindId:c.kind,roll:name,imgUrl:imgUrl(id,name),
619
- getMeta:async()=>{const r=await post('/api/meta',{id,name});if(r.error)throw new Error(r.error);return r.text?JSON.parse(r.text):null},
620
- saveMeta:async(data)=>{const r=await post('/api/meta',{id,name,text:JSON.stringify(data)});if(r.error)throw new Error(r.error)}}
621
- }
622
- async function mountPanel(c){
623
- const key=cur+'|'+active+'|'+c.id
624
- if(panelKey===key)return
625
- await unmountPanel()
626
- const host=$('#panelhost')
627
- try{
628
- const mod=await import(c.url)
629
- if(typeof mod.mount!=='function')throw new Error(c.url+' must export mount(el, api)')
630
- await mod.mount(host,panelApi(c))
631
- panelMod=mod;panelKey=key
632
- }catch(e){host.textContent='panel FAILED: '+(e&&e.message||e)} // loud in the drawer, never blank
633
- }
634
- async function loadTab(){
635
- document.querySelectorAll('.tabs [data-tab]').forEach(b=>b.classList.toggle('on',b.dataset.tab===tab))
636
- const isCustom=tab.indexOf('c:')===0
637
- $('#jsonwrap').style.display=isCustom?'none':''
638
- $('#panelhost').style.display=isCustom?'':'none'
639
- if(isCustom){
640
- const c=customs.find(x=>'c:'+x.id===tab)
641
- if(c&&cur&&active)await mountPanel(c)
642
- else{await unmountPanel();$('#panelhost').textContent=c?'no roll selected — generate or pick one first':'panel not on this asset'}
643
- return
644
- }
645
- await unmountPanel()
646
- const ta=$('#jsonta');$('#jsonmsg').textContent=''
647
- $('#jsonsave').disabled=(tab==='asset') // kind/asset structure is git-reviewed TS — read-only here
648
- ta.readOnly=(tab==='asset')
649
- if(tab==='asset'){const r=cur?await j('/api/asset?id='+encodeURIComponent(cur)):null;ta.value=r&&r.asset?JSON.stringify(r.asset,null,2):''}
650
- else ta.value=cur&&active?(await post('/api/meta',{id:cur,name:active})).text:''
651
- }
652
- $('#jsonsave').onclick=async()=>{
653
- if(tab!=='meta'||!cur||!active)return
654
- const r=await post('/api/meta',{id:cur,name:active,text:$('#jsonta').value})
655
- $('#jsonmsg').textContent=r.error?('FAIL: '+r.error):'saved'
656
- }
657
- // ── keyboard ──
658
- document.addEventListener('keydown',e=>{
659
- if(e.target.tagName==='TEXTAREA'||e.target.tagName==='INPUT'||!cur||!rolls.length)return
660
- const i=rolls.findIndex(x=>x.name===active)
661
- if(e.key==='ArrowRight'&&i<rolls.length-1){active=rolls[i+1].name;lastRollSig=null;renderRolls(false)}
662
- else if(e.key==='ArrowLeft'&&i>0){active=rolls[i-1].name;lastRollSig=null;renderRolls(false)}
663
- else if(e.key==='Enter'&&active)pickRoll(active)
664
- else if((e.key==='a'||e.key==='A')&&active)post('/api/alt',{id:cur,name:active}).then(()=>renderRolls(false))
665
- else if((e.key==='x'||e.key==='X')&&active)discard(active)
666
- else return
667
- e.preventDefault()
668
- })
669
- // ── heartbeat: status + change-signature refreshes (grid OR asset view) ──
670
- let firstBeat=true
671
- async function heartbeat(){
672
- const s=await j('/api/state')
673
- pending=s.pending||[]
674
- renderSide(s.assets)
675
- const p=s.progress
676
- $('#status').textContent=s.generating?('generating '+(s.current||'…')+' '+s.done+'/'+s.total+(s.queued?(' · '+s.queued+' queued'):'')+(p&&p.max?(' · step '+p.value+'/'+p.max):'')):''
677
- $('#qcancel').style.display=s.generating?'':'none'
678
- const pb=$('#prog');pb.style.display=(s.generating&&p&&p.max)?'block':'none'
679
- if(p&&p.max)pb.firstElementChild.style.width=Math.round(100*p.value/p.max)+'%'
680
- const e=$('#errs')
681
- if(s.errors.length){e.style.display='block';e.textContent=s.errors.join('\\n')}else e.style.display='none'
682
- if(catMode)await fillCat(false)
683
- else if(cur&&!document.querySelector('.vig:hover'))await renderRolls(false)
684
- if(firstBeat){firstBeat=false;restoreFromHash()}
685
- }
686
- heartbeat();setInterval(heartbeat,1500)
687
- </script></body></html>`