Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/mascot-gaze.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 3 additions & 1 deletion .fallowrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions apps/storybook/.storybook/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
199 changes: 199 additions & 0 deletions docs/superpowers/plans/2026-08-14-mascot-core-refactor.md

Large diffs are not rendered by default.

135 changes: 135 additions & 0 deletions docs/superpowers/specs/2026-08-14-mascot-componentization-design.md
Original file line number Diff line number Diff line change
@@ -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
<Mascot state={open ? 'awake' : 'rest'} working={isStreaming()} follow={!open}>
<Mascot.Eyes />
<Mascot.Antenna animation="throb" />
<Mascot.Binary curve="auto" />
</Mascot>
```

- `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: `<Mascot>` provides `state` / `working` / `follow` (and future shared flags)
via context. Part and effect children inherit; a child prop overrides
(`<Mascot.Antenna follow={false}>`). Precedence: child prop > parent context > default.
- Bare `<Mascot working={x} />` 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
`<Mascot.Eyes …>` 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"`).
8 changes: 6 additions & 2 deletions packages/mascot/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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"
}
}
Loading
Loading