@voqalize/avatar 0.2.1 → 0.3.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.
Files changed (201) hide show
  1. package/LICENSE +21 -661
  2. package/README.md +144 -663
  3. package/client/Avatar.tsx +57 -0
  4. package/client/AvatarClient.ts +523 -0
  5. package/client/arjun.ts +24 -0
  6. package/client/createAvatar.ts +139 -0
  7. package/client/createCanvasAvatar.ts +72 -0
  8. package/client/index.ts +39 -0
  9. package/client/internal.ts +80 -0
  10. package/client/interviewer-female.ts +4 -0
  11. package/client/interviewer-male.ts +4 -0
  12. package/client/ishita.ts +24 -0
  13. package/client/kabir.ts +24 -0
  14. package/client/meera.ts +24 -0
  15. package/client/naina.ts +24 -0
  16. package/client/professional-female-a.ts +4 -0
  17. package/client/professional-female-b.ts +4 -0
  18. package/client/professional-male-a.ts +4 -0
  19. package/client/professional-male-b.ts +4 -0
  20. package/client/react.ts +13 -0
  21. package/client/types.ts +146 -0
  22. package/client/useAvatar.ts +74 -0
  23. package/client/vikram.ts +24 -0
  24. package/dist/Avatar.d.ts +44 -0
  25. package/dist/Avatar.d.ts.map +1 -0
  26. package/{client/dist → dist}/Avatar.js +2 -2
  27. package/dist/Avatar.js.map +1 -0
  28. package/dist/AvatarClient.d.ts +196 -0
  29. package/dist/AvatarClient.d.ts.map +1 -0
  30. package/dist/AvatarClient.js +450 -0
  31. package/dist/AvatarClient.js.map +1 -0
  32. package/dist/arjun.d.ts +6 -0
  33. package/dist/arjun.d.ts.map +1 -0
  34. package/dist/arjun.js +18 -0
  35. package/dist/arjun.js.map +1 -0
  36. package/dist/createAvatar.d.ts +99 -0
  37. package/dist/createAvatar.d.ts.map +1 -0
  38. package/dist/createAvatar.js +75 -0
  39. package/dist/createAvatar.js.map +1 -0
  40. package/dist/createCanvasAvatar.d.ts +22 -0
  41. package/dist/createCanvasAvatar.d.ts.map +1 -0
  42. package/dist/createCanvasAvatar.js +47 -0
  43. package/dist/createCanvasAvatar.js.map +1 -0
  44. package/dist/index.d.ts +30 -0
  45. package/dist/index.d.ts.map +1 -0
  46. package/dist/index.js +29 -0
  47. package/dist/index.js.map +1 -0
  48. package/dist/internal.d.ts +30 -0
  49. package/dist/internal.d.ts.map +1 -0
  50. package/dist/internal.js +33 -0
  51. package/dist/internal.js.map +1 -0
  52. package/dist/interviewer-female.d.ts +4 -0
  53. package/dist/interviewer-female.d.ts.map +1 -0
  54. package/dist/interviewer-female.js +3 -0
  55. package/dist/interviewer-female.js.map +1 -0
  56. package/dist/interviewer-male.d.ts +4 -0
  57. package/dist/interviewer-male.d.ts.map +1 -0
  58. package/dist/interviewer-male.js +3 -0
  59. package/dist/interviewer-male.js.map +1 -0
  60. package/dist/ishita.d.ts +6 -0
  61. package/dist/ishita.d.ts.map +1 -0
  62. package/dist/ishita.js +18 -0
  63. package/dist/ishita.js.map +1 -0
  64. package/dist/kabir.d.ts +6 -0
  65. package/dist/kabir.d.ts.map +1 -0
  66. package/dist/kabir.js +18 -0
  67. package/dist/kabir.js.map +1 -0
  68. package/dist/meera.d.ts +6 -0
  69. package/dist/meera.d.ts.map +1 -0
  70. package/dist/meera.js +18 -0
  71. package/dist/meera.js.map +1 -0
  72. package/dist/naina.d.ts +6 -0
  73. package/dist/naina.d.ts.map +1 -0
  74. package/dist/naina.js +18 -0
  75. package/dist/naina.js.map +1 -0
  76. package/dist/professional-female-a.d.ts +4 -0
  77. package/dist/professional-female-a.d.ts.map +1 -0
  78. package/dist/professional-female-a.js +3 -0
  79. package/dist/professional-female-a.js.map +1 -0
  80. package/dist/professional-female-b.d.ts +4 -0
  81. package/dist/professional-female-b.d.ts.map +1 -0
  82. package/dist/professional-female-b.js +3 -0
  83. package/dist/professional-female-b.js.map +1 -0
  84. package/dist/professional-male-a.d.ts +4 -0
  85. package/dist/professional-male-a.d.ts.map +1 -0
  86. package/dist/professional-male-a.js +3 -0
  87. package/dist/professional-male-a.js.map +1 -0
  88. package/dist/professional-male-b.d.ts +4 -0
  89. package/dist/professional-male-b.d.ts.map +1 -0
  90. package/dist/professional-male-b.js +3 -0
  91. package/dist/professional-male-b.js.map +1 -0
  92. package/dist/react.d.ts +13 -0
  93. package/dist/react.d.ts.map +1 -0
  94. package/dist/react.js +13 -0
  95. package/dist/react.js.map +1 -0
  96. package/dist/types.d.ts +94 -0
  97. package/dist/types.d.ts.map +1 -0
  98. package/dist/types.js +93 -0
  99. package/dist/types.js.map +1 -0
  100. package/dist/useAvatar.d.ts +39 -0
  101. package/dist/useAvatar.d.ts.map +1 -0
  102. package/dist/useAvatar.js +35 -0
  103. package/dist/useAvatar.js.map +1 -0
  104. package/dist/vikram.d.ts +6 -0
  105. package/dist/vikram.d.ts.map +1 -0
  106. package/dist/vikram.js +18 -0
  107. package/dist/vikram.js.map +1 -0
  108. package/package.json +81 -24
  109. package/src/avatar.d.ts +161 -120
  110. package/src/avatar.js +234 -146
  111. package/src/behavior.d.ts +26 -0
  112. package/src/behavior.js +102 -0
  113. package/src/camera.js +29 -0
  114. package/src/canvas/author/parts/eye.mjs +722 -0
  115. package/src/canvas/author/parts/hand.mjs +1156 -0
  116. package/src/canvas/author/parts/mouth.mjs +741 -0
  117. package/src/canvas/author/parts/nose.mjs +100 -0
  118. package/src/canvas/author/parts/skin-detail.mjs +67 -0
  119. package/src/canvas/author/path.mjs +283 -0
  120. package/src/canvas/author/rig.mjs +405 -0
  121. package/src/canvas/avatars/round/face.d.mts +3 -0
  122. package/src/canvas/avatars/round/face.mjs +1307 -0
  123. package/src/canvas/create-rig.d.ts +15 -0
  124. package/src/canvas/create-rig.js +100 -0
  125. package/src/canvas/data/img/professional-female-a-hair-back.webp +0 -0
  126. package/src/canvas/data/img/professional-female-a-hair-front.webp +0 -0
  127. package/src/canvas/data/img/professional-female-a-top-body.webp +0 -0
  128. package/src/canvas/data/img/professional-female-b-hair-back.webp +0 -0
  129. package/src/canvas/data/img/professional-female-b-hair-front.webp +0 -0
  130. package/src/canvas/data/img/professional-female-b-top-body.webp +0 -0
  131. package/src/canvas/data/img/professional-male-a-hair-back.webp +0 -0
  132. package/src/canvas/data/img/professional-male-a-hair-front.webp +0 -0
  133. package/src/canvas/data/img/professional-male-a-top-body.webp +0 -0
  134. package/src/canvas/data/img/professional-male-b-hair-back.webp +0 -0
  135. package/src/canvas/data/img/professional-male-b-hair-front.webp +0 -0
  136. package/src/canvas/data/img/professional-male-b-top-body.webp +0 -0
  137. package/src/canvas/data/img/round-m3-hair-back.webp +0 -0
  138. package/src/canvas/data/img/round-m3-hair-front.webp +0 -0
  139. package/src/canvas/data/img/round-m3-top-body.webp +0 -0
  140. package/src/canvas/data/img/round-w1-hair-back.webp +0 -0
  141. package/src/canvas/data/img/round-w1-hair-front.webp +0 -0
  142. package/src/canvas/data/img/round-w1-top-body.webp +0 -0
  143. package/src/canvas/data/interviewer-female.rig.json +1 -0
  144. package/src/canvas/data/interviewer-male.rig.json +1 -0
  145. package/src/canvas/data/professional-female-a.rig.json +1 -0
  146. package/src/canvas/data/professional-female-b.rig.json +1 -0
  147. package/src/canvas/data/professional-male-a.rig.json +1 -0
  148. package/src/canvas/data/professional-male-b.rig.json +1 -0
  149. package/src/canvas/src/live.js +508 -0
  150. package/src/canvas/src/render2d.js +218 -0
  151. package/src/canvas/src/rig.js +297 -0
  152. package/src/canvas/src/vocab.js +96 -0
  153. package/src/clips.js +18 -7
  154. package/src/conformance.js +119 -0
  155. package/src/emotions.js +4 -3
  156. package/src/face-core.js +27 -1
  157. package/src/face-myna.d.ts +7 -0
  158. package/src/face-myna.js +192 -131
  159. package/src/face-peep-control-plane.js +167 -0
  160. package/src/face-peep.d.ts +7 -0
  161. package/src/face-peep.js +274 -116
  162. package/src/face-wren.d.ts +7 -0
  163. package/src/face-wren.js +17 -19
  164. package/src/faces.d.ts +12 -0
  165. package/src/faces.js +53 -0
  166. package/src/gaze.js +119 -4
  167. package/src/hand.js +120 -91
  168. package/src/idle.js +18 -101
  169. package/src/interjections.js +156 -31
  170. package/src/params.js +8 -4
  171. package/src/perform.js +7 -9
  172. package/src/rig.d.ts +24 -0
  173. package/src/rig.js +32 -0
  174. package/src/speech-timing.js +23 -0
  175. package/src/visemes.js +28 -12
  176. package/client/dist/Avatar.d.ts +0 -27
  177. package/client/dist/Avatar.d.ts.map +0 -1
  178. package/client/dist/Avatar.js.map +0 -1
  179. package/client/dist/AvatarClient.d.ts +0 -139
  180. package/client/dist/AvatarClient.d.ts.map +0 -1
  181. package/client/dist/AvatarClient.js +0 -223
  182. package/client/dist/AvatarClient.js.map +0 -1
  183. package/client/dist/index.d.ts +0 -22
  184. package/client/dist/index.d.ts.map +0 -1
  185. package/client/dist/index.js +0 -22
  186. package/client/dist/index.js.map +0 -1
  187. package/client/dist/types.d.ts +0 -86
  188. package/client/dist/types.d.ts.map +0 -1
  189. package/client/dist/types.js +0 -31
  190. package/client/dist/types.js.map +0 -1
  191. package/client/dist/useAvatar.d.ts +0 -42
  192. package/client/dist/useAvatar.d.ts.map +0 -1
  193. package/client/dist/useAvatar.js +0 -51
  194. package/client/dist/useAvatar.js.map +0 -1
  195. package/client/src/Avatar.tsx +0 -33
  196. package/client/src/AvatarClient.ts +0 -270
  197. package/client/src/index.ts +0 -22
  198. package/client/src/types.ts +0 -104
  199. package/client/src/useAvatar.ts +0 -85
  200. package/docs/contract-avatar.md +0 -371
  201. package/docs/contract-protocol.md +0 -461
package/src/face-peep.js CHANGED
@@ -37,29 +37,44 @@
37
37
  *
38
38
  * The second construction fact
39
39
  * ----------------------------
40
- * THE FACE IS SIX MARKS: two brows, two eyes, a nose, a mouth. No sclera, no
41
- * iris, no lashes, no nostrils, no lower-lip line, no ear interior beyond one
42
- * comma, no shading anywhere. An earlier pass had roughly five times that many
43
- * marks and it read as a corporate mascot rather than a peep. Every mark added
44
- * below this line has to earn its place against that.
40
+ * THE FACE IS SIX MARKS: two brows, two eyes, a nose, a mouth. No lashes, no
41
+ * nostrils, no lower-lip line, no ear interior beyond one comma, no shading
42
+ * anywhere. An earlier pass had roughly five times that many marks and it read
43
+ * as a corporate mascot rather than a peep. Every mark added below this line
44
+ * has to earn its place against that.
45
+ *
46
+ * The eye takes three paths to draw (lid line, aperture, iris) and is still one
47
+ * mark: the rule counts what you see, not what renders it.
48
+ *
49
+ * Two changes on 2026-08-27
50
+ * -------------------------
51
+ * Both came from building the Canvas2D professional identities and then
52
+ * looking at this one next to them. They were prototyped as a separate face and
53
+ * folded back in once they held up, so `git log` for that day is the review.
54
+ *
55
+ * 1. THE HEAD WAS DRAWN TALL AND NARROW — a 316-unit-wide head inside a
56
+ * 908-unit-wide 4:3 frame, filling barely a third of the tile, where the
57
+ * canvas avatars were calibrated at call-tile size from the start and read
58
+ * fuller. Rather than redraw every path, the whole head cluster (skull,
59
+ * ears, hair, features) is wrapped in one static anisotropic `scale()`,
60
+ * pivoted at the chin so the jaw stays where the neck expects it. Every
61
+ * landmark constant below still describes the original art space; RESHAPE is
62
+ * the only new geometry. The head outline was thinned to match — a line that
63
+ * reads at the old size is a rope at the new one.
64
+ * 2. THE EYE WAS A SOLID INK BEAN and gaze read only as the whole bean
65
+ * translating. It is now that bean opened out: the bean is the lid line, a
66
+ * paper almond sits inside it, and an ink iris inside that carries the gaze
67
+ * while the lid line holds still. See the eyes section — it took two wrong
68
+ * passes and a wider EYE.rx, and the reasons are recorded there because they
69
+ * are not recoverable from the numbers.
45
70
  *
46
71
  * What the style costs the rig, honestly
47
72
  * --------------------------------------
48
- * 1. GAZE IS WEAKER THAN ON THE OTHER TWO AVATARS. There is no sclera, so
49
- * `pupilX/pupilY` cannot move an iris against a white field — the whole eye
50
- * bean translates instead. That is the correct reading of the style and it
51
- * is what real peeps do, but it carries less information per unit of travel.
52
- * Mitigated by giving the bean more travel than blue-shirt gives its iris
53
- * (11 units on a 25-wide eye, against blue-shirt's 19 on a 74-wide one —
54
- * as a share of frame width that is still less, and it took the DISTRACTED
55
- * state reading as eye contact to learn 7 was not enough) and by leaning on
56
- * gaze.js's head-follow, which is avatar-agnostic and does most of the work
57
- * anyway.
58
- * 2. THE JAW DOES NOT DROP. The chin is part of the head's ink outline, and
73
+ * 1. THE JAW DOES NOT DROP. The chin is part of the head's ink outline, and
59
74
  * translating a piece of that outline breaks the silhouette. Peeps do not
60
75
  * have moving jaws either. The cost is real — see MOUTH_APERTURE for how the
61
76
  * aperture is sized to stay off the chin without one.
62
- * 3. ONE SHOULDER CANNOT RISE WITHOUT THE OTHER. The torso is a single filled
77
+ * 2. ONE SHOULDER CANNOT RISE WITHOUT THE OTHER. The torso is a single filled
63
78
  * path, as it is on blue-shirt, so an asymmetric shrug is a small rotation
64
79
  * about the sternum. Same compromise, same reason.
65
80
  *
@@ -76,12 +91,9 @@ import {
76
91
  f, createFaceShell, faceApi, poseTransforms, pairedTeeth,
77
92
  } from './face-core.js';
78
93
  import { taper, taperRing, region, polyD, rng } from './line-art.js';
94
+ import { viewBoxForHead } from './camera.js';
79
95
 
80
96
  export const THEME = {
81
- // Paper, not white: a pure #fff field makes the ink look printed on glass,
82
- // and every Open Peeps mock sits the figure on a warm off-white.
83
- bg0: '#f6f3ee',
84
- bg1: '#e8e2d8',
85
97
  ink: '#1b1b1b',
86
98
  paper: '#ffffff',
87
99
  accent: '#f97415',
@@ -102,21 +114,31 @@ export const THEME = {
102
114
  // fixing that means outlining the hair mass — a geometry change wearing a
103
115
  // colour change's clothes. The keys stay; the second palette does not.
104
116
 
105
- // ---------------------------------------------------------------------------
106
- // Frame.
107
- //
108
- // Native art space is 760x950; the viewBox is a portrait window onto it, by the
109
- // same rule blue-shirt's crop follows: about an eighth of a head of air above
110
- // the crown, a bit over half a head below the chin, head at ~61% of frame
111
- // height, mouth near the optical centre.
117
+ // --- reshape ----------------------------------------------------------------
118
+ // One static, non-animated transform, applied to the head cluster only (skull,
119
+ // ears, hair, features — never neck or body). Pivoted at CHIN_Y so the chin's
120
+ // own silhouette point does not move: everything above it is pulled toward that
121
+ // point, everything at or below it is left exactly as drawn, so the jaw-to-
122
+ // collar join needs no rework.
112
123
  //
113
- // The width is set by a constraint the other avatars do not have. This shirt is
114
- // WHITE on a near-white ground, so where blue-shirt could let background show
115
- // past the shoulder and lose nothing, here the shoulder line has to leave the
116
- // frame or the figure reads as a paper cut-out floating in the middle. 576 is
117
- // the widest window whose bottom corners are still inside the shirt.
118
- // ---------------------------------------------------------------------------
119
- const VB = { x: 92, y: 76, w: 576, h: 800 };
124
+ // SX widens, SY shortens. Because the camera (viewBoxForHead) derives frame
125
+ // width from frame height at a fixed 4:3, shortening the head's vertical span
126
+ // shrinks the WHOLE frame around it — that is what "broader" means here: not a
127
+ // wider aspect (every shipped avatar shares one 4:3 camera, on purpose; see
128
+ // docs/authoring-a-face.md § The camera), but a head that fills more of a
129
+ // tighter crop, further widened on top of that by SX.
130
+ const CX = 380;
131
+ const CHIN_Y = 597;
132
+ const RESHAPE_SX = 1.10;
133
+ const RESHAPE_SY = 0.88;
134
+ const RESHAPE = `translate(${CX} ${CHIN_Y}) scale(${RESHAPE_SX} ${RESHAPE_SY}) translate(${-CX} ${-CHIN_Y})`;
135
+
136
+ // The camera follows the visible hair silhouette, not the skull hidden under
137
+ // it. Keeping these as native landmarks means reframing never touches a path.
138
+ // crownY is the reshaped crown: CHIN_Y + (117 − CHIN_Y) × RESHAPE_SY, worked
139
+ // out by hand because the camera reads landmarks, not the transform above it.
140
+ const FRAME = { centerX: CX, crownY: 177, chinY: CHIN_Y };
141
+ const VB = viewBoxForHead(FRAME);
120
142
 
121
143
  /**
122
144
  * The avatar descriptor: what a host or tool may know about this face without
@@ -125,7 +147,10 @@ const VB = { x: 92, y: 76, w: 576, h: 800 };
125
147
  */
126
148
  export const META = {
127
149
  viewBox: { x: VB.x, y: VB.y, w: VB.w, h: VB.h },
128
- mouthCrop: { x: 296, y: 434, w: 168, h: 98 },
150
+ // The original crop (296, 434, 168, 98) with its corners carried through
151
+ // RESHAPE. A camera change does not move a path, but the reshape is geometry
152
+ // rather than camera, so the inspection crop has to follow it.
153
+ mouthCrop: { x: 288, y: 454, w: 185, h: 86 },
129
154
  };
130
155
 
131
156
  // --- landmarks --------------------------------------------------------------
@@ -135,9 +160,11 @@ export const META = {
135
160
  // about a third of the head's width. Most of a peep's head is empty, and
136
161
  // spreading the features to fill it — which is what "correct" proportion pushes
137
162
  // you toward — is the single fastest way to lose the style.
138
- const CX = 380;
163
+ //
164
+ // These are the NATIVE art space and RESHAPE never enters them: it is a wrapper
165
+ // on the rendered group, so `apply()` and every number below still mean what
166
+ // they meant before it existed.
139
167
  const HEAD_TOP = 120;
140
- const CHIN_Y = 597;
141
168
 
142
169
  // Brows are given as the DRAWN control points, not as an inner/outer pair for a
143
170
  // generator to invent a curve between. That is a correction: a generator that
@@ -157,7 +184,11 @@ const BROW_L = [[CX - 20, 347], [CX - 40, 340], [CX - 60, 336], [CX - 76, 337],
157
184
  [CX - 86, 339], [CX - 94, 343], [CX - 99, 348]];
158
185
  const BROW_R = [[CX + 22, 344], [CX + 40, 337], [CX + 58, 333], [CX + 73, 335],
159
186
  [CX + 83, 337], [CX + 90, 341], [CX + 95, 346]];
160
- const EYE = { y: 386, dx: 55, rx: 14, ry: 16.5 };
187
+ // rx was 14 — the bean was very nearly round — until the eye was opened out.
188
+ // You cannot cut an almond aperture from a round bean without making the lid
189
+ // line absurdly heavy, and a round aperture is exactly what read as a target.
190
+ // Widening the bean was the unlock; see the eyes section.
191
+ const EYE = { y: 386, dx: 55, rx: 16.5, ry: 16.5 };
161
192
  const NOSE_TOP = 402;
162
193
  const MOUTH = { cx: CX, cy: 488 };
163
194
 
@@ -202,7 +233,11 @@ const HEAD = [
202
233
  // Heavy along the jaw, nearly nothing at the crown. The crown is under the hair
203
234
  // mass, and ink drawn where it will be covered only ever shows up as a dark
204
235
  // fringe along the hairline.
205
- const HEAD_W = [4, 8, 13, 16, 17, 15, 11.5, 8, 5, 4];
236
+ //
237
+ // Scaled to ~0.65 of the original profile ([4, 8, 13, 16, 17, 15, 11.5, 8, 5,
238
+ // 4]) when RESHAPE landed: the same taper shape, but a line rather than a rope
239
+ // once the head is drawn broader and the frame tighter around it.
240
+ const HEAD_W = [3, 5, 8.5, 10.5, 11, 10, 7.5, 5, 3.5, 3];
206
241
 
207
242
  const EAR_L = [[236, 366], [208, 352], [186, 380], [192, 418], [198, 450], [224, 462], [239, 452]];
208
243
  const EAR_R = [[537, 372], [563, 360], [583, 388], [576, 424], [570, 452], [546, 462], [533, 452]];
@@ -354,8 +389,8 @@ const PLACKET = [[387, 700], [390, 734], [385, 768], [389, 800]];
354
389
  // Four, the same set blue-shirt runs. Draw order is head, body, features, hair:
355
390
  // the shirt has to be over the neck, and the fringe has to be over the brows.
356
391
  // ---------------------------------------------------------------------------
357
- const PARALLAX = { head: 1.0, body: 0.1, features: 1.22, hair: 1.12 };
358
- const LAYERS = ['head', 'body', 'features', 'hair'];
392
+ const PARALLAX = { neck: 1.0, skull: 1.0, body: 0.1, features: 1.22, hair: 1.12 };
393
+ const LAYERS = ['neck', 'skull', 'body', 'features', 'hair'];
359
394
 
360
395
  // Roll pivots at the base of the neck. About the chin, the cranium swings
361
396
  // sideways and the whole thing reads as a puppet on a stick.
@@ -363,7 +398,7 @@ const PIVOT = { x: CX, y: 700 };
363
398
 
364
399
  // Pose mechanics for face-core's poseTransforms, in this rig's own art units
365
400
  // (native 760x950 — about 2.6x smaller than blue-shirt's, hence units: 1 and
366
- // its own literals; see the art-unit rules in docs/contract-avatar.md).
401
+ // its own literals; see the art-unit rules in docs/authoring-a-face.md).
367
402
  //
368
403
  // The shrug tilt is 1.6 degrees, not blue-shirt's 1.9. This collar is a
369
404
  // shallow polo opening over a white neck on a white shirt, so there is no
@@ -378,13 +413,23 @@ const POSE = {
378
413
  shrugLift: 30, shrugTiltDeg: 1.6, shrugPivot: { x: PIVOT.x, y: 800 },
379
414
  yawPx: 28, pitchPx: 17,
380
415
  pivot: PIVOT,
416
+ // The optional pitch contract is the smallest useful upgrade for a face
417
+ // whose acknowledgements need to read as nods: a neck behind an independently
418
+ // movable skull/face/hair set, plus six calibration numbers. There are no
419
+ // nod-specific paths here — existing art remains the source of truth.
420
+ pitch: {
421
+ headLayers: ['skull', 'features', 'hair'], neckLayer: 'neck',
422
+ hinge: { x: CX, y: 620 }, neckBase: { x: CX, y: 720 },
423
+ headTravel: 1.0, neckTravel: 0.22,
424
+ foreshorten: 0.040, neckCompress: 0.034,
425
+ },
381
426
  // 1.2% at full inhale. Chest circumference changes ~2-3% in quiet
382
427
  // breathing, so a little over half that in linear scale is the calm end of
383
428
  // real — and it renders as the shoulder line rising 3 units and the chest
384
429
  // widening 4 either side, against a hem that stays where it is. The
385
430
  // predecessor (a 4.2-unit rigid slide of the whole shirt) moved more pixels
386
431
  // and read as less alive, which is the entire argument for the swell.
387
- breathSwell: 0.012, swellPivot: { x: CX, y: 950 },
432
+ breathSwell: 0.008, swellPivot: { x: CX, y: 950 },
388
433
  // Lateral trunk travel. 16 units is ~2% of the frame width — the trunk
389
434
  // re-settling is meant to be noticed peripherally and never watched.
390
435
  turnPx: 16,
@@ -444,11 +489,11 @@ function mouthGeometry(p) {
444
489
  // face has no such detail to fall back on — the bow IS the expression, so it
445
490
  // has to be geometric.
446
491
  //
447
- // The constant 6 is resting pleasantness. An attentive agent's neutral is not a
448
- // flat line, and at mouthCorner's REST value of 0.1 the corner term alone
449
- // gives 2.4 units of bow across an 84-wide mouth, which is nothing.
450
- const yL = cy - 6 - p.mouthCornerL * 24;
451
- const yR = cy - 6 - p.mouthCornerR * 24;
492
+ // Keep a trace of organic bow at rest, but not a social smile. Warmth is a
493
+ // context-driven expression layered by the behavior director, not the
494
+ // permanent default while the avatar is merely available.
495
+ const yL = cy - 1.5 - p.mouthCornerL * 24;
496
+ const yR = cy - 1.5 - p.mouthCornerR * 24;
452
497
 
453
498
  // The aperture opens DOWNWARD, 3:1. The upper lip is anchored to the maxilla
454
499
  // and barely moves; the lower rides the jaw. Splitting it evenly is what makes
@@ -536,37 +581,107 @@ function teethPath(m, amt, lower) {
536
581
  // ---------------------------------------------------------------------------
537
582
  // Generators: eyes
538
583
  //
539
- // A solid ink bean, built as two cubics meeting at pointed corners: an upper
540
- // lid arc and a lower lid arc. Not an <ellipse> — an ellipse cannot blink, and
541
- // it cannot have corners.
584
+ // A bean, built as two cubics meeting at pointed corners: an upper lid arc and
585
+ // a lower lid arc. Not an <ellipse> — an ellipse cannot blink, and it cannot
586
+ // have corners.
542
587
  //
543
588
  // Blinking is the top edge descending toward the bottom one. At full closure
544
589
  // both edges end up ABOVE the corners, so the shape is a thin crescent bowing
545
590
  // upward: the relaxed closed lid every peep is drawn with. Collapsing it to a
546
591
  // straight line instead reads as the eyes being deleted for a frame.
592
+ //
593
+ // The bean used to be the whole eye — one solid mark, translated bodily for
594
+ // gaze. It is now the LID LINE, with a paper almond cut out of it and an ink
595
+ // iris inside that. Two passes at this were wrong, and the shape of the
596
+ // mistakes is the reason the numbers below are so specific:
597
+ //
598
+ // 1. A paper disc drawn straight onto the ink bean — a light hole in a dark
599
+ // shape, backwards from how an eye reads.
600
+ // 2. Paper disc, then a small ink disc on top. Right order, wrong
601
+ // proportions: paper visible all the way around a small central pupil is
602
+ // three concentric rings, which is a target, and on a face it is the
603
+ // classic startle cue — sclera showing above and below the iris. Every
604
+ // frame, both eyes, so the whole rig read as alarmed.
605
+ //
606
+ // So the aperture is deliberately WIDER THAN IT IS TALL while the bean is not,
607
+ // and the iris is sized to fill its height rather than float inside it. Paper
608
+ // then survives only at the two corners, which is what makes gaze read: a small
609
+ // iris shift kills one corner and doubles the other — a far larger signal than
610
+ // the same shift of a disc in open space, and the reason travel could come
611
+ // DOWN (11 × 8 for the whole bean, against 4.5 × 2.2 for the iris) while the
612
+ // reading got stronger.
547
613
  // ---------------------------------------------------------------------------
548
- function eyePath(cx, cy, lid, squint, tiltDeg) {
549
- const L = clamp(lid);
550
- // 1.05 rather than 0.75 because a cubic reaches only ~0.75 of its control
551
- // offset at the midpoint; this is what makes the open bean EYE.ry tall. It
552
- // was 1.4, which drew a bean noticeably taller than it was wide — read as a
553
- // stare rather than as a peep, whose eyes sit at or below square.
554
- const topY = lerp(cy - EYE.ry * 1.05, cy - EYE.ry * 0.42, L);
555
- const botY = lerp(cy + EYE.ry * 1.05, cy - EYE.ry * 0.05, L) - clamp(squint) * EYE.ry * 0.7;
556
- const rx = EYE.rx;
614
+
615
+ // A cubic whose two controls share a y reaches only 3/4 of the way to it.
616
+ // Everything below works in DRAWN extents and divides that back out, so an
617
+ // inset of n units is n units on screen. The second pass's inset was a
618
+ // proportional one and could not be: scaled about the eye centre it inverts the
619
+ // moment a closing lid carries the bean's lower edge above that centre, and it
620
+ // leaked paper out through a shut eye.
621
+ const BULGE = 0.75;
622
+
623
+ /** Two cubics between (cx±rx, cyMid), bulging to yTop and yBot; tilt about cy. */
624
+ function lensPath(cx, cy, g, tiltDeg) {
625
+ if (!g) return '';
557
626
  const a = (tiltDeg * Math.PI) / 180;
558
627
  const ca = Math.cos(a), sa = Math.sin(a);
559
628
  const R = (x, y) => {
560
629
  const dx = x - cx, dy = y - cy;
561
630
  return `${f(cx + dx * ca - dy * sa)} ${f(cy + dx * sa + dy * ca)}`;
562
631
  };
632
+ const ctlTop = g.cyMid + (g.yTop - g.cyMid) / BULGE;
633
+ const ctlBot = g.cyMid + (g.yBot - g.cyMid) / BULGE;
563
634
  return (
564
- `M${R(cx - rx, cy)}` +
565
- `C${R(cx - rx * 0.5, topY)} ${R(cx + rx * 0.5, topY)} ${R(cx + rx, cy)}` +
566
- `C${R(cx + rx * 0.5, botY)} ${R(cx - rx * 0.5, botY)} ${R(cx - rx, cy)}Z`
635
+ `M${R(cx - g.rx, g.cyMid)}` +
636
+ `C${R(cx - g.rx * 0.5, ctlTop)} ${R(cx + g.rx * 0.5, ctlTop)} ${R(cx + g.rx, g.cyMid)}` +
637
+ `C${R(cx + g.rx * 0.5, ctlBot)} ${R(cx - g.rx * 0.5, ctlBot)} ${R(cx - g.rx, g.cyMid)}Z`
567
638
  );
568
639
  }
569
640
 
641
+ /** The lid silhouette, in drawn extents. */
642
+ function eyeGeom(cy, lid, squint) {
643
+ const L = clamp(lid);
644
+ // 1.05 rather than 0.75 because of BULGE; this is what makes the open bean
645
+ // EYE.ry tall. It was 1.4, which drew a bean noticeably taller than it was
646
+ // wide — read as a stare rather than as a peep, whose eyes sit at or below
647
+ // square.
648
+ const ctlTop = lerp(cy - EYE.ry * 1.05, cy - EYE.ry * 0.42, L);
649
+ const ctlBot = lerp(cy + EYE.ry * 1.05, cy - EYE.ry * 0.05, L) - clamp(squint) * EYE.ry * 0.7;
650
+ return {
651
+ cyMid: cy,
652
+ rx: EYE.rx,
653
+ yTop: cy + BULGE * (ctlTop - cy),
654
+ yBot: cy + BULGE * (ctlBot - cy),
655
+ };
656
+ }
657
+
658
+ // The lid line, as constant distances. Heavier above than below: an upper lid
659
+ // carries the lashes and reads as the eye's weight, and a lower line as heavy
660
+ // makes a doll. LASH_X is the corner, where the line has to stay thin or the
661
+ // almond loses its points and rounds back into the target shape.
662
+ const LASH_X = 3.0;
663
+ const LASH_TOP = 5.5;
664
+ const LASH_BOT = 4.5;
665
+
666
+ /** The paper almond inside the lid line, or null once the lid has shut on it. */
667
+ function apertureGeom(g) {
668
+ const yTop = g.yTop + LASH_TOP;
669
+ const yBot = g.yBot - LASH_BOT;
670
+ const rx = g.rx - LASH_X;
671
+ if (yBot - yTop < 0.5 || rx < 0.5) return null;
672
+ return { cyMid: (yTop + yBot) / 2, rx, yTop, yBot };
673
+ }
674
+
675
+ // Sized to fill the resting aperture's height (26 − 5.5 − 4.5 = 16 drawn units)
676
+ // rather than float inside it, so both lids crop it and the paper survives only
677
+ // as two corner slivers.
678
+ const IRIS_R = 8.0;
679
+
680
+ // Travel, native units. x is roughly one corner's worth of paper: at either
681
+ // extreme one sliver closes and the other doubles, which is the whole gaze
682
+ // signal.
683
+ const IRIS_TRAVEL = { x: 4.5, y: 2.2 };
684
+
570
685
  // ---------------------------------------------------------------------------
571
686
  // Generators: brows
572
687
  //
@@ -612,36 +727,47 @@ function markup(id, t) {
612
727
  <svg id="${id}" viewBox="${VB.x} ${VB.y} ${VB.w} ${VB.h}" xmlns="http://www.w3.org/2000/svg"
613
728
  preserveAspectRatio="xMidYMid meet" style="display:block;width:100%;height:100%">
614
729
  <defs>
615
- <radialGradient id="${id}-gBg" cx="50%" cy="36%" r="76%">
616
- <stop offset="0%" stop-color="${t.bg0}"/><stop offset="100%" stop-color="${t.bg1}"/>
617
- </radialGradient>
618
730
  <clipPath id="${id}-clipMouth"><path id="${id}-clipMouthP" d=""/></clipPath>
731
+ <clipPath id="${id}-clipEyeL"><path id="${id}-clipEyeLP" d=""/></clipPath>
732
+ <clipPath id="${id}-clipEyeR"><path id="${id}-clipEyeRP" d=""/></clipPath>
619
733
  </defs>
620
734
 
621
- <rect x="${VB.x}" y="${VB.y}" width="${VB.w}" height="${VB.h}" fill="url(#${id}-gBg)"/>
622
-
623
- <!-- head, ears and neck. Ears go under the head fill so the loop's inner half
624
- is covered and only the rim reads. -->
625
- <g id="${id}-head">
735
+ <!-- The neck stays behind the skull. This explicit split is what makes a nod
736
+ a nod: it lets one shorten the neck and pivot the head surface instead of
737
+ treating the entire portrait as one vertically sliding layer. -->
738
+ <g id="${id}-neck">
626
739
  <path d="${NECK_FILL}" fill="${t.paper}"/>
627
740
  ${ink(taper(NECK_L, [3, 8, 6]))}
628
741
  ${ink(taper(NECK_R, [3, 8, 6]))}
629
- <path d="${EAR_L_FILL}" fill="${t.paper}"/>
630
- <path d="${EAR_R_FILL}" fill="${t.paper}"/>
631
- ${ink(EAR_L_RING)}
632
- ${ink(EAR_R_RING)}
633
- <path d="${HEAD_FILL}" fill="${t.paper}"/>
634
- ${ink(HEAD_RING)}
635
- ${ink(taper(EAR_L_IN, [2.5, 5, 2.5]))}
636
- ${ink(taper(EAR_R_IN, [2.5, 4.5, 2.5]))}
637
- ${ink(taper(JAW_UNDER, [2, 5.5, 2]))}
638
- <!-- Hair underlay: the same mass, locked to the skull at head parallax.
639
- The hair layer runs faster, so under yaw it slides a few units across
640
- the cranium; the mass abuts the head outline with no overlap margin, so
641
- that slide would otherwise open a bright white sliver of scalp along the
642
- leading edge. On a white-on-white avatar that sliver is far more visible
643
- than blue-shirt's equivalent. Cheap insurance; do not remove it. -->
644
- <path d="${HAIR_D}" fill="${t.ink}"/>
742
+ </g>
743
+
744
+ <!-- head and ears. Ears go under the head fill so the loop's inner half
745
+ is covered and only the rim reads.
746
+
747
+ RESHAPE wraps the art INSIDE the parallax group, never the group itself:
748
+ poseTransforms writes the group's own transform every frame and would
749
+ overwrite it. Same for features and hair below. The neck and body are
750
+ deliberately outside it — the reshape is pivoted on the chin so that
751
+ join still lands. -->
752
+ <g id="${id}-skull">
753
+ <g transform="${RESHAPE}">
754
+ <path d="${EAR_L_FILL}" fill="${t.paper}"/>
755
+ <path d="${EAR_R_FILL}" fill="${t.paper}"/>
756
+ ${ink(EAR_L_RING)}
757
+ ${ink(EAR_R_RING)}
758
+ <path d="${HEAD_FILL}" fill="${t.paper}"/>
759
+ ${ink(HEAD_RING)}
760
+ ${ink(taper(EAR_L_IN, [2.5, 5, 2.5]))}
761
+ ${ink(taper(EAR_R_IN, [2.5, 4.5, 2.5]))}
762
+ ${ink(taper(JAW_UNDER, [2, 5.5, 2]))}
763
+ <!-- Hair underlay: the same mass, locked to the skull at head parallax.
764
+ The hair layer runs faster, so under yaw it slides a few units across
765
+ the cranium; the mass abuts the head outline with no overlap margin, so
766
+ that slide would otherwise open a bright white sliver of scalp along the
767
+ leading edge. On a white-on-white avatar that sliver is far more visible
768
+ than blue-shirt's equivalent. Cheap insurance; do not remove it. -->
769
+ <path d="${HAIR_D}" fill="${t.ink}"/>
770
+ </g>
645
771
  </g>
646
772
 
647
773
  <!-- shirt: silhouette, arm separation, creases, collar -->
@@ -666,28 +792,36 @@ function markup(id, t) {
666
792
 
667
793
  <!-- features: six marks and nothing else -->
668
794
  <g id="${id}-features">
669
- <path id="${id}-browL" fill="${t.ink}"/>
670
- <path id="${id}-browR" fill="${t.ink}"/>
671
- <g id="${id}-eyes">
672
- <path id="${id}-eyeL" fill="${t.ink}"/>
673
- <path id="${id}-eyeR" fill="${t.ink}"/>
674
- </g>
675
- <path d="${NOSE_D}" fill="${t.ink}"/>
676
- <g id="${id}-mouth">
677
- <path id="${id}-mouthIn" fill="${t.mouthIn}"/>
678
- <g clip-path="url(#${id}-clipMouth)">
679
- <ellipse id="${id}-tongue" fill="${t.tongue}"/>
680
- <path id="${id}-teeth" fill="${t.teeth}"/>
681
- <path id="${id}-teethLo" fill="${t.teeth}" opacity=".85"/>
795
+ <g transform="${RESHAPE}">
796
+ <path id="${id}-browL" fill="${t.ink}"/>
797
+ <path id="${id}-browR" fill="${t.ink}"/>
798
+ <g id="${id}-eyes">
799
+ <path id="${id}-eyeL" fill="${t.ink}"/>
800
+ <path id="${id}-eyeR" fill="${t.ink}"/>
801
+ <path id="${id}-apertureL" fill="${t.paper}"/>
802
+ <path id="${id}-apertureR" fill="${t.paper}"/>
803
+ <g clip-path="url(#${id}-clipEyeL)"><circle id="${id}-irisL" fill="${t.ink}"/></g>
804
+ <g clip-path="url(#${id}-clipEyeR)"><circle id="${id}-irisR" fill="${t.ink}"/></g>
805
+ </g>
806
+ <path d="${NOSE_D}" fill="${t.ink}"/>
807
+ <g id="${id}-mouth">
808
+ <path id="${id}-mouthIn" fill="${t.mouthIn}"/>
809
+ <g clip-path="url(#${id}-clipMouth)">
810
+ <ellipse id="${id}-tongue" fill="${t.tongue}"/>
811
+ <path id="${id}-teeth" fill="${t.teeth}"/>
812
+ <path id="${id}-teethLo" fill="${t.teeth}" opacity=".85"/>
813
+ </g>
814
+ <path id="${id}-lips" fill="${t.ink}"/>
682
815
  </g>
683
- <path id="${id}-lips" fill="${t.ink}"/>
684
816
  </g>
685
817
  </g>
686
818
 
687
819
  <!-- hair -->
688
820
  <g id="${id}-hair">
689
- <path d="${HAIR_D}" fill="${t.ink}"/>
690
- <path d="${region(HAIR_TUFT)}" fill="${t.ink}"/>
821
+ <g transform="${RESHAPE}">
822
+ <path d="${HAIR_D}" fill="${t.ink}"/>
823
+ <path d="${region(HAIR_TUFT)}" fill="${t.ink}"/>
824
+ </g>
691
825
  </g>
692
826
  </svg>`;
693
827
  }
@@ -708,9 +842,12 @@ export function createFace(mount, theme = {}) {
708
842
  const { svg, $, set } = createFaceShell(mount, id, markup(id, t));
709
843
 
710
844
  const el = {
711
- head: $('head'), body: $('body'), features: $('features'), hair: $('hair'),
845
+ neck: $('neck'), skull: $('skull'), body: $('body'), features: $('features'), hair: $('hair'),
712
846
  browL: $('browL'), browR: $('browR'),
713
- eyes: $('eyes'), eyeL: $('eyeL'), eyeR: $('eyeR'),
847
+ eyeL: $('eyeL'), eyeR: $('eyeR'),
848
+ clipEyeL: $('clipEyeLP'), clipEyeR: $('clipEyeRP'),
849
+ apertureL: $('apertureL'), apertureR: $('apertureR'),
850
+ irisL: $('irisL'), irisR: $('irisR'),
714
851
  mouthIn: $('mouthIn'), lips: $('lips'), clipMouth: $('clipMouthP'),
715
852
  teeth: $('teeth'), teethLo: $('teethLo'), tongue: $('tongue'),
716
853
  };
@@ -719,17 +856,31 @@ export function createFace(mount, theme = {}) {
719
856
  poseTransforms(p, set, el, POSE);
720
857
 
721
858
  // --- eyes -------------------------------------------------------------
722
- // With no sclera the whole bean travels; see the header for what that costs
723
- // and why it is still the right call. 7 units on a 25-wide eye is a lot of
724
- // relative travel — more than blue-shirt's iris gets — and it has to be, or
725
- // a gaze target does not read at all.
726
- set(el.eyes, 'transform', `translate(${f(p.pupilX * 11)} ${f(p.pupilY * 8)})`);
727
-
728
- // The lid follows vertical gaze downward only. Looking up genuinely does
729
- // widen the aperture, so there is nothing to add on that side.
859
+ // The lid line holds still and the iris carries the gaze. The lid follows
860
+ // vertical gaze downward only: looking up genuinely does widen the
861
+ // aperture, so there is nothing to add on that side.
730
862
  const lidFollow = Math.max(0, p.pupilY) * 0.22;
731
- set(el.eyeL, 'd', eyePath(CX - EYE.dx, EYE.y + 1, p.lidL + lidFollow, p.squintL, -9));
732
- set(el.eyeR, 'd', eyePath(CX + EYE.dx, EYE.y - 1, p.lidR + lidFollow, p.squintR, 8));
863
+ const eye = (cx, cy, lid, squint, tilt, aperture, iris, clip) => {
864
+ const g = eyeGeom(cy, lid, squint);
865
+ const ap = apertureGeom(g);
866
+ const apD = lensPath(cx, cy, ap, tilt);
867
+ set(el[aperture], 'd', apD);
868
+ // The iris is clipped to the APERTURE, not to the lid line, so it is
869
+ // cropped by the same edge the paper is — and a shut lid, which has no
870
+ // aperture at all, takes the iris with it and needs no opacity logic.
871
+ set(el[clip], 'd', apD);
872
+ set(el[iris], 'cx', f(cx + p.pupilX * IRIS_TRAVEL.x));
873
+ // Anchored to the aperture's midline rather than the eye's, so a
874
+ // half-closed eye keeps the iris centred in the slit instead of showing
875
+ // a band of paper above it.
876
+ set(el[iris], 'cy', f((ap ? ap.cyMid : cy) + p.pupilY * IRIS_TRAVEL.y));
877
+ set(el[iris], 'r', f(IRIS_R));
878
+ return lensPath(cx, cy, g, tilt);
879
+ };
880
+ set(el.eyeL, 'd', eye(CX - EYE.dx, EYE.y + 1, p.lidL + lidFollow, p.squintL, -9,
881
+ 'apertureL', 'irisL', 'clipEyeL'));
882
+ set(el.eyeR, 'd', eye(CX + EYE.dx, EYE.y - 1, p.lidR + lidFollow, p.squintR, 8,
883
+ 'apertureR', 'irisR', 'clipEyeR'));
733
884
 
734
885
  // --- brows ------------------------------------------------------------
735
886
  set(el.browL, 'd', browPath(BROW_L, p.browRaiseL, p.browAngleL, p.browInnerL));
@@ -765,3 +916,10 @@ export function createFace(mount, theme = {}) {
765
916
 
766
917
  return faceApi(mount, svg, apply, t);
767
918
  }
919
+
920
+ /**
921
+ * This face as a **Face** record — `{ create, meta }`, the shape
922
+ * `createAvatar({ face })` takes. Importing it costs this drawing and nothing
923
+ * else; `src/faces.js` is the all-three table, for tooling.
924
+ */
925
+ export const peep = { create: createFace, meta: META };
@@ -0,0 +1,7 @@
1
+ import type { AvatarMeta, Face, FaceFactory } from "./avatar.js";
2
+
3
+ export const createFace: FaceFactory;
4
+ export const META: AvatarMeta;
5
+ /** Default palette. Passed as `createAvatar({ theme })`; see authoring-a-face.md. */
6
+ export const THEME: Readonly<Record<string, string>>;
7
+ export const wren: Face;