@noobsociety/nsds 0.3.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/CHANGELOG.md +46 -1
  2. package/CONTRIBUTING.md +43 -2
  3. package/README.md +87 -36
  4. package/SECURITY.md +38 -4
  5. package/dist/client/index.cjs +1 -0
  6. package/dist/client/index.d.cts +6 -0
  7. package/dist/client/index.d.ts +6 -0
  8. package/dist/client/index.js +5 -0
  9. package/dist/components/_card-base.css +99 -0
  10. package/dist/components/hud/HUDBar.d.ts +16 -3
  11. package/dist/components/hud/HUDChat.d.ts +26 -0
  12. package/dist/components/hud/HUDJoystick.d.ts +21 -0
  13. package/dist/components/hud/HUDMinimap.d.ts +14 -0
  14. package/dist/components/hud/HUDPanel.d.ts +25 -0
  15. package/dist/components/hud/HUDTabWindow.d.ts +37 -0
  16. package/dist/components/hud-editor.css +197 -0
  17. package/dist/components/icons/RPGIcon.d.ts +15 -11
  18. package/dist/components/icons/registry.d.ts +37 -0
  19. package/dist/components/primitives.css +50 -20
  20. package/dist/components/react/index.d.ts +12 -2
  21. package/dist/components/scene-builder.css +740 -0
  22. package/dist/components/scene-builder.js +3039 -0
  23. package/dist/components/shared/constants.d.ts +41 -0
  24. package/dist/components/shared/styles.d.ts +1 -42
  25. package/dist/index.cjs +1 -1
  26. package/dist/index.js +6173 -1821
  27. package/dist/registry-BizUEm6W.js +136 -0
  28. package/dist/registry-Cyq-qspU.cjs +1 -0
  29. package/dist/styles.css +1 -0
  30. package/dist/tailwind/preset.cjs +108 -0
  31. package/dist/tailwind/preset.d.cts +3 -3
  32. package/dist/tokens/base.css +17 -4
  33. package/dist/tokens/colors.css +57 -53
  34. package/dist/tokens/hud.css +119 -78
  35. package/dist/tokens/motion.css +57 -23
  36. package/dist/tokens/spacing.css +39 -39
  37. package/dist/tokens/typography.css +20 -20
  38. package/package.json +41 -12
  39. package/dist/tailwind/package.json +0 -1
  40. package/dist/tailwind/preset.d.ts +0 -4
  41. package/dist/tailwind/preset.js +0 -144
@@ -0,0 +1,3039 @@
1
+ /* NoobSociety — Scene Builder editor engine.
2
+ Interactive logic for the HUD composer card: bg / grid / hud toggles, the
3
+ export / import + dirty-tracking + undo/redo command history, the RPG icon
4
+ SVG library (ICON_SVGS) and emote tips, the baked HUD element builders (vital
5
+ bars, tab windows, minimap), and the on-canvas drag / resize / marquee-select
6
+ / align / colour / text-edit engine, plus the palette drag-out and the
7
+ chrome / HUD tooltips.
8
+
9
+ Extracted verbatim from the card's inline <script> blocks (no behavioural
10
+ change) to mirror components/scene-builder.css. Load contract: a classic
11
+ (non-module) script that reads the DOM at end of <body>, so it must load
12
+ AFTER the markup and AFTER the inline #baked-state script that seeds
13
+ window.__hudBakedBaseline and the ns-* localStorage defaults. Linked directly
14
+ by the Scene Builder card; NOT auto-imported through styles.css, matching the
15
+ diverged-copy policy of the CSS chrome.
16
+
17
+ NOTE: ICON_SVGS duplicates the geometry in components/icons/RPGIcon.tsx
18
+ (vanilla strings vs JSX). Kept inline here for now; unifying the two across
19
+ the compiled-bundle boundary is a tracked follow-up. */
20
+
21
+ /* ══════════════════════════════════════════
22
+ TOGGLE UTILITIES — shared across bg/grid/hud
23
+ ══════════════════════════════════════════ */
24
+ function createToggle(btnId, imgId, storageKey, toggleClass, keyChar) {
25
+ const btn = document.getElementById(btnId);
26
+ const target = imgId ? document.getElementById(imgId) : null;
27
+ if (!btn) return;
28
+
29
+ let state = localStorage.getItem(storageKey) !== 'false';
30
+
31
+ function apply() {
32
+ if (target) target.style.display = state ? 'block' : 'none';
33
+ btn.classList.toggle(toggleClass, state);
34
+ localStorage.setItem(storageKey, state);
35
+ }
36
+
37
+ btn.addEventListener('click', function(e) {
38
+ e.stopPropagation();
39
+ state = !state;
40
+ apply();
41
+ });
42
+
43
+ if (keyChar) {
44
+ document.addEventListener('keydown', function(e) {
45
+ if ((e.key === keyChar || e.key === keyChar.toUpperCase()) &&
46
+ (!document.activeElement || document.activeElement.contentEditable !== 'true')) {
47
+ state = !state;
48
+ apply();
49
+ }
50
+ });
51
+ }
52
+
53
+ apply();
54
+ }
55
+
56
+ /* ══════════════════════════════════════════
57
+ BG TOGGLE
58
+ ══════════════════════════════════════════ */
59
+ (function() {
60
+ createToggle('bg-toggle', 'world-bg-img', 'ns-bg-on', 'is-on', 'b');
61
+ })();
62
+
63
+ /* ══════════════════════════════════════════
64
+ BACKGROUND (upload / replace / delete — project asset)
65
+ Stored as a data URL in ns-bg-image, separate from the HUD layout.
66
+ Displayed at native size, anchored top-left, never scaled.
67
+ ══════════════════════════════════════════ */
68
+ (function() {
69
+ var img = document.getElementById('world-bg-img');
70
+ var action = document.getElementById('bg-action');
71
+ var fileInput = document.getElementById('bg-file');
72
+ var bgToggle = document.getElementById('bg-toggle');
73
+ if (!img || !action || !fileInput) return;
74
+ var KEY = 'ns-bg-image';
75
+ /* Default scene background — a real project asset, so an imported .json (which
76
+ carries only ns- layout keys, never the bg) opens onto the game world
77
+ instead of a black void. A user upload overrides it; deleting reverts to it. */
78
+ var DEFAULT_BG = '../assets/scene-bg.png';
79
+
80
+ var UPLOAD_SVG =
81
+ '<svg width="13" height="13" viewBox="0 0 16 16" fill="none" aria-hidden="true">' +
82
+ '<rect x="2" y="3.5" width="12" height="9" rx="1" stroke="currentColor" stroke-width="1.2"/>' +
83
+ '<circle cx="5.5" cy="6.5" r="1" fill="currentColor"/>' +
84
+ '<path d="M3 12 6.5 8.5 9 11l2-1.8 2 2.3" stroke="currentColor" stroke-width="1.2" stroke-linejoin="round"/></svg>';
85
+ var DELETE_SVG =
86
+ '<svg width="13" height="13" viewBox="0 0 16 16" fill="none" aria-hidden="true">' +
87
+ '<path d="M3 4.5h10M6.5 4.5V3h3v1.5M5 4.5l.6 8a1 1 0 0 0 1 .9h2.8a1 1 0 0 0 1-.9l.6-8" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/></svg>';
88
+
89
+ function has() { return !!localStorage.getItem(KEY); }
90
+ function refresh() {
91
+ var stored = localStorage.getItem(KEY);
92
+ img.src = stored || DEFAULT_BG;
93
+ if (bgToggle) bgToggle.disabled = false;
94
+ var shown = localStorage.getItem('ns-bg-on') !== 'false';
95
+ img.style.display = shown ? 'block' : 'none';
96
+ if (bgToggle) bgToggle.classList.toggle('is-on', shown);
97
+ /* the one button is "upload" on the default scene, "reset" (red trash) once a custom bg is set */
98
+ action.innerHTML = stored ? DELETE_SVG : UPLOAD_SVG;
99
+ action.classList.toggle('is-delete', !!stored);
100
+ action.setAttribute('data-tip', stored ? 'Reset to default background' : 'Upload background');
101
+ action.setAttribute('aria-label', stored ? 'Reset to default background' : 'Upload background');
102
+ }
103
+
104
+ action.addEventListener('click', function(e) {
105
+ e.stopPropagation();
106
+ if (has()) {
107
+ localStorage.setItem(KEY, ''); /* delete (storage clear is globally guarded) */
108
+ refresh();
109
+ } else {
110
+ fileInput.click(); /* upload */
111
+ }
112
+ });
113
+ fileInput.addEventListener('change', function() {
114
+ var f = fileInput.files && fileInput.files[0];
115
+ if (!f) return;
116
+ var reader = new FileReader();
117
+ reader.onload = function() {
118
+ localStorage.setItem(KEY, reader.result);
119
+ localStorage.setItem('ns-bg-on', 'true');
120
+ refresh();
121
+ };
122
+ reader.readAsDataURL(f);
123
+ fileInput.value = '';
124
+ });
125
+ if (bgToggle) bgToggle.addEventListener('click', function() { setTimeout(refresh, 0); });
126
+
127
+ refresh();
128
+ })();
129
+
130
+ /* ══════════════════════════════════════════
131
+ WORLD GRID
132
+ ══════════════════════════════════════════ */
133
+ (function() {
134
+ const pat = ['l','d','l','d','d','d','l','d','l'];
135
+ const root = document.getElementById('world');
136
+ pat.forEach(function(t) {
137
+ const cell = document.createElement('div');
138
+ cell.className = 'cell-' + t;
139
+ const inner = document.createElement('div');
140
+ inner.className = 'inner';
141
+ for (var i = 0; i < 64; i++) {
142
+ const ig = document.createElement('div');
143
+ ig.className = 'ig';
144
+ inner.appendChild(ig);
145
+ }
146
+ cell.appendChild(inner);
147
+ root.appendChild(cell);
148
+ });
149
+ })();
150
+
151
+ /* ══════════════════════════════════════════
152
+ GRID TOGGLE
153
+ ══════════════════════════════════════════ */
154
+ (function() {
155
+ const btn = document.getElementById('grid-toggle');
156
+ const stage = document.getElementById('stage');
157
+ if (!btn) return;
158
+ let on = localStorage.getItem('ns-grid-on') !== 'false';
159
+
160
+ function apply() {
161
+ stage.classList.toggle('grid-hidden', !on);
162
+ btn.classList.toggle('is-on', on);
163
+ localStorage.setItem('ns-grid-on', on);
164
+ window.hudGridOn = on;
165
+ window.dispatchEvent(new Event('hud-grid-change'));
166
+ }
167
+
168
+ btn.addEventListener('click', function(e) { e.stopPropagation(); on = !on; apply(); });
169
+ document.addEventListener('keydown', function(e) {
170
+ if ((e.key === 'g' || e.key === 'G') &&
171
+ (!document.activeElement || document.activeElement.contentEditable !== 'true')) {
172
+ on = !on; apply();
173
+ }
174
+ });
175
+ apply();
176
+ })();
177
+
178
+ /* ══════════════════════════════════════════
179
+ EXPORT / IMPORT LAYOUT (portable .json file)
180
+ ══════════════════════════════════════════ */
181
+ (function() {
182
+ var exportBtn = document.getElementById('export-snapshot');
183
+ var importBtn = document.getElementById('import-snapshot');
184
+ var fileInput = document.getElementById('import-file');
185
+ if (!exportBtn || !importBtn || !fileInput) return;
186
+
187
+ var MARK_KEY = '__hud_saved_baseline'; // non-ns → never enters snapshots
188
+
189
+ /* Recursively sort object keys so equivalent JSON compares equal regardless of order */
190
+ function sortDeep(v) {
191
+ if (Array.isArray(v)) return v.map(sortDeep);
192
+ if (v && typeof v === 'object') {
193
+ var out = {};
194
+ Object.keys(v).sort().forEach(function(k) { out[k] = sortDeep(v[k]); });
195
+ return out;
196
+ }
197
+ return v;
198
+ }
199
+ function normVal(s) {
200
+ try { return JSON.stringify(sortDeep(JSON.parse(s))); } catch (e) { return s; }
201
+ }
202
+
203
+ /* All ns- state, as a plain object */
204
+ function stateObject() {
205
+ var o = {};
206
+ var keys = [];
207
+ for (var i = 0; i < localStorage.length; i++) {
208
+ var k = localStorage.key(i);
209
+ if (k && k.indexOf('ns-') === 0) keys.push(k);
210
+ }
211
+ keys.sort().forEach(function(k) { o[k] = localStorage.getItem(k); });
212
+ return o;
213
+ }
214
+
215
+ /* Canonical (order-independent) string for dirty comparison */
216
+ function canonical() {
217
+ var o = stateObject(), out = {};
218
+ Object.keys(o).sort().forEach(function(k) { out[k] = normVal(o[k]); });
219
+ return JSON.stringify(out);
220
+ }
221
+ function bakedBaseline() {
222
+ try {
223
+ var b = JSON.parse(window.__hudBakedBaseline || '{}'), o = {};
224
+ Object.keys(b).sort().forEach(function(k) { o[k] = normVal(b[k]); });
225
+ return JSON.stringify(o);
226
+ } catch (e) { return ''; }
227
+ }
228
+ function baseline() { return localStorage.getItem(MARK_KEY) || bakedBaseline(); }
229
+ function isDirty() { return canonical() !== baseline(); }
230
+
231
+ function render() {
232
+ var dirty = isDirty();
233
+ exportBtn.classList.toggle('is-dirty', dirty);
234
+ exportBtn.setAttribute('data-tip', dirty ? 'Export layout — unsaved (E)' : 'Export layout (E)');
235
+ }
236
+
237
+ function flash(btn) {
238
+ btn.classList.add('just-saved');
239
+ setTimeout(function() { btn.classList.remove('just-saved'); render(); }, 1500);
240
+ }
241
+
242
+ /* ════════════════════════════════════════════════════════════════
243
+ PRODUCT-GRADE LAYOUT DOCUMENT
244
+ The exported file is a normalized, self-describing element list —
245
+ not a raw dump of storage keys — so it is readable, diffable and
246
+ stable. serialize(): storage → document; toStorage(): document →
247
+ storage keys (lossless round-trip).
248
+ ════════════════════════════════════════════════════════════════ */
249
+ var GRID = { cols: 24, rows: 24, tilePx: 24 };
250
+ function pj(key, d) { try { var v = JSON.parse(localStorage.getItem(key)); return (v === null || v === undefined) ? d : v; } catch (e) { return d; } }
251
+ function maps() {
252
+ return {
253
+ layout: pj('ns-hud-layout', {}), customs: pj('ns-hud-custom', []),
254
+ icons: pj('ns-hud-icon-els', []), comps: pj('ns-hud-comp-els', []),
255
+ emotes: pj('ns-hud-emoji-els', []),texts: pj('ns-hud-texts', {}),
256
+ colors: pj('ns-hud-colors', {}), fgColors: pj('ns-hud-fgcolors', {}),
257
+ aligns: pj('ns-hud-align', {}),
258
+ valigns: pj('ns-hud-valigns', {}), sizes: pj('ns-hud-sizes', {}),
259
+ pads: pj('ns-hud-pads', {}),
260
+ zindex: pj('ns-hud-zindex', {}),
261
+ roles: pj('ns-hud-roles', {}), deleted: pj('ns-hud-deleted', []),
262
+ tabwin: pj('ns-hud-tabwin-data', {}),
263
+ natural: pj('ns-hud-natural', {}),
264
+ zorder: pj('ns-hud-zorder', {})
265
+ };
266
+ }
267
+ function rectFrom(p) { return p ? { col: p.c1, row: p.r1, width: p.c2 - p.c1, height: p.r2 - p.r1 } : undefined; }
268
+ /* Intrinsic content size — only emitted when it differs from the placed span
269
+ (i.e. the element was scaled by a resize). Restored verbatim on import so the
270
+ --scale that resize produced is reproduced 1:1 instead of snapping back to 1. */
271
+ function natFrom(m, id, rect) {
272
+ var n = m.natural && m.natural[id];
273
+ if (!n || !rect) return undefined;
274
+ if (n.w === rect.width && n.h === rect.height) return undefined;
275
+ return { w: n.w, h: n.h };
276
+ }
277
+ function styleFrom(m, id) {
278
+ var s = {};
279
+ if (m.colors[id]) s.color = m.colors[id];
280
+ if (m.fgColors && m.fgColors[id]) s.fgColor = m.fgColors[id];
281
+ if (m.sizes[id]) s.size = m.sizes[id];
282
+ if (m.pads && m.pads[id]) s.pad = m.pads[id];
283
+ if (m.zindex && typeof m.zindex[id] !== 'undefined') s.z = m.zindex[id];
284
+ if (m.aligns[id]) s.align = m.aligns[id];
285
+ if (m.valigns[id]) s.vAlign = m.valigns[id];
286
+ if (m.roles[id]) s.role = m.roles[id];
287
+ return Object.keys(s).length ? s : undefined;
288
+ }
289
+ function compact(o) { Object.keys(o).forEach(function(k){ if (o[k] === undefined) delete o[k]; }); return o; }
290
+
291
+ function serialize() {
292
+ var m = maps(), els = [], seen = {};
293
+ m.customs.forEach(function(c) {
294
+ seen[c.id] = 1;
295
+ var t = (m.texts['cet-' + c.id] !== undefined) ? m.texts['cet-' + c.id] : c.text;
296
+ var r = rectFrom(m.layout[c.id] || c.pos);
297
+ els.push(compact({ id: c.id, kind: 'text', text: t || '', rect: r, nat: natFrom(m, c.id, r), style: styleFrom(m, c.id) }));
298
+ });
299
+ m.icons.forEach(function(c) {
300
+ seen[c.id] = 1;
301
+ var r = rectFrom(m.layout[c.id]);
302
+ els.push(compact({ id: c.id, kind: 'icon', icon: c.iconName, rect: r, nat: natFrom(m, c.id, r), style: styleFrom(m, c.id) }));
303
+ });
304
+ m.comps.forEach(function(c) {
305
+ seen[c.id] = 1;
306
+ var r = rectFrom(m.layout[c.id]);
307
+ var e = { id: c.id, kind: 'component', component: c.compName, rect: r, nat: natFrom(m, c.id, r), style: styleFrom(m, c.id) };
308
+ if (m.tabwin[c.id]) e.tabs = m.tabwin[c.id].map(function(t){ return t.label; });
309
+ /* Bar widgets carry an editable inner label persisted under 'bet-<id>'. Capture
310
+ it (empty string included) so a cleared bar text survives export → import. */
311
+ if (m.texts['bet-' + c.id] !== undefined) e.text = m.texts['bet-' + c.id];
312
+ els.push(compact(e));
313
+ });
314
+ m.emotes.forEach(function(c) {
315
+ seen[c.id] = 1;
316
+ els.push(compact({ id: c.id, kind: 'emote', emote: c.emoji, rect: rectFrom(m.layout[c.id]), style: styleFrom(m, c.id) }));
317
+ });
318
+ var builtin = {};
319
+ Object.keys(m.layout).forEach(function(id){ if (id.indexOf('el-') === 0) builtin[id] = 1; });
320
+ m.deleted.forEach(function(id){ if (id.indexOf('el-') === 0) builtin[id] = 1; });
321
+ /* A builtin changed only via style/size/align/etc (never moved) has no layout
322
+ entry — collect it from every override map so its state survives export. */
323
+ [m.colors, m.fgColors, m.sizes, m.pads, m.zindex, m.aligns, m.valigns, m.roles, m.natural, m.zorder].forEach(function(map){
324
+ if (map) Object.keys(map).forEach(function(id){ if (id.indexOf('el-') === 0 && document.getElementById(id)) builtin[id] = 1; });
325
+ });
326
+ /* A builtin whose ONLY edit is its text (e.g. an HP bar cleared to empty) lives
327
+ solely under an 'et-<suffix>' key — map it back to its 'el-<suffix>' element so
328
+ the override (empty string included) is preserved instead of reverting on import. */
329
+ Object.keys(m.texts || {}).forEach(function(tk){
330
+ if (tk.indexOf('et-') === 0) { var bid = 'el-' + tk.slice(3); if (document.getElementById(bid)) builtin[bid] = 1; }
331
+ });
332
+ Object.keys(builtin).forEach(function(id) {
333
+ if (seen[id]) return;
334
+ var tk = 'et-' + id.replace(/^el-/, '');
335
+ var e = { id: id, kind: 'builtin', rect: rectFrom(m.layout[id]), style: styleFrom(m, id) };
336
+ if (m.texts[tk] !== undefined) e.text = m.texts[tk];
337
+ if (m.deleted.indexOf(id) >= 0) e.removed = true;
338
+ els.push(compact(e));
339
+ });
340
+ /* Stacking order (front/back) is a cross-cutting concern, so stamp it on every
341
+ element in one pass rather than threading it through each builder above.
342
+ Restored on import so overlapping objects keep the exact layering. */
343
+ els.forEach(function(e){ if (m.zorder && m.zorder[e.id] != null) e.stack = m.zorder[e.id]; });
344
+ return {
345
+ "$schema": "https://noobsociety.gg/schema/hud-layout/v1",
346
+ kind: "NoobSocietyHudLayout",
347
+ version: 1,
348
+ generator: "NoobSociety Scene Builder",
349
+ exportedAt: new Date().toISOString(),
350
+ grid: { cols: GRID.cols, rows: GRID.rows, tilePx: GRID.tilePx, origin: 1 },
351
+ canvas: {
352
+ showGrid: localStorage.getItem('ns-grid-on') !== 'false',
353
+ showHud: localStorage.getItem('ns-hud-on') !== 'false',
354
+ showBackground: localStorage.getItem('ns-bg-on') !== 'false'
355
+ },
356
+ elements: els
357
+ };
358
+ }
359
+
360
+ /* document → ns- storage keys */
361
+ function toStorage(doc) {
362
+ var layout = {}, customs = [], icons = [], comps = [], emotes = [],
363
+ texts = {}, colors = {}, fgColors = {}, aligns = {}, valigns = {}, sizes = {}, pads = {}, zindex = {}, roles = {}, deleted = [], tabwin = {}, natural = {}, zorder = {};
364
+ (doc.elements || []).forEach(function(e) {
365
+ var id = e.id; if (!id) return;
366
+ if (e.rect) layout[id] = { c1: e.rect.col, r1: e.rect.row, c2: e.rect.col + e.rect.width, r2: e.rect.row + e.rect.height };
367
+ if (e.nat) natural[id] = { w: e.nat.w, h: e.nat.h };
368
+ if (typeof e.stack !== 'undefined') zorder[id] = e.stack;
369
+ var st = e.style || {};
370
+ if (st.color) colors[id] = st.color;
371
+ if (st.fgColor) fgColors[id] = st.fgColor;
372
+ if (st.size) sizes[id] = st.size;
373
+ if (st.pad) pads[id] = st.pad;
374
+ if (typeof st.z !== 'undefined') zindex[id] = st.z;
375
+ if (st.align) aligns[id] = st.align;
376
+ if (st.vAlign) valigns[id] = st.vAlign;
377
+ if (st.role) roles[id] = st.role;
378
+ if (e.kind === 'text') {
379
+ customs.push({ id: id, pos: layout[id] || { c1:1, r1:1, c2:2, r2:2 }, text: e.text || '' });
380
+ texts['cet-' + id] = e.text || '';
381
+ } else if (e.kind === 'icon') {
382
+ icons.push({ id: id, iconName: e.icon });
383
+ } else if (e.kind === 'component') {
384
+ comps.push({ id: id, compName: e.component });
385
+ if (e.tabs) tabwin[id] = e.tabs.map(function(l){ return { label: l }; });
386
+ /* Restore the bar's inner label so buildBar reads it back from 'bet-<id>'. */
387
+ if (e.text !== undefined) texts['bet-' + id] = e.text;
388
+ } else if (e.kind === 'emote') {
389
+ emotes.push({ id: id, emoji: e.emote });
390
+ } else if (e.kind === 'builtin') {
391
+ if (e.text !== undefined) texts['et-' + id.replace(/^el-/, '')] = e.text;
392
+ if (e.removed) deleted.push(id);
393
+ }
394
+ });
395
+ var out = {
396
+ 'ns-hud-layout': JSON.stringify(layout),
397
+ 'ns-hud-natural': JSON.stringify(natural),
398
+ 'ns-hud-zorder': JSON.stringify(zorder),
399
+ 'ns-hud-custom': JSON.stringify(customs),
400
+ 'ns-hud-icon-els': JSON.stringify(icons),
401
+ 'ns-hud-comp-els': JSON.stringify(comps),
402
+ 'ns-hud-emoji-els': JSON.stringify(emotes),
403
+ 'ns-hud-texts': JSON.stringify(texts),
404
+ 'ns-hud-colors': JSON.stringify(colors),
405
+ 'ns-hud-fgcolors': JSON.stringify(fgColors),
406
+ 'ns-hud-align': JSON.stringify(aligns),
407
+ 'ns-hud-valigns': JSON.stringify(valigns),
408
+ 'ns-hud-sizes': JSON.stringify(sizes),
409
+ 'ns-hud-pads': JSON.stringify(pads),
410
+ 'ns-hud-zindex': JSON.stringify(zindex),
411
+ 'ns-hud-roles': JSON.stringify(roles),
412
+ 'ns-hud-deleted': JSON.stringify(deleted),
413
+ 'ns-hud-tabwin-data': JSON.stringify(tabwin)
414
+ };
415
+ if (doc.canvas) {
416
+ out['ns-grid-on'] = String(doc.canvas.showGrid !== false);
417
+ out['ns-hud-on'] = String(doc.canvas.showHud !== false);
418
+ out['ns-bg-on'] = String(doc.canvas.showBackground !== false);
419
+ }
420
+ return out;
421
+ }
422
+
423
+ /* ── Export → download .json (clean layout document) ── */
424
+ exportBtn.addEventListener('click', function(e) {
425
+ e.stopPropagation();
426
+ var doc = serialize();
427
+ var blob = new Blob([JSON.stringify(doc, null, 2)], { type: 'application/json' });
428
+ var url = URL.createObjectURL(blob);
429
+ var a = document.createElement('a');
430
+ var stamp = new Date().toISOString().slice(0, 19).replace(/:/g, '');
431
+ a.href = url;
432
+ a.download = 'scene-builder-export-' + stamp + '.json';
433
+ document.body.appendChild(a);
434
+ a.click();
435
+ document.body.removeChild(a);
436
+ setTimeout(function() { URL.revokeObjectURL(url); }, 1000);
437
+ localStorage.setItem(MARK_KEY, canonical());
438
+ flash(exportBtn);
439
+ });
440
+
441
+ /* ── Import ← read .json, apply, reload ── */
442
+ importBtn.addEventListener('click', function(e) { e.stopPropagation(); fileInput.click(); });
443
+ fileInput.addEventListener('change', function() {
444
+ var file = fileInput.files && fileInput.files[0];
445
+ if (!file) return;
446
+ var reader = new FileReader();
447
+ reader.onload = function() {
448
+ try {
449
+ var doc = JSON.parse(reader.result);
450
+ var stateKeys;
451
+ if (doc && Array.isArray(doc.elements)) {
452
+ stateKeys = toStorage(doc); // clean layout document
453
+ } else if (doc && doc.state) {
454
+ stateKeys = doc.state; // legacy raw-state dump
455
+ } else if (doc && Object.keys(doc).some(function(k){ return k.indexOf('ns-') === 0; })) {
456
+ stateKeys = doc; // legacy bare state
457
+ } else {
458
+ throw new Error('Unrecognised file — no "elements" array or layout state found.');
459
+ }
460
+ var keys = Object.keys(stateKeys).filter(function(k) { return k.indexOf('ns-') === 0; });
461
+ if (!keys.length) throw new Error('No HUD elements found in file.');
462
+ keys.forEach(function(k) { localStorage.setItem(k, stateKeys[k]); });
463
+ /* Stash the payload under a key the on-load baseline wipe does NOT clear,
464
+ so the scene survives the reload below instead of resetting to blank. */
465
+ var payload = {};
466
+ keys.forEach(function(k) { payload[k] = stateKeys[k]; });
467
+ localStorage.setItem('__hud_import_payload', JSON.stringify(payload));
468
+ localStorage.setItem('hud_saved_snapshot', btoa(unescape(encodeURIComponent('NSHUD1:' + JSON.stringify(stateKeys)))));
469
+ localStorage.setItem(MARK_KEY, canonical());
470
+ flash(importBtn);
471
+ setTimeout(function() { location.reload(); }, 300);
472
+ } catch (err) {
473
+ alert('Could not import this file — it is not a valid NoobSociety HUD layout.\n\n' + err.message);
474
+ }
475
+ fileInput.value = '';
476
+ };
477
+ reader.readAsText(file);
478
+ });
479
+
480
+ render();
481
+ setInterval(function() {
482
+ if (!exportBtn.classList.contains('just-saved')) render();
483
+ }, 700);
484
+
485
+ /* Keyboard shortcuts: E = export, I = import (ignored while editing text) */
486
+ document.addEventListener('keydown', function(e) {
487
+ if (e.metaKey || e.ctrlKey || e.altKey) return;
488
+ var editing = document.activeElement && document.activeElement.contentEditable === 'true';
489
+ if (editing) return;
490
+ if (e.key === 'e' || e.key === 'E') { e.preventDefault(); exportBtn.click(); }
491
+ else if (e.key === 'i' || e.key === 'I') { e.preventDefault(); importBtn.click(); }
492
+ });
493
+ })();
494
+
495
+ /* ══════════════════════════════════════════
496
+ HUD TOGGLE
497
+ ══════════════════════════════════════════ */
498
+ (function() {
499
+ const btn = document.getElementById('hud-toggle');
500
+ const hud = document.querySelector('.hud');
501
+ if (!btn || !hud) return;
502
+ let on = localStorage.getItem('ns-hud-on') !== 'false';
503
+
504
+ function apply() {
505
+ hud.style.display = on ? 'grid' : 'none';
506
+ btn.classList.toggle('is-on', on);
507
+ localStorage.setItem('ns-hud-on', on);
508
+ }
509
+
510
+ btn.addEventListener('click', function(e) { e.stopPropagation(); on = !on; apply(); });
511
+ document.addEventListener('keydown', function(e) {
512
+ if ((e.key === 'h' || e.key === 'H') &&
513
+ (!document.activeElement || document.activeElement.contentEditable !== 'true')) {
514
+ on = !on; apply();
515
+ }
516
+ });
517
+ apply();
518
+ })();
519
+
520
+
521
+ /* ══════════════════════════════════════════
522
+ RPG ICON SVG LIBRARY
523
+ ══════════════════════════════════════════ */
524
+ var ICON_SVGS = {
525
+ sword: '<line x1="14" y1="2" x2="6" y2="10" stroke="#cfd8e2" stroke-width="2" stroke-linecap="square"/><line x1="13" y1="2" x2="14" y2="3" stroke="#eef0f4" stroke-width="1" stroke-linecap="square"/><line x1="4" y1="9" x2="8" y2="13" stroke="#e6db74" stroke-width="2" stroke-linecap="square"/><line x1="6" y1="11" x2="3" y2="14" stroke="#b07040" stroke-width="2" stroke-linecap="square"/>',
526
+ staff: '<rect x="5" y="1" width="6" height="1" fill="#c8a8ff"/><rect x="4" y="2" width="8" height="4" fill="#ae81ff"/><rect x="5" y="6" width="6" height="1" fill="#c8a8ff"/><rect x="6" y="3" width="4" height="2" fill="#d4b8ff"/><rect x="7" y="7" width="2" height="8" fill="#8a6540"/><rect x="6" y="10" width="4" height="1" fill="#b07040"/>',
527
+ bow: '<rect x="3" y="1" width="3" height="2" fill="#8a6540"/><rect x="2" y="3" width="2" height="10" fill="#8a6540"/><rect x="3" y="13" width="3" height="2" fill="#8a6540"/><rect x="6" y="2" width="1" height="1" fill="#cfd8e2"/><rect x="7" y="3" width="1" height="10" fill="#cfd8e2"/><rect x="6" y="13" width="1" height="1" fill="#cfd8e2"/><rect x="7" y="7" width="7" height="2" fill="#c8a878"/><rect x="13" y="6" width="2" height="1" fill="#cfd8e2"/><rect x="14" y="7" width="2" height="2" fill="#cfd8e2"/><rect x="13" y="9" width="2" height="1" fill="#cfd8e2"/><rect x="7" y="5" width="2" height="2" fill="#ae81ff"/><rect x="7" y="9" width="2" height="2" fill="#ae81ff"/>',
528
+ katar: '<rect x="2" y="1" width="2" height="8" fill="#cfd8e2"/><rect x="2" y="9" width="2" height="3" fill="#a8b0bc"/><rect x="12" y="1" width="2" height="8" fill="#cfd8e2"/><rect x="12" y="9" width="2" height="3" fill="#a8b0bc"/><rect x="2" y="8" width="12" height="2" fill="#e6db74"/><rect x="6" y="10" width="4" height="5" fill="#8a6540"/><rect x="3" y="0" width="1" height="1" fill="#eef0f4"/><rect x="12" y="0" width="1" height="1" fill="#eef0f4"/>',
529
+ book: '<rect x="2" y="2" width="2" height="12" fill="#6a4a6a"/><rect x="4" y="2" width="10" height="12" fill="#8855aa"/><rect x="13" y="3" width="1" height="10" fill="#f4f0e8"/><rect x="7" y="4" width="2" height="1" fill="#c8a8ff"/><rect x="6" y="5" width="4" height="1" fill="#c8a8ff"/><rect x="7" y="6" width="2" height="1" fill="#c8a8ff"/><rect x="6" y="8" width="4" height="1" fill="#e6db74"/><rect x="7" y="9" width="2" height="2" fill="#e6db74"/>',
530
+ hammer: '<rect x="4" y="1" width="8" height="1" fill="#a8b0bc"/><rect x="3" y="2" width="10" height="5" fill="#a8b0bc"/><rect x="3" y="2" width="10" height="1" fill="#cfd8e2"/><rect x="3" y="3" width="1" height="3" fill="#cfd8e2"/><rect x="3" y="7" width="10" height="1" fill="#5a6570"/><rect x="7" y="7" width="2" height="8" fill="#8a6540"/><rect x="6" y="10" width="4" height="1" fill="#b07040"/>',
531
+ neutral: '<rect x="2" y="6" width="12" height="4" fill="#7a7a8a"/><rect x="6" y="2" width="4" height="12" fill="#7a7a8a"/><rect x="3" y="3" width="2" height="2" fill="#7a7a8a"/><rect x="11" y="3" width="2" height="2" fill="#7a7a8a"/><rect x="3" y="11" width="2" height="2" fill="#7a7a8a"/><rect x="11" y="11" width="2" height="2" fill="#7a7a8a"/><rect x="7" y="7" width="2" height="2" fill="#b0b0c0"/><rect x="7" y="4" width="2" height="1" fill="#b0b0c0"/><rect x="7" y="11" width="2" height="1" fill="#b0b0c0"/><rect x="4" y="7" width="1" height="2" fill="#b0b0c0"/><rect x="11" y="7" width="1" height="2" fill="#b0b0c0"/>',
532
+ earth: '<rect x="7" y="2" width="2" height="2" fill="#c8a050"/><rect x="6" y="4" width="4" height="2" fill="#c8a050"/><rect x="5" y="6" width="6" height="2" fill="#8b6914"/><rect x="4" y="8" width="8" height="2" fill="#8b6914"/><rect x="3" y="10" width="10" height="2" fill="#6a5010"/><rect x="1" y="12" width="14" height="3" fill="#5a4008"/><rect x="7" y="2" width="2" height="1" fill="#f0ece0"/><rect x="6" y="3" width="4" height="1" fill="#f0ece0"/><rect x="5" y="8" width="1" height="1" fill="#a07820"/><rect x="10" y="9" width="1" height="1" fill="#a07820"/>',
533
+ wind: '<rect x="2" y="4" width="10" height="1" fill="#5dcaa5"/><rect x="3" y="5" width="2" height="1" fill="#5dcaa5"/><rect x="12" y="3" width="2" height="2" fill="#5dcaa5"/><rect x="13" y="5" width="1" height="1" fill="#5dcaa5"/><rect x="1" y="7" width="11" height="2" fill="#5dcaa5"/><rect x="12" y="6" width="2" height="4" fill="#5dcaa5"/><rect x="11" y="10" width="2" height="1" fill="#5dcaa5"/><rect x="2" y="12" width="9" height="1" fill="#5dcaa5"/><rect x="2" y="11" width="2" height="1" fill="#5dcaa5"/><rect x="11" y="12" width="2" height="2" fill="#5dcaa5"/><rect x="10" y="14" width="2" height="1" fill="#5dcaa5"/><rect x="2" y="3" width="8" height="1" fill="#90e8cc"/><rect x="2" y="8" width="9" height="1" fill="#90e8cc"/>',
534
+ water: '<rect x="6" y="1" width="4" height="1" fill="#378add"/><rect x="5" y="2" width="6" height="2" fill="#378add"/><rect x="4" y="4" width="8" height="6" fill="#378add"/><rect x="5" y="10" width="6" height="2" fill="#378add"/><rect x="6" y="12" width="4" height="2" fill="#378add"/><rect x="7" y="14" width="2" height="1" fill="#378add"/><rect x="6" y="4" width="3" height="4" fill="#70b8f0"/><rect x="7" y="3" width="2" height="1" fill="#70b8f0"/><rect x="5" y="7" width="2" height="1" fill="#1a5a9a"/><rect x="9" y="8" width="2" height="1" fill="#1a5a9a"/>',
535
+ fire: '<rect x="4" y="11" width="8" height="4" fill="#ba7517"/><rect x="3" y="8" width="10" height="3" fill="#ba7517"/><rect x="4" y="5" width="8" height="3" fill="#ba7517"/><rect x="5" y="3" width="6" height="2" fill="#ba7517"/><rect x="6" y="1" width="4" height="2" fill="#ba7517"/><rect x="5" y="9" width="6" height="5" fill="#e8882a"/><rect x="6" y="7" width="4" height="2" fill="#e8882a"/><rect x="6" y="10" width="4" height="3" fill="#e6db74"/><rect x="7" y="9" width="2" height="1" fill="#e6db74"/>',
536
+ light: '<rect x="5" y="5" width="6" height="6" fill="#fac775"/><rect x="6" y="4" width="4" height="1" fill="#fac775"/><rect x="6" y="11" width="4" height="1" fill="#fac775"/><rect x="4" y="6" width="1" height="4" fill="#fac775"/><rect x="11" y="6" width="1" height="4" fill="#fac775"/><rect x="7" y="1" width="2" height="3" fill="#fac775"/><rect x="7" y="12" width="2" height="3" fill="#fac775"/><rect x="1" y="7" width="3" height="2" fill="#fac775"/><rect x="12" y="7" width="3" height="2" fill="#fac775"/><rect x="3" y="3" width="2" height="2" fill="#fac775"/><rect x="11" y="3" width="2" height="2" fill="#fac775"/><rect x="3" y="11" width="2" height="2" fill="#fac775"/><rect x="11" y="11" width="2" height="2" fill="#fac775"/><rect x="6" y="6" width="4" height="4" fill="#fff8e0"/>',
537
+ dark: '<rect x="5" y="2" width="6" height="2" fill="#888780"/><rect x="3" y="4" width="8" height="8" fill="#888780"/><rect x="5" y="12" width="6" height="2" fill="#888780"/><rect x="4" y="3" width="1" height="10" fill="#888780"/><rect x="8" y="4" width="4" height="8" fill="#1a1a20"/><rect x="7" y="5" width="5" height="6" fill="#1a1a20"/><rect x="11" y="2" width="1" height="1" fill="#c8c8d0"/><rect x="13" y="5" width="1" height="1" fill="#c8c8d0"/><rect x="12" y="9" width="1" height="1" fill="#c8c8d0"/>',
538
+ void: '<rect x="5" y="1" width="6" height="2" fill="#7f77dd"/><rect x="3" y="3" width="10" height="2" fill="#7f77dd"/><rect x="2" y="5" width="12" height="6" fill="#7f77dd"/><rect x="3" y="11" width="10" height="2" fill="#7f77dd"/><rect x="5" y="13" width="6" height="2" fill="#7f77dd"/><rect x="4" y="4" width="8" height="8" fill="#1a1828"/><rect x="5" y="3" width="6" height="1" fill="#1a1828"/><rect x="5" y="12" width="6" height="1" fill="#1a1828"/><rect x="3" y="5" width="1" height="6" fill="#1a1828"/><rect x="12" y="5" width="1" height="6" fill="#1a1828"/><rect x="6" y="6" width="4" height="4" fill="#4a44aa"/><rect x="7" y="5" width="2" height="1" fill="#4a44aa"/><rect x="7" y="10" width="2" height="1" fill="#4a44aa"/><rect x="5" y="7" width="1" height="2" fill="#4a44aa"/><rect x="10" y="7" width="1" height="2" fill="#4a44aa"/><rect x="7" y="7" width="2" height="2" fill="#c8c0ff"/>',
539
+ human: '<rect x="5" y="1" width="6" height="5" fill="#e8c87a"/><rect x="7" y="6" width="2" height="1" fill="#d4a85a"/><rect x="3" y="7" width="10" height="5" fill="#c8873a"/><rect x="1" y="7" width="2" height="4" fill="#c8873a"/><rect x="13" y="7" width="2" height="4" fill="#c8873a"/><rect x="4" y="12" width="3" height="3" fill="#8a5c2a"/><rect x="9" y="12" width="3" height="3" fill="#8a5c2a"/>',
540
+ beast: '<rect x="2" y="1" width="3" height="4" fill="#c8a060"/><rect x="11" y="1" width="3" height="4" fill="#c8a060"/><rect x="3" y="2" width="1" height="2" fill="#e8886a"/><rect x="12" y="2" width="1" height="2" fill="#e8886a"/><rect x="3" y="4" width="10" height="6" fill="#c8a060"/><rect x="5" y="8" width="6" height="3" fill="#d4b070"/><rect x="7" y="8" width="2" height="1" fill="#5a3020"/><rect x="4" y="6" width="3" height="2" fill="#e8a020"/><rect x="9" y="6" width="3" height="2" fill="#e8a020"/><rect x="5" y="6" width="1" height="2" fill="#1a1008"/><rect x="10" y="6" width="1" height="2" fill="#1a1008"/><rect x="3" y="11" width="10" height="4" fill="#a87840"/><rect x="1" y="11" width="2" height="3" fill="#a87840"/><rect x="13" y="11" width="2" height="3" fill="#a87840"/>',
541
+ demon: '<rect x="3" y="1" width="2" height="5" fill="#cc3030"/><rect x="11" y="1" width="2" height="5" fill="#cc3030"/><rect x="2" y="1" width="2" height="2" fill="#cc3030"/><rect x="12" y="1" width="2" height="2" fill="#cc3030"/><rect x="3" y="5" width="10" height="6" fill="#7a2a2a"/><rect x="4" y="7" width="3" height="2" fill="#ff2020"/><rect x="9" y="7" width="3" height="2" fill="#ff2020"/><rect x="5" y="7" width="1" height="2" fill="#ff8080"/><rect x="10" y="7" width="1" height="2" fill="#ff8080"/><rect x="6" y="11" width="1" height="2" fill="#f0e8d0"/><rect x="9" y="11" width="1" height="2" fill="#f0e8d0"/><rect x="2" y="12" width="12" height="4" fill="#5a1a1a"/><rect x="1" y="12" width="2" height="3" fill="#5a1a1a"/><rect x="13" y="12" width="2" height="3" fill="#5a1a1a"/>',
542
+ angel: '<rect x="4" y="1" width="8" height="1" fill="#e6db74"/><rect x="3" y="2" width="10" height="1" fill="#e6db74"/><rect x="3" y="3" width="2" height="1" fill="#e6db74"/><rect x="11" y="3" width="2" height="1" fill="#e6db74"/><rect x="5" y="4" width="6" height="5" fill="#f4e4c0"/><rect x="6" y="6" width="2" height="1" fill="#6090e0"/><rect x="10" y="6" width="2" height="1" fill="#6090e0"/><rect x="1" y="9" width="4" height="5" fill="#f0f0f8"/><rect x="11" y="9" width="4" height="5" fill="#f0f0f8"/><rect x="1" y="9" width="2" height="3" fill="#ffffff"/><rect x="13" y="9" width="2" height="3" fill="#ffffff"/><rect x="5" y="9" width="6" height="6" fill="#d4c8f0"/><rect x="4" y="10" width="1" height="4" fill="#d4c8f0"/><rect x="11" y="10" width="1" height="4" fill="#d4c8f0"/>',
543
+ spirit: '<rect x="6" y="1" width="4" height="2" fill="#66d9e8"/><rect x="5" y="3" width="6" height="4" fill="#66d9e8"/><rect x="4" y="7" width="8" height="4" fill="#66d9e8"/><rect x="5" y="11" width="6" height="2" fill="#66d9e8"/><rect x="4" y="13" width="2" height="2" fill="#66d9e8"/><rect x="7" y="13" width="2" height="2" fill="#66d9e8"/><rect x="10" y="13" width="2" height="2" fill="#66d9e8"/><rect x="6" y="4" width="4" height="3" fill="#b0eff6"/><rect x="7" y="3" width="2" height="1" fill="#b0eff6"/><rect x="6" y="7" width="2" height="2" fill="#ffffff"/><rect x="9" y="7" width="2" height="2" fill="#ffffff"/><rect x="7" y="8" width="1" height="1" fill="#66d9e8"/><rect x="10" y="8" width="1" height="1" fill="#66d9e8"/>',
544
+ small: '<rect x="1" y="11" width="3" height="4" fill="#cfd8e2"/><rect x="6" y="7" width="4" height="8" fill="#4a4a5a"/><rect x="12" y="4" width="3" height="11" fill="#4a4a5a"/><rect x="1" y="15" width="14" height="1" fill="#5a5a6a"/><rect x="1" y="8" width="1" height="2" fill="#e6db74"/><rect x="3" y="10" width="1" height="2" fill="#e6db74"/><rect x="2" y="10" width="1" height="1" fill="#e6db74"/>',
545
+ medium: '<rect x="1" y="11" width="3" height="4" fill="#4a4a5a"/><rect x="6" y="7" width="4" height="8" fill="#cfd8e2"/><rect x="12" y="4" width="3" height="11" fill="#4a4a5a"/><rect x="1" y="15" width="14" height="1" fill="#5a5a6a"/><rect x="6" y="4" width="1" height="2" fill="#e6db74"/><rect x="9" y="4" width="1" height="2" fill="#e6db74"/><rect x="7" y="5" width="2" height="1" fill="#e6db74"/>',
546
+ large: '<rect x="1" y="11" width="3" height="4" fill="#4a4a5a"/><rect x="6" y="7" width="4" height="8" fill="#4a4a5a"/><rect x="12" y="4" width="3" height="11" fill="#cfd8e2"/><rect x="1" y="15" width="14" height="1" fill="#5a5a6a"/><rect x="13" y="1" width="1" height="2" fill="#e6db74"/><rect x="14" y="1" width="1" height="3" fill="#e6db74"/><rect x="13" y="3" width="1" height="1" fill="#e6db74"/>',
547
+ slash: '<rect x="10" y="1" width="2" height="2" fill="#ff6040"/><rect x="9" y="3" width="2" height="2" fill="#ff6040"/><rect x="8" y="5" width="2" height="2" fill="#ff6040"/><rect x="12" y="3" width="2" height="2" fill="#ff9070"/><rect x="11" y="5" width="2" height="2" fill="#ff9070"/><rect x="10" y="7" width="2" height="2" fill="#ff9070"/><rect x="6" y="7" width="2" height="2" fill="#ff6040"/><rect x="5" y="9" width="2" height="2" fill="#ff6040"/><rect x="4" y="11" width="2" height="2" fill="#ff6040"/><rect x="3" y="13" width="2" height="2" fill="#c83010"/>',
548
+ thrust: '<rect x="1" y="7" width="8" height="2" fill="#a8b0bc"/><rect x="9" y="6" width="4" height="4" fill="#cfd8e2"/><rect x="13" y="7" width="2" height="2" fill="#eef0f4"/><rect x="6" y="6" width="2" height="1" fill="#e6db74"/><rect x="6" y="9" width="2" height="1" fill="#e6db74"/>',
549
+ smash: '<rect x="7" y="1" width="2" height="3" fill="#fac775"/><rect x="12" y="3" width="3" height="2" fill="#fac775"/><rect x="13" y="7" width="2" height="2" fill="#fac775"/><rect x="12" y="11" width="3" height="2" fill="#fac775"/><rect x="7" y="12" width="2" height="3" fill="#fac775"/><rect x="1" y="11" width="3" height="2" fill="#fac775"/><rect x="1" y="7" width="2" height="2" fill="#fac775"/><rect x="2" y="3" width="2" height="2" fill="#fac775"/><rect x="5" y="5" width="6" height="6" fill="#ff9040"/><rect x="6" y="6" width="4" height="4" fill="#ffe080"/>',
550
+ shoot: '<rect x="1" y="7" width="10" height="2" fill="#c8a878"/><rect x="11" y="6" width="2" height="4" fill="#cfd8e2"/><rect x="13" y="7" width="2" height="2" fill="#eef0f4"/><rect x="1" y="6" width="2" height="1" fill="#ae81ff"/><rect x="1" y="9" width="2" height="1" fill="#ae81ff"/><rect x="3" y="5" width="1" height="1" fill="#ae81ff"/><rect x="3" y="10" width="1" height="1" fill="#ae81ff"/>',
551
+ cast: '<rect x="7" y="1" width="2" height="2" fill="#c8a8ff"/><rect x="7" y="13" width="2" height="2" fill="#c8a8ff"/><rect x="1" y="7" width="2" height="2" fill="#c8a8ff"/><rect x="13" y="7" width="2" height="2" fill="#c8a8ff"/><rect x="4" y="4" width="2" height="2" fill="#ae81ff"/><rect x="10" y="4" width="2" height="2" fill="#ae81ff"/><rect x="4" y="10" width="2" height="2" fill="#ae81ff"/><rect x="10" y="10" width="2" height="2" fill="#ae81ff"/><rect x="6" y="6" width="4" height="4" fill="#7f77dd"/><rect x="7" y="7" width="2" height="2" fill="#ffffff"/>',
552
+ throw: '<rect x="3" y="3" width="2" height="2" fill="#e6db74"/><rect x="6" y="1" width="2" height="2" fill="#e6db74"/><rect x="10" y="2" width="2" height="2" fill="#e6db74"/><rect x="13" y="5" width="2" height="2" fill="#e6db74"/><rect x="12" y="9" width="3" height="3" fill="#c8a050"/><rect x="1" y="8" width="3" height="4" fill="#8a6540"/><rect x="3" y="10" width="2" height="4" fill="#e8c87a"/>',
553
+ charge: '<rect x="9" y="1" width="3" height="4" fill="#e6db74"/><rect x="6" y="5" width="6" height="4" fill="#e6db74"/><rect x="4" y="9" width="6" height="6" fill="#e6db74"/><rect x="10" y="5" width="2" height="1" fill="#fac775"/><rect x="7" y="9" width="2" height="1" fill="#fac775"/><rect x="1" y="4" width="5" height="1" fill="#c8a050"/><rect x="1" y="7" width="4" height="1" fill="#c8a050"/><rect x="1" y="10" width="3" height="1" fill="#c8a050"/>',
554
+ counter: '<rect x="4" y="1" width="8" height="10" fill="#a8b0bc"/><rect x="5" y="2" width="6" height="8" fill="#cfd8e2"/><rect x="6" y="11" width="4" height="2" fill="#a8b0bc"/><rect x="7" y="13" width="2" height="2" fill="#a8b0bc"/><rect x="7" y="4" width="2" height="4" fill="#5dcaa5"/><rect x="5" y="6" width="2" height="2" fill="#5dcaa5"/><rect x="9" y="6" width="2" height="2" fill="#5dcaa5"/>',
555
+ passive: '<rect x="5" y="1" width="6" height="1" fill="#5a6a7a"/><rect x="4" y="2" width="8" height="8" fill="#4a5a6a"/><rect x="5" y="10" width="6" height="2" fill="#4a5a6a"/><rect x="6" y="12" width="4" height="2" fill="#4a5a6a"/><rect x="7" y="14" width="2" height="1" fill="#4a5a6a"/><rect x="6" y="4" width="4" height="4" fill="#5dcaa5"/><rect x="7" y="3" width="2" height="1" fill="#5dcaa5"/><rect x="5" y="5" width="1" height="2" fill="#5dcaa5"/><rect x="10" y="5" width="1" height="2" fill="#5dcaa5"/><rect x="7" y="8" width="2" height="1" fill="#5dcaa5"/>',
556
+ active: '<rect x="7" y="1" width="2" height="3" fill="#fac775"/><rect x="7" y="12" width="2" height="3" fill="#fac775"/><rect x="1" y="7" width="3" height="2" fill="#fac775"/><rect x="12" y="7" width="3" height="2" fill="#fac775"/><rect x="3" y="3" width="2" height="2" fill="#fac775"/><rect x="11" y="3" width="2" height="2" fill="#fac775"/><rect x="3" y="11" width="2" height="2" fill="#fac775"/><rect x="11" y="11" width="2" height="2" fill="#fac775"/><rect x="5" y="5" width="6" height="6" fill="#ff9040"/><rect x="6" y="6" width="4" height="4" fill="#ffdd80"/><rect x="7" y="7" width="2" height="2" fill="#ffffff"/>',
557
+ combo: '<rect x="2" y="6" width="3" height="4" fill="#ae81ff"/><rect x="7" y="4" width="3" height="4" fill="#ae81ff"/><rect x="12" y="6" width="3" height="4" fill="#ae81ff"/><rect x="5" y="7" width="2" height="2" fill="#7f77dd"/><rect x="10" y="7" width="2" height="2" fill="#7f77dd"/><rect x="3" y="11" width="2" height="1" fill="#c8a8ff"/><rect x="8" y="9" width="2" height="1" fill="#c8a8ff"/><rect x="13" y="11" width="2" height="1" fill="#c8a8ff"/>',
558
+ aura: '<rect x="6" y="1" width="4" height="1" fill="#c8a8ff"/><rect x="4" y="2" width="2" height="1" fill="#c8a8ff"/><rect x="10" y="2" width="2" height="1" fill="#c8a8ff"/><rect x="2" y="4" width="1" height="2" fill="#c8a8ff"/><rect x="13" y="4" width="1" height="2" fill="#c8a8ff"/><rect x="1" y="6" width="1" height="4" fill="#c8a8ff"/><rect x="14" y="6" width="1" height="4" fill="#c8a8ff"/><rect x="2" y="10" width="1" height="2" fill="#c8a8ff"/><rect x="13" y="10" width="1" height="2" fill="#c8a8ff"/><rect x="4" y="13" width="2" height="1" fill="#c8a8ff"/><rect x="10" y="13" width="2" height="1" fill="#c8a8ff"/><rect x="6" y="14" width="4" height="1" fill="#c8a8ff"/><rect x="6" y="4" width="4" height="1" fill="#7f77dd"/><rect x="4" y="6" width="1" height="4" fill="#7f77dd"/><rect x="11" y="6" width="1" height="4" fill="#7f77dd"/><rect x="6" y="11" width="4" height="1" fill="#7f77dd"/><rect x="6" y="6" width="4" height="4" fill="#ae81ff"/><rect x="7" y="7" width="2" height="2" fill="#c8a8ff"/>',
559
+ buff: '<rect x="7" y="1" width="2" height="1" fill="#5dcaa5"/><rect x="6" y="2" width="4" height="2" fill="#5dcaa5"/><rect x="5" y="4" width="6" height="2" fill="#5dcaa5"/><rect x="7" y="6" width="2" height="9" fill="#5dcaa5"/><rect x="6" y="3" width="1" height="1" fill="#90e8cc"/><rect x="9" y="3" width="1" height="1" fill="#90e8cc"/><rect x="5" y="5" width="1" height="1" fill="#90e8cc"/><rect x="10" y="5" width="1" height="1" fill="#90e8cc"/>',
560
+ debuff: '<rect x="7" y="5" width="2" height="9" fill="#cc3030"/><rect x="5" y="10" width="6" height="2" fill="#cc3030"/><rect x="6" y="12" width="4" height="2" fill="#cc3030"/><rect x="7" y="14" width="2" height="1" fill="#cc3030"/><rect x="5" y="11" width="1" height="1" fill="#ff8080"/><rect x="10" y="11" width="1" height="1" fill="#ff8080"/><rect x="6" y="13" width="1" height="1" fill="#ff8080"/><rect x="9" y="13" width="1" height="1" fill="#ff8080"/>',
561
+ summon: '<rect x="6" y="1" width="4" height="1" fill="#7f77dd"/><rect x="4" y="2" width="2" height="2" fill="#7f77dd"/><rect x="10" y="2" width="2" height="2" fill="#7f77dd"/><rect x="2" y="4" width="2" height="2" fill="#7f77dd"/><rect x="12" y="4" width="2" height="2" fill="#7f77dd"/><rect x="1" y="6" width="2" height="4" fill="#7f77dd"/><rect x="13" y="6" width="2" height="4" fill="#7f77dd"/><rect x="2" y="10" width="2" height="2" fill="#7f77dd"/><rect x="12" y="10" width="2" height="2" fill="#7f77dd"/><rect x="4" y="12" width="2" height="2" fill="#7f77dd"/><rect x="10" y="12" width="2" height="2" fill="#7f77dd"/><rect x="6" y="14" width="4" height="1" fill="#7f77dd"/><rect x="5" y="4" width="6" height="8" fill="#1a1828"/><rect x="4" y="5" width="8" height="6" fill="#1a1828"/><rect x="6" y="6" width="4" height="4" fill="#4a44aa"/><rect x="7" y="7" width="2" height="2" fill="#ae81ff"/>',
562
+ stance: '<rect x="7" y="1" width="2" height="2" fill="#cfd8e2"/><rect x="6" y="3" width="4" height="4" fill="#cfd8e2"/><rect x="3" y="4" width="3" height="2" fill="#cfd8e2"/><rect x="10" y="3" width="3" height="2" fill="#cfd8e2"/><rect x="12" y="2" width="2" height="2" fill="#e6db74"/><rect x="5" y="7" width="2" height="4" fill="#cfd8e2"/><rect x="9" y="7" width="2" height="4" fill="#cfd8e2"/><rect x="4" y="11" width="2" height="3" fill="#cfd8e2"/><rect x="10" y="11" width="2" height="3" fill="#cfd8e2"/><rect x="7" y="3" width="2" height="1" fill="#eef0f4"/>',
563
+ potion: '<rect x="7" y="1" width="2" height="2" fill="#a8b0bc"/><rect x="6" y="3" width="4" height="1" fill="#c8a050"/><rect x="5" y="4" width="6" height="10" fill="#cfd8e2"/><rect x="4" y="6" width="8" height="7" fill="#cfd8e2"/><rect x="5" y="7" width="6" height="6" fill="#ff4080"/><rect x="4" y="9" width="8" height="4" fill="#ff4080"/><rect x="5" y="5" width="2" height="3" fill="#eef0f4"/><rect x="5" y="14" width="6" height="1" fill="#a8b0bc"/><rect x="4" y="13" width="8" height="1" fill="#a8b0bc"/>',
564
+ ether: '<rect x="7" y="1" width="2" height="3" fill="#a8b0bc"/><rect x="6" y="3" width="4" height="1" fill="#5dcaa5"/><rect x="5" y="4" width="6" height="9" fill="#cfd8e2"/><rect x="4" y="6" width="8" height="6" fill="#cfd8e2"/><rect x="5" y="6" width="6" height="6" fill="#66d9e8"/><rect x="4" y="8" width="8" height="3" fill="#66d9e8"/><rect x="6" y="7" width="2" height="3" fill="#b0eff6"/><rect x="5" y="13" width="6" height="1" fill="#a8b0bc"/><rect x="4" y="12" width="8" height="1" fill="#a8b0bc"/>',
565
+ scroll: '<rect x="1" y="4" width="3" height="8" fill="#c8a050"/><rect x="2" y="3" width="2" height="10" fill="#d4b060"/><rect x="12" y="4" width="3" height="8" fill="#c8a050"/><rect x="12" y="3" width="2" height="10" fill="#d4b060"/><rect x="3" y="2" width="10" height="12" fill="#f0e8c0"/><rect x="2" y="4" width="12" height="8" fill="#f0e8c0"/><rect x="5" y="5" width="6" height="1" fill="#c8a050"/><rect x="5" y="7" width="6" height="1" fill="#c8a050"/><rect x="5" y="9" width="4" height="1" fill="#c8a050"/>',
566
+ gem: '<rect x="6" y="1" width="4" height="1" fill="#b0eff6"/><rect x="5" y="2" width="6" height="2" fill="#66d9e8"/><rect x="4" y="4" width="8" height="1" fill="#b0eff6"/><rect x="3" y="5" width="10" height="4" fill="#378add"/><rect x="4" y="5" width="4" height="3" fill="#66d9e8"/><rect x="4" y="9" width="8" height="2" fill="#1a5a9a"/><rect x="5" y="11" width="6" height="2" fill="#1a5a9a"/><rect x="6" y="13" width="4" height="1" fill="#1a5a9a"/><rect x="7" y="14" width="2" height="1" fill="#1a5a9a"/>',
567
+ key: '<rect x="5" y="1" width="6" height="1" fill="#e6db74"/><rect x="4" y="2" width="8" height="5" fill="#e6db74"/><rect x="4" y="2" width="2" height="5" fill="#c8a050"/><rect x="5" y="3" width="6" height="3" fill="#c8a050"/><rect x="6" y="4" width="4" height="1" fill="#1a1a20"/><rect x="7" y="7" width="2" height="8" fill="#e6db74"/><rect x="9" y="10" width="3" height="1" fill="#e6db74"/><rect x="9" y="12" width="2" height="1" fill="#e6db74"/>',
568
+ shield: '<rect x="4" y="1" width="8" height="1" fill="#a8b0bc"/><rect x="3" y="2" width="10" height="7" fill="#a8b0bc"/><rect x="3" y="2" width="10" height="1" fill="#cfd8e2"/><rect x="3" y="3" width="1" height="6" fill="#cfd8e2"/><rect x="4" y="9" width="8" height="3" fill="#a8b0bc"/><rect x="5" y="12" width="6" height="2" fill="#a8b0bc"/><rect x="6" y="14" width="4" height="1" fill="#a8b0bc"/><rect x="7" y="4" width="2" height="5" fill="#378add"/><rect x="5" y="6" width="6" height="2" fill="#378add"/>',
569
+ armor: '<rect x="1" y="2" width="4" height="5" fill="#a8b0bc"/><rect x="11" y="2" width="4" height="5" fill="#a8b0bc"/><rect x="3" y="4" width="10" height="10" fill="#a8b0bc"/><rect x="4" y="3" width="8" height="11" fill="#a8b0bc"/><rect x="4" y="4" width="4" height="8" fill="#cfd8e2"/><rect x="7" y="3" width="2" height="11" fill="#5a6570"/><rect x="4" y="14" width="8" height="1" fill="#7a8290"/><rect x="6" y="1" width="4" height="3" fill="#8a9290"/>',
570
+ relic: '<rect x="5" y="1" width="6" height="1" fill="#c8a050"/><rect x="3" y="2" width="2" height="2" fill="#c8a050"/><rect x="11" y="2" width="2" height="2" fill="#c8a050"/><rect x="4" y="4" width="8" height="10" fill="#e6db74"/><rect x="3" y="6" width="10" height="6" fill="#e6db74"/><rect x="6" y="6" width="4" height="6" fill="#ae81ff"/><rect x="5" y="7" width="6" height="4" fill="#ae81ff"/><rect x="7" y="7" width="2" height="4" fill="#c8a8ff"/><rect x="4" y="4" width="2" height="2" fill="#c8a050"/><rect x="10" y="4" width="2" height="2" fill="#c8a050"/><rect x="4" y="12" width="2" height="2" fill="#c8a050"/><rect x="10" y="12" width="2" height="2" fill="#c8a050"/>',
571
+ helm: '<rect x="5" y="1" width="6" height="1" fill="#a8b0bc"/><rect x="4" y="2" width="8" height="2" fill="#a8b0bc"/><rect x="3" y="4" width="10" height="4" fill="#a8b0bc"/><rect x="3" y="4" width="10" height="1" fill="#cfd8e2"/><rect x="3" y="5" width="1" height="3" fill="#cfd8e2"/><rect x="4" y="6" width="8" height="2" fill="#1a2a38"/><rect x="2" y="8" width="12" height="4" fill="#a8b0bc"/><rect x="2" y="8" width="12" height="1" fill="#7a8290"/><rect x="5" y="10" width="6" height="1" fill="#7a8290"/>',
572
+ cloak: '<rect x="6" y="1" width="4" height="2" fill="#7a6aaa"/><rect x="5" y="3" width="6" height="2" fill="#7a6aaa"/><rect x="3" y="5" width="10" height="8" fill="#5a4a8a"/><rect x="2" y="7" width="12" height="5" fill="#5a4a8a"/><rect x="3" y="5" width="2" height="8" fill="#7a6aaa"/><rect x="11" y="5" width="2" height="8" fill="#7a6aaa"/><rect x="4" y="13" width="2" height="2" fill="#5a4a8a"/><rect x="10" y="13" width="2" height="2" fill="#5a4a8a"/><rect x="7" y="2" width="2" height="1" fill="#c8a8ff"/>',
573
+ greaves: '<rect x="3" y="1" width="4" height="7" fill="#a8b0bc"/><rect x="9" y="1" width="4" height="7" fill="#a8b0bc"/><rect x="3" y="1" width="4" height="1" fill="#cfd8e2"/><rect x="9" y="1" width="4" height="1" fill="#cfd8e2"/><rect x="3" y="3" width="1" height="4" fill="#cfd8e2"/><rect x="9" y="3" width="1" height="4" fill="#cfd8e2"/><rect x="2" y="8" width="5" height="4" fill="#a8b0bc"/><rect x="9" y="8" width="5" height="4" fill="#a8b0bc"/><rect x="1" y="12" width="7" height="3" fill="#a8b0bc"/><rect x="8" y="12" width="7" height="3" fill="#a8b0bc"/><rect x="1" y="12" width="7" height="1" fill="#cfd8e2"/><rect x="8" y="12" width="7" height="1" fill="#cfd8e2"/>',
574
+ ring: '<rect x="6" y="2" width="4" height="1" fill="#e6db74"/><rect x="4" y="3" width="2" height="2" fill="#e6db74"/><rect x="10" y="3" width="2" height="2" fill="#e6db74"/><rect x="3" y="5" width="2" height="4" fill="#e6db74"/><rect x="11" y="5" width="2" height="4" fill="#e6db74"/><rect x="4" y="9" width="2" height="2" fill="#e6db74"/><rect x="10" y="9" width="2" height="2" fill="#e6db74"/><rect x="6" y="11" width="4" height="1" fill="#e6db74"/><rect x="6" y="4" width="4" height="5" fill="#ae81ff"/><rect x="7" y="5" width="2" height="3" fill="#c8a8ff"/>',
575
+ hat: '<rect x="1" y="9" width="14" height="2" fill="#8a6540"/><rect x="2" y="8" width="12" height="1" fill="#8a6540"/><rect x="4" y="4" width="8" height="5" fill="#8a6540"/><rect x="5" y="3" width="6" height="1" fill="#8a6540"/><rect x="6" y="2" width="4" height="1" fill="#8a6540"/><rect x="1" y="9" width="14" height="1" fill="#c8a050"/><rect x="4" y="8" width="8" height="1" fill="#c8a050"/><rect x="6" y="5" width="4" height="2" fill="#c8a878"/>',
576
+ emblem: '<rect x="4" y="1" width="8" height="1" fill="#e6db74"/><rect x="3" y="2" width="10" height="7" fill="#e6db74"/><rect x="4" y="9" width="8" height="3" fill="#e6db74"/><rect x="5" y="12" width="6" height="2" fill="#e6db74"/><rect x="7" y="14" width="2" height="1" fill="#e6db74"/><rect x="5" y="3" width="6" height="6" fill="#ae81ff"/><rect x="4" y="4" width="8" height="4" fill="#ae81ff"/><rect x="7" y="4" width="2" height="5" fill="#c8a8ff"/><rect x="5" y="6" width="6" height="2" fill="#c8a8ff"/>',
577
+ cape: '<rect x="6" y="1" width="4" height="1" fill="#cc3030"/><rect x="5" y="2" width="6" height="2" fill="#cc3030"/><rect x="4" y="4" width="8" height="5" fill="#cc3030"/><rect x="3" y="6" width="10" height="4" fill="#cc3030"/><rect x="3" y="10" width="4" height="5" fill="#cc3030"/><rect x="9" y="10" width="4" height="5" fill="#cc3030"/><rect x="5" y="5" width="2" height="8" fill="#e84040"/><rect x="4" y="4" width="1" height="6" fill="#e84040"/>',
578
+ boots: '<rect x="4" y="1" width="3" height="9" fill="#8a6540"/><rect x="9" y="1" width="3" height="9" fill="#8a6540"/><rect x="4" y="1" width="3" height="1" fill="#c8a878"/><rect x="9" y="1" width="3" height="1" fill="#c8a878"/><rect x="3" y="10" width="4" height="4" fill="#8a6540"/><rect x="9" y="10" width="4" height="4" fill="#8a6540"/><rect x="2" y="13" width="6" height="2" fill="#8a6540"/><rect x="9" y="13" width="6" height="2" fill="#8a6540"/><rect x="2" y="13" width="6" height="1" fill="#c8a050"/><rect x="9" y="13" width="6" height="1" fill="#c8a050"/>',
579
+ badge: '<rect x="7" y="1" width="2" height="2" fill="#e6db74"/><rect x="7" y="13" width="2" height="2" fill="#e6db74"/><rect x="1" y="7" width="2" height="2" fill="#e6db74"/><rect x="13" y="7" width="2" height="2" fill="#e6db74"/><rect x="4" y="3" width="2" height="2" fill="#e6db74"/><rect x="10" y="3" width="2" height="2" fill="#e6db74"/><rect x="4" y="11" width="2" height="2" fill="#e6db74"/><rect x="10" y="11" width="2" height="2" fill="#e6db74"/><rect x="5" y="5" width="6" height="6" fill="#c8a050"/><rect x="6" y="6" width="4" height="4" fill="#5dcaa5"/><rect x="7" y="7" width="2" height="2" fill="#90e8cc"/>',
580
+ 'attack-sword': '<line x1="14" y1="2" x2="6" y2="10" stroke="#c0c8d0" stroke-width="2" stroke-linecap="square"/><line x1="13" y1="2" x2="14" y2="3" stroke="#e8ecf0" stroke-width="1" stroke-linecap="square"/><line x1="4" y1="9" x2="8" y2="13" stroke="#8a9098" stroke-width="2" stroke-linecap="square"/><line x1="6" y1="11" x2="3" y2="14" stroke="#5a6268" stroke-width="2" stroke-linecap="square"/>',
581
+ 'attack-staff': '<rect x="5" y="1" width="6" height="1" fill="#9aa2aa"/><rect x="4" y="2" width="8" height="4" fill="#8a9098"/><rect x="5" y="6" width="6" height="1" fill="#9aa2aa"/><rect x="6" y="3" width="4" height="2" fill="#b0b8c0"/><rect x="7" y="7" width="2" height="8" fill="#6a7278"/><rect x="6" y="10" width="4" height="1" fill="#7a8290"/>',
582
+ 'attack-bow': '<rect x="3" y="1" width="3" height="2" fill="#6a7278"/><rect x="2" y="3" width="2" height="10" fill="#6a7278"/><rect x="3" y="13" width="3" height="2" fill="#6a7278"/><rect x="6" y="2" width="1" height="1" fill="#c0c8d0"/><rect x="7" y="3" width="1" height="10" fill="#c0c8d0"/><rect x="6" y="13" width="1" height="1" fill="#c0c8d0"/><rect x="7" y="7" width="7" height="2" fill="#9aa2aa"/><rect x="13" y="6" width="2" height="1" fill="#c0c8d0"/><rect x="14" y="7" width="2" height="2" fill="#c0c8d0"/><rect x="13" y="9" width="2" height="1" fill="#c0c8d0"/><rect x="7" y="5" width="2" height="2" fill="#8a9098"/><rect x="7" y="9" width="2" height="2" fill="#8a9098"/>',
583
+ 'attack-katar': '<rect x="2" y="1" width="2" height="8" fill="#c0c8d0"/><rect x="2" y="9" width="2" height="3" fill="#9aa2aa"/><rect x="12" y="1" width="2" height="8" fill="#c0c8d0"/><rect x="12" y="9" width="2" height="3" fill="#9aa2aa"/><rect x="2" y="8" width="12" height="2" fill="#8a9098"/><rect x="6" y="10" width="4" height="5" fill="#6a7278"/><rect x="3" y="0" width="1" height="1" fill="#e8ecf0"/><rect x="12" y="0" width="1" height="1" fill="#e8ecf0"/>',
584
+ 'attack-book': '<rect x="2" y="2" width="2" height="12" fill="#5a6268"/><rect x="4" y="2" width="10" height="12" fill="#7a8290"/><rect x="13" y="3" width="1" height="10" fill="#e8ecf0"/><rect x="7" y="4" width="2" height="1" fill="#9aa2aa"/><rect x="6" y="5" width="4" height="1" fill="#9aa2aa"/><rect x="7" y="6" width="2" height="1" fill="#9aa2aa"/><rect x="6" y="8" width="4" height="1" fill="#a0a8b0"/><rect x="7" y="9" width="2" height="2" fill="#a0a8b0"/>',
585
+
586
+ inventory: '<rect x="5" y="1" width="6" height="2" fill="#8a6540"/><rect x="4" y="3" width="8" height="11" fill="#8a6540"/><rect x="3" y="5" width="10" height="8" fill="#8a6540"/><rect x="4" y="3" width="8" height="1" fill="#c8a878"/><rect x="4" y="4" width="1" height="8" fill="#c8a878"/><rect x="6" y="8" width="4" height="3" fill="#c8a050"/><rect x="7" y="9" width="2" height="1" fill="#e6db74"/>',
587
+ settings: '<rect x="7" y="1" width="2" height="2" fill="#a8b0bc"/><rect x="7" y="13" width="2" height="2" fill="#a8b0bc"/><rect x="1" y="7" width="2" height="2" fill="#a8b0bc"/><rect x="13" y="7" width="2" height="2" fill="#a8b0bc"/><rect x="3" y="3" width="2" height="2" fill="#a8b0bc"/><rect x="11" y="3" width="2" height="2" fill="#a8b0bc"/><rect x="3" y="11" width="2" height="2" fill="#a8b0bc"/><rect x="11" y="11" width="2" height="2" fill="#a8b0bc"/><rect x="4" y="4" width="8" height="8" fill="#a8b0bc"/><rect x="3" y="5" width="10" height="6" fill="#a8b0bc"/><rect x="5" y="3" width="6" height="10" fill="#a8b0bc"/><rect x="6" y="6" width="4" height="4" fill="#1a1a20"/><rect x="7" y="7" width="2" height="2" fill="#2a2a30"/>',
588
+ shop: '<rect x="6" y="1" width="4" height="2" fill="#8a6540"/><rect x="5" y="3" width="6" height="1" fill="#8a6540"/><rect x="4" y="4" width="8" height="9" fill="#e6db74"/><rect x="3" y="6" width="10" height="6" fill="#e6db74"/><rect x="4" y="4" width="8" height="1" fill="#fac775"/><rect x="4" y="5" width="1" height="8" fill="#fac775"/><rect x="6" y="7" width="4" height="4" fill="#c8a050"/><rect x="7" y="8" width="2" height="2" fill="#fac775"/><rect x="4" y="13" width="8" height="1" fill="#c8a050"/>',
589
+ quest: '<rect x="5" y="1" width="6" height="1" fill="#c8a050"/><rect x="4" y="2" width="8" height="12" fill="#f0e8c0"/><rect x="3" y="3" width="10" height="9" fill="#f0e8c0"/><rect x="5" y="13" width="6" height="1" fill="#c8a050"/><rect x="7" y="4" width="2" height="6" fill="#cc3030"/><rect x="7" y="11" width="2" height="2" fill="#cc3030"/>',
590
+ map: '<rect x="2" y="2" width="4" height="12" fill="#f0e8c0"/><rect x="6" y="2" width="4" height="12" fill="#e8e0b8"/><rect x="10" y="2" width="4" height="12" fill="#f0e8c0"/><rect x="2" y="2" width="12" height="1" fill="#c8a050"/><rect x="2" y="13" width="12" height="1" fill="#c8a050"/><rect x="3" y="5" width="3" height="1" fill="#5dcaa5"/><rect x="6" y="6" width="1" height="2" fill="#5dcaa5"/><rect x="7" y="8" width="4" height="1" fill="#5dcaa5"/><rect x="10" y="9" width="1" height="2" fill="#5dcaa5"/><rect x="11" y="10" width="2" height="2" fill="#ff6060"/>',
591
+ party: '<rect x="3" y="1" width="3" height="3" fill="#a8b0bc"/><rect x="2" y="4" width="5" height="4" fill="#a8b0bc"/><rect x="2" y="8" width="2" height="4" fill="#a8b0bc"/><rect x="5" y="8" width="2" height="4" fill="#a8b0bc"/><rect x="10" y="1" width="3" height="3" fill="#cfd8e2"/><rect x="9" y="4" width="5" height="4" fill="#cfd8e2"/><rect x="9" y="8" width="2" height="4" fill="#cfd8e2"/><rect x="12" y="8" width="2" height="4" fill="#cfd8e2"/>',
592
+ save: '<rect x="2" y="1" width="12" height="13" fill="#3a4a5a"/><rect x="3" y="1" width="8" height="5" fill="#a8b0bc"/><rect x="2" y="1" width="12" height="1" fill="#5a6a7a"/><rect x="2" y="2" width="1" height="12" fill="#5a6a7a"/><rect x="4" y="9" width="8" height="4" fill="#4a5a6a"/><rect x="5" y="10" width="6" height="2" fill="#3a4a5a"/><rect x="9" y="2" width="2" height="3" fill="#7a8290"/>',
593
+ menu: '<rect x="2" y="4" width="12" height="2" fill="#cfd8e2"/><rect x="2" y="7" width="12" height="2" fill="#cfd8e2"/><rect x="2" y="10" width="12" height="2" fill="#cfd8e2"/><rect x="2" y="4" width="12" height="1" fill="#eef0f4"/><rect x="2" y="7" width="12" height="1" fill="#eef0f4"/><rect x="2" y="10" width="12" height="1" fill="#eef0f4"/>',
594
+ 'attack-hammer': '<rect x="4" y="1" width="8" height="1" fill="#9aa2aa"/><rect x="3" y="2" width="10" height="5" fill="#9aa2aa"/><rect x="3" y="2" width="10" height="1" fill="#c0c8d0"/><rect x="3" y="3" width="1" height="3" fill="#c0c8d0"/><rect x="3" y="7" width="10" height="1" fill="#5a6268"/><rect x="7" y="7" width="2" height="8" fill="#6a7278"/><rect x="6" y="10" width="4" height="1" fill="#7a8290"/>',
595
+ };
596
+ var ICON_GROUPS = {
597
+ weapons: ['sword', 'bow', 'staff', 'katar', 'book', 'hammer'],
598
+ attack: ['attack-sword', 'attack-bow', 'attack-staff', 'attack-katar', 'attack-book', 'attack-hammer'],
599
+ techs: ['passive', 'active', 'combo', 'stance', 'buff', 'debuff'],
600
+ elements: ['neutral', 'fire', 'earth', 'wind', 'water', 'light', 'dark', 'void'],
601
+ races: ['human', 'beast', 'demon', 'angel', 'spirit'],
602
+ sizes: ['small', 'medium', 'large'],
603
+ items: ['potion', 'ether', 'scroll', 'gem', 'relic', 'key'],
604
+ equip: ['helm', 'armor', 'cloak', 'shield', 'ring', 'greaves'],
605
+ skins: ['hat', 'emblem', 'cape', 'badge', 'boots'],
606
+ menu: ['menu', 'inventory', 'party', 'quest', 'map', 'shop', 'settings', 'save'],
607
+ };
608
+
609
+ /* ── Emotes ─────────────────────────────────────────────────────────
610
+ Original pixel emotes — one warm round face + a signature expression
611
+ each, with our own chat-command codes (no third-party command sets).
612
+ Each face is drawn to read clearly for its meaning.
613
+ ─────────────────────────────────────────────────────────────────── */
614
+ var EMOTE_FACE =
615
+ '<rect x="5" y="1" width="6" height="1" fill="#f6d860"/>' +
616
+ '<rect x="3" y="2" width="10" height="1" fill="#f6d860"/>' +
617
+ '<rect x="2" y="3" width="12" height="8" fill="#f6d860"/>' +
618
+ '<rect x="3" y="11" width="10" height="1" fill="#e3c24f"/>' +
619
+ '<rect x="4" y="12" width="8" height="1" fill="#e3c24f"/>' +
620
+ '<rect x="5" y="13" width="6" height="1" fill="#c9aa3e"/>';
621
+ var INK = '#3a2e1a';
622
+ var EMOTES = {
623
+ /* warm — grateful closed-eye smile + blush */
624
+ '/tysm': EMOTE_FACE +
625
+ '<rect x="4" y="6" width="1" height="1" fill="'+INK+'"/><rect x="5" y="5" width="2" height="1" fill="'+INK+'"/><rect x="7" y="6" width="1" height="1" fill="'+INK+'"/>' +
626
+ '<rect x="8" y="6" width="1" height="1" fill="'+INK+'"/><rect x="9" y="5" width="2" height="1" fill="'+INK+'"/><rect x="11" y="6" width="1" height="1" fill="'+INK+'"/>' +
627
+ '<rect x="6" y="9" width="4" height="1" fill="'+INK+'"/><rect x="5" y="8" width="1" height="1" fill="'+INK+'"/><rect x="10" y="8" width="1" height="1" fill="'+INK+'"/>' +
628
+ '<rect x="3" y="8" width="1" height="1" fill="#f0a070"/><rect x="12" y="8" width="1" height="1" fill="#f0a070"/>',
629
+ /* distress — worried brows, calling mouth, panic sweat */
630
+ '/sos': EMOTE_FACE +
631
+ '<rect x="6" y="3" width="1" height="1" fill="'+INK+'"/><rect x="5" y="4" width="1" height="1" fill="'+INK+'"/><rect x="4" y="5" width="1" height="1" fill="'+INK+'"/>' +
632
+ '<rect x="9" y="3" width="1" height="1" fill="'+INK+'"/><rect x="10" y="4" width="1" height="1" fill="'+INK+'"/><rect x="11" y="5" width="1" height="1" fill="'+INK+'"/>' +
633
+ '<rect x="5" y="6" width="1" height="2" fill="'+INK+'"/><rect x="10" y="6" width="1" height="2" fill="'+INK+'"/>' +
634
+ '<rect x="6" y="10" width="4" height="2" fill="'+INK+'"/><rect x="7" y="11" width="2" height="1" fill="#c0432f"/>' +
635
+ '<rect x="12" y="2" width="1" height="1" fill="#5aa9e6"/><rect x="11" y="3" width="3" height="2" fill="#5aa9e6"/><rect x="12" y="5" width="1" height="1" fill="#5aa9e6"/><rect x="12" y="3" width="1" height="1" fill="#add6f5"/>',
636
+ /* joy — eyes scrunched shut, big open laugh, happy tears */
637
+ '/lol': EMOTE_FACE +
638
+ '<rect x="4" y="6" width="1" height="1" fill="'+INK+'"/><rect x="5" y="5" width="2" height="1" fill="'+INK+'"/><rect x="7" y="6" width="1" height="1" fill="'+INK+'"/>' +
639
+ '<rect x="8" y="6" width="1" height="1" fill="'+INK+'"/><rect x="9" y="5" width="2" height="1" fill="'+INK+'"/><rect x="11" y="6" width="1" height="1" fill="'+INK+'"/>' +
640
+ '<rect x="5" y="9" width="6" height="3" fill="'+INK+'"/><rect x="6" y="9" width="4" height="1" fill="#ffffff"/><rect x="6" y="11" width="4" height="1" fill="#c0432f"/>' +
641
+ '<rect x="3" y="7" width="1" height="2" fill="#5aa9e6"/><rect x="12" y="7" width="1" height="2" fill="#5aa9e6"/>',
642
+ /* triumph — confident wink, toothy grin, sparkle */
643
+ '/win': EMOTE_FACE +
644
+ '<rect x="5" y="6" width="2" height="2" fill="'+INK+'"/>' +
645
+ '<rect x="9" y="6" width="3" height="1" fill="'+INK+'"/>' +
646
+ '<rect x="5" y="9" width="6" height="2" fill="'+INK+'"/><rect x="6" y="9" width="4" height="1" fill="#ffffff"/><rect x="4" y="8" width="1" height="1" fill="'+INK+'"/><rect x="11" y="8" width="1" height="1" fill="'+INK+'"/>' +
647
+ '<rect x="12" y="2" width="1" height="3" fill="#f6d860"/><rect x="11" y="3" width="3" height="1" fill="#f6d860"/><rect x="12" y="3" width="1" height="1" fill="#fff8e0"/>',
648
+ /* hype — star-struck eyes, big grin, hype mark */
649
+ '/lgo': EMOTE_FACE +
650
+ '<rect x="5" y="4" width="1" height="3" fill="'+INK+'"/><rect x="4" y="5" width="3" height="1" fill="'+INK+'"/>' +
651
+ '<rect x="10" y="4" width="1" height="3" fill="'+INK+'"/><rect x="9" y="5" width="3" height="1" fill="'+INK+'"/>' +
652
+ '<rect x="5" y="9" width="6" height="3" fill="'+INK+'"/><rect x="6" y="9" width="4" height="1" fill="#ffffff"/><rect x="6" y="11" width="4" height="1" fill="#c0432f"/>' +
653
+ '<rect x="4" y="8" width="1" height="1" fill="'+INK+'"/><rect x="11" y="8" width="1" height="1" fill="'+INK+'"/>' +
654
+ '<rect x="13" y="1" width="1" height="3" fill="#f6d860"/><rect x="13" y="5" width="1" height="1" fill="#f6d860"/>',
655
+ /* dismay — flat unamused eyes, frown, sweat */
656
+ '/ugh': EMOTE_FACE +
657
+ '<rect x="4" y="6" width="3" height="1" fill="'+INK+'"/><rect x="9" y="6" width="3" height="1" fill="'+INK+'"/>' +
658
+ '<rect x="6" y="11" width="4" height="1" fill="'+INK+'"/><rect x="5" y="10" width="1" height="1" fill="'+INK+'"/><rect x="10" y="10" width="1" height="1" fill="'+INK+'"/>' +
659
+ '<rect x="12" y="2" width="1" height="1" fill="#5aa9e6"/><rect x="11" y="3" width="3" height="2" fill="#5aa9e6"/><rect x="12" y="5" width="1" height="1" fill="#5aa9e6"/><rect x="12" y="3" width="1" height="1" fill="#add6f5"/>',
660
+ /* sheepish — apologetic brows, nervous wavy mouth, sweat */
661
+ '/myb': EMOTE_FACE +
662
+ '<rect x="4" y="5" width="2" height="1" fill="'+INK+'"/><rect x="10" y="5" width="2" height="1" fill="'+INK+'"/>' +
663
+ '<rect x="5" y="6" width="1" height="1" fill="'+INK+'"/><rect x="10" y="6" width="1" height="1" fill="'+INK+'"/>' +
664
+ '<rect x="6" y="10" width="1" height="1" fill="'+INK+'"/><rect x="7" y="11" width="1" height="1" fill="'+INK+'"/><rect x="8" y="10" width="1" height="1" fill="'+INK+'"/><rect x="9" y="11" width="1" height="1" fill="'+INK+'"/>' +
665
+ '<rect x="4" y="2" width="1" height="1" fill="#5aa9e6"/><rect x="3" y="3" width="3" height="1" fill="#5aa9e6"/><rect x="4" y="4" width="1" height="1" fill="#5aa9e6"/><rect x="4" y="3" width="1" height="1" fill="#add6f5"/>',
666
+ /* shock — wide eyes, dropped jaw, floating question mark */
667
+ '/wut': EMOTE_FACE +
668
+ '<rect x="4" y="3" width="2" height="1" fill="'+INK+'"/><rect x="9" y="3" width="2" height="1" fill="'+INK+'"/>' +
669
+ '<rect x="4" y="5" width="3" height="3" fill="#ffffff"/><rect x="9" y="5" width="3" height="3" fill="#ffffff"/>' +
670
+ '<rect x="5" y="6" width="1" height="1" fill="'+INK+'"/><rect x="10" y="6" width="1" height="1" fill="'+INK+'"/>' +
671
+ '<rect x="6" y="9" width="4" height="3" fill="'+INK+'"/><rect x="7" y="10" width="2" height="1" fill="#f6d860"/>' +
672
+ '<rect x="11" y="1" width="2" height="1" fill="'+INK+'"/><rect x="13" y="2" width="1" height="1" fill="'+INK+'"/><rect x="12" y="3" width="1" height="1" fill="'+INK+'"/><rect x="12" y="5" width="1" height="1" fill="'+INK+'"/>',
673
+ /* rage — angry brows, grit, anger mark */
674
+ '/grr': EMOTE_FACE +
675
+ '<rect x="4" y="5" width="1" height="1" fill="'+INK+'"/><rect x="5" y="6" width="2" height="1" fill="'+INK+'"/>' +
676
+ '<rect x="9" y="6" width="2" height="1" fill="'+INK+'"/><rect x="11" y="5" width="1" height="1" fill="'+INK+'"/>' +
677
+ '<rect x="5" y="7" width="2" height="1" fill="'+INK+'"/><rect x="9" y="7" width="2" height="1" fill="'+INK+'"/>' +
678
+ '<rect x="6" y="10" width="4" height="1" fill="'+INK+'"/><rect x="5" y="11" width="1" height="1" fill="'+INK+'"/><rect x="10" y="11" width="1" height="1" fill="'+INK+'"/>' +
679
+ '<rect x="12" y="1" width="1" height="3" fill="#e23b3b"/><rect x="11" y="2" width="3" height="1" fill="#e23b3b"/>' +
680
+ '<rect x="3" y="8" width="1" height="1" fill="#e87a5a"/><rect x="12" y="8" width="1" height="1" fill="#e87a5a"/>',
681
+ };
682
+ Object.keys(EMOTES).forEach(function(k){ ICON_SVGS[k] = EMOTES[k]; });
683
+ ICON_GROUPS.emotes = ['/tysm', '/sos', '/lol', '/win', '/lgo', '/ugh', '/myb', '/wut', '/grr'];
684
+ var EMOTE_TIPS = {
685
+ '/tysm': '/tysm \u2014 Thank you so much!', '/sos': '/sos \u2014 Save our souls (help)', '/lol': '/lol \u2014 Laugh out loud',
686
+ '/win': '/win \u2014 Winner!', '/lgo': '/lgo \u2014 Let\u2019s go!', '/ugh': '/ugh \u2014 Disappointed',
687
+ '/myb': '/myb \u2014 My bad', '/wut': '/wut \u2014 What!?', '/grr': '/grr \u2014 Mad'
688
+ };
689
+ var GROUP_TIPS = {
690
+ ui: 'HUD widgets — bars, panels, minimap…', weapons: 'Weapon icons', attack: 'Weapon attack icons',
691
+ techs: 'Skill & ability icons', elements: 'Elemental affinity icons', races: 'Character race icons',
692
+ sizes: 'Creature size icons', items: 'Consumables & valuables', equip: 'Equipment slots',
693
+ skins: 'Cosmetic apparel', menu: 'Menu & system icons', emotes: 'Chat emotes'
694
+ };
695
+ /* Plural display labels for tab names that aren't already plural */
696
+ var GROUP_LABELS = { attack: 'ATTACKS', equip: 'GEAR', skins: 'COSMETICS', menu: 'MENUS' };
697
+
698
+ /* ── Icon auto-centering ─────────────────────────────────────────────
699
+ Every glyph is hand-drawn pixel art inside a 16×16 box but not always
700
+ centred (e.g. the SIZES bars sit on a bottom baseline). Compute each
701
+ glyph's content bbox from its source and translate it to the centre of
702
+ the box, so icons read consistently both axes. Done once, from source. */
703
+ function iconBBox(svg) {
704
+ var minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
705
+ function num(tag, attr) { var m = new RegExp('\\b' + attr + '="(-?[\\d.]+)"').exec(tag); return m ? parseFloat(m[1]) : NaN; }
706
+ (svg.match(/<rect\b[^>]*>/g) || []).forEach(function(t) {
707
+ var x = num(t,'x'), y = num(t,'y'), w = num(t,'width'), h = num(t,'height');
708
+ if (!isNaN(x) && !isNaN(y)) { minX=Math.min(minX,x); minY=Math.min(minY,y); maxX=Math.max(maxX,x+(w||0)); maxY=Math.max(maxY,y+(h||0)); }
709
+ });
710
+ (svg.match(/<line\b[^>]*>/g) || []).forEach(function(t) {
711
+ ['1','2'].forEach(function(n){ var x=num(t,'x'+n), y=num(t,'y'+n); if(!isNaN(x)&&!isNaN(y)){ minX=Math.min(minX,x); minY=Math.min(minY,y); maxX=Math.max(maxX,x); maxY=Math.max(maxY,y); } });
712
+ });
713
+ (svg.match(/<circle\b[^>]*>/g) || []).forEach(function(t) {
714
+ var cx=num(t,'cx'), cy=num(t,'cy'), r=num(t,'r')||0; if(!isNaN(cx)&&!isNaN(cy)){ minX=Math.min(minX,cx-r); minY=Math.min(minY,cy-r); maxX=Math.max(maxX,cx+r); maxY=Math.max(maxY,cy+r); }
715
+ });
716
+ if (minX === Infinity) return null;
717
+ return { x: minX, y: minY, w: maxX - minX, h: maxY - minY };
718
+ }
719
+ var ICON_BBOX = {};
720
+ Object.keys(ICON_SVGS).forEach(function(k){ ICON_BBOX[k] = iconBBox(ICON_SVGS[k]); });
721
+ /* Returns the glyph markup wrapped in a centring transform (or raw if already centred). */
722
+ function tok24(inner){ return '<g transform="scale(1.5)">' + inner + '</g>'; }
723
+ /* Full glyph markup for an icon/emote. Regular icons upscale 1.5× to fill the
724
+ cell; emotes are already full 16×16 faces, so they render at native size
725
+ centred in the 24-box (no upscale) — otherwise they bleed past the cell. */
726
+ function glyphMarkup(name){
727
+ return '<g transform="scale(1.5)">' + iconInner(name) + '</g>';
728
+ }
729
+ function iconInner(name) {
730
+ var svg = ICON_SVGS[name]; if (!svg) return '';
731
+ var bb = ICON_BBOX[name]; if (!bb) return svg;
732
+ var dx = 8 - (bb.x + bb.w / 2), dy = 8 - (bb.y + bb.h / 2);
733
+ if (Math.abs(dx) < 0.25 && Math.abs(dy) < 0.25) return svg;
734
+ return '<g transform="translate(' + dx.toFixed(2) + ',' + dy.toFixed(2) + ')">' + svg + '</g>';
735
+ }
736
+
737
+ var EMOJI_LIST = ['🙂'];;
738
+ var EMOJI_GROUPS = { emojis: EMOJI_LIST };
739
+
740
+ /* Weapons (and their attack variants) read best held diagonally — blade toward
741
+ the top-right. The sword art is already a top-right diagonal (0°). The scale
742
+ keeps the rotated square from clipping its box. Returns a CSS transform or ''. */
743
+ var WEAPON_TILT = {
744
+ staff: 45, katar: 45, hammer: 45, book: 45, bow: -45, sword: 0,
745
+ 'attack-staff': 45, 'attack-katar': 45, 'attack-hammer': 45,
746
+ 'attack-book': 45, 'attack-bow': -45, 'attack-sword': 0,
747
+ };
748
+ function iconTransform(name) {
749
+ var d = WEAPON_TILT[name];
750
+ return d ? ('rotate(' + d + 'deg) scale(0.82)') : '';
751
+ }
752
+ function applyIconTilt(svgEl, name) {
753
+ var t = iconTransform(name);
754
+ if (t) { svgEl.style.transform = t; svgEl.style.transformOrigin = 'center'; svgEl.style.overflow = 'visible'; }
755
+ }
756
+
757
+ /* ══════════════════════════════════════════
758
+ HUD COMPONENT LIBRARY
759
+ ══════════════════════════════════════════ */
760
+ var COMP_SVGS = {
761
+ bar: '<rect x="1" y="6" width="14" height="4" fill="rgba(255,255,255,0.08)"/><rect x="1" y="6" width="8" height="4" fill="#4caf73"/><rect x="1" y="6" width="8" height="1" fill="#5dcf83"/>',
762
+ barstam: '<rect x="1" y="6" width="14" height="4" fill="rgba(255,255,255,0.08)"/><rect x="1" y="6" width="11" height="4" fill="#5dcaa5"/><rect x="4" y="6" width="1" height="4" fill="rgba(0,0,0,0.5)"/><rect x="7" y="6" width="1" height="4" fill="rgba(0,0,0,0.5)"/><rect x="10" y="6" width="1" height="4" fill="rgba(0,0,0,0.5)"/>',
763
+ chat: '<rect x="2" y="2" width="12" height="9" fill="#0c1018"/><rect x="2" y="2" width="12" height="1" fill="#3a3a5a"/><rect x="4" y="11" width="3" height="2" fill="#0c1018"/><rect x="3" y="4" width="5" height="1" fill="#5dcaa5"/><rect x="3" y="6" width="8" height="1" fill="#8888ee"/><rect x="3" y="8" width="6" height="1" fill="rgba(255,255,255,0.5)"/>',
764
+ label: '<rect x="2" y="7" width="5" height="1" fill="rgba(255,255,255,0.5)"/><rect x="8" y="7" width="4" height="1" fill="rgba(255,255,255,0.3)"/>',
765
+ hdiv: '<rect x="1" y="7" width="14" height="2" fill="#7a7a8a"/><rect x="1" y="7" width="14" height="1" fill="#b0b0c0"/><rect x="1" y="6" width="2" height="4" fill="#5a5a6a"/><rect x="13" y="6" width="2" height="4" fill="#5a5a6a"/>',
766
+ vdiv: '<rect x="7" y="1" width="2" height="14" fill="#7a7a8a"/><rect x="7" y="1" width="1" height="14" fill="#b0b0c0"/><rect x="6" y="1" width="4" height="2" fill="#5a5a6a"/><rect x="6" y="13" width="4" height="2" fill="#5a5a6a"/>',
767
+ joy: '<rect x="5" y="1" width="6" height="2" fill="#5a5a7a"/><rect x="9" y="3" width="4" height="4" fill="#5a5a7a"/><rect x="1" y="3" width="4" height="4" fill="#5a5a7a"/><rect x="3" y="5" width="10" height="6" fill="#1a1a2a"/><rect x="5" y="3" width="6" height="10" fill="#1a1a2a"/><rect x="5" y="9" width="2" height="4" fill="#5a5a7a"/><rect x="9" y="9" width="2" height="4" fill="#5a5a7a"/><rect x="6" y="6" width="4" height="4" fill="#3a3a5a"/><rect x="7" y="7" width="2" height="2" fill="#9090b0"/>',
768
+ tabwin: '<rect x="1" y="4" width="14" height="11" fill="#0c1018"/><rect x="1" y="4" width="14" height="1" fill="#3a3a5a"/><rect x="1" y="1" width="4" height="4" fill="#2a2a44"/><rect x="5" y="1" width="4" height="4" fill="#181826"/><rect x="9" y="1" width="6" height="4" fill="#181826"/><rect x="2" y="6" width="10" height="1" fill="#3a3a5a"/><rect x="2" y="8" width="7" height="1" fill="#3a3a5a"/><rect x="2" y="10" width="9" height="1" fill="#3a3a5a"/><rect x="2" y="12" width="5" height="1" fill="#3a3a5a"/>',
769
+ bgd: '<rect x="1" y="1" width="14" height="14" fill="#0c1016" fill-opacity="0.75"/><rect x="1" y="1" width="14" height="14" fill="none" stroke="white" stroke-opacity="0.14" stroke-width="0.75"/>',
770
+ bgc: '<rect x="1" y="1" width="14" height="14" fill="none" stroke="white" stroke-opacity="0.24" stroke-width="0.75" stroke-dasharray="2 1"/>',
771
+ minimap: '<rect width="16" height="16" fill="#2a3818"/><polygon points="8,2 14,6 8,10 2,6" fill="#3a4a18" stroke="#c8a830" stroke-width="0.7"/><line x1="5" y1="4" x2="11" y2="8" stroke="#c8a830" stroke-width="0.3" opacity="0.5"/><line x1="8" y1="2" x2="8" y2="10" stroke="#c8a830" stroke-width="0.3" opacity="0.5"/><line x1="2" y1="6" x2="14" y2="6" stroke="#c8a830" stroke-width="0.3" opacity="0.5"/><rect x="3.5" y="4.5" width="2" height="2" fill="#ffffff"/><rect x="9" y="7" width="2" height="2" fill="#e6c840"/><rect x="11" y="5.5" width="1.5" height="1.5" fill="#e6c840"/><rect x="10" y="8.5" width="1.5" height="1.5" fill="#cc5533"/>',
772
+ avatar: '<image href="../assets/hero-avatar.svg" x="0" y="0" width="16" height="16" preserveAspectRatio="xMidYMid slice"/>',
773
+ };
774
+ var COMP_LABELS = { hdiv:'H-DIVIDER', vdiv:'V-DIVIDER', joy:'JOYSTICK', tabwin:'TABS', chat:'CHAT', bar:'BAR', barstam:'SEGMENTED', label:'LABEL', bgd:'PANEL', bgc:'OUTLINE', minimap:'MINIMAP', avatar:'AVATAR' };
775
+ var COMP_GROUPS = { ui: ['avatar', 'bar', 'barstam', 'label', 'tabwin', 'chat', 'joy', 'minimap', 'hdiv', 'vdiv', 'bgd', 'bgc'] };
776
+ /* Retired duplicates that were only a colour apart — map old saved scenes onto the survivors */
777
+ var COMP_ALIASES = { barmp:'bar', barxp:'bar', bgl:'bgd' };
778
+ var COMP_DEFAULT_POS = {
779
+ avatar: {c1:1,r1:1,c2:4,r2:4},
780
+ bar: {c1:1,r1:1,c2:9,r2:2},
781
+ barstam:{c1:1,r1:1,c2:9,r2:2},
782
+ chat: {c1:1,r1:1,c2:9,r2:6},
783
+ label: {c1:1,r1:1,c2:7,r2:2},
784
+ hdiv: {c1:1,r1:1,c2:9,r2:2},
785
+ vdiv: {c1:1,r1:1,c2:2,r2:9},
786
+ joy: {c1:1,r1:1,c2:5,r2:9},
787
+ tabwin: {c1:1,r1:9,c2:9,r2:17},
788
+ bgd: {c1:1,r1:1,c2:9,r2:9},
789
+ bgc: {c1:1,r1:1,c2:9,r2:9},
790
+ minimap: {c1:1,r1:1,c2:12,r2:10},
791
+ };
792
+ var BAR_KINDS = {
793
+ hp: { fill: 'var(--hud-hp-fill,#4caf73)', pct: 65, label: '26 / 40', seg: false },
794
+ stam: { fill: 'var(--hud-stamina-fill,#5dcaa5)', pct: 80, label: 'STAMINA', seg: true }
795
+ };
796
+ function buildBar(el, kind) {
797
+ var k = BAR_KINDS[kind] || BAR_KINDS.hp;
798
+ el.style.cssText += 'background:rgba(255,255,255,0.08);overflow:hidden;position:relative;';
799
+ el.setAttribute('data-bar-kind', kind);
800
+ var fill = document.createElement('div');
801
+ fill.className = 'bar-fill';
802
+ /* a previously-chosen colour (via the colour control) overrides the kind default */
803
+ var savedColor; try { savedColor = JSON.parse(localStorage.getItem('ns-hud-colors') || '{}')[el.id]; } catch(e) {}
804
+ fill.style.cssText = 'position:absolute;inset:0;width:' + k.pct + '%;background:' + (savedColor || k.fill) + ';';
805
+ el.appendChild(fill);
806
+ if (k.seg) {
807
+ var seg = document.createElement('div');
808
+ seg.style.cssText = 'position:absolute;inset:0;pointer-events:none;background:repeating-linear-gradient(90deg,transparent 0,transparent 7px,rgba(0,0,0,0.5) 7px,rgba(0,0,0,0.5) 8px);';
809
+ el.appendChild(seg);
810
+ }
811
+ var lbl = document.createElement('span');
812
+ lbl.className = 'et';
813
+ lbl.id = 'bet-' + el.id;
814
+ lbl.setAttribute('contenteditable', 'false');
815
+ lbl.style.cssText = 'position:absolute;inset:0;display:flex;align-items:center;padding-left:3px;font-size:4px;color:var(--ns-ink);white-space:nowrap;z-index:1;';
816
+ var savedTxt; try { savedTxt = JSON.parse(localStorage.getItem('ns-hud-texts') || '{}')[lbl.id]; } catch(e) {}
817
+ lbl.textContent = (savedTxt !== undefined) ? savedTxt : k.label;
818
+ /* editable + removable label — persists independently of element type */
819
+ lbl.addEventListener('blur', function() {
820
+ lbl.setAttribute('contenteditable', 'false');
821
+ try { var t = JSON.parse(localStorage.getItem('ns-hud-texts') || '{}'); t[lbl.id] = lbl.textContent; localStorage.setItem('ns-hud-texts', JSON.stringify(t)); } catch(e) {}
822
+ });
823
+ lbl.addEventListener('keydown', function(e) { if (e.key === 'Enter' || e.key === 'Escape') { e.preventDefault(); lbl.blur(); } });
824
+ el.appendChild(lbl);
825
+ }
826
+ var COMP_BUILD = {
827
+ avatar: function(el) {
828
+ el.style.cssText += 'overflow:hidden;background:var(--hud-avatar-bg,#2a2233);border:1.5px solid var(--ns-line-strong,rgba(255,255,255,0.5));';
829
+ var img = document.createElement('img');
830
+ img.src = '../assets/hero-avatar.svg';
831
+ img.alt = 'Rainbow Star';
832
+ img.style.cssText = 'width:100%;height:100%;display:block;object-fit:cover;object-position:center 20%;image-rendering:pixelated;pointer-events:none;';
833
+ el.appendChild(img);
834
+ },
835
+ bar: function(el) { buildBar(el, 'hp'); },
836
+ barstam: function(el) { buildBar(el, 'stam'); },
837
+ chat: function(el) {
838
+ el.style.cssText += 'overflow:hidden;display:flex;flex-direction:column;align-items:stretch;justify-content:flex-start;';
839
+ /* No baked-in background: each row is shadowed and reads on its own. The bg is
840
+ user-selectable via the B (object colour) control — including "None" — and any
841
+ choice is restored from storage on reload. */
842
+ el.setAttribute('data-tintable-bg', '');
843
+ var savedBg; try { savedBg = JSON.parse(localStorage.getItem('ns-hud-colors') || '{}')[el.id]; } catch(e) {}
844
+ if (savedBg != null && savedBg !== '') el.style.background = savedBg;
845
+ /* One line per chat scenario; whole line takes the channel colour, sized &
846
+ left-aligned like ordinary grid text, vertically centred in each cell.
847
+ Some lines carry an inline emote so the widget shows them in context. */
848
+ var lines = [
849
+ { txt:'(To Aria): nice combo!', color:'#e6db74', emote:'/win' }, /* private — sent */
850
+ { txt:'(From Zeph): ty! push mid', color:'#e6db74', emote:'/tysm' }, /* private — received */
851
+ { txt:'[P] Kira: incoming top', color:'#fac775', emote:'/sos' }, /* party */
852
+ { txt:'[G] Bram: raid at 8?', color:'#5dcaa5', emote:'/lgo' }, /* guild */
853
+ { txt:'Lio: gg wp', color:'rgba(255,255,255,0.92)' }, /* public */
854
+ ];
855
+ lines.forEach(function(m){
856
+ var row = document.createElement('div');
857
+ row.style.cssText = 'flex:1 1 0;min-height:var(--hud-row);display:flex;align-items:center;gap:2px;padding:0 3px;font-size:5px;line-height:1;white-space:nowrap;overflow:hidden;color:' + m.color + ';text-shadow:0 0 2px rgba(0,0,0,0.85);';
858
+ var txt = document.createElement('span');
859
+ txt.textContent = m.txt;
860
+ txt.style.cssText = 'flex:0 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;';
861
+ row.appendChild(txt);
862
+ if (m.emote) {
863
+ var svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
864
+ svg.setAttribute('viewBox', '0 0 24 24');
865
+ svg.style.cssText = 'width:8px;height:8px;flex-shrink:0;image-rendering:pixelated;display:block;';
866
+ svg.innerHTML = glyphMarkup(m.emote);
867
+ row.appendChild(svg);
868
+ }
869
+ el.appendChild(row);
870
+ });
871
+ },
872
+ label: function(el) {
873
+ el.style.cssText += 'display:flex;align-items:center;padding-left:3px;overflow:hidden;';
874
+ var span = document.createElement('span');
875
+ span.className = 'et';
876
+ span.style.cssText = 'font-size:5px;color:var(--ns-ink);white-space:nowrap;overflow:hidden;';
877
+ span.textContent = 'Label';
878
+ span.setAttribute('contenteditable','false');
879
+ el.appendChild(span);
880
+ },
881
+ hdiv: function(el) {
882
+ el.style.cssText += 'display:flex;align-items:center;padding:0 3px;';
883
+ var d = document.createElement('div');
884
+ d.style.cssText = 'flex:1;height:1px;background:linear-gradient(90deg,transparent,rgba(255,255,255,0.28) 15%,rgba(255,255,255,0.28) 85%,transparent);';
885
+ el.appendChild(d);
886
+ },
887
+ vdiv: function(el) {
888
+ el.style.cssText += 'display:flex;justify-content:center;align-items:stretch;padding:3px 0;';
889
+ var d = document.createElement('div');
890
+ d.style.cssText = 'width:1px;height:100%;background:linear-gradient(to bottom,transparent,rgba(255,255,255,0.28) 15%,rgba(255,255,255,0.28) 85%,transparent);align-self:stretch;';
891
+ el.appendChild(d);
892
+ },
893
+ joy: function(el) {
894
+ el.style.cssText += 'display:flex;align-items:center;justify-content:center;';
895
+ var base = document.createElement('div');
896
+ base.style.cssText = 'position:relative;height:80%;aspect-ratio:1/1;border-radius:50%;border:1.5px solid rgba(255,255,255,0.18);background:rgba(0,0,0,0.38);flex-shrink:0;';
897
+ var inn = document.createElement('div');
898
+ inn.style.cssText = 'position:absolute;inset:0;';
899
+ var gh = document.createElement('div');
900
+ gh.style.cssText = 'position:absolute;top:50%;left:0;right:0;height:1px;background:var(--hud-grid-overlay);transform:translateY(-50%);';
901
+ var gv = document.createElement('div');
902
+ gv.style.cssText = 'position:absolute;left:50%;top:0;bottom:0;width:1px;background:var(--hud-grid-overlay);transform:translateX(-50%);';
903
+ var thumb = document.createElement('div');
904
+ thumb.style.cssText = 'position:absolute;width:32%;height:32%;border-radius:50%;background:rgba(190,190,210,0.72);border:1px solid rgba(255,255,255,0.35);top:50%;left:50%;transform:translate(-50%,-50%);cursor:grab;';
905
+ inn.appendChild(gh); inn.appendChild(gv); inn.appendChild(thumb);
906
+ base.appendChild(inn); el.appendChild(base);
907
+ var jd = null;
908
+ thumb.addEventListener('mousedown', function(e) {
909
+ if (window.hudGridOn) return;
910
+ e.stopPropagation(); e.preventDefault();
911
+ var br = base.getBoundingClientRect();
912
+ jd = {cx:br.left+br.width/2, cy:br.top+br.height/2, r:br.width*0.26};
913
+ thumb.style.background = 'rgba(220,220,240,0.92)';
914
+ });
915
+ document.addEventListener('mousemove', function(e) {
916
+ if (!jd) return;
917
+ var dx=e.clientX-jd.cx, dy=e.clientY-jd.cy, d=Math.sqrt(dx*dx+dy*dy);
918
+ if (d>jd.r){dx=dx/d*jd.r;dy=dy/d*jd.r;}
919
+ thumb.style.left=(50+dx/jd.r*26)+'%';
920
+ thumb.style.top =(50+dy/jd.r*26)+'%';
921
+ thumb.style.transform='translate(-50%,-50%)';
922
+ });
923
+ document.addEventListener('mouseup', function() {
924
+ if (!jd) return; jd=null;
925
+ thumb.style.left='50%'; thumb.style.top='50%';
926
+ thumb.style.background='rgba(190,190,210,0.72)';
927
+ });
928
+ },
929
+ tabwin: function(el) {
930
+ el.style.cssText += 'display:flex;flex-direction:column;overflow:hidden;align-items:stretch;justify-content:flex-start;';
931
+ var elId = el.id;
932
+ function loadTabs() {
933
+ try { var a=JSON.parse(localStorage.getItem('ns-hud-tabwin-data')||'{}'); return (a[elId]&&a[elId].length)?a[elId]:null; } catch(e){return null;}
934
+ }
935
+ function saveTabs() {
936
+ try { var a=JSON.parse(localStorage.getItem('ns-hud-tabwin-data')||'{}'); a[elId]=tabs.map(function(t){return{label:t.label};}); localStorage.setItem('ns-hud-tabwin-data',JSON.stringify(a)); } catch(e){}
937
+ }
938
+ var tabs = loadTabs() || [{label:'Chat'},{label:'Logs'}];
939
+ var activeIdx = 0;
940
+ var tabEls = [];
941
+ var tabBar = document.createElement('div');
942
+ tabBar.setAttribute('data-tab-bar', '');
943
+ tabBar.style.cssText = 'display:flex;flex-shrink:0;height:var(--hud-row);border-bottom:1px solid rgba(0,0,0,0.4);';
944
+ var pane = document.createElement('div');
945
+ pane.style.cssText = 'flex:1;overflow:hidden;position:relative;background:rgba(10,18,22,0.6);';
946
+ function setActive(i) {
947
+ activeIdx = i;
948
+ tabEls.forEach(function(t,j){
949
+ var on=j===i;
950
+ t.style.background=on?'rgba(210,208,198,0.9)':'rgba(0,0,0,0.28)';
951
+ t.style.color=on?'#1c1c17':'rgba(200,200,200,0.4)';
952
+ });
953
+ }
954
+ function removeTab(i) {
955
+ if (tabs.length <= 1) return; /* always keep at least one tab */
956
+ tabs.splice(i, 1);
957
+ if (i < activeIdx) activeIdx--;
958
+ if (activeIdx >= tabs.length) activeIdx = tabs.length - 1;
959
+ saveTabs();
960
+ rebuild();
961
+ }
962
+ function makeTab(i) {
963
+ var tab = document.createElement('button');
964
+ tab.style.cssText = 'position:relative;width:var(--hud-cell-w);min-width:48px;flex-shrink:0;height:100%;padding:0 2px;border:none;border-right:1px solid rgba(0,0,0,0.35);font-family:var(--hud-font);font-size:4px;cursor:pointer;letter-spacing:0.04em;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;';
965
+ var lbl = document.createElement('span');
966
+ lbl.textContent = tabs[i].label;
967
+ lbl.style.cssText = 'outline:none;pointer-events:none;';
968
+ tab.appendChild(lbl);
969
+ tab.addEventListener('click', function(e){ e.stopPropagation(); setActive(i); });
970
+ tab.addEventListener('mousedown',function(e){ e.stopPropagation(); });
971
+ tab.addEventListener('dblclick', function(e){
972
+ e.stopPropagation();
973
+ lbl.contentEditable='true'; lbl.style.pointerEvents='auto'; lbl.focus();
974
+ try{var r=document.createRange();r.selectNodeContents(lbl);var s=window.getSelection();s.removeAllRanges();s.addRange(r);}catch(z){}
975
+ lbl.addEventListener('blur',function commit(){
976
+ lbl.contentEditable='false'; lbl.style.pointerEvents='none';
977
+ var t=lbl.textContent.trim()||tabs[i].label;
978
+ lbl.textContent=t; tabs[i].label=t; saveTabs();
979
+ lbl.removeEventListener('blur',commit);
980
+ });
981
+ lbl.addEventListener('keydown',function(e){if(e.key==='Enter'||e.key==='Escape'){e.preventDefault();lbl.blur();}});
982
+ });
983
+ var del = document.createElement('span');
984
+ del.textContent = '\u00d7'; del.setAttribute('data-tip', 'Delete tab');
985
+ del.style.cssText = 'position:absolute;top:0;right:0;bottom:0;width:9px;display:none;align-items:center;justify-content:center;font-size:7px;line-height:1;color:rgba(255,255,255,0.55);background:linear-gradient(to right,transparent,rgba(0,0,0,0.6) 45%);cursor:pointer;pointer-events:auto;';
986
+ tab.appendChild(del);
987
+ tab.addEventListener('mouseenter', function(){ if (tabs.length > 1) del.style.display = 'flex'; });
988
+ tab.addEventListener('mouseleave', function(){ del.style.display = 'none'; });
989
+ del.addEventListener('mouseenter', function(){ del.style.color = 'rgba(255,90,90,0.95)'; });
990
+ del.addEventListener('mouseleave', function(){ del.style.color = 'rgba(255,255,255,0.55)'; });
991
+ del.addEventListener('mousedown', function(e){ e.stopPropagation(); });
992
+ del.addEventListener('click', function(e){ e.stopPropagation(); removeTab(i); });
993
+ return tab;
994
+ }
995
+ function rebuild() {
996
+ tabBar.innerHTML=''; tabEls=[];
997
+ tabs.forEach(function(_,i){var t=makeTab(i);tabEls.push(t);tabBar.appendChild(t);});
998
+ var spacer=document.createElement('div');
999
+ spacer.style.cssText='flex:1;';
1000
+ tabBar.appendChild(spacer);
1001
+ var closeBtn=document.createElement('button');
1002
+ closeBtn.textContent='\u00d7'; closeBtn.setAttribute('data-tip','Close window');
1003
+ closeBtn.style.cssText='width:14px;flex-shrink:0;height:100%;padding:0;border:none;border-left:1px solid rgba(255,255,255,0.08);background:transparent;color:rgba(255,255,255,0.28);font-size:10px;line-height:1;cursor:pointer;';
1004
+ closeBtn.addEventListener('mouseenter',function(){closeBtn.style.color='rgba(255,80,80,0.9)';closeBtn.style.background='rgba(255,30,30,0.14)';});
1005
+ var add=document.createElement('button');
1006
+ add.textContent='+'; add.setAttribute('data-tip','Add tab');
1007
+ add.style.cssText='width:14px;flex-shrink:0;height:100%;padding:0;border:none;border-left:1px solid rgba(255,255,255,0.08);background:transparent;color:rgba(255,255,255,0.22);font-size:8px;cursor:pointer;';
1008
+ add.addEventListener('click', function(e){e.stopPropagation();var idx=tabs.length;tabs.push({label:'TAB '+(idx+1)});saveTabs();rebuild();setActive(idx);});
1009
+ add.addEventListener('mousedown', function(e){e.stopPropagation();});
1010
+ tabBar.appendChild(add);
1011
+ closeBtn.addEventListener('mouseleave',function(){closeBtn.style.color='rgba(255,255,255,0.28)';closeBtn.style.background='transparent';});
1012
+ closeBtn.addEventListener('click', function(e){e.stopPropagation();var d=el.querySelector('.del-btn');if(d)d.click();});
1013
+ closeBtn.addEventListener('mousedown', function(e){e.stopPropagation();});
1014
+ tabBar.appendChild(closeBtn);
1015
+ setActive(activeIdx);
1016
+ }
1017
+ rebuild();
1018
+ el.appendChild(tabBar);
1019
+ el.appendChild(pane);
1020
+ },
1021
+ bgd: function(el) {
1022
+ el.style.cssText += 'background:rgba(12,16,22,0.65);border:1px solid rgba(255,255,255,0.10);';
1023
+ el.setAttribute('data-backdrop','true');
1024
+ },
1025
+ bgc: function(el) {
1026
+ el.style.cssText += 'background:transparent;border:1px dashed rgba(255,255,255,0.22);';
1027
+ el.setAttribute('data-backdrop','true');
1028
+ },
1029
+ minimap: function(el) {
1030
+ el.style.cssText += 'overflow:hidden;background:#1e2a10;position:relative;';
1031
+ var elId = el.id;
1032
+ var stateKey = 'ns-mm-min-' + elId;
1033
+ var maxKey = 'ns-mm-maxpos-' + elId;
1034
+ var minKey = 'ns-mm-minpos-' + elId;
1035
+ var minimized = localStorage.getItem(stateKey) === 'true';
1036
+
1037
+ /* ── Canvas (fills element at any size) ── */
1038
+ var cv = document.createElement('canvas');
1039
+ var W = 240, H = 160;
1040
+ cv.width = W; cv.height = H;
1041
+ cv.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;image-rendering:pixelated;';
1042
+ el.appendChild(cv);
1043
+
1044
+ /* ── Toggle button — bottom-left ── */
1045
+ var toggleBtn = document.createElement('button');
1046
+ toggleBtn.style.cssText = [
1047
+ 'position:absolute;bottom:0;left:0;z-index:50;',
1048
+ 'background:rgba(10,16,6,0.82);',
1049
+ 'border:1px solid rgba(210,178,48,0.5);',
1050
+ 'color:rgba(210,178,48,0.9);',
1051
+ 'font-family:\'Courier New\',monospace;font-size:6px;font-weight:bold;',
1052
+ 'width:10px;height:10px;',
1053
+ 'display:flex;align-items:center;justify-content:center;',
1054
+ 'cursor:pointer;pointer-events:auto;padding:0;line-height:1;',
1055
+ 'transition:background 0.1s,border-color 0.1s,transform 0.22s cubic-bezier(0.4,0,0.2,1);',
1056
+ 'transform-origin:center center;',
1057
+ ].join('');
1058
+ toggleBtn.textContent = '↙';
1059
+ el.appendChild(toggleBtn);
1060
+
1061
+ toggleBtn.addEventListener('mouseenter', function() {
1062
+ toggleBtn.style.background = 'rgba(210,178,48,0.22)';
1063
+ toggleBtn.style.borderColor = 'rgba(210,178,48,0.9)';
1064
+ });
1065
+ toggleBtn.addEventListener('mouseleave', function() {
1066
+ toggleBtn.style.background = 'rgba(10,16,6,0.82)';
1067
+ toggleBtn.style.borderColor = 'rgba(210,178,48,0.5)';
1068
+ });
1069
+ toggleBtn.addEventListener('mousedown', function(e) { e.stopPropagation(); e.preventDefault(); });
1070
+
1071
+ /* Read current grid position from inline style */
1072
+ function getGridPos() {
1073
+ var gc = (el.style.gridColumn || '').split('/').map(function(s){ return parseInt(s.trim()); });
1074
+ var gr = (el.style.gridRow || '').split('/').map(function(s){ return parseInt(s.trim()); });
1075
+ if (gc.length < 2 || isNaN(gc[0]) || isNaN(gc[1])) return null;
1076
+ return { c1: gc[0], c2: gc[1], r1: gr[0], r2: gr[1] };
1077
+ }
1078
+
1079
+ toggleBtn.addEventListener('click', function(e) {
1080
+ e.stopPropagation();
1081
+ var curPos = getGridPos();
1082
+ if (!curPos) { minimized = !minimized; localStorage.setItem(stateKey, minimized ? 'true' : 'false'); applyState(); return; }
1083
+
1084
+ if (!minimized) {
1085
+ /* Maximize → Minimize: snapshot current size as max, restore saved min */
1086
+ localStorage.setItem(maxKey, JSON.stringify(curPos));
1087
+ var minPos = JSON.parse(localStorage.getItem(minKey) || 'null');
1088
+ if (!minPos) {
1089
+ /* Default min: 2 cols × 2 rows, anchored to same bottom-left corner */
1090
+ minPos = { c1: curPos.c1, r1: Math.max(1, curPos.r2 - 2), c2: curPos.c1 + 2, r2: curPos.r2 };
1091
+ }
1092
+ minimized = true;
1093
+ localStorage.setItem(stateKey, 'true');
1094
+ if (el._mmResize) el._mmResize(minPos);
1095
+ } else {
1096
+ /* Minimize → Maximize: snapshot current size as min, restore saved max */
1097
+ localStorage.setItem(minKey, JSON.stringify(curPos));
1098
+ var maxPos = JSON.parse(localStorage.getItem(maxKey) || 'null');
1099
+ if (!maxPos) {
1100
+ /* Default max: 12 cols × 10 rows, anchored to same bottom-left corner */
1101
+ maxPos = { c1: curPos.c1, r1: Math.max(1, curPos.r2 - 10), c2: Math.min(25, curPos.c1 + 12), r2: curPos.r2 };
1102
+ }
1103
+ minimized = false;
1104
+ localStorage.setItem(stateKey, 'false');
1105
+ if (el._mmResize) el._mmResize(maxPos);
1106
+ }
1107
+ applyState();
1108
+ });
1109
+
1110
+ function applyState() {
1111
+ /* ↙ when expanded (collapse), ↗ when collapsed (expand) */
1112
+ toggleBtn.textContent = minimized ? '↙' : '↗';
1113
+ }
1114
+
1115
+ /* ── Animation ── */
1116
+ var ctx = cv.getContext('2d');
1117
+ var G = 12, TW = 9, TH = 4.5;
1118
+ var ox = W * 0.5, oy = H * 0.22;
1119
+ function isoXY(x, y) { return { x: ox + (x - y) * TW, y: oy + (x + y) * TH }; }
1120
+ var dots = [
1121
+ { t: 'p', x: 3, y: 4.5 },
1122
+ { t: 'a', x: 6, y: 7 }, { t: 'a', x: 7, y: 8 }, { t: 'a', x: 8.5, y: 6.5 },
1123
+ { t: 'a', x: 9, y: 9 }, { t: 'a', x: 10, y: 7.5 }, { t: 'a', x: 9.5, y: 8.5 },
1124
+ { t: 'e', x: 8, y: 10 }, { t: 'e', x: 9.5, y: 11 }, { t: 'e', x: 7, y: 11.5 },
1125
+ ];
1126
+ var frame = 0, raf2;
1127
+ function mmTick() {
1128
+ frame++;
1129
+ var t = frame * 0.04;
1130
+ ctx.clearRect(0, 0, W, H);
1131
+ ctx.fillStyle = '#1e2a10'; ctx.fillRect(0, 0, W, H);
1132
+ var c0=isoXY(0,0), c1=isoXY(G,0), c2=isoXY(G,G), c3=isoXY(0,G);
1133
+ ctx.beginPath(); ctx.moveTo(c0.x,c0.y); ctx.lineTo(c1.x,c1.y); ctx.lineTo(c2.x,c2.y); ctx.lineTo(c3.x,c3.y); ctx.closePath();
1134
+ ctx.fillStyle = 'rgba(48,68,20,0.7)'; ctx.fill();
1135
+ ctx.strokeStyle = 'rgba(200,170,44,0.3)'; ctx.lineWidth = 0.5;
1136
+ for (var i = 0; i <= G; i++) {
1137
+ var a, b;
1138
+ a = isoXY(i,0); b = isoXY(i,G); ctx.beginPath(); ctx.moveTo(a.x,a.y); ctx.lineTo(b.x,b.y); ctx.stroke();
1139
+ a = isoXY(0,i); b = isoXY(G,i); ctx.beginPath(); ctx.moveTo(a.x,a.y); ctx.lineTo(b.x,b.y); ctx.stroke();
1140
+ }
1141
+ ctx.beginPath(); ctx.moveTo(c0.x,c0.y); ctx.lineTo(c1.x,c1.y); ctx.lineTo(c2.x,c2.y); ctx.lineTo(c3.x,c3.y); ctx.closePath();
1142
+ ctx.strokeStyle = 'rgba(210,178,48,0.88)'; ctx.lineWidth = 1.2; ctx.stroke();
1143
+ var pl = dots[0], ps = isoXY(pl.x, pl.y);
1144
+ ctx.save(); ctx.beginPath(); ctx.moveTo(ps.x, ps.y);
1145
+ var fd = -0.45, fs = 0.5, fl = 65;
1146
+ ctx.lineTo(ps.x + Math.cos(fd-fs)*fl, ps.y + Math.sin(fd-fs)*fl);
1147
+ ctx.lineTo(ps.x + Math.cos(fd+fs)*fl, ps.y + Math.sin(fd+fs)*fl);
1148
+ ctx.closePath(); ctx.fillStyle = 'rgba(255,255,255,0.09)'; ctx.fill();
1149
+ ctx.strokeStyle = 'rgba(255,255,255,0.55)'; ctx.lineWidth = 0.75; ctx.stroke(); ctx.restore();
1150
+ dots.forEach(function(d) {
1151
+ var s = isoXY(d.x, d.y), px = Math.round(s.x), py = Math.round(s.y);
1152
+ if (d.t === 'e') {
1153
+ var pulse = Math.sin(t * 2.8 + d.x) * 0.5 + 0.5;
1154
+ var sz = Math.ceil(2.5 + pulse);
1155
+ ctx.fillStyle = 'rgba(200,72,38,' + (0.65 + pulse * 0.35) + ')';
1156
+ ctx.fillRect(px - Math.floor(sz/2), py - Math.floor(sz/2), sz, sz);
1157
+ } else if (d.t === 'a') {
1158
+ ctx.fillStyle = '#e6c840'; ctx.fillRect(px - 1, py - 1, 3, 3);
1159
+ } else {
1160
+ ctx.fillStyle = '#ffffff'; ctx.fillRect(px - 2, py - 2, 4, 4);
1161
+ }
1162
+ });
1163
+ raf2 = requestAnimationFrame(mmTick);
1164
+ }
1165
+
1166
+ applyState();
1167
+ mmTick();
1168
+ el._stopAnim = function() { if (raf2) { cancelAnimationFrame(raf2); raf2 = null; } };
1169
+ },
1170
+ };
1171
+
1172
+ /* ══════════════════════════════════════════
1173
+ HUD MANAGER — single unified controller
1174
+ ══════════════════════════════════════════ */
1175
+ (function() {
1176
+ const COLS = 24, ROWS = 24;
1177
+ const card = document.querySelector('.hud');
1178
+ const ghost = document.getElementById('ghost');
1179
+ const delFloater = document.getElementById('del-floater');
1180
+ const alignBar = document.getElementById('align-bar');
1181
+ const alignBtns = alignBar.querySelectorAll('.ab[data-a]');
1182
+
1183
+ /* ── Storage keys ── */
1184
+ const S_LAYOUT = 'ns-hud-layout';
1185
+ const S_DELETED = 'ns-hud-deleted';
1186
+ const S_TEXTS = 'ns-hud-texts';
1187
+ const S_ALIGN = 'ns-hud-align';
1188
+ const S_CUSTOM = 'ns-hud-custom';
1189
+ const S_NATURAL = 'ns-hud-natural'; /* intrinsic content cell-size per id → drives --scale; persisted so resize-scaling survives reload & import */
1190
+
1191
+ function load(key, def) {
1192
+ try { return JSON.parse(localStorage.getItem(key) || 'null') || def; } catch(e) { return def; }
1193
+ }
1194
+ function save(key, val) {
1195
+ try { localStorage.setItem(key, JSON.stringify(val)); } catch(e) {}
1196
+ }
1197
+
1198
+ /* ── Default layout ── */
1199
+ const DEF_LAYOUT = {
1200
+ 'el-avatar': { c1:1, r1:1, c2:4, r2:5 },
1201
+ 'el-sword': { c1:4, r1:4, c2:5, r2:5 },
1202
+ 'el-lv': { c1:1, r1:4, c2:4, r2:5 },
1203
+ 'el-name': { c1:4, r1:1, c2:8, r2:2 },
1204
+ 'el-hp-label': { c1:4, r1:2, c2:5, r2:3 },
1205
+ 'el-mp-label': { c1:4, r1:3, c2:5, r2:4 },
1206
+ 'el-hp': { c1:5, r1:2, c2:8, r2:3 },
1207
+ 'el-mp': { c1:5, r1:3, c2:8, r2:4 },
1208
+ 'el-human': { c1:5, r1:4, c2:6, r2:5 },
1209
+ 'el-neutral': { c1:6, r1:4, c2:7, r2:5 },
1210
+ 'el-medium': { c1:3, r1:4, c2:4, r2:5 },
1211
+ 'el-bxp': { c1:1, r1:5, c2:8, r2:6 },
1212
+ 'el-txp': { c1:1, r1:6, c2:8, r2:7 },
1213
+ 'el-wxp': { c1:1, r1:7, c2:8, r2:8 },
1214
+ 'el-guild': { c1:1, r1:8, c2:3, r2:9 },
1215
+ 'el-divider': { c1:5, r1:1, c2:6, r2:9 },
1216
+ 'el-ref': { c1:6, r1:8, c2:8, r2:9 },
1217
+ };
1218
+ const DEF_NAT = {
1219
+ 'el-avatar': { w:3, h:4 }, 'el-sword': { w:2, h:2 },
1220
+ 'el-lv': { w:1, h:1 }, 'el-name': { w:5, h:1 },
1221
+ 'el-hp-label': { w:1, h:1 }, 'el-mp-label': { w:1, h:1 },
1222
+ 'el-hp': { w:5, h:1 }, 'el-mp': { w:5, h:1 },
1223
+ 'el-human': { w:2, h:2 }, 'el-neutral': { w:2, h:2 },
1224
+ 'el-medium': { w:2, h:2 }, 'el-bxp': { w:8, h:1 },
1225
+ 'el-txp': { w:8, h:1 }, 'el-wxp': { w:8, h:1 },
1226
+ 'el-guild': { w:2, h:1 },
1227
+ 'el-divider': { w:1, h:8 },
1228
+ 'el-ref': { w:3, h:1 },
1229
+ };
1230
+
1231
+ const layout = Object.assign({}, DEF_LAYOUT, load(S_LAYOUT, {}));
1232
+ const natural = Object.assign({}, DEF_NAT, load(S_NATURAL, {}));
1233
+ const deleted = load(S_DELETED, []);
1234
+ const texts = load(S_TEXTS, {});
1235
+ const aligns = load(S_ALIGN, {});
1236
+
1237
+ /* ── Stacking order (z-index) ─────────────────────────────────────────
1238
+ Persisted per element so the last object placed stays on top — even
1239
+ across reloads, where elements are otherwise restored grouped by type
1240
+ (icons, then comps…) which scrambles the visual order. Backdrops are
1241
+ excluded so panels stay behind as intended. */
1242
+ var zorder = load('ns-hud-zorder', {});
1243
+ var zTop = 1; Object.keys(zorder).forEach(function(k){ var v = +zorder[k]; if (v >= zTop) zTop = v + 1; });
1244
+ function applyStoredZorder(id, el) { if (!el || el.hasAttribute('data-backdrop')) return; if (zorder[id] != null) el.style.zIndex = zorder[id]; }
1245
+ function bumpZ(id, el) { if (!el || el.hasAttribute('data-backdrop')) return; zorder[id] = zTop++; el.style.zIndex = zorder[id]; save('ns-hud-zorder', zorder); }
1246
+
1247
+ /* Migrate legacy sequential span IDs (et-0..et-5) → stable element-based IDs */
1248
+ const LEGACY = { 'et-0':'et-lv', 'et-1':'et-name', 'et-2':'et-hp-label', 'et-3':'et-mp-label', 'et-4':'et-hp', 'et-5':'et-mp' };
1249
+ var migrated = false;
1250
+ Object.keys(LEGACY).forEach(function(old) {
1251
+ if (texts[old] !== undefined && texts[LEGACY[old]] === undefined) {
1252
+ texts[LEGACY[old]] = texts[old]; delete texts[old]; migrated = true;
1253
+ }
1254
+ });
1255
+ if (migrated) save(S_TEXTS, texts);
1256
+ var customs = load(S_CUSTOM, []).filter(function(c){ return c.text && c.text.trim(); });
1257
+
1258
+ /* ── Place element on grid ── */
1259
+ function place(id, pos) {
1260
+ const el = document.getElementById(id);
1261
+ if (!el) return;
1262
+ el.style.gridColumn = pos.c1 + ' / ' + pos.c2;
1263
+ el.style.gridRow = pos.r1 + ' / ' + pos.r2;
1264
+ const nat = natural[id];
1265
+ if (nat) {
1266
+ const sx = (pos.c2 - pos.c1) / nat.w;
1267
+ const sy = (pos.r2 - pos.r1) / nat.h;
1268
+ el.style.setProperty('--scale', Math.sqrt(sx * sy).toFixed(4));
1269
+ }
1270
+ layout[id] = pos;
1271
+ }
1272
+
1273
+ /* ── toCell ── */
1274
+ function toCell(cx, cy) {
1275
+ const r = card.getBoundingClientRect();
1276
+ return {
1277
+ col: Math.max(1, Math.min(COLS, Math.floor((cx - r.left) / (r.width / COLS)) + 1)),
1278
+ row: Math.max(1, Math.min(ROWS, Math.floor((cy - r.top) / (r.height / ROWS)) + 1)),
1279
+ };
1280
+ }
1281
+
1282
+ /* ══════════════════════
1283
+ DRAG / RESIZE STATE
1284
+ ══════════════════════ */
1285
+ var drag = null;
1286
+
1287
+ function startMove(e, el) {
1288
+ e.preventDefault();
1289
+ /* selection happens here (mousedown) so click/drag never fight */
1290
+ var additive = e.shiftKey || e.ctrlKey || e.metaKey;
1291
+ if (additive) { toggleSel(el.id); }
1292
+ else if (!isSel(el.id)) { selectOnly(el.id); }
1293
+ if (!isSel(el.id)) return; /* additive click deselected it — nothing to drag */
1294
+ syncAlignForSelection();
1295
+ const pos = Object.assign({}, layout[el.id]);
1296
+ const mc = toCell(e.clientX, e.clientY);
1297
+ var members = selSet.slice();
1298
+ var starts = {};
1299
+ var minC=Infinity, minR=Infinity, maxC=-Infinity, maxR=-Infinity;
1300
+ members.forEach(function(mid){ var p=layout[mid]; if(!p)return; starts[mid]=Object.assign({},p); minC=Math.min(minC,p.c1); minR=Math.min(minR,p.r1); maxC=Math.max(maxC,p.c2); maxR=Math.max(maxR,p.r2); });
1301
+ drag = { mode:'move', el, pos, members:members, starts:starts, bbox:{minC:minC,minR:minR,maxC:maxC,maxR:maxR}, offC: mc.col - pos.c1, offR: mc.row - pos.r1, next: null, delta:{dc:0,dr:0} };
1302
+ members.forEach(function(mid){ var me=document.getElementById(mid); if(me)me.classList.add('dragging'); });
1303
+ card.classList.add('drag-mode');
1304
+ ghost.classList.add('active');
1305
+ ghost.style.gridColumn = pos.c1 + '/' + pos.c2;
1306
+ ghost.style.gridRow = pos.r1 + '/' + pos.r2;
1307
+ alignBar.classList.remove('show'); /* hide while dragging; re-shown on mouseup */
1308
+ hideDelFloater();
1309
+ }
1310
+
1311
+ function startResize(e, el, dir) {
1312
+ e.preventDefault(); e.stopPropagation();
1313
+ const pos = Object.assign({}, layout[el.id]);
1314
+ drag = { mode:'resize', dir: dir||'se', el, pos, next: null };
1315
+ hideDelFloater();
1316
+ el.classList.add('dragging');
1317
+ card.classList.add('drag-mode');
1318
+ ghost.classList.add('active');
1319
+ ghost.style.gridColumn = pos.c1 + '/' + pos.c2;
1320
+ ghost.style.gridRow = pos.r1 + '/' + pos.r2;
1321
+ }
1322
+
1323
+ document.addEventListener('mousemove', function(e) {
1324
+ if (!drag) return;
1325
+ const mc = toCell(e.clientX, e.clientY);
1326
+ if (drag.mode === 'move') {
1327
+ const w = drag.pos.c2 - drag.pos.c1, h = drag.pos.r2 - drag.pos.r1;
1328
+ var dc = (mc.col - drag.offC) - drag.pos.c1, dr = (mc.row - drag.offR) - drag.pos.r1;
1329
+ /* clamp the delta so the whole group's bounding box stays on the grid */
1330
+ dc = Math.max(1 - drag.bbox.minC, Math.min(COLS + 1 - drag.bbox.maxC, dc));
1331
+ dr = Math.max(1 - drag.bbox.minR, Math.min(ROWS + 1 - drag.bbox.maxR, dr));
1332
+ drag.delta = { dc:dc, dr:dr };
1333
+ const nc1 = drag.pos.c1 + dc, nr1 = drag.pos.r1 + dr;
1334
+ drag.next = { c1:nc1, r1:nr1, c2:nc1+w, r2:nr1+h };
1335
+ } else {
1336
+ var d = drag.dir, p = drag.pos;
1337
+ var nc1=p.c1, nr1=p.r1, nc2=p.c2, nr2=p.r2;
1338
+ if (d.includes('e')) nc2 = Math.max(p.c1+1, Math.min(COLS+1, mc.col+1));
1339
+ if (d.includes('s')) nr2 = Math.max(p.r1+1, Math.min(ROWS+1, mc.row+1));
1340
+ if (d.includes('w')) nc1 = Math.max(1, Math.min(p.c2-1, mc.col));
1341
+ if (d.includes('n')) nr1 = Math.max(1, Math.min(p.r2-1, mc.row));
1342
+ drag.next = { c1:nc1, r1:nr1, c2:nc2, r2:nr2 };
1343
+ }
1344
+ ghost.style.gridColumn = drag.next.c1 + '/' + drag.next.c2;
1345
+ ghost.style.gridRow = drag.next.r1 + '/' + drag.next.r2;
1346
+ });
1347
+
1348
+ document.addEventListener('mouseup', function() {
1349
+ if (!drag) return;
1350
+ if (drag.next) {
1351
+ if (drag.mode === 'move' && drag.members && drag.members.length > 1) {
1352
+ var dc = drag.delta.dc, dr = drag.delta.dr;
1353
+ drag.members.forEach(function(mid){ var s = drag.starts[mid]; if (s) place(mid, { c1:s.c1+dc, r1:s.r1+dr, c2:s.c2+dc, r2:s.r2+dr }); });
1354
+ } else {
1355
+ place(drag.el.id, drag.next);
1356
+ }
1357
+ save(S_LAYOUT, layout);
1358
+ var _changes = [];
1359
+ if (drag.mode === 'move' && drag.members && drag.members.length > 1) {
1360
+ drag.members.forEach(function(mid){ var s = drag.starts[mid]; var a = layout[mid];
1361
+ if (s && a && (s.c1!==a.c1||s.r1!==a.r1||s.c2!==a.c2||s.r2!==a.r2))
1362
+ _changes.push({ id: mid, before:{c1:s.c1,r1:s.r1,c2:s.c2,r2:s.r2}, after:{c1:a.c1,r1:a.r1,c2:a.c2,r2:a.r2} }); });
1363
+ } else if (drag.el) {
1364
+ var s0 = drag.pos, a0 = layout[drag.el.id];
1365
+ if (s0 && a0 && (s0.c1!==a0.c1||s0.r1!==a0.r1||s0.c2!==a0.c2||s0.r2!==a0.r2))
1366
+ _changes.push({ id: drag.el.id, before:{c1:s0.c1,r1:s0.r1,c2:s0.c2,r2:s0.r2}, after:{c1:a0.c1,r1:a0.r1,c2:a0.c2,r2:a0.r2} });
1367
+ }
1368
+ pushTransform(_changes);
1369
+ }
1370
+ if (drag.members) drag.members.forEach(function(mid){ var me=document.getElementById(mid); if(me)me.classList.remove('dragging'); });
1371
+ else drag.el.classList.remove('dragging');
1372
+ card.classList.remove('drag-mode');
1373
+ ghost.classList.remove('active');
1374
+ drag = null;
1375
+ syncAlignForSelection();
1376
+ });
1377
+
1378
+ /* ══════════════════════
1379
+ SELECT / DELETE STATE
1380
+ ══════════════════════ */
1381
+ var selId = null; /* primary selection (drives the align toolbar) */
1382
+ var selSet = []; /* every selected id — multi-select / group */
1383
+
1384
+ function isSel(id) { return selSet.indexOf(id) >= 0; }
1385
+ function addSel(id) {
1386
+ var el = document.getElementById(id); if (!el) return;
1387
+ if (!isSel(id)) { selSet.push(id); el.classList.add('selected'); }
1388
+ selId = id;
1389
+ }
1390
+ function toggleSel(id) {
1391
+ if (isSel(id)) {
1392
+ var el = document.getElementById(id); if (el) el.classList.remove('selected');
1393
+ selSet = selSet.filter(function(x){ return x !== id; });
1394
+ selId = selSet.length ? selSet[selSet.length - 1] : null;
1395
+ } else { addSel(id); }
1396
+ }
1397
+ function selectOnly(id) { deselect(); addSel(id); refreshOptionsBar(); }
1398
+ function syncAlignForSelection() { refreshOptionsBar(); }
1399
+
1400
+ function deselect() {
1401
+ selSet.forEach(function(x){ var e = document.getElementById(x); if (e) e.classList.remove('selected'); });
1402
+ selSet = []; selId = null;
1403
+ positionDelFloater();
1404
+ }
1405
+
1406
+ /* ── Undo / redo history (command stack) ──────────────────────
1407
+ Each command holds closures over the exact DOM nodes, so undoing a delete
1408
+ re-inserts the original element with every listener and component (Tab
1409
+ Window, minimap…) state intact — no rebuild, no desync. */
1410
+ var undoStack = [], redoStack = [], applyingHistory = false;
1411
+ function updateHistoryButtons() {
1412
+ var u = document.getElementById('undo-btn'), r = document.getElementById('redo-btn');
1413
+ if (u) u.disabled = !undoStack.length;
1414
+ if (r) r.disabled = !redoStack.length;
1415
+ }
1416
+ function pushCmd(cmd) {
1417
+ if (applyingHistory) return;
1418
+ undoStack.push(cmd);
1419
+ if (undoStack.length > 120) undoStack.shift();
1420
+ redoStack.length = 0;
1421
+ updateHistoryButtons();
1422
+ }
1423
+ function undo() {
1424
+ var c = undoStack.pop(); if (!c) return;
1425
+ applyingHistory = true; try { c.undo(); } finally { applyingHistory = false; }
1426
+ redoStack.push(c); updateHistoryButtons();
1427
+ }
1428
+ function redo() {
1429
+ var c = redoStack.pop(); if (!c) return;
1430
+ applyingHistory = true; try { c.redo(); } finally { applyingHistory = false; }
1431
+ undoStack.push(c); updateHistoryButtons();
1432
+ }
1433
+ function refreshAfterHistory() { refreshOptionsBar(); positionDelFloater(); updateHistoryButtons(); }
1434
+
1435
+ /* Capture the steps to delete one element + restore it. The detached node is
1436
+ held in the closure, so restore re-inserts the very same element. */
1437
+ function captureDelete(id) {
1438
+ var el = document.getElementById(id);
1439
+ if (!el) return null;
1440
+ var kind = el.hasAttribute('data-custom') ? 'custom'
1441
+ : el.hasAttribute('data-icon-el') ? 'icon'
1442
+ : el.hasAttribute('data-comp-el') ? 'comp'
1443
+ : el.hasAttribute('data-emoji-el') ? 'emoji'
1444
+ : 'builtin';
1445
+ var prevLayout = layout[id] ? Object.assign({}, layout[id]) : null;
1446
+ var prevNatural = natural[id] ? Object.assign({}, natural[id]) : null;
1447
+ var savedItem =
1448
+ kind === 'custom' ? customs.filter(function(c){ return c.id === id; })[0]
1449
+ : kind === 'icon' ? savedIcons.filter(function(c){ return c.id === id; })[0]
1450
+ : kind === 'comp' ? savedComps.filter(function(c){ return c.id === id; })[0]
1451
+ : kind === 'emoji' ? savedEmojis.filter(function(c){ return c.id === id; })[0]
1452
+ : null;
1453
+ function applyDelete() {
1454
+ if (el._stopAnim) el._stopAnim();
1455
+ el.classList.remove('selected'); el.classList.remove('dragging');
1456
+ el.remove();
1457
+ delete layout[id];
1458
+ delete natural[id];
1459
+ save(S_LAYOUT, layout);
1460
+ save(S_NATURAL, natural);
1461
+ if (kind === 'icon') { savedIcons = savedIcons.filter(function(c){ return c.id !== id; }); save(S_ICONS, savedIcons); }
1462
+ else if (kind === 'comp') { savedComps = savedComps.filter(function(c){ return c.id !== id; }); save(S_COMPS, savedComps); }
1463
+ else if (kind === 'emoji') { savedEmojis = savedEmojis.filter(function(c){ return c.id !== id; }); save(S_EMOJIS, savedEmojis); }
1464
+ else if (kind === 'builtin'){ if (!deleted.includes(id)) deleted.push(id); save(S_DELETED, deleted); }
1465
+ else { customs = customs.filter(function(c){ return c.id !== id; }); save(S_CUSTOM, customs); }
1466
+ selSet = selSet.filter(function(x){ return x !== id; });
1467
+ if (selId === id) selId = selSet.length ? selSet[selSet.length - 1] : null;
1468
+ }
1469
+ function restore() {
1470
+ if (prevLayout) layout[id] = Object.assign({}, prevLayout);
1471
+ if (prevNatural) natural[id] = Object.assign({}, prevNatural);
1472
+ card.insertBefore(el, document.getElementById('ghost'));
1473
+ if (kind === 'builtin') { var i = deleted.indexOf(id); if (i >= 0) deleted.splice(i, 1); save(S_DELETED, deleted); }
1474
+ else if (kind === 'custom') { if (savedItem && customs.indexOf(savedItem) < 0) customs.push(savedItem); save(S_CUSTOM, customs); }
1475
+ else if (kind === 'icon') { if (savedItem && savedIcons.indexOf(savedItem) < 0) savedIcons.push(savedItem); save(S_ICONS, savedIcons); }
1476
+ else if (kind === 'comp') { if (savedItem && savedComps.indexOf(savedItem) < 0) savedComps.push(savedItem); save(S_COMPS, savedComps); }
1477
+ else if (kind === 'emoji') { if (savedItem && savedEmojis.indexOf(savedItem) < 0) savedEmojis.push(savedItem); save(S_EMOJIS, savedEmojis); }
1478
+ if (prevLayout) place(id, layout[id]);
1479
+ save(S_LAYOUT, layout);
1480
+ save(S_NATURAL, natural);
1481
+ }
1482
+ return { do: applyDelete, undo: restore };
1483
+ }
1484
+
1485
+ /* Delete a set of elements as ONE undoable step. */
1486
+ function performDelete(ids) {
1487
+ var cmds = ids.map(captureDelete).filter(Boolean);
1488
+ if (!cmds.length) return;
1489
+ cmds.forEach(function(c){ c.do(); });
1490
+ hideAlign();
1491
+ pushCmd({
1492
+ undo: function(){ cmds.slice().reverse().forEach(function(c){ c.undo(); }); refreshAfterHistory(); },
1493
+ redo: function(){ cmds.forEach(function(c){ c.do(); }); refreshAfterHistory(); }
1494
+ });
1495
+ refreshAfterHistory();
1496
+ }
1497
+ function doDelete(id) { performDelete([id]); }
1498
+ function deleteSelected() { performDelete(selSet.slice()); }
1499
+
1500
+ /* Record a freshly-added element (or group) as one undoable step. Undo
1501
+ removes the node (kept alive in the closure); redo re-inserts it. */
1502
+ function recordAdd(ids) {
1503
+ if (applyingHistory) return;
1504
+ var caps = ids.map(captureDelete).filter(Boolean);
1505
+ if (!caps.length) return;
1506
+ pushCmd({
1507
+ undo: function(){ caps.slice().reverse().forEach(function(c){ c.do(); }); refreshAfterHistory(); },
1508
+ redo: function(){ caps.forEach(function(c){ c.undo(); }); refreshAfterHistory(); }
1509
+ });
1510
+ }
1511
+
1512
+ /* Record a move / resize as one undoable step. */
1513
+ function pushTransform(changes) {
1514
+ if (applyingHistory || !changes.length) return;
1515
+ pushCmd({
1516
+ undo: function(){ changes.forEach(function(c){ place(c.id, c.before); }); save(S_LAYOUT, layout); refreshAfterHistory(); },
1517
+ redo: function(){ changes.forEach(function(c){ place(c.id, c.after); }); save(S_LAYOUT, layout); refreshAfterHistory(); }
1518
+ });
1519
+ }
1520
+
1521
+ function hideDelFloater() { if (delFloater) delFloater.style.display = 'none'; }
1522
+
1523
+ /* Floating delete button — sits on the cell next to the selection so it never
1524
+ covers the object or its resize handles; flips to the opposite side at the
1525
+ grid edge, and tucks just inside for a full-width selection. */
1526
+ function positionDelFloater() {
1527
+ var f = delFloater;
1528
+ if (!f) return;
1529
+ if (!selSet.length || drag) { f.style.display = 'none'; return; }
1530
+ var minC = Infinity, minR = Infinity, maxC = -Infinity;
1531
+ selSet.forEach(function(id){ var p = layout[id]; if (!p) return;
1532
+ if (p.c1 < minC) minC = p.c1; if (p.r1 < minR) minR = p.r1; if (p.c2 > maxC) maxC = p.c2; });
1533
+ if (!isFinite(minC)) { f.style.display = 'none'; return; }
1534
+ var cs = getComputedStyle(card);
1535
+ var cw = parseFloat(cs.getPropertyValue('--hud-col')) || 20;
1536
+ var ch = parseFloat(cs.getPropertyValue('--hud-row')) || 15;
1537
+ var SZ = 10;
1538
+ /* maxC / minC are grid LINES; the right neighbour cell starts at line maxC. */
1539
+ var leftLine = (maxC <= COLS) ? maxC : (minC - 1 >= 1 ? minC - 1 : maxC - 1);
1540
+ f.style.left = ((leftLine - 1) * cw + (cw - SZ) / 2) + 'px';
1541
+ f.style.top = ((minR - 1) * ch + (ch - SZ) / 2) + 'px';
1542
+ f.style.display = 'flex';
1543
+ }
1544
+
1545
+ /* Wire the history buttons + the floating delete button. */
1546
+ (function wireHistoryUI() {
1547
+ var u = document.getElementById('undo-btn'), r = document.getElementById('redo-btn');
1548
+ if (u) u.addEventListener('click', function(e){ e.stopPropagation(); undo(); });
1549
+ if (r) r.addEventListener('click', function(e){ e.stopPropagation(); redo(); });
1550
+ var f = delFloater;
1551
+ if (f) {
1552
+ f.addEventListener('mousedown', function(e){ e.stopPropagation(); e.preventDefault(); });
1553
+ f.addEventListener('click', function(e){ e.stopPropagation(); if (selSet.length) performDelete(selSet.slice()); });
1554
+ }
1555
+ updateHistoryButtons();
1556
+ })();
1557
+
1558
+ document.addEventListener('keydown', function(e) {
1559
+ var meta = e.ctrlKey || e.metaKey;
1560
+ var editing = document.activeElement && document.activeElement.isContentEditable;
1561
+ if (meta && !editing && (e.key === 'z' || e.key === 'Z')) { e.preventDefault(); if (e.shiftKey) redo(); else undo(); return; }
1562
+ if (meta && !editing && (e.key === 'y' || e.key === 'Y')) { e.preventDefault(); redo(); return; }
1563
+ /* Copy / paste the current selection (ignored while editing text) */
1564
+ if (meta && !editing && (e.key === 'c' || e.key === 'C')) { if (selSet.length) { e.preventDefault(); copySelection(); } return; }
1565
+ if (meta && !editing && (e.key === 'v' || e.key === 'V')) { if (clipboard.length) { e.preventDefault(); pasteClipboard(); } return; }
1566
+ if ((e.key === 'Delete' || e.key === 'Backspace') && selSet.length) {
1567
+ if (document.activeElement && document.activeElement.contentEditable === 'true') return;
1568
+ e.preventDefault();
1569
+ deleteSelected();
1570
+ }
1571
+ /* Enter / Escape end the pending interaction with the focused object —
1572
+ same as clicking away. While editing any contentEditable (custom text,
1573
+ labels, tab names, bar text) it commits via that element's blur handler;
1574
+ otherwise it clears the current selection + options bar. */
1575
+ if (e.key === 'Enter' || e.key === 'Escape') {
1576
+ var ae = document.activeElement;
1577
+ if (ae && ae.isContentEditable) {
1578
+ e.preventDefault();
1579
+ ae.blur();
1580
+ return;
1581
+ }
1582
+ if (e.key === 'Escape' || selSet.length) { e.preventDefault(); deselect(); hideAlign(); }
1583
+ }
1584
+ });
1585
+
1586
+ /* ══════════════════════
1587
+ ALIGN TOOLBAR
1588
+ ══════════════════════ */
1589
+ /* ── support predicates: which objects accept which option ── */
1590
+ function elById(id){ return document.getElementById(id); }
1591
+ function canTextAlign(el){ return !!(el && (el.querySelector('.et') || el.querySelector('[data-tab-bar]') || el.classList.contains('ns-text') || el.hasAttribute('data-custom'))); }
1592
+ function canSize(el){ return !!(el && el.querySelector('.et')); }
1593
+ function canColor(el){ return !!(el && (el.querySelector('.et') || el.querySelector('.bar-fill') || el.hasAttribute('data-backdrop'))); }
1594
+ function canColorFg(el){ return !!(el && el.querySelector('.et')); }
1595
+ function canColorBg(el){ return !!(el && (el.querySelector('.bar-fill') || el.hasAttribute('data-backdrop') || el.hasAttribute('data-tintable-bg'))); }
1596
+ function selWhere(pred){ return selSet.filter(function(id){ return pred(elById(id)); }); }
1597
+ function aggVal(ids, map, def){ var v; for (var i = 0; i < ids.length; i++){ var c = map[ids[i]] || def; if (i === 0) v = c; else if (c !== v) return undefined; } return v; }
1598
+
1599
+ const ALIGN_MAP = { left:'flex-start', center:'center', right:'flex-end' };
1600
+
1601
+ function applyAlign(id, a) {
1602
+ const el = document.getElementById(id);
1603
+ if (!el) return;
1604
+ aligns[id] = a;
1605
+ const jc = ALIGN_MAP[a];
1606
+ const pl = (a === 'center') ? '0' : '3px';
1607
+ const pr = (a === 'right') ? '3px' : '0';
1608
+ var tabBar = el.querySelector('[data-tab-bar]');
1609
+ if (tabBar) {
1610
+ tabBar.style.justifyContent = jc;
1611
+ } else if (id === 'el-hp' || id === 'el-mp') {
1612
+ const bt = el.querySelector('.bar-text');
1613
+ if (bt) { bt.style.justifyContent = jc; bt.style.paddingLeft = pl; bt.style.paddingRight = pr; }
1614
+ } else if (el.hasAttribute('data-bar-kind')) {
1615
+ var bspan = el.querySelector('.et');
1616
+ if (bspan) { bspan.style.justifyContent = jc; bspan.style.paddingLeft = pl; bspan.style.paddingRight = pr; }
1617
+ } else {
1618
+ el.style.justifyContent = jc;
1619
+ el.style.paddingLeft = pl;
1620
+ el.style.paddingRight = pr;
1621
+ }
1622
+ save(S_ALIGN, aligns);
1623
+ }
1624
+
1625
+ /* ── Vertical align ── */
1626
+ const S_VALIGN = 'ns-hud-valigns';
1627
+ const vAlignBtns = alignBar.querySelectorAll('.ab[data-va]');
1628
+ var valigns = load(S_VALIGN, {});
1629
+ const VA_MAP = { top: 'flex-start', middle: 'center', bottom: 'flex-end' };
1630
+
1631
+ function applyVAlign(id, va) {
1632
+ const el = document.getElementById(id);
1633
+ if (!el) return;
1634
+ var vt = el.hasAttribute('data-bar-kind') ? (el.querySelector('.et') || el) : el;
1635
+ vt.style.alignItems = VA_MAP[va] || 'center';
1636
+ valigns[id] = va;
1637
+ save(S_VALIGN, valigns);
1638
+ vAlignBtns.forEach(function(b){ b.classList.toggle('on', b.dataset.va === va); });
1639
+ }
1640
+
1641
+ vAlignBtns.forEach(function(btn) {
1642
+ btn.addEventListener('mousedown', function(e) {
1643
+ e.preventDefault(); e.stopPropagation();
1644
+ var ids = selWhere(canTextAlign); if (!ids.length) return;
1645
+ ids.forEach(function(id){ applyVAlign(id, btn.dataset.va); });
1646
+ refreshOptionsBar();
1647
+ });
1648
+ btn.addEventListener('click', function(e){ e.stopPropagation(); });
1649
+ });
1650
+
1651
+ /* Restore persisted valigns */
1652
+ Object.keys(valigns).forEach(function(id) {
1653
+ var el = document.getElementById(id);
1654
+ if (el && valigns[id] && VA_MAP[valigns[id]]) el.style.alignItems = VA_MAP[valigns[id]];
1655
+ });
1656
+
1657
+ function positionOptionsBar() {
1658
+ var minC = Infinity, minR = Infinity, maxR = -Infinity;
1659
+ selSet.forEach(function(id){ var p = layout[id]; if (!p) return;
1660
+ if (p.c1 < minC) minC = p.c1; if (p.r1 < minR) minR = p.r1; if (p.r2 > maxR) maxR = p.r2; });
1661
+ if (!isFinite(minC)) return;
1662
+ var cs = getComputedStyle(card);
1663
+ var cr = card.getBoundingClientRect();
1664
+ var cw = parseFloat(cs.getPropertyValue('--hud-col')) || (cr.width / COLS);
1665
+ var ch = parseFloat(cs.getPropertyValue('--hud-row')) || (cr.height / ROWS);
1666
+ var cols = 5, rows = 6;
1667
+ /* Snap the panel to whole grid cells so every option lands exactly on a cell.
1668
+ Sit it directly above the selection; flip below if it would clip the top. */
1669
+ var c1 = Math.max(1, Math.min(COLS + 1 - cols, minC));
1670
+ var r1 = minR - rows;
1671
+ if (r1 < 1) r1 = maxR;
1672
+ r1 = Math.max(1, Math.min(ROWS + 1 - rows, r1));
1673
+ alignBar.style.left = ((c1 - 1) * cw) + 'px';
1674
+ alignBar.style.top = ((r1 - 1) * ch) + 'px';
1675
+ }
1676
+
1677
+ /* Show the options bar for the current selection. Each option group appears
1678
+ only if at least one selected object supports it, and reflects the shared
1679
+ value (no active button when the selection is mixed). Clicking an option
1680
+ applies it to every selected object that supports it. */
1681
+ function refreshOptionsBar() {
1682
+ closeSwatches();
1683
+ positionDelFloater();
1684
+ if (!selSet.length) { alignBar.classList.remove('show'); return; }
1685
+ var alignIds = selWhere(canTextAlign);
1686
+ var sizeIds = selWhere(canSize);
1687
+ var fgIds = selWhere(canColorFg);
1688
+ var bgIds = selWhere(canColorBg);
1689
+ var padIds = selWhere(canPad);
1690
+ var zIds = selWhere(canZ);
1691
+ if (!alignIds.length && !sizeIds.length && !fgIds.length && !bgIds.length && !padIds.length && !zIds.length) { alignBar.classList.remove('show'); return; }
1692
+ /* Every option keeps its fixed slot; unsupported ones are disabled (greyed),
1693
+ never hidden, so the panel never reflows under the selection. */
1694
+ alignBtns.forEach(function(b){ b.classList.toggle('is-disabled', !alignIds.length); });
1695
+ vAlignBtns.forEach(function(b){ b.classList.toggle('is-disabled', !alignIds.length); });
1696
+ sizeBtns.forEach(function(b){ b.classList.toggle('is-disabled', !sizeIds.length); });
1697
+ padBtns.forEach(function(b){ b.classList.toggle('is-disabled', !padIds.length); });
1698
+ zBtns.forEach(function(b){ b.classList.toggle('is-disabled', !zIds.length); });
1699
+ abFgBtn.classList.toggle('is-disabled', !fgIds.length);
1700
+ abBgBtn.classList.toggle('is-disabled', !bgIds.length);
1701
+ var a = aggVal(alignIds, aligns, 'left');
1702
+ alignBtns.forEach(function(b){ b.classList.toggle('on', !!alignIds.length && b.dataset.a === a); });
1703
+ var va = aggVal(alignIds, valigns, 'middle');
1704
+ vAlignBtns.forEach(function(b){ b.classList.toggle('on', !!alignIds.length && b.dataset.va === va); });
1705
+ var sz = aggVal(sizeIds, sizes, 'sm');
1706
+ sizeBtns.forEach(function(b){ b.classList.toggle('on', !!sizeIds.length && b.dataset.sz === sz); });
1707
+ var pd = aggVal(padIds, pads, '0');
1708
+ padBtns.forEach(function(b){ b.classList.toggle('on', !!padIds.length && b.dataset.pad === pd); });
1709
+ var fgCol = aggVal(fgIds, fgColors, '');
1710
+ abFgBtn.style.background = fgCol || 'var(--ns-ink-dim)';
1711
+ var bgCol = aggVal(bgIds, colors, '');
1712
+ abBgBtn.style.background = bgCol || 'var(--ns-ink-dim)';
1713
+ alignBar.classList.add('show');
1714
+ positionOptionsBar();
1715
+ }
1716
+
1717
+ function hideAlign() {
1718
+ alignBar.classList.remove('show');
1719
+ if (typeof closeSwatches === 'function') closeSwatches();
1720
+ }
1721
+
1722
+ /* Align buttons — use mousedown to fire before any click-based deselect */
1723
+ alignBtns.forEach(function(btn) {
1724
+ btn.addEventListener('mousedown', function(e) {
1725
+ e.preventDefault(); e.stopPropagation();
1726
+ var ids = selWhere(canTextAlign); if (!ids.length) return;
1727
+ var a = btn.dataset.a;
1728
+ ids.forEach(function(id){ applyAlign(id, a); });
1729
+ refreshOptionsBar();
1730
+ });
1731
+ btn.addEventListener('click', function(e) {
1732
+ e.stopPropagation();
1733
+ });
1734
+ });
1735
+
1736
+ /* ── Role (label / value) buttons ─────────────────── */
1737
+ /* ── Text size buttons ────────────────────────────── */
1738
+ const S_SIZES = 'ns-hud-sizes';
1739
+ const sizeBtns = alignBar.querySelectorAll('.size-btn');
1740
+ var sizes = load(S_SIZES, {});
1741
+ var SIZE_MAP = { xs:'4px', sm:'5px', md:'7px', lg:'9px', xl:'11px' };
1742
+
1743
+ function applySize(id, sz) {
1744
+ const el = document.getElementById(id);
1745
+ if (!el) return;
1746
+ const span = el.querySelector('.et');
1747
+ if (span) span.style.fontSize = SIZE_MAP[sz] || '';
1748
+ sizes[id] = sz;
1749
+ save(S_SIZES, sizes);
1750
+ sizeBtns.forEach(function(b){ b.classList.toggle('on', b.dataset.sz === sz); });
1751
+ autoFitText(el, true);
1752
+ }
1753
+
1754
+ /* B — background / object colour (bar fill, panel bg, outline border) */
1755
+ function applyBg(id, color) {
1756
+ var el = document.getElementById(id); if (!el) return;
1757
+ var fill = el.querySelector('.bar-fill');
1758
+ if (fill) {
1759
+ fill.style.background = color; /* bars: recolour the fill */
1760
+ } else if (el.hasAttribute('data-backdrop')) {
1761
+ var bg = el.style.background || '';
1762
+ if (!bg || bg === 'transparent') el.style.borderColor = color; /* outline: recolour the border */
1763
+ else el.style.background = color; /* panel: recolour the background */
1764
+ } else if (el.hasAttribute('data-tintable-bg')) {
1765
+ el.style.background = color; /* chat & similar: recolour own bg ('transparent' clears it) */
1766
+ }
1767
+ colors[id] = color;
1768
+ }
1769
+ /* F — foreground / text colour (the editable label span) */
1770
+ function applyFg(id, color) {
1771
+ var el = document.getElementById(id); if (!el) return;
1772
+ var span = el.querySelector('.et'); if (span) span.style.color = color;
1773
+ fgColors[id] = color;
1774
+ }
1775
+
1776
+ sizeBtns.forEach(function(btn) {
1777
+ btn.addEventListener('mousedown', function(e) {
1778
+ e.preventDefault(); e.stopPropagation();
1779
+ var ids = selWhere(canSize); if (!ids.length) return;
1780
+ ids.forEach(function(id){ applySize(id, btn.dataset.sz); });
1781
+ refreshOptionsBar();
1782
+ });
1783
+ btn.addEventListener('click', function(e){ e.stopPropagation(); });
1784
+ });
1785
+
1786
+ /* ── Inset ──────────────────────────────────────── */
1787
+ /* Shrinks the object within its grid area by an EQUAL pixel margin on all
1788
+ four sides, keeping it centered. Implemented as a non-uniform scale so it
1789
+ insets even absolutely-positioned innards (bar fills, panel layers) that
1790
+ CSS padding can't touch — and it never grows the box. The margin is a
1791
+ fraction of one grid cell, capped so thin objects can't invert. Because
1792
+ the grid scales both axes together, the equal-margin look survives resize. */
1793
+ const S_PADS = 'ns-hud-pads';
1794
+ const padBtns = alignBar.querySelectorAll('.pad-btn');
1795
+ var pads = load(S_PADS, {});
1796
+ var PAD_K = { '0':0, '5':0.08, '10':0.15, '20':0.25, '25':0.32 };
1797
+
1798
+ function canPad(el){ return !!el; }
1799
+
1800
+ /* Set the centered-inset transform on one element (no persistence / UI). */
1801
+ function setInset(el, p) {
1802
+ el.style.transform = ''; /* measure at natural size */
1803
+ var k = PAD_K[p] || 0;
1804
+ if (!k) return;
1805
+ var rect = el.getBoundingClientRect();
1806
+ if (!rect.width || !rect.height) return;
1807
+ var cell = parseFloat(getComputedStyle(card).getPropertyValue('--hud-row')) || 15;
1808
+ var m = Math.min(k * cell, 0.4 * Math.min(rect.width, rect.height)); /* equal px / side, capped */
1809
+ if (m <= 0 || !isFinite(m)) return;
1810
+ var sx = (rect.width - 2 * m) / rect.width;
1811
+ var sy = (rect.height - 2 * m) / rect.height;
1812
+ el.style.transformOrigin = 'center center';
1813
+ el.style.transform = 'scale(' + sx.toFixed(4) + ',' + sy.toFixed(4) + ')';
1814
+ }
1815
+
1816
+ function applyPad(id, p) {
1817
+ const el = document.getElementById(id);
1818
+ if (!el) return;
1819
+ setInset(el, p);
1820
+ pads[id] = p;
1821
+ save(S_PADS, pads);
1822
+ padBtns.forEach(function(b){ b.classList.toggle('on', b.dataset.pad === p); });
1823
+ }
1824
+
1825
+ padBtns.forEach(function(btn) {
1826
+ btn.addEventListener('mousedown', function(e) {
1827
+ e.preventDefault(); e.stopPropagation();
1828
+ var ids = selWhere(canPad); if (!ids.length) return;
1829
+ ids.forEach(function(id){ applyPad(id, btn.dataset.pad); });
1830
+ refreshOptionsBar();
1831
+ });
1832
+ btn.addEventListener('click', function(e){ e.stopPropagation(); });
1833
+ });
1834
+
1835
+ /* ── Z-order (stacking) ───────────────────────────── */
1836
+ /* Per-object z-index so the user picks what sits on top. front/back jump past
1837
+ every other object; forward/backward step one level. */
1838
+ const S_ZIDX = 'ns-hud-zindex';
1839
+ const zBtns = alignBar.querySelectorAll('.z-btn');
1840
+ var zIndexes = load(S_ZIDX, {});
1841
+
1842
+ function canZ(el){ return !!el; }
1843
+ function zOf(el){ var v = parseInt(el.style.zIndex, 10); return isNaN(v) ? 0 : v; }
1844
+ function curZ(id){ var el = document.getElementById(id); return el ? zOf(el) : 0; }
1845
+ function zExtent() {
1846
+ var max = 0, min = 0, any = false;
1847
+ document.querySelectorAll('.hud-card > [data-grid]').forEach(function(e){
1848
+ if (e.id === 'ghost') return;
1849
+ var z = zOf(e);
1850
+ if (!any) { max = min = z; any = true; }
1851
+ else { if (z > max) max = z; if (z < min) min = z; }
1852
+ });
1853
+ return { max: max, min: min };
1854
+ }
1855
+ function applyZ(id, z) {
1856
+ var el = document.getElementById(id); if (!el) return;
1857
+ el.style.zIndex = z;
1858
+ zIndexes[id] = z;
1859
+ save(S_ZIDX, zIndexes);
1860
+ }
1861
+
1862
+ zBtns.forEach(function(btn) {
1863
+ btn.addEventListener('mousedown', function(e) {
1864
+ e.preventDefault(); e.stopPropagation();
1865
+ var ids = selWhere(canZ); if (!ids.length) return;
1866
+ var op = btn.dataset.z;
1867
+ ids.forEach(function(id) {
1868
+ if (op === 'front') applyZ(id, zExtent().max + 1);
1869
+ else if (op === 'back') applyZ(id, zExtent().min - 1);
1870
+ else if (op === 'fwd') applyZ(id, curZ(id) + 1);
1871
+ else if (op === 'bwd') applyZ(id, curZ(id) - 1);
1872
+ });
1873
+ refreshOptionsBar();
1874
+ });
1875
+ btn.addEventListener('click', function(e){ e.stopPropagation(); });
1876
+ });
1877
+
1878
+
1879
+
1880
+ /* ── Text color swatches ─────────────────────── */
1881
+ const S_COLORS = 'ns-hud-colors';
1882
+ const S_FGCOLORS = 'ns-hud-fgcolors';
1883
+ var colors = load(S_COLORS, {}); /* background / object colour */
1884
+ var fgColors = load(S_FGCOLORS, {}); /* foreground / text colour */
1885
+ const abFgBtn = document.getElementById('ab-fg-btn');
1886
+ const abBgBtn = document.getElementById('ab-bg-btn');
1887
+ const colorSwatches = document.getElementById('color-swatches');
1888
+ var swatchesOpen = false;
1889
+ var swatchMode = 'fg';
1890
+ var PALETTE = [
1891
+ '#eef0f4','#e6db74','#fac775','#ff6060',
1892
+ '#ff4080','#5dcaa5','#66d9e8','#378add',
1893
+ '#7f77dd','#ae81ff','#c8a050','#8a9098',
1894
+ ];
1895
+ PALETTE.forEach(function(c) {
1896
+ var dot = document.createElement('div');
1897
+ dot.className = 'cs-dot'; dot.dataset.c = c;
1898
+ dot.style.background = c;
1899
+ colorSwatches.appendChild(dot);
1900
+ });
1901
+ /* "None" swatch — clears an object's background. Only meaningful for the B
1902
+ (object colour) control, so openSwatches() shows it in bg mode, hides it in fg. */
1903
+ var noneDot = document.createElement('div');
1904
+ noneDot.className = 'cs-dot cs-dot--none';
1905
+ noneDot.dataset.c = 'transparent';
1906
+ noneDot.setAttribute('data-tip', 'None');
1907
+ noneDot.style.background = 'linear-gradient(135deg,#3a3f49 0 44%,#ff5a5a 44% 56%,#3a3f49 56% 100%)';
1908
+ colorSwatches.appendChild(noneDot);
1909
+ function openSwatches(mode) {
1910
+ swatchMode = mode || swatchMode;
1911
+ var pred = swatchMode === 'bg' ? canColorBg : canColorFg;
1912
+ var store = swatchMode === 'bg' ? colors : fgColors;
1913
+ var ids = selWhere(pred); if (!ids.length) return;
1914
+ var noneEl = colorSwatches.querySelector('.cs-dot--none');
1915
+ if (noneEl) noneEl.style.display = (swatchMode === 'bg') ? '' : 'none';
1916
+ var br = alignBar.getBoundingClientRect();
1917
+ var cr = card.getBoundingClientRect();
1918
+ var sw = 62, sh = 50;
1919
+ var left = parseFloat(alignBar.style.left) || 0;
1920
+ var top = (br.top - cr.top - sh - 2 < 0)
1921
+ ? (br.bottom - cr.top + 2)
1922
+ : (br.top - cr.top - sh - 2);
1923
+ colorSwatches.style.left = Math.max(0, Math.min(left, cr.width - sw)) + 'px';
1924
+ colorSwatches.style.top = top + 'px';
1925
+ var cur = aggVal(ids, store, '') || '';
1926
+ colorSwatches.querySelectorAll('.cs-dot').forEach(function(d){
1927
+ d.classList.toggle('on', d.dataset.c === cur);
1928
+ });
1929
+ colorSwatches.classList.add('show'); swatchesOpen = true;
1930
+ }
1931
+ function closeSwatches() { colorSwatches.classList.remove('show'); swatchesOpen = false; }
1932
+ function wireColorBtn(btn, mode) {
1933
+ btn.addEventListener('mousedown', function(e){ e.preventDefault(); e.stopPropagation(); });
1934
+ btn.addEventListener('click', function(e) {
1935
+ e.stopPropagation();
1936
+ (swatchesOpen && swatchMode === mode) ? closeSwatches() : openSwatches(mode);
1937
+ });
1938
+ }
1939
+ wireColorBtn(abFgBtn, 'fg');
1940
+ wireColorBtn(abBgBtn, 'bg');
1941
+ colorSwatches.addEventListener('mousedown', function(e){ e.stopPropagation(); });
1942
+ colorSwatches.addEventListener('click', function(e) {
1943
+ e.stopPropagation();
1944
+ var dot = e.target.closest('.cs-dot');
1945
+ if (!dot) return;
1946
+ var color = dot.dataset.c;
1947
+ var ids = selWhere(swatchMode === 'bg' ? canColorBg : canColorFg);
1948
+ if (!ids.length) return;
1949
+ if (swatchMode === 'bg') {
1950
+ ids.forEach(function(id){ applyBg(id, color); });
1951
+ save(S_COLORS, colors);
1952
+ abBgBtn.style.background = color;
1953
+ } else {
1954
+ ids.forEach(function(id){ applyFg(id, color); });
1955
+ save(S_FGCOLORS, fgColors);
1956
+ abFgBtn.style.background = color;
1957
+ }
1958
+ colorSwatches.querySelectorAll('.cs-dot').forEach(function(d){ d.classList.toggle('on', d.dataset.c === color); });
1959
+ closeSwatches();
1960
+ });
1961
+
1962
+
1963
+ /* ══════════════════════
1964
+ TEXT EDITING
1965
+ ══════════════════════ */
1966
+ /* Grow a text element's column span so its (nowrap) content is never clipped.
1967
+ Measures the span's layout width vs. the element's per-cell layout width and
1968
+ extends c2 to the right, clamped to the grid edge. Only ever grows, so it
1969
+ never fights a manual resize. */
1970
+ function autoFitText(el, allowShrink) {
1971
+ if (!el || !el.id) return;
1972
+ var pos = layout[el.id];
1973
+ var span = el.querySelector('.et');
1974
+ if (!pos || !span) return;
1975
+ /* Bars are fixed-width gauges: the label is an absolute overlay that fills
1976
+ the bar, so scrollWidth === width and the fit below would grow the cell by
1977
+ one column on every call (runaway). Only inline-flow text auto-grows. */
1978
+ if (el.querySelector('.bar-fill') || span.classList.contains('bar-text')) return;
1979
+ var curCols = (pos.c2 - pos.c1) || 1;
1980
+ var cellW = el.offsetWidth / curCols; /* layout px per cell */
1981
+ if (!cellW || !isFinite(cellW)) return;
1982
+ var needPx = span.scrollWidth + 6; /* content + padding/buffer */
1983
+ var needCols = Math.max(1, Math.ceil(needPx / cellW));
1984
+ var newC2 = Math.min(COLS + 1, pos.c1 + needCols);
1985
+ if (allowShrink ? (newC2 !== pos.c2) : (newC2 > pos.c2)) {
1986
+ place(el.id, { c1: pos.c1, r1: pos.r1, c2: newC2, r2: pos.r2 });
1987
+ if (natural[el.id]) natural[el.id].w = newC2 - pos.c1;
1988
+ save(S_LAYOUT, layout);
1989
+ save(S_NATURAL, natural);
1990
+ }
1991
+ }
1992
+
1993
+ function startEdit(span) {
1994
+ span.contentEditable = 'true';
1995
+ span.style.pointerEvents = 'auto';
1996
+ span.focus();
1997
+ if (!span.__autofit) {
1998
+ span.__autofit = true;
1999
+ span.addEventListener('input', function() {
2000
+ autoFitText(span.closest('[data-grid]'));
2001
+ });
2002
+ }
2003
+ /* Guarantee a clean exit on blur for EVERY edited span — including dynamically
2004
+ created component spans (HUD Label, etc.) that never got an init-time blur
2005
+ handler. Enter/Escape (handled centrally) blur the span, which lands here. */
2006
+ if (!span.__editexit) {
2007
+ span.__editexit = true;
2008
+ span.addEventListener('blur', function() {
2009
+ span.contentEditable = 'false';
2010
+ span.style.pointerEvents = 'none';
2011
+ commitEdit(span);
2012
+ });
2013
+ }
2014
+ try {
2015
+ const r = document.createRange();
2016
+ r.selectNodeContents(span);
2017
+ const s = window.getSelection();
2018
+ s.removeAllRanges(); s.addRange(r);
2019
+ } catch(e) {}
2020
+ }
2021
+
2022
+ function commitEdit(span) {
2023
+ span.contentEditable = 'false';
2024
+ span.style.pointerEvents = 'none';
2025
+ const out = {};
2026
+ document.querySelectorAll('.et').forEach(function(s) {
2027
+ if (s.id) out[s.id] = s.textContent;
2028
+ });
2029
+ save(S_TEXTS, out);
2030
+ }
2031
+
2032
+ /* Refinement bar — 10 equal segments */
2033
+ (function() {
2034
+ const bar = document.getElementById('refine-bar');
2035
+ if (!bar) return;
2036
+ const VALUE = 1;
2037
+ const MAX = 10;
2038
+ for (var i = 0; i < MAX; i++) {
2039
+ const seg = document.createElement('div');
2040
+ seg.className = 'hud-refine-seg' + (i < VALUE ? ' filled' : '');
2041
+ bar.appendChild(seg);
2042
+ }
2043
+ })();
2044
+
2045
+
2046
+ document.querySelectorAll('.et').forEach(function(span) {
2047
+ /* IDs are hardcoded in HTML — no sequential assignment needed */
2048
+ if (texts[span.id] !== undefined) span.textContent = texts[span.id];
2049
+ span.addEventListener('blur', function() { commitEdit(span); });
2050
+ span.addEventListener('keydown', function(e) {
2051
+ if (e.key === 'Enter' || e.key === 'Escape') { e.preventDefault(); span.blur(); }
2052
+ });
2053
+ });
2054
+
2055
+ /* ══════════════════════
2056
+ ELEMENT WIRING
2057
+ ══════════════════════ */
2058
+ const TEXT_IDS = new Set(['el-name','el-lv','el-hp-label','el-mp-label','el-hp','el-mp']);
2059
+
2060
+ function wireEl(id) {
2061
+ const el = document.getElementById(id);
2062
+ if (!el) return;
2063
+
2064
+ /* Delete button */
2065
+ const delBtn = el.querySelector('.del-btn');
2066
+ if (delBtn) {
2067
+ delBtn.addEventListener('mousedown', function(e) { e.stopPropagation(); });
2068
+ delBtn.addEventListener('click', function(e) { e.stopPropagation(); doDelete(id); });
2069
+ }
2070
+
2071
+ /* Resize handles (8 directions) */
2072
+ el.querySelectorAll('.rz[data-rz]').forEach(function(rz) {
2073
+ rz.addEventListener('mousedown', function(e) { startResize(e, el, rz.dataset.rz); });
2074
+ });
2075
+
2076
+ /* Drag (mousedown on element body) */
2077
+ el.addEventListener('mousedown', function(e) {
2078
+ if (e.target.classList.contains('rz')) return; /* any resize handle */
2079
+ if (e.target.classList.contains('del-btn')) return;
2080
+ if (e.target.classList.contains('et') && e.target.contentEditable === 'true') return;
2081
+ startMove(e, el);
2082
+ });
2083
+
2084
+ /* Click: selection + options are already handled on mousedown
2085
+ (startMove → syncAlignForSelection). Do NOT toggle here — toggling would
2086
+ hide the options on the very same click that showed them (flicker). We only
2087
+ keep the options shown for text-like elements so the click is idempotent. */
2088
+ el.addEventListener('click', function(e) {
2089
+ if (e.target.classList.contains('del-btn')) return;
2090
+ e.stopPropagation();
2091
+ refreshOptionsBar();
2092
+ });
2093
+
2094
+ /* Double-click: enter text edit mode */
2095
+ el.addEventListener('dblclick', function(e) {
2096
+ e.stopPropagation();
2097
+ const span = el.querySelector('.et');
2098
+ if (span) startEdit(span); /* Text edit works regardless of grid state */
2099
+ });
2100
+ }
2101
+
2102
+ /* ══════════════════════
2103
+ RESTORE DELETED + INIT
2104
+ ══════════════════════ */
2105
+ deleted.forEach(function(id) {
2106
+ const el = document.getElementById(id);
2107
+ if (el) el.remove();
2108
+ delete layout[id];
2109
+ });
2110
+
2111
+ /* Restore saved aligns */
2112
+ Object.keys(aligns).forEach(function(id) {
2113
+ if (aligns[id] && aligns[id] !== 'left') applyAlign(id, aligns[id]);
2114
+ });
2115
+ /* Restore saved valigns */
2116
+ Object.keys(valigns).forEach(function(id) {
2117
+ var el = document.getElementById(id);
2118
+ if (el && valigns[id] && VA_MAP[valigns[id]]) el.style.alignItems = VA_MAP[valigns[id]];
2119
+ });
2120
+
2121
+ /* Restore saved sizes */
2122
+ Object.keys(sizes).forEach(function(id) {
2123
+ var el = document.getElementById(id);
2124
+ if (el && sizes[id]) { var span = el.querySelector('.et'); if (span && SIZE_MAP[sizes[id]]) span.style.fontSize = SIZE_MAP[sizes[id]]; }
2125
+ });
2126
+
2127
+ /* Restore saved insets / z-order — deferred so every element is placed first */
2128
+ requestAnimationFrame(function() {
2129
+ Object.keys(pads).forEach(function(id) {
2130
+ var el = document.getElementById(id);
2131
+ if (el && pads[id]) setInset(el, pads[id]);
2132
+ });
2133
+ Object.keys(zIndexes).forEach(function(id) {
2134
+ var el = document.getElementById(id);
2135
+ if (el && typeof zIndexes[id] !== 'undefined') el.style.zIndex = zIndexes[id];
2136
+ });
2137
+ });
2138
+
2139
+ /* Restore saved colours — background then foreground */
2140
+ Object.keys(colors).forEach(function(id) {
2141
+ if (document.getElementById(id) && colors[id]) applyBg(id, colors[id]);
2142
+ });
2143
+ Object.keys(fgColors).forEach(function(id) {
2144
+ if (document.getElementById(id) && fgColors[id]) applyFg(id, fgColors[id]);
2145
+ });
2146
+
2147
+ /* Place all built-in elements */
2148
+ Object.keys(layout).forEach(function(id) {
2149
+ place(id, layout[id]);
2150
+ wireEl(id);
2151
+ });
2152
+
2153
+ /* Fit any saved long text to its content so nothing loads clipped.
2154
+ Deferred so layout is computed and custom-text elements exist too. */
2155
+ requestAnimationFrame(function() {
2156
+ document.querySelectorAll('.hud-card > [data-grid]').forEach(function(el) {
2157
+ if (el.querySelector('.et')) autoFitText(el);
2158
+ });
2159
+ });
2160
+
2161
+ /* ══════════════════════
2162
+ CUSTOM TEXT ELEMENTS
2163
+ ══════════════════════ */
2164
+ var customCounter = 0;
2165
+ customs.forEach(function(c) {
2166
+ const m = c.id && c.id.match(/^custom-(\d+)$/);
2167
+ if (m) customCounter = Math.max(customCounter, parseInt(m[1]) + 1);
2168
+ });
2169
+
2170
+ function saveCustoms() {
2171
+ const out = customs.map(function(c) {
2172
+ const el = document.getElementById(c.id);
2173
+ const span = el && el.querySelector('.et');
2174
+ return { id: c.id, pos: layout[c.id] || c.pos, text: span ? span.textContent : c.text };
2175
+ });
2176
+ save(S_CUSTOM, out);
2177
+ }
2178
+
2179
+ function makeCustom(id, pos, text) {
2180
+ const el = document.createElement('div');
2181
+ el.id = id;
2182
+ el.setAttribute('data-grid', '');
2183
+ el.setAttribute('data-custom', '');
2184
+ el.classList.add('ns-text');
2185
+
2186
+ const delBtn = document.createElement('div');
2187
+ delBtn.className = 'del-btn';
2188
+ delBtn.textContent = '×';
2189
+ el.appendChild(delBtn);
2190
+
2191
+ const span = document.createElement('span');
2192
+ span.className = 'et';
2193
+ span.textContent = text || '';
2194
+ if (!span.id) span.id = 'cet-' + id;
2195
+ el.appendChild(span);
2196
+
2197
+ /* Restore saved text */
2198
+ if (texts[span.id] !== undefined) span.textContent = texts[span.id];
2199
+
2200
+ ['nw','n','ne','e','se','s','sw','w'].forEach(function(d){var h=document.createElement('div');h.className='rz';h.dataset.rz=d;el.appendChild(h);});
2201
+
2202
+ card.insertBefore(el, document.getElementById('ghost'));
2203
+
2204
+ span.addEventListener('blur', function() {
2205
+ commitEdit(span);
2206
+ if (!span.textContent.trim()) {
2207
+ el.remove();
2208
+ customs = customs.filter(function(c){ return c.id !== id; });
2209
+ delete layout[id];
2210
+ save(S_CUSTOM, customs);
2211
+ save(S_LAYOUT, layout);
2212
+ } else {
2213
+ saveCustoms();
2214
+ }
2215
+ });
2216
+ span.addEventListener('keydown', function(e) {
2217
+ if (e.key === 'Enter' || e.key === 'Escape') { e.preventDefault(); span.blur(); }
2218
+ });
2219
+
2220
+ const resolvedPos = layout[id] || pos || { c1:1, r1:1, c2:2, r2:2 };
2221
+ if (!resolvedPos.c1) resolvedPos.c1 = 1;
2222
+ if (!resolvedPos.r1) resolvedPos.r1 = 1;
2223
+ if (!resolvedPos.c2) resolvedPos.c2 = resolvedPos.c1 + 1;
2224
+ if (!resolvedPos.r2) resolvedPos.r2 = resolvedPos.r1 + 1;
2225
+ if (!natural[id]) natural[id] = { w: resolvedPos.c2 - resolvedPos.c1, h: resolvedPos.r2 - resolvedPos.r1 };
2226
+ TEXT_IDS.add(id);
2227
+ place(id, resolvedPos);
2228
+ save(S_LAYOUT, layout); /* persist position immediately */
2229
+ save(S_NATURAL, natural); /* persist intrinsic size → preserves --scale across reload/import */
2230
+ wireEl(id);
2231
+ if (aligns && aligns[id]) applyAlign(id, aligns[id]);
2232
+ if (valigns && valigns[id]) applyVAlign(id, valigns[id]);
2233
+ if (sizes && sizes[id]) applySize(id, sizes[id]);
2234
+ if (pads && pads[id]) applyPad(id, pads[id]);
2235
+ if (zIndexes && typeof zIndexes[id] !== 'undefined') el.style.zIndex = zIndexes[id];
2236
+ if (fgColors && fgColors[id]) applyFg(id, fgColors[id]);
2237
+
2238
+ return { el, span };
2239
+ }
2240
+
2241
+ /* Restore saved custom elements */
2242
+ customs.forEach(function(c) { makeCustom(c.id, c.pos, c.text); });
2243
+
2244
+ /* Double-click detection: native + manual fallback */
2245
+ var lastClickTime = 0;
2246
+ var lastClickX = 0, lastClickY = 0;
2247
+
2248
+ function handleDoubleClick(e) {
2249
+ if (!card.contains(e.target)) return;
2250
+ if (e.target !== card && e.target.closest('[data-grid]')) return;
2251
+ const cell = toCell(e.clientX, e.clientY);
2252
+ const id = 'custom-' + (customCounter++);
2253
+ const pos = { c1:cell.col, r1:cell.row, c2:cell.col+1, r2:cell.row+1 };
2254
+ const { span } = makeCustom(id, pos, '');
2255
+ customs.push({ id, pos, text:'' });
2256
+ saveCustoms();
2257
+ startEdit(span);
2258
+ }
2259
+
2260
+ card.addEventListener('dblclick', handleDoubleClick);
2261
+
2262
+ /* Fallback: detect rapid clicks as double-click */
2263
+ card.addEventListener('click', function(e) {
2264
+ const now = Date.now();
2265
+ const dx = e.clientX - lastClickX;
2266
+ const dy = e.clientY - lastClickY;
2267
+ const dist = Math.sqrt(dx*dx + dy*dy);
2268
+
2269
+ if (now - lastClickTime < 300 && dist < 10) {
2270
+ /* This is a double-click */
2271
+ handleDoubleClick(e);
2272
+ }
2273
+ lastClickTime = now;
2274
+ lastClickX = e.clientX;
2275
+ lastClickY = e.clientY;
2276
+ });
2277
+
2278
+ /* ══════════════════════
2279
+ ICON ELEMENTS
2280
+ ══════════════════════ */
2281
+ var S_ICONS = 'ns-hud-icon-els';
2282
+ var savedIcons = load(S_ICONS, []);
2283
+ var iconElCtr = 0;
2284
+ savedIcons.forEach(function(c) {
2285
+ var m = c.id && c.id.match(/^iel-(\d+)$/);
2286
+ if (m) iconElCtr = Math.max(iconElCtr, parseInt(m[1]) + 1);
2287
+ });
2288
+
2289
+ function makeIconEl(id, iconName, pos) {
2290
+ if (!ICON_SVGS[iconName]) return null;
2291
+ var el = document.createElement('div');
2292
+ el.id = id;
2293
+ el.setAttribute('data-grid', '');
2294
+ el.setAttribute('data-icon-el', iconName);
2295
+
2296
+ var delBtn = document.createElement('div');
2297
+ delBtn.className = 'del-btn';
2298
+ delBtn.textContent = '\u00d7';
2299
+ el.appendChild(delBtn);
2300
+
2301
+ var svgEl = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
2302
+ svgEl.setAttribute('viewBox', '0 0 24 24');
2303
+ svgEl.style.cssText = 'display:block;width:16px;height:16px;image-rendering:pixelated;pointer-events:none;';
2304
+ svgEl.innerHTML = glyphMarkup(iconName);
2305
+ applyIconTilt(svgEl, iconName);
2306
+ el.appendChild(svgEl);
2307
+
2308
+ ['nw','n','ne','e','se','s','sw','w'].forEach(function(d){var h=document.createElement('div');h.className='rz';h.dataset.rz=d;el.appendChild(h);});
2309
+
2310
+ card.insertBefore(el, document.getElementById('ghost'));
2311
+ var resolvedPos = layout[id] || pos || { c1:1, r1:1, c2:3, r2:3 };
2312
+ if (!natural[id]) natural[id] = { w: resolvedPos.c2 - resolvedPos.c1, h: resolvedPos.r2 - resolvedPos.r1 };
2313
+ place(id, resolvedPos);
2314
+ save(S_LAYOUT, layout);
2315
+ save(S_NATURAL, natural);
2316
+ wireEl(id);
2317
+ return el;
2318
+ }
2319
+
2320
+ function saveIconEls() {
2321
+ save(S_ICONS, savedIcons.map(function(c) { return { id: c.id, iconName: c.iconName }; }));
2322
+ }
2323
+
2324
+ /* Restore persisted icon elements */
2325
+ savedIcons.forEach(function(c) {
2326
+ if (c.id && c.iconName && ICON_SVGS[c.iconName]) {
2327
+ makeIconEl(c.id, c.iconName, null);
2328
+ }
2329
+ });
2330
+
2331
+ /* ══════════════════════
2332
+ EMOJI ELEMENTS
2333
+ ══════════════════════ */
2334
+ var S_EMOJIS = 'ns-hud-emoji-els';
2335
+ var savedEmojis = load(S_EMOJIS, []);
2336
+ var emojiElCtr = 0;
2337
+ savedEmojis.forEach(function(c) {
2338
+ var m = c.id && c.id.match(/^eel-(\d+)$/);
2339
+ if (m) emojiElCtr = Math.max(emojiElCtr, parseInt(m[1]) + 1);
2340
+ });
2341
+
2342
+ function makeEmojiEl(id, emoji, pos) {
2343
+ var el = document.createElement('div');
2344
+ el.id = id;
2345
+ el.setAttribute('data-grid', '');
2346
+ el.setAttribute('data-emoji-el', emoji);
2347
+ var delBtn = document.createElement('div');
2348
+ delBtn.className = 'del-btn';
2349
+ delBtn.textContent = '\u00d7';
2350
+ el.appendChild(delBtn);
2351
+ var span = document.createElement('span');
2352
+ span.textContent = emoji;
2353
+ span.style.cssText = 'font-size:calc(var(--hud-row) * 1.4 * var(--scale));line-height:1;pointer-events:none;display:block;user-select:none;';
2354
+ el.appendChild(span);
2355
+ ['nw','n','ne','e','se','s','sw','w'].forEach(function(d){var h=document.createElement('div');h.className='rz';h.dataset.rz=d;el.appendChild(h);});
2356
+ card.insertBefore(el, document.getElementById('ghost'));
2357
+ var resolvedPos = layout[id] || pos || { c1:1, r1:1, c2:3, r2:3 };
2358
+ if (!natural[id]) natural[id] = { w: 2, h: 2 };
2359
+ place(id, resolvedPos);
2360
+ save(S_LAYOUT, layout);
2361
+ save(S_NATURAL, natural);
2362
+ wireEl(id);
2363
+ return el;
2364
+ }
2365
+
2366
+ function saveEmojiEls() {
2367
+ save(S_EMOJIS, savedEmojis.map(function(c){ return { id: c.id, emoji: c.emoji }; }));
2368
+ }
2369
+
2370
+ /* Restore persisted emoji elements */
2371
+ savedEmojis.forEach(function(c) {
2372
+ if (c.id && c.emoji) makeEmojiEl(c.id, c.emoji, null);
2373
+ });
2374
+
2375
+ /* ══════════════════════
2376
+ COMP ELEMENTS
2377
+ ══════════════════════ */
2378
+ var S_COMPS = 'ns-hud-comp-els';
2379
+ var savedComps = load(S_COMPS, []);
2380
+ var compElCtr = 0;
2381
+ savedComps.forEach(function(c) {
2382
+ var m = c.id && c.id.match(/^cel-(\d+)$/);
2383
+ if (m) compElCtr = Math.max(compElCtr, parseInt(m[1]) + 1);
2384
+ });
2385
+
2386
+ function makeCompEl(id, compName, pos) {
2387
+ compName = COMP_ALIASES[compName] || compName;
2388
+ var build = COMP_BUILD[compName];
2389
+ if (!build) return null;
2390
+ var el = document.createElement('div');
2391
+ el.id = id;
2392
+ el.setAttribute('data-grid', '');
2393
+ el.setAttribute('data-comp-el', compName);
2394
+ var delBtn = document.createElement('div');
2395
+ delBtn.className = 'del-btn';
2396
+ delBtn.textContent = '\u00d7';
2397
+ el.appendChild(delBtn);
2398
+ /* Expose resize helper for build functions (e.g. minimap toggle swaps sizes) */
2399
+ el._mmResize = function(p){ place(id, p); save(S_LAYOUT, layout); };
2400
+ build(el);
2401
+ ['nw','n','ne','e','se','s','sw','w'].forEach(function(d){var h=document.createElement('div');h.className='rz';h.dataset.rz=d;el.appendChild(h);});
2402
+ card.insertBefore(el, document.getElementById('ghost'));
2403
+ var def = COMP_DEFAULT_POS[compName] || {c1:1,r1:1,c2:5,r2:5};
2404
+ var resolvedPos = layout[id] || pos || {c1:1,r1:1,c2:1+(def.c2-def.c1),r2:1+(def.r2-def.r1)};
2405
+ if (!natural[id]) natural[id] = {w:resolvedPos.c2-resolvedPos.c1, h:resolvedPos.r2-resolvedPos.r1};
2406
+ place(id, resolvedPos);
2407
+ save(S_LAYOUT, layout);
2408
+ save(S_NATURAL, natural);
2409
+ wireEl(id);
2410
+ if (typeof aligns !== 'undefined' && aligns[id]) applyAlign(id, aligns[id]);
2411
+ if (typeof valigns !== 'undefined' && valigns[id]) applyVAlign(id, valigns[id]);
2412
+ if (typeof sizes !== 'undefined' && sizes[id]) applySize(id, sizes[id]);
2413
+ if (typeof pads !== 'undefined' && pads[id]) applyPad(id, pads[id]);
2414
+ if (typeof zIndexes !== 'undefined' && typeof zIndexes[id] !== 'undefined') el.style.zIndex = zIndexes[id];
2415
+ if (typeof colors !== 'undefined' && colors[id]) applyBg(id, colors[id]);
2416
+ if (typeof fgColors !== 'undefined' && fgColors[id]) applyFg(id, fgColors[id]);
2417
+ return el;
2418
+ }
2419
+
2420
+ function saveCompEls() {
2421
+ save(S_COMPS, savedComps.map(function(c){ return {id:c.id, compName:c.compName}; }));
2422
+ }
2423
+
2424
+ /* Restore persisted component elements */
2425
+ savedComps.forEach(function(c) {
2426
+ if (c.id && c.compName && COMP_BUILD[COMP_ALIASES[c.compName] || c.compName]) {
2427
+ makeCompEl(c.id, c.compName, null);
2428
+ }
2429
+ });
2430
+ /* Restore persisted stacking order across every element type */
2431
+ Object.keys(zorder).forEach(function(id){ var el = document.getElementById(id); if (el) applyStoredZorder(id, el); });
2432
+
2433
+ /* ══════════════════════
2434
+ PALETTE WIRING
2435
+ ══════════════════════ */
2436
+ var palOpen = false;
2437
+ var palGroup = 'weapons';
2438
+ var palEl = document.getElementById('icon-palette');
2439
+ var palIconsEl = document.getElementById('palette-icons');
2440
+ var hudTogBtn = document.getElementById('hud-toggle');
2441
+ var palClsBtn = null;
2442
+ var palTabs = [];
2443
+ (function buildPalTabs(){
2444
+ var tabsEl = document.getElementById('palette-tabs');
2445
+ if (!tabsEl) return;
2446
+ var defs = [{g:'ui',l:'WIDGETS'}]
2447
+ .concat(Object.keys(ICON_GROUPS).map(function(k){ return {g:k, l:(GROUP_LABELS[k] || k.toUpperCase())}; }));
2448
+ defs.forEach(function(d){
2449
+ var b = document.createElement('div');
2450
+ b.className = 'ns-hud-tab';
2451
+ b.dataset.group = d.g;
2452
+ b.setAttribute('data-tip', GROUP_TIPS[d.g] || d.l);
2453
+ /* Prefix glyph: each category leads with its first object's icon */
2454
+ var glyph = document.createElement('span');
2455
+ glyph.className = 'tab-glyph';
2456
+ if (d.g === 'emojis') {
2457
+ glyph.textContent = (EMOJI_LIST[0] || '');
2458
+ } else if (ICON_GROUPS[d.g] && ICON_GROUPS[d.g].length) {
2459
+ var first = ICON_GROUPS[d.g][0];
2460
+ var svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
2461
+ svg.setAttribute('viewBox', '0 0 24 24');
2462
+ svg.innerHTML = glyphMarkup(first);
2463
+ applyIconTilt(svg, first);
2464
+ glyph.appendChild(svg);
2465
+ } else if (COMP_GROUPS[d.g] && COMP_GROUPS[d.g].length) {
2466
+ var c = COMP_GROUPS[d.g][0];
2467
+ var csvg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
2468
+ csvg.setAttribute('viewBox', '0 0 24 24');
2469
+ csvg.innerHTML = tok24(COMP_SVGS[c] || '');
2470
+ glyph.appendChild(csvg);
2471
+ }
2472
+ var label = document.createElement('span');
2473
+ label.className = 'tab-label';
2474
+ label.textContent = d.l;
2475
+ if (glyph.childNodes.length || glyph.textContent) b.appendChild(glyph);
2476
+ b.appendChild(label);
2477
+ tabsEl.appendChild(b);
2478
+ palTabs.push(b);
2479
+ });
2480
+ })();
2481
+
2482
+ function renderPalette(group) {
2483
+ palIconsEl.innerHTML = '';
2484
+ var isComp = !!COMP_GROUPS[group];
2485
+ var isEmoji = !!EMOJI_GROUPS[group];
2486
+ var names = isComp ? COMP_GROUPS[group] : isEmoji ? EMOJI_GROUPS[group] : (ICON_GROUPS[group] || []);
2487
+ names.forEach(function(name) {
2488
+ var btn = document.createElement('div');
2489
+ btn.className = 'ns-hud-slot';
2490
+ btn.setAttribute('data-tip', isComp ? (COMP_LABELS[name] || name)
2491
+ : isEmoji ? name
2492
+ : (name.charAt(0) === '/' ? (EMOTE_TIPS[name] || name)
2493
+ : name.replace(/^attack-/, '').replace(/-/g, ' ').replace(/\b\w/g, function(c){ return c.toUpperCase(); })));
2494
+ if (isEmoji) {
2495
+ btn.style.cssText += 'font-size:13px;line-height:1;';
2496
+ btn.textContent = name;
2497
+ } else {
2498
+ var svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
2499
+ svg.setAttribute('viewBox', '0 0 24 24');
2500
+ svg.innerHTML = isComp ? tok24(COMP_SVGS[name] || '') : glyphMarkup(name);
2501
+ if (!isComp) applyIconTilt(svg, name);
2502
+ btn.appendChild(svg);
2503
+ }
2504
+ btn.addEventListener('mousedown', function(e) {
2505
+ e.preventDefault(); e.stopPropagation();
2506
+ palGhostEl = document.createElement('div');
2507
+ palGhostEl.style.cssText = 'position:fixed;pointer-events:none;z-index:9999;width:22px;height:22px;display:flex;align-items:center;justify-content:center;border:1px dashed rgba(230,219,116,0.55);opacity:0.75;';
2508
+ if (isEmoji) {
2509
+ var eg = document.createElement('span');
2510
+ eg.textContent = name;
2511
+ eg.style.cssText = 'font-size:13px;line-height:1;';
2512
+ palGhostEl.appendChild(eg);
2513
+ } else {
2514
+ var sg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
2515
+ sg.setAttribute('viewBox', '0 0 24 24');
2516
+ sg.style.cssText = 'width:14px;height:14px;image-rendering:pixelated;display:block;';
2517
+ sg.innerHTML = isComp ? tok24(COMP_SVGS[name] || '') : glyphMarkup(name);
2518
+ if (!isComp) applyIconTilt(sg, name);
2519
+ palGhostEl.appendChild(sg);
2520
+ }
2521
+ palGhostEl.style.left = (e.clientX - 11) + 'px';
2522
+ palGhostEl.style.top = (e.clientY - 11) + 'px';
2523
+ document.body.appendChild(palGhostEl);
2524
+ palDrag = { name: name, isComp: isComp, isEmoji: isEmoji, moved: false, sx: e.clientX, sy: e.clientY };
2525
+ });
2526
+ palIconsEl.appendChild(btn);
2527
+ });
2528
+ }
2529
+
2530
+ function setPalGroup(grp) {
2531
+ palGroup = grp;
2532
+ palTabs.forEach(function(b) { b.classList.toggle('is-active', b.dataset.group === grp); });
2533
+ renderPalette(grp);
2534
+ }
2535
+
2536
+ function openPal() {
2537
+ palOpen = true;
2538
+ if (palEl) palEl.classList.add('open');
2539
+ if (hudTogBtn) hudTogBtn.classList.add('is-on');
2540
+ if (palTabs.length > 0) setPalGroup(palTabs[0].dataset.group);
2541
+ }
2542
+ function closePal() {
2543
+ palOpen = false;
2544
+ if (palEl) palEl.classList.remove('open');
2545
+ if (hudTogBtn) hudTogBtn.classList.remove('is-on');
2546
+ }
2547
+
2548
+ if (palClsBtn) palClsBtn.addEventListener('click', function(e) { e.stopPropagation(); closePal(); });
2549
+ if (palTabs.length) setPalGroup('ui'); /* palette always visible in side panel */
2550
+
2551
+ palTabs.forEach(function(b) {
2552
+ b.addEventListener('click', function(e) { e.stopPropagation(); setPalGroup(b.dataset.group); });
2553
+ });
2554
+ if (palEl) palEl.addEventListener('click', function(e) { e.stopPropagation(); });
2555
+
2556
+ var palDrag = null;
2557
+ var palGhostEl = null;
2558
+ var textDrag = null;
2559
+
2560
+ /* TEXT tool drag: click and drag on card to create text */
2561
+ const textToolBtn = document.getElementById('text-tool');
2562
+ if (textToolBtn) {
2563
+ textToolBtn.addEventListener('mousedown', function(e) {
2564
+ e.preventDefault(); e.stopPropagation();
2565
+ textDrag = { active: true, moved: false };
2566
+
2567
+ /* Create ghost preview */
2568
+ palGhostEl = document.createElement('div');
2569
+ palGhostEl.style.cssText = 'position:fixed;pointer-events:none;z-index:9999;width:22px;height:22px;display:flex;align-items:center;justify-content:center;border:1px dashed rgba(230,219,116,0.55);opacity:0.75;color:var(--hud-gold);font-family:var(--hud-font);font-size:6px;line-height:1;';
2570
+ palGhostEl.textContent = 'A';
2571
+ palGhostEl.style.left = (e.clientX - 11) + 'px';
2572
+ palGhostEl.style.top = (e.clientY - 11) + 'px';
2573
+ document.body.appendChild(palGhostEl);
2574
+ });
2575
+ }
2576
+
2577
+ document.addEventListener('mousemove', function(e) {
2578
+ if (!textDrag) return;
2579
+ textDrag.moved = true;
2580
+ palGhostEl.style.left = (e.clientX - 11) + 'px';
2581
+ palGhostEl.style.top = (e.clientY - 11) + 'px';
2582
+ var cr = card.getBoundingClientRect();
2583
+ var over = e.clientX >= cr.left && e.clientX <= cr.right &&
2584
+ e.clientY >= cr.top && e.clientY <= cr.bottom;
2585
+ if (over) {
2586
+ palGhostEl.style.opacity = '0'; /* hide ghost when inside card — grid snap is shown */
2587
+ var cell = toCell(e.clientX, e.clientY);
2588
+ ghost.classList.add('active');
2589
+ ghost.style.gridColumn = cell.col + '/' + Math.min(COLS + 1, cell.col + 1);
2590
+ ghost.style.gridRow = cell.row + '/' + Math.min(ROWS + 1, cell.row + 1);
2591
+ } else {
2592
+ ghost.classList.remove('active');
2593
+ palGhostEl.style.opacity = '0.75';
2594
+ }
2595
+ });
2596
+
2597
+ document.addEventListener('mousemove', function(e) {
2598
+ if (!palDrag) return;
2599
+ /* Only count as a drag once the pointer clears a small threshold — otherwise
2600
+ the sub-pixel jitter of an ordinary click sets moved=true and the element
2601
+ (released over the palette, not the card) would be silently cancelled. */
2602
+ if (Math.abs(e.clientX - palDrag.sx) > 4 || Math.abs(e.clientY - palDrag.sy) > 4) palDrag.moved = true;
2603
+ palGhostEl.style.left = (e.clientX - 11) + 'px';
2604
+ palGhostEl.style.top = (e.clientY - 11) + 'px';
2605
+ var cr = card.getBoundingClientRect();
2606
+ var over = e.clientX >= cr.left && e.clientX <= cr.right &&
2607
+ e.clientY >= cr.top && e.clientY <= cr.bottom;
2608
+ if (over) {
2609
+ palGhostEl.style.opacity = '0'; /* hide ghost when inside card — grid snap is shown */
2610
+ var cell = toCell(e.clientX, e.clientY);
2611
+ var ghostW = palDrag.isComp ? ((COMP_DEFAULT_POS[palDrag.name] || {c1:1,r1:1,c2:5,r2:5}).c2 - (COMP_DEFAULT_POS[palDrag.name] || {c1:1,r1:1,c2:5,r2:5}).c1) : 1;
2612
+ var ghostH = palDrag.isComp ? ((COMP_DEFAULT_POS[palDrag.name] || {c1:1,r1:1,c2:5,r2:5}).r2 - (COMP_DEFAULT_POS[palDrag.name] || {c1:1,r1:1,c2:5,r2:5}).r1) : 1;
2613
+ ghost.classList.add('active');
2614
+ ghost.style.gridColumn = cell.col + '/' + Math.min(COLS + 1, cell.col + ghostW);
2615
+ ghost.style.gridRow = cell.row + '/' + Math.min(ROWS + 1, cell.row + ghostH);
2616
+ } else {
2617
+ palGhostEl.style.opacity = '0.75';
2618
+ ghost.classList.remove('active');
2619
+ }
2620
+ });
2621
+
2622
+ document.addEventListener('mouseup', function(e) {
2623
+ if (!palDrag && !textDrag) return;
2624
+
2625
+ if (textDrag) {
2626
+ /* TEXT tool drop */
2627
+ if (palGhostEl) { palGhostEl.remove(); palGhostEl = null; }
2628
+ ghost.classList.remove('active');
2629
+
2630
+ var cr = card.getBoundingClientRect();
2631
+ var over = e.clientX >= cr.left && e.clientX <= cr.right &&
2632
+ e.clientY >= cr.top && e.clientY <= cr.bottom;
2633
+
2634
+ if (textDrag.moved && over) {
2635
+ /* Dropped on card — create text at cell */
2636
+ var cell = toCell(e.clientX, e.clientY);
2637
+ var id = 'custom-' + (customCounter++);
2638
+ var pos = { c1: cell.col, r1: cell.row, c2: cell.col + 1, r2: cell.row + 1 };
2639
+ var { el: tEl, span } = makeCustom(id, pos, '');
2640
+ customs.push({ id, pos, text: '' });
2641
+ saveCustoms();
2642
+ if (tEl) bumpZ(id, tEl);
2643
+ startEdit(span);
2644
+ }
2645
+ textDrag = null;
2646
+ return;
2647
+ }
2648
+
2649
+ /* Original palette drag handler */
2650
+ if (!palDrag) return;
2651
+ var name = palDrag.name;
2652
+ var isComp = palDrag.isComp;
2653
+ var isEmoji = palDrag.isEmoji;
2654
+ var moved = palDrag.moved;
2655
+ palDrag = null;
2656
+ if (palGhostEl) { palGhostEl.remove(); palGhostEl = null; }
2657
+ ghost.classList.remove('active');
2658
+
2659
+ var cr = card.getBoundingClientRect();
2660
+ var over = e.clientX >= cr.left && e.clientX <= cr.right &&
2661
+ e.clientY >= cr.top && e.clientY <= cr.bottom;
2662
+ var def = isComp ? (COMP_DEFAULT_POS[name] || {c1:1,r1:1,c2:5,r2:5}) : null;
2663
+ var defW = def ? (def.c2 - def.c1) : 1;
2664
+ var defH = def ? (def.r2 - def.r1) : 1;
2665
+ var pos;
2666
+ if (moved && over) {
2667
+ /* Dropped on card — place at snapped cell */
2668
+ var cell = toCell(e.clientX, e.clientY);
2669
+ pos = { c1: cell.col, r1: cell.row,
2670
+ c2: Math.min(COLS + 1, cell.col + defW),
2671
+ r2: Math.min(ROWS + 1, cell.row + defH) };
2672
+ } else if (!moved) {
2673
+ /* Quick click — place at card center */
2674
+ pos = { c1: 4, r1: 4, c2: 4 + defW, r2: 4 + defH };
2675
+ } else {
2676
+ return; /* dragged but dropped outside card — cancel */
2677
+ }
2678
+ var id;
2679
+ if (isComp) {
2680
+ id = 'cel-' + (compElCtr++);
2681
+ savedComps.push({ id: id, compName: name });
2682
+ makeCompEl(id, name, pos);
2683
+ saveCompEls();
2684
+ } else if (isEmoji) {
2685
+ id = 'eel-' + (emojiElCtr++);
2686
+ savedEmojis.push({ id: id, emoji: name });
2687
+ makeEmojiEl(id, name, pos);
2688
+ saveEmojiEls();
2689
+ } else {
2690
+ id = 'iel-' + (iconElCtr++);
2691
+ savedIcons.push({ id: id, iconName: name });
2692
+ makeIconEl(id, name, pos);
2693
+ saveIconEls();
2694
+ }
2695
+ var newEl = document.getElementById(id);
2696
+ if (newEl) { selectOnly(id); bumpZ(id, newEl); recordAdd([id]); }
2697
+ });
2698
+
2699
+ /* ══════════════════════
2700
+ MARQUEE (rubber-band) SELECT + COPY / PASTE
2701
+ ══════════════════════ */
2702
+ function pointInCard(x, y) {
2703
+ var r = card.getBoundingClientRect();
2704
+ return x >= r.left && x <= r.right && y >= r.top && y <= r.bottom;
2705
+ }
2706
+
2707
+ var marquee = null, marqueeBox = null;
2708
+
2709
+ /* Press on empty canvas: start a rubber-band. A plain press (no drag) just
2710
+ deselects; dragging selects every element the rectangle touches. */
2711
+ document.addEventListener('mousedown', function(e) {
2712
+ if (e.button !== 0) return;
2713
+ var t = e.target;
2714
+ if (!t || !t.closest) return;
2715
+ if (t.closest('[data-grid]')) return; /* pressed a placed element */
2716
+ if (t.closest('#align-bar') || t.closest('#color-swatches') || t.closest('#side-panel')) return;
2717
+ if (!pointInCard(e.clientX, e.clientY)) { deselect(); hideAlign(); return; }
2718
+ marquee = { x0: e.clientX, y0: e.clientY, additive: (e.shiftKey || e.metaKey || e.ctrlKey), moved: false };
2719
+ if (!marquee.additive) { deselect(); hideAlign(); }
2720
+ });
2721
+
2722
+ document.addEventListener('mousemove', function(e) {
2723
+ if (!marquee) return;
2724
+ var dx = e.clientX - marquee.x0, dy = e.clientY - marquee.y0;
2725
+ if (!marquee.moved && (Math.abs(dx) + Math.abs(dy)) < 4) return;
2726
+ marquee.moved = true;
2727
+ if (!marqueeBox) {
2728
+ marqueeBox = document.createElement('div');
2729
+ marqueeBox.style.cssText = 'position:fixed;z-index:9998;border:1px solid rgba(230,219,116,0.9);background:rgba(230,219,116,0.12);pointer-events:none;';
2730
+ document.body.appendChild(marqueeBox);
2731
+ }
2732
+ marqueeBox.style.left = Math.min(e.clientX, marquee.x0) + 'px';
2733
+ marqueeBox.style.top = Math.min(e.clientY, marquee.y0) + 'px';
2734
+ marqueeBox.style.width = Math.abs(dx) + 'px';
2735
+ marqueeBox.style.height = Math.abs(dy) + 'px';
2736
+ });
2737
+
2738
+ document.addEventListener('mouseup', function(e) {
2739
+ if (!marquee) return;
2740
+ var m = marquee; marquee = null;
2741
+ if (marqueeBox) { marqueeBox.remove(); marqueeBox = null; }
2742
+ if (!m.moved) return; /* plain click — already deselected */
2743
+ var L = Math.min(e.clientX, m.x0), R = Math.max(e.clientX, m.x0);
2744
+ var T = Math.min(e.clientY, m.y0), B = Math.max(e.clientY, m.y0);
2745
+ if (!m.additive) deselect();
2746
+ document.querySelectorAll('.hud-card > [data-grid]').forEach(function(el) {
2747
+ if (el.id === 'ghost') return;
2748
+ var r = el.getBoundingClientRect();
2749
+ var hit = !(r.right < L || r.left > R || r.bottom < T || r.top > B);
2750
+ if (hit) addSel(el.id);
2751
+ });
2752
+ syncAlignForSelection();
2753
+ });
2754
+
2755
+ /* ── Copy / paste the current selection (in-app clipboard) ── */
2756
+ var clipboard = [];
2757
+
2758
+ function describeEl(id) {
2759
+ var el = document.getElementById(id); if (!el) return null;
2760
+ var pos = layout[id]; if (!pos) return null;
2761
+ /* Capture BOTH the grid span (pos) and the element's intrinsic cell size
2762
+ (nat) so a paste replicates the original 1:1 — same number of cells and
2763
+ same content scale — instead of being re-fit to its content. */
2764
+ var base = { pos: { c1: pos.c1, r1: pos.r1, c2: pos.c2, r2: pos.r2 },
2765
+ nat: natural[id] ? { w: natural[id].w, h: natural[id].h } : undefined };
2766
+ if (el.hasAttribute('data-comp-el')) {
2767
+ var cspan = el.querySelector('.et');
2768
+ return Object.assign(base, { kind: 'comp', name: el.getAttribute('data-comp-el'),
2769
+ bg: colors[id], fg: fgColors[id], size: sizes[id], pad: pads[id], z: zIndexes[id], align: aligns[id], valign: valigns[id],
2770
+ text: cspan ? cspan.textContent : undefined });
2771
+ }
2772
+ if (el.hasAttribute('data-icon-el')) return Object.assign(base, { kind: 'icon', name: el.getAttribute('data-icon-el') });
2773
+ if (el.hasAttribute('data-emoji-el')) return Object.assign(base, { kind: 'emoji', name: el.getAttribute('data-emoji-el') });
2774
+ var span = el.querySelector('.et');
2775
+ if (span) return Object.assign(base, { kind: 'text', text: span.textContent,
2776
+ fg: fgColors[id], size: sizes[id], pad: pads[id], z: zIndexes[id], align: aligns[id], valign: valigns[id] });
2777
+ return null;
2778
+ }
2779
+
2780
+ function copySelection() {
2781
+ if (!selSet.length) return;
2782
+ clipboard = selSet.map(describeEl).filter(Boolean);
2783
+ }
2784
+
2785
+ function clampPos(p) {
2786
+ var w = p.c2 - p.c1, h = p.r2 - p.r1;
2787
+ var c1 = Math.max(1, Math.min(p.c1, COLS + 1 - w));
2788
+ var r1 = Math.max(1, Math.min(p.r1, ROWS + 1 - h));
2789
+ return { c1: c1, r1: r1, c2: c1 + w, r2: r1 + h };
2790
+ }
2791
+
2792
+ function createFromDesc(d, pos) {
2793
+ var id = null;
2794
+ if (d.kind === 'comp' && COMP_BUILD[d.name]) {
2795
+ id = 'cel-' + (compElCtr++); savedComps.push({ id: id, compName: d.name }); makeCompEl(id, d.name, pos); saveCompEls();
2796
+ var nel = document.getElementById(id);
2797
+ if (nel && d.text !== undefined) {
2798
+ var ns = nel.querySelector('.et');
2799
+ if (ns) {
2800
+ ns.textContent = d.text;
2801
+ if (ns.id) { try { var t = JSON.parse(localStorage.getItem('ns-hud-texts') || '{}'); t[ns.id] = d.text; localStorage.setItem('ns-hud-texts', JSON.stringify(t)); } catch(e) {} }
2802
+ }
2803
+ }
2804
+ if (d.bg) { applyBg(id, d.bg); save(S_COLORS, colors); }
2805
+ if (d.fg) { applyFg(id, d.fg); save(S_FGCOLORS, fgColors); }
2806
+ if (d.size) applySize(id, d.size);
2807
+ if (d.pad) applyPad(id, d.pad);
2808
+ if (typeof d.z !== 'undefined') applyZ(id, d.z);
2809
+ if (d.align) applyAlign(id, d.align);
2810
+ if (d.valign) applyVAlign(id, d.valign);
2811
+ } else if (d.kind === 'icon' && ICON_SVGS[d.name]) {
2812
+ id = 'iel-' + (iconElCtr++); savedIcons.push({ id: id, iconName: d.name }); makeIconEl(id, d.name, pos); saveIconEls();
2813
+ } else if (d.kind === 'emoji') {
2814
+ id = 'eel-' + (emojiElCtr++); savedEmojis.push({ id: id, emoji: d.name }); makeEmojiEl(id, d.name, pos); saveEmojiEls();
2815
+ } else if (d.kind === 'text') {
2816
+ id = 'custom-' + (customCounter++);
2817
+ makeCustom(id, pos, d.text || '');
2818
+ customs.push({ id: id, pos: pos, text: d.text || '' });
2819
+ saveCustoms();
2820
+ if (d.fg) { applyFg(id, d.fg); save(S_FGCOLORS, fgColors); }
2821
+ if (d.size) applySize(id, d.size);
2822
+ if (d.pad) applyPad(id, d.pad);
2823
+ if (typeof d.z !== 'undefined') applyZ(id, d.z);
2824
+ if (d.align) applyAlign(id, d.align);
2825
+ if (d.valign) applyVAlign(id, d.valign);
2826
+ }
2827
+ return id;
2828
+ }
2829
+
2830
+ function pasteClipboard() {
2831
+ if (!clipboard.length) return;
2832
+ deselect();
2833
+ var newIds = [], cascade = [];
2834
+ clipboard.forEach(function(d) {
2835
+ var np = clampPos({ c1: d.pos.c1 + 1, r1: d.pos.r1 + 1, c2: d.pos.c2 + 1, r2: d.pos.r2 + 1 });
2836
+ var id = createFromDesc(d, np);
2837
+ if (id) {
2838
+ /* Replicate the source 1:1. createFromDesc may run applySize → autoFitText,
2839
+ which shrinks a text/comp element to hug its content; re-assert the
2840
+ captured intrinsic size + exact grid span as the LAST step so the paste
2841
+ occupies the same cells (and renders at the same scale) as the original. */
2842
+ if (d.nat) natural[id] = { w: d.nat.w, h: d.nat.h };
2843
+ place(id, np);
2844
+ save(S_LAYOUT, layout);
2845
+ save(S_NATURAL, natural);
2846
+ newIds.push(id);
2847
+ cascade.push(Object.assign({}, d, { pos: np }));
2848
+ }
2849
+ });
2850
+ newIds.forEach(addSel);
2851
+ syncAlignForSelection();
2852
+ recordAdd(newIds);
2853
+ clipboard = cascade; /* repeated paste cascades */
2854
+ }
2855
+
2856
+ })();
2857
+
2858
+
2859
+
2860
+ /* ══════════════════════════════════════════
2861
+ TOOLBAR & PALETTE DRAG & RESIZE
2862
+ ══════════════════════════════════════════ */
2863
+ (function() {
2864
+ const toolbarRow = document.getElementById('toolbar-row');
2865
+ const iconPalette = null; // Palette removed from UI
2866
+
2867
+ let draggedEl = null;
2868
+ let resizingEl = null;
2869
+ let offset = { x: 0, y: 0 };
2870
+ let startSize = { w: 0, h: 0 };
2871
+ let startPos = { x: 0, y: 0 };
2872
+
2873
+ function makeDraggable(el, header) {
2874
+ header.style.cursor = 'grab';
2875
+ header.addEventListener('mousedown', function(e) {
2876
+ if (resizingEl) return; // Don't drag while resizing
2877
+ draggedEl = el;
2878
+ const rect = el.getBoundingClientRect();
2879
+ offset.x = e.clientX - rect.left;
2880
+ offset.y = e.clientY - rect.top;
2881
+ el.style.position = 'fixed';
2882
+ el.style.zIndex = '10000';
2883
+ header.style.cursor = 'grabbing';
2884
+ e.preventDefault();
2885
+ });
2886
+ }
2887
+
2888
+ function addResizeHandle(el) {
2889
+ const handle = document.createElement('div');
2890
+ handle.style.cssText = `
2891
+ position: absolute;
2892
+ bottom: 0;
2893
+ right: 0;
2894
+ width: 16px;
2895
+ height: 16px;
2896
+ background: linear-gradient(135deg, transparent 50%, #999 50%);
2897
+ cursor: se-resize;
2898
+ z-index: 10001;
2899
+ `;
2900
+ el.style.position = 'fixed';
2901
+ el.appendChild(handle);
2902
+
2903
+ handle.addEventListener('mousedown', function(e) {
2904
+ resizingEl = el;
2905
+ startSize = { w: el.offsetWidth, h: el.offsetHeight };
2906
+ startPos = { x: e.clientX, y: e.clientY };
2907
+ e.preventDefault();
2908
+ });
2909
+ }
2910
+
2911
+ document.addEventListener('mousemove', function(e) {
2912
+ if (!draggedEl && !resizingEl) return;
2913
+
2914
+ if (draggedEl) {
2915
+ draggedEl.style.left = (e.clientX - offset.x) + 'px';
2916
+ draggedEl.style.top = (e.clientY - offset.y) + 'px';
2917
+ }
2918
+
2919
+ if (resizingEl) {
2920
+ const deltaX = e.clientX - startPos.x;
2921
+ const deltaY = e.clientY - startPos.y;
2922
+ resizingEl.style.width = Math.max(200, startSize.w + deltaX) + 'px';
2923
+ resizingEl.style.height = Math.max(100, startSize.h + deltaY) + 'px';
2924
+ }
2925
+ });
2926
+
2927
+ document.addEventListener('mouseup', function() {
2928
+ if (draggedEl) {
2929
+ const header = draggedEl.querySelector('[data-drag-header]') || draggedEl.firstElementChild;
2930
+ if (header) header.style.cursor = 'grab';
2931
+ draggedEl = null;
2932
+ }
2933
+ resizingEl = null;
2934
+ });
2935
+
2936
+ // Make toolbar draggable by the controls
2937
+ if (toolbarRow) {
2938
+ const hudControls = toolbarRow.querySelector('#hud-controls');
2939
+ if (hudControls) makeDraggable(toolbarRow, hudControls);
2940
+ addResizeHandle(toolbarRow);
2941
+ }
2942
+
2943
+ // Make palette draggable by the header
2944
+ if (iconPalette) {
2945
+ const paletteHead = iconPalette.querySelector('#palette-head');
2946
+ if (paletteHead) makeDraggable(iconPalette, paletteHead);
2947
+ addResizeHandle(iconPalette);
2948
+ }
2949
+ })();
2950
+
2951
+
2952
+
2953
+ (function() {
2954
+ /* Polished tooltip for UI chrome — replaces native title= popups.
2955
+ Anchors below the hovered [data-tip] element, with a caret + fade. */
2956
+ var tip = document.getElementById('ui-tooltip');
2957
+ if (!tip) return;
2958
+ var current = null, hideTimer = null;
2959
+
2960
+ function place(el) {
2961
+ var text = el.getAttribute('data-tip');
2962
+ if (!text) return;
2963
+ clearTimeout(hideTimer);
2964
+ current = el;
2965
+ tip.textContent = text;
2966
+ tip.style.display = 'block';
2967
+ tip.style.left = '0px'; tip.style.top = '0px'; // reset to measure
2968
+ var r = el.getBoundingClientRect();
2969
+ var t = tip.getBoundingClientRect();
2970
+ var pad = 6;
2971
+ var cx = r.left + r.width / 2;
2972
+ var left = cx - t.width / 2;
2973
+ left = Math.max(pad, Math.min(left, window.innerWidth - t.width - pad));
2974
+ /* prefer ABOVE the element; flip below only when there's no room */
2975
+ var above = true;
2976
+ var top = r.top - t.height - 7;
2977
+ if (top < pad) { above = false; top = r.bottom + 7; }
2978
+ tip.classList.toggle('below', !above);
2979
+ tip.style.left = Math.round(left) + 'px';
2980
+ tip.style.top = Math.round(top) + 'px';
2981
+ tip.style.setProperty('--arrow-x', Math.round(cx - left) + 'px');
2982
+ requestAnimationFrame(function() { tip.classList.add('show'); });
2983
+ }
2984
+ function hide() {
2985
+ current = null;
2986
+ tip.classList.remove('show');
2987
+ hideTimer = setTimeout(function() { if (!current) tip.style.display = 'none'; }, 130);
2988
+ }
2989
+ document.addEventListener('mouseover', function(e) {
2990
+ var el = e.target.closest('[data-tip]');
2991
+ if (el && el !== current) place(el);
2992
+ });
2993
+ document.addEventListener('mouseout', function(e) {
2994
+ var el = e.target.closest('[data-tip]');
2995
+ if (el && !e.relatedTarget || (el && !el.contains(e.relatedTarget))) hide();
2996
+ });
2997
+ /* hide on click/scroll so it never lingers */
2998
+ document.addEventListener('click', hide, true);
2999
+ window.addEventListener('scroll', hide, true);
3000
+ })();
3001
+
3002
+
3003
+
3004
+ (function() {
3005
+ var tip = document.getElementById('hud-tooltip');
3006
+ var stage = document.getElementById('stage');
3007
+ var EL_NAMES = {
3008
+ 'el-avatar':'Avatar','el-hp':'HP','el-mp':'MP',
3009
+ 'el-hp-label':'HP Label','el-mp-label':'MP Label','el-lv':'Level',
3010
+ 'el-sword':'Weapon','el-human':'Race','el-neutral':'Element',
3011
+ 'el-medium':'Size','el-bxp':'BXP','el-txp':'TXP',
3012
+ 'el-wxp':'WXP','el-ref':'Ref'
3013
+ };
3014
+ function getName(el) {
3015
+ if (!el) return null;
3016
+ var iconName = el.getAttribute('data-icon-el');
3017
+ if (iconName) return iconName.replace('attack-','').replace(/-/g,' ');
3018
+ var compName = el.getAttribute('data-comp-el');
3019
+ if (compName) return (window.COMP_LABELS && COMP_LABELS[compName]) || compName;
3020
+ if (EL_NAMES[el.id]) return EL_NAMES[el.id];
3021
+ if (el.id && /^custom-/.test(el.id)) return 'Text';
3022
+ if (el.id && /^cel-/.test(el.id)) return 'Component';
3023
+ if (el.id && /^eel-/.test(el.id)) return 'Emoji';
3024
+ return null;
3025
+ }
3026
+ stage.addEventListener('mouseover', function(e) {
3027
+ var el = e.target.closest('[data-grid]');
3028
+ var name = el ? getName(el) : null;
3029
+ if (name) { tip.textContent = name; tip.style.display = 'block'; }
3030
+ else { tip.style.display = 'none'; }
3031
+ });
3032
+ stage.addEventListener('mousemove', function(e) {
3033
+ if (tip.style.display === 'block') {
3034
+ tip.style.left = (e.clientX + 12) + 'px';
3035
+ tip.style.top = (e.clientY - 22) + 'px';
3036
+ }
3037
+ });
3038
+ stage.addEventListener('mouseleave', function() { tip.style.display = 'none'; });
3039
+ })();