@doan-labs/peek 1.0.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/identity.ts CHANGED
@@ -9,16 +9,20 @@
9
9
  */
10
10
 
11
11
  import {
12
+ ACCESSORIES,
12
13
  type Axis,
13
14
  type Brows,
14
15
  type Cheeks,
15
16
  COLORS,
16
17
  type Color,
17
18
  type Eyes,
19
+ type Eyewear,
18
20
  FACES,
19
21
  type Face,
22
+ type Headwear,
20
23
  LATEST,
21
24
  type Mouth,
25
+ type Neckwear,
22
26
  PARTS,
23
27
  type Trait,
24
28
  VERSIONS,
@@ -74,6 +78,9 @@ export type Axes = {
74
78
  mouth: Mouth
75
79
  cheeks: Cheeks
76
80
  trait: Trait
81
+ eyewear: Eyewear
82
+ headwear: Headwear
83
+ neckwear: Neckwear
77
84
  }
78
85
 
79
86
  export type Identity = Axes & {
@@ -90,6 +97,7 @@ export const LISTS: { readonly [A in Axis]: readonly Axes[A][] } = {
90
97
  face: Object.keys(FACES) as Face[],
91
98
  color: Object.keys(COLORS) as Color[],
92
99
  ...PARTS,
100
+ ...ACCESSORIES,
93
101
  }
94
102
  /** The axes in readout order. */
95
103
  export const AXES = Object.keys(LISTS) as Axis[]
@@ -142,6 +150,9 @@ export function identify(
142
150
  mouth: pick('mouth'),
143
151
  cheeks: pick('cheeks'),
144
152
  trait: pick('trait'),
153
+ eyewear: pick('eyewear'),
154
+ headwear: pick('headwear'),
155
+ neckwear: pick('neckwear'),
145
156
  persona,
146
157
  }
147
158
  }
package/src/index.ts CHANGED
@@ -1,28 +1,3 @@
1
- export type { Frame, Gaze } from './draw'
2
- export type { Axes, Identity, Persona } from './identity'
3
- export { AXES, identify, LISTS, tidy } from './identity'
1
+ export * from './core'
4
2
  export type { PeekProps } from './peek'
5
3
  export { Peek } from './peek'
6
- export type { PeekOptions } from './svg'
7
- export { toSvg } from './svg'
8
- export type {
9
- Axis,
10
- Brows,
11
- Channel,
12
- Cheeks,
13
- Color,
14
- Expression,
15
- Eyes,
16
- Face,
17
- Mouth,
18
- Trait,
19
- } from './tables'
20
- export {
21
- COLORS,
22
- EXPRESSIONS,
23
- FACES,
24
- GROUPS,
25
- LATEST,
26
- PARTS,
27
- VERSIONS,
28
- } 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
package/src/peek.tsx CHANGED
@@ -3,7 +3,7 @@
3
3
  * come from useId, so the server and the client agree.
4
4
  *
5
5
  * A name decides identity unless the caller overrides an axis: pass `face`,
6
- * `color`, `eyes`, `brows`, `mouth`, `cheeks` or `trait` and that axis wins
6
+ * `color`, a part, or a wardrobe slot and that axis wins
7
7
  * over the hash while every other axis stays as the name made it.
8
8
  *
9
9
  * With `animate`, the first paint (server and hydration) is still the static
@@ -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 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,11 +36,19 @@ 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
36
44
  }
37
45
 
46
+ // an unknown item from untyped callers keeps what the name picked
47
+ const wear = <S extends AccessorySlot>(o: PeekOptions, base: Identity, s: S) =>
48
+ (ACCESSORIES[s] as readonly string[]).includes(o[s] as string)
49
+ ? (o[s] as Identity[S])
50
+ : base[s]
51
+
38
52
  /**
39
53
  * A name decides identity unless the caller overrides an axis: an explicit
40
54
  * face, color or part wins over the hash, and every other axis stays put.
@@ -54,17 +68,31 @@ export function settle(
54
68
  mouth: o.mouth ?? base.mouth,
55
69
  cheeks: o.cheeks ?? base.cheeks,
56
70
  trait: o.trait ?? base.trait,
71
+ eyewear: wear(o, base, 'eyewear'),
72
+ headwear: wear(o, base, 'headwear'),
73
+ neckwear: wear(o, base, 'neckwear'),
57
74
  }
58
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'
59
80
  const look = {
60
81
  size: o.size ?? 64,
61
82
  frame: o.frame ?? 'ink',
62
83
  square: o.square ?? true,
63
84
  riso: o.riso ?? false,
64
85
  live,
86
+ // left out for peek, so its ids and bytes stay as they were
87
+ ...(variant !== 'peek' && { variant }),
65
88
  }
89
+ // Keep the byte-level peek@1 contract for faces without accessories.
90
+ const { eyewear, headwear, neckwear, ...legacy } = who
91
+ const wardrobe =
92
+ eyewear !== 'none' || headwear !== 'none' || neckwear !== 'none'
66
93
  const id =
67
- o.id ?? `peek-${fnv1a(JSON.stringify([who, pose, look])).toString(36)}`
94
+ o.id ??
95
+ `peek-${fnv1a(JSON.stringify([wardrobe ? who : legacy, pose, look])).toString(36)}`
68
96
  return { who, pose, opts: { ...look, id, title: o.title ?? name } }
69
97
  }
70
98
 
package/src/tables.ts CHANGED
@@ -175,6 +175,53 @@ export const COLORS = {
175
175
  aqua: { body: '#BDE1E5', deep: '#8FC7D1' },
176
176
  } as const
177
177
 
178
+ /*
179
+ * Variants: how a face is drawn, never who it is. Every variant reads the
180
+ * same identity, so a name keeps its face, color and parts in all of them.
181
+ */
182
+ export const VARIANTS = ['peek', 'haze', 'bloc'] as const
183
+ export type Variant = (typeof VARIANTS)[number]
184
+
185
+ /**
186
+ * Haze, transcribed from the Haze sheet (01.3). Each ink has a glow and a
187
+ * tint; a body is hazed by the tint of one of its two neighbors on the hue
188
+ * wheel, so the tint is never picked by hand.
189
+ */
190
+ export const HAZE = {
191
+ lavender: { tint: '#C8B1F5', glow: '#F2EDFD' },
192
+ fog: { tint: '#9EC3F2', glow: '#E6F1FF' },
193
+ clay: { tint: '#F4AE93', glow: '#FEECE5' },
194
+ mint: { tint: '#A3DAB5', glow: '#E5F5EA' },
195
+ butter: { tint: '#E1CC7A', glow: '#F4F0E0' },
196
+ rose: { tint: '#F0A9C4', glow: '#FDEAF1' },
197
+ aqua: { tint: '#85D3DC', glow: '#E0F5F7' },
198
+ } as const satisfies Record<keyof typeof COLORS, unknown>
199
+ /** The hue wheel, warm to cool: a body's warm side is the entry before it. */
200
+ export const WHEEL = [
201
+ 'clay',
202
+ 'butter',
203
+ 'mint',
204
+ 'aqua',
205
+ 'fog',
206
+ 'lavender',
207
+ 'rose',
208
+ ] as const satisfies readonly (keyof typeof COLORS)[]
209
+
210
+ /**
211
+ * Bloc's strong inks, palette P2 of the Bloc sheet (02.4): the same hue at
212
+ * OKLCH L 0.72 and C 0.12, butter, mint and aqua held lighter so none goes
213
+ * olive. The strong ink fills the dark half and the trait.
214
+ */
215
+ export const BLOC = {
216
+ lavender: '#B093E5',
217
+ fog: '#7BA8DF',
218
+ clay: '#E38A66',
219
+ mint: '#82CB9B',
220
+ butter: '#E2CA6A',
221
+ rose: '#DF84A8',
222
+ aqua: '#5EC3CD',
223
+ } as const satisfies Record<keyof typeof COLORS, string>
224
+
178
225
  /** Discrete anatomy. Each part reads every channel its feature owns. */
179
226
  export const PARTS = {
180
227
  eyes: ['oval', 'bead', 'ring'],
@@ -184,6 +231,20 @@ export const PARTS = {
184
231
  trait: ['square', 'fin', 'ring', 'dot', 'peak'],
185
232
  } as const
186
233
 
234
+ // The bow's single warm accent, transcribed from the accessory sheet.
235
+ export const ACCESSORY_INK = { knot: '#E5412D' } as const
236
+
237
+ /** Independent wardrobe slots. peek@1 picks none; callers can dress it. */
238
+ export const ACCESSORIES = {
239
+ eyewear: ['none', 'glasses', 'sunglasses'],
240
+ headwear: ['none', 'bow', 'cap', 'sprout'],
241
+ neckwear: ['none', 'tie'],
242
+ } as const
243
+ export type Eyewear = (typeof ACCESSORIES.eyewear)[number]
244
+ export type Headwear = (typeof ACCESSORIES.headwear)[number]
245
+ export type Neckwear = (typeof ACCESSORIES.neckwear)[number]
246
+ export type AccessorySlot = keyof typeof ACCESSORIES
247
+
187
248
  export type Face = keyof typeof FACES
188
249
  export type Color = keyof typeof COLORS
189
250
  export type Part = keyof typeof PARTS
@@ -192,11 +253,22 @@ export type Brows = (typeof PARTS.brows)[number]
192
253
  export type Mouth = (typeof PARTS.mouth)[number]
193
254
  export type Cheeks = (typeof PARTS.cheeks)[number]
194
255
  export type Trait = (typeof PARTS.trait)[number]
195
- export type Axis = 'face' | 'color' | Part
256
+ export type Axis = 'face' | 'color' | Part | AccessorySlot
196
257
 
197
258
  /** How much of each list a version may pick from. Append-only too. */
198
259
  export const VERSIONS: Record<number, Record<Axis, number>> = {
199
- 1: { face: 4, color: 7, eyes: 3, brows: 4, mouth: 4, cheeks: 3, trait: 5 },
260
+ 1: {
261
+ face: 4,
262
+ color: 7,
263
+ eyes: 3,
264
+ brows: 4,
265
+ mouth: 4,
266
+ cheeks: 3,
267
+ trait: 5,
268
+ eyewear: 1,
269
+ headwear: 1,
270
+ neckwear: 1,
271
+ },
200
272
  }
201
273
  export const LATEST = 1
202
274
 
package/src/vue.ts ADDED
@@ -0,0 +1,133 @@
1
+ /*
2
+ * <Peek name="Ada" /> for Vue 3.5+: the same tree and the same rules as the
3
+ * React component in peek.tsx. ids come from Vue's useId, so the server and
4
+ * the client agree; `class` and `style` fall through to the <svg>.
5
+ *
6
+ * With `animate`, the tree Vue renders stays in the pose the component
7
+ * mounted with and the shared loop writes every frame straight to the DOM,
8
+ * so a change of expression or gaze never reaches Vue's patch.
9
+ */
10
+
11
+ import {
12
+ computed,
13
+ defineComponent,
14
+ h,
15
+ onBeforeUnmount,
16
+ onMounted,
17
+ type PropType,
18
+ ref,
19
+ useId,
20
+ type VNode,
21
+ type VNodeRef,
22
+ watch,
23
+ } from 'vue'
24
+ import { Live } from './animate'
25
+ import { draw, type Gaze, type Node, restPose } from './draw'
26
+ import { type PeekOptions, settle } from './svg'
27
+
28
+ export * from './core'
29
+
30
+ export type PeekProps = Omit<PeekOptions, 'gaze' | 'id'> & {
31
+ name: string
32
+ /** x and y in -1..1, or 'pointer' to follow the pointer (animate only). */
33
+ gaze?: Gaze | 'pointer'
34
+ /** Bring it to life after mount. Off under prefers-reduced-motion. */
35
+ animate?: boolean
36
+ }
37
+
38
+ function toVue(node: Node, ref?: VNodeRef): VNode {
39
+ return h(
40
+ node.tag,
41
+ { key: node.key, ...node.attrs, ref },
42
+ node.children.map((c) => toVue(c)),
43
+ )
44
+ }
45
+
46
+ // Every prop unset stays undefined so the name decides it, as in React.
47
+ // Booleans need their type, or `<Peek animate />` would arrive as ''.
48
+ const any = { type: null, default: undefined }
49
+ const flag = { type: Boolean, default: undefined }
50
+
51
+ export const Peek = defineComponent(
52
+ (props: PeekProps) => {
53
+ const id = `peek${useId().replace(/[^\w-]/g, '')}`
54
+ const svg = ref<SVGSVGElement>()
55
+ let live: Live | null = null
56
+ const fixedOf = (g: PeekProps['gaze']) =>
57
+ Array.isArray(g) ? (g as Gaze) : undefined
58
+ const first = {
59
+ expression: props.expression ?? 'normal',
60
+ gaze: fixedOf(props.gaze),
61
+ }
62
+
63
+ const view = computed(() => {
64
+ const { name, gaze, animate, ...rest } = props
65
+ const { who, pose, opts } = settle(
66
+ name,
67
+ { ...rest, gaze: fixedOf(gaze), id },
68
+ animate,
69
+ )
70
+ const tree = draw(
71
+ who,
72
+ animate ? restPose(who, first.expression, first.gaze) : pose,
73
+ opts,
74
+ )
75
+ // live: the tree only changes when what the face is changes
76
+ return { who, opts, tree, sig: JSON.stringify([who, opts]) }
77
+ })
78
+ const expression = () => props.expression ?? 'normal'
79
+
80
+ onMounted(() => {
81
+ watch(
82
+ [() => props.animate, () => view.value.sig],
83
+ () => {
84
+ live?.destroy()
85
+ live = null
86
+ if (!props.animate || !svg.value) return
87
+ const { tree, who, opts } = view.value
88
+ live = new Live(svg.value, tree, who, opts, expression(), props.gaze)
89
+ },
90
+ { immediate: true, flush: 'post' },
91
+ )
92
+ watch(expression, (e) => live?.setExpression(e), { flush: 'post' })
93
+ watch(
94
+ () => String(props.gaze ?? ''),
95
+ () => live?.setGaze(props.gaze),
96
+ { flush: 'post' },
97
+ )
98
+ })
99
+ onBeforeUnmount(() => live?.destroy())
100
+
101
+ return () => toVue(view.value.tree, svg)
102
+ },
103
+ {
104
+ // biome-ignore lint/style/useVueMultiWordComponentNames: <Peek> in every framework
105
+ name: 'Peek',
106
+ props: {
107
+ name: { type: String, required: true },
108
+ gaze: any,
109
+ animate: { type: Boolean, default: false },
110
+ size: { type: Number, default: undefined },
111
+ expression: any,
112
+ frame: any,
113
+ square: flag,
114
+ title: {
115
+ type: [String, Boolean] as PropType<string | false>,
116
+ default: undefined,
117
+ },
118
+ riso: flag,
119
+ version: { type: Number, default: undefined },
120
+ variant: any,
121
+ face: any,
122
+ color: any,
123
+ eyes: any,
124
+ brows: any,
125
+ mouth: any,
126
+ cheeks: any,
127
+ trait: any,
128
+ eyewear: any,
129
+ headwear: any,
130
+ neckwear: any,
131
+ },
132
+ },
133
+ )