@7ots/cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/.env.example +140 -0
  2. package/LICENSE +21 -0
  3. package/README.md +655 -0
  4. package/brand/apple-touch-icon.png +0 -0
  5. package/brand/favicon.svg +4 -0
  6. package/brand/icon-512.png +0 -0
  7. package/brand/mark-mono.svg +3 -0
  8. package/brand/mark.svg +4 -0
  9. package/brand/og-es.png +0 -0
  10. package/brand/og-pt.png +0 -0
  11. package/brand/og.png +0 -0
  12. package/brand/ots.json +11 -0
  13. package/brand/tokens.css +59 -0
  14. package/brand/wordmark.svg +5 -0
  15. package/cli/7ots.mjs +400 -0
  16. package/cli/lib/brain.mjs +143 -0
  17. package/cli/lib/config.mjs +70 -0
  18. package/cli/lib/hooks.mjs +151 -0
  19. package/cli/lib/i18n.mjs +480 -0
  20. package/cli/lib/identity.mjs +51 -0
  21. package/cli/lib/install.mjs +79 -0
  22. package/cli/lib/lines.mjs +73 -0
  23. package/cli/lib/meet.mjs +166 -0
  24. package/cli/lib/open.mjs +19 -0
  25. package/cli/lib/orquesta.mjs +84 -0
  26. package/cli/lib/paths.mjs +49 -0
  27. package/cli/lib/pet-core.mjs +184 -0
  28. package/cli/lib/pet-server.mjs +296 -0
  29. package/cli/lib/prompts.mjs +75 -0
  30. package/cli/lib/terminal.mjs +145 -0
  31. package/cli/lib/ui.mjs +234 -0
  32. package/cli/lib/wizard.mjs +279 -0
  33. package/cli/pet/electron/main.cjs +86 -0
  34. package/cli/pet/pet.html +404 -0
  35. package/dist/7ots.esm.js +627 -0
  36. package/dist/7ots.esm.js.map +7 -0
  37. package/dist/7ots.iife.js +627 -0
  38. package/dist/7ots.iife.js.map +7 -0
  39. package/llms.txt +57 -0
  40. package/package.json +73 -0
  41. package/server/admin.mjs +213 -0
  42. package/server/apuchat-relay.mjs +170 -0
  43. package/server/channels/agent.mjs +136 -0
  44. package/server/channels/apuchat.mjs +364 -0
  45. package/server/channels/apumail.mjs +211 -0
  46. package/server/channels/index.mjs +69 -0
  47. package/server/contact.mjs +151 -0
  48. package/server/demo.mjs +154 -0
  49. package/server/i18n.mjs +54 -0
  50. package/server/identity.mjs +108 -0
  51. package/server/llm.mjs +413 -0
  52. package/server/platform/auth.mjs +290 -0
  53. package/server/platform/crypto.mjs +80 -0
  54. package/server/platform/db.mjs +130 -0
  55. package/server/platform/routes.mjs +388 -0
  56. package/server/platform/runtime.mjs +180 -0
  57. package/server/platform/store.mjs +331 -0
  58. package/server/sdk.mjs +125 -0
  59. package/server/server.mjs +456 -0
  60. package/server/settings.mjs +177 -0
  61. package/server/tts.mjs +186 -0
  62. package/skills/7ots/SKILL.md +59 -0
  63. package/src/actions/ActionRegistry.js +254 -0
  64. package/src/actions/PageTools.js +270 -0
  65. package/src/actions/builtins.js +565 -0
  66. package/src/auth/AuthManager.js +128 -0
  67. package/src/avatar/AvatarStage.js +473 -0
  68. package/src/character/AvatarEditor.js +829 -0
  69. package/src/character/Character.js +1191 -0
  70. package/src/character/motion.js +794 -0
  71. package/src/character/parts.js +559 -0
  72. package/src/context/ContextManager.js +472 -0
  73. package/src/core/AgentBrain.js +341 -0
  74. package/src/core/AgentWidget.js +1059 -0
  75. package/src/core/EventBus.js +61 -0
  76. package/src/core/Proactivity.js +363 -0
  77. package/src/core/storage.js +35 -0
  78. package/src/i18n/index.js +193 -0
  79. package/src/i18n/messages/character.js +543 -0
  80. package/src/i18n/messages/identity.js +141 -0
  81. package/src/i18n/messages/platform.js +114 -0
  82. package/src/i18n/messages/server.js +630 -0
  83. package/src/i18n/messages/widget.js +362 -0
  84. package/src/identity/ContactCard.js +351 -0
  85. package/src/identity/Face.js +148 -0
  86. package/src/identity/random.js +112 -0
  87. package/src/identity/schema.js +262 -0
  88. package/src/index.js +117 -0
  89. package/src/integrations/apuchat.js +212 -0
  90. package/src/integrations/apumail.js +88 -0
  91. package/src/llm/ProxyLLM.js +87 -0
  92. package/src/mcp/McpClient.js +153 -0
  93. package/src/ui/Companion.js +498 -0
  94. package/src/ui/UIManager.js +425 -0
  95. package/src/ui/VirtualPointer.js +746 -0
  96. package/src/ui/markdown.js +94 -0
  97. package/src/voice/VoiceEngine.js +227 -0
@@ -0,0 +1,829 @@
1
+ /**
2
+ * Editor de avatar de 7ots: <ots-avatar-editor> / createAvatarEditor().
3
+ *
4
+ * const ed = createAvatarEditor(div, { value: identity.look, onChange: (look) => … });
5
+ * <ots-avatar-editor></ots-avatar-editor> → el.value = {...}; el.addEventListener('change', e => e.detail)
6
+ *
7
+ * Valor: { kind: 'character'|'realistic'|'image', character: spec, avatar: { url, body }, image }
8
+ *
9
+ * - Personaje 2D: estilos, forma (se deforma arrastrando sus puntos, con simetría), color,
10
+ * acabado, ojos, boca, cara y accesorios (arrastrables). Deshacer/rehacer, aleatorio,
11
+ * exportar SVG/JSON, vista previa hablando/con ánimos/gestos.
12
+ * - Realista 3D: modelo .glb para TalkingHead (rig Mixamo + blendshapes ARKit + visemas Oculus).
13
+ * - Imagen: retrato fijo.
14
+ *
15
+ * Idioma: atributo `lang` (o la propiedad nativa `el.lang`, u `opts.locale` en createAvatarEditor).
16
+ * Cambiarlo vuelve a pintar los textos en caliente conservando el valor, la pestaña y el historial.
17
+ * Sin `lang`, se usa el del ancestro con [lang] / <html lang> / navegador (detectLocale) y, si la
18
+ * página cambia el idioma global con setLocale(), el editor lo sigue.
19
+ */
20
+
21
+ import {
22
+ normalizeCharacter, renderCharacter, createCharacter, applyPreset, randomCharacter, layout, bodyPoints,
23
+ SHAPES, EYES, BROWS, MOUTHS, CHEEKS, PATTERNS, FINISHES, ACCESSORIES, ACCESSORY_GROUPS, PRESETS, BODY_POINTS,
24
+ CHARACTER_MOODS, CHARACTER_GESTURES, CHARACTER_MORPHS, partLabel,
25
+ } from './Character.js';
26
+ import { translator, detectLocale, getLocale, onLocaleChange, formatNumber } from '../i18n/index.js';
27
+
28
+ const VIEW = [-20, -50, 240, 270];
29
+ const AXIS = 100;
30
+ const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]);
31
+
32
+ export const SAMPLE_3D = [
33
+ { label: 'Brunette (TalkingHead, MIT)', url: 'https://cdn.jsdelivr.net/gh/met4citizen/TalkingHead@1.7.0/avatars/brunette.glb', body: 'F' },
34
+ ];
35
+
36
+ const TABS = ['styles', 'shape', 'color', 'eyes', 'mouth', 'face', 'acc'];
37
+ const GESTURES = CHARACTER_GESTURES;
38
+ // Morphs temporales para la vista previa (las formas "shape:<id>" se ven ya en la pestaña de forma).
39
+ const MORPHS = CHARACTER_MORPHS.filter((m) => !m.startsWith('shape'));
40
+ const OUTLINES = ['auto', 'none', 'thin', 'bold'];
41
+ const MAX_ACC = 16;
42
+
43
+ const CSS = `
44
+ :host{all:initial;display:block;font:14px/1.45 system-ui,-apple-system,"Segoe UI",sans-serif;color:var(--ae-fg);
45
+ --ae-bg:#ffffff;--ae-soft:#f4f4fa;--ae-line:#e4e4ee;--ae-fg:#17171f;--ae-muted:#6b6f82;--ae-p:#6d5dfc;--ae-p-soft:#6d5dfc1f;--ae-stage:radial-gradient(circle at 50% 35%,#ffffff,#eceaf7)}
46
+ @media (prefers-color-scheme:dark){:host{--ae-bg:#17171e;--ae-soft:#1f1f28;--ae-line:#2c2c38;--ae-fg:#ececf3;--ae-muted:#9a9db0;--ae-stage:radial-gradient(circle at 50% 35%,#2a2a36,#15151c)}}
47
+ :host([theme=light]){--ae-bg:#ffffff;--ae-soft:#f4f4fa;--ae-line:#e4e4ee;--ae-fg:#17171f;--ae-muted:#6b6f82;--ae-stage:radial-gradient(circle at 50% 35%,#ffffff,#eceaf7)}
48
+ :host([theme=dark]){--ae-bg:#17171e;--ae-soft:#1f1f28;--ae-line:#2c2c38;--ae-fg:#ececf3;--ae-muted:#9a9db0;--ae-stage:radial-gradient(circle at 50% 35%,#2a2a36,#15151c)}
49
+ *{box-sizing:border-box}
50
+ .wrap{background:var(--ae-bg);border:1px solid var(--ae-line);border-radius:16px;overflow:hidden}
51
+ .kinds{display:flex;gap:4px;padding:10px;border-bottom:1px solid var(--ae-line);flex-wrap:wrap}
52
+ .kinds button,.tabs button{font:inherit;border:0;background:transparent;color:var(--ae-muted);padding:7px 12px;border-radius:999px;cursor:pointer}
53
+ .kinds button[aria-pressed=true]{background:var(--ae-p);color:#fff}
54
+ .body{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1.25fr);min-height:460px}
55
+ @media (max-width:760px){.body{grid-template-columns:minmax(0,1fr)}}
56
+ .left{border-right:1px solid var(--ae-line);display:flex;flex-direction:column;min-width:0}
57
+ @media (max-width:760px){.left{border-right:0;border-bottom:1px solid var(--ae-line)}}
58
+ .stage{position:relative;aspect-ratio:240/270;max-height:420px;margin:0 auto;width:100%;background:var(--ae-stage);touch-action:none;user-select:none}
59
+ .stage>div,.stage>svg{position:absolute;inset:0;width:100%;height:100%}
60
+ .ov{overflow:visible}
61
+ .h{cursor:grab;fill:#fff;stroke:var(--ae-p);stroke-width:2.2;outline:none}
62
+ .h:hover,.h:focus-visible{fill:var(--ae-p)}
63
+ .h.axis{stroke-dasharray:3 2}
64
+ .h.sel{fill:var(--ae-p);stroke:#fff}
65
+ .hl{fill:none;stroke:var(--ae-p);stroke-width:1;stroke-dasharray:4 3;opacity:.55;pointer-events:none}
66
+ .bar{display:flex;flex-wrap:wrap;gap:6px;padding:10px;align-items:center;border-top:1px solid var(--ae-line)}
67
+ .bar .sp{flex:1}
68
+ .btn{font:inherit;font-size:13px;border:1px solid var(--ae-line);background:var(--ae-bg);color:var(--ae-fg);padding:6px 10px;border-radius:9px;cursor:pointer;display:inline-flex;gap:6px;align-items:center}
69
+ .btn:hover{border-color:var(--ae-p)}
70
+ .btn:disabled{opacity:.4;cursor:default}
71
+ .btn.p{background:var(--ae-p);border-color:var(--ae-p);color:#fff}
72
+ select.btn{padding:5px 8px}
73
+ .right{display:flex;flex-direction:column;min-width:0}
74
+ .tabs{display:flex;gap:2px;padding:8px 8px 0;overflow-x:auto;border-bottom:1px solid var(--ae-line);scrollbar-width:thin}
75
+ .tabs button{border-radius:9px 9px 0 0;white-space:nowrap;padding:8px 11px}
76
+ .tabs button[aria-selected=true]{color:var(--ae-fg);box-shadow:inset 0 -2px var(--ae-p)}
77
+ .panel{padding:14px;overflow:auto;max-height:560px;flex:1}
78
+ .grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(78px,1fr));gap:8px}
79
+ .opt{font:inherit;font-size:11.5px;color:var(--ae-muted);background:var(--ae-soft);border:2px solid transparent;border-radius:12px;padding:4px 4px 5px;cursor:pointer;display:flex;flex-direction:column;align-items:center;gap:1px;min-width:0}
80
+ .opt svg{width:100%;aspect-ratio:1;display:block}
81
+ .opt span{white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:100%}
82
+ .opt:hover{border-color:var(--ae-line)}
83
+ .opt[aria-pressed=true]{border-color:var(--ae-p);background:var(--ae-p-soft);color:var(--ae-fg)}
84
+ h4{margin:16px 0 8px;font-size:12px;text-transform:uppercase;letter-spacing:.06em;color:var(--ae-muted);font-weight:600}
85
+ h4:first-child{margin-top:0}
86
+ .row{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:10px 14px}
87
+ label.f{display:flex;flex-direction:column;gap:4px;font-size:12.5px;color:var(--ae-muted)}
88
+ label.f input[type=range]{accent-color:var(--ae-p);width:100%}
89
+ label.c{display:flex;align-items:center;gap:8px;font-size:12.5px;color:var(--ae-muted)}
90
+ input[type=color]{width:34px;height:28px;padding:0;border:1px solid var(--ae-line);border-radius:8px;background:none;cursor:pointer}
91
+ input[type=text],input[type=url]{font:inherit;width:100%;padding:7px 9px;border-radius:9px;border:1px solid var(--ae-line);background:var(--ae-soft);color:var(--ae-fg)}
92
+ .chips{display:flex;flex-wrap:wrap;gap:6px}
93
+ .chip{font:inherit;font-size:12.5px;border:1px solid var(--ae-line);background:var(--ae-soft);color:var(--ae-fg);padding:5px 10px;border-radius:999px;cursor:pointer}
94
+ .chip[aria-pressed=true]{border-color:var(--ae-p);background:var(--ae-p-soft)}
95
+ .sw{display:flex;flex-wrap:wrap;gap:6px}
96
+ .sw button{width:24px;height:24px;border-radius:50%;border:2px solid var(--ae-bg);box-shadow:0 0 0 1px var(--ae-line);cursor:pointer}
97
+ .hint{font-size:12.5px;color:var(--ae-muted);margin:8px 0 0}
98
+ .card{border:1px solid var(--ae-line);border-radius:12px;padding:12px;background:var(--ae-soft)}
99
+ .sel-acc{margin-top:12px}
100
+ .hidden{display:none!important}
101
+ .alt{padding:18px;display:grid;gap:14px;max-width:640px}
102
+ .alt img{max-width:160px;border-radius:14px;border:1px solid var(--ae-line)}
103
+ `;
104
+
105
+ const SWATCHES = ['#a0714f', '#f1c27d', '#fcd9b6', '#e0a97e', '#8d5524', '#ef4444', '#f97316', '#facc15', '#84cc16', '#22c55e', '#14b8a6', '#06b6d4', '#3b82f6', '#6366f1', '#a855f7', '#ec4899', '#f8fafc', '#94a3b8', '#1f2937'];
106
+
107
+ /** Valor completo del editor a partir de un `look` de identidad (o parcial). */
108
+ export function normalizeEditorValue(v = {}) {
109
+ const kind = ['character', 'realistic', 'image'].includes(v.kind) ? v.kind : 'character';
110
+ return {
111
+ kind,
112
+ character: normalizeCharacter(v.character || {}),
113
+ avatar: { url: typeof v.avatar?.url === 'string' ? v.avatar.url : '', body: v.avatar?.body === 'M' ? 'M' : 'F' },
114
+ image: typeof v.image === 'string' ? v.image : '',
115
+ };
116
+ }
117
+
118
+ /**
119
+ * @param {HTMLElement} container
120
+ * @param {{ value?: object, onChange?: (v:object)=>void, realistic?: boolean, locale?: string }} [opts]
121
+ * locale: idioma de la interfaz ('es', 'en', 'pt'…); se puede cambiar luego con `el.lang = 'en'`.
122
+ */
123
+ export function createAvatarEditor(container, opts = {}) {
124
+ const el = document.createElement('ots-avatar-editor');
125
+ if (opts.realistic === false) el.setAttribute('no-realistic', '');
126
+ if (opts.locale) el.setAttribute('lang', opts.locale);
127
+ if (opts.value) el.value = opts.value;
128
+ if (opts.onChange) el.addEventListener('change', (e) => opts.onChange(e.detail));
129
+ container.appendChild(el);
130
+ return {
131
+ el,
132
+ get value() { return el.value; },
133
+ set value(v) { el.value = v; },
134
+ destroy: () => el.remove(),
135
+ };
136
+ }
137
+
138
+ const Base = typeof HTMLElement === 'undefined' ? class {} : HTMLElement;
139
+
140
+ export class AvatarEditor extends Base {
141
+ static get observedAttributes() {
142
+ return ['lang'];
143
+ }
144
+
145
+ constructor() {
146
+ super();
147
+ this._v = normalizeEditorValue();
148
+ this._tab = 'styles';
149
+ this._accGroup = 'head';
150
+ this._sel = -1;
151
+ this._sym = true;
152
+ this._past = [];
153
+ this._future = [];
154
+ this._ready = false;
155
+ this._t = translator(detectLocale());
156
+ }
157
+
158
+ /** Idioma en uso (resuelto: 'es', 'en', 'pt'…). Para cambiarlo, el atributo/propiedad `lang`. */
159
+ get locale() {
160
+ return this._t.locale;
161
+ }
162
+
163
+ attributeChangedCallback(name, prev, next) {
164
+ if (name === 'lang' && prev !== next) this._applyLocale();
165
+ }
166
+
167
+ /** lang propio → idioma global si la página lo cambió → ancestro [lang] / <html lang> / navegador. */
168
+ _resolveLocale() {
169
+ const own = this.getAttribute('lang');
170
+ if (own) return detectLocale(own);
171
+ if (this._followGlobal) return getLocale();
172
+ return detectLocale(this.parentElement?.closest?.('[lang]')?.getAttribute('lang'));
173
+ }
174
+
175
+ _applyLocale() {
176
+ this._t = translator(this._resolveLocale());
177
+ if (!this._ready) return;
178
+ this._renderChrome();
179
+ this._renderAll();
180
+ }
181
+
182
+ get value() { return structuredClone(this._v); }
183
+ set value(v) {
184
+ this._v = normalizeEditorValue(v);
185
+ this._past = [];
186
+ this._future = [];
187
+ this._sel = -1;
188
+ if (this._ready) this._renderAll();
189
+ }
190
+
191
+ connectedCallback() {
192
+ // Idioma global: si la página llama a setLocale() (y no hay lang propio), el editor lo sigue.
193
+ this._offLocale?.();
194
+ this._g0 = getLocale();
195
+ this._offLocale = onLocaleChange((g) => {
196
+ if (g !== this._g0) {
197
+ this._g0 = g;
198
+ this._followGlobal = true;
199
+ }
200
+ if (this._localeQueued) return; // addMessages emite una vez por idioma: agrupar
201
+ this._localeQueued = true;
202
+ queueMicrotask(() => {
203
+ this._localeQueued = false;
204
+ this._applyLocale();
205
+ });
206
+ });
207
+ if (this._ready) return;
208
+ this._ready = true;
209
+ this._t = translator(this._resolveLocale());
210
+ const root = this.attachShadow({ mode: 'open' });
211
+ root.innerHTML = `<style>${CSS}</style>
212
+ <div class="wrap">
213
+ <div class="kinds" role="group"></div>
214
+ <div class="body" data-view="character">
215
+ <div class="left">
216
+ <div class="stage">
217
+ <div class="ch"></div>
218
+ <svg class="ov" viewBox="${VIEW.join(' ')}"></svg>
219
+ </div>
220
+ <div class="bar" data-bar="play"></div>
221
+ <div class="bar" data-bar="export"></div>
222
+ </div>
223
+ <div class="right">
224
+ <div class="tabs" role="tablist"></div>
225
+ <div class="panel" role="tabpanel"></div>
226
+ </div>
227
+ </div>
228
+ <div class="alt hidden" data-view="realistic"></div>
229
+ <div class="alt hidden" data-view="image"></div>
230
+ </div>`;
231
+ this.$ = (s) => root.querySelector(s);
232
+ this.$$ = (s) => [...root.querySelectorAll(s)];
233
+ this._ch = createCharacter(this.$('.ch'), this._v.character, { fit: VIEW.join(' '), title: this._t('editor.preview') });
234
+ this._ov = this.$('.ov');
235
+ this._renderChrome();
236
+
237
+ root.addEventListener('click', (e) => this._onClick(e));
238
+ root.addEventListener('input', (e) => this._onInput(e, false));
239
+ root.addEventListener('change', (e) => this._onInput(e, true));
240
+ this._ov.addEventListener('pointerdown', (e) => this._onDown(e));
241
+ this._ov.addEventListener('keydown', (e) => this._onHandleKey(e));
242
+ this._ov.addEventListener('wheel', (e) => this._onWheel(e), { passive: false });
243
+ this.$('.stage').addEventListener('pointermove', (e) => { if (!this._drag) this._ch.lookAtPoint(e.clientX, e.clientY); });
244
+ this.$('.stage').addEventListener('pointerleave', () => this._ch.lookAt(0, 0));
245
+ this.addEventListener('keydown', (e) => {
246
+ if (!(e.ctrlKey || e.metaKey) || e.key.toLowerCase() !== 'z') return;
247
+ const t = e.composedPath()[0];
248
+ if (t?.tagName === 'INPUT' && t.type === 'text') return;
249
+ e.preventDefault();
250
+ e.shiftKey ? this._redo() : this._undo();
251
+ });
252
+ this._renderAll();
253
+ }
254
+
255
+ disconnectedCallback() {
256
+ this._speech?.stop?.();
257
+ this._offLocale?.();
258
+ this._offLocale = null;
259
+ }
260
+
261
+ // ───────────── estado ─────────────
262
+
263
+ get _c() { return this._v.character; }
264
+
265
+ /** Cambia el personaje. commit=false durante un arrastre o un slider (sin historial). */
266
+ _set(mut, { commit = true, panel = true } = {}) {
267
+ if (!this._pending) this._pending = structuredClone(this._v);
268
+ const next = structuredClone(this._c);
269
+ mut(next);
270
+ this._v.character = normalizeCharacter(next);
271
+ this._ch.update(this._v.character);
272
+ this._drawOverlay();
273
+ if (commit) this._commit(panel);
274
+ }
275
+
276
+ _commit(panel = true) {
277
+ if (this._pending) {
278
+ this._past.push(this._pending);
279
+ if (this._past.length > 100) this._past.shift();
280
+ this._future = [];
281
+ this._pending = null;
282
+ }
283
+ if (panel) this._renderPanel();
284
+ this._syncBar();
285
+ this.dispatchEvent(new CustomEvent('change', { detail: this.value, bubbles: true, composed: true }));
286
+ }
287
+
288
+ _setMeta(mut) {
289
+ this._pending = structuredClone(this._v);
290
+ mut(this._v);
291
+ this._commit(false);
292
+ this._renderKind();
293
+ }
294
+
295
+ _undo() {
296
+ if (!this._past.length) return;
297
+ this._future.push(structuredClone(this._v));
298
+ this._v = this._past.pop();
299
+ this._afterHistory();
300
+ }
301
+ _redo() {
302
+ if (!this._future.length) return;
303
+ this._past.push(structuredClone(this._v));
304
+ this._v = this._future.pop();
305
+ this._afterHistory();
306
+ }
307
+ _afterHistory() {
308
+ this._sel = Math.min(this._sel, this._c.accessories.length - 1);
309
+ this._renderAll();
310
+ this.dispatchEvent(new CustomEvent('change', { detail: this.value, bubbles: true, composed: true }));
311
+ }
312
+
313
+ // ───────────── render ─────────────
314
+
315
+ _renderAll() {
316
+ this._ch.update(this._c);
317
+ this._renderKind();
318
+ this._renderPanel();
319
+ this._drawOverlay();
320
+ this._syncBar();
321
+ }
322
+
323
+ /** Textos fijos (tipos, barras, pestañas) en el idioma actual. */
324
+ _renderChrome() {
325
+ const t = this._t;
326
+ const kinds = this.$('.kinds');
327
+ kinds.setAttribute('aria-label', t('editor.kinds.label'));
328
+ kinds.innerHTML = ['character', ...(this.hasAttribute('no-realistic') ? [] : ['realistic']), 'image']
329
+ .map((k) => `<button type="button" data-kind="${k}">${esc(t(`editor.kinds.${k}`))}</button>`)
330
+ .join('');
331
+ this._ov.setAttribute('aria-label', t('editor.controls'));
332
+ this._ch.svg.setAttribute('aria-label', t('editor.preview'));
333
+ const mood = this.$('[data-a=mood]')?.value || 'neutral';
334
+ this.$('[data-bar=play]').innerHTML = `
335
+ <button class="btn" type="button" data-a="undo" title="${esc(t('editor.undoTitle', { keys: 'Ctrl+Z' }))}" aria-label="${esc(t('editor.undo'))}">↶</button>
336
+ <button class="btn" type="button" data-a="redo" title="${esc(t('editor.redoTitle', { keys: 'Ctrl+Shift+Z' }))}" aria-label="${esc(t('editor.redo'))}">↷</button>
337
+ <button class="btn" type="button" data-a="random" title="${esc(t('editor.randomTitle'))}">🎲 ${esc(t('editor.random'))}</button>
338
+ <span class="sp"></span>
339
+ <button class="btn" type="button" data-a="talk">🗣 ${esc(t('editor.talk'))}</button>
340
+ <select class="btn" data-a="mood" aria-label="${esc(t('editor.mood'))}">${CHARACTER_MOODS.map((m) => `<option value="${m}">${esc(partLabel(t, 'moods', m))}</option>`).join('')}</select>
341
+ <select class="btn" data-a="gesture" aria-label="${esc(t('editor.gesture'))}"><option value="">${esc(t('editor.gesturePick'))}</option>${GESTURES.map((g) => `<option value="${g}">${esc(partLabel(t, 'gestures', g))}</option>`).join('')}</select>
342
+ <select class="btn" data-a="morph" aria-label="${esc(t('editor.morph'))}"><option value="">${esc(t('editor.morphPick'))}</option>${MORPHS.map((m) => `<option value="${m}">${esc(partLabel(t, 'morphs', m))}</option>`).join('')}</select>`;
343
+ this.$('[data-a=mood]').value = mood;
344
+ this.$('[data-bar=export]').innerHTML = `
345
+ <button class="btn" type="button" data-a="svg">⬇ SVG</button>
346
+ <button class="btn" type="button" data-a="png">⬇ PNG</button>
347
+ <button class="btn" type="button" data-a="json">⧉ ${esc(t('editor.copyJson'))}</button>
348
+ <span class="sp"></span>
349
+ <span class="hint" data-msg aria-live="polite"></span>`;
350
+ this.$('.tabs').innerHTML = TABS.map((id) => `<button type="button" role="tab" data-tab="${id}">${esc(t(`editor.tabs.${id}`))}</button>`).join('');
351
+ }
352
+
353
+ /** Texto del catálogo escapado, con variables que ya son HTML (p. ej. `{name}` → <b>…</b>). */
354
+ _html(key, html = {}) {
355
+ return esc(this._t(key)).replace(/\{(\w+)\}/g, (m, k) => html[k] ?? m);
356
+ }
357
+
358
+ _syncBar() {
359
+ this.$('[data-a=undo]').disabled = !this._past.length;
360
+ this.$('[data-a=redo]').disabled = !this._future.length;
361
+ }
362
+
363
+ _renderKind() {
364
+ const k = this._v.kind;
365
+ this.$$('[data-kind]').forEach((b) => b.setAttribute('aria-pressed', String(b.dataset.kind === k)));
366
+ this.$$('[data-view]').forEach((v) => v.classList.toggle('hidden', v.dataset.view !== k));
367
+ if (k === 'realistic') this._renderRealistic();
368
+ if (k === 'image') this._renderImage();
369
+ }
370
+
371
+ _renderRealistic() {
372
+ const a = this._v.avatar;
373
+ const t = this._t;
374
+ this.$('[data-view=realistic]').innerHTML = `
375
+ <div><h4>${esc(t('editor.realistic.title'))}</h4>
376
+ <p class="hint" style="margin:0">${esc(t('editor.realistic.intro'))}</p></div>
377
+ <label class="f">${esc(t('editor.realistic.url'))}<input type="url" data-m="avatar.url" placeholder="https://…/avatar.glb" value="${esc(a.url)}"></label>
378
+ <div><h4>${esc(t('editor.realistic.body'))}</h4><div class="chips">
379
+ <button type="button" class="chip" data-m-body="F" aria-pressed="${a.body === 'F'}">${esc(t('editor.realistic.bodyF'))}</button>
380
+ <button type="button" class="chip" data-m-body="M" aria-pressed="${a.body === 'M'}">${esc(t('editor.realistic.bodyM'))}</button></div></div>
381
+ <div><h4>${esc(t('editor.realistic.samples'))}</h4><div class="chips">${SAMPLE_3D.map((s, i) => `<button type="button" class="chip" data-sample="${i}" aria-pressed="${a.url === s.url}">${esc(s.label)}</button>`).join('')}</div></div>
382
+ <div class="card"><strong>${esc(t('editor.realistic.ownTitle'))}</strong>
383
+ <p class="hint">· ${this._html('editor.realistic.ownMpfb', { name: '<b>MPFB2 / MakeHuman</b>' })}<br>
384
+ · ${this._html('editor.realistic.ownVroid', { name: '<b>VRoid Studio</b>' })}<br>
385
+ · ${esc(t('editor.realistic.ownCommercial'))}</p>
386
+ <p class="hint">${esc(t('editor.realistic.fallback'))}</p></div>`;
387
+ }
388
+
389
+ _renderImage() {
390
+ const img = this._v.image;
391
+ const t = this._t;
392
+ this.$('[data-view=image]').innerHTML = `
393
+ <div><h4>${esc(t('editor.image.title'))}</h4><p class="hint" style="margin:0">${esc(t('editor.image.intro'))}</p></div>
394
+ <label class="f">${esc(t('editor.image.url'))}<input type="url" data-m="image" placeholder="${esc(t('editor.image.placeholder'))}" value="${esc(img)}"></label>
395
+ ${/^https?:\/\//.test(img) ? `<img src="${esc(img)}" alt="${esc(t('editor.image.alt'))}">` : ''}`;
396
+ }
397
+
398
+ _thumbView(kind) {
399
+ if (kind === 'face') {
400
+ const L = layout(this._c);
401
+ const s = Math.max(L.box.w * 0.82, (L.mouthY - L.eyeY) * 2.4);
402
+ return `${L.cx - s / 2} ${(L.eyeY + L.mouthY) / 2 - s / 2 - L.r * 0.4} ${s} ${s}`;
403
+ }
404
+ return '-25 -55 250 260';
405
+ }
406
+
407
+ /** Rejilla de miniaturas; `kind` es el tipo de pieza del catálogo de textos (ver partLabel). */
408
+ _grid(kind, items, current, attr, mk, view, pressed = (it) => it.id === current) {
409
+ return `<div class="grid">${items
410
+ .map((it) => {
411
+ const label = esc(partLabel(this._t, kind, it.id));
412
+ return `<button type="button" class="opt" ${attr}="${it.id}" aria-pressed="${pressed(it)}" title="${label}">${renderCharacter(mk(it), { open: 0 }, { viewBox: view })}<span>${label}</span></button>`;
413
+ })
414
+ .join('')}</div>`;
415
+ }
416
+
417
+ _range(path, key, min, max, step, value) {
418
+ return `<label class="f">${esc(this._t(key))}<input type="range" data-k="${path}" min="${min}" max="${max}" step="${step}" value="${value}"></label>`;
419
+ }
420
+ _color(path, key, value) {
421
+ return `<label class="c"><input type="color" data-k="${path}" value="${value || '#000000'}">${esc(this._t(key))}</label>`;
422
+ }
423
+
424
+ _renderPanel() {
425
+ this.$$('[data-tab]').forEach((b) => b.setAttribute('aria-selected', String(b.dataset.tab === this._tab)));
426
+ const c = this._c;
427
+ const t = this._t;
428
+ const T = (key, vars) => esc(t(key, vars));
429
+ const with_ = (patch) => ({ ...c, ...patch });
430
+ const face = this._thumbView('face');
431
+ const full = this._thumbView('full');
432
+ let h = '';
433
+ switch (this._tab) {
434
+ case 'styles':
435
+ h = `<h4>${T('editor.styles.title')}</h4>${this._grid('presets', PRESETS, c.preset, 'data-preset', (p) => ({ preset: p.id }), full)}
436
+ <p class="hint">${T('editor.styles.hint')}</p>
437
+ <label class="c" style="margin-top:8px"><input type="checkbox" data-keep-body ${this._keepBody ? 'checked' : ''}> ${T('editor.styles.keepBody')}</label>`;
438
+ break;
439
+ case 'shape':
440
+ h = `<h4>${T('editor.shape.title')}</h4>${this._grid('shapes', SHAPES, c.body.points ? '' : c.body.shape, 'data-shape', (s) => with_({ body: { ...c.body, shape: s.id, points: null } }), full)}
441
+ <h4>${T('editor.shape.deform')}</h4>
442
+ <p class="hint" style="margin-top:0">${T('editor.shape.hint')}</p>
443
+ <div class="chips" style="margin-top:8px">
444
+ <button type="button" class="chip" data-a="sym" aria-pressed="${this._sym}">↔ ${T('editor.shape.symmetry')}</button>
445
+ <button type="button" class="chip" data-a="reshape" ${c.body.points ? '' : 'disabled'}>${T('editor.shape.reset')}</button>
446
+ <button type="button" class="chip" data-a="squash">${T('editor.shape.wider')}</button>
447
+ <button type="button" class="chip" data-a="stretch">${T('editor.shape.taller')}</button>
448
+ </div>`;
449
+ break;
450
+ case 'color':
451
+ h = `<h4>${T('editor.color.body')}</h4><div class="sw">${SWATCHES.map((s) => `<button type="button" data-swatch="${s}" style="background:${s}" title="${s}" aria-label="${T('editor.color.swatch', { color: s })}"></button>`).join('')}</div>
452
+ <div class="row" style="margin-top:10px">${this._color('body.color', 'editor.color.custom', c.body.color)}</div>
453
+ <h4>${T('editor.color.finish')}</h4>${this._grid('finishes', FINISHES, c.finish, 'data-finish', (f) => with_({ finish: f.id }), full)}
454
+ <h4>${T('editor.color.outline')}</h4><div class="chips">${OUTLINES.map((v) => `<button type="button" class="chip" data-outline="${v}" aria-pressed="${c.outline === v}">${T(`editor.color.outlines.${v}`)}</button>`).join('')}</div>
455
+ <h4>${T('editor.color.pattern')}</h4>${this._grid('patterns', PATTERNS, c.body.pattern, 'data-pattern', (p) => with_({ body: { ...c.body, pattern: p.id } }), full)}
456
+ <div class="row" style="margin-top:10px">${this._color('body.patternColor', 'editor.color.patternColor', c.body.patternColor)}</div>`;
457
+ break;
458
+ case 'eyes':
459
+ h = `<h4>${T('editor.eyes.title', { count: EYES.length })}</h4>${this._grid('eyes', EYES, c.eyes.type, 'data-eyes', (e) => with_({ eyes: { ...c.eyes, type: e.id } }), face)}
460
+ <h4>${T('editor.settings')}</h4><div class="row">
461
+ ${this._range('eyes.size', 'editor.size', 0.4, 2.2, 0.05, c.eyes.size)}
462
+ ${this._range('eyes.spacing', 'editor.spacing', 0.3, 2, 0.05, c.eyes.spacing)}
463
+ ${this._range('eyes.y', 'editor.height', -50, 50, 1, c.eyes.y)}
464
+ </div><div class="row" style="margin-top:10px">
465
+ ${this._color('eyes.iris', 'editor.eyes.iris', c.eyes.iris)}${this._color('eyes.white', 'editor.eyes.white', c.eyes.white)}${this._color('eyes.ink', 'editor.eyes.pupil', c.eyes.ink)}
466
+ </div><p class="hint">${T('editor.eyes.hint')}</p>`;
467
+ break;
468
+ case 'mouth':
469
+ h = `<h4>${T('editor.mouth.title', { count: MOUTHS.length })}</h4>${this._grid('mouths', MOUTHS, c.mouth.type, 'data-mouth', (m) => with_({ mouth: { ...c.mouth, type: m.id } }), face)}
470
+ <h4>${T('editor.settings')}</h4><div class="row">
471
+ ${this._range('mouth.size', 'editor.size', 0.4, 2.2, 0.05, c.mouth.size)}
472
+ ${this._range('mouth.y', 'editor.height', -50, 50, 1, c.mouth.y)}
473
+ </div><div class="row" style="margin-top:10px">
474
+ ${this._color('mouth.color', 'editor.mouth.stroke', c.mouth.color || '#5b3b25')}${this._color('mouth.lipColor', 'editor.mouth.lipstick', c.mouth.lipColor)}
475
+ </div><p class="hint">${T('editor.mouth.hint', { talk: t('editor.talk') })}</p>`;
476
+ break;
477
+ case 'face':
478
+ h = `<h4>${T('editor.face.brows')}</h4>${this._grid('brows', BROWS, c.brows.type, 'data-brows', (b) => with_({ brows: { ...c.brows, type: b.id } }), face)}
479
+ <div class="row" style="margin-top:10px">${this._color('brows.color', 'editor.face.browColor', c.brows.color || '#3f2a1d')}</div>
480
+ <h4>${T('editor.face.cheeks')}</h4>${this._grid('cheeks', CHEEKS, c.cheeks.type, 'data-cheeks', (x) => with_({ cheeks: { ...c.cheeks, type: x.id } }), face)}
481
+ <div class="row" style="margin-top:10px">${this._color('cheeks.color', 'editor.colorLabel', c.cheeks.color)}</div>`;
482
+ break;
483
+ case 'acc': {
484
+ const on = new Set(c.accessories.map((a) => a.id));
485
+ const list = ACCESSORIES.filter((a) => a.group === this._accGroup);
486
+ const bare = { ...c, accessories: [] };
487
+ h = `<div class="chips">${ACCESSORY_GROUPS.map(([g]) => `<button type="button" class="chip" data-accgroup="${g}" aria-pressed="${g === this._accGroup}">${esc(partLabel(t, 'groups', g))}</button>`).join('')}</div>
488
+ <h4>${T('editor.acc.group', { group: partLabel(t, 'groups', this._accGroup) })}</h4>
489
+ ${this._grid('accessories', list, '', 'data-acc', (a) => ({ ...bare, accessories: [{ id: a.id }] }), full, (a) => on.has(a.id))}
490
+ ${this._accEditor()}`;
491
+ break;
492
+ }
493
+ }
494
+ this.$('.panel').innerHTML = h;
495
+ }
496
+
497
+ _accEditor() {
498
+ const list = this._c.accessories;
499
+ const T = (key, vars) => esc(this._t(key, vars));
500
+ if (!list.length) return `<p class="hint">${T('editor.acc.empty')}</p>`;
501
+ const a = list[this._sel];
502
+ return `<h4>${T('editor.acc.worn', { count: list.length })}</h4><div class="chips">${list
503
+ .map((x, i) => `<button type="button" class="chip" data-selacc="${i}" aria-pressed="${i === this._sel}">${esc(partLabel(this._t, 'accessories', x.id))}</button>`)
504
+ .join('')}</div>
505
+ ${a ? `<div class="card sel-acc"><div class="row">
506
+ ${this._range(`acc.${this._sel}.scale`, 'editor.size', 0.2, 3, 0.05, a.scale)}
507
+ ${this._range(`acc.${this._sel}.rot`, 'editor.acc.rotation', -180, 180, 1, a.rot)}
508
+ ${this._range(`acc.${this._sel}.x`, 'editor.acc.posX', -120, 120, 1, a.x)}
509
+ ${this._range(`acc.${this._sel}.y`, 'editor.acc.posY', -120, 120, 1, a.y)}
510
+ </div><div class="row" style="margin-top:10px">
511
+ ${this._color(`acc.${this._sel}.color`, 'editor.colorLabel', a.color)}${this._color(`acc.${this._sel}.color2`, 'editor.acc.detail', a.color2)}
512
+ </div><div class="chips" style="margin-top:10px">
513
+ <button type="button" class="chip" data-a="accflip">⇋ ${T('editor.acc.flip')}</button>
514
+ <button type="button" class="chip" data-a="accup">${T('editor.acc.front')}</button>
515
+ <button type="button" class="chip" data-a="accdown">${T('editor.acc.back')}</button>
516
+ <button type="button" class="chip" data-a="accreset">${T('editor.acc.reset')}</button>
517
+ <button type="button" class="chip" data-a="accdel">✕ ${T('editor.acc.remove')}</button>
518
+ </div><p class="hint">${T('editor.acc.hint')}</p></div>` : `<p class="hint">${T('editor.acc.select')}</p>`}`;
519
+ }
520
+
521
+ // ───────────── overlay (puntos arrastrables) ─────────────
522
+
523
+ _drawOverlay() {
524
+ const c = this._c;
525
+ const t = this._t;
526
+ const L = layout(c);
527
+ let h = '';
528
+ if (this._v.kind === 'character') {
529
+ if (this._tab === 'shape') {
530
+ const pts = bodyPoints(c);
531
+ h += `<path class="hl" d="M${pts.map((p) => p.join(' ')).join('L')}Z"/>`;
532
+ h += pts
533
+ .map((p, i) => `<circle class="h${this._sym && (i === 0 || i === BODY_POINTS / 2) ? ' axis' : ''}" tabindex="0" role="slider" aria-label="${esc(t('editor.shape.point', { n: formatNumber(i + 1, t.locale) }))}" data-pt="${i}" cx="${p[0]}" cy="${p[1]}" r="5.5"/>`)
534
+ .join('');
535
+ }
536
+ if (this._tab === 'eyes') h += `<circle class="h" tabindex="0" aria-label="${esc(t('editor.eyes.drag'))}" data-face="eyes" cx="${L.cx + L.sp}" cy="${L.eyeY}" r="${Math.max(6, L.r * 0.5)}"/>`;
537
+ if (this._tab === 'mouth') h += `<circle class="h" tabindex="0" aria-label="${esc(t('editor.mouth.drag'))}" data-face="mouth" cx="${L.cx}" cy="${L.mouthY}" r="6"/>`;
538
+ if (this._tab === 'acc') {
539
+ c.accessories.forEach((a, i) => {
540
+ const part = ACCESSORIES.find((x) => x.id === a.id);
541
+ const at = L.anchors[part.anchor] || L.anchors.center;
542
+ h += `<circle class="h${i === this._sel ? ' sel' : ''}" tabindex="0" aria-label="${esc(t('editor.acc.drag', { name: partLabel(t, 'accessories', part.id) }))}" data-acch="${i}" cx="${at.x + a.x}" cy="${at.y + a.y}" r="6"/>`;
543
+ });
544
+ }
545
+ }
546
+ this._ov.innerHTML = h;
547
+ if (this._focusSel) {
548
+ this._ov.querySelector(this._focusSel)?.focus();
549
+ this._focusSel = null;
550
+ }
551
+ }
552
+
553
+ _svgPoint(e) {
554
+ const m = this._ov.getScreenCTM();
555
+ if (!m) return null;
556
+ const p = new DOMPoint(e.clientX, e.clientY).matrixTransform(m.inverse());
557
+ return [p.x, p.y];
558
+ }
559
+
560
+ _onDown(e) {
561
+ const t = e.target;
562
+ if (!(t instanceof SVGElement) || !t.classList.contains('h')) return;
563
+ e.preventDefault();
564
+ const start = this._svgPoint(e);
565
+ if (!start) return;
566
+ const c0 = structuredClone(this._c);
567
+ let kind;
568
+ if (t.dataset.pt) kind = ['pt', +t.dataset.pt];
569
+ else if (t.dataset.face) kind = ['face', t.dataset.face];
570
+ else if (t.dataset.acch) {
571
+ kind = ['acc', +t.dataset.acch];
572
+ if (this._sel !== kind[1]) {
573
+ this._sel = kind[1];
574
+ this._renderPanel();
575
+ }
576
+ } else return;
577
+ this._drag = true;
578
+ this._ov.setPointerCapture(e.pointerId);
579
+ let raf = 0;
580
+ let last = start;
581
+ const move = (ev) => {
582
+ last = this._svgPoint(ev) || last;
583
+ if (!raf) raf = requestAnimationFrame(() => { raf = 0; apply(false); });
584
+ };
585
+ const apply = (commit) => {
586
+ const dx = last[0] - start[0];
587
+ const dy = last[1] - start[1];
588
+ this._set((n) => this._dragMut(n, c0, kind, dx, dy, last), { commit, panel: commit });
589
+ if (!commit) this._drawOverlay();
590
+ };
591
+ const up = () => {
592
+ cancelAnimationFrame(raf);
593
+ this._ov.removeEventListener('pointermove', move);
594
+ this._ov.removeEventListener('pointerup', up);
595
+ this._ov.removeEventListener('pointercancel', up);
596
+ this._drag = false;
597
+ apply(true);
598
+ };
599
+ this._ov.addEventListener('pointermove', move);
600
+ this._ov.addEventListener('pointerup', up);
601
+ this._ov.addEventListener('pointercancel', up);
602
+ }
603
+
604
+ _dragMut(n, c0, [type, id], dx, dy, at) {
605
+ if (type === 'pt') {
606
+ const pts = structuredClone(c0.body.points || bodyPoints(c0));
607
+ const i = id;
608
+ const onAxis = i === 0 || i === BODY_POINTS / 2;
609
+ pts[i] = [this._sym && onAxis ? pts[i][0] : at[0], at[1]];
610
+ if (this._sym && !onAxis) {
611
+ const j = (BODY_POINTS - i) % BODY_POINTS;
612
+ pts[j] = [2 * AXIS - at[0], at[1]];
613
+ }
614
+ n.body.points = pts.map(([x, y]) => [Math.min(235, Math.max(-35, x)), Math.min(235, Math.max(-35, y))]);
615
+ } else if (type === 'face' && id === 'eyes') {
616
+ n.eyes.y = c0.eyes.y + dy;
617
+ const L = layout(c0);
618
+ n.eyes.spacing = c0.eyes.spacing * Math.max(0.1, (L.sp + dx) / L.sp);
619
+ } else if (type === 'face' && id === 'mouth') {
620
+ n.mouth.y = c0.mouth.y + dy;
621
+ } else if (type === 'acc') {
622
+ n.accessories[id].x = c0.accessories[id].x + dx;
623
+ n.accessories[id].y = c0.accessories[id].y + dy;
624
+ }
625
+ }
626
+
627
+ _onHandleKey(e) {
628
+ const t = e.target;
629
+ const d = { ArrowLeft: [-2, 0], ArrowRight: [2, 0], ArrowUp: [0, -2], ArrowDown: [0, 2] }[e.key];
630
+ if (!d || !(t instanceof SVGElement)) return;
631
+ e.preventDefault();
632
+ const [dx, dy] = e.shiftKey ? [d[0] * 5, d[1] * 5] : d;
633
+ const c0 = structuredClone(this._c);
634
+ let kind;
635
+ let sel;
636
+ if (t.dataset.pt) {
637
+ kind = ['pt', +t.dataset.pt];
638
+ sel = `[data-pt="${t.dataset.pt}"]`;
639
+ } else if (t.dataset.face) {
640
+ kind = ['face', t.dataset.face];
641
+ sel = `[data-face="${t.dataset.face}"]`;
642
+ } else if (t.dataset.acch) {
643
+ kind = ['acc', +t.dataset.acch];
644
+ sel = `[data-acch="${t.dataset.acch}"]`;
645
+ } else return;
646
+ const cx = +t.getAttribute('cx');
647
+ const cy = +t.getAttribute('cy');
648
+ this._focusSel = sel;
649
+ this._set((n) => this._dragMut(n, c0, kind, dx, dy, [cx + dx, cy + dy]), { panel: false });
650
+ }
651
+
652
+ _onWheel(e) {
653
+ if (this._tab !== 'acc' || this._sel < 0) return;
654
+ e.preventDefault();
655
+ const i = this._sel;
656
+ this._set((n) => {
657
+ const a = n.accessories[i];
658
+ if (e.shiftKey) a.rot = Math.max(-180, Math.min(180, a.rot + Math.sign(e.deltaY) * 5));
659
+ else a.scale = Math.max(0.2, Math.min(3, a.scale * (e.deltaY > 0 ? 0.95 : 1.05)));
660
+ }, { commit: false, panel: false });
661
+ clearTimeout(this._wheelT);
662
+ this._wheelT = setTimeout(() => this._commit(true), 350);
663
+ }
664
+
665
+ // ───────────── eventos del panel ─────────────
666
+
667
+ _onClick(e) {
668
+ const b = e.target.closest?.('button');
669
+ if (!b) return;
670
+ const d = b.dataset;
671
+ const c = this._c;
672
+ if (d.kind) return this._setMeta((v) => (v.kind = d.kind));
673
+ if (d.tab) {
674
+ this._tab = d.tab;
675
+ this._renderPanel();
676
+ this._drawOverlay();
677
+ return;
678
+ }
679
+ if (d.preset) return this._set((n) => Object.assign(n, applyPreset(n, d.preset, this._keepBody ? ['body', 'finish'] : [])));
680
+ if (d.shape) return this._set((n) => { n.body.shape = d.shape; n.body.points = null; });
681
+ if (d.swatch) return this._set((n) => { n.body.color = d.swatch; });
682
+ if (d.finish) return this._set((n) => { n.finish = d.finish; });
683
+ if (d.outline) return this._set((n) => { n.outline = d.outline; });
684
+ if (d.pattern) return this._set((n) => { n.body.pattern = d.pattern; });
685
+ if (d.eyes) return this._set((n) => { n.eyes.type = d.eyes; });
686
+ if (d.mouth) return this._set((n) => { n.mouth.type = d.mouth; });
687
+ if (d.brows) return this._set((n) => { n.brows.type = d.brows; });
688
+ if (d.cheeks) return this._set((n) => { n.cheeks.type = d.cheeks; });
689
+ if (d.accgroup) {
690
+ this._accGroup = d.accgroup;
691
+ return this._renderPanel();
692
+ }
693
+ if (d.acc) {
694
+ const i = c.accessories.findIndex((a) => a.id === d.acc);
695
+ if (i >= 0) {
696
+ this._sel = -1;
697
+ return this._set((n) => n.accessories.splice(i, 1));
698
+ }
699
+ if (c.accessories.length >= MAX_ACC) return this._msg(this._t('editor.acc.max', { max: MAX_ACC }));
700
+ this._sel = c.accessories.length;
701
+ return this._set((n) => n.accessories.push({ id: d.acc }));
702
+ }
703
+ if (d.selacc) {
704
+ this._sel = +d.selacc;
705
+ this._renderPanel();
706
+ return this._drawOverlay();
707
+ }
708
+ if (d.mBody) return this._setMeta((v) => (v.avatar.body = d.mBody));
709
+ if (d.sample) return this._setMeta((v) => { v.avatar = { url: SAMPLE_3D[+d.sample].url, body: SAMPLE_3D[+d.sample].body }; });
710
+ const i = this._sel;
711
+ switch (d.a) {
712
+ case 'undo': return this._undo();
713
+ case 'redo': return this._redo();
714
+ case 'random': return this._set((n) => Object.assign(n, randomCharacter()));
715
+ case 'sym': this._sym = !this._sym; b.setAttribute('aria-pressed', String(this._sym)); return this._drawOverlay();
716
+ case 'reshape': return this._set((n) => { n.body.points = null; });
717
+ case 'squash':
718
+ case 'stretch': {
719
+ const k = d.a === 'squash' ? [1.08, 0.96] : [0.95, 1.06];
720
+ return this._set((n) => {
721
+ const pts = n.body.points || bodyPoints(n);
722
+ const ys = pts.map((p) => p[1]);
723
+ const cy = (Math.min(...ys) + Math.max(...ys)) / 2;
724
+ n.body.points = pts.map(([x, y]) => [AXIS + (x - AXIS) * k[0], cy + (y - cy) * k[1]]);
725
+ });
726
+ }
727
+ case 'accflip': return this._set((n) => { n.accessories[i].flip = !n.accessories[i].flip; });
728
+ case 'accreset': return this._set((n) => Object.assign(n.accessories[i], { x: 0, y: 0, scale: 1, rot: 0, flip: false }));
729
+ case 'accdel': this._sel = -1; return this._set((n) => n.accessories.splice(i, 1));
730
+ case 'accup':
731
+ case 'accdown': {
732
+ const j = d.a === 'accup' ? i + 1 : i - 1;
733
+ if (j < 0 || j >= c.accessories.length) return;
734
+ this._sel = j;
735
+ return this._set((n) => { [n.accessories[i], n.accessories[j]] = [n.accessories[j], n.accessories[i]]; });
736
+ }
737
+ case 'talk': return this._talk();
738
+ case 'svg': return this._download('avatar.svg', new Blob([this._exportSVG()], { type: 'image/svg+xml' }));
739
+ case 'png': return this._png();
740
+ case 'json':
741
+ navigator.clipboard?.writeText(JSON.stringify(this._c, null, 2)).then(() => this._msg(this._t('editor.jsonCopied')), () => this._msg(this._t('editor.copyFailed')));
742
+ return;
743
+ }
744
+ }
745
+
746
+ _onInput(e, final) {
747
+ const t = e.target;
748
+ if (t.dataset?.a === 'mood' && final) return this._ch.mood(t.value);
749
+ if (t.dataset?.a === 'gesture' && final) {
750
+ if (t.value) this._ch.gesture(t.value);
751
+ t.value = '';
752
+ return;
753
+ }
754
+ if (t.dataset?.a === 'morph' && final) {
755
+ if (t.value) this._ch.morph(t.value);
756
+ t.value = '';
757
+ return;
758
+ }
759
+ if (t.hasAttribute?.('data-keep-body')) {
760
+ this._keepBody = t.checked;
761
+ return;
762
+ }
763
+ if (t.dataset?.m) {
764
+ if (!final) return;
765
+ return this._setMeta((v) => {
766
+ if (t.dataset.m === 'image') v.image = t.value.trim();
767
+ else v.avatar.url = t.value.trim();
768
+ });
769
+ }
770
+ const k = t.dataset?.k;
771
+ if (!k) return;
772
+ const val = t.type === 'range' ? Number(t.value) : t.value;
773
+ const [a, b, c] = k.split('.');
774
+ this._set(
775
+ (n) => {
776
+ if (a === 'acc') n.accessories[+b][c] = val;
777
+ else n[a][b] = val;
778
+ },
779
+ { commit: final, panel: false },
780
+ );
781
+ }
782
+
783
+ // ───────────── utilidades ─────────────
784
+
785
+ _talk() {
786
+ // Lip-sync por visemas a partir de una frase de ejemplo (en el idioma del editor).
787
+ this._speech?.stop?.();
788
+ this._speech = this._ch.speak(this._t('editor.talkSample'));
789
+ }
790
+
791
+ _exportSVG() {
792
+ const svg = renderCharacter(this._c, {}, { viewBox: this._ch.svg.getAttribute('viewBox'), title: 'Avatar 7ots' });
793
+ return svg.replace('<svg ', '<svg width="512" height="576" ');
794
+ }
795
+
796
+ _png() {
797
+ const img = new Image();
798
+ const url = URL.createObjectURL(new Blob([this._exportSVG()], { type: 'image/svg+xml' }));
799
+ img.onload = () => {
800
+ const cv = document.createElement('canvas');
801
+ cv.width = 512;
802
+ cv.height = 576;
803
+ cv.getContext('2d').drawImage(img, 0, 0, 512, 576);
804
+ URL.revokeObjectURL(url);
805
+ cv.toBlob((b) => b && this._download('avatar.png', b));
806
+ };
807
+ img.onerror = () => { URL.revokeObjectURL(url); this._msg(this._t('editor.pngFailed')); };
808
+ img.src = url;
809
+ }
810
+
811
+ _download(name, blob) {
812
+ const a = document.createElement('a');
813
+ a.href = URL.createObjectURL(blob);
814
+ a.download = name;
815
+ a.click();
816
+ setTimeout(() => URL.revokeObjectURL(a.href), 1000);
817
+ }
818
+
819
+ _msg(s) {
820
+ const m = this.$('[data-msg]');
821
+ m.textContent = s;
822
+ clearTimeout(this._msgT);
823
+ this._msgT = setTimeout(() => (m.textContent = ''), 2500);
824
+ }
825
+ }
826
+
827
+ if (typeof customElements !== 'undefined' && !customElements.get('ots-avatar-editor')) {
828
+ customElements.define('ots-avatar-editor', AvatarEditor);
829
+ }