@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@doan-labs/peek",
3
- "version": "1.0.0",
3
+ "version": "2.0.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {
@@ -18,6 +18,16 @@
18
18
  "types": "./dist/identity.d.ts",
19
19
  "import": "./dist/identity.js",
20
20
  "default": "./dist/identity.js"
21
+ },
22
+ "./vue": {
23
+ "types": "./dist/vue.d.ts",
24
+ "import": "./dist/vue.js",
25
+ "default": "./dist/vue.js"
26
+ },
27
+ "./svelte": {
28
+ "types": "./dist/svelte.d.ts",
29
+ "svelte": "./dist/svelte.js",
30
+ "default": "./dist/svelte.js"
21
31
  }
22
32
  },
23
33
  "scripts": {
@@ -27,18 +37,24 @@
27
37
  "prepack": "bun run build"
28
38
  },
29
39
  "peerDependencies": {
30
- "react": ">=19"
40
+ "react": ">=19",
41
+ "svelte": ">=5.20",
42
+ "vue": ">=3.5"
31
43
  },
32
44
  "devDependencies": {
33
- "@peek/typescript-config": "workspace:*",
45
+ "@peek/typescript-config": "0.0.0",
34
46
  "@types/bun": "latest",
35
47
  "@types/react": "^19.2.0",
36
48
  "@types/react-dom": "^19.2.0",
37
49
  "react": "^19.2.0",
38
50
  "react-dom": "^19.2.0",
39
- "typescript": "^6.0.2"
51
+ "svelte": "^5.20",
52
+ "typescript": "^6.0.2",
53
+ "vue": "^3.5"
40
54
  },
41
- "description": "Deterministic SVG avatars from a name, with expressions and a React component.",
55
+ "description": "Deterministic SVG avatars from a name, with expressions and components for React, Vue and Svelte.",
56
+ "license": "MIT",
57
+ "author": "Doan Labs",
42
58
  "homepage": "https://peek.doan-labs.com",
43
59
  "repository": {
44
60
  "type": "git",
@@ -49,6 +65,8 @@
49
65
  "avatar",
50
66
  "svg",
51
67
  "react",
68
+ "vue",
69
+ "svelte",
52
70
  "deterministic",
53
71
  "peek"
54
72
  ],
@@ -56,6 +74,7 @@
56
74
  "dist",
57
75
  "src/*.ts",
58
76
  "src/*.tsx",
77
+ "src/*.svelte",
59
78
  "!src/*.test.ts",
60
79
  "README.md"
61
80
  ],
@@ -65,6 +84,12 @@
65
84
  "peerDependenciesMeta": {
66
85
  "react": {
67
86
  "optional": true
87
+ },
88
+ "svelte": {
89
+ "optional": true
90
+ },
91
+ "vue": {
92
+ "optional": true
68
93
  }
69
94
  }
70
95
  }
package/src/bloc.ts ADDED
@@ -0,0 +1,326 @@
1
+ /*
2
+ * Bloc, from the Bloc sheet (direction A, palette P2): every face rebuilt
3
+ * from a square, a circle and a bar. The same four silhouettes, the body cut
4
+ * in two down the middle (strong on the left, light on the right), one ink
5
+ * bar for both brows, and the trait as a block over the upper right outline.
6
+ * No round corners, no gradients, no outlines.
7
+ *
8
+ * Expressions come from the same channels as peek, read as three moulds:
9
+ * round eyes and a whole bar at rest, a level cut from below or above for
10
+ * the smiling and the drowsy, a tilted cut and a bar broken in two for the
11
+ * angry, the sad and the confused. The bar is always two halves, so it
12
+ * breaks and mends continuously when animated.
13
+ */
14
+
15
+ import {
16
+ clamp,
17
+ depth,
18
+ f2,
19
+ type Node,
20
+ n,
21
+ type Parts,
22
+ seat,
23
+ smooth,
24
+ } from './draw'
25
+ import { BLOC, COLORS, FACES, type Face, type FaceGeom, INK } from './tables'
26
+
27
+ const RAD = Math.PI / 180
28
+
29
+ /* Peek's silhouettes; the triangle's round joins are cut sharp and it grows
30
+ * to the 300 box the circle and the diamond fill, its base unmoved. */
31
+ const TRI = [
32
+ [170, 12.8],
33
+ [320, 312.8],
34
+ [20, 312.8],
35
+ ] as const
36
+ const BODY: Record<Face, { body: FaceGeom['body']; crown: FaceGeom['crown'] }> =
37
+ {
38
+ diamond: FACES.diamond,
39
+ semicircle: FACES.semicircle,
40
+ circle: FACES.circle,
41
+ triangle: {
42
+ body: { d: `M${TRI.map((p) => p.join(' ')).join('L')}Z` },
43
+ crown: { poly: TRI, r: 0 },
44
+ },
45
+ }
46
+
47
+ // Bloc's own ramps: lines thicken 2.6x below 30px, eyes and trait grow
48
+ const strokeFor = (px: number) =>
49
+ px >= 96 ? 1 : px >= 56 ? 1.25 : px >= 40 ? 1.6 : px >= 30 ? 2 : 2.6
50
+ const boostFor = (px: number) => (px < 30 ? 1.25 : px < 56 ? 1.12 : 1)
51
+
52
+ /** A point turned by `deg` about the origin, y down. */
53
+ const turn = (x: number, y: number, deg: number) => {
54
+ const c = Math.cos(deg * RAD)
55
+ const s = Math.sin(deg * RAD)
56
+ return [x * c - y * s, x * s + y * c] as const
57
+ }
58
+ const poly = (pts: readonly (readonly [number, number])[]) =>
59
+ `M${pts.map(([x, y]) => `${f2(x)} ${f2(y)}`).join('L')}Z`
60
+
61
+ export function blocParts({ who, pose, o, defs, g }: Parts): [Node[], Node[]] {
62
+ const sh: FaceGeom = FACES[who.face]
63
+ const { body, crown } = BODY[who.face]
64
+ const id = o.id
65
+ const P = who.persona
66
+ const spread = P.spread
67
+ const st = strokeFor(o.size)
68
+ const up = boostFor(o.size)
69
+ const strong = BLOC[who.color]
70
+ // the light half goes deep on light grounds, so the body never sinks in
71
+ const light =
72
+ o.frame === 'bone' || o.frame === 'paper'
73
+ ? COLORS[who.color].deep
74
+ : COLORS[who.color].body
75
+ const lineW = sh.brow.w * st
76
+
77
+ // the body: one block, its right half cut out in the light ink
78
+ defs.push(
79
+ n('clipPath', 'half', { id: `${id}-r` }, [
80
+ n('rect', 'half.shape', { x: 170, y: -30, width: 200, height: 400 }),
81
+ ]),
82
+ )
83
+ const block = (key: string, fill: string, clip?: string) =>
84
+ 'd' in body
85
+ ? n('path', key, { d: body.d, fill, 'clip-path': clip })
86
+ : n('circle', key, {
87
+ cx: body.circle[0],
88
+ cy: body.circle[1],
89
+ r: body.circle[2],
90
+ fill,
91
+ 'clip-path': clip,
92
+ })
93
+
94
+ // eyes: a paper disc with its pupil, a solid bead, or a rimmed ring
95
+ const eyeS = g('eyeS')
96
+ const r0 = Math.sqrt(sh.rx * sh.ry) * up
97
+ const lid0 = g('lid')
98
+ const lidAsym = g('lidAsym')
99
+ const lower = clamp(g('lower'), 0, 1)
100
+ const pupil = g('pupil')
101
+ const gx = clamp(g('gx'), -1.1, 1.1)
102
+ const gy = clamp(g('gy'), -1.1, 1.1)
103
+ const tilt = g('lidTilt')
104
+ const bead = who.eyes === 'bead'
105
+ const eyes = sh.eyes.map(([ex, ey], i) => {
106
+ const side = i === 0 ? -1 : 1
107
+ const kk = `eye${i}`
108
+ let lid = clamp(lid0 + (i === 0 ? lidAsym : -lidAsym * 0.3), 0, 1)
109
+ lid = lid + (1 - lid) * pose.blink
110
+ // peek rests its lid a tenth down; a Bloc eye rests whole
111
+ const cut = clamp((lid - 0.1) / 0.9, 0, 1)
112
+ const r = bead ? r0 * 0.7 * eyeS * (0.4 + 0.6 * pupil) : r0 * eyeS
113
+ const [tx, ty] = bead
114
+ ? [ex + side * spread + gx * 6, ey + gy * 6]
115
+ : [ex + side * spread, ey]
116
+ // the cuts: a straight lid from above, tilted, and a level one below
117
+ const a = side * tilt
118
+ const edge = -r - 1 + cut * (2 * r + 2)
119
+ const floor = r + 1 - lower * r * 2.2
120
+ const W = r + 2
121
+ const lidAt = (x: number) =>
122
+ x * Math.tan(a * RAD) + edge / Math.cos(a * RAD)
123
+ const [t0, t1] = [lidAt(-W), lidAt(W)]
124
+ defs.push(
125
+ n('clipPath', `${kk}.clip`, { id: `${id}-e${i}` }, [
126
+ n('path', `${kk}.clip.shape`, {
127
+ d: poly([
128
+ [-W, t0],
129
+ [W, t1],
130
+ [W, Math.max(floor, t1)],
131
+ [-W, Math.max(floor, t0)],
132
+ ]),
133
+ }),
134
+ ]),
135
+ )
136
+ const pr = sh.pr * pupil * up
137
+ const px = gx * Math.max(0, r - pr) * 0.85
138
+ const py = gy * Math.max(0, r - pr) * 0.75
139
+ const rim = Math.min(r * 0.27 * Math.min(st, 1.6), r * 0.4)
140
+ const inner: Node[] = bead
141
+ ? [n('circle', `${kk}.bead`, { cx: 0, cy: 0, r: f2(r), fill: INK.ink })]
142
+ : [
143
+ n('circle', `${kk}.white`, {
144
+ cx: 0,
145
+ cy: 0,
146
+ r: f2(r),
147
+ fill: INK.paper,
148
+ }),
149
+ n('circle', `${kk}.pupil`, {
150
+ cx: f2(px),
151
+ cy: f2(py),
152
+ r: f2(pr),
153
+ fill: INK.ink,
154
+ }),
155
+ ]
156
+ if (who.eyes === 'ring')
157
+ inner.push(
158
+ n('circle', `${kk}.rim`, {
159
+ cx: 0,
160
+ cy: 0,
161
+ r: f2(r - rim / 2),
162
+ fill: 'none',
163
+ stroke: INK.ink,
164
+ 'stroke-width': f2(rim),
165
+ }),
166
+ )
167
+ // closed: a bar where the eye was
168
+ const co = smooth(0.86, 1, lid)
169
+ const yy = Math.min(edge, r) - lineW / 2
170
+ return n('g', kk, { transform: `translate(${f2(tx)} ${f2(ty)})` }, [
171
+ n('g', `${kk}.inner`, { 'clip-path': `url(#${id}-e${i})` }, inner),
172
+ n('path', `${kk}.closed`, {
173
+ d: `M${f2(-r * 0.95)} ${f2(yy)}H${f2(r * 0.95)}`,
174
+ transform: `rotate(${f2(a * 0.6)})`,
175
+ stroke: INK.ink,
176
+ 'stroke-width': f2(lineW * 1.4),
177
+ opacity: co > 0.01 ? f2(co) : 0,
178
+ }),
179
+ ])
180
+ })
181
+
182
+ // the bar: both brows in one, from one eye's outer edge to the other's.
183
+ // The persona slants it; the expression's tilt breaks it into a V or a
184
+ // caret; asymmetry lifts its right end. It never leaves the body.
185
+ const bt = pose.browTilt
186
+ const basym = g('browAsym')
187
+ const th = lineW * 2 * g('browW')
188
+ const hw =
189
+ sh.eyes[1][0] - 170 + spread + Math.sqrt(sh.rx * sh.ry) * P.mul.eyeS * up
190
+ const gap = smooth(0, 8, Math.abs(bt)) * th * 0.9
191
+ const slant = P.add.browTilt * 0.3 - Math.atan((1.3 * basym) / (2 * hw)) / RAD
192
+ const y0 = sh.brow.y + g('browY') - (eyeS - 1) * sh.ry * 0.9 - 0.35 * basym
193
+ // `len` shortens the bar from its outer ends, 1 is full length
194
+ const halves = (dy: number, len = 1) =>
195
+ [-1, 1].map((s) => {
196
+ const [ox, oy] = turn((s * gap) / 2, 0, slant)
197
+ const x0 = 170 + ox
198
+ const yy = y0 + dy + oy
199
+ const ang = slant + s * bt
200
+ const [ux, uy] = turn(s * (hw * len - gap / 2), 0, ang)
201
+ const [nx, ny] = turn(0, th / 2, ang)
202
+ return [
203
+ [x0 - nx, yy - ny],
204
+ [x0 + ux - nx, yy + uy - ny],
205
+ [x0 + ux + nx, yy + uy + ny],
206
+ [x0 + nx, yy + ny],
207
+ ] as const
208
+ })
209
+ const inside = (dy: number, len = 1) =>
210
+ halves(dy, len).every((q) =>
211
+ q.every(([x, y]) => depth(crown, x, y) + 2 < 0),
212
+ )
213
+ // pushed down until it fits; if even that fails, it grows shorter
214
+ let lo = 0
215
+ let hi = 0
216
+ if (!inside(0)) {
217
+ hi = 60
218
+ for (let k = 0; k < 12; k++) {
219
+ const mid = (lo + hi) / 2
220
+ if (inside(mid)) hi = mid
221
+ else lo = mid
222
+ }
223
+ }
224
+ let len = 1
225
+ if (!inside(hi)) {
226
+ let short = 0
227
+ for (let k = 0; k < 12; k++) {
228
+ const mid = (short + len) / 2
229
+ if (inside(hi, mid)) short = mid
230
+ else len = mid
231
+ }
232
+ len = short
233
+ }
234
+ const bars = halves(hi, len).map((q, i) =>
235
+ n('path', `bar${i}`, { d: poly(q), fill: INK.ink }),
236
+ )
237
+
238
+ // mouth: poly a turned square, round a half disc, line a bar, box a
239
+ // block. Diamonds and triangles get a bigger one, it vanished at 16px.
240
+ const m = sh.mouth
241
+ const big =
242
+ (who.face === 'diamond' || who.face === 'triangle' ? 1.25 : 1) * up
243
+ const mw = m.w * Math.max(0.05, g('mw')) * big
244
+ const mt = g('mt') * m.d * big
245
+ const mb = g('mb') * m.d * big
246
+ const mid = (mt + mb) / 2
247
+ const kb = 4 / 3
248
+ let d: string
249
+ if (who.mouth === 'poly') {
250
+ const s = Math.max(mw * 0.9, (mb - mt) / 2)
251
+ d = poly([
252
+ [-s, mid],
253
+ [0, mid - s],
254
+ [s, mid],
255
+ [0, mid + s],
256
+ ])
257
+ } else if (who.mouth === 'box') {
258
+ const h = Math.max(mb - mt, lineW * 1.6) / 2
259
+ const w = mw * 0.85
260
+ d = poly([
261
+ [-w, mid - h],
262
+ [w, mid - h],
263
+ [w, mid + h],
264
+ [-w, mid + h],
265
+ ])
266
+ } else if (who.mouth === 'line') {
267
+ const h = (lineW * 1.4) / 2
268
+ const w = mw * 1.5
269
+ const y = mid * 0.5
270
+ d = poly([
271
+ [-w, y - h],
272
+ [w, y - h],
273
+ [w, y + h],
274
+ [-w, y + h],
275
+ ])
276
+ } else {
277
+ d = `M${f2(-mw)} 0C${f2(-mw)} ${f2(mb * kb)} ${f2(mw)} ${f2(mb * kb)} ${f2(mw)} 0C${f2(mw)} ${f2(mt * kb)} ${f2(-mw)} ${f2(mt * kb)} ${f2(-mw)} 0Z`
278
+ }
279
+ const mouth = n('path', 'mouth', {
280
+ d,
281
+ transform: `translate(${f2(m.x + g('mx'))} ${f2(m.y + g('my'))}) rotate(${f2(g('mk'))})`,
282
+ fill: INK.ink,
283
+ })
284
+
285
+ // the trait, as a block over the upper right outline. Perky lifts it
286
+ // clear, low slides it down the side.
287
+ const hair = g('hair')
288
+ const s = seat(crown, Math.max(0, hair) * 8, 110 + Math.max(0, -hair) * 40)
289
+ const shape =
290
+ who.trait === 'square'
291
+ ? n('rect', 'trait.shape', { x: -21, y: -21, width: 42, height: 42 })
292
+ : who.trait === 'dot'
293
+ ? n('circle', 'trait.shape', { cx: 0, cy: 0, r: 22 })
294
+ : who.trait === 'ring'
295
+ ? n('circle', 'trait.shape', {
296
+ cx: 0,
297
+ cy: 0,
298
+ r: 17.5,
299
+ fill: 'none',
300
+ stroke: strong,
301
+ 'stroke-width': 13,
302
+ })
303
+ : who.trait === 'fin'
304
+ ? n('path', 'trait.shape', { d: 'M-27 27V-27A54 54 0 0 1 27 27Z' })
305
+ : // peak stands as a bar, upright and short of the brow bar's length
306
+ n('rect', 'trait.shape', {
307
+ x: -16,
308
+ y: -30,
309
+ width: 32,
310
+ height: 60,
311
+ })
312
+ const trait = n(
313
+ 'g',
314
+ 'trait',
315
+ {
316
+ transform: `translate(${f2(s.x)} ${f2(s.y + pose.lag)}) scale(${f2(up)})`,
317
+ fill: strong,
318
+ },
319
+ [shape],
320
+ )
321
+
322
+ return [
323
+ [block('body', strong), block('body.light', light, `url(#${id}-r)`), trait],
324
+ [...eyes, ...bars, mouth],
325
+ ]
326
+ }
package/src/core.ts ADDED
@@ -0,0 +1,39 @@
1
+ /* Everything but a component: each framework entry re-exports this. */
2
+ export type { Frame, Gaze, Node } from './draw'
3
+ /** Low level: the drawing tree that toSvg and <Peek> both render. */
4
+ export { draw } from './draw'
5
+ export type { Axes, Identity, Persona } from './identity'
6
+ export { AXES, identify, LISTS, tidy } from './identity'
7
+ export type { PeekOptions } from './svg'
8
+ export { settle, toSvg } from './svg'
9
+ export type {
10
+ AccessorySlot,
11
+ Axis,
12
+ Brows,
13
+ Channel,
14
+ Cheeks,
15
+ Color,
16
+ Expression,
17
+ Eyes,
18
+ Eyewear,
19
+ Face,
20
+ Headwear,
21
+ Mouth,
22
+ Neckwear,
23
+ Trait,
24
+ Variant,
25
+ } from './tables'
26
+ export {
27
+ ACCESSORIES,
28
+ BLOC,
29
+ COLORS,
30
+ EXPRESSIONS,
31
+ FACES,
32
+ GROUPS,
33
+ HAZE,
34
+ LATEST,
35
+ PARTS,
36
+ VARIANTS,
37
+ VERSIONS,
38
+ WHEEL,
39
+ } from './tables'