clauderipple 0.8.9 → 0.9.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 (75) hide show
  1. package/CHANGELOG.md +62 -0
  2. package/README.es.md +0 -4
  3. package/README.ja.md +0 -4
  4. package/README.ko.md +5 -1
  5. package/README.md +5 -1
  6. package/README.pt-BR.md +0 -4
  7. package/README.zh-CN.md +0 -4
  8. package/dist/app/dist/main.js +23 -3
  9. package/dist/app/package.json +1 -1
  10. package/dist/cli/src/codex.js +3 -1
  11. package/dist/cli/src/index.js +1 -1
  12. package/dist/cli/src/settings.js +5 -2
  13. package/dist/cli/src/tray.js +41 -5
  14. package/dist/mod/.claude-plugin/plugin.json +9 -1
  15. package/dist/mod/hooks/cache.ts +60 -0
  16. package/dist/mod/hooks/compact.ts +40 -0
  17. package/dist/mod/hooks/handoff.ts +43 -0
  18. package/dist/mod/hooks/i18n.ts +2716 -0
  19. package/dist/mod/hooks/judge.ts +226 -0
  20. package/dist/mod/hooks/ladder.js +179 -0
  21. package/dist/mod/hooks/ladder.ts +174 -0
  22. package/dist/mod/hooks/modes.ts +93 -0
  23. package/dist/mod/hooks/register.tsx +1458 -256
  24. package/dist/mod/hooks/session.ts +62 -0
  25. package/dist/mod/hooks/settings.js +342 -0
  26. package/dist/mod/hooks/settings.ts +321 -0
  27. package/dist/mod/hooks/stats.ts +59 -0
  28. package/dist/mod/hooks/ui/agents.tsx +138 -0
  29. package/dist/mod/hooks/ui/alerts.tsx +370 -0
  30. package/dist/mod/hooks/ui/art.ts +628 -0
  31. package/dist/mod/hooks/ui/band.tsx +836 -0
  32. package/dist/mod/hooks/ui/kit.tsx +258 -0
  33. package/dist/mod/hooks/ui/log.tsx +109 -0
  34. package/dist/mod/hooks/ui/parts.tsx +217 -0
  35. package/dist/mod/hooks/ui/ripple.ts +614 -0
  36. package/dist/mod/hooks/ui/settings.tsx +376 -0
  37. package/dist/mod/hooks/ui/setup.tsx +148 -0
  38. package/dist/mod/hooks/ui/theme.ts +217 -0
  39. package/dist/mod/hooks/ui/view.ts +371 -0
  40. package/dist/mod/hooks/worker.ts +1 -1
  41. package/dist/mod/hooks/workers.ts +217 -0
  42. package/dist/mod/tests/register.test.tsx +1730 -0
  43. package/dist/mod/tests/ui.test.tsx +853 -0
  44. package/dist/mod/tsconfig.json +2 -1
  45. package/dist/mod/types/index.d.ts +320 -1
  46. package/dist/router/src/admin.js +195 -53
  47. package/dist/router/src/agents.js +24 -5
  48. package/dist/router/src/bootstrap.js +4 -2
  49. package/dist/router/src/compat.js +10 -2
  50. package/dist/router/src/config.js +3 -0
  51. package/dist/router/src/health.js +33 -0
  52. package/dist/router/src/index.js +1 -0
  53. package/dist/router/src/ingress/models.js +4 -1
  54. package/dist/router/src/mod-settings.js +119 -0
  55. package/dist/router/src/model-policy.js +6 -0
  56. package/dist/router/src/model-scores.js +159 -0
  57. package/dist/router/src/picker.js +7 -0
  58. package/dist/router/src/providers/anthropic-account-pool.js +10 -4
  59. package/dist/router/src/providers/anthropic-accounts.js +26 -1
  60. package/dist/router/src/providers/chatgpt/catalog.js +45 -14
  61. package/dist/router/src/providers/chatgpt/index.js +9 -3
  62. package/dist/router/src/providers/chatgpt/translate.js +9 -5
  63. package/dist/router/src/providers/google/index.js +6 -2
  64. package/dist/router/src/providers/openai/index.js +4 -1
  65. package/dist/router/src/providers/openai/translate.js +1 -1
  66. package/dist/router/src/proxy.js +14 -3
  67. package/dist/router/src/version.js +1 -1
  68. package/dist/ui/app.js +932 -209
  69. package/dist/ui/favicon-32.png +0 -0
  70. package/dist/ui/favicon.svg +12 -0
  71. package/dist/ui/i18n.js +923 -237
  72. package/dist/ui/index.html +159 -87
  73. package/dist/ui/style.css +789 -408
  74. package/docs/ARCHITECTURE.md +134 -2
  75. package/package.json +7 -3
@@ -0,0 +1,614 @@
1
+ // 리플이 (Ripple), the mod's mascot: a hollow ripple ring with a face, a wave crest on top and two
2
+ // ripple ears, alive wherever it is drawn. Every shape is the approved sheet "리플이 v2 (물마루)"
3
+ // (design-drafts/ripple-char-v2.html and brand/, 2026-10-11) on its 56-unit grid; the motion is ours.
4
+ //
5
+ // What the host allows, and so what this draws (see parts.tsx):
6
+ // - A plain Svg image (never isInteractive) keeps its CSS motion and does not blink when the desktop
7
+ // redraws (seen in the owner's app, 2026-10-11). But each redraw is a new image, so the motion
8
+ // starts over: every loop opens on a natural pose (the rest pose, or a part that is hidden at
9
+ // rest, like a ripple about to spread), and the big moments (a look, a hop, a yawn) come seconds
10
+ // in, so the first second after a redraw is quiet and reads as going on.
11
+ // - A face is one fixed string per state; only the gauge varies, in 5% steps, so a figure that
12
+ // creeps does not make a new picture every redraw.
13
+ // - Idle life is several loops of unrelated lengths (blinks on 7, 11 and 13 s, a look on 13, a hop
14
+ // on 17), so the sum never looks mechanical. One loop per element: blinks nest a group each.
15
+ // - Under prefers-reduced-motion nothing moves: the markup alone is the rest pose. No filters, no
16
+ // external references, no script.
17
+ // - Two details: `full` (34 px up) is the colour sheet, the face in ink; `mini` (below, the band's
18
+ // 20 px) is the mono template the sheet gives for small sizes, every part in the tone, heavier
19
+ // lines, no mouth or brows, its moves a little larger so they read.
20
+ // - Colours are the palette variables svgDoc sets (theme.ts), so light and dark differ by tokens.
21
+ //
22
+ // Pure: no imports at run time, so node tests can load it.
23
+
24
+ import type { Palette } from './theme'
25
+
26
+ export type Face =
27
+ | 'calm' | 'thinking' | 'working' | 'drowsy' | 'sleepy' | 'uneasy' | 'strained' | 'alarmed'
28
+ | 'dazed' | 'asking' | 'squeezing' | 'going' | 'pleased' | 'sorry'
29
+ export type FaceTone = 'accent' | 'ok' | 'warn' | 'bad'
30
+ export type Detail = 'full' | 'mini'
31
+
32
+ export const FACES: readonly Face[] = ['calm', 'thinking', 'working', 'drowsy', 'sleepy', 'uneasy', 'strained', 'alarmed', 'dazed', 'asking', 'squeezing', 'going', 'pleased', 'sorry']
33
+
34
+ /** The grid every face is drawn on: the SVG's viewBox is `0 0 56 56` at any size. */
35
+ export const GRID = 56
36
+
37
+ /** From this size up the full drawing (the sheet's 34 px badge floor); below it, the mini one. */
38
+ export const MIN_SIZE = 34
39
+
40
+ export function detailOf(size: number): Detail {
41
+ return size < MIN_SIZE ? 'mini' : 'full'
42
+ }
43
+
44
+ export const FACE_TONE: Record<Face, FaceTone> = {
45
+ calm: 'accent',
46
+ thinking: 'accent',
47
+ working: 'accent',
48
+ drowsy: 'accent',
49
+ sleepy: 'accent',
50
+ uneasy: 'warn',
51
+ strained: 'warn',
52
+ alarmed: 'bad',
53
+ dazed: 'bad',
54
+ asking: 'accent',
55
+ squeezing: 'accent',
56
+ going: 'accent',
57
+ pleased: 'ok',
58
+ sorry: 'bad',
59
+ }
60
+
61
+ /** The faces whose ring is a gauge when given a value; their strain grows with it. */
62
+ export const GAUGE_FACES: readonly Face[] = ['uneasy', 'strained', 'alarmed']
63
+
64
+ /** A gauge value as drawn: whole multiples of 5 from 0 to 100. */
65
+ export function gaugeStep(percent: number): number {
66
+ if (!Number.isFinite(percent)) return 0
67
+ return Math.min(100, Math.max(0, Math.round(percent / 5) * 5))
68
+ }
69
+
70
+ const v = (key: keyof Palette): string => 'var(--' + key + ')'
71
+ const n = (x: number): string => String(Math.round(x * 100) / 100)
72
+
73
+ // ---- Parts ---------------------------------------------------------------------------------------
74
+
75
+ /** The crest's variants: its base sits on the outer edge of the ring at −100°…−64°, buried in it. */
76
+ const CREST = {
77
+ up: 'M24.7 12.29C24.2 6.2 29.2 2.4 35 2.6C39.6 2.8 42.6 6.2 41.6 9.8C40.6 8 38.6 7 36.8 7.6C35 8.2 35 11 36.33 13.92Z',
78
+ tall: 'M24.7 12.29C24.4 5.6 28.6 0.8 34.2 0.6C37.6 0.5 40.2 2.2 40.8 4.6C39.2 3.8 37.2 4 36.2 5.4C35 7.2 35.2 10.6 36.33 13.92Z',
79
+ short: 'M24.7 12.29C25 8.4 28.4 6.4 32 6.6C34.8 6.8 36.8 8.4 36.8 10.2C36 9.6 35.2 9.8 34.9 10.6C34.7 11.6 35.4 12.8 36.33 13.92Z',
80
+ wind: 'M24.7 12.29C25.6 6.4 31.6 3.6 38 4C42 4.2 44.6 6 45.2 8.4C43.4 7.4 40.8 7.4 38.8 8.4C37.2 9.4 36 11.6 36.33 13.92Z',
81
+ wilt: 'M24.7 12.29C25.4 6 31.4 3.8 36.6 4.6C41.6 5.4 44.4 9.6 43.6 14.6C42.4 12 40.2 10.6 38.2 11C37.2 11.4 36.6 12.6 36.33 13.92Z',
82
+ left: 'M31.3 12.29C31.8 6.2 26.8 2.4 21 2.6C16.4 2.8 13.4 6.2 14.4 9.8C15.4 8 17.4 7 19.2 7.6C21 8.2 21 11 19.67 13.92Z',
83
+ } as const
84
+
85
+ /** The question's tail: two points on the ring's outer edge meeting at (8.6, 53.4). */
86
+ const TAIL = 'M18.5 47.45Q15.4 51.2 8.6 53.4Q11.8 48.2 13.03 42.7Z'
87
+
88
+ const RING = '<circle cx="28" cy="31" r="16.5" fill="none"'
89
+ /** The ears: the sheet's colour arcs (r 23.5, 2.4) and the mono template's (r 24.5, 4). */
90
+ const EARS: Record<Detail, { r: string; l: string; width: number }> = {
91
+ full: { r: 'M49.47 21.44A23.5 23.5 0 0 1 49.47 40.56', l: 'M6.53 40.56A23.5 23.5 0 0 1 6.53 21.44', width: 2.4 },
92
+ mini: { r: 'M50.02 20.26A24.5 24.5 0 0 1 50.02 41.74', l: 'M5.98 41.74A24.5 24.5 0 0 1 5.98 20.26', width: 4 },
93
+ }
94
+ const OUTER_R = 'M53.86 23.59A26.9 26.9 0 0 1 53.86 38.41'
95
+ const OUTER_L = 'M2.14 38.41A26.9 26.9 0 0 1 2.14 23.59'
96
+
97
+ /** What one drawing paints with: the tone, and the face's ink (the tone too on the mini). */
98
+ type Pen = { mini: boolean; tone: string; ink: string; muted: string; ring: number }
99
+
100
+ function penOf(face: Face, detail: Detail): Pen {
101
+ const tone = v(FACE_TONE[face])
102
+ const mini = detail === 'mini'
103
+ return { mini, tone, ink: mini ? tone : v('ink'), muted: mini ? tone : v('muted'), ring: mini ? 6.4 : 5 }
104
+ }
105
+
106
+ function stroke(d: string, paint: string, width: number, extra = ''): string {
107
+ return '<path d="' + d + '" fill="none" stroke="' + paint + '" stroke-width="' + n(width) + '" stroke-linecap="round"' + extra + '/>'
108
+ }
109
+
110
+ /** A line of the face: ink, round caps and joins; heavier on the mini. */
111
+ function line(p: Pen, d: string, width = 2.2): string {
112
+ return stroke(d, p.ink, p.mini ? Math.max(3.2, width * 1.45) : width, ' stroke-linejoin="round"')
113
+ }
114
+
115
+ /** The capsule eyes, the left one at `x, y`, `h` tall; the mini's are the template's 5 wide, half again as tall. */
116
+ function capsules(p: Pen, x: number, y: number, h: number): string {
117
+ const w = p.mini ? 5 : 3.6
118
+ const hh = p.mini ? h * 1.5 : h
119
+ const top = y + h / 2 - hh / 2
120
+ return [x, x + 11.2].map(left => '<rect x="' + n(left + 1.8 - w / 2) + '" y="' + n(top) + '" width="' + n(w) + '" height="' + n(hh) + '" rx="' + n(w / 2) + '" fill="' + p.ink + '"/>').join('')
121
+ }
122
+
123
+ /** A water drop (sweat, tear) at its top point, scaled; muted, larger on the mini. */
124
+ function drop(p: Pen, x: number, y: number, scale: number): string {
125
+ const s = p.mini ? scale * 1.5 : scale
126
+ const r = 2.3 * s
127
+ return (
128
+ '<path d="M' + n(x) + ' ' + n(y) + 'q' + n(-r) + ' ' + n(3.1 * s) + ' ' + n(-r) + ' ' + n(4.5 * s) + 'a' + n(r) + ' ' + n(r) + ' 0 0 0 ' + n(2 * r) +
129
+ ' 0q0 ' + n(-1.4 * s) + ' ' + n(-r) + ' ' + n(-4.5 * s) + 'z" fill="' + p.muted + '"/>'
130
+ )
131
+ }
132
+
133
+ type Ears = 'on' | 'dim' | 'double' | 'none' | 'left' | 'waving'
134
+
135
+ /** The ears, each in a group of its own so one can wave. */
136
+ function ears(p: Pen, kind: Ears): string {
137
+ const e = EARS[p.mini ? 'mini' : 'full']
138
+ const ear = (d: string, opacity?: number) => stroke(d, p.tone, e.width, opacity === undefined ? '' : ' stroke-opacity="' + opacity + '"')
139
+ const L = (opacity?: number) => '<g class="earL">' + ear(e.l, opacity) + '</g>'
140
+ const R = (opacity?: number) => '<g class="earR">' + ear(e.r, opacity) + '</g>'
141
+ switch (kind) {
142
+ case 'none':
143
+ return ''
144
+ case 'left':
145
+ return L(0.55)
146
+ case 'dim':
147
+ return L(0.4) + R(0.4)
148
+ case 'waving':
149
+ return L(0.4) + R()
150
+ case 'on':
151
+ return L() + R()
152
+ case 'double':
153
+ // The mini has no room past the ears: its alarm is the pulse alone.
154
+ return L() + R() + (p.mini ? '' : stroke(OUTER_R, p.tone, 2, ' stroke-opacity="0.55"') + stroke(OUTER_L, p.tone, 2, ' stroke-opacity="0.55"'))
155
+ }
156
+ }
157
+
158
+ /** Copies of the ears that spread outward like water, unseen at rest. */
159
+ function waves(p: Pen, sides: 'both' | 'right'): string {
160
+ const e = EARS[p.mini ? 'mini' : 'full']
161
+ const wave = (cls: string, d: string) => stroke(d, p.tone, e.width * 0.8, ' class="' + cls + '" opacity="0"')
162
+ return (sides === 'both' ? wave('wL', e.l) : '') + wave('wR', e.r)
163
+ }
164
+
165
+ /** The ring as a gauge: a faint track and the value from twelve o'clock, clockwise. */
166
+ function gauge(p: Pen, percent: number): string {
167
+ const step = gaugeStep(percent)
168
+ const track = RING + ' stroke="' + v('faint') + '" stroke-width="' + p.ring + '"/>'
169
+ if (step === 0) return track
170
+ if (step === 100) return RING + ' stroke="' + p.tone + '" stroke-width="' + p.ring + '"/>'
171
+ const a = ((-90 + 3.6 * step) * Math.PI) / 180
172
+ const x = 28 + 16.5 * Math.cos(a)
173
+ const y = 31 + 16.5 * Math.sin(a)
174
+ return track + stroke('M28 14.5A16.5 16.5 0 ' + (step > 50 ? 1 : 0) + ' 1 ' + n(x) + ' ' + n(y), p.tone, p.ring)
175
+ }
176
+
177
+ /** The ring in progress: eight dashes, the first under the crest. It turns by whole dashes. */
178
+ function dashed(p: Pen): string {
179
+ return '<g class="spin">' + RING + ' stroke="' + p.tone + '" stroke-width="' + p.ring + '" stroke-dasharray="9.2 3.76" transform="rotate(-99 28 31)"/></g>'
180
+ }
181
+
182
+ /** Ripples that spread from the ring while it thinks, unseen at rest. */
183
+ function rings(p: Pen): string {
184
+ return ['r1', 'r2'].map(cls => '<circle class="' + cls + '" cx="28" cy="31" r="19" fill="none" stroke="' + p.tone + '" stroke-width="' + (p.mini ? 2.6 : 1.6) + '" opacity="0"/>').join('')
185
+ }
186
+
187
+ /** A thought's bubbles rising to the upper right, small to large. */
188
+ function bubbles(p: Pen): string {
189
+ const w = p.mini ? 2.4 : 1.5
190
+ return [[43.6, 14.2, 1.3], [47.6, 9.2, 1.9], [51.6, 3.9, 2.6]]
191
+ .map(([x, y, r], i) => '<circle class="bub bub' + (i + 1) + '" cx="' + x + '" cy="' + y + '" r="' + r + '" fill="none" stroke="' + p.tone + '" stroke-width="' + w + '"/>')
192
+ .join('')
193
+ }
194
+
195
+ function zs(p: Pen): string {
196
+ if (p.mini) return '<path class="z1" d="M42.6 2.6h6.4l-6.4 6.8h6.4" fill="none" stroke="' + p.muted + '" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"/>'
197
+ return (
198
+ '<path class="z1" d="M44.4 4.6h4l-4 4.4h4" fill="none" stroke="' + p.muted + '" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>' +
199
+ '<path class="z2" d="M50 10.6h2.8l-2.8 3h2.8" fill="none" stroke="' + p.muted + '" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round" opacity=".7"/>'
200
+ )
201
+ }
202
+
203
+ // ---- Faces ---------------------------------------------------------------------------------------
204
+
205
+ type Spec = {
206
+ crest: keyof typeof CREST
207
+ ring: 'solid' | 'gauge' | 'dashed' | 'broken'
208
+ ears: Ears
209
+ /** The eyes, in nested groups of their own so they can blink on several loops. */
210
+ eyes: (p: Pen) => string
211
+ /** Where the eyes' middle is, for their blink. */
212
+ eyesY: number
213
+ /** Brows: the full drawing only. */
214
+ brows?: string
215
+ /** The mouth, in a group of its own: the full drawing only. */
216
+ mouth?: string
217
+ /** The mouth that opens in a yawn, unseen at rest. */
218
+ yawn?: boolean
219
+ /** Muted props (z, sweat, tear), drawn last. */
220
+ prop?: (p: Pen) => string
221
+ /** Crest and ring at this opacity, in one group so their overlap does not darken. */
222
+ faded?: number
223
+ tail?: boolean
224
+ /** Ripples spreading from the ears. */
225
+ waves?: 'both' | 'right'
226
+ /** Under the body: ripples from the ring, the done halo. */
227
+ behind?: (p: Pen) => string
228
+ /** Outside the figure, so a tilt does not carry it: the asking bubbles. */
229
+ outside?: (p: Pen) => string
230
+ }
231
+
232
+ const ink = (d: string, width = 2.2) => stroke(d, v('ink'), width, ' stroke-linejoin="round"')
233
+
234
+ const SPECS: Record<Face, Spec> = {
235
+ calm: { crest: 'up', ring: 'solid', ears: 'on', waves: 'both', eyes: p => capsules(p, 20.6, 26.2, 5.6), eyesY: 29, mouth: ink('M24.8 36.2q3.2 2.6 6.4 0') },
236
+ // Eyes up and to the right, a lopsided "hmm"; ripples spread while it weighs.
237
+ thinking: {
238
+ crest: 'up',
239
+ ring: 'solid',
240
+ ears: 'dim',
241
+ eyes: p => capsules(p, 21.4, 24.4, 5.2),
242
+ eyesY: 27,
243
+ mouth: ink('M26.2 37.6q2.2 .7 4.4 -.5'),
244
+ behind: rings,
245
+ },
246
+ // Focused: eyes a little low, the mouth set, the ring turning.
247
+ working: { crest: 'up', ring: 'dashed', ears: 'dim', waves: 'both', eyes: p => capsules(p, 20.6, 27, 5), eyesY: 29.5, mouth: ink('M26 37.2h4') },
248
+ // Heavy lids over half eyes; nods off and catches itself, now and then a yawn.
249
+ drowsy: {
250
+ crest: 'up',
251
+ ring: 'solid',
252
+ ears: 'dim',
253
+ faded: 0.75,
254
+ eyes: p => (p.mini ? capsules(p, 20.6, 29, 3) : capsules(p, 20.6, 29, 2.8) + line(p, 'M19.9 28.4h5', 1.8) + line(p, 'M31.1 28.4h5', 1.8)),
255
+ eyesY: 30.2,
256
+ mouth: ink('M26.6 37.2q1.4 1 2.8 0'),
257
+ yawn: true,
258
+ },
259
+ sleepy: {
260
+ crest: 'short',
261
+ ring: 'solid',
262
+ ears: 'none',
263
+ faded: 0.45,
264
+ eyes: p => line(p, 'M20 29q2.4 2.2 4.8 0') + line(p, 'M31.2 29q2.4 2.2 4.8 0'),
265
+ eyesY: 30.1,
266
+ mouth: ink('M26.6 37.2q1.4 1 2.8 0'),
267
+ yawn: true,
268
+ prop: zs,
269
+ },
270
+ uneasy: {
271
+ crest: 'up',
272
+ ring: 'gauge',
273
+ ears: 'dim',
274
+ eyes: p => capsules(p, 20.6, 26.9, 5),
275
+ eyesY: 29.4,
276
+ brows: ink('M19.4 24.4L25 22.4', 2) + ink('M36.6 24.4L31 22.4', 2),
277
+ mouth: ink('M22.9 37.2q1.275 -1.7 2.55 0t2.55 0t2.55 0t2.55 0'),
278
+ prop: p => '<g class="sweat">' + drop(p, 46.6, 13.8, 1) + '</g>',
279
+ },
280
+ strained: {
281
+ crest: 'tall',
282
+ ring: 'gauge',
283
+ ears: 'on',
284
+ eyes: p => capsules(p, 20.6, 27.6, 4.4),
285
+ eyesY: 29.8,
286
+ brows: ink('M19.6 23.6h5.4', 2) + ink('M31 23.6h5.4', 2),
287
+ mouth: ink('M24.9 37.4h6.2'),
288
+ prop: p => '<g class="sweat">' + drop(p, 46.6, 13.8, 1) + '</g>',
289
+ },
290
+ alarmed: {
291
+ crest: 'tall',
292
+ ring: 'gauge',
293
+ ears: 'double',
294
+ eyes: p => (p.mini ? capsules(p, 20.6, 25.2, 7.6) : '<circle cx="22.4" cy="29" r="3.3" fill="' + p.ink + '"/><circle cx="33.6" cy="29" r="3.3" fill="' + p.ink + '"/>'),
295
+ eyesY: 29,
296
+ brows: ink('M18.8 22.4q3.2 -2.2 6.6 -0.8', 2) + ink('M37.2 22.4q-3.2 -2.2 -6.6 -0.8', 2),
297
+ mouth: '<ellipse cx="28" cy="37.8" rx="2.6" ry="3.1" fill="' + v('ink') + '"/>',
298
+ prop: p => '<g class="sweat">' + drop(p, 46.6, 13.8, 1) + '</g><g class="sweat2">' + drop(p, 10.4, 15.2, 0.8) + '</g>',
299
+ },
300
+ dazed: {
301
+ crest: 'short',
302
+ ring: 'broken',
303
+ ears: 'left',
304
+ eyes: p => '<g class="e1">' + line(p, 'M20.2 26.8l4.4 4.4m0 -4.4l-4.4 4.4') + '</g><g class="e2">' + line(p, 'M31.4 26.8l4.4 4.4m0 -4.4l-4.4 4.4') + '</g>',
305
+ eyesY: 29,
306
+ mouth: ink('M23.6 37.8l2.2 -1.8l2.2 1.8l2.2 -1.8l2.2 1.8'),
307
+ },
308
+ asking: {
309
+ crest: 'left',
310
+ ring: 'solid',
311
+ ears: 'dim',
312
+ tail: true,
313
+ eyes: p => capsules(p, 21.4, 25, 5.6),
314
+ eyesY: 27.8,
315
+ brows: ink('M30.4 22.2q2.6 -1.8 5.4 -0.6', 2),
316
+ mouth: '<ellipse cx="28" cy="37.2" rx="1.7" ry="2" fill="' + v('ink') + '"/>',
317
+ outside: bubbles,
318
+ },
319
+ squeezing: {
320
+ crest: 'up',
321
+ ring: 'dashed',
322
+ ears: 'dim',
323
+ eyes: p => line(p, 'M20.4 26.6l3.4 2.4l-3.4 2.4') + line(p, 'M35.6 26.6l-3.4 2.4l3.4 2.4'),
324
+ eyesY: 29,
325
+ mouth: ink('M25.6 37.4h4.8'),
326
+ },
327
+ // Off it goes: the right ear waves, a ripple leaves from it.
328
+ going: { crest: 'wind', ring: 'dashed', ears: 'waving', waves: 'right', eyes: p => capsules(p, 22.2, 26.2, 5.6), eyesY: 29, mouth: ink('M26.6 37.2q1.4 1 2.8 0') },
329
+ pleased: {
330
+ crest: 'up',
331
+ ring: 'solid',
332
+ ears: 'on',
333
+ waves: 'both',
334
+ eyes: p => line(p, 'M20 30q2.4 -3.4 4.8 0') + line(p, 'M31.2 30q2.4 -3.4 4.8 0'),
335
+ eyesY: 28.3,
336
+ mouth: '<path d="M23.8 35.4h8.4a4.2 4.2 0 0 1 -8.4 0z" fill="' + v('ink') + '"/>',
337
+ // The done ripple: unseen at rest, spreads once from the ring.
338
+ behind: p => '<circle class="halo" cx="28" cy="31" r="19" fill="none" stroke="' + p.tone + '" stroke-width="' + (p.mini ? 2.6 : 1.6) + '" opacity="0"/>',
339
+ },
340
+ sorry: {
341
+ crest: 'wilt',
342
+ ring: 'solid',
343
+ ears: 'none',
344
+ eyes: p => capsules(p, 20.6, 27.6, 4.4),
345
+ eyesY: 29.8,
346
+ brows: ink('M19.2 24.8L24.6 22.2', 2) + ink('M36.8 24.8L31.4 22.2', 2),
347
+ mouth: ink('M24.8 38.4q3.2 -2.6 6.4 0'),
348
+ prop: p => '<g class="tear">' + drop(p, 36.4, 33.2, 0.8) + '</g>',
349
+ },
350
+ }
351
+
352
+ function ringOf(p: Pen, s: Spec, percent?: number): string {
353
+ if (s.ring === 'broken') {
354
+ // Broken at 22°…54°; the shard (28°…46°) knocked 6.2 out along 38° and turned 18°.
355
+ return (
356
+ stroke('M37.7 44.35A16.5 16.5 0 1 1 43.3 37.18', p.tone, p.ring) +
357
+ '<g class="shard">' + stroke('M42.57 38.75A16.5 16.5 0 0 1 39.46 42.87', p.tone, p.ring, ' transform="translate(4.89 3.82) rotate(18 41.18 40.93)"') + '</g>'
358
+ )
359
+ }
360
+ if (s.ring === 'dashed') return dashed(p)
361
+ if (s.ring === 'gauge' && percent !== undefined) return gauge(p, percent)
362
+ return RING + ' stroke="' + p.tone + '" stroke-width="' + p.ring + '"/>'
363
+ }
364
+
365
+ /**
366
+ * The face's markup on the 56 grid, in the drawing order of the sheet: crest, tail, ring, ears,
367
+ * eyes, face, props. `percent` draws the ring of uneasy, strained and alarmed as a gauge. The
368
+ * groups are what the motion moves: `.fig` the whole figure (hops, tilts), `.pose` its shape
369
+ * (breath, squash, wobble), `.face` the look, `.b1`…`.b3` the blinks.
370
+ */
371
+ export function rippleBody(face: Face, percent?: number, detail: Detail = 'full'): string {
372
+ const s = SPECS[face]
373
+ const p = penOf(face, detail)
374
+ const crest = '<g class="crest"><g class="crest2"><path d="' + CREST[s.crest] + '" fill="' + p.tone + '"/></g></g>' + (s.tail ? '<path d="' + TAIL + '" fill="' + p.tone + '"/>' : '')
375
+ const ring = ringOf(p, s, percent)
376
+ const body = s.faded ? '<g opacity="' + s.faded + '">' + crest + ring + '</g>' : crest + ring
377
+ const eyes = '<g class="eyes"><g class="b1"><g class="b2"><g class="b3">' + s.eyes(p) + '</g></g></g></g>'
378
+ const mouth = p.mini
379
+ ? ''
380
+ : (s.brows ?? '') + (s.mouth ? '<g class="mouth">' + s.mouth + '</g>' : '') +
381
+ (s.yawn ? '<ellipse class="yawn" cx="28" cy="37.8" rx="2.4" ry="3" fill="' + v('ink') + '" opacity="0"/>' : '')
382
+ return (
383
+ '<g class="fig"><g class="pose">' + (s.behind ? s.behind(p) : '') + body +
384
+ '<g class="ears">' + ears(p, s.ears) + '</g>' + (s.waves ? waves(p, s.waves) : '') +
385
+ '<g class="face">' + eyes + mouth + '</g>' +
386
+ (s.prop ? '<g class="prop">' + s.prop(p) + '</g>' : '') + '</g></g>' +
387
+ (s.outside ? s.outside(p) : '')
388
+ )
389
+ }
390
+
391
+ // ---- Motion --------------------------------------------------------------------------------------
392
+
393
+ /** One animation: what it moves, its keyframes, how long, and whether it waits or plays once. */
394
+ type Loop = { at: string; name: string; frames: string; seconds: number; timing?: 'linear' | 'ease-out'; delay?: number; once?: boolean }
395
+
396
+ const loop = (at: string, name: string, frames: string, seconds: number, more: Omit<Loop, 'at' | 'name' | 'frames' | 'seconds'> = {}): Loop => ({ at, name, frames, seconds, ...more })
397
+
398
+ /**
399
+ * The keyframes, `k` the scale of their travel (larger on the mini, where a unit is a third of a
400
+ * pixel). Each opens on `0%` at the rest pose or with its part hidden, and each but the turns
401
+ * (`spin`, `dizzy`: a dash, or an × a quarter round, draws the same) ends where it began.
402
+ */
403
+ function frames(k: number) {
404
+ const px = (x: number) => n(x * k) + 'px'
405
+ const tx = (x: number) => 'translateX(' + px(x) + ')'
406
+ const ty = (y: number) => 'translateY(' + px(y) + ')'
407
+ return {
408
+ breathe: '0%,100%{transform:none}50%{transform:scale(1.012,1.028)}',
409
+ // Three blinks on loops of 7, 11 and 13 s: they fall at 2.2, 5.9, 7.6, 9.2, 16.2, 18.7…
410
+ blinkA: '0%,100%{transform:none}29%,33%{transform:none}31%{transform:scaleY(.1)}',
411
+ blinkB: '0%,100%{transform:none}68%,70.6%{transform:none}69.3%{transform:scaleY(.1)}',
412
+ blinkC: '0%,100%{transform:none}44%,46.2%{transform:none}45.1%{transform:scaleY(.1)}',
413
+ sway: '0%,100%{transform:none}30%{transform:rotate(-5deg)}70%{transform:rotate(3deg)}',
414
+ look: '0%,100%{transform:none}38%{transform:none}43%,53%{transform:' + tx(-1.6) + '}58%,72%{transform:none}77%,86%{transform:' + tx(1.6) + '}91%{transform:none}',
415
+ // A crouch, a small hop (kept under the top edge), a squash on landing.
416
+ hop: '0%,100%{transform:none}60%{transform:none}62%{transform:scale(1.03,.96)}64.5%{transform:translateY(-2px)}67%{transform:scale(1.03,.97)}69%{transform:none}',
417
+ wave: '0%,100%{opacity:0;transform:none}8%{opacity:.6}60%{opacity:0;transform:scale(1.14)}',
418
+ ripple: '0%,100%{opacity:0;transform:none}10%{opacity:.5}75%{opacity:0;transform:scale(1.34)}',
419
+ twirl: '0%,100%{transform:none}20%{transform:rotate(-14deg)}45%{transform:rotate(6deg)}70%{transform:rotate(-8deg)}',
420
+ ponder: '0%,100%{transform:none}40%,60%{transform:translate(' + px(1) + ',' + px(-0.6) + ')}',
421
+ spin: '0%{transform:none}100%{transform:rotate(45deg)}',
422
+ busy: '0%,100%{transform:none}50%{transform:' + ty(-0.8) + '}',
423
+ nod: '0%,100%{transform:none}45%{transform:none}62%{transform:' + ty(1.2) + ' rotate(3deg)}70%{transform:' + ty(1.5) + ' rotate(3.5deg)}74%{transform:' + ty(-0.3) + '}78%{transform:none}',
424
+ scan: '0%,100%{transform:none}20%,40%{transform:' + tx(-1.2) + '}60%,80%{transform:' + tx(1.2) + '}',
425
+ bob: '0%,100%{transform:none}50%{transform:rotate(-6deg)}',
426
+ heavy: '0%,100%{transform:none}18%{transform:none}26%,38%{transform:scaleY(.12)}44%{transform:none}',
427
+ yawn: '0%,100%{opacity:0;transform:scale(.3)}66%{opacity:0;transform:scale(.3)}74%,84%{opacity:1;transform:none}90%{opacity:0;transform:scale(.3)}',
428
+ gape: '0%,100%{opacity:1}66%{opacity:1}70%,86%{opacity:0}90%{opacity:1}',
429
+ stretch: '0%,100%{transform:none}66%{transform:none}76%,84%{transform:scale(.98,1.04)}90%{transform:none}',
430
+ snore: '0%,100%{transform:none}50%{transform:scale(1.02,1.04)}',
431
+ zrise: '0%,100%{opacity:1;transform:none}70%{opacity:0;transform:translate(' + px(2) + ',' + px(-3) + ')}71%{opacity:0;transform:none}',
432
+ fret: '0%,100%{transform:none}30%{transform:' + tx(-0.8) + '}70%{transform:' + tx(0.8) + '}',
433
+ drip: '0%,100%{opacity:1;transform:none}55%{opacity:0;transform:' + ty(3) + '}56%{opacity:0;transform:none}',
434
+ quiver: '0%,100%{transform:none}15%{transform:rotate(3deg)}30%{transform:rotate(-3deg)}45%{transform:none}',
435
+ pulse: '0%,100%{opacity:1}50%{opacity:.3}',
436
+ jolt: '0%,100%{transform:none}10%{transform:' + ty(-1) + '}22%{transform:none}',
437
+ dizzy: '0%{transform:none}100%{transform:rotate(360deg)}',
438
+ orbit: '0%,100%{transform:none}25%{transform:translate(' + px(0.8) + ',' + px(-0.4) + ') rotate(2deg)}50%{transform:' + ty(-0.7) + '}75%{transform:translate(' + px(-0.8) + ',' + px(-0.4) + ') rotate(-2deg)}',
439
+ shard: '0%,100%{transform:none}50%{transform:translate(' + px(1.2) + ',' + px(1.6) + ') rotate(10deg)}',
440
+ tilt: '0%,100%{transform:none}14%,70%{transform:rotate(-7deg)}86%{transform:none}',
441
+ pop: '0%,100%{opacity:0;transform:scale(.2)}20%{opacity:1;transform:scale(1.15)}26%,72%{opacity:1;transform:none}84%{opacity:0;transform:none}',
442
+ squash: '0%,100%{transform:none}30%{transform:scale(1.08,.9)}62%{transform:scale(.95,1.06)}82%{transform:none}',
443
+ squeeze: '0%,100%{transform:none}30%{transform:scale(.93)}62%{transform:scale(1.04)}',
444
+ wag: '0%,100%{transform:none}12%{transform:rotate(-28deg)}26%{transform:rotate(10deg)}40%{transform:rotate(-24deg)}54%{transform:none}',
445
+ leave: '0%,100%{opacity:0;transform:none}10%{opacity:.7}55%{opacity:0;transform:' + tx(6) + '}',
446
+ flutter: '0%,100%{transform:none}50%{transform:rotate(-4deg)}',
447
+ lean: '0%,100%{transform:none}50%{transform:' + tx(0.8) + '}',
448
+ // Done: a crouch, a jump (under the top edge), a squash, a little rebound, still.
449
+ bounce: '0%{transform:none}16%{transform:scale(1.05,.93)}40%{transform:translateY(-2.2px)}62%{transform:scale(1.04,.96)}80%{transform:translateY(-.8px)}100%{transform:none}',
450
+ halo: '0%,100%{opacity:0;transform:none}12%{opacity:.55}90%{opacity:0;transform:scale(1.42)}',
451
+ sink: '0%,100%{transform:none}50%{transform:' + ty(1) + '}',
452
+ droop: '0%,100%{transform:none}50%{transform:rotate(5deg)}',
453
+ tear: '0%,100%{opacity:1;transform:none}55%{opacity:0;transform:' + ty(2.6) + '}56%{opacity:0;transform:none}',
454
+ }
455
+ }
456
+
457
+ type Frames = ReturnType<typeof frames>
458
+
459
+ /** The idle life: breath, three blinks, the crest's sway, a look either way, a hop, the ears' ripples. */
460
+ function idle(f: Frames, delay = 0, blink = true): Loop[] {
461
+ const d = delay ? { delay } : {}
462
+ return [
463
+ loop('.pose', 'breathe', f.breathe, 4.2, d),
464
+ loop('.crest', 'sway', f.sway, 5.4, d),
465
+ loop('.face', 'look', f.look, 13, d),
466
+ loop('.fig', 'hop', f.hop, 17, d),
467
+ loop('.wL,.wR', 'wave', f.wave, 3.6, { delay: delay + 0.6 }),
468
+ ...(blink ? [loop('.b1', 'blinkA', f.blinkA, 7, d), loop('.b2', 'blinkB', f.blinkB, 11, d), loop('.b3', 'blinkC', f.blinkC, 13, d)] : []),
469
+ ]
470
+ }
471
+
472
+ /** How hard a gauge face strains at `percent`: its wobble's swing (deg) and beat (s). */
473
+ function strain(face: Face, percent: number): { swing: number; beat: number } {
474
+ const x = gaugeStep(percent) / 100
475
+ if (face === 'alarmed') return { swing: 3 + 2 * x, beat: 1.4 - 0.4 * x }
476
+ if (face === 'strained') return { swing: 2 + 3 * x, beat: 2.6 - 1.2 * x }
477
+ return { swing: 1.5 + 3 * x, beat: 3.2 - 1.4 * x }
478
+ }
479
+
480
+ /** The value a gauge face strains at when it is drawn without one. */
481
+ const DEFAULT_PERCENT: Partial<Record<Face, number>> = { uneasy: 60, strained: 85, alarmed: 97 }
482
+
483
+ function motionOf(face: Face, percent: number | undefined, detail: Detail): Loop[] {
484
+ const f = frames(detail === 'mini' ? 1.5 : 1)
485
+ const full = detail === 'full'
486
+ switch (face) {
487
+ case 'calm':
488
+ return idle(f)
489
+ case 'thinking':
490
+ return [
491
+ loop('.pose', 'breathe', f.breathe, 4.2),
492
+ loop('.crest', 'twirl', f.twirl, 2.6),
493
+ loop('.face', 'ponder', f.ponder, 5.2),
494
+ loop('.r1', 'ripple', f.ripple, 2.6),
495
+ loop('.r2', 'ripple', f.ripple, 2.6, { delay: 1.3 }),
496
+ loop('.b1', 'blinkA', f.blinkA, 7),
497
+ loop('.b2', 'blinkB', f.blinkB, 11),
498
+ ]
499
+ case 'working':
500
+ return [
501
+ loop('.spin', 'spin', f.spin, 1.2, { timing: 'linear' }),
502
+ loop('.fig', 'busy', f.busy, 0.8),
503
+ loop('.crest', 'bob', f.bob, 0.8),
504
+ loop('.face', 'scan', f.scan, 2.4),
505
+ loop('.wL,.wR', 'wave', f.wave, 1.6),
506
+ loop('.b1', 'blinkA', f.blinkA, 7),
507
+ ]
508
+ case 'drowsy':
509
+ return [
510
+ loop('.fig', 'nod', f.nod, 6),
511
+ loop('.b1', 'heavy', f.heavy, 7),
512
+ loop('.crest', 'sway', f.sway, 6.4),
513
+ ...(full ? [loop('.pose', 'stretch', f.stretch, 9), loop('.yawn', 'yawn', f.yawn, 9), loop('.mouth', 'gape', f.gape, 9)] : []),
514
+ ]
515
+ case 'sleepy':
516
+ return [
517
+ loop('.pose', 'snore', f.snore, 4.6),
518
+ loop('.z1', 'zrise', f.zrise, 3.6),
519
+ ...(full ? [loop('.z2', 'zrise', f.zrise, 3.6, { delay: 1.2 }), loop('.fig', 'stretch', f.stretch, 11), loop('.yawn', 'yawn', f.yawn, 11), loop('.mouth', 'gape', f.gape, 11)] : []),
520
+ ]
521
+ case 'uneasy':
522
+ case 'strained':
523
+ case 'alarmed': {
524
+ // The wobble swings wider and beats faster as the gauge fills; so does the sweat.
525
+ const { swing, beat } = strain(face, percent ?? DEFAULT_PERCENT[face]!)
526
+ const wob = '0%,100%{transform:none}25%{transform:rotate(' + n(swing) + 'deg)}75%{transform:rotate(' + n(-swing) + 'deg)}'
527
+ const b = Number(n(beat))
528
+ const sweat = Number(n(beat * 1.3))
529
+ if (face === 'uneasy') {
530
+ return [loop('.pose', 'wob', wob, b), loop('.face', 'fret', f.fret, 3.2), loop('.sweat', 'drip', f.drip, sweat), loop('.crest', 'sway', f.sway, 2.8), loop('.b1', 'blinkA', f.blinkA, 7), loop('.b2', 'blinkB', f.blinkB, 11)]
531
+ }
532
+ if (face === 'strained') {
533
+ return [loop('.pose', 'wob', wob, b), loop('.crest', 'quiver', f.quiver, b), loop('.ears', 'pulse', f.pulse, b), loop('.sweat', 'drip', f.drip, sweat), loop('.b1', 'blinkA', f.blinkA, 7)]
534
+ }
535
+ return [
536
+ loop('.pose', 'wob', wob, b),
537
+ loop('.crest', 'quiver', f.quiver, b),
538
+ loop('.ears', 'pulse', f.pulse, b),
539
+ loop('.face', 'jolt', f.jolt, b),
540
+ loop('.sweat', 'drip', f.drip, sweat),
541
+ loop('.sweat2', 'drip', f.drip, sweat, { delay: Number(n(sweat / 2)) }),
542
+ ]
543
+ }
544
+ case 'dazed':
545
+ return [
546
+ loop('.e1', 'dizzy', f.dizzy, 1.8, { timing: 'linear' }),
547
+ loop('.e2', 'dizzy', f.dizzy, 1.8, { timing: 'linear' }),
548
+ loop('.fig', 'orbit', f.orbit, 2.4),
549
+ loop('.shard', 'shard', f.shard, 2.6),
550
+ loop('.crest', 'sway', f.sway, 3),
551
+ ]
552
+ case 'asking':
553
+ return [
554
+ loop('.fig', 'tilt', f.tilt, 4.4),
555
+ loop('.bub1', 'pop', f.pop, 4.4),
556
+ loop('.bub2', 'pop', f.pop, 4.4, { delay: 0.25 }),
557
+ loop('.bub3', 'pop', f.pop, 4.4, { delay: 0.5 }),
558
+ loop('.b1', 'blinkA', f.blinkA, 7),
559
+ loop('.b2', 'blinkB', f.blinkB, 11),
560
+ ]
561
+ case 'squeezing':
562
+ return [loop('.pose', 'squash', f.squash, 1.4), loop('.spin', 'spin', f.spin, 1.4, { timing: 'linear' }), loop('.ears', 'squeeze', f.squeeze, 1.4)]
563
+ case 'going':
564
+ return [
565
+ loop('.earR', 'wag', f.wag, 2.4),
566
+ loop('.wR', 'leave', f.leave, 2.4, { delay: 0.3 }),
567
+ loop('.spin', 'spin', f.spin, 1.6, { timing: 'linear' }),
568
+ loop('.crest', 'flutter', f.flutter, 0.8),
569
+ loop('.fig', 'lean', f.lean, 2.4),
570
+ ]
571
+ case 'pleased':
572
+ // A happy bounce and its ripple, once; then the idle life, its eyes too glad to blink.
573
+ return [loop('.fig', 'bounce', f.bounce, 1.2, { timing: 'ease-out', once: true }), loop('.halo', 'halo', f.halo, 1.4, { timing: 'ease-out', once: true }), ...idle(f, 1.2, false)]
574
+ case 'sorry':
575
+ return [loop('.fig', 'sink', f.sink, 4.2), loop('.crest', 'droop', f.droop, 4.2), loop('.tear', 'tear', f.tear, 3.6), loop('.b2', 'blinkB', f.blinkB, 11)]
576
+ }
577
+ }
578
+
579
+ // Where each part turns: the ring's foot (the whole figure), its centre, the crest's base, the ears'
580
+ // roots, the middle of the eyes; the bubbles and the yawn about themselves.
581
+ const ORIGINS =
582
+ 'g,circle,path,rect,ellipse{transform-box:view-box}' +
583
+ '.fig,.pose{transform-origin:28px 49.5px}' +
584
+ '.spin,.halo,.face,.ears,.wL,.wR,.r1,.r2{transform-origin:28px 31px}' +
585
+ '.crest,.crest2{transform-origin:30.5px 13px}' +
586
+ '.earR{transform-origin:45px 31px}' +
587
+ '.e1{transform-origin:22.4px 29px}.e2{transform-origin:33.6px 29px}' +
588
+ '.shard{transform-origin:46px 44px}' +
589
+ '.yawn,.mouth{transform-origin:28px 37.6px}' +
590
+ '.bub{transform-box:fill-box;transform-origin:center}'
591
+
592
+ /**
593
+ * The face's motion, for svgDoc's `css`; all of it inside a no-preference reduced-motion query, so
594
+ * with less motion asked for the drawing is its rest pose. `percent` is the gauge's, which sets how
595
+ * hard uneasy, strained and alarmed strain (in its 5% steps, like the ring).
596
+ */
597
+ export function rippleCss(face: Face, percent?: number, detail: Detail = 'full'): string {
598
+ const moves = motionOf(face, percent, detail)
599
+ const keyframes = new Map<string, string>()
600
+ for (const move of moves) keyframes.set(move.name, move.frames)
601
+ // One rule per element, its animations in one list: two rules on one element would drop one.
602
+ const by = new Map<string, string[]>()
603
+ for (const move of moves) {
604
+ const one = move.name + ' ' + n(move.seconds) + 's ' + (move.timing ?? 'ease-in-out') + (move.delay ? ' ' + n(move.delay) + 's' : '') + (move.once ? ' 1' : ' infinite')
605
+ by.set(move.at, [...(by.get(move.at) ?? []), one])
606
+ }
607
+ const eyes = '.eyes,.b1,.b2,.b3{transform-origin:28px ' + SPECS[face].eyesY + 'px}'
608
+ return (
609
+ ORIGINS + eyes +
610
+ '@media (prefers-reduced-motion:no-preference){' +
611
+ [...keyframes].map(([name, body]) => '@keyframes ' + name + '{' + body + '}').join('') +
612
+ [...by].map(([at, list]) => at + '{animation:' + list.join(',') + '}').join('') + '}'
613
+ )
614
+ }