angular-avatar-player 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.
@@ -0,0 +1,1664 @@
1
+ import * as i0 from '@angular/core';
2
+ import { Injectable, input, inject, ElementRef, NgZone, signal, computed, effect, ViewEncapsulation, Component, Input, NgModule } from '@angular/core';
3
+ import { CommonModule } from '@angular/common';
4
+ import { DomSanitizer } from '@angular/platform-browser';
5
+
6
+ /**
7
+ * - `base` — main face fill
8
+ * - `ear` — secondary tone for ears
9
+ * - `lip` — lips
10
+ * - `shadow` — a THIRD tone reserved for small linework (nose stroke).
11
+ *
12
+ * `shadow` used to be conflated with `ear`, which made the nose vanish
13
+ * on the two darkest tones: `deep` ear is #3C200E against a #4A2912 face,
14
+ * far too close to read as a line. `shadow` is always a clear step darker
15
+ * than `ear` so it stays legible at the 2px stroke width we use.
16
+ *
17
+ * The style guide allows at most two tones per *region*. The nose is its
18
+ * own region, so a dedicated third value is compliant.
19
+ */
20
+ const SKIN_TONES = {
21
+ light: { base: '#FDDBB4', ear: '#F5C89A', lip: '#E8967A', shadow: '#E0A473' },
22
+ medium: { base: '#F0AC78', ear: '#E09A62', lip: '#C97050', shadow: '#C67C45' },
23
+ tan: { base: '#D4845A', ear: '#C0724A', lip: '#A8533A', shadow: '#A15532' },
24
+ dark: { base: '#8D5524', ear: '#7A4820', lip: '#6B3A18', shadow: '#5E3312' },
25
+ deep: { base: '#4A2912', ear: '#3C200E', lip: '#5C2E18', shadow: '#291505' },
26
+ };
27
+
28
+ const HAIR_COLORS = {
29
+ black: '#1A1A2E',
30
+ brown: '#6B3A2A',
31
+ blonde: '#E8C96E',
32
+ red: '#C0392B',
33
+ gray: '#9E9E9E',
34
+ white: '#F0F0F0',
35
+ };
36
+
37
+ const EYE_COLORS = {
38
+ brown: '#6B3A2A',
39
+ blue: '#2980B9',
40
+ green: '#27AE60',
41
+ gray: '#7F8C8D',
42
+ black: '#1A1A2E',
43
+ };
44
+
45
+ // libs/avatar-player/src/lib/geometry/svg-parts/hair-shapes.ts/svg-parts/hair-shapes.ts
46
+ // 7 haircut styles: short, long, curly, bald, bun, ponytail, mohawk
47
+ // Each provides back (rendered behind face) + front (rendered on top)
48
+ // All shapes: solid fill, no gradients, no strokes > 2.5, flat silhouettes
49
+ // Colors use CSS var: fill="var(--hair-color)"
50
+ // Face ellipse: cx=100 cy=88 rx=52 ry=56 → top of head ~y=32
51
+ const HAIR_SHAPES = {
52
+ short: {
53
+ back: `
54
+ <ellipse cx="100" cy="62" rx="54" ry="34" fill="var(--hair-color)"/>`,
55
+ front: `
56
+ <path d="M48,75 Q48,32 100,28 Q152,32 152,75 Q140,58 100,54 Q60,58 48,75 Z"
57
+ fill="var(--hair-color)"/>`,
58
+ },
59
+ long: {
60
+ back: `
61
+ <path d="M44,70 Q44,28 100,22 Q156,28 156,70 L156,140 Q156,150 148,150
62
+ L52,150 Q44,150 44,140 Z"
63
+ fill="var(--hair-color)"/>`,
64
+ front: `
65
+ <path d="M46,78 Q46,28 100,22 Q154,28 154,78 Q142,54 100,48 Q58,54 46,78 Z"
66
+ fill="var(--hair-color)"/>
67
+ <path d="M46,78 L46,110 Q48,95 52,85 Z" fill="var(--hair-color)"/>
68
+ <path d="M154,78 L154,110 Q152,95 148,85 Z" fill="var(--hair-color)"/>`,
69
+ },
70
+ curly: {
71
+ back: `
72
+ <path d="M42,80 Q38,30 100,22 Q162,30 158,80 L160,135 Q155,145 148,140
73
+ Q142,148 132,142 Q122,150 112,142 Q102,148 92,142 Q82,150 72,142
74
+ Q62,148 52,140 Q45,145 40,135 Z"
75
+ fill="var(--hair-color)"/>`,
76
+ front: `
77
+ <path d="M44,82 Q42,30 100,22 Q158,30 156,82
78
+ Q148,60 130,50 Q100,42 70,50 Q52,60 44,82 Z"
79
+ fill="var(--hair-color)"/>
80
+ <circle cx="44" cy="88" r="8" fill="var(--hair-color)"/>
81
+ <circle cx="156" cy="88" r="8" fill="var(--hair-color)"/>
82
+ <circle cx="48" cy="100" r="7" fill="var(--hair-color)"/>
83
+ <circle cx="152" cy="100" r="7" fill="var(--hair-color)"/>`,
84
+ },
85
+ bald: {
86
+ back: '',
87
+ front: '',
88
+ },
89
+ bun: {
90
+ back: `
91
+ <circle cx="100" cy="24" r="18" fill="var(--hair-color)"/>
92
+ <ellipse cx="100" cy="60" rx="54" ry="32" fill="var(--hair-color)"/>`,
93
+ front: `
94
+ <path d="M48,75 Q48,32 100,28 Q152,32 152,75 Q140,56 100,52 Q60,56 48,75 Z"
95
+ fill="var(--hair-color)"/>`,
96
+ },
97
+ ponytail: {
98
+ back: `
99
+ <ellipse cx="100" cy="62" rx="54" ry="34" fill="var(--hair-color)"/>
100
+ <path d="M130,42 Q158,40 162,60 Q166,90 158,130 Q154,142 148,140
101
+ Q152,120 150,80 Q148,50 130,42 Z"
102
+ fill="var(--hair-color)"/>`,
103
+ front: `
104
+ <path d="M48,75 Q48,32 100,28 Q152,32 152,75 Q140,56 100,52 Q60,56 48,75 Z"
105
+ fill="var(--hair-color)"/>`,
106
+ },
107
+ mohawk: {
108
+ back: '',
109
+ front: `
110
+ <!-- Shaved sides – faint stubble -->
111
+ <path d="M54,70 Q54,42 100,34 Q146,42 146,70 Q136,56 100,52 Q64,56 54,70 Z"
112
+ fill="var(--hair-color)" opacity="0.12"/>
113
+ <!-- Mohawk crest – 5 pointed spikes -->
114
+ <path d="M84,64 Q84,48 86,40
115
+ L80,20 L89,36
116
+ L86,6 L95,30
117
+ L100,2 L105,30
118
+ L114,6 L111,36
119
+ L120,20 L114,40
120
+ Q116,48 116,64
121
+ Q108,54 100,52 Q92,54 84,64 Z"
122
+ fill="var(--hair-color)"/>`,
123
+ },
124
+ };
125
+
126
+ // libs/avatar-player/src/lib/geometry/svg-parts/eye-shapes.ts/svg-parts/eye-shapes.ts
127
+ // 4 eye styles: round, almond, wide, narrow
128
+ // Each style provides sclera + iris SVG strings
129
+ // Eyes are positioned at cx=74 (left) and cx=126 (right), cy=88
130
+ // Sclera r≈11, iris r≈9, pupil r≈5 — large eyes per style guide
131
+ // Colors use CSS vars: fill="var(--eye-color)"
132
+ const EYE_SHAPES = {
133
+ round: {
134
+ sclera: `
135
+ <circle cx="74" cy="88" r="11" fill="#FFF"/>
136
+ <circle cx="126" cy="88" r="11" fill="#FFF"/>`,
137
+ iris: `
138
+ <circle cx="74" cy="88" r="9" fill="var(--eye-color)"/>
139
+ <circle cx="74" cy="88" r="5" fill="#1A1A2E"/>
140
+ <circle cx="77" cy="85" r="2" fill="#FFF"/>
141
+ <circle cx="126" cy="88" r="9" fill="var(--eye-color)"/>
142
+ <circle cx="126" cy="88" r="5" fill="#1A1A2E"/>
143
+ <circle cx="129" cy="85" r="2" fill="#FFF"/>`,
144
+ },
145
+ almond: {
146
+ sclera: `
147
+ <ellipse cx="74" cy="88" rx="12" ry="9" fill="#FFF"/>
148
+ <ellipse cx="126" cy="88" rx="12" ry="9" fill="#FFF"/>`,
149
+ iris: `
150
+ <circle cx="74" cy="88" r="8" fill="var(--eye-color)"/>
151
+ <circle cx="74" cy="88" r="5" fill="#1A1A2E"/>
152
+ <circle cx="77" cy="85" r="2" fill="#FFF"/>
153
+ <circle cx="126" cy="88" r="8" fill="var(--eye-color)"/>
154
+ <circle cx="126" cy="88" r="5" fill="#1A1A2E"/>
155
+ <circle cx="129" cy="85" r="2" fill="#FFF"/>`,
156
+ },
157
+ wide: {
158
+ sclera: `
159
+ <ellipse cx="74" cy="88" rx="13" ry="11" fill="#FFF"/>
160
+ <ellipse cx="126" cy="88" rx="13" ry="11" fill="#FFF"/>`,
161
+ iris: `
162
+ <circle cx="74" cy="88" r="9" fill="var(--eye-color)"/>
163
+ <circle cx="74" cy="88" r="5" fill="#1A1A2E"/>
164
+ <circle cx="77" cy="85" r="2.5" fill="#FFF"/>
165
+ <circle cx="126" cy="88" r="9" fill="var(--eye-color)"/>
166
+ <circle cx="126" cy="88" r="5" fill="#1A1A2E"/>
167
+ <circle cx="129" cy="85" r="2.5" fill="#FFF"/>`,
168
+ },
169
+ narrow: {
170
+ sclera: `
171
+ <ellipse cx="74" cy="88" rx="12" ry="7" fill="#FFF"/>
172
+ <ellipse cx="126" cy="88" rx="12" ry="7" fill="#FFF"/>`,
173
+ iris: `
174
+ <circle cx="74" cy="88" r="7" fill="var(--eye-color)"/>
175
+ <circle cx="74" cy="88" r="4" fill="#1A1A2E"/>
176
+ <circle cx="77" cy="86" r="1.5" fill="#FFF"/>
177
+ <circle cx="126" cy="88" r="7" fill="var(--eye-color)"/>
178
+ <circle cx="126" cy="88" r="4" fill="#1A1A2E"/>
179
+ <circle cx="129" cy="86" r="1.5" fill="#FFF"/>`,
180
+ },
181
+ };
182
+
183
+ // libs/avatar-player/src/lib/geometry/svg-parts/mustache-shapes.ts/svg-parts/mustache-shapes.ts
184
+ // 5 mustache styles: none, thin, thick, handlebar, chevron
185
+ // Positioned below nose (~y=112) and above mouth (~y=119)
186
+ // Flat fill only, no gradients, stroke-width ≤ 3
187
+ // Color: fill="var(--hair-color)"
188
+ const MUSTACHE_SHAPES = {
189
+ none: '',
190
+ thin: `
191
+ <!-- Thin pencil mustache -->
192
+ <path d="M82,114 Q91,118 100,115 Q109,118 118,114"
193
+ stroke="var(--hair-color)" stroke-width="3" fill="none"
194
+ stroke-linecap="round"/>`,
195
+ thick: `
196
+ <!-- Thick walrus mustache -->
197
+ <path d="M78,111 Q82,112 88,114 Q94,117 100,115
198
+ Q106,117 112,114 Q118,112 122,111
199
+ L124,113 Q120,120 112,122 Q106,123 100,122
200
+ Q94,123 88,122 Q80,120 76,113 Z"
201
+ fill="var(--hair-color)"/>`,
202
+ handlebar: `
203
+ <!-- Handlebar mustache with curled tips -->
204
+ <path d="M80,112 Q90,118 100,115 Q110,118 120,112
205
+ L122,114 Q112,121 100,119 Q88,121 78,114 Z"
206
+ fill="var(--hair-color)"/>
207
+ <!-- Left curl -->
208
+ <path d="M80,113 Q74,110 72,114 Q70,118 73,122"
209
+ stroke="var(--hair-color)" stroke-width="3.5" fill="none"
210
+ stroke-linecap="round"/>
211
+ <!-- Right curl -->
212
+ <path d="M120,113 Q126,110 128,114 Q130,118 127,122"
213
+ stroke="var(--hair-color)" stroke-width="3.5" fill="none"
214
+ stroke-linecap="round"/>`,
215
+ chevron: `
216
+ <!-- Chevron mustache — wide inverted V shape -->
217
+ <path d="M76,111 L88,112 Q94,118 100,116 Q106,118 112,112
218
+ L124,111 L126,113 Q116,122 100,120
219
+ Q84,122 74,113 Z"
220
+ fill="var(--hair-color)"/>`,
221
+ };
222
+
223
+ // libs/avatar-player/src/lib/geometry/svg-parts/beard-shapes.ts/svg-parts/beard-shapes.ts
224
+ // 5 beard styles: none, stubble, short, long, goatee
225
+ //
226
+ // The outer edge of both full beards follows the face ellipse
227
+ // (cx=100 cy=88 rx=52 ry=56) rather than a hand-typed curve. An earlier
228
+ // version kept its outer edge near x=49 down to y=118, but the face has
229
+ // already narrowed to x=55 by then, so the beard projected past the jaw
230
+ // as a dark wedge on each side.
231
+ //
232
+ // Face half-width by row, which is what the outer curves are matched to:
233
+ // y=96 -> 51.5 y=110 -> 47.9 y=120 -> 45.0
234
+ // y=130 -> 37.2 y=140 -> 26.0 chin -> 0 (y=144)
235
+ //
236
+ // Flat fill only, no gradients, stroke-width ≤ 2.5
237
+ // Color: fill="var(--hair-color)"
238
+ const BEARD_SHAPES = {
239
+ none: '',
240
+ stubble: `
241
+ <!-- 5 o'clock shadow following the jawline. The previous version ran to
242
+ x=76/124 at y=140, where the face half-width is only ~19 (x 81..119),
243
+ so it overhung the jaw by ~5px on each side and had to be cut back by
244
+ the face clip. It now ends at the chin instead of hanging below it. -->
245
+ <path d="M72,114 Q70,126 80,136 Q90,143 100,144 Q110,143 120,136 Q130,126 128,114
246
+ Q122,126 100,132 Q78,126 72,114 Z"
247
+ fill="var(--hair-color)" opacity="0.2"/>`,
248
+ short: `
249
+ <!-- Jaw-hugging beard. Outer edge is inset 3px inside the face ellipse so
250
+ it never projects past the jaw; inner edge sits above it, leaving a
251
+ band that thins at the cheeks and thickens at the chin. -->
252
+ <path d="M52,96
253
+ C52,108 56,120 64,128
254
+ C72,136 86,143 100,143
255
+ C114,143 128,136 136,128
256
+ C144,120 148,108 148,96
257
+ C146,104 140,114 130,121
258
+ C120,128 110,131 100,131
259
+ C90,131 80,128 70,121
260
+ C60,114 54,104 52,96 Z"
261
+ fill="var(--hair-color)"/>`,
262
+ long: `
263
+ <!-- Same jaw-hugging top, then flowing below the chin to a rounded point. -->
264
+ <path d="M52,96
265
+ C52,110 58,126 70,138
266
+ C80,148 90,160 95,172
267
+ C96,176 104,176 105,172
268
+ C110,160 120,148 130,138
269
+ C142,126 148,110 148,96
270
+ C146,104 140,114 130,121
271
+ C120,128 110,131 100,131
272
+ C90,131 80,128 70,121
273
+ C60,114 54,104 52,96 Z"
274
+ fill="var(--hair-color)"/>`,
275
+ goatee: `
276
+ <!-- Soul patch -->
277
+ <ellipse cx="100" cy="126" rx="4" ry="2.5" fill="var(--hair-color)"/>
278
+ <!-- Compact chin goatee -->
279
+ <path d="M88,130 Q86,140 92,147 Q96,152 100,153 Q104,152 108,147 Q114,140 112,130
280
+ Q108,138 100,142 Q92,138 88,130 Z"
281
+ fill="var(--hair-color)"/>`,
282
+ };
283
+
284
+ // libs/avatar-player/src/lib/geometry/svg-parts/glasses-shapes.ts/svg-parts/glasses-shapes.ts
285
+ // 5 glasses styles: none, round, rectangular, sunglasses, monocle
286
+ // Positioned over eyes at cy=88, cx=74 (left), cx=126 (right)
287
+ // Stroke-only where possible, flat fills, no gradients
288
+ // stroke-width ≤ 2.5
289
+ const GLASSES_SHAPES = {
290
+ none: '',
291
+ round: `
292
+ <circle cx="74" cy="88" r="16" fill="none" stroke="#1A1A2E" stroke-width="2"/>
293
+ <circle cx="126" cy="88" r="16" fill="none" stroke="#1A1A2E" stroke-width="2"/>
294
+ <line x1="90" y1="88" x2="110" y2="88" stroke="#1A1A2E" stroke-width="2"/>
295
+ <line x1="58" y1="88" x2="48" y2="86" stroke="#1A1A2E" stroke-width="1.5"/>
296
+ <line x1="142" y1="88" x2="152" y2="86" stroke="#1A1A2E" stroke-width="1.5"/>`,
297
+ rectangular: `
298
+ <rect x="58" y="78" width="32" height="20" rx="4" fill="none"
299
+ stroke="#1A1A2E" stroke-width="2"/>
300
+ <rect x="110" y="78" width="32" height="20" rx="4" fill="none"
301
+ stroke="#1A1A2E" stroke-width="2"/>
302
+ <line x1="90" y1="88" x2="110" y2="88" stroke="#1A1A2E" stroke-width="2"/>
303
+ <line x1="58" y1="86" x2="48" y2="84" stroke="#1A1A2E" stroke-width="1.5"/>
304
+ <line x1="142" y1="86" x2="152" y2="84" stroke="#1A1A2E" stroke-width="1.5"/>`,
305
+ sunglasses: `
306
+ <path d="M58,82 Q58,76 66,76 L82,76 Q90,76 90,82 L90,94 Q90,100 82,100
307
+ L66,100 Q58,100 58,94 Z" fill="#1A1A2E"/>
308
+ <path d="M110,82 Q110,76 118,76 L134,76 Q142,76 142,82 L142,94 Q142,100 134,100
309
+ L118,100 Q110,100 110,94 Z" fill="#1A1A2E"/>
310
+ <line x1="90" y1="86" x2="110" y2="86" stroke="#1A1A2E" stroke-width="2.5"/>
311
+ <line x1="58" y1="84" x2="48" y2="82" stroke="#1A1A2E" stroke-width="2"/>
312
+ <line x1="142" y1="84" x2="152" y2="82" stroke="#1A1A2E" stroke-width="2"/>`,
313
+ monocle: `
314
+ <circle cx="126" cy="88" r="16" fill="none" stroke="#C9A84C" stroke-width="2"/>
315
+ <line x1="126" y1="104" x2="120" y2="140" stroke="#C9A84C" stroke-width="1.5"/>`,
316
+ };
317
+
318
+ // libs/avatar-player/src/lib/geometry/svg-parts/profession-layers.ts/svg-parts/profession-layers.ts
319
+ // 9 professions: none, doctor, engineer, teacher, chef, police, astronaut, artist, business
320
+ // Each provides: body (shoulders/clothing) + accessory (badge/hat/item on top of head)
321
+ // Canvas: 200×200. Face center (100,88). Neck x=88–112, y=135–157.
322
+ // Body renders as layer 1 (below head). Accessory renders as layer 14 (above hair).
323
+ // Flat fills only, no gradients/filters, stroke-width ≤ 2.5
324
+ const PROFESSION_LAYERS = {
325
+ // ─── None: Plain crew-neck T-shirt ──────────────────
326
+ none: {
327
+ body: `
328
+ <path d="M30,200 Q30,158 55,150 Q75,143 88,140
329
+ L88,145 Q90,148 100,148 Q110,148 112,145 L112,140
330
+ Q125,143 145,150 Q170,158 170,200 Z" fill="#6B7280"/>
331
+ <!-- Collar line -->
332
+ <path d="M88,145 Q90,150 100,151 Q110,150 112,145"
333
+ fill="none" stroke="#5B6370" stroke-width="1.5" stroke-linecap="round"/>`,
334
+ accessory: '',
335
+ },
336
+ // ─── Doctor: White lab coat over blue scrubs, stethoscope ───
337
+ doctor: {
338
+ body: `
339
+ <!-- Blue scrub underneath -->
340
+ <path d="M30,200 Q30,158 55,150 Q75,143 88,140
341
+ L88,145 Q90,148 100,148 Q110,148 112,145 L112,140
342
+ Q125,143 145,150 Q170,158 170,200 Z" fill="#3B82F6"/>
343
+ <!-- V-neck scrub neckline -->
344
+ <path d="M88,140 L96,155 L100,152 L104,155 L112,140"
345
+ fill="#3B82F6" stroke="#2563EB" stroke-width="1"/>
346
+ <!-- White lab coat — open front, over shoulders -->
347
+ <path d="M30,200 Q30,158 55,150 Q70,145 80,142
348
+ L82,155 L90,200 Z" fill="#F8FAFC"/>
349
+ <path d="M170,200 Q170,158 145,150 Q130,145 120,142
350
+ L118,155 L110,200 Z" fill="#F8FAFC"/>
351
+ <!-- Coat lapels -->
352
+ <path d="M80,142 L86,158 L82,155" fill="#E2E8F0" stroke="#CBD5E1" stroke-width="0.5"/>
353
+ <path d="M120,142 L114,158 L118,155" fill="#E2E8F0" stroke="#CBD5E1" stroke-width="0.5"/>
354
+ <!-- Breast pocket -->
355
+ <rect x="34" y="168" width="14" height="10" rx="1" fill="none" stroke="#CBD5E1" stroke-width="1"/>
356
+ <!-- Stethoscope — tubes start behind neck, just under jaw (~y=142) -->
357
+ <path d="M95,142 Q80,148 76,156 Q74,166 84,172 Q94,177 100,175"
358
+ fill="none" stroke="#475569" stroke-width="2.5" stroke-linecap="round"/>
359
+ <path d="M105,142 Q120,148 124,156 Q126,166 116,172 Q106,177 100,175"
360
+ fill="none" stroke="#475569" stroke-width="2.5" stroke-linecap="round"/>
361
+ <!-- Stethoscope chest piece (centered on chest) -->
362
+ <circle cx="100" cy="177" r="5" fill="#64748B" stroke="#475569" stroke-width="1.2"/>
363
+ <circle cx="100" cy="177" r="2" fill="#94A3B8"/>`,
364
+ accessory: ``,
365
+ },
366
+ // ─── Engineer: Orange safety vest, hard hat ─────────
367
+ engineer: {
368
+ body: `
369
+ <!-- Blue work shirt -->
370
+ <path d="M30,200 Q30,158 55,150 Q75,143 88,140
371
+ L88,145 Q90,148 100,148 Q110,148 112,145 L112,140
372
+ Q125,143 145,150 Q170,158 170,200 Z" fill="#1E3A5F"/>
373
+ <!-- Orange hi-vis vest -->
374
+ <path d="M45,200 Q48,160 65,152 Q78,146 88,144
375
+ L92,200 Z" fill="#F97316"/>
376
+ <path d="M155,200 Q152,160 135,152 Q122,146 112,144
377
+ L108,200 Z" fill="#F97316"/>
378
+ <!-- Reflective stripes -->
379
+ <line x1="55" y1="164" x2="88" y2="152" stroke="#FDE68A" stroke-width="2.5"/>
380
+ <line x1="145" y1="164" x2="112" y2="152" stroke="#FDE68A" stroke-width="2.5"/>
381
+ <line x1="58" y1="178" x2="90" y2="168" stroke="#FDE68A" stroke-width="2.5"/>
382
+ <line x1="142" y1="178" x2="110" y2="168" stroke="#FDE68A" stroke-width="2.5"/>`,
383
+ accessory: `
384
+ <!-- Hair cover (hides any hair poking through) -->
385
+ <ellipse cx="100" cy="52" rx="56" ry="20" fill="#D97706"/>
386
+ <!-- Hard hat — dome -->
387
+ <path d="M50,52 Q50,14 100,8 Q150,14 150,52 Z" fill="#F59E0B"/>
388
+ <!-- Brim -->
389
+ <rect x="44" y="50" width="112" height="9" rx="4" fill="#D97706"/>
390
+ <!-- Brim shadow line -->
391
+ <line x1="48" y1="57" x2="152" y2="57" stroke="#B45309" stroke-width="1"/>
392
+ <!-- Center ridge -->
393
+ <line x1="100" y1="10" x2="100" y2="50" stroke="#EAB308" stroke-width="1" opacity="0.4"/>`,
394
+ },
395
+ // ─── Teacher: Professor with graduation cap, sweater vest, bow tie ─────
396
+ teacher: {
397
+ body: `
398
+ <!-- Light blue dress shirt -->
399
+ <path d="M30,200 Q30,158 55,150 Q75,143 88,140
400
+ L88,145 Q90,148 100,148 Q110,148 112,145 L112,140
401
+ Q125,143 145,150 Q170,158 170,200 Z" fill="#DBEAFE"/>
402
+ <!-- Shirt collar -->
403
+ <path d="M88,140 L82,148 L94,152" fill="#BFDBFE" stroke="#93C5FD" stroke-width="0.7"/>
404
+ <path d="M112,140 L118,148 L106,152" fill="#BFDBFE" stroke="#93C5FD" stroke-width="0.7"/>
405
+ <!-- Dark navy sweater vest -->
406
+ <path d="M55,200 Q56,162 65,154 Q78,147 88,144
407
+ L94,152 L100,148 L106,152 L112,144
408
+ Q122,147 135,154 Q144,162 145,200 Z" fill="#1E293B"/>
409
+ <!-- V-neck of sweater vest -->
410
+ <path d="M88,144 L94,152 L100,148 L106,152 L112,144"
411
+ fill="#DBEAFE" stroke="#1E293B" stroke-width="0.8"/>
412
+ <!-- Sweater vest bottom ribbing -->
413
+ <line x1="58" y1="196" x2="142" y2="196" stroke="#334155" stroke-width="1"/>
414
+ <line x1="57" y1="198" x2="143" y2="198" stroke="#334155" stroke-width="0.5"/>
415
+ <!-- Sleeves (shirt showing) -->
416
+ <path d="M30,200 Q30,158 55,150 Q62,148 65,154
417
+ L58,200 Z" fill="#DBEAFE"/>
418
+ <path d="M170,200 Q170,158 145,150 Q138,148 135,154
419
+ L142,200 Z" fill="#DBEAFE"/>
420
+ <!-- Bow tie -->
421
+ <path d="M93,147 L88,143 L88,151 Z" fill="#991B1B"/>
422
+ <path d="M107,147 L112,143 L112,151 Z" fill="#991B1B"/>
423
+ <circle cx="100" cy="147" r="2.5" fill="#B91C1C" stroke="#7F1D1D" stroke-width="0.5"/>`,
424
+ accessory: `
425
+ <!-- Graduation mortarboard cap -->
426
+ <!-- Cap crown (skull cap — covers top of head generously) -->
427
+ <path d="M46,56 Q44,38 62,30 Q80,22 100,22 Q120,22 138,30 Q156,38 154,56
428
+ Q134,62 100,62 Q66,62 46,56 Z" fill="#1E293B" stroke="#0F172A" stroke-width="0.6"/>
429
+ <!-- Flat board — large diamond shape for 3D perspective -->
430
+ <polygon points="100,8 168,34 100,54 32,34" fill="#1E293B" stroke="#0F172A" stroke-width="0.8"/>
431
+ <!-- Board top highlight for depth -->
432
+ <polygon points="100,11 164,34 100,52 36,34" fill="#334155" opacity="0.3"/>
433
+ <!-- Button on top center -->
434
+ <circle cx="100" cy="32" r="3.5" fill="#475569" stroke="#0F172A" stroke-width="0.8"/>
435
+ <!-- Tassel string from center button draping to right -->
436
+ <path d="M100,32 Q124,24 144,32 Q158,44 160,62" fill="none" stroke="#EAB308" stroke-width="2.2" stroke-linecap="round"/>
437
+ <!-- Tassel end fringe -->
438
+ <line x1="158" y1="60" x2="156" y2="70" stroke="#EAB308" stroke-width="1.2"/>
439
+ <line x1="160" y1="62" x2="159" y2="72" stroke="#EAB308" stroke-width="1.2"/>
440
+ <line x1="162" y1="61" x2="162" y2="71" stroke="#EAB308" stroke-width="1.2"/>
441
+ <line x1="159" y1="71" x2="159" y2="77" stroke="#CA8A04" stroke-width="1.5"/>`,
442
+ },
443
+ // ─── Chef: Double-breasted white jacket, tall toque ─
444
+ chef: {
445
+ body: `
446
+ <!-- White chef jacket -->
447
+ <path d="M30,200 Q30,158 55,150 Q75,143 88,140
448
+ L88,145 Q90,148 100,148 Q110,148 112,145 L112,140
449
+ Q125,143 145,150 Q170,158 170,200 Z" fill="#FEFEFE"/>
450
+ <!-- Mandarin collar -->
451
+ <rect x="86" y="138" width="28" height="8" rx="3" fill="#F1F5F9" stroke="#E2E8F0" stroke-width="1"/>
452
+ <!-- Double-breasted buttons (left row) -->
453
+ <circle cx="88" cy="162" r="2" fill="#E2E8F0" stroke="#CBD5E1" stroke-width="0.8"/>
454
+ <circle cx="88" cy="174" r="2" fill="#E2E8F0" stroke="#CBD5E1" stroke-width="0.8"/>
455
+ <circle cx="88" cy="186" r="2" fill="#E2E8F0" stroke="#CBD5E1" stroke-width="0.8"/>
456
+ <!-- Right row -->
457
+ <circle cx="96" cy="162" r="2" fill="#E2E8F0" stroke="#CBD5E1" stroke-width="0.8"/>
458
+ <circle cx="96" cy="174" r="2" fill="#E2E8F0" stroke="#CBD5E1" stroke-width="0.8"/>
459
+ <circle cx="96" cy="186" r="2" fill="#E2E8F0" stroke="#CBD5E1" stroke-width="0.8"/>
460
+ <!-- Center front seam -->
461
+ <line x1="100" y1="146" x2="100" y2="200" stroke="#E2E8F0" stroke-width="0.8"/>`,
462
+ accessory: `
463
+ <!-- Chef toque blanche — puffy hat, sized to sit inside the 0–200 canvas.
464
+ The previous version ran to y=-48, so most of it was off-canvas and
465
+ read as a shapeless blob. The face top is y=32, so the hat occupies
466
+ y≈2…59: high enough to look tall, never past the top edge. -->
467
+ <!-- Band at the forehead — spans the head width -->
468
+ <rect x="48" y="44" width="104" height="15" rx="4" fill="#FEFEFE" stroke="#E2E8F0" stroke-width="1.2"/>
469
+ <!-- Puffy toque body -->
470
+ <path d="M56,46 C52,38 50,28 52,20
471
+ C54,11 62,6 72,5
472
+ C80,4 88,4 100,4
473
+ C112,4 120,4 128,5
474
+ C138,6 146,11 148,20
475
+ C150,28 148,38 144,46 Z"
476
+ fill="#FEFEFE" stroke="#E2E8F0" stroke-width="1"/>
477
+ <!-- Crown puffs — overlapping rounded bumps, all within the canvas -->
478
+ <ellipse cx="76" cy="18" rx="20" ry="12" fill="#FEFEFE"/>
479
+ <ellipse cx="124" cy="18" rx="20" ry="12" fill="#FEFEFE"/>
480
+ <ellipse cx="100" cy="14" rx="24" ry="13" fill="#FEFEFE"/>
481
+ <!-- Pleat lines -->
482
+ <path d="M76,44 Q70,26 76,8" stroke="#F0F0F0" stroke-width="0.7" fill="none"/>
483
+ <path d="M100,44 L100,4" stroke="#F0F0F0" stroke-width="0.7" fill="none"/>
484
+ <path d="M124,44 Q130,26 124,8" stroke="#F0F0F0" stroke-width="0.7" fill="none"/>`,
485
+ },
486
+ // ─── Police: Navy uniform, cap with badge ───────────
487
+ police: {
488
+ body: `
489
+ <!-- Navy uniform -->
490
+ <path d="M30,200 Q30,158 55,150 Q75,143 88,140
491
+ L88,145 Q90,148 100,148 Q110,148 112,145 L112,140
492
+ Q125,143 145,150 Q170,158 170,200 Z" fill="#1E3A8A"/>
493
+ <!-- Collar -->
494
+ <path d="M86,140 L82,148 L92,152 L100,148 L108,152 L118,148 L114,140"
495
+ fill="#1E40AF" stroke="#1E3A8A" stroke-width="0.8"/>
496
+ <!-- Shoulder epaulettes -->
497
+ <rect x="52" y="148" width="18" height="5" rx="2" fill="#1E40AF" stroke="#2563EB" stroke-width="0.8"/>
498
+ <rect x="130" y="148" width="18" height="5" rx="2" fill="#1E40AF" stroke="#2563EB" stroke-width="0.8"/>
499
+ <!-- Badge (left breast) -->
500
+ <g transform="translate(82,168)">
501
+ <polygon points="0,-6 1.5,-2 5.5,-2 2.5,0.5 3.5,4.5 0,2 -3.5,4.5 -2.5,0.5 -5.5,-2 -1.5,-2"
502
+ fill="#EAB308" stroke="#CA8A04" stroke-width="0.5"/>
503
+ </g>
504
+ <!-- Buttons down front -->
505
+ <circle cx="100" cy="175" r="1.5" fill="#D4D4D8"/>
506
+ <circle cx="100" cy="185" r="1.5" fill="#D4D4D8"/>
507
+ <circle cx="100" cy="195" r="1.5" fill="#D4D4D8"/>
508
+ <!-- Breast pockets -->
509
+ <path d="M76,162 L76,172 L88,172" fill="none" stroke="#1E40AF" stroke-width="1"/>
510
+ <path d="M124,162 L124,172 L112,172" fill="none" stroke="#1E40AF" stroke-width="1"/>`,
511
+ accessory: `
512
+ <!-- Hair cover -->
513
+ <ellipse cx="100" cy="54" rx="56" ry="20" fill="#1E3A8A"/>
514
+ <!-- Police cap — crown -->
515
+ <path d="M50,54 L50,40 Q50,20 100,16 Q150,20 150,40 L150,54 Z" fill="#1E3A8A"/>
516
+ <!-- Cap band -->
517
+ <rect x="50" y="46" width="100" height="8" rx="0" fill="#1E40AF"/>
518
+ <!-- Cap badge -->
519
+ <g transform="translate(100,44)">
520
+ <polygon points="0,-6 2,-2 6,-2 3,1 4,5 0,3 -4,5 -3,1 -6,-2 -2,-2"
521
+ fill="#EAB308"/>
522
+ </g>
523
+ <!-- Cap brim (visor) -->
524
+ <path d="M46,54 Q46,62 100,66 Q154,62 154,54 Z" fill="#111827"/>`,
525
+ },
526
+ // ─── Astronaut: Silver/white space suit, glass helmet ──
527
+ astronaut: {
528
+ body: `
529
+ <!-- Neck cover — hides skin-tone neck behind collar -->
530
+ <rect x="82" y="130" width="36" height="20" rx="6" fill="#CFD8DC"/>
531
+ <!-- Bulky space suit -->
532
+ <path d="M22,200 Q22,155 50,147 Q72,140 88,138
533
+ L88,142 Q92,146 100,146 Q108,146 112,142 L112,138
534
+ Q128,140 150,147 Q178,155 178,200 Z" fill="#CFD8DC"/>
535
+ <!-- Suit seam lines -->
536
+ <line x1="60" y1="148" x2="52" y2="200" stroke="#B0BEC5" stroke-width="1.2"/>
537
+ <line x1="140" y1="148" x2="148" y2="200" stroke="#B0BEC5" stroke-width="1.2"/>
538
+ <!-- Chest plate -->
539
+ <rect x="76" y="148" width="48" height="34" rx="8" fill="#B0BEC5" stroke="#90A4AE" stroke-width="1.2"/>
540
+ <!-- Status lights on chest -->
541
+ <circle cx="90" cy="157" r="2.5" fill="#4ADE80"/>
542
+ <circle cx="110" cy="157" r="2.5" fill="#60A5FA"/>
543
+ <!-- Mission patch (left shoulder) -->
544
+ <path d="M42,153 L50,150 L58,153 L50,162 Z" fill="#1E3A5F" stroke="#BBB" stroke-width="0.5"/>
545
+ <path d="M46,154 L50,152 L54,154" fill="none" stroke="#60A5FA" stroke-width="0.8"/>
546
+ <!-- Agency logo patch (right shoulder) -->
547
+ <circle cx="155" cy="159" r="7" fill="#1E3A5F" stroke="#BBB" stroke-width="0.5"/>
548
+ <circle cx="155" cy="159" r="4" fill="none" stroke="#60A5FA" stroke-width="0.8"/>
549
+ <!-- Collar ring -->
550
+ <ellipse cx="100" cy="141" rx="30" ry="9" fill="#607D8B"/>
551
+ <ellipse cx="100" cy="139" rx="28" ry="7" fill="#78909C"/>
552
+ <ellipse cx="100" cy="137" rx="24" ry="5" fill="#90A4AE"/>`,
553
+ accessory: `
554
+ <!-- Helmet dome -->
555
+ <ellipse cx="100" cy="78" rx="60" ry="64" fill="#CFD8DC" opacity="0.30"/>
556
+ <!-- Helmet outer rim -->
557
+ <ellipse cx="100" cy="78" rx="60" ry="64" fill="none"
558
+ stroke="#78909C" stroke-width="4"/>
559
+ <!-- Visor glass reflection -->
560
+ <path d="M64,52 Q74,36 90,40" fill="none" stroke="#FFF" stroke-width="2.5" stroke-linecap="round" opacity="0.40"/>
561
+ <!-- Side bolts -->
562
+ <circle cx="42" cy="88" r="4" fill="#546E7A" stroke="#455A64" stroke-width="1"/>
563
+ <circle cx="158" cy="88" r="4" fill="#546E7A" stroke="#455A64" stroke-width="1"/>
564
+ <!-- Antenna. The helmet crown is at y=14, leaving ~12px of headroom
565
+ inside the 200×200 canvas — keep the tip's outer edge at y >= 2. -->
566
+ <line x1="78" y1="16" x2="78" y2="8" stroke="#78909C" stroke-width="2.5" stroke-linecap="round"/>
567
+ <circle cx="78" cy="5" r="3" fill="#EF4444" stroke="#DC2626" stroke-width="0.8"/>`,
568
+ },
569
+ // ─── Artist: Striped shirt, apron, beret + brush ─────
570
+ artist: {
571
+ body: `
572
+ <!-- Dark striped undershirt -->
573
+ <path d="M30,200 Q30,158 55,150 Q75,143 88,140
574
+ L88,145 Q90,148 100,148 Q110,148 112,145 L112,140
575
+ Q125,143 145,150 Q170,158 170,200 Z" fill="#333A45"/>
576
+ <!-- Horizontal stripes on shirt -->
577
+ <line x1="42" y1="168" x2="158" y2="168" stroke="#4E5563" stroke-width="2"/>
578
+ <line x1="36" y1="180" x2="164" y2="180" stroke="#4E5563" stroke-width="2"/>
579
+ <line x1="34" y1="192" x2="166" y2="192" stroke="#4E5563" stroke-width="2"/>
580
+ <!-- Canvas-colored apron over shirt -->
581
+ <path d="M72,152 L72,200 L128,200 L128,152
582
+ Q128,148 120,148 L100,148 L80,148 Q72,148 72,152 Z" fill="#E8DCCA"/>
583
+ <!-- Apron neck strap (V-shape) -->
584
+ <path d="M88,140 L80,148" stroke="#D4C4A8" stroke-width="2" stroke-linecap="round"/>
585
+ <path d="M112,140 L120,148" stroke="#D4C4A8" stroke-width="2" stroke-linecap="round"/>
586
+ <!-- Apron pocket -->
587
+ <rect x="84" y="170" width="32" height="16" rx="2" fill="none" stroke="#C4B498" stroke-width="1"/>
588
+ <!-- Paintbrush in pocket -->
589
+ <line x1="92" y1="163" x2="92" y2="172" stroke="#8B5E3C" stroke-width="2" stroke-linecap="round"/>
590
+ <rect x="90" y="160" width="4" height="5" rx="1" fill="#3B82F6"/>
591
+ <!-- Paint smudge on apron — natural drip shapes -->
592
+ <path d="M110,175 Q112,173 114,176 Q113,178 110,177 Z" fill="#EF4444" opacity="0.6"/>
593
+ <path d="M78,188 Q80,186 83,188 Q82,191 79,190 Z" fill="#7C3AED" opacity="0.5"/>
594
+ <circle cx="120" cy="190" r="2" fill="#FBBF24" opacity="0.5"/>`,
595
+ accessory: `
596
+ <!-- Hair cover -->
597
+ <ellipse cx="100" cy="54" rx="56" ry="20" fill="#DC2626"/>
598
+ <!-- Beret — classic flat shape sitting on head -->
599
+ <path d="M50,54 Q50,36 70,28 Q86,22 100,22 Q120,22 140,30 Q156,38 156,54 Z" fill="#DC2626"/>
600
+ <!-- Beret puff — floppy top drooping to the right -->
601
+ <path d="M100,22 Q120,14 145,18 Q164,24 162,38 Q160,48 156,54
602
+ Q152,40 130,30 Q110,22 100,22 Z" fill="#EF4444"/>
603
+ <!-- Band (rim that hugs the forehead) -->
604
+ <path d="M50,54 Q50,60 100,62 Q150,60 156,54" fill="none"
605
+ stroke="#B91C1C" stroke-width="2.5"/>
606
+ <!-- Nub on top -->
607
+ <circle cx="138" cy="20" r="3.5" fill="#B91C1C"/>`,
608
+ },
609
+ // ─── Business: Black suit, white shirt, red tie ─────
610
+ business: {
611
+ body: `
612
+ <!-- White dress shirt -->
613
+ <path d="M30,200 Q30,158 55,150 Q75,143 88,140
614
+ L88,145 Q90,148 100,148 Q110,148 112,145 L112,140
615
+ Q125,143 145,150 Q170,158 170,200 Z" fill="#F8FAFC"/>
616
+ <!-- Shirt collar -->
617
+ <path d="M86,140 L80,150 L94,150 Z" fill="#F1F5F9" stroke="#E2E8F0" stroke-width="0.5"/>
618
+ <path d="M114,140 L120,150 L106,150 Z" fill="#F1F5F9" stroke="#E2E8F0" stroke-width="0.5"/>
619
+ <!-- Suit jacket -->
620
+ <path d="M30,200 Q30,158 55,150 Q66,146 76,143
621
+ L82,152 L88,200 Z" fill="#333A45"/>
622
+ <path d="M170,200 Q170,158 145,150 Q134,146 124,143
623
+ L118,152 L112,200 Z" fill="#333A45"/>
624
+ <!-- Lapels (V-shape) -->
625
+ <path d="M76,143 L86,158 L82,152" fill="#3E4652" stroke="#4E5563" stroke-width="0.5"/>
626
+ <path d="M124,143 L114,158 L118,152" fill="#3E4652" stroke="#4E5563" stroke-width="0.5"/>
627
+ <!-- Suit button -->
628
+ <circle cx="88" cy="175" r="2" fill="#3F3F46"/>
629
+ <circle cx="112" cy="175" r="2" fill="#3F3F46"/>
630
+ <!-- Necktie -->
631
+ <path d="M96,148 L100,150 L104,148 L100,146 Z" fill="#DC2626"/>
632
+ <path d="M97,150 L100,152 L103,150 L103.5,150" fill="#DC2626"/>
633
+ <polygon points="100,152 95,200 105,200" fill="#DC2626"/>
634
+ <!-- Tie knot -->
635
+ <path d="M97,148 L100,151 L103,148" fill="#B91C1C"/>
636
+ <!-- Tie stripe detail -->
637
+ <line x1="97" y1="165" x2="103" y2="162" stroke="#991B1B" stroke-width="1"/>
638
+ <line x1="97" y1="175" x2="103" y2="172" stroke="#991B1B" stroke-width="1"/>
639
+ <line x1="97" y1="185" x2="103" y2="182" stroke="#991B1B" stroke-width="1"/>`,
640
+ accessory: ``,
641
+ },
642
+ };
643
+
644
+ // libs/avatar-player/src/lib/geometry/trait-previews.ts/trait-previews.ts
645
+ //
646
+ // Small SVG thumbnails for the creator's pickers.
647
+ //
648
+ // The UI was showing an emoji per option while TraitOption already had an
649
+ // unused `svgPreview` field. Emoji neither match the artwork nor tell you what
650
+ // "handlebar" looks like. These build the real shape from the same part data
651
+ // the renderer uses, so a preview can never drift from the result.
652
+ //
653
+ // Everything is drawn with literal hex colours, not CSS custom properties:
654
+ // the thumbnails are injected with innerHTML and must stand on their own.
655
+ const SKIN = SKIN_TONES.medium.base;
656
+ const SKIN_DARK = SKIN_TONES.medium.ear;
657
+ const HAIR = HAIR_COLORS.brown;
658
+ const EYE = EYE_COLORS.brown;
659
+ /**
660
+ * Part data references `var(--hair-color)` and friends. Substitute literals so
661
+ * a thumbnail renders correctly outside the avatar's own stylesheet.
662
+ */
663
+ function inlineVars(svg) {
664
+ return svg
665
+ .replace(/var\(--hair-color\)/g, HAIR)
666
+ .replace(/var\(--eye-color\)/g, EYE)
667
+ .replace(/var\(--skin-base\)/g, SKIN)
668
+ .replace(/var\(--skin-ear\)/g, SKIN_DARK)
669
+ .replace(/var\(--skin-shadow\)/g, SKIN_DARK)
670
+ .replace(/var\(--lip-color\)/g, SKIN_TONES.medium.lip);
671
+ }
672
+ /** Crop window around the head. Parts live in 0–200 space centred on x=100. */
673
+ const VIEWBOX = '32 18 136 136';
674
+ function wrap(inner, viewBox = VIEWBOX) {
675
+ return (`<svg xmlns="http://www.w3.org/2000/svg" viewBox="${viewBox}" ` +
676
+ `preserveAspectRatio="xMidYMid meet">${inner}</svg>`);
677
+ }
678
+ const head = (rx = 52, ry = 56, cy = 88) => `<ellipse cx="100" cy="${cy}" rx="${rx}" ry="${ry}" fill="${SKIN}"/>`;
679
+ const ears = () => `<ellipse cx="48" cy="90" rx="8" ry="10" fill="${SKIN_DARK}"/>` +
680
+ `<ellipse cx="152" cy="90" rx="8" ry="10" fill="${SKIN_DARK}"/>`;
681
+ const eyes = () => {
682
+ const e = EYE_SHAPES.round;
683
+ return `<g>${inlineVars(e.sclera)}</g><g>${inlineVars(e.iris)}</g>`;
684
+ };
685
+ const brows = () => `<rect x="63" y="72" width="22" height="5" rx="3" fill="${HAIR}"/>` +
686
+ `<rect x="115" y="72" width="22" height="5" rx="3" fill="${HAIR}"/>`;
687
+ const noseMouth = () => `<path d="M97,105 Q100,112 103,105" stroke="${SKIN_DARK}" stroke-width="2" fill="none" stroke-linecap="round"/>` +
688
+ `<path d="M84,120 Q100,132 116,120" stroke="${SKIN_TONES.medium.lip}" stroke-width="2.5" fill="none" stroke-linecap="round"/>`;
689
+ /** Neutral bust used behind profession thumbnails. */
690
+ const bust = () => `<path d="M30,200 Q30,150 100,150 Q170,150 170,200 Z" fill="#8A93A5"/>`;
691
+ function buildTraitPreview(kind, value) {
692
+ switch (kind) {
693
+ case 'gender': {
694
+ const woman = value === 'woman';
695
+ const face = woman
696
+ ? `<ellipse cx="100" cy="86" rx="49" ry="55" fill="${SKIN}"/>` +
697
+ `<g class="lashes">${[0, 1, 2, 3, 4, 5, 6, 7]
698
+ .map((i) => {
699
+ const xs = [65, 70, 76, 82, 117, 122, 128, 134][i];
700
+ return `<path d="M${xs},78 l-3,-4" stroke="${HAIR}" stroke-width="1.5" stroke-linecap="round"/>`;
701
+ })
702
+ .join('')}</g>`
703
+ : head();
704
+ return wrap(ears() + face + eyes() + brows() + noseMouth());
705
+ }
706
+ case 'haircut': {
707
+ const h = HAIR_SHAPES[value];
708
+ if (!h)
709
+ return wrap(head());
710
+ return wrap(ears() + `<g>${inlineVars(h.back)}</g>` + head() + `<g>${inlineVars(h.front)}</g>`);
711
+ }
712
+ case 'eyeStyle': {
713
+ const e = EYE_SHAPES[value];
714
+ if (!e)
715
+ return wrap(head());
716
+ return wrap(head() + `<g>${inlineVars(e.sclera)}</g><g>${inlineVars(e.iris)}</g>`);
717
+ }
718
+ case 'mustache': {
719
+ const m = MUSTACHE_SHAPES[value];
720
+ return wrap(ears() + head() + eyes() + brows() + noseMouth() + (m ? `<g>${inlineVars(m)}</g>` : ''));
721
+ }
722
+ case 'beard': {
723
+ const b = BEARD_SHAPES[value];
724
+ return wrap(ears() + head() + eyes() + brows() + noseMouth() + (b ? `<g>${inlineVars(b)}</g>` : ''));
725
+ }
726
+ case 'glasses': {
727
+ const g = GLASSES_SHAPES[value];
728
+ return wrap(ears() + head() + eyes() + brows() + noseMouth() + (g ? `<g>${inlineVars(g)}</g>` : ''));
729
+ }
730
+ case 'profession': {
731
+ const p = PROFESSION_LAYERS[value];
732
+ const body = p?.body ? inlineVars(p.body) : bust();
733
+ const acc = p?.accessory ? inlineVars(p.accessory) : '';
734
+ // Profession art is drawn on the full 0–200 canvas, so use the whole
735
+ // thing rather than the head crop the face previews use.
736
+ return wrap(body + ears() + head() + eyes() + brows() + noseMouth() + acc, '0 0 200 200');
737
+ }
738
+ default:
739
+ return wrap(head());
740
+ }
741
+ }
742
+
743
+ // libs/avatar-player/src/lib/geometry/visemes.ts/visemes.ts
744
+ //
745
+ // Pure text -> viseme mapping, shared by the runtime lip-sync service and the
746
+ // offline generator that bakes an animated mouth into an exported SVG.
747
+ //
748
+ // Deliberately not a real phoneme model: every unmapped consonant falls back
749
+ // to the "spread" viseme, so the result reads as "mouth is moving" rather
750
+ // than accurate speech. Do not present it as phonetics.
751
+ /** Silence. */
752
+ const VISEME_SILENCE = 0;
753
+ /** A / I — wide open */
754
+ const VISEME_WIDE = 1;
755
+ /** O / U — rounded */
756
+ const VISEME_ROUND = 2;
757
+ /** E — spread */
758
+ const VISEME_SPREAD = 3;
759
+ /** M / B / P — pressed */
760
+ const VISEME_PRESSED = 4;
761
+ /** F / V — teeth on lip */
762
+ const VISEME_TEETH = 5;
763
+ /** How long each viseme is held, in ms. */
764
+ const MS_PER_VISEME = 80;
765
+ /**
766
+ * Map text characters to viseme ids 0–5.
767
+ * 0=silence, 1=A/I wide, 2=O/U round, 3=E spread, 4=M/B/P pressed, 5=F/V teeth
768
+ */
769
+ function textToVisemes(text) {
770
+ return text
771
+ .toLowerCase()
772
+ .split('')
773
+ .map((char) => {
774
+ if ('ai'.includes(char))
775
+ return VISEME_WIDE;
776
+ if ('ou'.includes(char))
777
+ return VISEME_ROUND;
778
+ if ('e'.includes(char))
779
+ return VISEME_SPREAD;
780
+ if ('mbp'.includes(char))
781
+ return VISEME_PRESSED;
782
+ if ('fv'.includes(char))
783
+ return VISEME_TEETH;
784
+ if (char === ' ')
785
+ return VISEME_SILENCE;
786
+ return VISEME_SPREAD; // default consonant
787
+ });
788
+ }
789
+
790
+ // libs/avatar-player/src/lib/geometry/svg-parts/mouth-shapes.ts/svg-parts/mouth-shapes.ts
791
+ // 6 viseme paths for lip-sync animation
792
+ // All paths fit within the face area (x: 82–118, y: 118–136)
793
+ // stroke="var(--lip-color)" stroke-width="3" fill="none" stroke-linecap="round"
794
+ const MOUTH_SHAPES = {
795
+ 0: 'M84,120 Q100,132 116,120', // silence — gentle smile
796
+ 1: 'M84,118 Q100,136 116,118', // A/I — wide open
797
+ 2: 'M90,118 Q100,130 110,118', // O/U — round
798
+ 3: 'M82,122 Q100,128 118,122', // E — spread
799
+ 4: 'M86,122 Q100,124 114,122', // M/B/P — pressed
800
+ 5: 'M84,120 Q92,126 100,122 Q108,126 116,120', // F/V — teeth-lip
801
+ };
802
+
803
+ // libs/avatar-player/src/lib/geometry/svg-parts/beard-path.ts/svg-parts/beard-path.ts
804
+ //
805
+ // Facial hair is authored as artwork, then FITTED to the head at render
806
+ // time.
807
+ //
808
+ // The shapes in beard-shapes.ts / mustache-shapes.ts were drawn against the
809
+ // man's face (cx=100 cy=88 rx=52 ry=56). A woman's head is a different
810
+ // ellipse, so the same path either juts past her jaw or leaves a gap.
811
+ //
812
+ // The first three attempts generated new geometry instead, which produced
813
+ // uniform bands that lost the hand-drawn character. This keeps the original
814
+ // silhouettes and rescales them by the ratio between the two faces, so:
815
+ // - a man is unchanged, exactly, by construction (the ratios are 1)
816
+ // - a woman gets the same shape at the same relative position on a
817
+ // proportionally smaller head
818
+ //
819
+ // The rescale is applied to the NUMBERS in the path, never as a CSS
820
+ // transform. An SVG transform attribute would be resolved against the
821
+ // element's bounding box under `transform-box: fill-box` and fling the
822
+ // beard across the face.
823
+ /** The two head shapes the renderer supports. */
824
+ const MAN_FACE = { cx: 100, cy: 88, rx: 52, ry: 56 };
825
+ const WOMAN_FACE = { cx: 100, cy: 86, rx: 49, ry: 55 };
826
+ function faceFor(gender) {
827
+ return gender === 'woman' ? WOMAN_FACE : MAN_FACE;
828
+ }
829
+ /** Half-width of the head at a given y. Returns 0 at or past the chin. */
830
+ function faceHalfWidthAt(face, y) {
831
+ const t = (y - face.cy) / face.ry;
832
+ if (t <= -1 || t >= 1)
833
+ return 0;
834
+ return face.rx * Math.sqrt(1 - t * t);
835
+ }
836
+ const r2 = (n) => Math.round(n * 100) / 100;
837
+ const lerp = (a, b, t) => a + (b - a) * t;
838
+ /** How far the beard's outer edge deliberately overshoots the face. */
839
+ const OVERHANG = 1;
840
+ /** Closed path through points, smoothed with quadratic midpoints. */
841
+ function smoothClosedPath(pts) {
842
+ if (pts.length < 3)
843
+ return '';
844
+ const mid = (a, b) => [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2];
845
+ let d = `M${r2(pts[0][0])},${r2(pts[0][1])}`;
846
+ for (let i = 1; i <= pts.length; i++) {
847
+ const cur = pts[i % pts.length];
848
+ const m = mid(cur, pts[(i + 1) % pts.length]);
849
+ d += ` Q${r2(cur[0])},${r2(cur[1])} ${r2(m[0])},${r2(m[1])}`;
850
+ }
851
+ return d + ' Z';
852
+ }
853
+ /**
854
+ * Rewrite every coordinate in a `d` attribute from the man's face into the
855
+ * target face. Safe because these shapes use only absolute M/L/Q/C/Z — no
856
+ * relative commands, no arcs, so all numbers come in x,y pairs.
857
+ */
858
+ function fitPathData(d, face) {
859
+ const sx = face.rx / MAN_FACE.rx;
860
+ const sy = face.ry / MAN_FACE.ry;
861
+ if (sx === 1 && sy === 1 && face.cx === MAN_FACE.cx && face.cy === MAN_FACE.cy) {
862
+ return d; // man's face — leave the artwork byte-for-byte alone
863
+ }
864
+ let i = 0;
865
+ return d.replace(/-?\d+(\.\d+)?/g, (m) => {
866
+ const v = parseFloat(m);
867
+ const isX = i % 2 === 0;
868
+ i++;
869
+ const scaled = isX
870
+ ? face.cx + (v - MAN_FACE.cx) * sx
871
+ : face.cy + (v - MAN_FACE.cy) * sy;
872
+ return String(r2(scaled));
873
+ });
874
+ }
875
+ /** Fit every `d` and every ellipse in a piece of SVG markup to a head. */
876
+ function fitToFace(markup, face) {
877
+ if (!markup)
878
+ return '';
879
+ const sx = face.rx / MAN_FACE.rx;
880
+ const sy = face.ry / MAN_FACE.ry;
881
+ return markup
882
+ .replace(/d="([^"]*)"/g, (_, d) => `d="${fitPathData(d, face)}"`)
883
+ .replace(/<ellipse([^>]*)\/>/g, (whole, attrs) => {
884
+ const out = attrs
885
+ .replace(/cx="(-?\d+(?:\.\d+)?)"/, (_, v) => `cx="${r2(face.cx + (parseFloat(v) - MAN_FACE.cx) * sx)}"`)
886
+ .replace(/cy="(-?\d+(?:\.\d+)?)"/, (_, v) => `cy="${r2(face.cy + (parseFloat(v) - MAN_FACE.cy) * sy)}"`)
887
+ .replace(/rx="(-?\d+(?:\.\d+)?)"/, (_, v) => `rx="${r2(parseFloat(v) * sx)}"`)
888
+ .replace(/ry="(-?\d+(?:\.\d+)?)"/, (_, v) => `ry="${r2(parseFloat(v) * sy)}"`);
889
+ return `<ellipse${out}/>`;
890
+ });
891
+ }
892
+ const BEARD_BANDS = {
893
+ // 5 o'clock shadow: low and faint, hugging the jaw only.
894
+ stubble: { startFrac: 0.36, endFrac: 0.97, inset: 3, thicknessTop: 4, thicknessBottom: 7 },
895
+ // Jaw-hugging crescent, tapering to nothing by the ears.
896
+ short: { startFrac: 0.17, endFrac: 0.99, inset: 2.5, thicknessTop: 4, thicknessBottom: 14 },
897
+ // Same crescent, then a narrow drop. Kept narrow on purpose: a wide drop
898
+ // reads as a bib rather than a beard.
899
+ long: {
900
+ startFrac: 0.17, endFrac: 0.99, inset: 2.5, thicknessTop: 4, thicknessBottom: 15,
901
+ drop: 0.30, dropWidth: 0.26,
902
+ },
903
+ // Chin patch only.
904
+ goatee: { startFrac: 0.60, endFrac: 0.99, inset: 13, thicknessTop: 5, thicknessBottom: 12 },
905
+ };
906
+ function buildBeardSvg(style, face, fill) {
907
+ if (style === 'none')
908
+ return '';
909
+ const o = BEARD_BANDS[style];
910
+ const steps = 26;
911
+ const yTop = face.cy + face.ry * o.startFrac;
912
+ const yJaw = face.cy + face.ry * o.endFrac;
913
+ const chinY = face.cy + face.ry;
914
+ const dropLen = o.drop ? face.ry * o.drop : 0;
915
+ const dropW = o.dropWidth ? face.rx * o.dropWidth : 0;
916
+ /**
917
+ * Outer half-width; past the chin it becomes the drop's taper.
918
+ *
919
+ * The band's outer edge is drawn slightly OUTSIDE the face outline, not
920
+ * inset inside it. Two reasons: the smoothing below passes through the
921
+ * MIDPOINTS between samples rather than through the samples themselves, so
922
+ * an edge placed exactly on the face ends up a sliver inside it and shows
923
+ * as a skin gap. Overhanging by a pixel and letting the face clip trim it
924
+ * back gives contact by construction instead of by tuning.
925
+ */
926
+ const outerHalf = (y) => {
927
+ if (y <= chinY)
928
+ return Math.max(0, faceHalfWidthAt(face, y) + OVERHANG);
929
+ if (!dropLen)
930
+ return 0;
931
+ const t = (y - chinY) / dropLen;
932
+ return t >= 1 ? 0 : dropW * Math.sqrt(1 - t * t);
933
+ };
934
+ const innerR = [];
935
+ const innerL = [];
936
+ for (let i = 0; i <= steps; i++) {
937
+ const t = i / steps;
938
+ const y = lerp(yTop, yJaw, t);
939
+ // The inner edge follows the same curve, a band-thickness inside it.
940
+ // Deriving it from the face is what closes the gap.
941
+ const hw = Math.max(0, faceHalfWidthAt(face, y) - o.inset - lerp(o.thicknessTop, o.thicknessBottom, t));
942
+ innerR.push([face.cx + hw, y]);
943
+ innerL.push([face.cx - hw, y]);
944
+ }
945
+ const outerR = [];
946
+ const outerL = [];
947
+ for (let i = 0; i <= steps; i++) {
948
+ const y = lerp(yTop, chinY + dropLen, i / steps);
949
+ const hw = outerHalf(y);
950
+ outerR.push([face.cx + hw, y]);
951
+ outerL.push([face.cx - hw, y]);
952
+ }
953
+ const ring = [
954
+ ...innerL,
955
+ ...[...innerR].reverse(),
956
+ ...outerR,
957
+ ...[...outerL].reverse(),
958
+ ];
959
+ const d = smoothClosedPath(ring);
960
+ if (!d)
961
+ return '';
962
+ // Stubble still relies on opacity rather than a second tone; logged for Phase 5.
963
+ const opacity = style === 'stubble' ? ' opacity="0.22"' : '';
964
+ return `<path d="${d}" fill="${fill}"${opacity}/>`;
965
+ }
966
+
967
+ // libs/avatar-player/src/lib/geometry/avatar-renderer.ts/avatar-renderer.ts
968
+ //
969
+ // SINGLE SOURCE OF TRUTH for avatar geometry.
970
+ //
971
+ // Both the live preview (SvgAvatarComponent) and the export bundle
972
+ // (AvatarService.buildSvg) call this. Previously each had its own copy
973
+ // of the render logic, and they drifted badly: the exporter ignored gender
974
+ // and omitted every clipPath, so head-wearing professions exported with
975
+ // hair punching through their hats.
976
+ //
977
+ // Nothing in here may read DOM state. Everything the renderer needs is
978
+ // derived from the config (plus an optional viseme), which is what makes
979
+ // the output reproducible and testable.
980
+ /**
981
+ * Professions whose accessory covers the top of the head, so hair must clip.
982
+ *
983
+ * `chef` is here because the toque band sits across the forehead; without it
984
+ * long hair renders straight through the hat.
985
+ */
986
+ const HAT_PROFESSIONS = new Set([
987
+ 'engineer',
988
+ 'police',
989
+ 'artist',
990
+ 'chef',
991
+ ]);
992
+ /** Professions with a full helmet, which clips hair AND facial hair. */
993
+ const HELMET_PROFESSIONS = new Set(['astronaut']);
994
+ /** Canonical canvas. */
995
+ const CANVAS = { width: 200, height: 200, viewBox: '0 0 200 200' };
996
+ /**
997
+ * Hat brim clip. A flat cut at y=56 — see the plan: deriving this from the
998
+ * actual hat geometry is Phase 3 work.
999
+ */
1000
+ const HAT_CLIP_RECT = { x: 0, y: 56, width: 200, height: 144 };
1001
+ /** Helmet aperture. */
1002
+ const HELMET_CLIP_ELLIPSE = { cx: 100, cy: 78, rx: 57, ry: 62 };
1003
+ /**
1004
+ * Build the `<animate>` child for the mouth, or '' when there is nothing to
1005
+ * animate. Consecutive duplicates are collapsed so the animation spends its
1006
+ * time on distinct mouth shapes rather than repeating the same one.
1007
+ */
1008
+ function mouthAnimation(text) {
1009
+ const visemes = textToVisemes(text);
1010
+ if (visemes.length === 0)
1011
+ return '';
1012
+ // Collapse runs of the same viseme, then always return to silence so the
1013
+ // loop does not snap between two open shapes.
1014
+ const seq = [];
1015
+ for (const v of visemes) {
1016
+ if (seq.length === 0 || seq[seq.length - 1] !== v)
1017
+ seq.push(v);
1018
+ }
1019
+ if (seq[seq.length - 1] !== 0)
1020
+ seq.push(0);
1021
+ const values = seq.map((v) => MOUTH_SHAPES[v] ?? MOUTH_SHAPES[0]);
1022
+ const totalMs = seq.length * MS_PER_VISEME;
1023
+ // calcMode="discrete" steps between the shapes with no interpolation —
1024
+ // which is what lip sync wants, and also avoids interpolating between
1025
+ // paths whose command counts differ (viseme 5 is a three-segment path).
1026
+ return (`<animate attributeName="d" calcMode="discrete" dur="${(totalMs / 1000).toFixed(3)}s"` +
1027
+ ` repeatCount="indefinite" values="${values.join(';')}"/>`);
1028
+ }
1029
+ function isAstronaut(config) {
1030
+ return HELMET_PROFESSIONS.has(config.profession);
1031
+ }
1032
+ function hasHat(config) {
1033
+ return HAT_PROFESSIONS.has(config.profession);
1034
+ }
1035
+ /** Clip reference for the hair-back / hair-front layers, or null. */
1036
+ function hairClipUrl(config, idsPrefix = '') {
1037
+ if (isAstronaut(config))
1038
+ return `url(#${idsPrefix}helmet-clip)`;
1039
+ if (hasHat(config))
1040
+ return `url(#${idsPrefix}hat-clip)`;
1041
+ return null;
1042
+ }
1043
+ /** Clip reference for ears / mustache / beard, or null. */
1044
+ function helmetClipUrl(config, idsPrefix = '') {
1045
+ return isAstronaut(config) ? `url(#${idsPrefix}helmet-clip)` : null;
1046
+ }
1047
+ /** CSS custom properties that drive every colour in the artwork. */
1048
+ function avatarCssVars(config) {
1049
+ const skin = SKIN_TONES[config.skinTone];
1050
+ return {
1051
+ '--skin-base': skin.base,
1052
+ '--skin-ear': skin.ear,
1053
+ '--skin-shadow': skin.shadow,
1054
+ '--lip-color': skin.lip,
1055
+ '--hair-color': HAIR_COLORS[config.hairColor],
1056
+ '--eye-color': EYE_COLORS[config.eyeColor],
1057
+ };
1058
+ }
1059
+ function cssVarDecls(config) {
1060
+ return Object.entries(avatarCssVars(config))
1061
+ .map(([k, v]) => `${k}: ${v}`)
1062
+ .join(';');
1063
+ }
1064
+ /** `clip-path="url(#x)"` only when the value exists — avoids empty attrs. */
1065
+ function clipAttr(url) {
1066
+ return url ? ` clip-path="${url}"` : '';
1067
+ }
1068
+ const LASH_PATHS = [
1069
+ 'M65,78 L62,74', 'M70,76 L68,72', 'M76,76 L78,72', 'M82,78 L85,74',
1070
+ 'M117,78 L114,74', 'M122,76 L120,72', 'M128,76 L130,72', 'M134,78 L137,74',
1071
+ ];
1072
+ /**
1073
+ * The avatar markup WITHOUT the outer `<svg>` element.
1074
+ * The preview component supplies its own root so it can keep Angular
1075
+ * bindings on it; the exporter wraps this in a standalone document.
1076
+ */
1077
+ function buildAvatarSvgInner(config, opts = {}) {
1078
+ const { viseme = 0, animated = false, idsPrefix = '' } = opts;
1079
+ const isWoman = config.gender === 'woman';
1080
+ const astronaut = isAstronaut(config);
1081
+ const hair = HAIR_SHAPES[config.haircut] ?? { back: '', front: '' };
1082
+ const eyes = EYE_SHAPES[config.eyeStyle] ?? { sclera: '', iris: '' };
1083
+ // Facial hair is authored once and rescaled to whichever head is in use.
1084
+ // A man is untouched (the ratios are exactly 1); a woman gets the same
1085
+ // silhouette at the same relative position on a proportionally smaller
1086
+ // head, which is what the hand-drawn shapes were missing.
1087
+ const facialHead = faceFor(config.gender);
1088
+ const mustache = fitToFace(MUSTACHE_SHAPES[config.mustache] ?? '', facialHead);
1089
+ const beard = buildBeardSvg(config.beard, facialHead, HAIR_COLORS[config.hairColor]);
1090
+ const glasses = GLASSES_SHAPES[config.glasses] ?? '';
1091
+ const profession = PROFESSION_LAYERS[config.profession] ?? { body: '', accessory: '' };
1092
+ const mouthPath = MOUTH_SHAPES[viseme] ?? MOUTH_SHAPES[0];
1093
+ const hairClip = hairClipUrl(config, idsPrefix);
1094
+ const helmetClip = helmetClipUrl(config, idsPrefix);
1095
+ // ── defs ────────────────────────────────────────────────────────────
1096
+ // Face containment for facial hair. The hand-drawn beard and moustache
1097
+ // artwork is not perfectly authored to the jawline — `stubble` overhangs
1098
+ // even on a man — so rescaling alone cannot guarantee they stay on the
1099
+ // head. This clip is derived from the face in use and is the backstop.
1100
+ // The ellipse arcs over the top, then runs straight down past the chin so
1101
+ // a long beard may still fall onto the chest.
1102
+ const faceClipPath = `M${facialHead.cx - facialHead.rx},${facialHead.cy} ` +
1103
+ `A${facialHead.rx},${facialHead.ry} 0 0 1 ${facialHead.cx + facialHead.rx},${facialHead.cy} ` +
1104
+ `L${facialHead.cx + facialHead.rx},192 L${facialHead.cx - facialHead.rx},192 Z`;
1105
+ const defs = `<defs>` +
1106
+ `<clipPath id="${idsPrefix}face-clip"><path d="${faceClipPath}"/></clipPath>` +
1107
+ `<clipPath id="${idsPrefix}hat-clip">` +
1108
+ `<rect x="${HAT_CLIP_RECT.x}" y="${HAT_CLIP_RECT.y}" width="${HAT_CLIP_RECT.width}" height="${HAT_CLIP_RECT.height}"/>` +
1109
+ `</clipPath>` +
1110
+ `<clipPath id="${idsPrefix}helmet-clip">` +
1111
+ `<ellipse cx="${HELMET_CLIP_ELLIPSE.cx}" cy="${HELMET_CLIP_ELLIPSE.cy}" rx="${HELMET_CLIP_ELLIPSE.rx}" ry="${HELMET_CLIP_ELLIPSE.ry}"/>` +
1112
+ `</clipPath>` +
1113
+ `</defs>`;
1114
+ // ── 1. body ──────────────────────────────────────────────────────────
1115
+ const body = `<g class="layer-body">${profession.body}</g>`;
1116
+ // ── 2. neck (hidden for astronaut — the collar covers it) ───────────
1117
+ const neck = astronaut
1118
+ ? ''
1119
+ : isWoman
1120
+ ? `<rect class="layer-neck" x="90" y="135" width="20" height="22" rx="8" fill="var(--skin-base)"/>`
1121
+ : `<rect class="layer-neck" x="88" y="135" width="24" height="22" rx="8" fill="var(--skin-base)"/>`;
1122
+ // ── 3. ears ─────────────────────────────────────────────────────────
1123
+ const earRx = isWoman ? 7 : 8;
1124
+ const earRy = isWoman ? 9 : 10;
1125
+ const ears = `<g class="layer-ears"${clipAttr(helmetClip)}>` +
1126
+ `<ellipse cx="48" cy="90" rx="${earRx}" ry="${earRy}" fill="var(--skin-ear)"/>` +
1127
+ `<ellipse cx="152" cy="90" rx="${earRx}" ry="${earRy}" fill="var(--skin-ear)"/>` +
1128
+ `</g>`;
1129
+ // ── 4. hair back ────────────────────────────────────────────────────
1130
+ const hairBack = `<g class="layer-hair-back"${clipAttr(hairClip)}>${hair.back}</g>`;
1131
+ // ── 5. face ─────────────────────────────────────────────────────────
1132
+ const face = isWoman
1133
+ ? `<ellipse class="layer-face" cx="100" cy="86" rx="49" ry="55" fill="var(--skin-base)"/>`
1134
+ : `<ellipse class="layer-face" cx="100" cy="88" rx="52" ry="56" fill="var(--skin-base)"/>`;
1135
+ // ── 6. eyes ─────────────────────────────────────────────────────────
1136
+ // `.pupils` is driven by --pupil-x/--pupil-y so no post-render DOM
1137
+ // patching is needed; see svg-avatar.component.scss.
1138
+ const eyeGroup = `<g class="layer-eyes">` +
1139
+ `<g>${eyes.sclera}</g>` +
1140
+ `<g class="pupils"><g>${eyes.iris}</g></g>` +
1141
+ `</g>`;
1142
+ // ── 6b. lashes (woman only) ─────────────────────────────────────────
1143
+ const lashes = isWoman
1144
+ ? `<g class="layer-lashes">${LASH_PATHS.map((d) => `<path d="${d}" stroke="var(--hair-color)" stroke-width="1.5" stroke-linecap="round" fill="none"/>`).join('')}</g>`
1145
+ : '';
1146
+ // ── 7. eyelids (blink) ──────────────────────────────────────────────
1147
+ // The eyelids are OPAQUE skin-coloured rects painted over the eyes. The
1148
+ // only thing that hides them is `transform: scaleY(0)` from the consuming
1149
+ // stylesheet — so a standalone SVG with no stylesheet ships with its eyes
1150
+ // completely covered. Two defences:
1151
+ // 1. static output omits the layer outright, because a still image has
1152
+ // nothing to blink
1153
+ // 2. animated output carries transform="scale(1,0)" as an inline
1154
+ // presentation attribute, so the eyes are hidden even if the CSS
1155
+ // never arrives. CSS still wins over the attribute, so blinking works.
1156
+ const eyelids = animated
1157
+ ? `<g class="layer-eyelids">` +
1158
+ `<rect class="eyelid-left" x="63" y="77" width="22" height="22" rx="11" fill="var(--skin-base)" transform="scale(1,0)"/>` +
1159
+ `<rect class="eyelid-right" x="115" y="77" width="22" height="22" rx="11" fill="var(--skin-base)" transform="scale(1,0)"/>` +
1160
+ `</g>`
1161
+ : '';
1162
+ // ── 8. brows ────────────────────────────────────────────────────────
1163
+ const brows = isWoman
1164
+ ? `<g class="layer-brows">` +
1165
+ `<path d="M64,74 Q74,69 84,73" stroke="var(--hair-color)" stroke-width="2" fill="none" stroke-linecap="round"/>` +
1166
+ `<path d="M116,73 Q126,69 136,74" stroke="var(--hair-color)" stroke-width="2" fill="none" stroke-linecap="round"/>` +
1167
+ `</g>`
1168
+ : `<g class="layer-brows">` +
1169
+ `<rect x="63" y="72" width="22" height="5" rx="3" fill="var(--hair-color)"/>` +
1170
+ `<rect x="115" y="72" width="22" height="5" rx="3" fill="var(--hair-color)"/>` +
1171
+ `</g>`;
1172
+ // ── 9. nose — uses --skin-shadow so it stays visible on deep skin ───
1173
+ const nose = isWoman
1174
+ ? `<path class="layer-nose" d="M98,104 Q100,110 102,104" stroke="var(--skin-shadow)" stroke-width="1.8" fill="none" stroke-linecap="round"/>`
1175
+ : `<path class="layer-nose" d="M97,105 Q100,112 103,105" stroke="var(--skin-shadow)" stroke-width="2" fill="none" stroke-linecap="round"/>`;
1176
+ // ── 10. mouth ───────────────────────────────────────────────────────
1177
+ // When `speech` is set the mouth carries a self-running SMIL animation,
1178
+ // so the file speaks on its own with no host page involved.
1179
+ const speech = opts.speech;
1180
+ const mouthAnim = speech ? mouthAnimation(speech) : '';
1181
+ const mouthInner = isWoman
1182
+ ? `stroke="var(--lip-color)" stroke-width="2.5" fill="var(--lip-color)" fill-opacity="0.35" stroke-linecap="round"`
1183
+ : `stroke="var(--lip-color)" stroke-width="2.5" fill="none" stroke-linecap="round"`;
1184
+ const mouth = `<path class="layer-mouth" d="${mouthPath}" ${mouthInner}>` +
1185
+ mouthAnim +
1186
+ `</path>`;
1187
+ // ── 11-14 ───────────────────────────────────────────────────────────
1188
+ // No CSS transform: the shape is rescaled in the path data itself. The
1189
+ // inner groups carry the face clip and the outer the helmet clip, because
1190
+ // an element can only take one clip-path.
1191
+ const facialHair = `<g class="layer-mustache"${clipAttr(helmetClip)}>` +
1192
+ `<g clip-path="url(#${idsPrefix}face-clip)">${mustache}</g></g>` +
1193
+ `<g class="layer-beard"${clipAttr(helmetClip)}>` +
1194
+ `<g clip-path="url(#${idsPrefix}face-clip)">${beard}</g></g>`;
1195
+ const glassesGroup = `<g class="layer-glasses">${glasses}</g>`;
1196
+ const hairFront = `<g class="layer-hair-front"${clipAttr(hairClip)}>${hair.front}</g>`;
1197
+ const accessory = `<g class="layer-accessory">${profession.accessory}</g>`;
1198
+ const head = `<g class="layer-head"${animated ? ' animate-idle' : ''}>` + [ears, hairBack, face, eyeGroup, lashes, eyelids, brows, nose, mouth, facialHair, glassesGroup, hairFront, accessory].join('') + `</g>`;
1199
+ return defs + body + neck + head;
1200
+ }
1201
+ /**
1202
+ * A complete, standalone SVG document. This is what gets written to disk
1203
+ * and what the demo viewer renders — it must depend on nothing outside
1204
+ * itself, so the palette is inlined as a `style` attribute on the root.
1205
+ *
1206
+ * `animated` is forced off regardless of what the caller passed. That flag
1207
+ * emits the `.animate-idle` class, which is driven by the consuming
1208
+ * component's stylesheet; a standalone file has no stylesheet, so the class
1209
+ * would be a promise the document cannot keep. `speech` still applies,
1210
+ * because SMIL travels inside the file.
1211
+ */
1212
+ function buildAvatarSvg(config, opts = {}) {
1213
+ const inner = buildAvatarSvgInner(config, { ...opts, animated: false });
1214
+ return (`<svg xmlns="http://www.w3.org/2000/svg" viewBox="${CANVAS.viewBox}"` +
1215
+ ` style="${cssVarDecls(config)}">` +
1216
+ inner +
1217
+ `</svg>`);
1218
+ }
1219
+
1220
+ /**
1221
+ * Blink, pupil drift and head-idle driving — framework-free.
1222
+ *
1223
+ * This was the body of an Angular `@Injectable()` service. Nothing in it ever
1224
+ * touched Angular, only `setTimeout` and `requestAnimationFrame`, so it lives
1225
+ * here and the Angular wrapper just delegates. The React player uses it
1226
+ * directly.
1227
+ *
1228
+ * Head idle is deliberately *not* here: it is a pure CSS animation, so it costs
1229
+ * no JavaScript and no framework re-render. See the stylesheet.
1230
+ */
1231
+ class AvatarAnimator {
1232
+ timers = [];
1233
+ rafs = [];
1234
+ /**
1235
+ * Start random blink animation.
1236
+ * Calls onState with CSS class names: 'blinking' → 'blink-half' → ''
1237
+ * Interval: 2.5–5s random for natural feel.
1238
+ */
1239
+ startBlink(onState) {
1240
+ const doBlink = () => {
1241
+ onState('blinking');
1242
+ const t1 = setTimeout(() => {
1243
+ onState('blink-half');
1244
+ const t2 = setTimeout(() => {
1245
+ onState('');
1246
+ // Schedule next blink: 2.5s–5s random interval
1247
+ const t3 = setTimeout(doBlink, 2500 + Math.random() * 2500);
1248
+ this.timers.push(t3);
1249
+ }, 50);
1250
+ this.timers.push(t2);
1251
+ }, 80);
1252
+ this.timers.push(t1);
1253
+ };
1254
+ const init = setTimeout(doBlink, 1000 + Math.random() * 1000);
1255
+ this.timers.push(init);
1256
+ }
1257
+ /**
1258
+ * Start random eye movement (pupil tracking).
1259
+ * Calls onOffset with {x, y} pixel offsets for the pupil group.
1260
+ */
1261
+ startEyeMovement(onOffset) {
1262
+ const move = () => {
1263
+ const dx = (Math.random() - 0.5) * 8;
1264
+ const dy = (Math.random() - 0.5) * 4;
1265
+ onOffset({ x: dx, y: dy });
1266
+ const t = setTimeout(move, 1800 + Math.random() * 2200);
1267
+ this.timers.push(t);
1268
+ };
1269
+ const init = setTimeout(move, 500);
1270
+ this.timers.push(init);
1271
+ }
1272
+ /**
1273
+ * Start subtle head idle motion via requestAnimationFrame.
1274
+ * Produces a gentle sine-wave rotation + vertical bob.
1275
+ */
1276
+ startHeadIdle(onTransform) {
1277
+ const start = performance.now();
1278
+ const tick = (now) => {
1279
+ const t = (now - start) / 1000;
1280
+ const rotZ = Math.sin(t * 0.6) * 1.5;
1281
+ const transY = Math.sin(t * 0.4) * 1.5;
1282
+ onTransform(`rotate(${rotZ}, 100, 150) translate(0, ${transY})`);
1283
+ this.rafs.push(requestAnimationFrame(tick));
1284
+ };
1285
+ this.rafs.push(requestAnimationFrame(tick));
1286
+ }
1287
+ /** Stop all running animations and clear timers/rafs. */
1288
+ stopAll() {
1289
+ this.timers.forEach(clearTimeout);
1290
+ this.rafs.forEach(cancelAnimationFrame);
1291
+ this.timers = [];
1292
+ this.rafs = [];
1293
+ }
1294
+ }
1295
+
1296
+ /**
1297
+ * Drives visemes in real time — framework-free.
1298
+ *
1299
+ * The text -> viseme mapping itself lives in ./visemes because the offline SVG
1300
+ * generator needs the exact same mapping to bake an animated mouth into an
1301
+ * exported file. Keeping one copy is the whole point — if this drifted from
1302
+ * the generator, exported avatars would mouth different words than the preview
1303
+ * does.
1304
+ */
1305
+ class LipSyncPlayer {
1306
+ MS_PER_VISEME = MS_PER_VISEME;
1307
+ /** @see textToVisemes */
1308
+ textToVisemes(text) {
1309
+ return textToVisemes(text);
1310
+ }
1311
+ /**
1312
+ * Play a viseme sequence, calling onViseme(id) each frame.
1313
+ *
1314
+ * `onEnd` fires exactly once when the sequence finishes or is cancelled, so
1315
+ * callers can clear a "speaking" flag reliably. Do NOT infer the end from
1316
+ * onViseme(0): viseme 0 is silence and occurs at every space, so it would
1317
+ * end the utterance at the first word gap.
1318
+ *
1319
+ * Returns a cancel function. Always resets to 0 (silence) on end or cancel.
1320
+ */
1321
+ play(visemes, onViseme, onEnd) {
1322
+ let i = 0;
1323
+ let cancelled = false;
1324
+ let finished = false;
1325
+ const finish = () => {
1326
+ if (finished)
1327
+ return;
1328
+ finished = true;
1329
+ onViseme(0); // reset to silence
1330
+ onEnd?.();
1331
+ };
1332
+ const tick = () => {
1333
+ if (cancelled || i >= visemes.length) {
1334
+ finish();
1335
+ return;
1336
+ }
1337
+ onViseme(visemes[i++]);
1338
+ setTimeout(tick, this.MS_PER_VISEME);
1339
+ };
1340
+ tick();
1341
+ return () => {
1342
+ cancelled = true;
1343
+ finish();
1344
+ };
1345
+ }
1346
+ }
1347
+
1348
+ /**
1349
+ * Public entry point of the avatar geometry.
1350
+ *
1351
+ * WHY THIS LIVES INSIDE THE ANGULAR LIBRARY
1352
+ *
1353
+ * The code below is plain TypeScript with no framework imports — it could sit in
1354
+ * a package of its own, and that is where it used to live. It lives here
1355
+ * because of a hard limit in ng-packagr:
1356
+ *
1357
+ * - ng-packagr treats EVERY bare specifier as external
1358
+ * (`isExternalDependency`, ng-packagr/lib/flatten/rollup.js), so a
1359
+ * `from 'avatar-core'` import ships unresolved and breaks the tarball.
1360
+ * - a relative import into another package does not work either: ng-packagr
1361
+ * compiles src/ -> dist/avatar-player/esm2022/ first and only then bundles,
1362
+ * so a path valid from src/ points at nothing from the intermediate dir.
1363
+ *
1364
+ * Living inside the library means ng-packagr compiles it as first-party source
1365
+ * and inlines it into the published bundle. The React package imports these
1366
+ * same files with a relative specifier at build time, so there is still exactly
1367
+ * ONE copy of the geometry in the repository and in CI.
1368
+ *
1369
+ * Consequence: consumers of angular-avatar-player and react-avatar-player get
1370
+ * their own copy of the renderer inside the tarball. That is deliberate — it
1371
+ * means `npm i angular-avatar-player` is a single, self-contained install.
1372
+ */
1373
+ // ── Color palettes ──────────────────────────────────────────
1374
+
1375
+ // libs/avatar-player/src/lib/services/lip-sync.service.ts
1376
+ /**
1377
+ * Angular-facing wrapper around the framework-free {@link LipSyncPlayer}.
1378
+ *
1379
+ * The logic lives in ../geometry so the React player and any non-Angular
1380
+ * renderer use the exact same mapping. This class exists only so Angular apps
1381
+ * can inject it and so DI gives each player component its own instance.
1382
+ */
1383
+ class LipSyncService extends LipSyncPlayer {
1384
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.18", ngImport: i0, type: LipSyncService, deps: null, target: i0.ɵɵFactoryTarget.Injectable });
1385
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "19.2.18", ngImport: i0, type: LipSyncService });
1386
+ }
1387
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.18", ngImport: i0, type: LipSyncService, decorators: [{
1388
+ type: Injectable
1389
+ }] });
1390
+
1391
+ // libs/avatar-player/src/lib/services/avatar-animation.service.ts
1392
+ /**
1393
+ * Angular-facing wrapper around the framework-free {@link AvatarAnimator}.
1394
+ *
1395
+ * The timers live in ../geometry so the React player can drive the same blink
1396
+ * and pupil logic. This class exists only so Angular apps can inject it and so
1397
+ * DI scopes one animator per component — a shared instance would mean one avatar
1398
+ * leaving the DOM stops blinking for every other avatar on the page.
1399
+ */
1400
+ class AvatarAnimationService extends AvatarAnimator {
1401
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.18", ngImport: i0, type: AvatarAnimationService, deps: null, target: i0.ɵɵFactoryTarget.Injectable });
1402
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "19.2.18", ngImport: i0, type: AvatarAnimationService });
1403
+ }
1404
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.18", ngImport: i0, type: AvatarAnimationService, decorators: [{
1405
+ type: Injectable
1406
+ }] });
1407
+
1408
+ // libs/avatar-player/src/lib/components/svg-avatar/svg-avatar.component.ts
1409
+ class SvgAvatarComponent {
1410
+ // ── Signal inputs ────────────────────────────────────────────
1411
+ //
1412
+ // These were `@Input` fields mirrored into signals via ngOnChanges.
1413
+ // That indirection was the root cause of four separate bugs: a `computed`
1414
+ // or `effect` that reads a plain property registers no dependency, so it
1415
+ // evaluates once and never invalidates. The symptoms were a frozen avatar,
1416
+ // permanently disabled undo/redo, and a dead Test Speech button.
1417
+ //
1418
+ // Signal inputs make that mistake impossible: reading `config()` inside a
1419
+ // computed registers a real dependency, so the derived values below cannot
1420
+ // silently go stale.
1421
+ config = input.required();
1422
+ animationsEnabled = input(true);
1423
+ /** 0–5, controlled externally for lip sync. */
1424
+ viseme = input(0);
1425
+ /**
1426
+ * Namespace for generated element ids. Set this when inlining more than
1427
+ * one avatar into a document, otherwise every avatar's `url(#hat-clip)`
1428
+ * resolves to the first matching element on the page.
1429
+ */
1430
+ idsPrefix = input('');
1431
+ sanitizer = inject(DomSanitizer);
1432
+ host = inject(ElementRef);
1433
+ zone = inject(NgZone);
1434
+ anim = inject(AvatarAnimationService);
1435
+ viewBox = CANVAS.viewBox;
1436
+ // Animation state (driven by AvatarAnimationService)
1437
+ eyeBlinkClass = signal('');
1438
+ pupilOffset = signal({ x: 0, y: 0 });
1439
+ /**
1440
+ * Palette plus the two pupil-offset custom properties the SCSS reads.
1441
+ *
1442
+ * A computed, not a getter: a getter returns a new SafeStyle on every
1443
+ * change-detection pass, so Angular re-wrote the style attribute each
1444
+ * cycle. A getter also cannot track `config`, so it went stale too.
1445
+ */
1446
+ cssVarsStyle = computed(() => {
1447
+ const p = this.pupilOffset();
1448
+ const decls = `--pupil-x: ${p.x}px; --pupil-y: ${p.y}px; ` +
1449
+ Object.entries(avatarCssVars(this.config()))
1450
+ .map(([k, v]) => `${k}: ${v}`)
1451
+ .join('; ');
1452
+ return this.sanitizer.bypassSecurityTrustStyle(decls);
1453
+ });
1454
+ /**
1455
+ * The markup is regenerated only when the *config* changes — deliberately
1456
+ * NOT when the viseme does. Re-assigning innerHTML mid-utterance would
1457
+ * recreate `.layer-head` and restart the head-idle animation every 80ms.
1458
+ * Viseme changes are applied to the existing mouth path below instead.
1459
+ */
1460
+ innerHtml = computed(() => this.sanitizer.bypassSecurityTrustHtml(buildAvatarSvgInner(this.config(), {
1461
+ animated: this.animationsEnabled(),
1462
+ idsPrefix: this.idsPrefix(),
1463
+ })));
1464
+ // Public shape helpers — the geometry itself lives in lib/shared.
1465
+ isAstronaut = computed(() => isAstronaut(this.config()));
1466
+ hasHat = computed(() => hasHat(this.config()));
1467
+ hairClipUrl = computed(() => hairClipUrl(this.config(), this.idsPrefix()));
1468
+ helmetClipUrl = computed(() => helmetClipUrl(this.config(), this.idsPrefix()));
1469
+ mouthPath = computed(() => MOUTH_SHAPES[this.viseme()] ?? MOUTH_SHAPES[0]);
1470
+ constructor() {
1471
+ // Keep the mouth in sync without re-rendering the whole avatar.
1472
+ effect(() => {
1473
+ // Both reads are signal-tracked: mouthPath so the mouth follows lip
1474
+ // sync, innerHtml so the path is re-applied after each markup swap,
1475
+ // when the fresh .layer-mouth element is guaranteed to be in the DOM.
1476
+ const path = this.mouthPath();
1477
+ this.innerHtml();
1478
+ this.applyMouthPath(path);
1479
+ });
1480
+ }
1481
+ applyMouthPath(path) {
1482
+ const mouth = this.host.nativeElement.querySelector('.layer-mouth');
1483
+ if (mouth) {
1484
+ mouth.setAttribute('d', path);
1485
+ return;
1486
+ }
1487
+ // Markup was swapped this tick and the element is not queryable yet.
1488
+ requestAnimationFrame(() => {
1489
+ this.host.nativeElement
1490
+ .querySelector('.layer-mouth')
1491
+ ?.setAttribute('d', path);
1492
+ });
1493
+ }
1494
+ ngOnInit() {
1495
+ if (this.animationsEnabled()) {
1496
+ this.zone.runOutsideAngular(() => {
1497
+ this.anim.startBlink((v) => this.zone.run(() => this.eyeBlinkClass.set(v)));
1498
+ this.anim.startEyeMovement((v) => this.zone.run(() => this.pupilOffset.set(v)));
1499
+ // Head idle is a pure CSS animation — no JS needed
1500
+ });
1501
+ }
1502
+ }
1503
+ ngOnDestroy() {
1504
+ this.anim.stopAll();
1505
+ }
1506
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.18", ngImport: i0, type: SvgAvatarComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
1507
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.1.0", version: "19.2.18", type: SvgAvatarComponent, isStandalone: true, selector: "app-svg-avatar", inputs: { config: { classPropertyName: "config", publicName: "config", isSignal: true, isRequired: true, transformFunction: null }, animationsEnabled: { classPropertyName: "animationsEnabled", publicName: "animationsEnabled", isSignal: true, isRequired: false, transformFunction: null }, viseme: { classPropertyName: "viseme", publicName: "viseme", isSignal: true, isRequired: false, transformFunction: null }, idsPrefix: { classPropertyName: "idsPrefix", publicName: "idsPrefix", isSignal: true, isRequired: false, transformFunction: null } }, providers: [AvatarAnimationService], ngImport: i0, template: `<svg xmlns="http://www.w3.org/2000/svg"
1508
+ [attr.viewBox]="viewBox"
1509
+ class="avatar-svg"
1510
+ [class.blink-blinking]="eyeBlinkClass() === 'blinking'"
1511
+ [class.blink-half]="eyeBlinkClass() === 'blink-half'"
1512
+ [style]="cssVarsStyle()"
1513
+ [innerHTML]="innerHtml()"></svg>`, isInline: true, styles: [".avatar-svg{display:block;width:100%;height:100%;overflow:visible;shape-rendering:geometricPrecision}.avatar-svg *{transform-box:fill-box}.avatar-svg .eyelid-left,.avatar-svg .eyelid-right{transform-origin:center center;transform:scaleY(0);transition:transform .05s linear}.avatar-svg.blink-blinking .eyelid-left,.avatar-svg.blink-blinking .eyelid-right{transform:scaleY(1)}.avatar-svg.blink-half .eyelid-left,.avatar-svg.blink-half .eyelid-right{transform:scaleY(.5)}.avatar-svg .layer-head{transform-origin:100px 150px}.avatar-svg .layer-head.animate-idle{animation:avatar-head-idle 8s ease-in-out infinite;will-change:transform}.avatar-svg .pupils{transform:translate(var(--pupil-x, 0px),var(--pupil-y, 0px));transition:transform .4s cubic-bezier(.25,.46,.45,.94);will-change:transform}.avatar-svg .layer-mouth{transition:d .06s linear}@keyframes avatar-head-idle{0%,to{transform:rotate(0) translate(0)}20%{transform:rotate(1.2deg) translateY(-1.2px)}50%{transform:rotate(-.8deg) translateY(.8px)}70%{transform:rotate(1.5deg) translateY(-1.5px)}}@media (prefers-reduced-motion: reduce){.avatar-svg .layer-head{animation:none!important;transform:none!important}.avatar-svg .pupils,.avatar-svg .layer-mouth,.avatar-svg .eyelid-left,.avatar-svg .eyelid-right{transition:none}}\n"], encapsulation: i0.ViewEncapsulation.None });
1514
+ }
1515
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.18", ngImport: i0, type: SvgAvatarComponent, decorators: [{
1516
+ type: Component,
1517
+ args: [{ selector: 'app-svg-avatar', standalone: true, encapsulation: ViewEncapsulation.None, providers: [AvatarAnimationService], template: `<svg xmlns="http://www.w3.org/2000/svg"
1518
+ [attr.viewBox]="viewBox"
1519
+ class="avatar-svg"
1520
+ [class.blink-blinking]="eyeBlinkClass() === 'blinking'"
1521
+ [class.blink-half]="eyeBlinkClass() === 'blink-half'"
1522
+ [style]="cssVarsStyle()"
1523
+ [innerHTML]="innerHtml()"></svg>`, styles: [".avatar-svg{display:block;width:100%;height:100%;overflow:visible;shape-rendering:geometricPrecision}.avatar-svg *{transform-box:fill-box}.avatar-svg .eyelid-left,.avatar-svg .eyelid-right{transform-origin:center center;transform:scaleY(0);transition:transform .05s linear}.avatar-svg.blink-blinking .eyelid-left,.avatar-svg.blink-blinking .eyelid-right{transform:scaleY(1)}.avatar-svg.blink-half .eyelid-left,.avatar-svg.blink-half .eyelid-right{transform:scaleY(.5)}.avatar-svg .layer-head{transform-origin:100px 150px}.avatar-svg .layer-head.animate-idle{animation:avatar-head-idle 8s ease-in-out infinite;will-change:transform}.avatar-svg .pupils{transform:translate(var(--pupil-x, 0px),var(--pupil-y, 0px));transition:transform .4s cubic-bezier(.25,.46,.45,.94);will-change:transform}.avatar-svg .layer-mouth{transition:d .06s linear}@keyframes avatar-head-idle{0%,to{transform:rotate(0) translate(0)}20%{transform:rotate(1.2deg) translateY(-1.2px)}50%{transform:rotate(-.8deg) translateY(.8px)}70%{transform:rotate(1.5deg) translateY(-1.5px)}}@media (prefers-reduced-motion: reduce){.avatar-svg .layer-head{animation:none!important;transform:none!important}.avatar-svg .pupils,.avatar-svg .layer-mouth,.avatar-svg .eyelid-left,.avatar-svg .eyelid-right{transition:none}}\n"] }]
1524
+ }], ctorParameters: () => [] });
1525
+
1526
+ // libs/avatar-player/src/lib/components/avatar-player/avatar-player.component.ts
1527
+ class AvatarPlayerComponent {
1528
+ config;
1529
+ speaking = false;
1530
+ message = '';
1531
+ /**
1532
+ * Namespace for the generated `clipPath` ids.
1533
+ *
1534
+ * REQUIRED when more than one player is on a page. Every avatar emits
1535
+ * `url(#hat-clip)`, which resolves against the *first* matching element in
1536
+ * the document — so without a unique prefix per avatar they all clip against
1537
+ * whichever came first, and hats and helmets stop hiding hair.
1538
+ */
1539
+ idsPrefix = '';
1540
+ /** Base edge length in px. It grows while speaking. */
1541
+ size = 48;
1542
+ currentViseme = signal(0);
1543
+ lipSync = inject(LipSyncService);
1544
+ zone = inject(NgZone);
1545
+ lipSyncCancelFn = null;
1546
+ ngOnChanges(changes) {
1547
+ if (changes['speaking'] || changes['message']) {
1548
+ // Always cancel previous playback before starting new
1549
+ this.lipSyncCancelFn?.();
1550
+ if (this.speaking && this.message) {
1551
+ const visemes = this.lipSync.textToVisemes(this.message);
1552
+ this.lipSyncCancelFn = this.lipSync.play(visemes, (v) => {
1553
+ this.zone.run(() => this.currentViseme.set(v));
1554
+ });
1555
+ }
1556
+ else {
1557
+ this.currentViseme.set(0);
1558
+ }
1559
+ }
1560
+ }
1561
+ /** Speaking reads as a small "leaning in", so the avatar grows. */
1562
+ displaySize() {
1563
+ return this.speaking ? this.size * 1.5 : this.size;
1564
+ }
1565
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.18", ngImport: i0, type: AvatarPlayerComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
1566
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "19.2.18", type: AvatarPlayerComponent, isStandalone: true, selector: "app-avatar-player", inputs: { config: "config", speaking: "speaking", message: "message", idsPrefix: "idsPrefix", size: "size" }, providers: [LipSyncService], usesOnChanges: true, ngImport: i0, template: `
1567
+ <div class="avatar-player" [class.avatar-player--speaking]="speaking">
1568
+ <app-svg-avatar
1569
+ [config]="config"
1570
+ [animationsEnabled]="true"
1571
+ [viseme]="currentViseme()"
1572
+ [idsPrefix]="idsPrefix"
1573
+ [style.width.px]="displaySize()"
1574
+ [style.height.px]="displaySize()"
1575
+ class="player-avatar" />
1576
+ </div>
1577
+ `, isInline: true, styles: [".avatar-player{display:inline-flex;position:relative}.avatar-player .player-avatar{border-radius:50%;transition:width .2s ease,height .2s ease}\n"], dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "component", type: SvgAvatarComponent, selector: "app-svg-avatar", inputs: ["config", "animationsEnabled", "viseme", "idsPrefix"] }] });
1578
+ }
1579
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.18", ngImport: i0, type: AvatarPlayerComponent, decorators: [{
1580
+ type: Component,
1581
+ args: [{ selector: 'app-avatar-player', standalone: true, imports: [CommonModule, SvgAvatarComponent], providers: [LipSyncService], template: `
1582
+ <div class="avatar-player" [class.avatar-player--speaking]="speaking">
1583
+ <app-svg-avatar
1584
+ [config]="config"
1585
+ [animationsEnabled]="true"
1586
+ [viseme]="currentViseme()"
1587
+ [idsPrefix]="idsPrefix"
1588
+ [style.width.px]="displaySize()"
1589
+ [style.height.px]="displaySize()"
1590
+ class="player-avatar" />
1591
+ </div>
1592
+ `, styles: [".avatar-player{display:inline-flex;position:relative}.avatar-player .player-avatar{border-radius:50%;transition:width .2s ease,height .2s ease}\n"] }]
1593
+ }], propDecorators: { config: [{
1594
+ type: Input,
1595
+ args: [{ required: true }]
1596
+ }], speaking: [{
1597
+ type: Input
1598
+ }], message: [{
1599
+ type: Input
1600
+ }], idsPrefix: [{
1601
+ type: Input
1602
+ }], size: [{
1603
+ type: Input
1604
+ }] } });
1605
+
1606
+ // libs/avatar-player/src/lib/avatar-player.module.ts
1607
+ class AvatarPlayerModule {
1608
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.18", ngImport: i0, type: AvatarPlayerModule, deps: [], target: i0.ɵɵFactoryTarget.NgModule });
1609
+ static ɵmod = i0.ɵɵngDeclareNgModule({ minVersion: "14.0.0", version: "19.2.18", ngImport: i0, type: AvatarPlayerModule, imports: [CommonModule,
1610
+ // Standalone components imported into the module
1611
+ SvgAvatarComponent,
1612
+ AvatarPlayerComponent], exports: [
1613
+ // Only these two need to be exported for consumers
1614
+ SvgAvatarComponent,
1615
+ AvatarPlayerComponent] });
1616
+ static ɵinj = i0.ɵɵngDeclareInjector({ minVersion: "12.0.0", version: "19.2.18", ngImport: i0, type: AvatarPlayerModule, providers: [
1617
+ LipSyncService,
1618
+ AvatarAnimationService,
1619
+ ], imports: [CommonModule,
1620
+ AvatarPlayerComponent] });
1621
+ }
1622
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.18", ngImport: i0, type: AvatarPlayerModule, decorators: [{
1623
+ type: NgModule,
1624
+ args: [{
1625
+ imports: [
1626
+ CommonModule,
1627
+ // Standalone components imported into the module
1628
+ SvgAvatarComponent,
1629
+ AvatarPlayerComponent,
1630
+ ],
1631
+ exports: [
1632
+ // Only these two need to be exported for consumers
1633
+ SvgAvatarComponent,
1634
+ AvatarPlayerComponent,
1635
+ ],
1636
+ providers: [
1637
+ LipSyncService,
1638
+ AvatarAnimationService,
1639
+ ],
1640
+ }]
1641
+ }] });
1642
+
1643
+ /**
1644
+ * Single public entry point of the Angular avatar package.
1645
+ *
1646
+ * A consuming app only ever imports from here:
1647
+ *
1648
+ * import { AvatarPlayerComponent } from 'angular-avatar-player';
1649
+ *
1650
+ * The SVG geometry, palettes and viseme mapping live in ./lib/geometry — plain
1651
+ * TypeScript with no Angular imports, but shipped inside this package. They are
1652
+ * re-exported below, so there is one import path and a consumer never needs a
1653
+ * second package to reach the renderer. `react-avatar-player` compiles the same
1654
+ * geometry files into its own bundle, which is what keeps the two players from
1655
+ * drifting apart.
1656
+ */
1657
+ // ── Components ──────────────────────────────────────────────
1658
+
1659
+ /**
1660
+ * Generated bundle index. Do not edit.
1661
+ */
1662
+
1663
+ export { AvatarAnimationService, AvatarAnimator, AvatarPlayerComponent, AvatarPlayerModule, BEARD_SHAPES, CANVAS, EYE_COLORS, EYE_SHAPES, GLASSES_SHAPES, HAIR_COLORS, HAIR_SHAPES, HAT_PROFESSIONS, HELMET_PROFESSIONS, LipSyncPlayer, LipSyncService, MAN_FACE, MOUTH_SHAPES, MS_PER_VISEME, MUSTACHE_SHAPES, PROFESSION_LAYERS, SKIN_TONES, SvgAvatarComponent, VISEME_PRESSED, VISEME_ROUND, VISEME_SILENCE, VISEME_SPREAD, VISEME_TEETH, VISEME_WIDE, WOMAN_FACE, avatarCssVars, buildAvatarSvg, buildAvatarSvgInner, buildBeardSvg, buildTraitPreview, faceFor, faceHalfWidthAt, fitToFace, hairClipUrl, hasHat, helmetClipUrl, isAstronaut, textToVisemes };
1664
+ //# sourceMappingURL=angular-avatar-player.mjs.map