@doan-labs/peek 1.1.0 → 2.0.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/src/draw.ts CHANGED
@@ -9,7 +9,8 @@
9
9
  * what is invisible (opacity 0) to stay small.
10
10
  */
11
11
 
12
- import type { Axes, Persona } from './identity'
12
+ import { blocParts } from './bloc'
13
+ import { type Axes, fnv1a, mulberry32, type Persona } from './identity'
13
14
  import {
14
15
  ACCESSORY_INK,
15
16
  type Channel,
@@ -19,7 +20,10 @@ import {
19
20
  type Expression,
20
21
  FACES,
21
22
  type FaceGeom,
23
+ HAZE,
22
24
  INK,
25
+ type Variant,
26
+ WHEEL,
23
27
  } from './tables'
24
28
 
25
29
  export type Node = {
@@ -55,9 +59,12 @@ export type DrawOptions = {
55
59
  title: string | false
56
60
  /** Keep invisible nodes so an animator can write into them. */
57
61
  live?: boolean
62
+ /** How the face is drawn. Default 'peek'. */
63
+ variant?: Variant
58
64
  }
59
65
 
60
- export type Drawable = Axes & { persona: Persona }
66
+ /** `key`, the tidied name, seeds Haze's blobs; without it, it hazes as ''. */
67
+ export type Drawable = Axes & { persona: Persona; key?: string }
61
68
 
62
69
  /* The frame: viewBox -30 -30 400 400, the floor on its bottom edge. */
63
70
  export const VB = [-30, -30, 400, 400] as const
@@ -70,7 +77,7 @@ export const smooth = (a: number, b: number, v: number) => {
70
77
  const t = clamp((v - a) / (b - a), 0, 1)
71
78
  return t * t * (3 - 2 * t)
72
79
  }
73
- const f2 = (v: number) => String(Math.round(v * 100) / 100)
80
+ export const f2 = (v: number) => String(Math.round(v * 100) / 100)
74
81
  const f3 = (v: number) => String(Math.round(v * 1000) / 1000)
75
82
  const strokeFor = (px: number) =>
76
83
  px >= 96 ? 1 : px >= 56 ? 1.25 : px >= 40 ? 1.6 : px >= 30 ? 2 : 2.4
@@ -213,7 +220,12 @@ export function ringWalk(sh: FaceGeom): { rest: number; min: number } {
213
220
  }
214
221
 
215
222
  type Attrs = Record<string, string | number | false | undefined>
216
- function n(tag: string, key: string, attrs: Attrs, children: Node[] = []) {
223
+ export function n(
224
+ tag: string,
225
+ key: string,
226
+ attrs: Attrs,
227
+ children: Node[] = [],
228
+ ) {
217
229
  const out: Record<string, string> = {}
218
230
  for (const k in attrs) {
219
231
  const v = attrs[k]
@@ -232,14 +244,11 @@ function prune(node: Node): Node | null {
232
244
 
233
245
  export function draw(who: Drawable, pose: Pose, o: DrawOptions): Node {
234
246
  const sh: FaceGeom = FACES[who.face]
235
- const { body: fill, deep } = COLORS[who.color]
236
247
  const P = who.persona
237
248
  const mul = P.mul as Partial<Record<Channel, number>>
238
249
  const add = P.add as Partial<Record<Channel, number>>
239
250
  const g = (c: Channel) => pose[c] * (mul[c] ?? 1) + (add[c] ?? 0)
240
- const spread = P.spread
241
251
  const id = o.id
242
- const st = strokeFor(o.size)
243
252
  const { baseY, R } = frameOf(sh)
244
253
  const defs: Node[] = []
245
254
 
@@ -368,8 +377,262 @@ export function draw(who: Drawable, pose: Pose, o: DrawOptions): Node {
368
377
  const B = sh.bottom
369
378
  const ty = baseY - alt * R
370
379
  const sc = (v: number) => Math.round(v * 100000) / 100000
371
- const bodyNode =
380
+ const [front, back] = (o.variant === 'bloc' ? blocParts : peekParts)({
381
+ who,
382
+ pose,
383
+ o,
384
+ defs,
385
+ g,
386
+ })
387
+
388
+ // the plates sit a hair out of register at large sizes
389
+ const off = (dx: number, dy: number) =>
390
+ riso ? `translate(${f2(dx * k * upp)} ${f2(dy * k * upp)})` : undefined
391
+ const av = n(
392
+ 'g',
393
+ 'av',
394
+ {
395
+ transform: `translate(${f2(x)} ${f2(ty)}) rotate(${f2(rot)} 170 ${B}) translate(170 ${B}) scale(${sc(sx)} ${sc(sy)}) translate(-170 ${-B})`,
396
+ filter: dim > 0.01 ? `url(#${id}-dim)` : undefined,
397
+ },
398
+ [
399
+ n(
400
+ 'g',
401
+ 'plateA',
402
+ {
403
+ transform: off(-0.55, 0.45),
404
+ filter: riso ? `url(#${id}-pA)` : undefined,
405
+ },
406
+ front,
407
+ ),
408
+ n(
409
+ 'g',
410
+ 'plateB',
411
+ {
412
+ transform: off(0.6, -0.5),
413
+ filter: riso ? `url(#${id}-pB)` : undefined,
414
+ },
415
+ back,
416
+ ),
417
+ ],
418
+ )
419
+
420
+ // the thing in the corner: a dot lattice from the mark's vocabulary
421
+ const scene: Node[] = []
422
+ if (o.frame !== 'none') {
423
+ const bg =
424
+ o.frame === 'ink' ? INK.ink : o.frame === 'bone' ? INK.bone : INK.paper
425
+ scene.push(n('rect', 'bg', { ...VBr, fill: bg }))
426
+ const th = clamp(g('thing'), 0, 1)
427
+ const dots: Node[] = []
428
+ for (let j = 0; j < 3; j++)
429
+ for (let i = 0; i < 3; i++) {
430
+ const q = j * 3 + i
431
+ const kk = smooth(q / 12, q / 12 + 0.35, th)
432
+ dots.push(
433
+ n('circle', `lattice.${q}`, {
434
+ cx: 312 + i * 18,
435
+ cy: 2 + j * 18,
436
+ r: f2(1.2 + 2.2 * kk),
437
+ opacity: f3(kk * 0.55),
438
+ }),
439
+ )
440
+ }
441
+ scene.push(
442
+ n('g', 'lattice', { fill: o.frame === 'ink' ? INK.bone : INK.ink }, dots),
443
+ )
444
+ }
445
+ scene.push(av)
446
+ if (grainy)
447
+ scene.push(
448
+ n('rect', 'grain', {
449
+ ...VBr,
450
+ filter: `url(#${id}-grain)`,
451
+ opacity: o.frame === 'ink' ? 0.3 : 0.16,
452
+ 'pointer-events': 'none',
453
+ }),
454
+ )
455
+
456
+ const root = n(
457
+ 'svg',
458
+ 'root',
459
+ {
460
+ xmlns: 'http://www.w3.org/2000/svg',
461
+ viewBox: VB.join(' '),
462
+ width: o.size,
463
+ height: o.size,
464
+ role: o.title === false ? undefined : 'img',
465
+ 'aria-label': o.title === false ? undefined : o.title,
466
+ 'aria-hidden': o.title === false ? 'true' : undefined,
467
+ },
468
+ [
469
+ n('defs', 'defs', {}, defs),
470
+ n('g', 'frame', { 'clip-path': `url(#${id}-f)` }, scene),
471
+ ],
472
+ )
473
+ return o.live ? root : (prune(root) ?? root)
474
+ }
475
+
476
+ /** What a variant draws from: the defs it may add to, and the pose reader. */
477
+ export type Parts = {
478
+ who: Drawable
479
+ pose: Pose
480
+ o: DrawOptions
481
+ defs: Node[]
482
+ /** A channel with the persona applied. */
483
+ g: (c: Channel) => number
484
+ }
485
+
486
+ /*
487
+ * Haze, from the Haze sheet: the body is its own ink plus three soft radial
488
+ * blobs, cut to the outline. A deep one at the bottom, a tint from a hue
489
+ * neighbor low on the outer edge, a glow at the top. Where they sit comes
490
+ * from the name, mulberry32(fnv1a('haze:' + key)), so two faces of one
491
+ * color haze apart.
492
+ * Blob sizes are in body widths; the light level, used below 32px, keeps
493
+ * the face nearly peek where the haze would only be mud.
494
+ */
495
+ const HAZE_LEVEL = {
496
+ medium: {
497
+ deep: [0.502, 0.224],
498
+ glow: [0.749, 0.337],
499
+ tint: [0.4, 0.5],
500
+ tintStops: [
501
+ [0, 0.878],
502
+ [0.15, 0.878],
503
+ [0.618, 0.396],
504
+ ],
505
+ },
506
+ light: {
507
+ deep: [0.298, 0.133],
508
+ glow: [0.549, 0.247],
509
+ tint: [0.31, 0.387],
510
+ tintStops: [
511
+ [0, 0.549],
512
+ [0.55, 0.247],
513
+ ],
514
+ },
515
+ } as const
516
+
517
+ function hazePaint(who: Drawable, o: DrawOptions): Node[] {
518
+ const sh: FaceGeom = FACES[who.face]
519
+ const id = o.id
520
+ const L = HAZE_LEVEL[o.size < 32 ? 'light' : 'medium']
521
+ // the body's box: the crown grown by its radius, down to the bottom
522
+ const xs = sh.crown.poly.map((p) => p[0])
523
+ const ys = sh.crown.poly.map((p) => p[1])
524
+ const x0 = Math.min(...xs) - sh.crown.r
525
+ const y0 = Math.min(...ys) - sh.crown.r
526
+ const W = Math.max(...xs) + sh.crown.r - x0
527
+ const H = sh.bottom - y0
528
+
529
+ // the sheet's own seed and draw order, fitted to its 12 names: never
530
+ // reorder these lines. The tint walks one track, low and out to high and in.
531
+ const rnd = mulberry32(fnv1a(`haze:${who.key ?? ''}`))
532
+ const warm = rnd() < 0.5
533
+ const left = rnd() < 0.5
534
+ const t = rnd()
535
+ const tintX = 0.04 + 0.174 * t
536
+ const tintY = 0.649 + 0.202 * t
537
+ const glowX = 0.38 - 0.12 * rnd()
538
+ const glowY = 0.12 + 0.099 * rnd()
539
+ const deepX = 0.251 - 0.151 * rnd()
540
+ const tintA = 90 - 180 * rnd()
541
+
542
+ const at = (inset: number, onLeft: boolean) =>
543
+ x0 + W * (onLeft ? inset : 1 - inset)
544
+ const i = WHEEL.indexOf(who.color)
545
+ const near = WHEEL[(i + (warm ? WHEEL.length - 1 : 1)) % WHEEL.length]!
546
+ const { body, deep } = COLORS[who.color]
547
+ const blob = (
548
+ k: string,
549
+ color: string,
550
+ [cx, cy, a, rx, ry]: [number, number, number, number, number],
551
+ stops: readonly (readonly [number, number])[],
552
+ ) =>
553
+ n(
554
+ 'radialGradient',
555
+ `haze.${k}`,
556
+ {
557
+ id: `${id}-h${k[0]}`,
558
+ gradientUnits: 'userSpaceOnUse',
559
+ cx: 0,
560
+ cy: 0,
561
+ r: 1,
562
+ gradientTransform: `translate(${f2(cx)} ${f2(cy)}) rotate(${f2(a)}) scale(${f2(rx)} ${f2(ry)})`,
563
+ },
564
+ [...stops, [1, 0] as const].map(([at, op], j) =>
565
+ n('stop', `haze.${k}.${j}`, {
566
+ offset: at,
567
+ 'stop-color': color,
568
+ 'stop-opacity': op,
569
+ }),
570
+ ),
571
+ )
572
+ const fade = ([a, b]: readonly [number, number]) =>
573
+ [
574
+ [0, a],
575
+ [0.55, b],
576
+ ] as const
577
+ const blobs = [
578
+ blob(
579
+ 'deep',
580
+ deep,
581
+ [at(deepX, !left), y0 + H * 0.95, 0, W * 0.5, W * 0.35],
582
+ fade(L.deep),
583
+ ),
584
+ blob(
585
+ 'tint',
586
+ HAZE[near].tint,
587
+ [at(tintX, left), y0 + H * tintY, tintA, W * L.tint[0], W * L.tint[1]],
588
+ L.tintStops,
589
+ ),
590
+ blob(
591
+ 'glow',
592
+ HAZE[who.color].glow,
593
+ [at(glowX, !left), y0 + H * glowY, 0, W * 0.475, W * 0.356],
594
+ fade(L.glow),
595
+ ),
596
+ ]
597
+ const shape = (key: string, paint: string) =>
372
598
  'd' in sh.body
599
+ ? n('path', key, {
600
+ d: sh.body.d,
601
+ fill: paint,
602
+ stroke: sh.body.stroke ? paint : undefined,
603
+ 'stroke-width': sh.body.stroke,
604
+ 'stroke-linejoin': sh.body.stroke ? 'round' : undefined,
605
+ })
606
+ : n('circle', key, {
607
+ cx: sh.body.circle[0],
608
+ cy: sh.body.circle[1],
609
+ r: sh.body.circle[2],
610
+ fill: paint,
611
+ })
612
+ return [
613
+ ...blobs,
614
+ n('g', 'haze', { id: `${id}-hz` }, [
615
+ shape('haze.body', body),
616
+ ...['deep', 'tint', 'glow'].map((k) =>
617
+ shape(`haze.${k}.fill`, `url(#${id}-h${k[0]})`),
618
+ ),
619
+ ]),
620
+ ]
621
+ }
622
+
623
+ /** The peek face, and Haze: the same face with a hazed body. */
624
+ function peekParts({ who, pose, o, defs, g }: Parts): [Node[], Node[]] {
625
+ const sh: FaceGeom = FACES[who.face]
626
+ const { body: fill, deep } = COLORS[who.color]
627
+ const P = who.persona
628
+ const spread = P.spread
629
+ const id = o.id
630
+ const st = strokeFor(o.size)
631
+ const haze = o.variant === 'haze'
632
+ if (haze) defs.push(...hazePaint(who, o))
633
+ const bodyNode = haze
634
+ ? n('use', 'body', { href: `#${id}-hz` })
635
+ : 'd' in sh.body
373
636
  ? n('path', 'body', {
374
637
  d: sh.body.d,
375
638
  fill,
@@ -475,25 +738,47 @@ export function draw(who: Drawable, pose: Pose, o: DrawOptions): Node {
475
738
  const lrx = rx * 1.55
476
739
  const lry = ry * 1.15
477
740
  const top = ry + 1.6 - lower * ry * 1.5
741
+ // a bead has no white to show where it looks, so the whole eye leans
742
+ const [etx, ety] = bead
743
+ ? [ex + side * spread + gx * 6, ey + gy * 6]
744
+ : [ex + side * spread, ey]
745
+ const lids = [
746
+ n('rect', `${kk}.lid`, {
747
+ x: f2(-rx * 2.2),
748
+ y: f2(-ry * 3),
749
+ width: f2(rx * 4.4),
750
+ height: f2(Math.max(0, edge + ry * 3)),
751
+ transform: `rotate(${f2(a)})`,
752
+ fill,
753
+ }),
754
+ n('ellipse', `${kk}.lower`, {
755
+ cx: 0,
756
+ cy: f2(top + lry),
757
+ rx: f2(lrx),
758
+ ry: f2(lry),
759
+ fill,
760
+ }),
761
+ ]
762
+ // Haze: the lids cut the body's own paint, so they never read as patches
763
+ if (haze)
764
+ defs.push(n('clipPath', `${kk}.lidshape`, { id: `${id}-s${i}` }, lids))
478
765
  const parts: Node[] = [
479
766
  n('g', `${kk}.inner`, { 'clip-path': `url(#${id}-e${i})` }, inner),
480
- n('g', `${kk}.lids`, { 'clip-path': `url(#${id}-l${i})` }, [
481
- n('rect', `${kk}.lid`, {
482
- x: f2(-rx * 2.2),
483
- y: f2(-ry * 3),
484
- width: f2(rx * 4.4),
485
- height: f2(Math.max(0, edge + ry * 3)),
486
- transform: `rotate(${f2(a)})`,
487
- fill,
488
- }),
489
- n('ellipse', `${kk}.lower`, {
490
- cx: 0,
491
- cy: f2(top + lry),
492
- rx: f2(lrx),
493
- ry: f2(lry),
494
- fill,
495
- }),
496
- ]),
767
+ n(
768
+ 'g',
769
+ `${kk}.lids`,
770
+ { 'clip-path': `url(#${id}-l${i})` },
771
+ haze
772
+ ? [
773
+ n('g', `${kk}.lidpaint`, { 'clip-path': `url(#${id}-s${i})` }, [
774
+ n('use', `${kk}.lidbody`, {
775
+ href: `#${id}-hz`,
776
+ transform: `translate(${f2(-etx)} ${f2(-ety)})`,
777
+ }),
778
+ ]),
779
+ ]
780
+ : lids,
781
+ ),
497
782
  ]
498
783
  // a rimmed eye inks its lid edges too, so the outline follows the lids
499
784
  if (who.eyes === 'ring')
@@ -538,17 +823,7 @@ export function draw(who: Drawable, pose: Pose, o: DrawOptions): Node {
538
823
  opacity: co > 0.01 ? f2(co) : 0,
539
824
  }),
540
825
  )
541
- return n(
542
- 'g',
543
- kk,
544
- {
545
- // a bead has no white to show where it looks, so the whole eye leans
546
- transform: bead
547
- ? `translate(${f2(ex + side * spread + gx * 6)} ${f2(ey + gy * 6)})`
548
- : `translate(${f2(ex + side * spread)} ${ey})`,
549
- },
550
- parts,
551
- )
826
+ return n('g', kk, { transform: `translate(${f2(etx)} ${f2(ety)})` }, parts)
552
827
  })
553
828
 
554
829
  // brows
@@ -980,90 +1255,8 @@ export function draw(who: Drawable, pose: Pose, o: DrawOptions): Node {
980
1255
  )
981
1256
  }
982
1257
 
983
- // the plates sit a hair out of register at large sizes
984
- const off = (dx: number, dy: number) =>
985
- riso ? `translate(${f2(dx * k * upp)} ${f2(dy * k * upp)})` : undefined
986
- const av = n(
987
- 'g',
988
- 'av',
989
- {
990
- transform: `translate(${f2(x)} ${f2(ty)}) rotate(${f2(rot)} 170 ${B}) translate(170 ${B}) scale(${sc(sx)} ${sc(sy)}) translate(-170 ${-B})`,
991
- filter: dim > 0.01 ? `url(#${id}-dim)` : undefined,
992
- },
993
- [
994
- n(
995
- 'g',
996
- 'plateA',
997
- {
998
- transform: off(-0.55, 0.45),
999
- filter: riso ? `url(#${id}-pA)` : undefined,
1000
- },
1001
- [bodyNode, ...cheeks, trait],
1002
- ),
1003
- n(
1004
- 'g',
1005
- 'plateB',
1006
- {
1007
- transform: off(0.6, -0.5),
1008
- filter: riso ? `url(#${id}-pB)` : undefined,
1009
- },
1010
- [...eyes, ...brows, mouth, ...wardrobe],
1011
- ),
1012
- ],
1013
- )
1014
-
1015
- // the thing in the corner: a dot lattice from the mark's vocabulary
1016
- const scene: Node[] = []
1017
- if (o.frame !== 'none') {
1018
- const bg =
1019
- o.frame === 'ink' ? INK.ink : o.frame === 'bone' ? INK.bone : INK.paper
1020
- scene.push(n('rect', 'bg', { ...VBr, fill: bg }))
1021
- const th = clamp(g('thing'), 0, 1)
1022
- const dots: Node[] = []
1023
- for (let j = 0; j < 3; j++)
1024
- for (let i = 0; i < 3; i++) {
1025
- const q = j * 3 + i
1026
- const kk = smooth(q / 12, q / 12 + 0.35, th)
1027
- dots.push(
1028
- n('circle', `lattice.${q}`, {
1029
- cx: 312 + i * 18,
1030
- cy: 2 + j * 18,
1031
- r: f2(1.2 + 2.2 * kk),
1032
- opacity: f3(kk * 0.55),
1033
- }),
1034
- )
1035
- }
1036
- scene.push(
1037
- n('g', 'lattice', { fill: o.frame === 'ink' ? INK.bone : INK.ink }, dots),
1038
- )
1039
- }
1040
- scene.push(av)
1041
- if (grainy)
1042
- scene.push(
1043
- n('rect', 'grain', {
1044
- ...VBr,
1045
- filter: `url(#${id}-grain)`,
1046
- opacity: o.frame === 'ink' ? 0.3 : 0.16,
1047
- 'pointer-events': 'none',
1048
- }),
1049
- )
1050
-
1051
- const root = n(
1052
- 'svg',
1053
- 'root',
1054
- {
1055
- xmlns: 'http://www.w3.org/2000/svg',
1056
- viewBox: VB.join(' '),
1057
- width: o.size,
1058
- height: o.size,
1059
- role: o.title === false ? undefined : 'img',
1060
- 'aria-label': o.title === false ? undefined : o.title,
1061
- 'aria-hidden': o.title === false ? 'true' : undefined,
1062
- },
1063
- [
1064
- n('defs', 'defs', {}, defs),
1065
- n('g', 'frame', { 'clip-path': `url(#${id}-f)` }, scene),
1066
- ],
1067
- )
1068
- return o.live ? root : (prune(root) ?? root)
1258
+ return [
1259
+ [bodyNode, ...cheeks, trait],
1260
+ [...eyes, ...brows, mouth, ...wardrobe],
1261
+ ]
1069
1262
  }
package/src/index.ts CHANGED
@@ -1,35 +1,3 @@
1
- export type { Frame, Gaze, Node } from './draw'
2
- /** Low level: the drawing tree that toSvg and <Peek> both render. */
3
- export { draw } from './draw'
4
- export type { Axes, Identity, Persona } from './identity'
5
- export { AXES, identify, LISTS, tidy } from './identity'
1
+ export * from './core'
6
2
  export type { PeekProps } from './peek'
7
3
  export { Peek } from './peek'
8
- export type { PeekOptions } from './svg'
9
- export { settle, toSvg } from './svg'
10
- export type {
11
- AccessorySlot,
12
- Axis,
13
- Brows,
14
- Channel,
15
- Cheeks,
16
- Color,
17
- Expression,
18
- Eyes,
19
- Eyewear,
20
- Face,
21
- Headwear,
22
- Mouth,
23
- Neckwear,
24
- Trait,
25
- } from './tables'
26
- export {
27
- ACCESSORIES,
28
- COLORS,
29
- EXPRESSIONS,
30
- FACES,
31
- GROUPS,
32
- LATEST,
33
- PARTS,
34
- VERSIONS,
35
- } from './tables'
@@ -0,0 +1,75 @@
1
+ <!--
2
+ <Peek name="Ada" /> for Svelte 5: the same tree and the same rules as the
3
+ React component in peek.tsx. ids come from $props.id(), so the server and
4
+ the client agree. The inside of the svg is the exact markup toSvg writes.
5
+
6
+ With `animate`, the markup stays in the pose the component mounted with and
7
+ the shared loop writes every frame straight to the DOM, so a change of
8
+ expression or gaze never reaches Svelte.
9
+ -->
10
+ <script lang="ts">
11
+ import { untrack } from 'svelte'
12
+ import {
13
+ draw,
14
+ Live,
15
+ type PeekProps,
16
+ restPose,
17
+ serialize,
18
+ settle,
19
+ } from './svelte-peek'
20
+
21
+ let {
22
+ name,
23
+ gaze,
24
+ animate = false,
25
+ class: className,
26
+ style,
27
+ ...rest
28
+ }: PeekProps = $props()
29
+ const uid = $props.id()
30
+ const id = `peek${uid.replace(/[^\w-]/g, '')}`
31
+ const fixedOf = (g: PeekProps['gaze']) =>
32
+ Array.isArray(g) ? (g as readonly [number, number]) : undefined
33
+ const first = untrack(() => ({
34
+ expression: rest.expression ?? 'normal',
35
+ gaze: fixedOf(gaze),
36
+ }))
37
+
38
+ const s = $derived(settle(name, { ...rest, gaze: fixedOf(gaze), id }, animate))
39
+ const tree = $derived(
40
+ draw(
41
+ s.who,
42
+ animate ? restPose(s.who, first.expression, first.gaze) : s.pose,
43
+ s.opts,
44
+ ),
45
+ )
46
+ const inner = $derived(tree.children.map(serialize).join(''))
47
+ const expression = $derived(rest.expression ?? 'normal')
48
+ // live: the face only restarts when what the face is changes
49
+ const sig = $derived(JSON.stringify([s.who, s.opts]))
50
+ const gazeKey = $derived(String(gaze ?? ''))
51
+
52
+ let svg: SVGSVGElement | undefined
53
+ let live: Live | null = null
54
+
55
+ $effect(() => {
56
+ void sig
57
+ if (!animate || !svg) return
58
+ const l = untrack(() => new Live(svg!, tree, s.who, s.opts, expression, gaze))
59
+ live = l
60
+ return () => {
61
+ l.destroy()
62
+ live = null
63
+ }
64
+ })
65
+ $effect(() => {
66
+ live?.setExpression(expression)
67
+ })
68
+ $effect(() => {
69
+ void gazeKey
70
+ untrack(() => live?.setGaze(gaze))
71
+ })
72
+ </script>
73
+
74
+ <!-- spread, so an unset class is no attribute rather than class="" -->
75
+ <svg bind:this={svg} {...tree.attrs} {...{ class: className, style }}>{@html inner}</svg>
@@ -0,0 +1,5 @@
1
+ import type { Component } from 'svelte'
2
+ import type { PeekProps } from './svelte-peek'
3
+
4
+ declare const Peek: Component<PeekProps>
5
+ export default Peek
@@ -0,0 +1,18 @@
1
+ /* What peek.svelte imports. Built as its own file, since the component ships
2
+ * as source and the app's bundler compiles it. */
3
+ import type { Gaze } from './draw'
4
+ import type { PeekOptions } from './svg'
5
+
6
+ export { Live } from './animate'
7
+ export { draw, restPose } from './draw'
8
+ export { serialize, settle } from './svg'
9
+
10
+ export type PeekProps = Omit<PeekOptions, 'gaze' | 'id'> & {
11
+ name: string
12
+ /** x and y in -1..1, or 'pointer' to follow the pointer (animate only). */
13
+ gaze?: Gaze | 'pointer'
14
+ /** Bring it to life after mount. Off under prefers-reduced-motion. */
15
+ animate?: boolean
16
+ class?: string
17
+ style?: string
18
+ }
package/src/svelte.ts ADDED
@@ -0,0 +1,3 @@
1
+ export * from './core'
2
+ export { default as Peek } from './peek.svelte'
3
+ export type { PeekProps } from './svelte-peek'
package/src/svg.ts CHANGED
@@ -13,7 +13,13 @@ import {
13
13
  restPose,
14
14
  } from './draw'
15
15
  import { type Axes, fnv1a, type Identity, identify } from './identity'
16
- import { ACCESSORIES, type AccessorySlot, type Expression } from './tables'
16
+ import {
17
+ ACCESSORIES,
18
+ type AccessorySlot,
19
+ type Expression,
20
+ VARIANTS,
21
+ type Variant,
22
+ } from './tables'
17
23
 
18
24
  export type PeekOptions = Partial<Axes> & {
19
25
  /** px. Drives the stroke ramp (thicker below 96) and the riso print. */
@@ -30,6 +36,8 @@ export type PeekOptions = Partial<Axes> & {
30
36
  riso?: boolean
31
37
  /** Pin a style version. Default the latest. */
32
38
  version?: number
39
+ /** How the face is drawn: 'peek', 'haze' or 'bloc'. Never who it is. */
40
+ variant?: Variant
33
41
  /** Prefix for ids inside the SVG. Default derived from everything drawn,
34
42
  * so two different avatars inlined in one page never share defs. */
35
43
  id?: string
@@ -65,12 +73,18 @@ export function settle(
65
73
  neckwear: wear(o, base, 'neckwear'),
66
74
  }
67
75
  const pose = restPose(who, o.expression ?? 'normal', o.gaze)
76
+ // an unknown variant from untyped callers draws peek
77
+ const variant = VARIANTS.includes(o.variant as Variant)
78
+ ? (o.variant as Variant)
79
+ : 'peek'
68
80
  const look = {
69
81
  size: o.size ?? 64,
70
82
  frame: o.frame ?? 'ink',
71
83
  square: o.square ?? true,
72
84
  riso: o.riso ?? false,
73
85
  live,
86
+ // left out for peek, so its ids and bytes stay as they were
87
+ ...(variant !== 'peek' && { variant }),
74
88
  }
75
89
  // Keep the byte-level peek@1 contract for faces without accessories.
76
90
  const { eyewear, headwear, neckwear, ...legacy } = who