diff --git a/.changeset/mascot-gaze.md b/.changeset/mascot-gaze.md new file mode 100644 index 000000000..391c69682 --- /dev/null +++ b/.changeset/mascot-gaze.md @@ -0,0 +1,5 @@ +--- +'@conciv/mascot': patch +--- + +The FAB robot now reacts to the pointer and to work. While the widget is closed, the eyes translate and the antenna leans toward the cursor, both easing off with distance. While the agent is working, the antenna throbs with a squash-stretch beat and a stream of binary digits rises from its tip, growing in from the tip on start and draining before it collapses on stop. Pointer-follow disarms and recenters when the panel opens or work starts, the emitter is removed on destroy, and prefers-reduced-motion keeps the static pose with no pointer-follow and no emitter. diff --git a/.fallowrc.json b/.fallowrc.json index 96b1c139e..fb8dfbbe1 100644 --- a/.fallowrc.json +++ b/.fallowrc.json @@ -28,7 +28,9 @@ "**/*.stories.tsx", "**/*.test-d.ts", "**/*.fixtures.ts", - "packages/ui-kit-chat/src/store/story-connection.ts" + "packages/ui-kit-chat/src/store/story-connection.ts", + "packages/mascot/src/story-support.tsx", + "packages/mascot/src/story-bubble-effects.tsx" ], "duplicates": { "minOccurrences": 3 diff --git a/apps/storybook/.storybook/main.ts b/apps/storybook/.storybook/main.ts index 43b8f42c5..9192cbda8 100644 --- a/apps/storybook/.storybook/main.ts +++ b/apps/storybook/.storybook/main.ts @@ -16,6 +16,7 @@ const config: StorybookConfig = { '../../../packages/ui-kit-tap/src/**/*.stories.@(js|jsx|mjs|ts|tsx)', '../../../packages/solid-diffs/src/**/*.stories.@(js|jsx|mjs|ts|tsx)', '../../../packages/solid-streamdown/src/**/*.stories.@(js|jsx|mjs|ts|tsx)', + '../../../packages/mascot/src/**/*.stories.@(js|jsx|mjs|ts|tsx)', ], addons: ['@chromatic-com/storybook', '@storybook/addon-vitest', '@storybook/addon-a11y', '@storybook/addon-docs'], framework: process.env.VITEST ? {name: 'storybook-solidjs-vite', options: {docgen: false}} : 'storybook-solidjs-vite', diff --git a/docs/superpowers/plans/2026-08-14-mascot-core-refactor.md b/docs/superpowers/plans/2026-08-14-mascot-core-refactor.md new file mode 100644 index 000000000..d50585ba6 --- /dev/null +++ b/docs/superpowers/plans/2026-08-14-mascot-core-refactor.md @@ -0,0 +1,199 @@ +# Mascot Core Implementation Plan (Phase 1 of 5) — v2 after codex review + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build the framework-free compositional core of the approved spec (`docs/superpowers/specs/2026-08-14-mascot-componentization-design.md`) on a fresh branch, with independent pose/activity/follow controllers, one shipped effect (Binary), a `connect()` surface wrappers can mirror mechanically, and a temporary `createFabRobotRig` adapter keeping both consumers untouched. + +**Architecture:** Greenfield structure, donor-verified values. Three independent controllers compose on shared part elements — pose (rest/awake expressions), follow (pointer tracking), activity (working overlay: antenna throb, eye blink, effect start/stop). `createMascot` owns lifecycle (unregistered → registered → destroyed) and config diffing. The donor branch (`feat/mascot-gaze`) supplies tuned constants, timeline values, and lifecycle guards as reference — never its three-exclusive-state machine. + +**Tech Stack:** TypeScript strict, gsap, tsdown, vitest (node env, pure math only), Playwright behavior harness checked into the package. + +## Global Constraints + +- Repo style: zero comments, no classes, no IIFEs, no `any`/`as`/`@ts-ignore`, oxfmt (no semicolons, single quotes, 120 width). +- Branch: NEW `feat/mascot-component` off `origin/main`, fresh worktree. Donor `feat/mascot-gaze` (PR #486) is read-only reference; it is never merged and never edited by this plan. Task 0 copies spec + this plan onto the new branch as its first commit and opens a draft PR. +- Approved-production donor values (owner-approved via the playground picks; everything else in the stories is NOT approved): gaze falloff 220px / eye range 3px / quickTo 0.6s power3.out; antenna lean 10° / quickTo 0.5s power3.out; lean-wrapper isolation technique; throb timeline values (scaleY 1.3 / scaleX 0.88, beats 0/0.3/1.15/1.45, elastic.out(1,0.5)); blink values (scaleY 0.1 in 0.07s power2.in, back to pose eye scale in 0.18s power2.out at beats 1.15/1.22); binary emitter (5 digits, two lanes ±3px, 9px ui-monospace 700, rise −54px 2.2s stagger 0.42, color `var(--pw-accent, #e0218a)`, pre-set opacity 0 before timeline — donor commit `740e5d55` fix); tip fractions (0.5, 0.15625); enter back.out(2.2) 0.36s / exit power2.in 0.5s; pose values from donor `rig.ts` `setOpenPose`/`playOpen`/`playClose`; `measureEmitterRoom` math (donor `story-support.tsx`). +- Deferred OUT of phase 1 (explicit): curve styles / MotionPathPlugin / `CurveStyle` type (path-curve phase, after wrappers); the other 15 effects (phase 4); reduced-motion live re-evaluation (current product behavior — sampled at transitions — is kept; a media-query listener is a later enhancement and is NOT in scope). +- No new runtime dependencies. ONE new devDependency is pre-approved by the owner for this plan: `playwright` in `packages/mascot` at the exact version `packages/embed` pins, used only by the checked-in behavior harness. +- Package publish surface this phase: `exports` stays exactly `{".": {types: "./dist/rig.d.ts", import: "./dist/rig.js"}}`; `rig.ts` remains the single tsdown entry and re-exports the new core API (`createMascot`, types) alongside the legacy adapter. No package.json exports change. Gate: `pnpm turbo run publint attw --filter=@conciv/mascot` green. Subpath restructuring is phase 2. +- Gates before every commit: `pnpm exec fallow audit --format json --quiet --explain --gate-marker agent` (fix INTRODUCED). Task-end gate: `TURBO_CONCURRENCY=70% pnpm turbo run typecheck --filter=@conciv/mascot --filter=@conciv/app --filter=site`. +- Never `git stash`. Bash cwd resets between calls: pin absolute paths to the NEW worktree. + +## Core design decisions (binding, from codex-review triage) + +1. **Controllers, not a state machine.** `parts/pose.ts`, `parts/follow.ts`, `parts/activity.ts` are independent; each owns disjoint gsap property sets on the shared elements: pose owns head `yPercent/rotation/scaleX/scaleY`, eyes `scaleX/scaleY` (resting values), antenna `rotation/scaleX/scaleY` on the antenna element; follow owns eyes `x/y` and lean-wrapper `rotation`; activity owns antenna `scaleX/scaleY` pulses layered via its own timeline (started only when pose is settled — see matrix), eye blink `scaleY` excursions returning to the pose-defined value, and effect start/stop. +2. **state × working matrix** (all four cells defined; parity harness pins the three legacy ones): + - rest+idle: rest pose; follow armed if `follow`. + - rest+working: rest pose; throb+blink+effects; follow disarmed while working (arming rule below). + - awake+idle: awake pose (perk-up animation on entry); follow armed if `follow`. + - awake+working: awake pose retained; throb+blink+effects run from the awake pose (blink returns to awake eye scale 1.06); effects anchored at the awake antenna tip. + - Follow arming rule: armed iff `follow && !working` (working owns the antenna scale channel and attention; the adapter reproduces legacy behavior with its table below). +3. **Effect ownership.** `createBinaryEmitter(stage, tip)` returns `{element, start(), stop(onRemoved), remove()}`; `stop` runs the staged exit and calls `onRemoved` after removal so the owner (activity controller) clears its reference in one place; `remove` is immediate teardown for destroy. Owner holds at most one reference; `start` while an exit is in flight kills the exit and re-enters (donor `startEmitter` guard semantics). +4. **Lifecycle.** `createMascot(initialConfig)` → service in `unregistered` state: `update()` only stores config. `registerParts({stage, head, eyes, antenna})` tears down any previous registration completely (destroy-equivalent, then re-setup: transform origins, lean wrapper, instant pose set from stored config — no entry animations on registration) and is therefore safe under React StrictMode/HMR re-attachment. `destroy()` → terminal: all controllers disposed, listener removed, emitter removed, lean wrapper unwrapped (`replaceWith`), further calls no-op. +5. **gsap ownership rule.** Every controller retains handles to every tween/timeline it creates and exposes `dispose()`; transitions kill-then-create through the owning controller only — no cross-controller `killTweensOf` by property string on shared elements except the documented pose/lean split (lean rotation lives on the wrapper element, pose rotation on the antenna element — donor technique). `destroy` = dispose all controllers, in order activity → follow → pose. +6. **connect() contract (framework-neutral).** `connect()` returns per-part getters: `getRootProps() / getHeadProps() / getEyesProps() / getAntennaProps() / getEffectHostProps()`, each `{style: Record, ref: (el: HTMLElement | null) => void}` — style as a property record (spreadable in Solid; React wrappers convert keys where needed, documented in phase 2), ref as a per-part setter so wrappers register parts individually; core applies registration when all required parts are present (root+head+eyes+antenna) and tears down when any goes null. The phase-1 adapter does NOT use `connect()`; a harness check drives it directly so the contract is executable before phase 2. +7. **Reduced motion at boundaries.** Checked (`matchMedia`) at each transition exactly as the donor does: under reduce, poses set instantly, follow never arms, activity starts no timelines and no effects. No live listener (deferred, see Global Constraints). + +--- + +### Task 0: Branch, worktree, docs seed + +- [ ] **Step 1:** `git -C /Users/omrikatz/Public/web/aidx worktree add .claude/worktrees/mascot-component -b feat/mascot-component origin/main` (then `pnpm install` in the new worktree). +- [ ] **Step 2:** Copy `docs/superpowers/specs/2026-08-14-mascot-componentization-design.md` and `docs/superpowers/plans/2026-08-14-mascot-core-refactor.md` from `feat/mascot-gaze` (e.g. `git show feat/mascot-gaze:docs/... > docs/...`) into the new worktree, commit: `docs(mascot): componentization spec and phase-1 plan`. +- [ ] **Step 3:** Push and open draft PR titled `feat(mascot): componentized core (phase 1)`, body linking PR #486 as the prototype it replaces. + +### Task 1: config.ts + path.ts with exact-value unit tests + +**Files:** + +- Create: `packages/mascot/src/core/config.ts` — types `MascotState = 'rest' | 'awake'`, `MascotConfig = {state: MascotState; working: boolean; follow: boolean}`, all approved constants from Global Constraints, `reduceMotion()`. +- Create: `packages/mascot/src/core/path.ts` — `measureEmitterRoom(anchor: {x: number; y: number}, bounds: {top: number; left: number; right: number}): {rise: number; bend: number}` — reimplemented to the donor algorithm (clamp rise to [8, 54] from headroom minus 12px margin; shortfall < 10px → bend 0; else bend = shortfall × 1.4 toward the roomier side, clamped to that side's room, sign = direction, tie → right). +- Create: `packages/mascot/test/path.test.ts`. + +- [ ] **Step 1: Write the failing test** with EXACT precomputed values (from the algorithm above, no post-hoc adjustment): + +```ts +import {describe, expect, it} from 'vitest' +import {measureEmitterRoom} from '../src/core/path.js' + +describe('measureEmitterRoom', () => { + it('full rise, no bend, ample headroom', () => { + expect(measureEmitterRoom({x: 130, y: 100}, {top: 0, left: 0, right: 260})).toEqual({rise: 54, bend: 0}) + }) + it('squeezed top-left bends right by 1.4x the shortfall', () => { + expect(measureEmitterRoom({x: 30, y: 28}, {top: 0, left: 0, right: 260})).toEqual({rise: 16, bend: 53.2}) + }) + it('squeezed top-right bends left', () => { + expect(measureEmitterRoom({x: 230, y: 28}, {top: 0, left: 0, right: 260})).toEqual({rise: 16, bend: -53.2}) + }) + it('trivial squeeze stays straight', () => { + expect(measureEmitterRoom({x: 130, y: 58}, {top: 0, left: 0, right: 260})).toEqual({rise: 46, bend: 0}) + }) +}) +``` + +Derivations, so the implementer can check the tests are right before running: headroom = y − top − 12; rise = clamp(headroom, 8, 54). y=100 → 88 → 54, shortfall 0. y=28 → 16, shortfall 38 → bend 38×1.4 = 53.2, left room = 30−12 = 18 < right room = 260−30−12 = 218 → +53.2 (clamped to 218, no-op). x=230 mirror → −53.2. y=58 → 46, shortfall 8 < 10 → 0. + +- [ ] **Step 2:** Run `cd /packages/mascot && pnpm exec vitest run test/path.test.ts`. Expected: FAIL (module not found). +- [ ] **Step 3:** Implement both files. If any expected value disagrees with the implementation, re-derive by hand FIRST (the numbers above are the contract); only a derivation error in the plan justifies changing a test literal, and the change must be called out in the task report. +- [ ] **Step 4:** Test green; typecheck filter `@conciv/mascot` green. +- [ ] **Step 5:** Commit: `feat(mascot): core config and pure emitter-path math`. + +### Task 2: parts/pose.ts + +**Files:** + +- Create: `packages/mascot/src/core/parts/pose.ts` + +**Interfaces — Produces:** + +```ts +export type PoseParts = {head: HTMLElement; eyes: HTMLElement; antenna: HTMLElement} +export type PoseController = { + set: (state: MascotState) => void + animateTo: (state: MascotState) => void + eyeRestScaleY: () => number + dispose: () => void +} +export function createPoseController(parts: PoseParts): PoseController +``` + +- [ ] **Step 1:** Implement from donor `rig.ts` (branch `feat/mascot-gaze`, symbols `setClosed`, `setOpenPose`, `playOpen`, `playClose`): `set('rest')` = clearProps transform on all three parts; `set('awake')` = the `setOpenPose` sets; `animateTo('awake')` = the `playOpen` timeline; `animateTo('rest')` = the `playClose` timeline (WITHOUT the donor's `startGaze()` tail call — arming is the service's job now). Retain the active timeline in a local; kill it before starting a new one; `dispose` kills it and clears transforms it owns. `eyeRestScaleY()` returns 1 for rest, 1.06 for awake — activity's blink return value. +- [ ] **Step 2:** Typecheck green. Commit folded into Task 5 if fallow flags unused; otherwise commit `feat(mascot): pose controller`. + +### Task 3: parts/follow.ts + +**Files:** + +- Create: `packages/mascot/src/core/parts/follow.ts` + +**Interfaces — Produces:** + +```ts +export type FollowParts = {eyes: HTMLElement; leanWrapper: HTMLElement | undefined} +export type FollowController = { + arm: () => void + disarm: (animated: boolean) => void + dispose: () => void +} +export function createFollowController(parts: FollowParts): FollowController +export function wrapForLean(antenna: HTMLElement): HTMLElement | undefined +``` + +- [ ] **Step 1:** Implement from donor `rig.ts` `startGaze`/`stopGaze`/`resetGaze`/`detachGaze`/`wrapForLean`: one window pointermove listener; shared falloff math (bounds from eyes element, `reach = min(1, d/220)`, `atan2`); eyes quickTo x/y (0.6s power3.out) × 3px; wrapper quickTo rotation (0.5s power3.out) × 10°; `arm` idempotent (listener-reference-is-the-state), respects `reduceMotion()`; `disarm(true)` = detach + 0.25s power2.out return to zero, `disarm(false)` = detach + instant zero; `dispose` = detach + kill. +- [ ] **Step 2:** Typecheck green. Commit policy as Task 2. + +### Task 4: parts/activity.ts + effects/binary.ts + tip-transition.ts + +**Files:** + +- Create: `packages/mascot/src/core/tip-transition.ts` — `enterFromTip(element)` (fromTo scale 0.2→1, opacity 0→1, back.out(2.2), 0.36s), `exitIntoTip(element, onComplete)` (to scale 0.2 opacity 0, power2.in, 0.5s), both returning their tween. +- Create: `packages/mascot/src/core/effects/binary.ts` — decision 3's `createBinaryEmitter` with the approved digit/timeline values (including the `gsap.set(digits, {opacity: 0})` pre-timeline fix and straight −54px rise; NO curve support this phase). +- Create: `packages/mascot/src/core/parts/activity.ts`: + +```ts +export type ActivityParts = {stage: HTMLElement; antenna: HTMLElement; eyes: HTMLElement} +export type ActivityController = { + start: (eyeRestScaleY: number) => void + stop: () => void + dispose: () => void +} +export function createActivityController(parts: ActivityParts): ActivityController +``` + +- [ ] **Step 1:** Implement activity: `start` builds the throb+blink repeat timeline (approved beats; blink returns to the passed `eyeRestScaleY`) and starts the binary emitter (created lazily from `tipOffset(stage, antenna)` — tip-fraction math from config constants; skipped entirely under `reduceMotion()`); `stop` kills the timeline, returns antenna scale and eye scaleY to their pose values with short tweens (0.2s power2.out), and calls `emitter.stop(onRemoved → clear ref)`; `start` during an in-flight exit kills the exit and re-enters (decision 3). `dispose` = kill + `emitter.remove()`. +- [ ] **Step 2:** Typecheck green. Commit with Task 5 or standalone per fallow. + +### Task 5: mascot.ts (service) + adapter in rig.ts + publish gates + +**Files:** + +- Create: `packages/mascot/src/core/mascot.ts` +- Create: `packages/mascot/src/rig.ts` (new branch's version): `export {robotLayers}`, legacy types, `createFabRobotRig` adapter, `export {createMascot}` + core types. + +**Interfaces — Produces:** + +```ts +export type MascotService = { + update: (config: MascotConfig) => void + registerParts: (parts: {stage: HTMLElement; head: HTMLElement; eyes: HTMLElement; antenna: HTMLElement}) => void + connect: () => MascotConnect + destroy: () => void +} +export function createMascot(initial: MascotConfig): MascotService +``` + +- [ ] **Step 1:** Implement per binding decisions 1-7: unregistered stores config; `registerParts` full-teardown-then-setup, instant pose from stored config, controllers created here; `update` diffs previous vs next and orders transitions: working rising edge → disarm follow (instant), activity.start; working falling edge → activity.stop, then arm follow if eligible; state change → pose.animateTo (activity keeps running across it when working — matrix cell 4); follow change → arm/disarm per rule `follow && !working`. +- [ ] **Step 2:** `connect()` per decision 6, including `getEffectHostProps()`; getters' style records carry the layer styling (position absolute, inset 0, layer data URI background, contain, pixelated) from `robotLayers`. +- [ ] **Step 3:** Adapter — exact translation table (decision + codex finding 5): + +```ts +closed → {state: 'rest', working: false, follow: true} +open → {state: 'awake', working: false, follow: false} +work → {state: 'rest', working: true, follow: false} +``` + +`createFabRobotRig({head, eyes, antenna})` derives stage as the layers' shared parent (donor `sharedParent`), creates service with the `closed` record, registers parts, maps `apply`, passes through `destroy`. + +- [ ] **Step 4:** Gates: typecheck (mascot, @conciv/app, site), `pnpm lint`, `pnpm format:check`, mascot vitest, fallow audit, `pnpm turbo run publint attw --filter=@conciv/mascot`, dist grep: no `solid`/`react` strings. +- [ ] **Step 5:** Commit: `feat(mascot): compositional core with pose/follow/activity controllers and legacy adapter`. + +### Task 6: Checked-in behavior harness + parity + consumers + +**Files:** + +- Create: `packages/mascot/harness/verify.mjs` + `packages/mascot/harness/page.html` — the donor scratch harness (`gaze/verify.mjs` pattern) rebuilt as a repository artifact: serves the built dist, drives Chromium via playwright, prints PASS/FAIL per check, exits non-zero on any FAIL. +- Modify: `packages/mascot/package.json` — devDep `playwright` (embed's exact version), script `"verify:behavior": "node harness/verify.mjs"` (not wired into turbo test — manual/agent gate; note this in the PR body). + +**Checks (supersets the donor's 15):** legacy trio via the adapter — closed gaze (eyes ±3px, antenna ±10° with falloff ratio ≈ 0.5 at half distance), work (throb maxScaleY 1.3, 5 digits, staged enter, drain exit, flap ×5 → one emitter, no runaway tweens), open pose values; PLUS new-surface checks driven through `createMascot` directly: awake+working (awake pose retained while throbbing, blink returns to 1.06), update-before-registerParts (no throw, config applied on registration), repeated registerParts (no duplicate listeners/wrappers — assert one lean wrapper, pointermove count via instrumented addEventListener), destroy-during-exit (no dangling element), reduced-motion (all static, no emitter). + +- [ ] **Step 1:** Build harness; run against `pnpm turbo run build --filter=@conciv/mascot` dist. All checks PASS. +- [ ] **Step 2:** Donor-parity spot check: run the SAME harness's legacy-trio section against the donor worktree's built dist (`/Users/omrikatz/Public/web/aidx/.claude/worktrees/agent-a7c0a33a7e8995ce3/packages/mascot/dist`) — both green proves parity without file swapping. +- [ ] **Step 3:** `pnpm turbo run build --filter=@conciv/embed`; grep bundle for `elastic.out(1, 0.5)` and `var(--pw-accent`; run widget typecheck trio again. +- [ ] **Step 4:** Commit: `test(mascot): checked-in behavior harness`; push; report full harness output verbatim in the PR + changeset `.changeset/mascot-core.md` (patch, describing the internal restructuring + new core API). + +--- + +## Codex-review disposition (run 2026-08-14, gpt-5.6-sol, 15 findings) + +Blockers 1-4: resolved by binding decisions 1-4 (compositional controllers + matrix; emitter `stop(onRemoved)` ownership; greenfield structure with donor-as-values-only; explicit lifecycle). Majors 5-12: adapter table now exact (Task 5); publish surface pinned + publint/attw gate (Global Constraints, Task 5); `connect()` framework-neutral records + per-part refs + effect-host getter (decision 6); gsap ownership rule (decision 5); MotionPathPlugin/curves explicitly deferred with the `CurveStyle` type removed from phase 1 (Global Constraints); reduced-motion boundary rule + harness check (decision 7, Task 6); harness checked into the package with a script (Task 6); state-machine/lifecycle tests added as harness checks incl. awake+working (Task 6). Minors 13-15: moot (no story-support edits on the new branch), exact test values precomputed with derivations (Task 1), donor policy tightened to an approved-values list (Global Constraints). diff --git a/docs/superpowers/specs/2026-08-14-mascot-componentization-design.md b/docs/superpowers/specs/2026-08-14-mascot-componentization-design.md new file mode 100644 index 000000000..f25c68fba --- /dev/null +++ b/docs/superpowers/specs/2026-08-14-mascot-componentization-design.md @@ -0,0 +1,135 @@ +# Mascot componentization design + +Date: 2026-08-14. Status: approved by owner, pending implementation plan. + +## Goal + +Replace the prototype rig (`packages/mascot/src/rig.ts`, `createFabRobotRig`) with a real component +library: a framework-free animation core plus Solid and React compound-component wrappers, exposing +every mascot behavior and effect as composable, documented API. Pre-release rules apply: no +back-compat shim, `rig.ts` is deleted and both consumers migrate in the same effort. + +## API + +```tsx + + + + + +``` + +- `state: 'rest' | 'awake'` — expressions, not app concepts. `rest` is the neutral hanging pose; + `awake` is the perk-up animation (head bounce with overshoot, eyes widen, antenna flick) settling + into the lifted pose. The union is open for future expressions (`success`, `error` — the smoothui + ai-orb-face reference shows the target vocabulary). +- `working: boolean` — activity. Runs the working visuals (antenna motion, eye blink loop, mounted + effects) in either state. The mascot has no policy about state/working combinations: it does what + the props say. Consumers encode semantics, e.g. the widget passes + `working={isStreaming() && !open}` to stay quiet while the panel is open. +- `follow: boolean` — pointer tracking (eyes translate, antenna leans). No hidden state coupling: + the widget passes `!open` because it does not want tracking under an open panel; another consumer + may track always. +- Context inheritance: `` provides `state` / `working` / `follow` (and future shared flags) + via context. Part and effect children inherit; a child prop overrides + (``). Precedence: child prop > parent context > default. +- Bare `` renders the full standard robot: `Mascot` (root) renders a default + element for any part that has no corresponding child, so the three layers always exist; declaring + `` replaces the default eyes element with the child-rendered one (same prop + getter, plus the consumer's overrides). Effect children are additive — each mounted effect child + adds its effect, two children = two emitters. + +## Architecture (approach A, Ark-aligned) + +Logic lives once in a framework-free core; wrappers are mechanical mirrors, following the Ark UI / +zag.js split verified against the ark repo (`packages/{react,solid}/src/components/avatar`): a +`connect()`-style API returning prop getters, spread by thin per-framework part files. + +``` +packages/mascot/src/ + core/ + mascot.ts createMascot(config) → service: {update(config), connect(), destroy()} + parts/eyes.ts gaze follow (translate; falloff 220px, range 3px) + parts/antenna.ts lean follow (max 10°, wrapper-span rotation isolation as in current rig) + + named motion presets: throb, sine, wobble, metronome, vibrate + path/room.ts measureEmitterRoom (pure) + curve builders: arc, hook, fan, straight; + 'auto' resolves via measured room against real viewport bounds + effects/*.ts one module per effect (16), self-describing, no central registry + tip-transition.ts, poses.ts, shared timing/easing constants + solid/ use-mascot.ts, mascot-context.ts, mascot-root.tsx, mascot-eyes.tsx, + mascot-antenna.tsx, one file per effect child, index.ts + react/ the same files, React idioms (forwardRef, hooks), mirrored by convention +``` + +- Parts are framework-rendered: `getRootProps() / getHeadProps() / getEyesProps() / +getAntennaProps() / getEffectHostProps(id)` return style/attr bags (layer data-URI backgrounds, + positioning, image-rendering). Part components spread them and register their element refs back to + the core, which uses them as gsap targets. Consumers gain class/style hooks and DevTools presence. +- Effect interiors are core-imperative: the effect's host span is framework-rendered, everything + inside (digit churn, canvas) is owned by the effect module. This is the one deliberate departure + from zag, which never animates; gsap requires imperative ownership, and framework-rendered nodes + mutated by gsap are a known reconciler landmine in this repo. +- Exports: `.` (core, gsap-only deps), `./solid`, `./react`; solid-js and react are optional peer + dependencies; MotionPathPlugin is registered inside core. tsdown entries per subpath; effect + modules are separate chunks so importing one effect child never bundles the other fifteen. +- Config is plain data; `update(config)` diffs and sequences transitions (state change, follow + toggle, effect list membership) — wrappers never sequence animations. Core is fully drivable + without a wrapper (stories, tests, vanilla consumers). + +## Effects + +Free-form by design — no imposed lifecycle interface. An effect component receives the shared +context (tip anchor in stage coordinates, stage size, reactive `state`/`working`, reduced-motion) +and opt-in helpers (`TipTransition`, curve builders, `measureEmitterRoom`), and otherwise owns its +host completely. Lifecycle is component lifecycle: mount = exist, react to context however it +chooses, unmount = clean up. + +Conventions held by review, not types: staged enter/exit (nothing pops in or out — grow from the +tip on start, drain/collapse on stop), idempotence under rapid working flapping, zero leaks on +unmount. The current playground implementations are the reference pattern and are moved, not +rewritten. + +The 16 effect components: `Binary`, `Matrix`, `ThoughtCloud`, `PixelBubbles`, `SignalRings`, +`SpeechBubble`, `Steam`, `Spark`, `SparkBurst`, `SparkFountain`, `Satellite`, `LedCone`, +`TickRing`, `SignalBars`, `Heart`, `Notes`. Traveling/particle effects honor `curve` +(`arc | hook | fan | straight | auto`); anchored effects (ThoughtCloud, SpeechBubble) ignore it. +Digits/particles follow the path with vertical tangent at the tip and tangent tilt (autoRotate), +two-lane offsets riding the rotated local frame — as proven in the EmitterPath story. + +Out of scope: antenna-art tip variants (LED tip, color cycle, etc.) — separate lane if picked later. + +## Behavior parity requirements + +The refactor must reproduce, verified by the existing Playwright harness pattern: + +- rest + follow: eyes translate (3px range) and antenna leans (10° max) with the 220px falloff, + one pointermove listener, arm/disarm lifecycle, pose-tween isolation (lean on a rig-created + wrapper span so pose `rotation` tweens never race it). +- working: antenna throb (squash-stretch, elastic release), eye blink loop, mounted effects staged + in from the tip; on stop, effects drain before removal; exactly one emitter under flapping. +- awake: perk-up animation and settled pose, identical to the current open-state timeline. +- reduced motion: static poses, no follow, no effects — all current branches preserved. +- destroy: listener removed, tweens killed, effect DOM removed, antenna un-wrapped. + +## Testing + +- Pure math (`measureEmitterRoom`, curve geometry) gets vitest unit tests in the package. +- Behavior evidence via the Playwright harness (per-feature checks with revert-verification, the + pattern used throughout this branch); promoted from scratch scripts into repeatable package + scripts where cheap. +- Post-migration, widget-level behavior rides the existing embed integration tests. + +## Migration plan (phases, each landing green) + +1. Core refactor: rig.ts → core modules behind `createMascot`, visual behavior unchanged, + harness-verified parity. +2. Solid wrapper + widget migration (`apps/conciv/src/shell/fab-robot.tsx` becomes the compound + API one-liner; widget mapping: `state={open ? 'awake' : 'rest'}`, + `working={isStreaming() && !open}`, `follow={!open}`). +3. React wrapper + site migration (`apps/site/src/components/landing/robot-fab.tsx`). +4. Stories rewritten as the variant gallery driven by the real API; `story-support` shrinks to page + chrome; `.fallowrc.json` ignore entries revisited. +5. Delete `rig.ts` and `createFabRobotRig`, fallow sweep, changeset updated. + +Open at plan time: same PR vs stacked PRs per phase; which effects the widget mounts by default +(current pick: `Binary` with `curve="auto"`). diff --git a/packages/mascot/package.json b/packages/mascot/package.json index e82a0d95b..4cd39ed55 100644 --- a/packages/mascot/package.json +++ b/packages/mascot/package.json @@ -29,7 +29,7 @@ }, "scripts": { "build": "tsdown", - "typecheck": "tsc -p tsconfig.json --noEmit", + "typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.stories.json --noEmit", "lint": "oxlint", "test": "vitest run --passWithNoTests", "publint": "publint", @@ -40,7 +40,11 @@ }, "devDependencies": { "@conciv/vitest-config": "workspace:*", + "solid-js": "^1.9.13", + "storybook": "^10.5.0", + "storybook-solidjs-vite": "^10.6.0", "tsdown": "^0.22.4", - "typescript": "^6.0.3" + "typescript": "^6.0.3", + "vite-plugin-solid": "^2.11.12" } } diff --git a/packages/mascot/src/antenna-art.stories.tsx b/packages/mascot/src/antenna-art.stories.tsx new file mode 100644 index 000000000..683ad8351 --- /dev/null +++ b/packages/mascot/src/antenna-art.stories.tsx @@ -0,0 +1,480 @@ +import {createEffect, createSignal, onCleanup, onMount, For, Show, type JSX} from 'solid-js' +import {Dynamic} from 'solid-js/web' +import type {Meta, StoryObj} from 'storybook-solidjs-vite' +import gsap from 'gsap' +import {createFabRobotRig, robotLayers, type FabRobotRig, type RigState} from './rig.js' + +const meta: Meta = {title: 'mascot/AntennaArt'} +export default meta +type Story = StoryObj + +const STAGE_SIZE_PX = 44 + +const ANTENNA_PIXEL_SIZE = 128 + +const TIP_LEFT = 54 + +const TIP_RIGHT = 73 + +const TIP_TOP = 12 + +const TIP_BOTTOM = 27 + +const TIP_OUTLINE_SUM = 150 + +const GLOW_SCALE = 2.1 + +const UPPER_BALL_OFFSET = -13 + +const chromeBorderColor = 'rgba(128, 134, 156, 0.45)' + +const tipPalette: [number, number, number][] = [ + [224, 33, 138], + [56, 214, 232], + [255, 210, 63], +] + +const chargeStages = [0.25, 0.5, 0.75, 1] + +const prefersReducedMotion = () => + typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches + +function createPixelCanvas(): {canvas: HTMLCanvasElement; context: CanvasRenderingContext2D} { + const canvas = document.createElement('canvas') + canvas.width = ANTENNA_PIXEL_SIZE + canvas.height = ANTENNA_PIXEL_SIZE + const context = canvas.getContext('2d') + if (context === null) throw new Error('canvas 2d context unavailable') + context.imageSmoothingEnabled = false + return {canvas, context} +} + +async function readAntennaPixels(): Promise { + const image = new Image() + image.src = robotLayers.antenna + await image.decode() + const {context} = createPixelCanvas() + context.drawImage(image, 0, 0) + return context.getImageData(0, 0, ANTENNA_PIXEL_SIZE, ANTENNA_PIXEL_SIZE) +} + +function clonePixels(pixels: ImageData): ImageData { + return new ImageData(new Uint8ClampedArray(pixels.data), pixels.width, pixels.height) +} + +function toSpriteUrl(pixels: ImageData): string { + const {canvas, context} = createPixelCanvas() + context.putImageData(pixels, 0, 0) + return canvas.toDataURL() +} + +const insideTip = (x: number, y: number) => x >= TIP_LEFT && x <= TIP_RIGHT && y >= TIP_TOP && y <= TIP_BOTTOM + +function eachPixel(pixels: ImageData, visit: (index: number, x: number, y: number) => void): void { + for (let y = 0; y < pixels.height; y += 1) { + for (let x = 0; x < pixels.width; x += 1) visit((y * pixels.width + x) * 4, x, y) + } +} + +function maskedSprite(pixels: ImageData, keepTip: boolean): ImageData { + const result = clonePixels(pixels) + eachPixel(result, (index, x, y) => { + if (insideTip(x, y) === keepTip) return + result.data[index + 3] = 0 + }) + return result +} + +const channel = (data: Uint8ClampedArray, index: number) => data[index] ?? 0 + +function paintTip(pixels: ImageData, color: [number, number, number], fromRow: number): ImageData { + const result = maskedSprite(pixels, true) + eachPixel(result, (index, x, y) => { + if (!insideTip(x, y) || y < fromRow) return + if (channel(result.data, index + 3) < 8) return + const brightness = channel(result.data, index) + channel(result.data, index + 1) + channel(result.data, index + 2) + if (brightness < TIP_OUTLINE_SUM) return + result.data[index] = color[0] + result.data[index + 1] = color[1] + result.data[index + 2] = color[2] + }) + return result +} + +function hollowTip(pixels: ImageData): ImageData { + const base = maskedSprite(pixels, true) + const result = clonePixels(base) + const alphaAt = (x: number, y: number) => { + if (x < 0 || y < 0 || x >= ANTENNA_PIXEL_SIZE || y >= ANTENNA_PIXEL_SIZE) return 0 + return channel(base.data, (y * ANTENNA_PIXEL_SIZE + x) * 4 + 3) + } + eachPixel(result, (index, x, y) => { + if (alphaAt(x, y) < 8) return + if (alphaAt(x - 1, y) < 8 || alphaAt(x + 1, y) < 8 || alphaAt(x, y - 1) < 8 || alphaAt(x, y + 1) < 8) return + result.data[index + 3] = 0 + }) + return result +} + +function scaledTipUrl(tipPixels: ImageData, scale: number): string { + const source = createPixelCanvas() + source.context.putImageData(tipPixels, 0, 0) + const {canvas, context} = createPixelCanvas() + const centerX = (TIP_LEFT + TIP_RIGHT + 1) / 2 + const centerY = (TIP_TOP + TIP_BOTTOM + 1) / 2 + context.translate(centerX, centerY) + context.scale(scale, scale) + context.translate(-centerX, -centerY) + context.drawImage(source.canvas, 0, 0) + return canvas.toDataURL() +} + +function shiftedTipUrl(tipPixels: ImageData, offsetY: number): string { + const source = createPixelCanvas() + source.context.putImageData(tipPixels, 0, 0) + const {canvas, context} = createPixelCanvas() + context.drawImage(source.canvas, 0, offsetY) + return canvas.toDataURL() +} + +type AntennaSprites = { + stick: string + tip: string + tipColors: string[] + glow: string + charge: string[] + hollow: string + upperBall: string +} + +let spritesPromise: Promise | undefined + +async function deriveSprites(): Promise { + const pixels = await readAntennaPixels() + const tipHeight = TIP_BOTTOM - TIP_TOP + 1 + return { + stick: toSpriteUrl(maskedSprite(pixels, false)), + tip: toSpriteUrl(maskedSprite(pixels, true)), + tipColors: tipPalette.map((color) => toSpriteUrl(paintTip(pixels, color, TIP_TOP))), + glow: scaledTipUrl(paintTip(pixels, tipPalette[0] ?? [224, 33, 138], TIP_TOP), GLOW_SCALE), + charge: chargeStages.map((fraction) => + toSpriteUrl(paintTip(pixels, [120, 255, 180], TIP_BOTTOM + 1 - Math.round(tipHeight * fraction))), + ), + hollow: toSpriteUrl(hollowTip(pixels)), + upperBall: shiftedTipUrl(maskedSprite(pixels, true), UPPER_BALL_OFFSET), + } +} + +function antennaSprites(): Promise { + if (spritesPromise === undefined) spritesPromise = deriveSprites() + return spritesPromise +} + +const stageStyle: JSX.CSSProperties = { + display: 'inline-block', + position: 'relative', + width: `${STAGE_SIZE_PX}px`, + height: `${STAGE_SIZE_PX}px`, +} + +function layerStyle(image: string): JSX.CSSProperties { + return { + position: 'absolute', + inset: '0', + 'background-image': `url('${image}')`, + 'background-repeat': 'no-repeat', + 'background-position': 'center', + 'background-size': 'contain', + 'image-rendering': 'pixelated', + } +} + +const groupStyle: JSX.CSSProperties = {position: 'absolute', inset: '0', 'will-change': 'transform'} + +function ArtStage(props: {state: RigState; children: JSX.Element}): JSX.Element { + let headElement: HTMLSpanElement | undefined + let eyesElement: HTMLSpanElement | undefined + let groupElement: HTMLSpanElement | undefined + let rig: FabRobotRig | undefined + + onMount(() => { + if (!headElement || !eyesElement || !groupElement) return + rig = createFabRobotRig({head: headElement, eyes: eyesElement, antenna: groupElement}) + createEffect(() => { + rig?.apply(props.state) + }) + }) + onCleanup(() => rig?.destroy()) + + return ( +