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