@usenavii/core 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/parts.js CHANGED
@@ -133,6 +133,63 @@ var ANCHORS = {
133
133
  groundY: 94,
134
134
  cheekY: 53,
135
135
  cheekOffset: 12
136
+ },
137
+ // Squircle — FULL-BLEED. Body fills 0-100, face composed as ID portrait:
138
+ // upper-third eye line, lower-third mouth, generous cheek/eye spread.
139
+ squircle: {
140
+ cx: 50,
141
+ eyeY: 44,
142
+ eyeOffset: 13,
143
+ eyeScale: 1,
144
+ mouthY: 62,
145
+ mouthSpan: 8,
146
+ topperX: 50,
147
+ topperY: 8,
148
+ groundY: 98,
149
+ cheekY: 52,
150
+ cheekOffset: 24
151
+ },
152
+ // Pumpkin — round body w/ slight horizontal lobing. Face mid-low, stem on top.
153
+ pumpkin: {
154
+ cx: 50,
155
+ eyeY: 52,
156
+ eyeOffset: 11,
157
+ eyeScale: 1,
158
+ mouthY: 66,
159
+ mouthSpan: 9,
160
+ topperX: 50,
161
+ topperY: 18,
162
+ groundY: 88,
163
+ cheekY: 60,
164
+ cheekOffset: 20
165
+ },
166
+ // Ghost — tall wavy silhouette. Face high (under the "hood"), wavy hem below.
167
+ ghost: {
168
+ cx: 50,
169
+ eyeY: 42,
170
+ eyeOffset: 8,
171
+ eyeScale: 1.05,
172
+ mouthY: 54,
173
+ mouthSpan: 6,
174
+ topperX: 50,
175
+ topperY: 12,
176
+ groundY: 92,
177
+ cheekY: 50,
178
+ cheekOffset: 14
179
+ },
180
+ // SkullHead — slightly elongated egg w/ deep eye sockets.
181
+ skullHead: {
182
+ cx: 50,
183
+ eyeY: 50,
184
+ eyeOffset: 10,
185
+ eyeScale: 1,
186
+ mouthY: 70,
187
+ mouthSpan: 7,
188
+ topperX: 50,
189
+ topperY: 16,
190
+ groundY: 90,
191
+ cheekY: 60,
192
+ cheekOffset: 16
136
193
  }
137
194
  };
138
195
 
@@ -153,30 +210,52 @@ var BODY_PATHS = {
153
210
  // Taro — gourd shape: small head bulge, fuller bottom
154
211
  taro: "M50 14 C58 14 64 22 64 30 C64 36 60 40 60 46 C60 54 76 60 78 76 C80 88 66 91 50 91 C34 91 20 88 22 76 C24 60 40 54 40 46 C40 40 36 36 36 30 C36 22 42 14 50 14 Z",
155
212
  // Wisp — tall narrow body, slight bottom flare, ghost-like
156
- wisp: "M50 12 C60 12 66 24 66 40 C66 60 74 78 70 90 C64 96 36 96 30 90 C26 78 34 60 34 40 C34 24 40 12 50 12 Z"
213
+ wisp: "M50 12 C60 12 66 24 66 40 C66 60 74 78 70 90 C64 96 36 96 30 90 C26 78 34 60 34 40 C34 24 40 12 50 12 Z",
214
+ // Squircle — FULL-BLEED corporate plate. Fills entire viewport with tight
215
+ // corner radius (~4px). Reads as a tile / ID photo not a contained avatar.
216
+ // Pairs with `flat: true` packs (Office) — face floats on a wall of color.
217
+ squircle: "M4 0 C2 0 0 2 0 4 C0 35 0 65 0 96 C0 98 2 100 4 100 C35 100 65 100 96 100 C98 100 100 98 100 96 C100 65 100 35 100 4 C100 2 98 0 96 0 C65 0 35 0 4 0 Z",
218
+ // Pumpkin — round, slightly wider than tall, with subtle horizontal "lobes"
219
+ // for the iconic carved-pumpkin gourd shape. Stem area kept clear at top.
220
+ pumpkin: "M50 18 C70 18 86 30 86 52 C86 74 70 88 50 88 C30 88 14 74 14 52 C14 30 30 18 50 18 Z",
221
+ // Ghost — soft rounded top with wavy bottom hem (3 humps), evoking a sheet.
222
+ ghost: "M50 12 C68 12 78 24 78 42 C78 60 80 76 80 88 L74 84 L68 90 L62 84 L56 90 L50 84 L44 90 L38 84 L32 90 L26 84 L20 88 C20 76 22 60 22 42 C22 24 32 12 50 12 Z",
223
+ // SkullHead — egg-ish shape, slight pinch at jaw for skull silhouette.
224
+ skullHead: "M50 16 C68 16 80 30 80 50 C80 64 76 72 70 78 L68 86 L60 88 L60 82 L40 82 L40 88 L32 86 L30 78 C24 72 20 64 20 50 C20 30 32 16 50 16 Z"
157
225
  };
158
226
  function bodyAnchor(id) {
159
227
  return ANCHORS[id];
160
228
  }
161
- function renderBodyDefs(_id, _palette, gradId) {
229
+ function renderBodyDefs(_id, palette, gradId, opts) {
230
+ if (opts?.flat) {
231
+ return `<radialGradient id="${gradId}"><stop offset="0%" stop-color="${palette.bodyFrom}" /><stop offset="100%" stop-color="${palette.bodyFrom}" /></radialGradient>`;
232
+ }
162
233
  return `
163
234
  <radialGradient id="${gradId}" cx="42%" cy="32%" r="68%">
164
- <stop offset="0%" stop-color="${_palette.bodyFrom}" />
165
- <stop offset="100%" stop-color="${_palette.bodyTo}" />
235
+ <stop offset="0%" stop-color="${palette.bodyFrom}" />
236
+ <stop offset="100%" stop-color="${palette.bodyTo}" />
166
237
  </radialGradient>`.trim();
167
238
  }
168
- function renderBody(id, palette, gradId) {
239
+ function renderBody(id, palette, gradId, opts) {
169
240
  const d = BODY_PATHS[id];
170
241
  const a = ANCHORS[id];
242
+ const flat = opts?.flat === true;
171
243
  const outlineColor = withAlpha(palette.ink, 0.18);
172
- return [
173
- // Ground shadow — soft ellipse just below body, grounds the figure
174
- `<ellipse cx="${a.cx}" cy="${a.groundY + 4}" rx="22" ry="2.6" fill="${palette.ink}" opacity="0.16" />`,
175
- // Body fill
176
- `<path d="${d}" fill="url(#${gradId})" stroke="${outlineColor}" stroke-width="0.7" />`,
177
- // Sheen — small light spot upper-left, scaled to body
178
- `<ellipse cx="${a.cx - 12}" cy="${a.eyeY - 14}" rx="11" ry="7" fill="#FFFFFF" opacity="0.22" transform="rotate(-18 ${a.cx - 12} ${a.eyeY - 14})" />`
179
- ].join("");
244
+ const parts = [];
245
+ if (!flat) {
246
+ parts.push(
247
+ `<ellipse cx="${a.cx}" cy="${a.groundY + 4}" rx="22" ry="2.6" fill="${palette.ink}" opacity="0.16" />`
248
+ );
249
+ }
250
+ parts.push(
251
+ flat ? `<path d="${d}" fill="url(#${gradId})" />` : `<path d="${d}" fill="url(#${gradId})" stroke="${outlineColor}" stroke-width="0.7" />`
252
+ );
253
+ if (!flat) {
254
+ parts.push(
255
+ `<ellipse cx="${a.cx - 12}" cy="${a.eyeY - 14}" rx="11" ry="7" fill="#FFFFFF" opacity="0.22" transform="rotate(-18 ${a.cx - 12} ${a.eyeY - 14})" />`
256
+ );
257
+ }
258
+ return parts.join("");
180
259
  }
181
260
  function withAlpha(hex, alpha) {
182
261
  const h = hex.replace("#", "");
@@ -189,12 +268,13 @@ function withAlpha(hex, alpha) {
189
268
  }
190
269
 
191
270
  // src/parts/eyes.ts
192
- function renderEyes(id, palette, anchor) {
271
+ function renderEyes(id, palette, anchor, opts) {
193
272
  const lx = anchor.cx - anchor.eyeOffset;
194
273
  const rx = anchor.cx + anchor.eyeOffset;
195
274
  const y = anchor.eyeY;
196
275
  const s = anchor.eyeScale;
197
276
  const ink = palette.ink;
277
+ const sw = opts?.strokeMul ?? 1;
198
278
  switch (id) {
199
279
  case "round":
200
280
  return [
@@ -216,24 +296,24 @@ function renderEyes(id, palette, anchor) {
216
296
  ].join("");
217
297
  case "squint":
218
298
  return [
219
- arc(lx - 4.5, y, lx, y - 3.5, lx + 4.5, y, ink, 1.8),
220
- arc(rx - 4.5, y, rx, y - 3.5, rx + 4.5, y, ink, 1.8)
299
+ arc(lx - 4.5, y, lx, y - 3.5, lx + 4.5, y, ink, 1.8 * sw),
300
+ arc(rx - 4.5, y, rx, y - 3.5, rx + 4.5, y, ink, 1.8 * sw)
221
301
  ].join("");
222
302
  case "wink":
223
303
  return [
224
304
  sclera(lx, y, 4 * s, 4.5 * s),
225
305
  pupil(lx, y, 2.2 * s, ink),
226
306
  glint(lx + 1, y - 1),
227
- arc(rx - 4, y, rx, y - 3.5, rx + 4, y, ink, 1.8)
307
+ arc(rx - 4, y, rx, y - 3.5, rx + 4, y, ink, 1.8 * sw)
228
308
  ].join("");
229
309
  case "sleepy":
230
310
  return [
231
311
  // Heavier upper lid — half-closed
232
- `<path d="M${lx - 4} ${y - 0.5} Q${lx} ${y + 2} ${lx + 4} ${y - 0.5}" stroke="${ink}" stroke-width="1.7" stroke-linecap="round" fill="none" />`,
233
- `<path d="M${rx - 4} ${y - 0.5} Q${rx} ${y + 2} ${rx + 4} ${y - 0.5}" stroke="${ink}" stroke-width="1.7" stroke-linecap="round" fill="none" />`,
312
+ `<path d="M${lx - 4} ${y - 0.5} Q${lx} ${y + 2} ${lx + 4} ${y - 0.5}" stroke="${ink}" stroke-width="${1.7 * sw}" stroke-linecap="round" fill="none" />`,
313
+ `<path d="M${rx - 4} ${y - 0.5} Q${rx} ${y + 2} ${rx + 4} ${y - 0.5}" stroke="${ink}" stroke-width="${1.7 * sw}" stroke-linecap="round" fill="none" />`,
234
314
  // tiny visible pupils
235
- `<circle cx="${lx}" cy="${y + 0.5}" r="0.9" fill="${ink}" />`,
236
- `<circle cx="${rx}" cy="${y + 0.5}" r="0.9" fill="${ink}" />`
315
+ `<circle cx="${lx}" cy="${y + 0.5}" r="${0.9 * sw}" fill="${ink}" />`,
316
+ `<circle cx="${rx}" cy="${y + 0.5}" r="${0.9 * sw}" fill="${ink}" />`
237
317
  ].join("");
238
318
  case "star":
239
319
  return [starEye(lx, y, ink), starEye(rx, y, ink)].join("");
@@ -250,20 +330,20 @@ function renderEyes(id, palette, anchor) {
250
330
  ].join("");
251
331
  case "dot":
252
332
  return [
253
- `<circle cx="${lx}" cy="${y}" r="${1.4 * s}" fill="${ink}" />`,
254
- `<circle cx="${rx}" cy="${y}" r="${1.4 * s}" fill="${ink}" />`
333
+ `<circle cx="${lx}" cy="${y}" r="${1.4 * s * sw}" fill="${ink}" />`,
334
+ `<circle cx="${rx}" cy="${y}" r="${1.4 * s * sw}" fill="${ink}" />`
255
335
  ].join("");
256
336
  case "cross":
257
- return [crossEye(lx, y, ink), crossEye(rx, y, ink)].join("");
337
+ return [crossEye(lx, y, ink, sw), crossEye(rx, y, ink, sw)].join("");
258
338
  }
259
339
  }
260
340
  function heartEye(cx, cy, color) {
261
341
  const s = 2;
262
342
  return `<path d="M${cx} ${cy + s * 1.4} L${cx - s * 1.8} ${cy - s * 0.2} A${s} ${s} 0 0 1 ${cx} ${cy - s * 0.6} A${s} ${s} 0 0 1 ${cx + s * 1.8} ${cy - s * 0.2} Z" fill="${color}" />`;
263
343
  }
264
- function crossEye(cx, cy, color) {
344
+ function crossEye(cx, cy, color, sw = 1) {
265
345
  const s = 2.4;
266
- return `<g stroke="${color}" stroke-width="1.6" stroke-linecap="round"><line x1="${cx - s}" y1="${cy - s}" x2="${cx + s}" y2="${cy + s}" /><line x1="${cx - s}" y1="${cy + s}" x2="${cx + s}" y2="${cy - s}" /></g>`;
346
+ return `<g stroke="${color}" stroke-width="${1.6 * sw}" stroke-linecap="round"><line x1="${cx - s}" y1="${cy - s}" x2="${cx + s}" y2="${cy + s}" /><line x1="${cx - s}" y1="${cy + s}" x2="${cx + s}" y2="${cy - s}" /></g>`;
267
347
  }
268
348
  function sclera(cx, cy, rx, ry) {
269
349
  return `<ellipse cx="${cx}" cy="${cy}" rx="${rx}" ry="${ry}" fill="#FFFFFF" />`;
@@ -283,41 +363,68 @@ function starEye(cx, cy, color) {
283
363
  }
284
364
 
285
365
  // src/parts/mouth.ts
286
- function renderMouth(id, palette, anchor, curveScale = 1) {
366
+ function renderMouth(id, palette, anchor, curveScale = 1, opts) {
287
367
  const cx = anchor.cx;
288
368
  const y = anchor.mouthY;
289
369
  const w = anchor.mouthSpan * curveScale;
290
370
  const ink = palette.ink;
371
+ const sw = opts?.strokeMul ?? 1;
372
+ const base = 1.8 * sw;
291
373
  switch (id) {
292
374
  case "smile":
293
- return `<path d="M${cx - w} ${y} Q${cx} ${y + 5} ${cx + w} ${y}" stroke="${ink}" stroke-width="1.8" stroke-linecap="round" fill="none" />`;
375
+ return `<path d="M${cx - w} ${y} Q${cx} ${y + 5} ${cx + w} ${y}" stroke="${ink}" stroke-width="${base}" stroke-linecap="round" fill="none" />`;
294
376
  case "grin":
295
- return `<path d="M${cx - w - 1} ${y - 2} Q${cx} ${y + 7} ${cx + w + 1} ${y - 2}" stroke="${ink}" stroke-width="1.8" stroke-linecap="round" fill="none" />`;
377
+ return `<path d="M${cx - w - 1} ${y - 2} Q${cx} ${y + 7} ${cx + w + 1} ${y - 2}" stroke="${ink}" stroke-width="${base}" stroke-linecap="round" fill="none" />`;
296
378
  case "open":
297
379
  return [
298
- `<path d="M${cx - w - 1} ${y - 2} Q${cx} ${y + 9} ${cx + w + 1} ${y - 2}" stroke="${ink}" stroke-width="1.8" stroke-linecap="round" fill="${ink}" fill-opacity="0.55" />`,
380
+ `<path d="M${cx - w - 1} ${y - 2} Q${cx} ${y + 9} ${cx + w + 1} ${y - 2}" stroke="${ink}" stroke-width="${base}" stroke-linecap="round" fill="${ink}" fill-opacity="0.55" />`,
299
381
  `<ellipse cx="${cx}" cy="${y + 3}" rx="${w * 0.55}" ry="1.8" fill="#F472B6" opacity="0.75" />`
300
382
  ].join("");
301
383
  case "flat":
302
- return `<path d="M${cx - w + 1} ${y} L${cx + w - 1} ${y}" stroke="${ink}" stroke-width="1.8" stroke-linecap="round" fill="none" />`;
384
+ return `<path d="M${cx - w + 1} ${y} L${cx + w - 1} ${y}" stroke="${ink}" stroke-width="${base}" stroke-linecap="round" fill="none" />`;
303
385
  case "smirk":
304
- return `<path d="M${cx - w} ${y} Q${cx} ${y + 3} ${cx + w + 1} ${y - 2}" stroke="${ink}" stroke-width="1.8" stroke-linecap="round" fill="none" />`;
386
+ return `<path d="M${cx - w} ${y} Q${cx} ${y + 3} ${cx + w + 1} ${y - 2}" stroke="${ink}" stroke-width="${base}" stroke-linecap="round" fill="none" />`;
305
387
  case "awe":
306
388
  return `<ellipse cx="${cx}" cy="${y + 1}" rx="${w * 0.45}" ry="3.2" fill="${ink}" opacity="0.85" />`;
307
389
  case "tongue":
308
390
  return [
309
- `<path d="M${cx - w} ${y} Q${cx} ${y + 6} ${cx + w} ${y}" stroke="${ink}" stroke-width="1.8" stroke-linecap="round" fill="none" />`,
310
- `<path d="M${cx - 2} ${y + 4} Q${cx} ${y + 9} ${cx + 2} ${y + 4} Z" fill="#F472B6" stroke="${ink}" stroke-width="0.6" />`
391
+ `<path d="M${cx - w} ${y} Q${cx} ${y + 6} ${cx + w} ${y}" stroke="${ink}" stroke-width="${base}" stroke-linecap="round" fill="none" />`,
392
+ `<path d="M${cx - 2} ${y + 4} Q${cx} ${y + 9} ${cx + 2} ${y + 4} Z" fill="#F472B6" stroke="${ink}" stroke-width="${0.6 * sw}" />`
311
393
  ].join("");
312
394
  case "tooth":
313
395
  return [
314
- `<path d="M${cx - w} ${y} Q${cx} ${y + 5} ${cx + w} ${y}" stroke="${ink}" stroke-width="1.8" stroke-linecap="round" fill="none" />`,
315
- `<rect x="${cx - 1.2}" y="${y + 0.4}" width="2.4" height="2.6" rx="0.4" fill="#FFFFFF" stroke="${ink}" stroke-width="0.4" />`
396
+ `<path d="M${cx - w} ${y} Q${cx} ${y + 5} ${cx + w} ${y}" stroke="${ink}" stroke-width="${base}" stroke-linecap="round" fill="none" />`,
397
+ `<rect x="${cx - 1.2}" y="${y + 0.4}" width="2.4" height="2.6" rx="0.4" fill="#FFFFFF" stroke="${ink}" stroke-width="${0.4 * sw}" />`
316
398
  ].join("");
317
399
  case "wave":
318
- return `<path d="M${cx - w} ${y + 1} Q${cx - w / 2} ${y - 1.5} ${cx} ${y + 1} Q${cx + w / 2} ${y + 3.5} ${cx + w} ${y + 1}" stroke="${ink}" stroke-width="1.8" stroke-linecap="round" fill="none" />`;
400
+ return `<path d="M${cx - w} ${y + 1} Q${cx - w / 2} ${y - 1.5} ${cx} ${y + 1} Q${cx + w / 2} ${y + 3.5} ${cx + w} ${y + 1}" stroke="${ink}" stroke-width="${base}" stroke-linecap="round" fill="none" />`;
319
401
  case "dot":
320
- return `<circle cx="${cx}" cy="${y + 1}" r="1.2" fill="${ink}" />`;
402
+ return `<circle cx="${cx}" cy="${y + 1}" r="${1.2 * sw}" fill="${ink}" />`;
403
+ case "jagged": {
404
+ const half = w + 1;
405
+ const top = y - 1;
406
+ const bot = y + 5;
407
+ const step = half * 2 / 8;
408
+ const x0 = cx - half;
409
+ const points = [];
410
+ points.push(`${x0} ${top}`);
411
+ for (let i = 1; i <= 8; i++) {
412
+ const px = x0 + step * i;
413
+ const py = i % 2 === 1 ? bot : top;
414
+ points.push(`${px.toFixed(2)} ${py}`);
415
+ }
416
+ return `<path d="M${points.join(" L")} L${cx + half} ${top} Z" fill="${ink}" />`;
417
+ }
418
+ case "fangs": {
419
+ const half = w - 1;
420
+ return [
421
+ `<path d="M${cx - half} ${y} L${cx + half} ${y}" stroke="${ink}" stroke-width="${base}" stroke-linecap="round" fill="none" />`,
422
+ // Left fang
423
+ `<path d="M${cx - 2.5} ${y + 0.4} L${cx - 1.2} ${y + 4.5} L${cx - 0.2} ${y + 0.4} Z" fill="#FFFFFF" stroke="${ink}" stroke-width="${0.5 * sw}" />`,
424
+ // Right fang
425
+ `<path d="M${cx + 0.2} ${y + 0.4} L${cx + 1.2} ${y + 4.5} L${cx + 2.5} ${y + 0.4} Z" fill="#FFFFFF" stroke="${ink}" stroke-width="${0.5 * sw}" />`
426
+ ].join("");
427
+ }
321
428
  }
322
429
  }
323
430
 
@@ -354,7 +461,8 @@ function renderAntenna(id, anchor, palette) {
354
461
  }
355
462
 
356
463
  // src/parts/accessory.ts
357
- function renderAccessory(id, palette, anchor) {
464
+ function renderAccessory(id, palette, anchor, opts) {
465
+ const sw = opts?.strokeMul ?? 1;
358
466
  switch (id) {
359
467
  case "none":
360
468
  return "";
@@ -389,10 +497,11 @@ function renderAccessory(id, palette, anchor) {
389
497
  const rx = anchor.cx + anchor.eyeOffset;
390
498
  const y = anchor.eyeY;
391
499
  const r = 6;
500
+ const gw = 1.2 * sw;
392
501
  return [
393
- `<circle cx="${lx}" cy="${y}" r="${r}" fill="none" stroke="${palette.ink}" stroke-width="1.2" />`,
394
- `<circle cx="${rx}" cy="${y}" r="${r}" fill="none" stroke="${palette.ink}" stroke-width="1.2" />`,
395
- `<line x1="${lx + r}" y1="${y}" x2="${rx - r}" y2="${y}" stroke="${palette.ink}" stroke-width="1.2" />`,
502
+ `<circle cx="${lx}" cy="${y}" r="${r}" fill="none" stroke="${palette.ink}" stroke-width="${gw}" />`,
503
+ `<circle cx="${rx}" cy="${y}" r="${r}" fill="none" stroke="${palette.ink}" stroke-width="${gw}" />`,
504
+ `<line x1="${lx + r}" y1="${y}" x2="${rx - r}" y2="${y}" stroke="${palette.ink}" stroke-width="${gw}" />`,
396
505
  // subtle lens fill
397
506
  `<circle cx="${lx}" cy="${y}" r="${r - 1}" fill="#FFFFFF" opacity="0.18" />`,
398
507
  `<circle cx="${rx}" cy="${y}" r="${r - 1}" fill="#FFFFFF" opacity="0.18" />`
@@ -410,6 +519,20 @@ function renderAccessory(id, palette, anchor) {
410
519
  case "mole": {
411
520
  return `<circle cx="${anchor.cx - anchor.cheekOffset * 0.6}" cy="${anchor.cheekY + 2}" r="0.9" fill="${palette.ink}" />`;
412
521
  }
522
+ case "earring": {
523
+ const ex = anchor.cheekOffset + 4;
524
+ const ey = anchor.cheekY + 4;
525
+ const lx = anchor.cx - ex;
526
+ const rx = anchor.cx + ex;
527
+ return [
528
+ // Left earring — small stud + drop
529
+ `<circle cx="${lx}" cy="${ey}" r="${1.1 * sw}" fill="${palette.accent}" stroke="${palette.ink}" stroke-width="${0.4 * sw}" />`,
530
+ `<ellipse cx="${lx}" cy="${ey + 3.2}" rx="${1.3 * sw}" ry="${2 * sw}" fill="${palette.accent}" stroke="${palette.ink}" stroke-width="${0.4 * sw}" />`,
531
+ // Right earring
532
+ `<circle cx="${rx}" cy="${ey}" r="${1.1 * sw}" fill="${palette.accent}" stroke="${palette.ink}" stroke-width="${0.4 * sw}" />`,
533
+ `<ellipse cx="${rx}" cy="${ey + 3.2}" rx="${1.3 * sw}" ry="${2 * sw}" fill="${palette.accent}" stroke="${palette.ink}" stroke-width="${0.4 * sw}" />`
534
+ ].join("");
535
+ }
413
536
  }
414
537
  function dot(cx, cy, color) {
415
538
  return `<circle cx="${cx}" cy="${cy}" r="0.85" fill="${color}" opacity="0.55" />`;
@@ -503,6 +626,84 @@ function renderTopper(id, anchor, palette) {
503
626
  `<path d="M${cx - 5} ${topY + 4} L${cx - 6} ${topY - 4} M${cx - 6} ${topY - 4} L${cx - 10} ${topY - 6} M${cx - 6} ${topY - 4} L${cx - 6} ${topY - 9} M${cx - 6} ${topY - 9} L${cx - 8} ${topY - 11} M${cx - 6} ${topY - 9} L${cx - 3} ${topY - 11}" stroke="${ink}" stroke-width="1.3" stroke-linecap="round" fill="none" />`,
504
627
  `<path d="M${cx + 5} ${topY + 4} L${cx + 6} ${topY - 4} M${cx + 6} ${topY - 4} L${cx + 10} ${topY - 6} M${cx + 6} ${topY - 4} L${cx + 6} ${topY - 9} M${cx + 6} ${topY - 9} L${cx + 8} ${topY - 11} M${cx + 6} ${topY - 9} L${cx + 3} ${topY - 11}" stroke="${ink}" stroke-width="1.3" stroke-linecap="round" fill="none" />`
505
628
  ].join("");
629
+ case "bob": {
630
+ const eyeY = anchor.eyeY;
631
+ const tt = topY - 4;
632
+ const bt = eyeY + 6;
633
+ return [
634
+ // Main hair cap — slightly asymmetric for soft look
635
+ `<path d="M${cx - 24} ${bt} Q${cx - 26} ${eyeY - 4} ${cx - 22} ${tt + 4} Q${cx - 14} ${tt - 2} ${cx} ${tt - 3} Q${cx + 14} ${tt - 2} ${cx + 22} ${tt + 4} Q${cx + 26} ${eyeY - 4} ${cx + 24} ${bt} Q${cx + 18} ${eyeY + 2} ${cx + 14} ${eyeY - 2} Q${cx} ${eyeY - 8} ${cx - 14} ${eyeY - 2} Q${cx - 18} ${eyeY + 2} ${cx - 24} ${bt} Z" fill="${ink}" opacity="0.92" />`,
636
+ // Subtle highlight strand
637
+ `<path d="M${cx - 14} ${tt + 4} Q${cx - 6} ${tt + 2} ${cx + 2} ${tt + 6}" stroke="${palette.accent}" stroke-width="0.6" fill="none" opacity="0.25" />`
638
+ ].join("");
639
+ }
640
+ case "bun": {
641
+ const baseY = topY + 4;
642
+ const bunY = topY - 8;
643
+ return [
644
+ // Hair base on crown
645
+ `<path d="M${cx - 16} ${baseY} Q${cx} ${topY - 4} ${cx + 16} ${baseY} Q${cx + 12} ${baseY - 4} ${cx} ${baseY - 6} Q${cx - 12} ${baseY - 4} ${cx - 16} ${baseY} Z" fill="${ink}" opacity="0.92" />`,
646
+ // Bun disc
647
+ `<ellipse cx="${cx}" cy="${bunY}" rx="6" ry="5" fill="${ink}" opacity="0.95" />`,
648
+ // Bun wrap detail
649
+ `<ellipse cx="${cx}" cy="${bunY - 0.5}" rx="3.5" ry="2.5" fill="none" stroke="${palette.accent}" stroke-width="0.4" opacity="0.4" />`
650
+ ].join("");
651
+ }
652
+ case "witchHat": {
653
+ const tipY = topY - 26;
654
+ const baseY = topY + 2;
655
+ return [
656
+ // Cone — slight curve, tilts right
657
+ `<path d="M${cx - 14} ${baseY} Q${cx - 4} ${baseY - 10} ${cx + 4} ${tipY} Q${cx + 2} ${baseY - 4} ${cx + 14} ${baseY} Z" fill="${ink}" opacity="0.96" />`,
658
+ // Brim — wide flat oval w/ slight curve
659
+ `<ellipse cx="${cx}" cy="${baseY + 2}" rx="22" ry="3.4" fill="${ink}" opacity="0.96" />`,
660
+ // Band across cone base
661
+ `<rect x="${cx - 14}" y="${baseY - 4}" width="28" height="3" fill="${palette.accent}" opacity="0.85" />`,
662
+ // Buckle
663
+ `<rect x="${cx - 2}" y="${baseY - 4}" width="4" height="3" fill="${palette.bodyFrom}" stroke="${ink}" stroke-width="0.4" />`,
664
+ // Star/moon sparkle near tip
665
+ `<circle cx="${cx + 2}" cy="${tipY + 6}" r="0.9" fill="${palette.accent}" opacity="0.9" />`
666
+ ].join("");
667
+ }
668
+ case "pumpkinStem": {
669
+ return [
670
+ // Main stem — slightly curved
671
+ `<path d="M${cx - 2} ${topY + 4} Q${cx} ${topY - 2} ${cx + 1} ${topY - 8} L${cx + 3} ${topY - 8} Q${cx + 4} ${topY} ${cx + 2} ${topY + 4} Z" fill="#3F6F2C" stroke="${ink}" stroke-width="0.5" />`,
672
+ // Leaf curling off
673
+ `<path d="M${cx + 3} ${topY - 4} Q${cx + 9} ${topY - 8} ${cx + 12} ${topY - 4} Q${cx + 8} ${topY - 2} ${cx + 3} ${topY - 2} Z" fill="#4A8035" stroke="${ink}" stroke-width="0.4" />`,
674
+ // Vein on leaf
675
+ `<path d="M${cx + 5} ${topY - 3} L${cx + 11} ${topY - 5}" stroke="#2D5020" stroke-width="0.4" />`
676
+ ].join("");
677
+ }
678
+ case "ghostSheet": {
679
+ return [
680
+ // Sheet cap — wider than body, hangs lower at sides
681
+ `<path d="M${cx - 22} ${topY + 8} Q${cx - 26} ${topY - 4} ${cx - 14} ${topY - 10} Q${cx} ${topY - 14} ${cx + 14} ${topY - 10} Q${cx + 26} ${topY - 4} ${cx + 22} ${topY + 8} Q${cx + 12} ${topY + 4} ${cx} ${topY + 6} Q${cx - 12} ${topY + 4} ${cx - 22} ${topY + 8} Z" fill="${palette.accent}" stroke="${ink}" stroke-width="0.6" opacity="0.9" />`,
682
+ // Fold shadows
683
+ `<path d="M${cx - 12} ${topY - 6} Q${cx - 10} ${topY - 2} ${cx - 14} ${topY + 4}" stroke="${ink}" stroke-width="0.45" fill="none" opacity="0.35" />`,
684
+ `<path d="M${cx + 12} ${topY - 6} Q${cx + 10} ${topY - 2} ${cx + 14} ${topY + 4}" stroke="${ink}" stroke-width="0.45" fill="none" opacity="0.35" />`
685
+ ].join("");
686
+ }
687
+ case "ponytail": {
688
+ const eyeY = anchor.eyeY;
689
+ const fh = eyeY - 7;
690
+ const crownY = topY;
691
+ const baseX = cx + 18;
692
+ const baseY = crownY + 6;
693
+ return [
694
+ // Sleek hair cap — narrower than bob, hugs the crown, soft hairline.
695
+ `<path d="M${cx - 22} ${fh} Q${cx - 24} ${crownY - 2} ${cx - 12} ${crownY - 4} L${cx + 14} ${crownY - 4} Q${cx + 24} ${crownY} ${cx + 22} ${fh} Q${cx + 10} ${fh - 1} ${cx} ${fh + 2} Q${cx - 10} ${fh - 1} ${cx - 22} ${fh} Z" fill="${ink}" opacity="0.94" />`,
696
+ // Subtle highlight sweeping back toward the tie
697
+ `<path d="M${cx - 12} ${crownY - 2} Q${cx} ${crownY - 3} ${baseX - 2} ${baseY - 2}" stroke="${palette.accent}" stroke-width="0.5" fill="none" opacity="0.3" />`,
698
+ // Ponytail tie — small ring where the hair gathers
699
+ `<ellipse cx="${baseX}" cy="${baseY}" rx="3" ry="2.4" fill="${ink}" opacity="0.95" />`,
700
+ `<ellipse cx="${baseX}" cy="${baseY}" rx="1.4" ry="1.1" fill="${palette.accent}" opacity="0.32" />`,
701
+ // Tail — long tapered strand curving down and slightly out
702
+ `<path d="M${baseX - 1} ${baseY + 2} Q${baseX + 5} ${baseY + 10} ${baseX + 8} ${baseY + 20} Q${baseX + 9} ${baseY + 28} ${baseX + 4} ${baseY + 30} Q${baseX + 1} ${baseY + 22} ${baseX - 3} ${baseY + 12} Z" fill="${ink}" opacity="0.92" />`,
703
+ // Inner highlight following the tail's flow direction
704
+ `<path d="M${baseX + 2} ${baseY + 6} Q${baseX + 5} ${baseY + 16} ${baseX + 6} ${baseY + 24}" stroke="${palette.accent}" stroke-width="0.5" fill="none" opacity="0.25" />`
705
+ ].join("");
706
+ }
506
707
  }
507
708
  }
508
709
 
@@ -573,6 +774,20 @@ function renderOutfit(id, anchor, palette) {
573
774
  `<circle cx="${cx}" cy="${cy + 7}" r="1.6" fill="${accent}" stroke="${ink}" stroke-width="0.5" />`,
574
775
  `<circle cx="${cx}" cy="${cy + 7}" r="0.7" fill="${palette.blush}" />`
575
776
  ].join("");
777
+ case "tie": {
778
+ const knotTop = cy - 3;
779
+ const knotBot = cy + 1;
780
+ return [
781
+ // Shirt-collar peek behind the tie (so tie reads as worn over a shirt)
782
+ `<path d="M${cx - 11} ${cy - 2} L${cx - 3} ${knotBot} L${cx + 3} ${knotBot} L${cx + 11} ${cy - 2} L${cx + 6} ${cy + 6} L${cx - 6} ${cy + 6} Z" fill="${accent}" stroke="${ink}" stroke-width="0.55" />`,
783
+ // Knot — small trapezoid centered
784
+ `<path d="M${cx - 3.2} ${knotTop} L${cx + 3.2} ${knotTop} L${cx + 2.4} ${knotBot} L${cx - 2.4} ${knotBot} Z" fill="${palette.bodyTo}" stroke="${ink}" stroke-width="0.5" />`,
785
+ // Blade — narrower at top, widens, then pointed tip at bottom
786
+ `<path d="M${cx - 2.4} ${knotBot} L${cx + 2.4} ${knotBot} L${cx + 3.4} ${cy + 6} L${cx + 2.8} ${cy + 12} L${cx} ${cy + 15} L${cx - 2.8} ${cy + 12} L${cx - 3.4} ${cy + 6} Z" fill="${palette.bodyTo}" stroke="${ink}" stroke-width="0.5" />`,
787
+ // Subtle highlight stripe down the blade
788
+ `<path d="M${cx} ${knotBot + 0.5} L${cx} ${cy + 13.5}" stroke="${ink}" stroke-width="0.35" opacity="0.35" />`
789
+ ].join("");
790
+ }
576
791
  }
577
792
  }
578
793
 
@@ -586,6 +801,9 @@ var BODY_IDS = [
586
801
  "dumpling",
587
802
  "taro",
588
803
  "wisp"
804
+ // NOTE: 'squircle' is intentionally NOT in the base pool — pack-only body so
805
+ // existing seeds keep their original picks (no determinism shift). Packs can
806
+ // opt-in via `picks.body: ['squircle']`.
589
807
  ];
590
808
  var EYE_IDS = [
591
809
  "round",
@@ -647,7 +865,8 @@ var OUTFIT_IDS = [
647
865
  "scarf",
648
866
  "bowtie",
649
867
  "sunflower",
650
- "necklace"
868
+ "necklace",
869
+ "tie"
651
870
  ];
652
871
 
653
872
  export { ACCESSORY_IDS, ANCHORS, ANTENNA_IDS, BACKGROUND_IDS, BODY_IDS, EYE_IDS, MOUTH_IDS, OUTFIT_IDS, PALETTES, PALETTE_BY_ID, TOPPER_IDS, bodyAnchor, renderAccessory, renderAntenna, renderBackground, renderBody, renderBodyDefs, renderEyes, renderMouth, renderOutfit, renderTopper };