diff --git a/public/registry/care-ui/care-filly-classic/care-filly-classic.json b/public/registry/care-ui/care-filly-classic/care-filly-classic.json new file mode 100644 index 0000000..81190bd --- /dev/null +++ b/public/registry/care-ui/care-filly-classic/care-filly-classic.json @@ -0,0 +1,13 @@ +{ + "name": "care-filly-classic", + "type": "registry:ui", + "registryDependencies": [], + "files": [ + { + "path": "registry/care-ui/care-filly-classic/care-filly-classic.tsx", + "content": "/**\n * @name animated-character\n * @description Spring-driven animated AI character with state presets, gaze, blinking, talking and head movement\n * @type registry:ui\n */\nimport * as React from \"react\";\n\nimport { cn } from \"@/lib/utils\";\n\n/* ------------------------------------------------------------------ */\n/* Animation engine */\n/* Architecture (adapted from the GrokBot prototype): */\n/* STATE -> TIMERS/EVENTS -> TARGET VALUES -> SPRINGS -> SVG FRAME */\n/* Every frame is computed absolutely from base geometry: no transform */\n/* accumulation, no drift. Runs on requestAnimationFrame with a fixed */\n/* 120 Hz spring timestep and delta clamping, outside the React render */\n/* cycle (direct SVG attribute updates). */\n/* ------------------------------------------------------------------ */\nconst clamp = (v: number, a: number, b: number) => (v < a ? a : v > b ? b : v);\nconst rand = (a: number, b: number) => a + Math.random() * (b - a);\nconst smooth = (t: number) => (t <= 0 ? 0 : t >= 1 ? 1 : t * t * (3 - 2 * t));\nconst wpick = (pairs: ReadonlyArray) => {\n let total = 0;\n for (const pair of pairs) total += pair[0];\n\n let r = Math.random() * total;\n for (const pair of pairs) {\n r -= pair[0];\n if (r <= 0) return pair[1];\n }\n\n return pairs[pairs.length - 1][1];\n};\n\nclass Spring {\n x: number;\n v: number;\n t: number;\n w: number;\n z: number;\n\n constructor(v: number, w: number, z = 1) {\n this.x = v;\n this.v = 0;\n this.t = v;\n this.w = w;\n this.z = z;\n }\n\n step(h: number) {\n const a =\n -2 * this.z * this.w * this.v - this.w * this.w * (this.x - this.t);\n this.v += a * h;\n this.x += this.v * h;\n }\n set(t: number) {\n this.t = t;\n }\n}\n\ntype ExpressionName =\n | \"neutral\"\n | \"smallSmile\"\n | \"smile\"\n | \"bigSmile\"\n | \"sad\"\n | \"worried\"\n | \"surprised\"\n | \"thinking\"\n | \"confused\";\n\ntype CharacterStateName =\n | \"idle\"\n | \"listening\"\n | \"talking\"\n | \"writing\"\n | \"thinking\"\n | \"loading\"\n | \"happy\"\n | \"sad\"\n | \"surprised\"\n | \"confused\"\n | \"excited\"\n | \"sleepy\";\nexport type CareFillyClassicVariant = \"light\" | \"dark\";\ntype DirectionName = keyof typeof DIRS;\n\ntype ExpressionConfig = {\n open?: number;\n wide?: number;\n cy?: number;\n cx?: number;\n cs?: number;\n my?: number;\n mx?: number;\n asym?: number;\n eye?: number;\n lid?: number;\n};\n\ntype SequenceStep = {\n do?: () => void;\n wait?: number | [number, number];\n};\n\ntype Timer = {\n at: number;\n fn: () => void;\n};\n\ntype BlinkState = {\n active: boolean;\n t: number;\n dur: number;\n hold: number;\n min: number;\n queue: number;\n next: number;\n};\n\ntype RollState = {\n t0: number;\n dur: number;\n bx: number;\n by: number;\n};\n\ntype HeadBase = {\n x: number;\n y: number;\n rot: number;\n};\n\ntype CharacterStateConfig = {\n expr: ExpressionName;\n gaze: [number, number];\n blink: [number, number];\n micro: { amp: number; speed: number };\n gap?: [number, number];\n events?: ReadonlyArray;\n head?: Partial;\n lid?: number;\n eye?: number;\n blinkDur?: number;\n talk?: boolean;\n loop?: \"loadingLoop\" | \"confusedLoop\";\n enter?: (engine: CharacterEngine) => void;\n};\n\ntype StatusPayload = {\n state: string | null;\n expr: ExpressionName;\n gazet: string;\n gazep: string;\n vel: string;\n blink: string;\n head: string;\n mouth: string;\n};\n\n/* Base geometry of the supplied artwork (viewBox 0 0 119.91 119.91) */\nconst GEOMETRY = {\n light: {\n CX: 59.955,\n CY: 61, // head pivot\n RX: 4.6,\n RY: 3.1, // gaze range in SVG units\n ELX: 40.73,\n ELY: 49.09, // eye-left center\n ERX: 79.18,\n ERY: 49.09, // eye-right center\n MBX: 59.95,\n MBY: 82.53, // mouth bar center\n MLX: 40.73,\n MLY: 74.48, // mouth-left corner center\n MRX: 79.18,\n MRY: 74.48, // mouth-right corner center\n EW: 8,\n MBY_TOP: 78.5,\n MBW: 30.4,\n MBH: 8.06,\n },\n dark: {\n CX: 60,\n CY: 61,\n RX: 4.6,\n RY: 3.1,\n ELX: 40.77,\n ELY: 49.13,\n ERX: 79.23,\n ERY: 49.13,\n MBX: 60,\n MBY: 82.58,\n MLX: 40.77,\n MLY: 74.52,\n MRX: 79.23,\n MRY: 74.52,\n EW: 8,\n MBY_TOP: 78.55,\n MBW: 30.4,\n MBH: 8.06,\n },\n} as const;\n\n/* Mouth expressions = target values only; springs do the morphing.\n cy > 0 moves corners DOWN (base pose has raised corners = smile). */\nconst EXPR: Record = {\n neutral: { open: 1, wide: 1, cy: 0, cx: 0, cs: 1 },\n smallSmile: { open: 1.15, wide: 1, cy: 0, cx: 0, cs: 1 },\n smile: { open: 1.35, wide: 1, cy: 0, cx: 0, cs: 1 },\n bigSmile: { open: 1.7, wide: 1, cy: 0, cx: 0, cs: 1 },\n sad: { open: 1, wide: 1, cy: 16.11, cx: 0, cs: 1, my: -5.5 },\n worried: { open: 0.42, wide: 0.66, cy: 4.6, cx: -1, cs: 0, my: 0.4 },\n surprised: {\n open: 2,\n wide: 0.52,\n cy: 2.2,\n cx: -2.2,\n cs: 0,\n my: -3.2,\n eye: 1.28,\n },\n thinking: {\n open: 0.45,\n wide: 0.55,\n cy: 1.5,\n cx: -1.6,\n cs: 0,\n mx: 2.4,\n lid: 0.95,\n },\n confused: { open: 0.6, wide: 0.72, cy: 1, asym: 2.6, cs: 0, mx: 1.6 },\n};\n\nconst TALK_SHAPES = [\n { o: 1.5, w: 0.85 },\n { o: 0.5, w: 0.95 },\n { o: 1.25, w: 0.65 },\n { o: 1.9, w: 0.72 },\n { o: 0.75, w: 1.02 },\n { o: 1.05, w: 0.9 },\n { o: 0.32, w: 0.9 },\n];\n\nconst DIRS = {\n center: [0, 0],\n left: [-0.85, 0],\n right: [0.85, 0],\n up: [0, -0.85],\n down: [0, 0.85],\n \"upper-left\": [-0.7, -0.7],\n \"upper-right\": [0.7, -0.7],\n \"lower-left\": [-0.7, 0.7],\n \"lower-right\": [0.7, 0.7],\n};\n\nconst WRITING_PATH_D =\n \"M7.73145 285.912C143.731 137.912 134.731 -38.0885 60.7314 22.9118C-21.4532 90.6589 46.7314 241.912 112.731 273.912C178.731 305.912 218.731 155.912 176.731 177.912C134.731 199.912 166.731 299.912 216.731 273.912C266.731 247.912 231.731 141.912 545.731 199.912C796.931 246.312 796.065 181.912 772.731 143.912\";\n\ntype WritingStrokeSpec = {\n d: string;\n baseWidth: number;\n baseHeight: number;\n stroke: number;\n segmentRatio: number;\n speedRatio: number;\n easeAmount: number;\n easeHz: number;\n};\n\nconst WRITING_STROKE: WritingStrokeSpec = {\n d: WRITING_PATH_D,\n baseWidth: 793,\n baseHeight: 294,\n stroke: 44,\n segmentRatio: 0.24,\n speedRatio: 0.64,\n easeAmount: 0.35,\n easeHz: 1.2,\n};\n\nfunction writingStrokeScale(\n geom: (typeof GEOMETRY)[CareFillyClassicVariant],\n spec: WritingStrokeSpec\n) {\n const targetWidth = geom.EW * 4.6;\n return targetWidth / spec.baseWidth;\n}\n\nfunction writingStrokeTransform(\n geom: (typeof GEOMETRY)[CareFillyClassicVariant],\n spec: WritingStrokeSpec\n) {\n const s = writingStrokeScale(geom, spec);\n const tx = geom.MBX - (spec.baseWidth / 2) * s;\n const ty = geom.MBY - (spec.baseHeight / 2) * s;\n return `translate(${tx.toFixed(3)} ${ty.toFixed(3)}) scale(${s.toFixed(5)})`;\n}\n\n/* Each state: base pose + blink cadence + weighted event pool (or scripted loop). */\nconst STATES: Record = {\n idle: {\n expr: \"neutral\",\n gaze: [0, 0],\n blink: [2.2, 6.5],\n micro: { amp: 0.5, speed: 1 },\n gap: [1.6, 4.2],\n events: [\n [5, \"gazeShift\"],\n [3, \"glance\"],\n [2, \"headDrift\"],\n [1, \"doubleBlink\"],\n [1, \"microSmile\"],\n [0.4, \"eyeRoll\"],\n ],\n },\n listening: {\n expr: \"smallSmile\",\n gaze: [0, 0.12],\n head: { rot: -1.2 },\n blink: [2, 5.5],\n micro: { amp: 0.6, speed: 1 },\n gap: [1.5, 2.8],\n events: [\n [1.9, \"gazeShift\"],\n [1, \"listeningMouthShift\"],\n [3, \"listeningSideRotateNod\"],\n [0.55, \"headDrift\"],\n [0.6, \"doubleBlink\"],\n ],\n },\n talking: {\n expr: \"neutral\",\n talk: true,\n gaze: [0, 0],\n blink: [2.5, 6],\n micro: { amp: 0.8, speed: 1.25 },\n gap: [1.5, 3.5],\n events: [\n [3, \"gazeShift\"],\n [1, \"headDrift\"],\n ],\n },\n writing: {\n expr: \"neutral\",\n gaze: [0, 0],\n head: { rot: 0, y: 0, x: 0 },\n lid: 1,\n eye: 1,\n blink: [9, 12],\n blinkDur: 0.35,\n micro: { amp: 0, speed: 1 },\n },\n thinking: {\n expr: \"thinking\",\n gaze: [-0.45, -0.55],\n head: { rot: -5, x: -1 },\n lid: 0.95,\n blink: [3, 7],\n blinkDur: 0.55,\n micro: { amp: 0.4, speed: 0.7 },\n gap: [2.5, 5],\n events: [\n [2, \"switchSide\"],\n [1, \"gazeShift\"],\n [1, \"slowBlink\"],\n ],\n },\n loading: {\n expr: \"neutral\",\n gaze: [0, 0],\n lid: 0.97,\n blink: [3, 6],\n micro: { amp: 0.45, speed: 0.9 },\n loop: \"loadingLoop\",\n },\n happy: {\n expr: \"smile\",\n gaze: [0, -0.05],\n head: { y: -1.2 },\n blink: [2.5, 6],\n micro: { amp: 0.7, speed: 1.2 },\n gap: [1.8, 4],\n events: [\n [2, \"smilePulse\"],\n [2, \"gazeShift\"],\n [1, \"headDrift\"],\n [1, \"doubleBlink\"],\n ],\n },\n sad: {\n expr: \"sad\",\n gaze: [0, 0.55],\n head: { rot: 2.5, y: 2.2 },\n lid: 0.8,\n eye: 0.97,\n blink: [3.5, 7.5],\n blinkDur: 0.6,\n micro: { amp: 0.3, speed: 0.55 },\n gap: [3, 6],\n events: [\n [2, \"gazeShiftDown\"],\n [1, \"sigh\"],\n [1, \"slowBlink\"],\n ],\n },\n surprised: {\n expr: \"surprised\",\n gaze: [0, -0.08],\n head: { y: -2 },\n eye: 1.3,\n blink: [4, 8],\n micro: { amp: 0.5, speed: 1.1 },\n gap: [2.5, 5],\n events: [\n [2, \"gazeShift\"],\n [1.2, \"surprisedPulse\"],\n [1, \"doubleBlink\"],\n ],\n enter: (e: CharacterEngine) => e.headPulse({ y: -1.6, rot: -1 }, 0.35),\n },\n confused: {\n expr: \"confused\",\n gaze: [0, 0],\n blink: [2.5, 6],\n micro: { amp: 0.5, speed: 0.9 },\n loop: \"confusedLoop\",\n },\n excited: {\n expr: \"bigSmile\",\n gaze: [0, -0.05],\n head: { y: -0.8 },\n eye: 1.12,\n blink: [2, 5],\n micro: { amp: 1.2, speed: 2.1 },\n gap: [0.9, 2.2],\n events: [\n [3, \"bounce\"],\n [2, \"gazeShift\"],\n [1, \"doubleBlink\"],\n ],\n },\n sleepy: {\n expr: \"neutral\",\n gaze: [0, 0.5],\n head: { rot: 3, y: 1.8 },\n lid: 0.55,\n eye: 0.95,\n blink: [2, 4.5],\n blinkDur: 0.9,\n micro: { amp: 0.5, speed: 0.45 },\n gap: [2.5, 5.5],\n events: [\n [2, \"longClose\"],\n [2, \"swaySlow\"],\n [1, \"gazeShiftDown\"],\n ],\n },\n};\n\nclass CharacterEngine {\n svg: SVGSVGElement;\n head: SVGGraphicsElement;\n face: SVGGraphicsElement;\n eyesG: SVGGraphicsElement;\n mouthG: SVGGraphicsElement;\n eyeL: SVGGraphicsElement;\n eyeR: SVGGraphicsElement;\n bar: SVGGraphicsElement;\n mL: SVGGraphicsElement;\n mR: SVGGraphicsElement;\n nose: SVGGraphicsElement | null;\n shell: SVGGraphicsElement | null;\n wG: SVGGraphicsElement | null;\n wP: SVGPathElement | null;\n wLen: number;\n writingT0: number;\n\n gx: Spring;\n gy: Spring;\n yaw: Spring;\n hx: Spring;\n hy: Spring;\n hr: Spring;\n lid: Spring;\n eyeS: Spring;\n open: Spring;\n wide: Spring;\n cy: Spring;\n cx: Spring;\n cs: Spring;\n asym: Spring;\n mx: Spring;\n my: Spring;\n mAmp: Spring;\n sleepyMorph: Spring;\n loadingMix: Spring;\n springs: Spring[];\n\n time: number;\n acc: number;\n last: number;\n timers: Timer[];\n gen: Record;\n bl: BlinkState;\n talking: boolean;\n talkNext: number;\n roll: RollState | null;\n mouse: boolean;\n debug: boolean;\n microPhase: number;\n microSpeed: number;\n expressionName: ExpressionName;\n stateName: CharacterStateName | null;\n hb: HeadBase;\n _lastStat: number;\n acts: Record void>;\n st: CharacterStateConfig;\n gazeBias: [number, number];\n thinkSide?: number;\n _ow?: [number, number];\n _pm: ((e: PointerEvent) => void) | null;\n guides: SVGGElement | null;\n gL: SVGCircleElement | null;\n gR: SVGCircleElement | null;\n gT: SVGGElement | null;\n geom: (typeof GEOMETRY)[CareFillyClassicVariant];\n onChange?: () => void;\n onStatus?: (payload: StatusPayload) => void;\n _raf: (t: number) => void;\n _rafId: number;\n\n constructor(\n svg: SVGSVGElement,\n opts: { state?: string; variant?: CareFillyClassicVariant } = {}\n ) {\n this.svg = svg;\n const q = (part: string) =>\n svg.querySelector('[data-part=\"' + part + '\"]') as SVGGraphicsElement;\n this.head = q(\"head\");\n this.face = q(\"face\");\n this.eyesG = q(\"eyes\");\n this.mouthG = q(\"mouth-group\");\n this.eyeL = q(\"eye-left\");\n this.eyeR = q(\"eye-right\");\n this.bar = q(\"mouth\");\n this.mL = q(\"mouth-left\");\n this.mR = q(\"mouth-right\");\n this.nose = svg.querySelector(\n '[data-part=\"nose\"]'\n ) as SVGGraphicsElement | null;\n this.shell = svg.querySelector(\n '[data-part=\"shell\"]'\n ) as SVGGraphicsElement | null;\n this.wG = svg.querySelector(\n '[data-part=\"writing-stroke\"]'\n ) as SVGGraphicsElement | null;\n this.wP = svg.querySelector(\n '[data-part=\"writing-line\"]'\n ) as SVGPathElement | null;\n this.wLen = 0;\n this.writingT0 = 0;\n if (this.wP) {\n this.wP.setAttribute(\"d\", WRITING_STROKE.d);\n try {\n this.wLen = this.wP.getTotalLength();\n } catch {\n this.wLen = 0;\n }\n }\n\n const S = (v: number, w: number, z = 1) => new Spring(v, w, z);\n this.gx = S(0, 11);\n this.gy = S(0, 11);\n this.yaw = S(0, 8, 0.95);\n this.hx = S(0, 6.5);\n this.hy = S(0, 6.5, 0.95);\n this.hr = S(0, 7, 0.9);\n this.lid = S(1, 14);\n this.eyeS = S(1, 10, 0.85);\n this.open = S(1, 16, 0.9);\n this.wide = S(1, 14, 0.95);\n this.cy = S(0, 11, 0.85);\n this.cx = S(0, 11);\n this.cs = S(1, 12);\n this.asym = S(0, 11);\n this.mx = S(0, 9);\n this.my = S(0, 11);\n this.mAmp = S(0.5, 3);\n this.sleepyMorph = S(0, 10, 0.9);\n this.loadingMix = S(0, 9, 0.9);\n this.springs = [\n this.gx,\n this.gy,\n this.yaw,\n this.hx,\n this.hy,\n this.hr,\n this.lid,\n this.eyeS,\n this.open,\n this.wide,\n this.cy,\n this.cx,\n this.cs,\n this.asym,\n this.mx,\n this.my,\n this.mAmp,\n this.sleepyMorph,\n this.loadingMix,\n ];\n\n this.time = 0;\n this.acc = 0;\n this.last = performance.now();\n this.timers = [];\n this.gen = {};\n this.bl = {\n active: false,\n t: 0,\n dur: 0.32,\n hold: 0,\n min: 0.05,\n queue: 0,\n next: 2,\n };\n this.talking = false;\n this.talkNext = 0;\n this.roll = null;\n this.mouse = false;\n this.debug = false;\n this.microPhase = 0;\n this.microSpeed = 1;\n this.expressionName = \"neutral\";\n this.stateName = null;\n this.hb = { x: 0, y: 0, rot: 0 };\n this._pm = null;\n this.guides = null;\n this.gL = null;\n this.gR = null;\n this.gT = null;\n this.geom = GEOMETRY[opts.variant || \"light\"];\n this._lastStat = -1;\n\n this.acts = this._buildActions();\n /* convenience look methods: lookLeft(), lookUpperRight(), ... */\n const lookMethodMap = this as unknown as Record void>;\n for (const d of Object.keys(DIRS) as DirectionName[]) {\n const name =\n \"look\" +\n d\n .split(\"-\")\n .map((s) => s[0].toUpperCase() + s.slice(1))\n .join(\"\");\n lookMethodMap[name] = () => this.look(d);\n }\n this.gazeBias = [0, 0];\n this.st = STATES.idle;\n this.setState((opts.state || \"idle\") as CharacterStateName);\n this._raf = (t: number) => this.frame(t);\n this._rafId = requestAnimationFrame(this._raf);\n }\n\n /* ---------- clock ---------- */\n frame(now: number) {\n const dt = clamp((now - this.last) / 1000, 0, 0.1);\n this.last = now;\n this.acc += dt;\n const h = 1 / 120;\n let n = 0;\n while (this.acc >= h && n < 24) {\n this.stepFixed(h);\n this.acc -= h;\n n++;\n }\n this.render();\n this._rafId = requestAnimationFrame(this._raf);\n }\n\n stepFixed(h: number) {\n this.time += h;\n this.microPhase += h * this.microSpeed;\n if (this.timers.length) {\n const due: Timer[] = [];\n const rest: Timer[] = [];\n for (const tm of this.timers) (tm.at <= this.time ? due : rest).push(tm);\n if (due.length) {\n this.timers = rest;\n for (const tm of due) tm.fn();\n }\n }\n /* eye roll: continuous circular target */\n if (this.roll) {\n const p = (this.time - this.roll.t0) / this.roll.dur;\n if (p >= 1) {\n this.gx.set(this.roll.bx);\n this.gy.set(this.roll.by);\n this.gx.w = 11;\n this.gy.w = 11;\n this.roll = null;\n } else {\n const phi = Math.PI * 2 * smooth(p),\n r = 0.85 * Math.sin(Math.PI * p);\n this.gx.set(this.roll.bx + r * Math.sin(phi));\n this.gy.set(this.roll.by - r * Math.cos(phi));\n }\n }\n /* talking: procedural mouth-shape picker, varied timing + pauses */\n if (this.talking && this.time >= this.talkNext) {\n if (Math.random() < 0.13) {\n this.open.set(0.22);\n this.wide.set(0.95);\n this.talkNext = this.time + rand(0.22, 0.5);\n } else {\n const s = TALK_SHAPES[(Math.random() * TALK_SHAPES.length) | 0];\n this.open.set(s.o);\n this.wide.set(s.w);\n this.talkNext = this.time + rand(0.07, 0.19);\n }\n }\n /* blink: independent layer, irregular cadence */\n const b = this.bl;\n if (b.active) {\n b.t += h;\n if (b.t >= b.dur + b.hold) {\n b.active = false;\n this.scheduleBlink();\n if (b.queue > 0) {\n b.queue--;\n this.after(0.13, () => this.startBlink(0.3));\n }\n }\n } else if (this.time >= b.next) {\n this.startBlink(this.st.blinkDur || 0.32);\n }\n for (const s of this.springs) s.step(h);\n }\n\n blinkVal() {\n const b = this.bl;\n if (!b.active) return 1;\n const closeD = b.dur * 0.42,\n openD = b.dur * 0.58;\n let t = b.t;\n if (t < closeD) return 1 - (1 - b.min) * smooth(t / closeD);\n t -= closeD;\n if (t < b.hold) return b.min;\n t -= b.hold;\n if (t < openD) return b.min + (1 - b.min) * smooth(t / openD);\n return 1;\n }\n\n /* ---------- scheduling ---------- */\n after(d: number, fn: () => void) {\n this.timers.push({ at: this.time + d, fn });\n }\n\n cancel(ch: string) {\n this.gen[ch] = (this.gen[ch] || 0) + 1;\n }\n\n seq(ch: string, steps: SequenceStep[], loop = false) {\n const gen = (this.gen[ch] = (this.gen[ch] || 0) + 1);\n const run = (i: number) => {\n if (this.gen[ch] !== gen) return;\n if (i >= steps.length) {\n if (loop) run(0);\n return;\n }\n const st = steps[i];\n if (st.do) st.do();\n const w = Array.isArray(st.wait)\n ? rand(st.wait[0], st.wait[1])\n : st.wait || 0;\n this.after(w, () => run(i + 1));\n };\n run(0);\n }\n loopEvents() {\n if (!this.st.events || !this.st.gap) {\n return;\n }\n\n const gap = this.st.gap;\n const events = this.st.events;\n const gen = (this.gen.ev = (this.gen.ev || 0) + 1);\n const tick = () => {\n if (gen !== this.gen.ev) return;\n const fn = this.acts[wpick(events)];\n if (fn) fn();\n this.after(rand(gap[0], gap[1]), tick);\n };\n this.after(rand(gap[0] * 0.5, gap[1] * 0.7), tick);\n }\n\n _buildActions(): Record void> {\n return {\n gazeShift: () => {\n if (this.mouse) return;\n const b = this.gazeBias;\n this.seq(\"gaze\", [\n {\n do: () =>\n this.gazeTo(b[0] + rand(-0.35, 0.35), b[1] + rand(-0.25, 0.25)),\n wait: [0.8, 2.2],\n },\n {\n do: () => {\n if (Math.random() < 0.7) this.gazeTo(b[0], b[1]);\n },\n },\n ]);\n },\n gazeShiftDown: () => {\n if (this.mouse) return;\n const b = this.gazeBias;\n this.seq(\"gaze\", [\n {\n do: () =>\n this.gazeTo(\n b[0] + rand(-0.25, 0.25),\n clamp(b[1] + rand(0, 0.2), -1, 1)\n ),\n wait: [1, 2.5],\n },\n { do: () => this.gazeTo(b[0], b[1]) },\n ]);\n },\n glance: () => {\n if (this.mouse) return;\n const s = Math.random() < 0.5 ? -1 : 1,\n b = this.gazeBias;\n this.seq(\"gaze\", [\n { do: () => this.gazeTo(0.55 * s, b[1]), wait: [0.6, 1.4] },\n { do: () => this.gazeTo(b[0], b[1]) },\n ]);\n },\n headDrift: () =>\n this.headPulse(\n { rot: rand(-3.5, 3.5), x: rand(-1.4, 1.4), y: rand(-0.8, 0.8) },\n rand(1, 2.2)\n ),\n nodOnce: () => this.nod(),\n doubleBlink: () => this.doubleBlink(),\n slowBlink: () => this.slowBlink(),\n microSmile: () => {\n if (this.expressionName !== \"neutral\" || this.talking) return;\n this.seq(\"mouth\", [\n { do: () => this.setExpression(\"smallSmile\", false), wait: [1.2, 2] },\n { do: () => this.setExpression(\"neutral\", false) },\n ]);\n },\n smilePulse: () =>\n this.seq(\"mouth\", [\n { do: () => this.setExpression(\"bigSmile\", false), wait: [0.9, 1.6] },\n { do: () => this.setExpression(\"smile\", false) },\n ]),\n eyeRoll: () => this.eyeRoll(),\n bounce: () =>\n this.seq(\"head\", [\n { do: () => this.hy.set(this.hb.y - 2), wait: 0.16 },\n { do: () => this.hy.set(this.hb.y) },\n ]),\n sigh: () =>\n this.seq(\"head\", [\n { do: () => this.hy.set(this.hb.y + 1.4), wait: [1, 1.6] },\n { do: () => this.hy.set(this.hb.y) },\n ]),\n surprisedPulse: () => {\n if (this.stateName !== \"surprised\" || this.talking) return;\n const e = EXPR.surprised;\n const baseOpen = e.open ?? 1;\n const baseWide = e.wide ?? 1;\n const baseMy = e.my ?? 0;\n const baseEye = (this.st?.eye ?? 1) * (e.eye ?? 1);\n\n if (Math.random() < 0.2) {\n this.after(rand(0.04, 0.12), () => this.startBlink(rand(0.24, 0.32)));\n }\n\n this.seq(\"surprisePulse\", [\n {\n do: () => {\n // Single subtle wow pulse: mouth and eyes pop slightly with a tiny head cue.\n this.open.set(baseOpen * rand(1.08, 1.16));\n this.wide.set(baseWide * rand(0.91, 0.97));\n this.my.set(baseMy + rand(-0.5, -0.2));\n this.eyeS.set(baseEye * rand(1.03, 1.08));\n this.hy.set(this.hb.y + rand(0.28, 0.52));\n this.hr.set(this.hb.rot + rand(0.25, 0.7));\n },\n wait: [0.16, 0.24],\n },\n {\n do: () => {\n this.open.set(baseOpen);\n this.wide.set(baseWide);\n this.my.set(baseMy);\n this.eyeS.set(baseEye);\n this.hy.set(this.hb.y);\n this.hr.set(this.hb.rot);\n },\n wait: [0.24, 0.36],\n },\n ]);\n },\n longClose: () => this.startBlink(1.0, rand(0.2, 0.5), 0.04),\n swaySlow: () => this.headPulse({ rot: rand(-2.5, 2.5) }, rand(1.5, 2.8)),\n listeningSideRotateNod: () => {\n if (this.stateName !== \"listening\") return;\n const dir = Math.random() < 0.5 ? -1 : 1;\n const a1 = rand(3.4, 4.5);\n const a2 = a1 * rand(0.68, 0.8);\n const y1 = rand(0.28, 0.4);\n const y2 = y1 * rand(0.7, 0.82);\n this.seq(\"head\", [\n {\n do: () => {\n this.hy.set(this.hb.y);\n this.hx.set(this.hb.x);\n this.hr.set(this.hb.rot + rand(-0.16, 0.16));\n this.yaw.set(0);\n },\n wait: [0.07, 0.11],\n },\n {\n do: () => {\n this.hy.set(this.hb.y);\n this.hx.set(this.hb.x);\n this.hr.set(this.hb.rot + dir * a1);\n this.yaw.set(dir * y1);\n },\n wait: [0.14, 0.2],\n },\n {\n do: () => {\n this.hy.set(this.hb.y);\n this.hx.set(this.hb.x);\n this.hr.set(this.hb.rot + dir * rand(0.12, 0.26));\n this.yaw.set(dir * rand(0.02, 0.07));\n },\n wait: [0.1, 0.15],\n },\n {\n do: () => {\n this.hy.set(this.hb.y);\n this.hx.set(this.hb.x);\n this.hr.set(this.hb.rot - dir * a1);\n this.yaw.set(-dir * y1);\n },\n wait: [0.14, 0.2],\n },\n {\n do: () => {\n this.hy.set(this.hb.y);\n this.hx.set(this.hb.x);\n this.hr.set(this.hb.rot);\n this.yaw.set(0);\n },\n wait: [0.11, 0.16],\n },\n {\n do: () => {\n this.hy.set(this.hb.y);\n this.hx.set(this.hb.x);\n this.hr.set(this.hb.rot + dir * a2);\n this.yaw.set(dir * y2);\n },\n wait: [0.12, 0.18],\n },\n {\n do: () => {\n this.hy.set(this.hb.y);\n this.hx.set(this.hb.x);\n this.hr.set(this.hb.rot - dir * a2);\n this.yaw.set(-dir * y2);\n },\n wait: [0.12, 0.18],\n },\n {\n do: () => {\n this.hy.set(this.hb.y);\n this.hx.set(this.hb.x);\n this.hr.set(this.hb.rot);\n this.yaw.set(0);\n },\n wait: [0.12, 0.18],\n },\n ]);\n },\n switchSide: () => {\n this.thinkSide = -(this.thinkSide || 1);\n const s = this.thinkSide;\n if (!this.mouse) this.gazeTo(0.45 * s, this.gazeBias[1]);\n this.hb.rot = 5 * s;\n this.hr.set(this.hb.rot);\n },\n listeningMouthShift: () => {\n if (this.talking) return;\n\n const nextExpr = wpick([\n [5, \"smallSmile\"],\n [3, \"neutral\"],\n [1, \"worried\"],\n ]) as ExpressionName;\n\n this.seq(\"mouth\", [\n {\n do: () => {\n this.setExpression(nextExpr, false);\n this.open.set(rand(0.94, 1.14));\n this.wide.set(rand(0.93, 1.05));\n this.cy.set(rand(-0.1, 0.35));\n },\n wait: [1, 1.9],\n },\n { do: () => this.setExpression(\"smallSmile\", false) },\n ]);\n },\n };\n }\n\n /* ---------- scripted state loops (randomized every pass: no GIF feel) ---------- */\n loadingLoop() {\n this.seq(\n \"loop\",\n [\n {\n do: () => this.gazeTo(-0.55 + rand(-0.08, 0.08), rand(-0.1, 0.05)),\n wait: [0.5, 0.9],\n },\n { do: () => this.gazeTo(0, 0), wait: [0.25, 0.5] },\n { do: () => this.startBlink(0.3), wait: [0.35, 0.6] },\n {\n do: () => this.gazeTo(0.55 + rand(-0.08, 0.08), rand(-0.1, 0.05)),\n wait: [0.5, 0.9],\n },\n { do: () => this.gazeTo(0, 0), wait: [0.25, 0.45] },\n {\n do: () =>\n this.headPulse(\n {\n rot: rand(1.5, 3) * (Math.random() < 0.5 ? -1 : 1),\n y: rand(0.5, 1.2),\n },\n rand(0.5, 0.8)\n ),\n wait: [0.7, 1.2],\n },\n ],\n true\n );\n }\n confusedLoop() {\n this.seq(\n \"loop\",\n [\n {\n do: () => {\n this.gazeTo(-0.6, -0.1);\n this.hb.rot = -5;\n this.hr.set(-5);\n this.yaw.set(-0.25);\n },\n wait: [0.9, 1.5],\n },\n {\n do: () => {\n this.gazeTo(0.6, -0.1);\n this.hb.rot = 5;\n this.hr.set(5);\n this.yaw.set(0.25);\n },\n wait: [0.9, 1.5],\n },\n {\n do: () => {\n this.gazeTo(0.1, 0);\n this.hb.rot = rand(-7, 7);\n this.hr.set(this.hb.rot);\n this.yaw.set(0);\n if (Math.random() < 0.5) this.startBlink(0.3);\n },\n wait: [0.8, 1.4],\n },\n ],\n true\n );\n }\n\n /* ---------- state manager ---------- */\n setState(name: CharacterStateName) {\n const st = STATES[name];\n if (!st) return;\n const wasWriting = this.stateName === \"writing\";\n this.stateName = name;\n this.st = st;\n this.cancel(\"ev\");\n this.cancel(\"loop\");\n this.cancel(\"gaze\");\n this.cancel(\"head\");\n this.cancel(\"mouth\");\n this.cancel(\"surprisePulse\");\n this.timers = [];\n this.bl.queue = 0;\n this.thinkSide = undefined;\n this.roll = null;\n this.hb = {\n x: st.head?.x || 0,\n y: st.head?.y || 0,\n rot: st.head?.rot || 0,\n };\n this.hx.set(this.hb.x);\n this.hy.set(this.hb.y);\n this.hr.set(this.hb.rot);\n this.yaw.set(0);\n this.sleepyMorph.set(name === \"sleepy\" ? 1 : 0);\n this.loadingMix.set(name === \"loading\" ? 1 : 0);\n if (name === \"writing\" && !wasWriting) this.writingT0 = this.time;\n this.gazeBias = st.gaze || [0, 0];\n if (!this.mouse) this.gazeTo(this.gazeBias[0], this.gazeBias[1]);\n this.setExpression(st.expr || \"neutral\", false);\n if (st.talk && !this.talking) this.startTalking(false);\n else if (!st.talk && this.talking) this.stopTalking(false);\n this.mAmp.set(st.micro?.amp ?? 0.5);\n this.microSpeed = st.micro?.speed ?? 1;\n this.scheduleBlink();\n if (st.enter) st.enter(this);\n if (st.loop === \"loadingLoop\") this.loadingLoop();\n else if (st.loop === \"confusedLoop\") this.confusedLoop();\n else if (st.events) this.loopEvents();\n this.notify();\n }\n\n setExpression(name: ExpressionName, notify = true) {\n const E = EXPR[name];\n if (!E) return;\n if (notify) this.cancel(\"mouth\");\n this.expressionName = name;\n this.cy.set(E.cy || 0);\n this.cx.set(E.cx || 0);\n this.cs.set(E.cs ?? 1);\n this.asym.set(E.asym || 0);\n this.mx.set(E.mx || 0);\n this.my.set(E.my || 0);\n if (this.talking) this.cs.set(0);\n if (!this.talking) {\n this.open.set(E.open ?? 1);\n this.wide.set(E.wide ?? 1);\n }\n this.eyeS.set((this.st?.eye ?? 1) * (E.eye ?? 1));\n this.lid.set((this.st?.lid ?? 1) * (E.lid ?? 1));\n if (notify) this.notify();\n }\n\n /* ---------- blink ---------- */\n scheduleBlink() {\n const r = this.st?.blink || [2.5, 6];\n this.bl.next = this.time + rand(r[0], r[1]);\n }\n startBlink(dur = 0.32, hold = 0, min = 0.05) {\n const b = this.bl;\n if (b.active) {\n b.queue++;\n return;\n }\n b.active = true;\n b.t = 0;\n b.dur = dur;\n b.hold = hold;\n b.min = min;\n }\n blink() {\n this.startBlink(0.32);\n }\n doubleBlink() {\n this.startBlink(0.3);\n this.bl.queue = Math.max(this.bl.queue, 1);\n }\n slowBlink() {\n this.startBlink(0.85, 0.12, 0.04);\n }\n\n /* ---------- gaze ---------- */\n gazeTo(x: number, y: number) {\n this.gx.set(clamp(x, -1, 1));\n this.gy.set(clamp(y, -1, 1));\n }\n\n look(dir: string) {\n const d = DIRS[dir as DirectionName];\n if (d) {\n this.cancel(\"gaze\");\n this.gazeTo(d[0], d[1]);\n }\n }\n\n setGazeTarget(o: { x: number; y: number }) {\n this.cancel(\"gaze\");\n this.gazeTo(o.x, o.y);\n }\n eyeRoll() {\n this.cancel(\"gaze\");\n this.roll = { t0: this.time, dur: 1.5, bx: this.gx.t, by: this.gy.t };\n this.gx.w = 16;\n this.gy.w = 16;\n }\n\n /* ---------- head ---------- */\n headPulse(d: Partial, hold: number) {\n this.seq(\"head\", [\n {\n do: () => {\n if (d.rot != null) this.hr.set(this.hb.rot + d.rot);\n if (d.x != null) this.hx.set(this.hb.x + d.x);\n if (d.y != null) this.hy.set(this.hb.y + d.y);\n },\n wait: hold,\n },\n {\n do: () => {\n this.hr.set(this.hb.rot);\n this.hx.set(this.hb.x);\n this.hy.set(this.hb.y);\n },\n },\n ]);\n }\n nod() {\n this.seq(\"head\", [\n { do: () => this.hy.set(this.hb.y + 3), wait: 0.18 },\n { do: () => this.hy.set(this.hb.y) },\n ]);\n }\n doubleNod() {\n this.seq(\"head\", [\n { do: () => this.hy.set(this.hb.y + 3), wait: 0.18 },\n { do: () => this.hy.set(this.hb.y - 0.4), wait: 0.2 },\n { do: () => this.hy.set(this.hb.y + 2.6), wait: 0.18 },\n { do: () => this.hy.set(this.hb.y) },\n ]);\n }\n shakeHead() {\n this.seq(\"head\", [\n {\n do: () => {\n this.yaw.set(-0.6);\n this.hx.set(this.hb.x - 1.5);\n },\n wait: 0.22,\n },\n {\n do: () => {\n this.yaw.set(0.6);\n this.hx.set(this.hb.x + 1.5);\n },\n wait: 0.22,\n },\n {\n do: () => {\n this.yaw.set(-0.3);\n this.hx.set(this.hb.x - 0.8);\n },\n wait: 0.2,\n },\n {\n do: () => {\n this.yaw.set(0);\n this.hx.set(this.hb.x);\n },\n },\n ]);\n }\n tiltLeft() {\n this.headPulse({ rot: -7 }, 1.3);\n }\n tiltRight() {\n this.headPulse({ rot: 7 }, 1.3);\n }\n\n /* ---------- expressions (convenience) ---------- */\n smile() {\n this.setExpression(\"smile\");\n }\n smallSmile() {\n this.setExpression(\"smallSmile\");\n }\n bigSmile() {\n this.setExpression(\"bigSmile\");\n }\n sad() {\n this.setExpression(\"sad\");\n }\n worried() {\n this.setExpression(\"worried\");\n }\n surprised() {\n this.setExpression(\"surprised\");\n }\n think() {\n this.setExpression(\"thinking\");\n }\n confusedFace() {\n this.setExpression(\"confused\");\n }\n\n /* ---------- talking ---------- */\n startTalking(notify = true) {\n if (this.talking) return;\n this.talking = true;\n this.talkNext = this.time;\n this.cs.set(0);\n this._ow = [this.open.w, this.wide.w];\n this.open.w = 24;\n this.wide.w = 20;\n if (notify) this.notify();\n }\n stopTalking(notify = true) {\n if (!this.talking) return;\n this.talking = false;\n if (this._ow) {\n this.open.w = this._ow[0];\n this.wide.w = this._ow[1];\n }\n const E = EXPR[this.expressionName];\n this.open.set(E.open ?? 1);\n this.wide.set(E.wide ?? 1);\n this.cs.set(E.cs ?? 1);\n if (notify) this.notify();\n }\n\n reset() {\n this.stopTalking(false);\n this.setState(\"idle\");\n }\n\n /* ---------- pointer tracking ---------- */\n setMouseTracking(on: boolean) {\n this.mouse = !!on;\n if (on && !this._pm) {\n this._pm = (e: PointerEvent) => {\n const r = this.svg.getBoundingClientRect();\n const nx = clamp(\n (e.clientX - (r.left + r.width / 2)) / (r.width / 2),\n -1,\n 1\n );\n const ny = clamp(\n (e.clientY - (r.top + r.height / 2)) / (r.height / 2),\n -1,\n 1\n );\n this.gazeTo(nx * 0.7, ny * 0.7);\n };\n window.addEventListener(\"pointermove\", this._pm);\n } else if (!on && this._pm) {\n window.removeEventListener(\"pointermove\", this._pm);\n this._pm = null;\n this.gazeTo(this.gazeBias[0], this.gazeBias[1]);\n }\n }\n\n /* ---------- debug ---------- */\n setDebug(on: boolean) {\n this.debug = !!on;\n if (on && !this.guides) this.buildGuides();\n if (this.guides) this.guides.style.opacity = on ? \"1\" : \"0\";\n }\n buildGuides() {\n const G = this.geom;\n const NS = \"http://www.w3.org/2000/svg\";\n const g = document.createElementNS(NS, \"g\");\n g.style.opacity = \"0\";\n g.style.transition = \"opacity .25s\";\n g.style.pointerEvents = \"none\";\n const bounds = document.createElementNS(NS, \"rect\");\n const eyeHalf = G.EW / 2;\n bounds.setAttribute(\"x\", String(G.ELX - eyeHalf - G.RX));\n bounds.setAttribute(\"y\", String(G.ELY - eyeHalf - G.RY));\n bounds.setAttribute(\n \"width\",\n String(G.ERX + eyeHalf - (G.ELX - eyeHalf) + 2 * G.RX)\n );\n bounds.setAttribute(\"height\", String(G.EW + 2 * G.RY));\n bounds.setAttribute(\"fill\", \"none\");\n bounds.setAttribute(\"stroke-dasharray\", \"2 2\");\n bounds.setAttribute(\"stroke-width\", \".6\");\n bounds.style.stroke = \"var(--placeholder-foreground, #999)\";\n g.appendChild(bounds);\n const dot = () => {\n const c = document.createElementNS(NS, \"circle\");\n c.setAttribute(\"r\", \"1.4\");\n c.style.fill = \"var(--primary, #10b981)\";\n g.appendChild(c);\n return c;\n };\n this.gL = dot();\n this.gR = dot();\n const cross = document.createElementNS(NS, \"g\");\n const cx = (G.ELX + G.ERX) / 2;\n const cy = G.ELY;\n for (const pts of [\n [-2.2, 0, 2.2, 0],\n [0, -2.2, 0, 2.2],\n ]) {\n const l = document.createElementNS(NS, \"line\");\n l.setAttribute(\"x1\", String(cx + pts[0]));\n l.setAttribute(\"y1\", String(cy + pts[1]));\n l.setAttribute(\"x2\", String(cx + pts[2]));\n l.setAttribute(\"y2\", String(cy + pts[3]));\n l.setAttribute(\"stroke-width\", \".7\");\n l.style.stroke = \"var(--destructive, #ef4444)\";\n cross.appendChild(l);\n }\n g.appendChild(cross);\n this.gT = cross;\n this.head.appendChild(g);\n this.guides = g;\n }\n\n notify() {\n if (this.onChange) {\n this.onChange();\n }\n }\n\n /* ---------- renderer: one absolute frame from base geometry ---------- */\n render() {\n const G = this.geom;\n const ph = this.microPhase,\n a = this.mAmp.x;\n const mX = a * 0.55 * (Math.sin(ph * 1.1) + 0.4 * Math.sin(ph * 2.3 + 1.7));\n const mY =\n a * 0.45 * (Math.sin(ph * 0.9 + 0.8) + 0.4 * Math.sin(ph * 2.1 + 0.3));\n const mR = a * 0.7 * Math.sin(ph * 0.65 + 2);\n const yaw = this.yaw.x;\n const hx = this.hx.x + mX;\n let hy = this.hy.x + mY;\n const rot = this.hr.x + mR;\n if (this.talking) hy += (1 - this.open.x) * 0.4;\n this.head.setAttribute(\n \"transform\",\n `translate(${hx.toFixed(2)} ${hy.toFixed(2)}) rotate(${rot.toFixed(2)} ${G.CX} ${G.CY})`\n );\n this.face.setAttribute(\n \"transform\",\n `translate(${(yaw * 0.28).toFixed(2)} 0)`\n );\n\n const rawGx = this.gx.x * G.RX + mX * 0.25;\n const rawGy = this.gy.x * G.RY + mY * 0.2;\n const etx = rawGx + yaw * 5.2;\n const ety = rawGy;\n this.eyesG.setAttribute(\n \"transform\",\n `translate(${etx.toFixed(2)} ${ety.toFixed(2)})`\n );\n\n const openV = clamp(this.lid.x, 0.02, 1.15) * this.blinkVal();\n const es = this.eyeS.x;\n const sy = Math.max(0.045, es * openV);\n const bsx = 1 + (es - 1) * 0.55;\n const ay = Math.abs(yaw);\n const sxL = Math.max(0.05, bsx * (1 - ay * 0.12 - Math.max(0, yaw) * 0.3));\n const sxR = Math.max(0.05, bsx * (1 - ay * 0.12 - Math.max(0, -yaw) * 0.3));\n this.eyeL.setAttribute(\n \"transform\",\n `translate(${G.ELX} ${G.ELY}) scale(${sxL.toFixed(3)} ${sy.toFixed(3)}) translate(${-G.ELX} ${-G.ELY})`\n );\n this.eyeR.setAttribute(\n \"transform\",\n `translate(${G.ERX} ${G.ERY}) scale(${sxR.toFixed(3)} ${sy.toFixed(3)}) translate(${-G.ERX} ${-G.ERY})`\n );\n\n const mtx = rawGx * 0.28 + yaw * 3.6 + this.mx.x;\n const mty = this.my.x + rawGy * 0.18;\n this.mouthG.setAttribute(\n \"transform\",\n `translate(${mtx.toFixed(2)} ${mty.toFixed(2)})`\n );\n const sleepyBlend = clamp(this.sleepyMorph.x, 0, 1);\n const sleepyMouthY = 1 - 0.22 * sleepyBlend;\n const ow = Math.max(0.06, this.open.x) * sleepyMouthY;\n const ww = Math.max(0.2, this.wide.x);\n const barAY = ow > 1 ? G.MBY_TOP : G.MBY; // grow downward from top edge when taller than base\n const csv = Math.max(0, this.cs.x);\n const sleepyCornerY = 1 - 0.38 * sleepyBlend;\n const cornerScaleY = csv * sleepyCornerY;\n const cvis = csv < 0.04 ? \"hidden\" : \"visible\";\n const cornerLift = 0;\n const cornerSpread = 0;\n const lty = this.cy.x - this.asym.x + cornerLift;\n const rty = this.cy.x + this.asym.x + cornerLift;\n const cornerBottomY = G.MBY_TOP + 0.01;\n\n const isWriting = this.stateName === \"writing\";\n const loadingMix = clamp(this.loadingMix.x, 0, 1);\n const isLoading = loadingMix > 0.001;\n const loadingBlend = loadingMix;\n const isWritingOnly = this.stateName === \"writing\";\n const phase = (this.time * 2.3) % 3;\n const glow = (index: number) => {\n const d = Math.abs(phase - index);\n const cycDist = Math.min(d, 3 - d);\n return Math.max(0, 1 - cycDist);\n };\n const lerp = (a: number, b: number, t: number) => a + (b - a) * t;\n\n const barBaseX = G.MBX - G.MBW / 2;\n const barBaseY = G.MBY_TOP;\n const normalBarX = G.MBX + (barBaseX - G.MBX) * ww;\n const normalBarY = barAY + (barBaseY - barAY) * ow;\n const normalBarW = G.MBW * ww;\n const normalBarH = G.MBH * ow;\n\n const normalLeftX = G.MLX + (-G.EW / 2) * csv + (-this.cx.x - cornerSpread);\n const normalRightX = G.MRX + (-G.EW / 2) * csv + (this.cx.x + cornerSpread);\n const cornerBaseY = G.MLY - G.EW / 2;\n const cornerYOffset = cornerBaseY - cornerBottomY;\n const normalLeftY = cornerBottomY + cornerYOffset * cornerScaleY + lty;\n const normalRightY = cornerBottomY + cornerYOffset * cornerScaleY + rty;\n const normalCornerW = G.EW * csv;\n const normalCornerH = G.EW * cornerScaleY;\n\n const loadH = G.MBH;\n const loadGap = G.MBW * 0.05;\n const loadSideW = loadH;\n const loadMidW = loadSideW * 2;\n const loadLayoutW = loadSideW * 2 + loadMidW + loadGap * 2;\n const loadX0 = G.MBX - loadLayoutW / 2;\n const loadY = G.MBY - loadH / 2;\n\n const loadLeftX = loadX0;\n const loadBarX = loadX0 + loadSideW + loadGap;\n const loadRightX = loadBarX + loadMidW + loadGap;\n\n const blend = loadingMix;\n const leftX = lerp(normalLeftX, loadLeftX, blend);\n const leftY = lerp(normalLeftY, loadY, blend);\n const leftW = lerp(normalCornerW, loadSideW, blend);\n const leftH = lerp(normalCornerH, loadH, blend);\n\n const barX = lerp(normalBarX, loadBarX, blend);\n const barY = lerp(normalBarY, loadY, blend);\n const barW = lerp(normalBarW, loadMidW, blend);\n const barH = lerp(normalBarH, loadH, blend);\n\n const rightX = lerp(normalRightX, loadRightX, blend);\n const rightY = lerp(normalRightY, loadY, blend);\n const rightW = lerp(normalCornerW, loadSideW, blend);\n const rightH = lerp(normalCornerH, loadH, blend);\n\n this.mL.setAttribute(\"transform\", \"none\");\n this.bar.setAttribute(\"transform\", \"none\");\n this.mR.setAttribute(\"transform\", \"none\");\n this.mL.setAttribute(\"x\", leftX.toFixed(2));\n this.mL.setAttribute(\"y\", leftY.toFixed(2));\n this.mL.setAttribute(\"width\", leftW.toFixed(2));\n this.mL.setAttribute(\"height\", leftH.toFixed(2));\n this.bar.setAttribute(\"x\", barX.toFixed(2));\n this.bar.setAttribute(\"y\", barY.toFixed(2));\n this.bar.setAttribute(\"width\", barW.toFixed(2));\n this.bar.setAttribute(\"height\", barH.toFixed(2));\n this.mR.setAttribute(\"x\", rightX.toFixed(2));\n this.mR.setAttribute(\"y\", rightY.toFixed(2));\n this.mR.setAttribute(\"width\", rightW.toFixed(2));\n this.mR.setAttribute(\"height\", rightH.toFixed(2));\n\n this.bar.setAttribute(\"visibility\", isWriting ? \"hidden\" : \"visible\");\n this.mL.setAttribute(\n \"visibility\",\n isWriting ? \"hidden\" : isLoading ? \"visible\" : cvis\n );\n this.mR.setAttribute(\n \"visibility\",\n isWriting ? \"hidden\" : isLoading ? \"visible\" : cvis\n );\n const base = 0.22;\n const span = 0.78;\n const leftShimmerOpacity = (base + glow(0) * span) * loadingBlend;\n const barShimmerOpacity = (base + glow(1) * span) * loadingBlend;\n const rightShimmerOpacity = (base + glow(2) * span) * loadingBlend;\n const normalOpacity = 1 - loadingBlend;\n this.mL.setAttribute(\n \"opacity\",\n (normalOpacity + leftShimmerOpacity).toFixed(3)\n );\n this.bar.setAttribute(\n \"opacity\",\n (normalOpacity + barShimmerOpacity).toFixed(3)\n );\n this.mR.setAttribute(\n \"opacity\",\n (normalOpacity + rightShimmerOpacity).toFixed(3)\n );\n this.face.setAttribute(\"visibility\", isWritingOnly ? \"hidden\" : \"visible\");\n if (this.shell)\n this.shell.setAttribute(\n \"visibility\",\n isWritingOnly ? \"hidden\" : \"visible\"\n );\n this.eyesG.setAttribute(\"visibility\", isWritingOnly ? \"hidden\" : \"visible\");\n if (this.nose)\n this.nose.setAttribute(\n \"visibility\",\n isWritingOnly ? \"hidden\" : \"visible\"\n );\n\n if (this.wG && this.wP) {\n this.wG.setAttribute(\"visibility\", isWriting ? \"visible\" : \"hidden\");\n if (isWriting) {\n const spec = WRITING_STROKE;\n if (this.wP.getAttribute(\"d\") !== spec.d) {\n this.wP.setAttribute(\"d\", spec.d);\n try {\n this.wLen = this.wP.getTotalLength();\n } catch {\n this.wLen = 700;\n }\n }\n const len = this.wLen > 0 ? this.wLen : 700;\n const seg = len * spec.segmentRatio;\n const gap = Math.max(1, len - seg);\n const baseSpeed = len * spec.speedRatio;\n const omega = 2 * Math.PI * spec.easeHz;\n const writingT = Math.max(0, this.time - this.writingT0);\n const easedTravel =\n baseSpeed * writingT +\n ((baseSpeed * spec.easeAmount) / omega) * Math.sin(omega * writingT);\n this.wG.setAttribute(\"transform\", writingStrokeTransform(G, spec));\n const cycleOffset = easedTravel % len;\n const offset = -cycleOffset;\n\n this.wP.setAttribute(\"d\", spec.d);\n this.wP.setAttribute(\"stroke-width\", String(spec.stroke));\n this.wP.setAttribute(\n \"stroke-dasharray\",\n `${seg.toFixed(2)} ${gap.toFixed(2)}`\n );\n this.wP.setAttribute(\"stroke-dashoffset\", offset.toFixed(2));\n this.wP.setAttribute(\"stroke-opacity\", \"0.98\");\n }\n }\n\n if (this.debug && this.guides && this.gT && this.gL && this.gR) {\n this.gT.setAttribute(\n \"transform\",\n `translate(${(this.gx.t * G.RX).toFixed(2)} ${(this.gy.t * G.RY).toFixed(2)})`\n );\n this.gL.setAttribute(\"cx\", (G.ELX + etx).toFixed(2));\n this.gL.setAttribute(\"cy\", (G.ELY + ety).toFixed(2));\n this.gR.setAttribute(\"cx\", (G.ERX + etx).toFixed(2));\n this.gR.setAttribute(\"cy\", (G.ERY + ety).toFixed(2));\n }\n if (this.onStatus && this.time - this._lastStat > 0.12) {\n this._lastStat = this.time;\n const b = this.bl;\n this.onStatus({\n state: this.stateName,\n expr: this.expressionName,\n gazet: `${this.gx.t.toFixed(2)}, ${this.gy.t.toFixed(2)}`,\n gazep: `${this.gx.x.toFixed(2)}, ${this.gy.x.toFixed(2)}`,\n vel: (Math.abs(this.gx.v) + Math.abs(this.gy.v)).toFixed(2),\n blink: b.active\n ? `blinking ${(this.blinkVal() * 100) | 0}%`\n : `open, next ${(b.next - this.time).toFixed(1)}s`,\n head: `${this.hr.x.toFixed(1)} deg, y ${this.hy.x.toFixed(1)}`,\n mouth: this.talking\n ? `talking, open ${this.open.x.toFixed(2)}`\n : `open ${this.open.x.toFixed(2)}, wide ${this.wide.x.toFixed(2)}`,\n });\n }\n }\n\n destroy() {\n cancelAnimationFrame(this._rafId);\n if (this._pm) window.removeEventListener(\"pointermove\", this._pm);\n this.cancel(\"ev\");\n this.cancel(\"loop\");\n this.cancel(\"gaze\");\n this.cancel(\"head\");\n this.cancel(\"mouth\");\n this.cancel(\"surprisePulse\");\n this.timers = [];\n }\n}\n\n/* ------------------------------------------------------------------ */\n/* React component */\n/* ------------------------------------------------------------------ */\n\nexport const CHARACTER_STATES = [\n \"idle\",\n \"listening\",\n \"talking\",\n \"writing\",\n \"thinking\",\n \"loading\",\n \"happy\",\n \"sad\",\n \"surprised\",\n \"confused\",\n \"excited\",\n \"sleepy\",\n] as const;\n\nexport type CareFillyClassicState = (typeof CHARACTER_STATES)[number];\n\nexport interface CareFillyClassicHandle {\n setState: (state: CareFillyClassicState) => void;\n look: (dir: string) => void;\n setGazeTarget: (target: { x: number; y: number }) => void;\n blink: () => void;\n doubleBlink: () => void;\n slowBlink: () => void;\n eyeRoll: () => void;\n nod: () => void;\n doubleNod: () => void;\n shakeHead: () => void;\n tiltLeft: () => void;\n tiltRight: () => void;\n setExpression: (name: string) => void;\n startTalking: () => void;\n stopTalking: () => void;\n setMouseTracking: (on: boolean) => void;\n reset: () => void;\n}\n\nexport interface CareFillyClassicProps extends Omit<\n React.ComponentProps<\"div\">,\n \"children\"\n> {\n /** Behavioral state preset. Transitions blend from the current pose. */\n state?: CareFillyClassicState;\n /** When true, the eyes smoothly follow the pointer; when false they return to the state's gaze. */\n mouseTracking?: boolean;\n /** Visual shell variant with the same animation behavior model. */\n variant?: CareFillyClassicVariant;\n /** CSS width for the character (e.g. \"20px\", \"2rem\"). Overrides the default size. */\n size?: string | number;\n /** CSS color for the character (e.g. \"#3b82f6\", \"oklch(70% 0.2 250)\"). Defaults to currentColor. */\n color?: string;\n}\n\nexport const CareFillyClassic = React.forwardRef<\n CareFillyClassicHandle,\n CareFillyClassicProps\n>(function AnimatedCharacter(\n {\n state = \"idle\",\n mouseTracking = false,\n variant = \"light\",\n size,\n color,\n className,\n style,\n ...props\n },\n ref\n) {\n const svgRef = React.useRef(null);\n const engineRef = React.useRef(null);\n\n React.useEffect(() => {\n if (!svgRef.current) return;\n const engine = new CharacterEngine(svgRef.current, { variant });\n engineRef.current = engine;\n return () => {\n engine.destroy();\n engineRef.current = null;\n };\n }, [variant]);\n\n React.useEffect(() => {\n engineRef.current?.setState(state);\n }, [state]);\n\n React.useEffect(() => {\n engineRef.current?.setMouseTracking(mouseTracking);\n }, [mouseTracking]);\n\n React.useImperativeHandle(ref, () => {\n return {\n setState: (nextState: CareFillyClassicState) =>\n engineRef.current?.setState(nextState),\n look: (dir: string) => engineRef.current?.look(dir),\n setGazeTarget: (target: { x: number; y: number }) =>\n engineRef.current?.setGazeTarget(target),\n blink: () => engineRef.current?.blink(),\n doubleBlink: () => engineRef.current?.doubleBlink(),\n slowBlink: () => engineRef.current?.slowBlink(),\n eyeRoll: () => engineRef.current?.eyeRoll(),\n nod: () => engineRef.current?.nod(),\n doubleNod: () => engineRef.current?.doubleNod(),\n shakeHead: () => engineRef.current?.shakeHead(),\n tiltLeft: () => engineRef.current?.tiltLeft(),\n tiltRight: () => engineRef.current?.tiltRight(),\n setExpression: (name: string) =>\n engineRef.current?.setExpression(name as ExpressionName),\n startTalking: () => engineRef.current?.startTalking(),\n stopTalking: () => engineRef.current?.stopTalking(),\n setMouseTracking: (on: boolean) =>\n engineRef.current?.setMouseTracking(on),\n reset: () => engineRef.current?.reset(),\n };\n }, []);\n\n return (\n \n {/* Original artwork: geometry preserved verbatim; wrapper groups only. */}\n \n {variant === \"dark\" ? (\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n ) : (\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n )}\n \n \n );\n});\n", + "type": "registry:component", + "target": "components/careui/care-filly-classic.tsx" + } + ] +} \ No newline at end of file diff --git a/public/registry/care-ui/care-filly/care-filly.json b/public/registry/care-ui/care-filly/care-filly.json new file mode 100644 index 0000000..3d59d1a --- /dev/null +++ b/public/registry/care-ui/care-filly/care-filly.json @@ -0,0 +1,13 @@ +{ + "name": "care-filly", + "type": "registry:ui", + "registryDependencies": [], + "files": [ + { + "path": "registry/care-ui/care-filly/care-filly.tsx", + "content": "/**\n * @name care-filly\n * @description Spring-driven animated Filly character (Filly-New-Series) with face-plate nodding,\n * blinking, talking, expressions and all state presets.\n * @type registry:ui\n */\nimport * as React from \"react\";\n\nimport { cn } from \"@/lib/utils\";\n\n/* ------------------------------------------------------------------ */\n/* Shared utilities */\n/* ------------------------------------------------------------------ */\nconst clamp = (v: number, a: number, b: number) => (v < a ? a : v > b ? b : v);\nconst rand = (a: number, b: number) => a + Math.random() * (b - a);\nconst smooth = (t: number) => (t <= 0 ? 0 : t >= 1 ? 1 : t * t * (3 - 2 * t));\nconst wpick = (pairs: ReadonlyArray) => {\n let total = 0;\n for (const p of pairs) total += p[0];\n let r = Math.random() * total;\n for (const p of pairs) {\n r -= p[0];\n if (r <= 0) return p[1];\n }\n return pairs[pairs.length - 1][1];\n};\n\nclass Spring {\n x: number;\n v: number;\n t: number;\n w: number;\n z: number;\n constructor(v: number, w: number, z = 1) {\n this.x = v;\n this.v = 0;\n this.t = v;\n this.w = w;\n this.z = z;\n }\n step(h: number) {\n const a =\n -2 * this.z * this.w * this.v - this.w * this.w * (this.x - this.t);\n this.v += a * h;\n this.x += this.v * h;\n }\n set(t: number) {\n this.t = t;\n }\n}\n\n/* ------------------------------------------------------------------ */\n/* Types */\n/* ------------------------------------------------------------------ */\ntype ExpressionName =\n | \"neutral\"\n | \"smallSmile\"\n | \"smile\"\n | \"bigSmile\"\n | \"sad\"\n | \"worried\"\n | \"surprised\"\n | \"thinking\"\n | \"confused\";\n\ntype CharacterStateName =\n | \"idle\"\n | \"listening\"\n | \"talking\"\n | \"writing\"\n | \"thinking\"\n | \"loading\"\n | \"happy\"\n | \"sad\"\n | \"surprised\"\n | \"confused\"\n | \"excited\"\n | \"sleepy\";\n\ntype ExpressionConfig = {\n open?: number;\n wide?: number;\n cy?: number;\n cx?: number;\n cs?: number;\n my?: number;\n mx?: number;\n asym?: number;\n eye?: number;\n lid?: number;\n};\n\ntype SequenceStep = { do?: () => void; wait?: number | [number, number] };\ntype Timer = { at: number; fn: () => void };\ntype BlinkState = {\n active: boolean;\n t: number;\n dur: number;\n hold: number;\n min: number;\n queue: number;\n next: number;\n};\ntype RollState = { t0: number; dur: number; bx: number; by: number };\ntype HeadBase = { x: number; y: number; rot: number };\ntype DirectionName = keyof typeof DIRS;\n\ntype CharacterStateConfig = {\n expr: ExpressionName;\n gaze: [number, number];\n blink: [number, number];\n micro: { amp: number; speed: number };\n gap?: [number, number];\n events?: ReadonlyArray;\n head?: Partial;\n lid?: number;\n eye?: number;\n blinkDur?: number;\n talk?: boolean;\n loop?: \"loadingLoop\" | \"confusedLoop\";\n enter?: (e: FillyEngine) => void;\n};\n\n/* ------------------------------------------------------------------ */\n/* Filly geometry — derived from Filly-New-Series front-facing SVG */\n/* Eye/mouth centers match classic geometry almost exactly */\n/* ------------------------------------------------------------------ */\nconst FILLY_GEOM = {\n CX: 59.955,\n CY: 61,\n RX: 4.6,\n RY: 3.1,\n ELX: 40.73,\n ELY: 49.09,\n ERX: 79.18,\n ERY: 49.09,\n MBX: 59.95,\n MBY: 82.5,\n MLX: 40.75,\n MLY: 74.5,\n MRX: 79.15,\n MRY: 74.5,\n EW: 8,\n MBY_TOP: 78.5,\n MBW: 30.4,\n MBH: 8,\n} as const;\n\n/*\n * Face-plate translation scale factors.\n * Derived from comparing Filly-New-Series nodding SVG positions to the\n * front-facing baseline:\n * Pure horizontal max shift = ±9 SVG units (at full left/right nod)\n * Pure vertical max shift = ±8.5 SVG units (at full up/down nod)\n *\n * Engine spring ranges:\n * hr (rotation spring) — tiltLeft/Right peaks at ±7, listenNod ±4.5\n * yaw — shakeHead peaks at ±0.6, listenNod ±0.4\n * hx — headDrift / shake ±1.4\n * hy — nod() peaks at +3, bounce at -2\n */\nconst FP = {\n HR: 0.71, // hr → face-plate x (tiltLeft hr=7 → fpTX ≈ 5)\n YAW: 5.0, // yaw → face-plate x\n HX: 0.5, // hx → face-plate x (supplementary)\n HY: 1.33, // hy → face-plate y (nod depth=3 → fpTY ≈ 4)\n MAX_TX: 5.0, // always ≥6 SVG units from shell edge; no edge-touching\n MAX_TY: 4.0,\n} as const;\n\n/* ------------------------------------------------------------------ */\n/* Expression / state tables */\n/* ------------------------------------------------------------------ */\nconst EXPR: Record = {\n neutral: { open: 1, wide: 1, cy: 0, cx: 0, cs: 1 },\n smallSmile: { open: 1.15, wide: 1, cy: 0, cx: 0, cs: 1 },\n smile: { open: 1.35, wide: 1, cy: 0, cx: 0, cs: 1 },\n bigSmile: { open: 1.7, wide: 1, cy: 0, cx: 0, cs: 1 },\n sad: { open: 1, wide: 1, cy: 16.11, cx: 0, cs: 1, my: -5.5 },\n worried: { open: 0.42, wide: 0.66, cy: 4.6, cx: -1, cs: 0, my: 0.4 },\n surprised: {\n open: 2,\n wide: 0.52,\n cy: 2.2,\n cx: -2.2,\n cs: 0,\n my: -3.2,\n eye: 1.1,\n },\n thinking: {\n open: 0.45,\n wide: 0.55,\n cy: 1.5,\n cx: -1.6,\n cs: 0,\n mx: 2.4,\n lid: 0.95,\n },\n confused: { open: 0.6, wide: 0.72, cy: 1, asym: 2.6, cs: 0, mx: 1.6 },\n};\n\nconst TALK_SHAPES = [\n { o: 1.5, w: 0.85 },\n { o: 0.5, w: 0.95 },\n { o: 1.25, w: 0.65 },\n { o: 1.9, w: 0.72 },\n { o: 0.75, w: 1.02 },\n { o: 1.05, w: 0.9 },\n { o: 0.32, w: 0.9 },\n];\n\nconst DIRS = {\n center: [0, 0],\n left: [-0.85, 0],\n right: [0.85, 0],\n up: [0, -0.85],\n down: [0, 0.85],\n \"upper-left\": [-0.7, -0.7],\n \"upper-right\": [0.7, -0.7],\n \"lower-left\": [-0.7, 0.7],\n \"lower-right\": [0.7, 0.7],\n};\n\nconst WRITING_PATH_D =\n \"M7.73145 285.912C143.731 137.912 134.731 -38.0885 60.7314 22.9118C-21.4532 90.6589 46.7314 241.912 112.731 273.912C178.731 305.912 218.731 155.912 176.731 177.912C134.731 199.912 166.731 299.912 216.731 273.912C266.731 247.912 231.731 141.912 545.731 199.912C796.931 246.312 796.065 181.912 772.731 143.912\";\n\nconst WRITING_STROKE = {\n d: WRITING_PATH_D,\n baseWidth: 793,\n baseHeight: 294,\n stroke: 44,\n segmentRatio: 0.24,\n speedRatio: 0.64,\n easeAmount: 0.35,\n easeHz: 1.2,\n} as const;\n\nfunction writingStrokeScale(spec: typeof WRITING_STROKE) {\n return (FILLY_GEOM.EW * 4.6) / spec.baseWidth;\n}\nfunction writingStrokeTransform(spec: typeof WRITING_STROKE) {\n const s = writingStrokeScale(spec);\n const tx = FILLY_GEOM.MBX - (spec.baseWidth / 2) * s;\n const ty = FILLY_GEOM.MBY - (spec.baseHeight / 2) * s;\n return `translate(${tx.toFixed(3)} ${ty.toFixed(3)}) scale(${s.toFixed(5)})`;\n}\n\nconst STATES: Record = {\n idle: {\n expr: \"neutral\",\n gaze: [0, 0],\n blink: [2.2, 6.5],\n micro: { amp: 0.5, speed: 1 },\n gap: [1.6, 4.2],\n events: [\n [5, \"gazeShift\"],\n [3, \"glance\"],\n [1, \"doubleBlink\"],\n [1, \"microSmile\"],\n [0.4, \"eyeRoll\"],\n ],\n },\n listening: {\n expr: \"smallSmile\",\n gaze: [0, 0.12],\n head: { rot: -1.2 },\n blink: [2, 5.5],\n micro: { amp: 0.6, speed: 1 },\n gap: [0.9, 1.8],\n events: [\n [1, \"gazeShift\"],\n [0.5, \"listeningMouthShift\"],\n [5, \"listeningNod\"],\n [0.4, \"doubleBlink\"],\n ],\n },\n talking: {\n expr: \"neutral\",\n talk: true,\n gaze: [0, 0],\n blink: [2.5, 6],\n micro: { amp: 0.8, speed: 1.25 },\n gap: [1.5, 3.5],\n events: [\n [1, \"gazeShift\"],\n [1, \"talkingRotate\"],\n ],\n },\n writing: {\n expr: \"neutral\",\n gaze: [0, 0],\n head: { rot: 0, y: 0, x: 0 },\n lid: 1,\n eye: 1,\n blink: [9, 12],\n blinkDur: 0.35,\n micro: { amp: 0, speed: 1 },\n },\n thinking: {\n expr: \"thinking\",\n gaze: [-0.45, -0.55],\n head: { rot: -5, x: -1 },\n lid: 0.95,\n blink: [3, 7],\n blinkDur: 0.55,\n micro: { amp: 0.4, speed: 0.7 },\n gap: [2.5, 5],\n events: [\n [2, \"switchSide\"],\n [1, \"gazeShift\"],\n [1, \"slowBlink\"],\n ],\n },\n loading: {\n expr: \"neutral\",\n gaze: [0, 0],\n lid: 0.97,\n blink: [3, 6],\n micro: { amp: 0.45, speed: 0.9 },\n loop: \"loadingLoop\",\n },\n happy: {\n expr: \"smile\",\n gaze: [0, -0.05],\n head: { y: -1.2 },\n blink: [2.5, 6],\n micro: { amp: 0.7, speed: 1.2 },\n gap: [1.8, 4],\n events: [\n [2, \"smilePulse\"],\n [2, \"gazeShift\"],\n [1, \"doubleBlink\"],\n ],\n },\n sad: {\n expr: \"sad\",\n gaze: [0, 0.55],\n head: { rot: 2.5, y: 2.2 },\n lid: 0.8,\n eye: 0.97,\n blink: [3.5, 7.5],\n blinkDur: 0.6,\n micro: { amp: 0.3, speed: 0.55 },\n gap: [3, 6],\n events: [\n [2, \"gazeShiftDown\"],\n [2, \"sadSway\"],\n [1, \"sigh\"],\n [1, \"slowBlink\"],\n ],\n },\n surprised: {\n expr: \"surprised\",\n gaze: [0, -0.08],\n head: { y: -2 },\n eye: 1.1,\n blink: [4, 8],\n micro: { amp: 0.5, speed: 1.1 },\n gap: [2.5, 5],\n events: [\n [2, \"gazeShift\"],\n [1, \"doubleBlink\"],\n ],\n enter: (e: FillyEngine) => {\n e.headPulse({ y: -1.6, rot: -1 }, 0.35);\n e.surprisedLoop();\n },\n },\n confused: {\n expr: \"confused\",\n gaze: [0, 0],\n blink: [2.5, 6],\n micro: { amp: 0.5, speed: 0.9 },\n loop: \"confusedLoop\",\n },\n excited: {\n expr: \"bigSmile\",\n gaze: [0, -0.05],\n head: { y: -0.8 },\n eye: 1.12,\n blink: [2, 5],\n micro: { amp: 1.2, speed: 2.1 },\n gap: [0.9, 2.2],\n events: [\n [2, \"gazeShift\"],\n [2, \"excitedRotate\"],\n [1, \"doubleBlink\"],\n ],\n },\n sleepy: {\n expr: \"neutral\",\n gaze: [0, 0.5],\n head: { rot: 3, y: 1.8 },\n lid: 0.55,\n eye: 0.95,\n blink: [2, 4.5],\n blinkDur: 0.9,\n micro: { amp: 0.5, speed: 0.45 },\n gap: [2.5, 5.5],\n events: [\n [2, \"longClose\"],\n [2, \"swaySlow\"],\n [1, \"gazeShiftDown\"],\n ],\n },\n};\n\n/* ------------------------------------------------------------------ */\n/* Filly animation engine */\n/* Same state/event/expression/blink/gaze model as CharacterEngine. */\n/* render() uses face-plate translation (not head rotation) to show */\n/* nodding direction, matching the Filly-New-Series SVG keyframes. */\n/* ------------------------------------------------------------------ */\nclass FillyEngine {\n svg: SVGSVGElement;\n head: SVGGraphicsElement;\n faceplate: SVGGraphicsElement | null;\n face: SVGGraphicsElement;\n eyesG: SVGGraphicsElement;\n mouthG: SVGGraphicsElement;\n eyeL: SVGGraphicsElement;\n eyeR: SVGGraphicsElement;\n bar: SVGGraphicsElement;\n mL: SVGGraphicsElement;\n mR: SVGGraphicsElement;\n nose: SVGGraphicsElement | null;\n shell: SVGGraphicsElement | null;\n maskFace: SVGGraphicsElement | null;\n wG: SVGGraphicsElement | null;\n wP: SVGPathElement | null;\n wLen: number;\n writingT0: number;\n waveL: SVGGraphicsElement | null;\n waveR: SVGGraphicsElement | null;\n waveLPaths: SVGElement[];\n waveRPaths: SVGElement[];\n\n gx: Spring;\n gy: Spring;\n yaw: Spring;\n hx: Spring;\n hy: Spring;\n hr: Spring;\n lid: Spring;\n eyeS: Spring;\n open: Spring;\n wide: Spring;\n cy: Spring;\n cx: Spring;\n cs: Spring;\n asym: Spring;\n mx: Spring;\n my: Spring;\n mAmp: Spring;\n sleepyMorph: Spring;\n loadingMix: Spring;\n wavePulse: Spring;\n springs: Spring[];\n\n time: number;\n acc: number;\n last: number;\n timers: Timer[];\n gen: Record;\n bl: BlinkState;\n talking: boolean;\n talkNext: number;\n roll: RollState | null;\n mouse: boolean;\n microPhase: number;\n microSpeed: number;\n expressionName: ExpressionName;\n stateName: CharacterStateName | null;\n hb: HeadBase;\n acts: Record void>;\n st: CharacterStateConfig;\n gazeBias: [number, number];\n thinkSide?: number;\n _ow?: [number, number];\n _pm: ((e: PointerEvent) => void) | null;\n onChange?: () => void;\n _raf: (t: number) => void;\n _rafId: number;\n\n constructor(svg: SVGSVGElement, initialState?: string) {\n this.svg = svg;\n const q = (p: string) =>\n svg.querySelector(`[data-part=\"${p}\"]`) as SVGGraphicsElement;\n this.head = q(\"head\");\n this.faceplate = svg.querySelector(\n '[data-part=\"face-plate\"]'\n ) as SVGGraphicsElement | null;\n this.face = q(\"face\");\n this.eyesG = q(\"eyes\");\n this.mouthG = q(\"mouth-group\");\n this.eyeL = q(\"eye-left\");\n this.eyeR = q(\"eye-right\");\n this.bar = q(\"mouth\");\n this.mL = q(\"mouth-left\");\n this.mR = q(\"mouth-right\");\n this.nose = svg.querySelector(\n '[data-part=\"nose\"]'\n ) as SVGGraphicsElement | null;\n this.shell = svg.querySelector(\n '[data-part=\"shell\"]'\n ) as SVGGraphicsElement | null;\n this.maskFace = svg.querySelector(\n '[data-part=\"mask-face\"]'\n ) as SVGGraphicsElement | null;\n this.wG = svg.querySelector(\n '[data-part=\"writing-stroke\"]'\n ) as SVGGraphicsElement | null;\n this.wP = svg.querySelector(\n '[data-part=\"writing-line\"]'\n ) as SVGPathElement | null;\n this.waveL = svg.querySelector(\n '[data-part=\"wave-left\"]'\n ) as SVGGraphicsElement | null;\n this.waveR = svg.querySelector(\n '[data-part=\"wave-right\"]'\n ) as SVGGraphicsElement | null;\n this.waveLPaths = this.waveL\n ? Array.from(this.waveL.querySelectorAll(\"path\"))\n : [];\n this.waveRPaths = this.waveR\n ? Array.from(this.waveR.querySelectorAll(\"path\"))\n : [];\n this.wLen = 0;\n this.writingT0 = 0;\n if (this.wP) {\n this.wP.setAttribute(\"d\", WRITING_STROKE.d);\n try {\n this.wLen = this.wP.getTotalLength();\n } catch {\n this.wLen = 0;\n }\n }\n\n const S = (v: number, w: number, z = 1) => new Spring(v, w, z);\n this.gx = S(0, 11);\n this.gy = S(0, 11);\n this.yaw = S(0, 8, 0.95);\n this.hx = S(0, 6.5);\n this.hy = S(0, 6.5, 0.95);\n this.hr = S(0, 7, 0.9);\n this.lid = S(1, 14);\n this.eyeS = S(1, 10, 0.85);\n this.open = S(1, 16, 0.9);\n this.wide = S(1, 14, 0.95);\n this.cy = S(0, 11, 0.85);\n this.cx = S(0, 11);\n this.cs = S(1, 12);\n this.asym = S(0, 11);\n this.mx = S(0, 9);\n this.my = S(0, 11);\n this.mAmp = S(0.5, 3);\n this.sleepyMorph = S(0, 10, 0.9);\n this.loadingMix = S(0, 9, 0.9);\n this.wavePulse = S(0, 3.5, 0.7);\n this.springs = [\n this.gx,\n this.gy,\n this.yaw,\n this.hx,\n this.hy,\n this.hr,\n this.lid,\n this.eyeS,\n this.open,\n this.wide,\n this.cy,\n this.cx,\n this.cs,\n this.asym,\n this.mx,\n this.my,\n this.mAmp,\n this.sleepyMorph,\n this.loadingMix,\n this.wavePulse,\n ];\n\n this.time = 0;\n this.acc = 0;\n this.last = performance.now();\n this.timers = [];\n this.gen = {};\n this.bl = {\n active: false,\n t: 0,\n dur: 0.32,\n hold: 0,\n min: 0.05,\n queue: 0,\n next: 2,\n };\n this.talking = false;\n this.talkNext = 0;\n this.roll = null;\n this.mouse = false;\n this.microPhase = 0;\n this.microSpeed = 1;\n this.expressionName = \"neutral\";\n this.stateName = null;\n this.hb = { x: 0, y: 0, rot: 0 };\n this._pm = null;\n this.gazeBias = [0, 0];\n this.acts = this._buildActions();\n this.st = STATES.idle;\n this.setState((initialState || \"idle\") as CharacterStateName);\n this._raf = (t: number) => this.frame(t);\n this._rafId = requestAnimationFrame(this._raf);\n }\n\n /* ---------- clock ---------- */\n frame(now: number) {\n const dt = clamp((now - this.last) / 1000, 0, 0.1);\n this.last = now;\n this.acc += dt;\n const h = 1 / 120;\n let n = 0;\n while (this.acc >= h && n < 24) {\n this.stepFixed(h);\n this.acc -= h;\n n++;\n }\n this.render();\n this._rafId = requestAnimationFrame(this._raf);\n }\n\n stepFixed(h: number) {\n this.time += h;\n this.microPhase += h * this.microSpeed;\n if (this.timers.length) {\n const due: Timer[] = [],\n rest: Timer[] = [];\n for (const tm of this.timers) (tm.at <= this.time ? due : rest).push(tm);\n if (due.length) {\n this.timers = rest;\n for (const tm of due) tm.fn();\n }\n }\n if (this.roll) {\n const p = (this.time - this.roll.t0) / this.roll.dur;\n if (p >= 1) {\n this.gx.set(this.roll.bx);\n this.gy.set(this.roll.by);\n this.gx.w = 11;\n this.gy.w = 11;\n this.roll = null;\n } else {\n const phi = Math.PI * 2 * smooth(p),\n r = 0.85 * Math.sin(Math.PI * p);\n this.gx.set(this.roll.bx + r * Math.sin(phi));\n this.gy.set(this.roll.by - r * Math.cos(phi));\n }\n }\n if (this.talking && this.time >= this.talkNext) {\n if (Math.random() < 0.13) {\n this.open.set(0.22);\n this.wide.set(0.95);\n this.talkNext = this.time + rand(0.22, 0.5);\n } else {\n const s = TALK_SHAPES[(Math.random() * TALK_SHAPES.length) | 0];\n this.open.set(s.o);\n this.wide.set(s.w);\n this.talkNext = this.time + rand(0.07, 0.19);\n }\n }\n const b = this.bl;\n if (b.active) {\n b.t += h;\n if (b.t >= b.dur + b.hold) {\n b.active = false;\n this.scheduleBlink();\n if (b.queue > 0) {\n b.queue--;\n this.after(0.13, () => this.startBlink(0.3));\n }\n }\n } else if (this.time >= b.next) {\n this.startBlink(this.st.blinkDur || 0.32);\n }\n for (const s of this.springs) s.step(h);\n }\n\n blinkVal() {\n const b = this.bl;\n if (!b.active) return 1;\n const closeD = b.dur * 0.42,\n openD = b.dur * 0.58;\n let t = b.t;\n if (t < closeD) return 1 - (1 - b.min) * smooth(t / closeD);\n t -= closeD;\n if (t < b.hold) return b.min;\n t -= b.hold;\n if (t < openD) return b.min + (1 - b.min) * smooth(t / openD);\n return 1;\n }\n\n /* ---------- scheduling ---------- */\n after(d: number, fn: () => void) {\n this.timers.push({ at: this.time + d, fn });\n }\n\n cancel(ch: string) {\n this.gen[ch] = (this.gen[ch] || 0) + 1;\n }\n\n seq(ch: string, steps: SequenceStep[], loop = false) {\n const gen = (this.gen[ch] = (this.gen[ch] || 0) + 1);\n const run = (i: number) => {\n if (this.gen[ch] !== gen) return;\n if (i >= steps.length) {\n if (loop) run(0);\n return;\n }\n const st = steps[i];\n if (st.do) st.do();\n const w = Array.isArray(st.wait)\n ? rand(st.wait[0], st.wait[1])\n : st.wait || 0;\n this.after(w, () => run(i + 1));\n };\n run(0);\n }\n\n loopEvents() {\n if (!this.st.events || !this.st.gap) return;\n const gap = this.st.gap,\n events = this.st.events;\n const gen = (this.gen.ev = (this.gen.ev || 0) + 1);\n const tick = () => {\n if (gen !== this.gen.ev) return;\n const fn = this.acts[wpick(events)];\n if (fn) fn();\n this.after(rand(gap[0], gap[1]), tick);\n };\n this.after(rand(gap[0] * 0.5, gap[1] * 0.7), tick);\n }\n\n _buildActions(): Record void> {\n return {\n gazeShift: () => {\n if (this.mouse) return;\n const b = this.gazeBias;\n this.seq(\"gaze\", [\n {\n do: () =>\n this.gazeTo(b[0] + rand(-0.35, 0.35), b[1] + rand(-0.25, 0.25)),\n wait: [0.8, 2.2],\n },\n {\n do: () => {\n if (Math.random() < 0.7) this.gazeTo(b[0], b[1]);\n },\n },\n ]);\n },\n gazeShiftDown: () => {\n if (this.mouse) return;\n const b = this.gazeBias;\n this.seq(\"gaze\", [\n {\n do: () =>\n this.gazeTo(\n b[0] + rand(-0.25, 0.25),\n clamp(b[1] + rand(0, 0.2), -1, 1)\n ),\n wait: [1, 2.5],\n },\n { do: () => this.gazeTo(b[0], b[1]) },\n ]);\n },\n glance: () => {\n if (this.mouse) return;\n const s = Math.random() < 0.5 ? -1 : 1,\n b = this.gazeBias;\n this.seq(\"gaze\", [\n { do: () => this.gazeTo(0.55 * s, b[1]), wait: [0.6, 1.4] },\n { do: () => this.gazeTo(b[0], b[1]) },\n ]);\n },\n headDrift: () =>\n this.headPulse(\n { rot: rand(-3.5, 3.5), x: rand(-1.4, 1.4), y: rand(-0.8, 0.8) },\n rand(1, 2.2)\n ),\n nodOnce: () => this.nod(),\n doubleBlink: () => this.doubleBlink(),\n slowBlink: () => this.slowBlink(),\n microSmile: () => {\n if (this.expressionName !== \"neutral\" || this.talking) return;\n this.seq(\"mouth\", [\n { do: () => this.setExpression(\"smallSmile\", false), wait: [1.2, 2] },\n { do: () => this.setExpression(\"neutral\", false) },\n ]);\n },\n smilePulse: () =>\n this.seq(\"mouth\", [\n { do: () => this.setExpression(\"bigSmile\", false), wait: [0.9, 1.6] },\n { do: () => this.setExpression(\"smile\", false) },\n ]),\n eyeRoll: () => this.eyeRoll(),\n bounce: () =>\n this.seq(\"head\", [\n { do: () => this.hy.set(this.hb.y - 2), wait: 0.16 },\n { do: () => this.hy.set(this.hb.y) },\n ]),\n sigh: () =>\n this.seq(\"head\", [\n { do: () => this.hy.set(this.hb.y + 1.4), wait: [1, 1.6] },\n { do: () => this.hy.set(this.hb.y) },\n ]),\n surprisedPulse: () => {\n if (this.stateName !== \"surprised\" || this.talking) return;\n const e = EXPR.surprised;\n const baseOpen = e.open ?? 1,\n baseWide = e.wide ?? 1;\n const baseMy = e.my ?? 0,\n baseEye = (this.st?.eye ?? 1) * (e.eye ?? 1);\n if (Math.random() < 0.2)\n this.after(rand(0.04, 0.12), () => this.startBlink(rand(0.24, 0.32)));\n this.seq(\"surprisePulse\", [\n {\n do: () => {\n this.open.set(baseOpen * rand(1.08, 1.16));\n this.wide.set(baseWide * rand(0.91, 0.97));\n this.my.set(baseMy + rand(-0.5, -0.2));\n this.eyeS.set(baseEye * rand(1.03, 1.08));\n },\n wait: [0.16, 0.24],\n },\n {\n do: () => {\n this.open.set(baseOpen);\n this.wide.set(baseWide);\n this.my.set(baseMy);\n this.eyeS.set(baseEye);\n },\n wait: [0.24, 0.36],\n },\n ]);\n },\n longClose: () => this.startBlink(1.0, rand(0.2, 0.5), 0.04),\n swaySlow: () => this.headPulse({ rot: rand(-2.5, 2.5) }, rand(1.5, 2.8)),\n /* Natural up/down listening nod — hy drives face-plate TY directly. */\n talkingRotate: () => {\n if (!this.talking) return;\n const dir = Math.random() < 0.5 ? -1 : 1;\n this.seq(\"head\", [\n {\n do: () => this.hr.set(this.hb.rot + dir * rand(1.2, 2.2)),\n wait: [0.25, 0.55],\n },\n { do: () => this.hr.set(this.hb.rot) },\n ]);\n },\n excitedRotate: () => {\n if (this.stateName !== \"excited\") return;\n const dir = Math.random() < 0.5 ? -1 : 1;\n this.seq(\"head\", [\n {\n do: () => {\n this.hy.set(this.hb.y - 1.5);\n this.hr.set(this.hb.rot + dir * 2.5);\n },\n wait: 0.12,\n },\n {\n do: () => {\n this.hy.set(this.hb.y);\n this.hr.set(this.hb.rot - dir * 1.2);\n },\n wait: 0.14,\n },\n {\n do: () => {\n this.hy.set(this.hb.y + 0.6);\n this.hr.set(this.hb.rot + dir * 0.8);\n },\n wait: 0.1,\n },\n {\n do: () => {\n this.hy.set(this.hb.y);\n this.hr.set(this.hb.rot);\n },\n },\n ]);\n },\n /* Slow mournful left/right sway for sad state */\n sadSway: () => {\n if (this.stateName !== \"sad\") return;\n const dir = Math.random() < 0.5 ? -1 : 1;\n const amt = rand(3.5, 5.5);\n this.seq(\"head\", [\n {\n do: () => {\n this.hr.set(this.hb.rot + dir * amt);\n this.yaw.set(dir * 0.09);\n },\n wait: [1.0, 1.6],\n },\n {\n do: () => {\n this.hr.set(this.hb.rot);\n this.yaw.set(0);\n },\n wait: [0.8, 1.2],\n },\n {\n do: () => {\n this.hr.set(this.hb.rot - dir * amt * 0.6);\n this.yaw.set(-dir * 0.05);\n },\n wait: [1.0, 1.5],\n },\n {\n do: () => {\n this.hr.set(this.hb.rot);\n this.yaw.set(0);\n },\n },\n ]);\n },\n /* Slow, deliberate 2-beat listening nod — natural human understanding response */\n listeningNod: () => {\n if (this.stateName !== \"listening\") return;\n const depth = rand(2.2, 2.8);\n const tilt = rand(-1.5, 1.5);\n const prev = this.expressionName;\n this.seq(\"head\", [\n {\n do: () => {\n this.hy.set(this.hb.y + depth);\n this.hr.set(this.hb.rot + tilt);\n this.setExpression(\"smile\", false);\n },\n wait: [0.28, 0.36],\n },\n { do: () => this.hy.set(this.hb.y - 0.25), wait: [0.18, 0.24] },\n {\n do: () => {\n this.hy.set(this.hb.y + depth * 0.65);\n this.hr.set(this.hb.rot + tilt * 0.5);\n },\n wait: [0.26, 0.32],\n },\n {\n do: () => {\n this.hy.set(this.hb.y);\n this.hr.set(this.hb.rot);\n this.setExpression(prev, false);\n },\n },\n ]);\n },\n switchSide: () => {\n this.thinkSide = -(this.thinkSide || 1);\n const s = this.thinkSide;\n if (!this.mouse) this.gazeTo(0.45 * s, this.gazeBias[1]);\n this.hb.rot = 5 * s;\n this.hr.set(this.hb.rot);\n },\n listeningMouthShift: () => {\n if (this.talking) return;\n const nextExpr = wpick([\n [5, \"smallSmile\"],\n [3, \"neutral\"],\n [1, \"worried\"],\n ]) as ExpressionName;\n this.seq(\"mouth\", [\n {\n do: () => {\n this.setExpression(nextExpr, false);\n this.open.set(rand(0.94, 1.14));\n this.wide.set(rand(0.93, 1.05));\n this.cy.set(rand(-0.1, 0.35));\n },\n wait: [1, 1.9],\n },\n { do: () => this.setExpression(\"smallSmile\", false) },\n ]);\n },\n };\n }\n\n loadingLoop() {\n this.seq(\n \"loop\",\n [\n {\n do: () => this.gazeTo(-0.55 + rand(-0.08, 0.08), rand(-0.1, 0.05)),\n wait: [0.5, 0.9],\n },\n { do: () => this.gazeTo(0, 0), wait: [0.25, 0.5] },\n { do: () => this.startBlink(0.3), wait: [0.35, 0.6] },\n {\n do: () => this.gazeTo(0.55 + rand(-0.08, 0.08), rand(-0.1, 0.05)),\n wait: [0.5, 0.9],\n },\n { do: () => this.gazeTo(0, 0), wait: [0.25, 0.45] },\n ],\n true\n );\n }\n\n surprisedLoop() {\n const expr = EXPR.surprised;\n const baseOpen = expr.open ?? 2;\n const baseWide = expr.wide ?? 0.52;\n const baseMy = expr.my ?? -3.2;\n const baseEye = (this.st?.eye ?? 1) * (expr.eye ?? 1);\n this.seq(\n \"loop\",\n [\n {\n do: () => {\n this.open.set(baseOpen * rand(1.07, 1.14));\n this.wide.set(baseWide * rand(0.9, 0.97));\n this.my.set(baseMy + rand(-0.5, -0.1));\n this.eyeS.set(baseEye * rand(1.04, 1.08));\n },\n wait: [0.18, 0.26],\n },\n {\n do: () => {\n this.open.set(baseOpen);\n this.wide.set(baseWide);\n this.my.set(baseMy);\n this.eyeS.set(baseEye);\n },\n wait: [1.3, 2.2],\n },\n ],\n true\n );\n }\n confusedLoop() {\n this.seq(\n \"loop\",\n [\n {\n do: () => {\n this.gazeTo(-0.6, -0.1);\n this.hb.rot = -5;\n this.hr.set(-5);\n this.yaw.set(-0.25);\n },\n wait: [0.9, 1.5],\n },\n {\n do: () => {\n this.gazeTo(0.6, -0.1);\n this.hb.rot = 5;\n this.hr.set(5);\n this.yaw.set(0.25);\n },\n wait: [0.9, 1.5],\n },\n {\n do: () => {\n this.gazeTo(0.1, 0);\n this.hb.rot = rand(-7, 7);\n this.hr.set(this.hb.rot);\n this.yaw.set(0);\n if (Math.random() < 0.5) this.startBlink(0.3);\n },\n wait: [0.8, 1.4],\n },\n ],\n true\n );\n }\n\n /* ---------- state manager ---------- */\n setState(name: CharacterStateName) {\n const st = STATES[name];\n if (!st) return;\n this.stateName = name;\n this.st = st;\n this.cancel(\"ev\");\n this.cancel(\"loop\");\n this.cancel(\"gaze\");\n this.cancel(\"head\");\n this.cancel(\"mouth\");\n this.cancel(\"surprisePulse\");\n this.timers = [];\n this.bl.queue = 0;\n this.thinkSide = undefined;\n this.roll = null;\n this.hb = {\n x: st.head?.x || 0,\n y: st.head?.y || 0,\n rot: st.head?.rot || 0,\n };\n this.hx.set(this.hb.x);\n this.hy.set(this.hb.y);\n this.hr.set(this.hb.rot);\n this.yaw.set(0);\n this.sleepyMorph.set(name === \"sleepy\" ? 1 : 0);\n this.loadingMix.set(name === \"loading\" ? 1 : 0);\n this.wavePulse.set(name === \"listening\" ? 1 : 0);\n if (name === \"writing\") this.writingT0 = this.time;\n this.gazeBias = st.gaze || [0, 0];\n if (!this.mouse) this.gazeTo(this.gazeBias[0], this.gazeBias[1]);\n this.setExpression(st.expr || \"neutral\", false);\n if (st.talk && !this.talking) this.startTalking(false);\n else if (!st.talk && this.talking) this.stopTalking(false);\n this.mAmp.set(st.micro?.amp ?? 0.5);\n this.microSpeed = st.micro?.speed ?? 1;\n this.scheduleBlink();\n if (st.enter) st.enter(this);\n if (st.loop === \"loadingLoop\") this.loadingLoop();\n else if (st.loop === \"confusedLoop\") this.confusedLoop();\n else if (st.events) this.loopEvents();\n if (this.onChange) this.onChange();\n }\n\n setExpression(name: ExpressionName, notify = true) {\n const E = EXPR[name];\n if (!E) return;\n if (notify) this.cancel(\"mouth\");\n this.expressionName = name;\n this.cy.set(E.cy || 0);\n this.cx.set(E.cx || 0);\n this.cs.set(E.cs ?? 1);\n this.asym.set(E.asym || 0);\n this.mx.set(E.mx || 0);\n this.my.set(E.my || 0);\n if (this.talking) this.cs.set(0);\n if (!this.talking) {\n this.open.set(E.open ?? 1);\n this.wide.set(E.wide ?? 1);\n }\n this.eyeS.set((this.st?.eye ?? 1) * (E.eye ?? 1));\n this.lid.set((this.st?.lid ?? 1) * (E.lid ?? 1));\n if (notify && this.onChange) this.onChange();\n }\n\n /* ---------- blink ---------- */\n scheduleBlink() {\n const r = this.st?.blink || [2.5, 6];\n this.bl.next = this.time + rand(r[0], r[1]);\n }\n startBlink(dur = 0.32, hold = 0, min = 0.05) {\n const b = this.bl;\n if (b.active) {\n b.queue++;\n return;\n }\n b.active = true;\n b.t = 0;\n b.dur = dur;\n b.hold = hold;\n b.min = min;\n }\n blink() {\n this.startBlink(0.32);\n }\n doubleBlink() {\n this.startBlink(0.3);\n this.bl.queue = Math.max(this.bl.queue, 1);\n }\n slowBlink() {\n this.startBlink(0.85, 0.12, 0.04);\n }\n\n /* ---------- gaze ---------- */\n gazeTo(x: number, y: number) {\n this.gx.set(clamp(x, -1, 1));\n this.gy.set(clamp(y, -1, 1));\n }\n look(dir: string) {\n const d = DIRS[dir as DirectionName];\n if (d) {\n this.cancel(\"gaze\");\n this.gazeTo(d[0], d[1]);\n }\n }\n setGazeTarget(o: { x: number; y: number }) {\n this.cancel(\"gaze\");\n this.gazeTo(o.x, o.y);\n }\n eyeRoll() {\n this.cancel(\"gaze\");\n this.roll = { t0: this.time, dur: 1.5, bx: this.gx.t, by: this.gy.t };\n this.gx.w = 16;\n this.gy.w = 16;\n }\n\n /* ---------- head ---------- */\n headPulse(d: Partial, hold: number) {\n this.seq(\"head\", [\n {\n do: () => {\n if (d.rot != null) this.hr.set(this.hb.rot + d.rot);\n if (d.x != null) this.hx.set(this.hb.x + d.x);\n if (d.y != null) this.hy.set(this.hb.y + d.y);\n },\n wait: hold,\n },\n {\n do: () => {\n this.hr.set(this.hb.rot);\n this.hx.set(this.hb.x);\n this.hy.set(this.hb.y);\n },\n },\n ]);\n }\n nod() {\n this.seq(\"head\", [\n { do: () => this.hy.set(this.hb.y + 3), wait: 0.18 },\n { do: () => this.hy.set(this.hb.y) },\n ]);\n }\n doubleNod() {\n this.seq(\"head\", [\n { do: () => this.hy.set(this.hb.y + 3), wait: 0.18 },\n { do: () => this.hy.set(this.hb.y - 0.4), wait: 0.2 },\n { do: () => this.hy.set(this.hb.y + 2.6), wait: 0.18 },\n { do: () => this.hy.set(this.hb.y) },\n ]);\n }\n shakeHead() {\n this.seq(\"head\", [\n {\n do: () => {\n this.yaw.set(-0.6);\n this.hx.set(this.hb.x - 1.5);\n },\n wait: 0.22,\n },\n {\n do: () => {\n this.yaw.set(0.6);\n this.hx.set(this.hb.x + 1.5);\n },\n wait: 0.22,\n },\n {\n do: () => {\n this.yaw.set(-0.3);\n this.hx.set(this.hb.x - 0.8);\n },\n wait: 0.2,\n },\n {\n do: () => {\n this.yaw.set(0);\n this.hx.set(this.hb.x);\n },\n },\n ]);\n }\n tiltLeft() {\n this.headPulse({ rot: -7 }, 1.3);\n }\n tiltRight() {\n this.headPulse({ rot: 7 }, 1.3);\n }\n /* Triple down-nod with spring rebound — human \"yes\" */\n yesNod() {\n const prev = this.expressionName;\n this.seq(\"head\", [\n {\n do: () => {\n this.hy.set(this.hb.y + 4.0);\n this.setExpression(\"smile\", false);\n },\n wait: 0.22,\n },\n { do: () => this.hy.set(this.hb.y - 0.9), wait: 0.18 },\n { do: () => this.hy.set(this.hb.y + 3.2), wait: 0.2 },\n { do: () => this.hy.set(this.hb.y - 0.6), wait: 0.17 },\n { do: () => this.hy.set(this.hb.y + 2.0), wait: 0.19 },\n {\n do: () => {\n this.hy.set(this.hb.y);\n this.setExpression(prev, false);\n },\n },\n ]);\n }\n /* Fast decaying left-right shake with eye blinks — human \"no\" */\n noShake() {\n const prev = this.expressionName;\n this.setExpression(\"worried\", false);\n this.startBlink(0.18);\n this.seq(\"head\", [\n {\n do: () => {\n this.hr.set(this.hb.rot - 5.5);\n this.yaw.set(-0.18);\n },\n wait: 0.1,\n },\n {\n do: () => {\n this.hr.set(this.hb.rot + 5.5);\n this.yaw.set(0.18);\n },\n wait: 0.1,\n },\n {\n do: () => {\n this.hr.set(this.hb.rot - 4.5);\n this.yaw.set(-0.14);\n this.startBlink(0.2);\n },\n wait: 0.1,\n },\n {\n do: () => {\n this.hr.set(this.hb.rot + 4.5);\n this.yaw.set(0.14);\n },\n wait: 0.1,\n },\n {\n do: () => {\n this.hr.set(this.hb.rot - 2.5);\n this.yaw.set(-0.08);\n },\n wait: 0.1,\n },\n {\n do: () => {\n this.hr.set(this.hb.rot + 1.5);\n this.yaw.set(0.05);\n this.startBlink(0.18);\n },\n wait: 0.1,\n },\n {\n do: () => {\n this.hr.set(this.hb.rot);\n this.yaw.set(0);\n this.setExpression(prev, false);\n },\n },\n ]);\n }\n nodUp() {\n this.seq(\"head\", [\n { do: () => this.hy.set(this.hb.y - 3), wait: 0.8 },\n { do: () => this.hy.set(this.hb.y) },\n ]);\n }\n // hr ≈ -5.38 → fpTX ≈ +7; hy ≈ ±1.77 → fpTY ≈ ±5 — matches diagonal nodding SVG keyframes\n nodTopLeft() {\n this.headPulse({ rot: -5.38, y: -1.77 }, 0.8);\n }\n nodTopRight() {\n this.headPulse({ rot: 5.38, y: -1.77 }, 0.8);\n }\n nodBottomLeft() {\n this.headPulse({ rot: -5.38, y: 1.77 }, 0.8);\n }\n nodBottomRight() {\n this.headPulse({ rot: 5.38, y: 1.77 }, 0.8);\n }\n\n /* ---------- expressions (convenience) ---------- */\n smile() {\n this.setExpression(\"smile\");\n }\n smallSmile() {\n this.setExpression(\"smallSmile\");\n }\n bigSmile() {\n this.setExpression(\"bigSmile\");\n }\n sad() {\n this.setExpression(\"sad\");\n }\n worried() {\n this.setExpression(\"worried\");\n }\n surprised() {\n this.setExpression(\"surprised\");\n }\n think() {\n this.setExpression(\"thinking\");\n }\n confusedFace() {\n this.setExpression(\"confused\");\n }\n\n /* ---------- talking ---------- */\n startTalking(notify = true) {\n if (this.talking) return;\n this.talking = true;\n this.talkNext = this.time;\n this.cs.set(0);\n this._ow = [this.open.w, this.wide.w];\n this.open.w = 24;\n this.wide.w = 20;\n if (notify && this.onChange) this.onChange();\n }\n stopTalking(notify = true) {\n if (!this.talking) return;\n this.talking = false;\n if (this._ow) {\n this.open.w = this._ow[0];\n this.wide.w = this._ow[1];\n }\n const E = EXPR[this.expressionName];\n this.open.set(E.open ?? 1);\n this.wide.set(E.wide ?? 1);\n this.cs.set(E.cs ?? 1);\n if (notify && this.onChange) this.onChange();\n }\n\n reset() {\n this.stopTalking(false);\n this.setState(\"idle\");\n }\n\n /* ---------- pointer tracking ---------- */\n setMouseTracking(on: boolean) {\n this.mouse = !!on;\n if (on && !this._pm) {\n this._pm = (e: PointerEvent) => {\n const r = this.svg.getBoundingClientRect();\n const nx = clamp(\n (e.clientX - (r.left + r.width / 2)) / (r.width / 2),\n -1,\n 1\n );\n const ny = clamp(\n (e.clientY - (r.top + r.height / 2)) / (r.height / 2),\n -1,\n 1\n );\n /* Gaze follows cursor with safety margin to prevent edge compression */\n this.gazeTo(nx * 0.35, ny * 0.35);\n this.hr.set(this.hb.rot - nx * 6.0);\n this.hy.set(this.hb.y + ny * 2.0);\n this.yaw.set(-nx * 0.18);\n };\n window.addEventListener(\"pointermove\", this._pm);\n } else if (!on && this._pm) {\n window.removeEventListener(\"pointermove\", this._pm);\n this._pm = null;\n this.gazeTo(this.gazeBias[0], this.gazeBias[1]);\n this.hr.set(this.hb.rot);\n this.hy.set(this.hb.y);\n this.yaw.set(0);\n }\n }\n\n /* ---------- renderer */\n /* */\n /* Key difference from CharacterEngine: instead of rotating/translating */\n /* the head group, we translate the face-plate group to show nodding. */\n /* */\n /* Face-plate TX/TY are derived from hr + yaw + hx / hy springs, */\n /* scaled to match the Filly-New-Series keyframe nodding positions: */\n /* ±9 SVG units horizontal (left/right nod) */\n /* ±8.5 SVG units vertical (up/down nod) */\n /* -------------------------------------------------------------------- */\n render() {\n const G = FILLY_GEOM;\n const ph = this.microPhase,\n a = this.mAmp.x;\n const mX = a * 0.55 * (Math.sin(ph * 1.1) + 0.4 * Math.sin(ph * 2.3 + 1.7));\n const mY =\n a * 0.45 * (Math.sin(ph * 0.9 + 0.8) + 0.4 * Math.sin(ph * 2.1 + 0.3));\n const mR = a * 0.7 * Math.sin(ph * 0.65 + 2);\n\n /* Face-plate translation — maps spring values to nodding positions.\n Negation on X: hr < 0 (tilt left) → face plate shifts right (+x),\n matching the Filly-New-Series left-nod where inner panel goes right. */\n let fpTY = this.hy.x * FP.HY + mY;\n if (this.talking) fpTY += (1 - this.open.x) * 0.5;\n const fpTX = clamp(\n -(this.hr.x * FP.HR + this.yaw.x * FP.YAW + this.hx.x * FP.HX) +\n mX +\n mR * FP.HR,\n -FP.MAX_TX,\n FP.MAX_TX\n );\n const fpTYc = clamp(fpTY, -FP.MAX_TY, FP.MAX_TY);\n\n /* Whole-head rotation: hr drives both face-plate translate and a subtle head rotate.\n Mask holes carry the same rotation so the cutout stays aligned with the shell. */\n const headRotDeg = this.hr.x * 0.5 + mR * 0.6;\n this.head.setAttribute(\n \"transform\",\n `rotate(${headRotDeg.toFixed(2)} ${G.CX} ${G.CY})`\n );\n if (this.shell) {\n this.shell.setAttribute(\n \"transform\",\n `translate(${(-fpTX * 0.35).toFixed(2)} ${(-fpTYc * 0.28).toFixed(2)})`\n );\n }\n if (this.faceplate) {\n this.faceplate.setAttribute(\n \"transform\",\n `translate(${fpTX.toFixed(2)} ${fpTYc.toFixed(2)})`\n );\n }\n if (this.maskFace) {\n this.maskFace.setAttribute(\n \"transform\",\n this.stateName === \"writing\"\n ? `rotate(0 ${G.CX} ${G.CY})`\n : `rotate(${headRotDeg.toFixed(2)} ${G.CX} ${G.CY}) translate(${fpTX.toFixed(2)} ${fpTYc.toFixed(2)})`\n );\n }\n this.face.setAttribute(\"transform\", \"translate(0 0)\");\n\n /* Gaze: eyes lead the nod (above face center, larger arc) */\n const rawGx = this.gx.x * G.RX;\n const rawGy = this.gy.x * G.RY;\n this.eyesG.setAttribute(\n \"transform\",\n `translate(${(rawGx + fpTX * 0.1).toFixed(2)} ${(rawGy + fpTYc * 0.1).toFixed(2)})`\n );\n\n /* Eye blink/scale — flat art style: uniform x-scale, no yaw squeeze */\n const openV = clamp(this.lid.x, 0.02, 1.15) * this.blinkVal();\n const es = this.eyeS.x;\n const sy = Math.max(0.045, es * openV);\n const esx = Math.max(0.1, es);\n this.eyeL.setAttribute(\n \"transform\",\n `translate(${G.ELX} ${G.ELY}) scale(${esx.toFixed(3)} ${sy.toFixed(3)}) translate(${-G.ELX} ${-G.ELY})`\n );\n this.eyeR.setAttribute(\n \"transform\",\n `translate(${G.ERX} ${G.ERY}) scale(${esx.toFixed(3)} ${sy.toFixed(3)}) translate(${-G.ERX} ${-G.ERY})`\n );\n\n /* Mouth: chin resists the nod slightly (closer to neck pivot, shorter arc) */\n const mtx = rawGx * 0.28 + this.mx.x;\n const mty = this.my.x + rawGy * 0.18;\n this.mouthG.setAttribute(\n \"transform\",\n `translate(${(mtx + fpTX * 0.04).toFixed(2)} ${(mty - fpTYc * 0.05).toFixed(2)})`\n );\n\n const sleepyBlend = clamp(this.sleepyMorph.x, 0, 1);\n const sleepyMouthY = 1 - 0.22 * sleepyBlend;\n const ow = Math.max(0.06, this.open.x) * sleepyMouthY;\n const ww = Math.max(0.2, this.wide.x);\n const barAY = ow > 1 ? G.MBY_TOP : G.MBY;\n const csv = Math.max(0, this.cs.x);\n const sleepyCornerY = 1 - 0.38 * sleepyBlend;\n const cornerScaleY = csv * sleepyCornerY;\n const cvis = csv < 0.04 ? \"hidden\" : \"visible\";\n const lty = this.cy.x - this.asym.x;\n const rty = this.cy.x + this.asym.x;\n const cornerBottomY = G.MBY_TOP + 0.01;\n const loadingMix = clamp(this.loadingMix.x, 0, 1);\n const isLoading = loadingMix > 0.001;\n const isWriting = this.stateName === \"writing\";\n const loadH = G.MBH,\n loadGap = G.MBW * 0.05;\n const loadSideW = loadH,\n loadMidW = loadSideW * 2;\n const loadLayoutW = loadSideW * 2 + loadMidW + loadGap * 2;\n const loadX0 = G.MBX - loadLayoutW / 2,\n loadY = G.MBY - loadH / 2;\n const lerp = (a: number, b: number, t: number) => a + (b - a) * t;\n const blend = loadingMix;\n\n const barBaseX = G.MBX - G.MBW / 2;\n const normalBarX = G.MBX + (barBaseX - G.MBX) * ww;\n const normalBarY = barAY + (G.MBY_TOP - barAY) * ow;\n const normalBarW = G.MBW * ww,\n normalBarH = G.MBH * ow;\n const normalLeftX = G.MLX + (-G.EW / 2) * csv + -this.cx.x;\n const normalRightX = G.MRX + (-G.EW / 2) * csv + this.cx.x;\n const normalCornerW = G.EW * csv;\n /* Corner height is fixed at default bar height — only visibility (cs) changes it */\n const normalCornerH = G.MBH * cornerScaleY;\n const normalLeftY = cornerBottomY - normalCornerH + lty;\n const normalRightY = cornerBottomY - normalCornerH + rty;\n\n this.mL.setAttribute(\"transform\", \"none\");\n this.bar.setAttribute(\"transform\", \"none\");\n this.mR.setAttribute(\"transform\", \"none\");\n this.mL.setAttribute(\"x\", lerp(normalLeftX, loadX0, blend).toFixed(2));\n this.mL.setAttribute(\"y\", lerp(normalLeftY, loadY, blend).toFixed(2));\n this.mL.setAttribute(\n \"width\",\n lerp(normalCornerW, loadSideW, blend).toFixed(2)\n );\n this.mL.setAttribute(\n \"height\",\n lerp(normalCornerH, loadH, blend).toFixed(2)\n );\n this.bar.setAttribute(\n \"x\",\n lerp(normalBarX, loadX0 + loadSideW + loadGap, blend).toFixed(2)\n );\n this.bar.setAttribute(\"y\", lerp(normalBarY, loadY, blend).toFixed(2));\n this.bar.setAttribute(\n \"width\",\n lerp(normalBarW, loadMidW, blend).toFixed(2)\n );\n this.bar.setAttribute(\"height\", lerp(normalBarH, loadH, blend).toFixed(2));\n this.mR.setAttribute(\n \"x\",\n lerp(\n normalRightX,\n loadX0 + loadSideW + loadGap + loadMidW + loadGap,\n blend\n ).toFixed(2)\n );\n this.mR.setAttribute(\"y\", lerp(normalRightY, loadY, blend).toFixed(2));\n this.mR.setAttribute(\n \"width\",\n lerp(normalCornerW, loadSideW, blend).toFixed(2)\n );\n this.mR.setAttribute(\n \"height\",\n lerp(normalCornerH, loadH, blend).toFixed(2)\n );\n\n /* Loading shimmer */\n const phase = (this.time * 2.3) % 3;\n const glow = (i: number) => {\n const d = Math.abs(phase - i);\n return Math.max(0, 1 - Math.min(d, 3 - d));\n };\n const base = 0.22,\n span = 0.78;\n this.mL.setAttribute(\n \"opacity\",\n (1 - blend + (base + glow(0) * span) * blend).toFixed(3)\n );\n this.bar.setAttribute(\n \"opacity\",\n (1 - blend + (base + glow(1) * span) * blend).toFixed(3)\n );\n this.mR.setAttribute(\n \"opacity\",\n (1 - blend + (base + glow(2) * span) * blend).toFixed(3)\n );\n\n this.bar.setAttribute(\"visibility\", isWriting ? \"hidden\" : \"visible\");\n this.mL.setAttribute(\n \"visibility\",\n isWriting ? \"hidden\" : isLoading ? \"visible\" : cvis\n );\n this.mR.setAttribute(\n \"visibility\",\n isWriting ? \"hidden\" : isLoading ? \"visible\" : cvis\n );\n this.face.setAttribute(\"visibility\", isWriting ? \"hidden\" : \"visible\");\n this.eyesG.setAttribute(\"visibility\", isWriting ? \"hidden\" : \"visible\");\n if (this.nose)\n this.nose.setAttribute(\"visibility\", isWriting ? \"hidden\" : \"visible\");\n if (this.shell)\n this.shell.setAttribute(\"visibility\", isWriting ? \"hidden\" : \"visible\");\n\n /* Writing stroke animation */\n if (this.wG && this.wP) {\n this.wG.setAttribute(\"visibility\", isWriting ? \"visible\" : \"hidden\");\n if (isWriting) {\n const spec = WRITING_STROKE;\n if (this.wP.getAttribute(\"d\") !== spec.d) {\n this.wP.setAttribute(\"d\", spec.d);\n try {\n this.wLen = this.wP.getTotalLength();\n } catch {\n this.wLen = 700;\n }\n }\n const len = this.wLen > 0 ? this.wLen : 700;\n const seg = len * spec.segmentRatio,\n gap = Math.max(1, len - seg);\n const baseSpeed = len * spec.speedRatio;\n const omega = 2 * Math.PI * spec.easeHz;\n const wt = Math.max(0, this.time - this.writingT0);\n const easedTravel =\n baseSpeed * wt +\n ((baseSpeed * spec.easeAmount) / omega) * Math.sin(omega * wt);\n this.wG.setAttribute(\"transform\", writingStrokeTransform(spec));\n this.wP.setAttribute(\"stroke-width\", String(spec.stroke));\n this.wP.setAttribute(\n \"stroke-dasharray\",\n `${seg.toFixed(2)} ${gap.toFixed(2)}`\n );\n this.wP.setAttribute(\n \"stroke-dashoffset\",\n (-(easedTravel % len)).toFixed(2)\n );\n this.wP.setAttribute(\"stroke-opacity\", \"0.98\");\n }\n }\n\n /* Listening audio wave: inner arc leads ripple outward — 3-arc stagger over 1.3s period */\n const wBase = clamp(this.wavePulse.x, 0, 1);\n const animateWave = (\n el: SVGGraphicsElement,\n paths: SVGElement[],\n tOffset: number\n ) => {\n el.setAttribute(\"visibility\", wBase > 0.02 ? \"visible\" : \"hidden\");\n if (wBase <= 0.02) return;\n el.setAttribute(\"opacity\", wBase.toFixed(3));\n const period = 1.3;\n const ph = ((this.time + tOffset) % period) / period;\n // paths[2]=inner leads, paths[1]=middle, paths[0]=outer lags by 20% each\n const pulse = (start: number) => {\n const t = (ph - start + 1) % 1;\n return t < 0.35 ? Math.sin((Math.PI * t) / 0.35) : 0;\n };\n if (paths[0]) paths[0].setAttribute(\"opacity\", pulse(0.4).toFixed(3));\n if (paths[1]) paths[1].setAttribute(\"opacity\", pulse(0.2).toFixed(3));\n if (paths[2]) paths[2].setAttribute(\"opacity\", pulse(0).toFixed(3));\n };\n if (this.waveL) animateWave(this.waveL, this.waveLPaths, 0);\n if (this.waveR) animateWave(this.waveR, this.waveRPaths, 0);\n }\n\n destroy() {\n cancelAnimationFrame(this._rafId);\n if (this._pm) window.removeEventListener(\"pointermove\", this._pm);\n this.cancel(\"ev\");\n this.cancel(\"loop\");\n this.cancel(\"gaze\");\n this.cancel(\"head\");\n this.cancel(\"mouth\");\n this.cancel(\"surprisePulse\");\n this.timers = [];\n }\n}\n\n/* ------------------------------------------------------------------ */\n/* React component */\n/* ------------------------------------------------------------------ */\n\nexport const FILLY_CHARACTER_STATES = [\n \"idle\",\n \"listening\",\n \"talking\",\n \"writing\",\n \"thinking\",\n \"loading\",\n \"happy\",\n \"sad\",\n \"surprised\",\n \"confused\",\n \"excited\",\n \"sleepy\",\n] as const;\n\nexport type CareFillyState = (typeof FILLY_CHARACTER_STATES)[number];\n\nexport interface CareFillyHandle {\n setState: (state: CareFillyState) => void;\n look: (dir: string) => void;\n setGazeTarget: (target: { x: number; y: number }) => void;\n blink: () => void;\n doubleBlink: () => void;\n slowBlink: () => void;\n eyeRoll: () => void;\n nod: () => void;\n yesNod: () => void;\n noShake: () => void;\n nodUp: () => void;\n nodTopLeft: () => void;\n nodTopRight: () => void;\n nodBottomLeft: () => void;\n nodBottomRight: () => void;\n doubleNod: () => void;\n shakeHead: () => void;\n tiltLeft: () => void;\n tiltRight: () => void;\n setExpression: (name: string) => void;\n startTalking: () => void;\n stopTalking: () => void;\n setMouseTracking: (on: boolean) => void;\n reset: () => void;\n}\n\nexport interface CareFillyProps extends Omit<\n React.ComponentProps<\"div\">,\n \"children\"\n> {\n /** Behavioral state preset. */\n state?: CareFillyState;\n /** When true, eyes follow the pointer. */\n mouseTracking?: boolean;\n /** CSS width for the character (e.g. \"20px\", \"2rem\"). Overrides the default size. */\n size?: string | number;\n /** CSS color for the character (e.g. \"#3b82f6\", \"oklch(70% 0.2 250)\"). Defaults to currentColor. */\n color?: string;\n}\n\nexport const CareFilly = React.forwardRef(\n function AnimatedCharacterFilly(\n {\n state = \"idle\",\n mouseTracking = false,\n size,\n color,\n className,\n style,\n ...props\n },\n ref\n ) {\n const svgRef = React.useRef(null);\n const engineRef = React.useRef(null);\n const maskId = `filly-mask-${React.useId().replace(/:/g, \"\")}`;\n\n React.useEffect(() => {\n if (!svgRef.current) return;\n const engine = new FillyEngine(svgRef.current, state);\n engineRef.current = engine;\n return () => {\n engine.destroy();\n engineRef.current = null;\n };\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, []);\n\n React.useEffect(() => {\n engineRef.current?.setState(state);\n }, [state]);\n React.useEffect(() => {\n engineRef.current?.setMouseTracking(mouseTracking);\n }, [mouseTracking]);\n\n React.useImperativeHandle(\n ref,\n () => ({\n setState: (s: CareFillyState) => engineRef.current?.setState(s),\n look: (dir: string) => engineRef.current?.look(dir),\n setGazeTarget: (t: { x: number; y: number }) =>\n engineRef.current?.setGazeTarget(t),\n blink: () => engineRef.current?.blink(),\n doubleBlink: () => engineRef.current?.doubleBlink(),\n slowBlink: () => engineRef.current?.slowBlink(),\n eyeRoll: () => engineRef.current?.eyeRoll(),\n yesNod: () => engineRef.current?.yesNod(),\n noShake: () => engineRef.current?.noShake(),\n nod: () => engineRef.current?.nod(),\n nodUp: () => engineRef.current?.nodUp(),\n nodTopLeft: () => engineRef.current?.nodTopLeft(),\n nodTopRight: () => engineRef.current?.nodTopRight(),\n nodBottomLeft: () => engineRef.current?.nodBottomLeft(),\n nodBottomRight: () => engineRef.current?.nodBottomRight(),\n doubleNod: () => engineRef.current?.doubleNod(),\n shakeHead: () => engineRef.current?.shakeHead(),\n tiltLeft: () => engineRef.current?.tiltLeft(),\n tiltRight: () => engineRef.current?.tiltRight(),\n setExpression: (name: string) =>\n engineRef.current?.setExpression(name as ExpressionName),\n startTalking: () => engineRef.current?.startTalking(),\n stopTalking: () => engineRef.current?.stopTalking(),\n setMouseTracking: (on: boolean) =>\n engineRef.current?.setMouseTracking(on),\n reset: () => engineRef.current?.reset(),\n }),\n []\n );\n\n return (\n \n {/*\n Filly-New-Series SVG structure.\n - shell: static outer rounded body (data-part=\"shell\")\n - face-plate: translates as a unit for nodding (data-part=\"face-plate\")\n - face: inner white cross-panel (data-part=\"face\")\n - nose: centre nose bridge rectangle\n - eyes: gaze group\n - mouth-group: expressions / talking / loading / writing\n */}\n \n \n {/*\n SVG mask cutout: white = show shell, black = punch hole.\n data-part=\"mask-face\" is translated each frame to match the face-plate\n position so the cutout moves with the nod animation.\n */}\n \n \n \n \n \n \n \n \n \n \n\n \n {/* Shell with animated cutout — mask holes follow face-plate translate each frame */}\n \n\n {/* Left ear audio wave — left-bowing paths point outward from left ear */}\n \n \n \n \n \n \n \n\n {/* Right ear audio wave — right-bowing paths point outward from right ear */}\n \n \n \n \n \n \n \n\n {/* Face plate — translates for nodding; eyes and mouth ride inside it */}\n \n {/* Empty group — engine uses this for visibility/transform; cutout is on the mask */}\n \n\n {/* Eyes — gaze-translated by engine on top of face-plate offset */}\n \n \n \n \n\n {/* Mouth — expressions, talking, loading shimmer, writing stroke */}\n \n \n \n \n \n \n \n \n \n \n \n \n );\n }\n);\n", + "type": "registry:component", + "target": "components/careui/care-filly.tsx" + } + ] +} \ No newline at end of file diff --git a/public/registry/care-ui/index.json b/public/registry/care-ui/index.json index 8144170..8cb3ebb 100644 --- a/public/registry/care-ui/index.json +++ b/public/registry/care-ui/index.json @@ -631,6 +631,20 @@ "registry/care-ui/carousel/carousel.tsx" ] }, + { + "name": "care-filly", + "type": "registry:ui", + "files": [ + "registry/care-ui/care-filly/care-filly.tsx" + ] + }, + { + "name": "care-filly-classic", + "type": "registry:ui", + "files": [ + "registry/care-ui/care-filly-classic/care-filly-classic.tsx" + ] + }, { "name": "card", "type": "registry:ui", @@ -737,13 +751,6 @@ "registry/care-ui/aspect-ratio/aspect-ratio.tsx" ] }, - { - "name": "animated-character", - "type": "registry:ui", - "files": [ - "registry/care-ui/animated-character/animated-character.tsx" - ] - }, { "name": "alert", "type": "registry:ui", diff --git a/src/components/app-sidebar.tsx b/src/components/app-sidebar.tsx index 31af7f3..d5f2329 100644 --- a/src/components/app-sidebar.tsx +++ b/src/components/app-sidebar.tsx @@ -21,9 +21,7 @@ import { } from "@/components/ui/studio-sidebar"; import { X } from "lucide-react"; -const TOOL_ONLY_COMPONENT_IDS = new Set([ - "animated-character", -]); +const TOOL_ONLY_COMPONENT_IDS = new Set(["animated-character"]); // Navigation data const data = { diff --git a/src/components/dynamic-main-content.tsx b/src/components/dynamic-main-content.tsx index 50de39e..5564821 100644 --- a/src/components/dynamic-main-content.tsx +++ b/src/components/dynamic-main-content.tsx @@ -72,26 +72,52 @@ const componentNavOrder = Object.keys(componentNames).filter( function PropsTable({ props, }: { - props: Array<{ name: string; type: string; description: string; default?: string }>; + props: Array<{ + name: string; + type: string; + description: string; + default?: string; + }>; }) { return (
- - - - + + + + {props.map((prop, index) => ( - - - - - + + + + + ))} @@ -180,7 +206,9 @@ function ExampleItem({ example }: { example: ComponentExample }) { {example.trailingProps && (
{example.trailingProps.title} - {example.trailingProps.description && {example.trailingProps.description}} + {example.trailingProps.description && ( + {example.trailingProps.description} + )}
)} diff --git a/src/components/search-form.tsx b/src/components/search-form.tsx index 4ffe026..dd1fe62 100644 --- a/src/components/search-form.tsx +++ b/src/components/search-form.tsx @@ -27,9 +27,7 @@ import { getComponentIds } from "@/lib/component-registry"; import { documentationPages } from "@/lib/documentation"; import { ERROR_PAGES } from "@/components/error-pages/registry"; -const TOOL_ONLY_COMPONENT_IDS = new Set([ - "animated-character", -]); +const TOOL_ONLY_COMPONENT_IDS = new Set(["animated-character"]); const navSections = [ { diff --git a/src/components/ui/attachment.tsx b/src/components/ui/attachment.tsx index eabe73c..000749c 100644 --- a/src/components/ui/attachment.tsx +++ b/src/components/ui/attachment.tsx @@ -192,7 +192,7 @@ function AttachmentGroup({ className, ...props }: React.ComponentProps<"div">) {
) {
diff --git a/src/lib/registry/care-filly-classic.tsx b/src/lib/registry/care-filly-classic.tsx index 51a997f..df156d8 100644 --- a/src/lib/registry/care-filly-classic.tsx +++ b/src/lib/registry/care-filly-classic.tsx @@ -298,10 +298,17 @@ export const animatedCharacterDoc: ComponentDoc = { description: (
  • - CareFilly{" "}(care-filly) — layered face-plate character. The face-plate translates independently, creating a parallax depth illusion on every nod. Use for prominent roles: onboarding, empty states, loading screens, full-page assistants. + CareFilly (care-filly) — layered + face-plate character. The face-plate translates independently, creating + a parallax depth illusion on every nod. Use for prominent roles: + onboarding, empty states, loading screens, full-page assistants.
  • - CareFillyClassic{" "}(care-filly-classic) — classic pixel-head character. The entire head rotates as one rigid unit. Use for compact, inline contexts: chat bubbles, search bars, status indicators. Available in light and dark variants. + CareFillyClassic (care-filly-classic) — + classic pixel-head character. The entire head rotates as one rigid unit. + Use for compact, inline contexts: chat bubbles, search bars, status + indicators. Available in light and dark{" "} + variants.
), @@ -403,12 +410,41 @@ ref.current?.noShake() // Decaying left-right shake with blinks`, title: "CareFilly props", description: 'import { CareFilly } from "@/components/ui/care-filly"', props: [ - { name: "state", type: `"idle" | "listening" | "talking" | "writing" | "thinking" | "loading" | "happy" | "sad" | "surprised" | "confused" | "excited" | "sleepy"`, description: "Behavioral state preset. Transitions blend from the current pose." }, - { name: "mouseTracking", type: "boolean", description: "Eyes smoothly follow the pointer when true; return to the state gaze when false." }, - { name: "size", type: "string | number", description: 'CSS width for the SVG (e.g. "32px", "2rem"). Overrides the default 6rem.' }, - { name: "color", type: "string", description: 'CSS color tinting the entire character via currentColor (e.g. "#3b82f6").' }, - { name: "ref", type: "CareFillyHandle", description: "Imperative API: setState, nod, yesNod, noShake, shakeHead, blink, eyeRoll, startTalking, stopTalking, setGazeTarget, setMouseTracking, reset." }, - { name: "className", type: "string", description: "Additional CSS classes on the wrapper." }, + { + name: "state", + type: `"idle" | "listening" | "talking" | "writing" | "thinking" | "loading" | "happy" | "sad" | "surprised" | "confused" | "excited" | "sleepy"`, + description: + "Behavioral state preset. Transitions blend from the current pose.", + }, + { + name: "mouseTracking", + type: "boolean", + description: + "Eyes smoothly follow the pointer when true; return to the state gaze when false.", + }, + { + name: "size", + type: "string | number", + description: + 'CSS width for the SVG (e.g. "32px", "2rem"). Overrides the default 6rem.', + }, + { + name: "color", + type: "string", + description: + 'CSS color tinting the entire character via currentColor (e.g. "#3b82f6").', + }, + { + name: "ref", + type: "CareFillyHandle", + description: + "Imperative API: setState, nod, yesNod, noShake, shakeHead, blink, eyeRoll, startTalking, stopTalking, setGazeTarget, setMouseTracking, reset.", + }, + { + name: "className", + type: "string", + description: "Additional CSS classes on the wrapper.", + }, ], }, }, @@ -474,15 +510,50 @@ useEffect(() => {
`, trailingProps: { title: "CareFillyClassic props", - description: 'import { CareFillyClassic } from "@/components/ui/care-filly-classic"', + description: + 'import { CareFillyClassic } from "@/components/ui/care-filly-classic"', props: [ - { name: "state", type: `"idle" | "listening" | "talking" | "writing" | "thinking" | "loading" | "happy" | "sad" | "surprised" | "confused" | "excited" | "sleepy"`, description: "Behavioral state preset. Transitions blend from the current pose." }, - { name: "mouseTracking", type: "boolean", description: "Eyes smoothly follow the pointer when true; return to the state gaze when false." }, - { name: "variant", type: '"light" | "dark"', description: "Selects the visual shell. light — pixel head on light background; dark — white shell with dark pixel features." }, - { name: "size", type: "string | number", description: 'CSS width for the SVG (e.g. "32px", "2rem"). Overrides the default.' }, - { name: "color", type: "string", description: 'CSS color tinting the entire character via currentColor (e.g. "#3b82f6").' }, - { name: "ref", type: "CareFillyClassicHandle", description: "Imperative API: setState, nod, shakeHead, blink, eyeRoll, startTalking, stopTalking, setGazeTarget, setMouseTracking, reset." }, - { name: "className", type: "string", description: "Additional CSS classes on the wrapper." }, + { + name: "state", + type: `"idle" | "listening" | "talking" | "writing" | "thinking" | "loading" | "happy" | "sad" | "surprised" | "confused" | "excited" | "sleepy"`, + description: + "Behavioral state preset. Transitions blend from the current pose.", + }, + { + name: "mouseTracking", + type: "boolean", + description: + "Eyes smoothly follow the pointer when true; return to the state gaze when false.", + }, + { + name: "variant", + type: '"light" | "dark"', + description: + "Selects the visual shell. light — pixel head on light background; dark — white shell with dark pixel features.", + }, + { + name: "size", + type: "string | number", + description: + 'CSS width for the SVG (e.g. "32px", "2rem"). Overrides the default.', + }, + { + name: "color", + type: "string", + description: + 'CSS color tinting the entire character via currentColor (e.g. "#3b82f6").', + }, + { + name: "ref", + type: "CareFillyClassicHandle", + description: + "Imperative API: setState, nod, shakeHead, blink, eyeRoll, startTalking, stopTalking, setGazeTarget, setMouseTracking, reset.", + }, + { + name: "className", + type: "string", + description: "Additional CSS classes on the wrapper.", + }, ], }, }, diff --git a/src/lib/types.ts b/src/lib/types.ts index 8fb718f..e24dd56 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -7,7 +7,16 @@ export interface ComponentExample { code?: string; preview?: React.ReactNode; /** Optional props table rendered below this example, scoped to this component/variant. */ - trailingProps?: { title: string; description?: string; props: Array<{ name: string; type: string; description: string; default?: string }> }; + trailingProps?: { + title: string; + description?: string; + props: Array<{ + name: string; + type: string; + description: string; + default?: string; + }>; + }; } export interface DocumentationPage { @@ -46,7 +55,12 @@ export interface ComponentDoc { propSections?: Array<{ title: string; description?: string; - props: Array<{ name: string; type: string; description: string; default?: string }>; + props: Array<{ + name: string; + type: string; + description: string; + default?: string; + }>; }>; }
PropTypeDefaultDescription + Prop + + Type + + Default + + Description +
{prop.name}{prop.type}{prop.default || "—"}{prop.description}
+ {prop.name} + + {prop.type} + + {prop.default || "—"} + + {prop.description} +