From da1635ee8ea9458bbf0b54206b34769b4a5fc8db Mon Sep 17 00:00:00 2001 From: Omri Katz <9701896+omridevk@users.noreply.github.com> Date: Fri, 14 Aug 2026 14:01:15 +0300 Subject: [PATCH 01/15] feat(mascot): eyes track the cursor while the FAB robot is closed The rig arms a window pointermove listener whenever it is in the closed state and steers the eyes layer with gsap.quickTo, so the widget FAB and the site FAB both gain the gaze with no consumer change. Deflection is polar: reach ramps to 3px over 220px of cursor distance, smoothed over 600ms. Opening or starting work disarms the listener and returns the eyes to centre; reduced-motion never arms it. Open/work/close kills are now scoped to the posed properties so the pose timelines and the gaze tweens no longer fight over the eyes layer. Co-Authored-By: Claude Fable 5 --- packages/mascot/src/rig.ts | 62 +++++++++++++++++++++++++++++++++++--- 1 file changed, 58 insertions(+), 4 deletions(-) diff --git a/packages/mascot/src/rig.ts b/packages/mascot/src/rig.ts index 60bd5f2af..2e0d52ff0 100644 --- a/packages/mascot/src/rig.ts +++ b/packages/mascot/src/rig.ts @@ -10,18 +10,65 @@ export type FabRobotRig = {apply: (state: RigState) => void; destroy: () => void const reduceMotion = () => typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches +const posedProperties = 'yPercent,rotation,scaleX,scaleY' + +const gazeProperties = 'x,y' + +const gazeFalloffPixels = 220 + +const gazeRangePixels = 3 + export function createFabRobotRig({head, eyes, antenna}: RigLayers): FabRobotRig { const parts = [head, eyes, antenna] let workTimeline: gsap.core.Timeline | undefined let previous: RigState | undefined + let gazePointerMove: ((event: PointerEvent) => void) | undefined gsap.set(head, {transformOrigin: '50% 80%'}) gsap.set(eyes, {transformOrigin: '49.6% 58.6%'}) gsap.set(antenna, {transformOrigin: '50% 32.8%'}) + const detachGaze = () => { + if (gazePointerMove === undefined) return + window.removeEventListener('pointermove', gazePointerMove) + gazePointerMove = undefined + } + + const resetGaze = () => { + detachGaze() + gsap.killTweensOf(eyes, gazeProperties) + gsap.set(eyes, {x: 0, y: 0}) + } + + const stopGaze = () => { + detachGaze() + gsap.killTweensOf(eyes, gazeProperties) + gsap.to(eyes, {x: 0, y: 0, duration: 0.25, ease: 'power2.out'}) + } + + const startGaze = () => { + if (reduceMotion() || gazePointerMove !== undefined) return + gsap.killTweensOf(eyes, gazeProperties) + const moveX = gsap.quickTo(eyes, 'x', {duration: 0.6, ease: 'power3.out'}) + const moveY = gsap.quickTo(eyes, 'y', {duration: 0.6, ease: 'power3.out'}) + gazePointerMove = (event) => { + const bounds = eyes.getBoundingClientRect() + if (bounds.width === 0 || bounds.height === 0) return + const offsetX = event.clientX - (bounds.left + bounds.width / 2) + const offsetY = event.clientY - (bounds.top + bounds.height / 2) + const distance = Math.sqrt(offsetX * offsetX + offsetY * offsetY) + const reach = Math.min(1, distance / gazeFalloffPixels) * gazeRangePixels + const angle = Math.atan2(offsetY, offsetX) + moveX(Math.cos(angle) * reach) + moveY(Math.sin(angle) * reach) + } + window.addEventListener('pointermove', gazePointerMove) + } + const setClosed = () => gsap.set(parts, {clearProps: 'transform'}) const setOpenPose = () => { + resetGaze() gsap.set(head, {yPercent: -2, rotation: 0, scaleX: 1, scaleY: 1}) gsap.set(eyes, {scaleX: 1, scaleY: 1.06}) gsap.set(antenna, {rotation: -4}) @@ -34,7 +81,8 @@ export function createFabRobotRig({head, eyes, antenna}: RigLayers): FabRobotRig const playOpen = () => { if (reduceMotion()) return setOpenPose() - gsap.killTweensOf(parts) + stopGaze() + gsap.killTweensOf(parts, posedProperties) gsap .timeline() .to(head, {yPercent: 6, scaleX: 1.05, scaleY: 0.92, duration: 0.08, ease: 'power2.in'}) @@ -49,19 +97,21 @@ export function createFabRobotRig({head, eyes, antenna}: RigLayers): FabRobotRig const playClose = () => { if (reduceMotion()) return setClosed() - gsap.killTweensOf(parts) + gsap.killTweensOf(parts, posedProperties) gsap .timeline() .to(head, {yPercent: 4, scaleY: 0.95, duration: 0.07, ease: 'power2.in'}) .to(head, {yPercent: 0, scaleX: 1, scaleY: 1, rotation: 0, duration: 0.2, ease: 'power3.out'}) .to(eyes, {scaleX: 1, scaleY: 1, duration: 0.16, ease: 'power2.out'}, '<') .to(antenna, {rotation: 0, duration: 0.22, ease: 'power2.out'}, '<') + startGaze() } const startWork = () => { if (reduceMotion()) return setOpenPose() + resetGaze() stopWork() - gsap.killTweensOf(parts) + gsap.killTweensOf(parts, posedProperties) setClosed() workTimeline = gsap .timeline({repeat: -1}) @@ -75,6 +125,7 @@ export function createFabRobotRig({head, eyes, antenna}: RigLayers): FabRobotRig if (state === 'work') return startWork() if (state === 'open') return setOpenPose() setClosed() + startGaze() } const apply = (state: RigState) => { @@ -94,7 +145,7 @@ export function createFabRobotRig({head, eyes, antenna}: RigLayers): FabRobotRig if (fromWork) { stopWork() if (reduceMotion()) return setOpenPose() - gsap.killTweensOf(parts) + gsap.killTweensOf(parts, posedProperties) gsap.to(head, {yPercent: -2, rotation: 0, scaleX: 1, scaleY: 1, duration: 0.3, ease: 'power2.out'}) gsap.to(eyes, {scaleX: 1, scaleY: 1.06, duration: 0.3, ease: 'power2.out'}) gsap.to(antenna, {rotation: -4, duration: 0.3, ease: 'power2.out'}) @@ -104,9 +155,12 @@ export function createFabRobotRig({head, eyes, antenna}: RigLayers): FabRobotRig } const destroy = () => { + detachGaze() stopWork() gsap.killTweensOf(parts) } + startGaze() + return {apply, destroy} } From 94674c1502d9c2153dafb80803d78f17f125cfbc Mon Sep 17 00:00:00 2001 From: Omri Katz <9701896+omridevk@users.noreply.github.com> Date: Fri, 14 Aug 2026 14:15:06 +0300 Subject: [PATCH 02/15] chore: changeset for mascot gaze Co-Authored-By: Claude Fable 5 --- .changeset/mascot-gaze.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/mascot-gaze.md diff --git a/.changeset/mascot-gaze.md b/.changeset/mascot-gaze.md new file mode 100644 index 000000000..d120ce7df --- /dev/null +++ b/.changeset/mascot-gaze.md @@ -0,0 +1,5 @@ +--- +'@conciv/mascot': patch +--- + +The FAB robot's eyes now slowly follow the mouse cursor while the widget is closed. Gaze disarms and recenters when the panel opens or the robot is working, and is disabled entirely under prefers-reduced-motion. From ee81cc140ed4d39074043af602a69c2a1c623094 Mon Sep 17 00:00:00 2001 From: Omri Katz <9701896+omridevk@users.noreply.github.com> Date: Fri, 14 Aug 2026 14:34:34 +0300 Subject: [PATCH 03/15] feat(mascot): storybook story for the FAB robot rig Adds rig.stories.tsx with closed/open/work stories plus an interactive state switch, so the rig (and the cursor gaze in the closed state) can be iterated on visually. The rig is framework-free, so the story mounts it from a Solid wrapper mirroring the widget's fab-robot.tsx layer structure and destroys it on cleanup. Story styling is inline rather than UnoCSS utilities, following solid-stick-to-bottom: mascot ships no uno.config.ts and the unocss lint plugin requires one. Storybook deps are dev-only and no solid-js peer dependency is added, so the published package stays framework-free. Co-Authored-By: Claude Fable 5 --- apps/storybook/.storybook/main.ts | 1 + packages/mascot/package.json | 8 +- packages/mascot/src/rig.stories.tsx | 127 ++++++++++++++++ packages/mascot/tsconfig.stories.json | 12 ++ pnpm-lock.yaml | 200 ++++++++++++++------------ 5 files changed, 252 insertions(+), 96 deletions(-) create mode 100644 packages/mascot/src/rig.stories.tsx create mode 100644 packages/mascot/tsconfig.stories.json 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/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/rig.stories.tsx b/packages/mascot/src/rig.stories.tsx new file mode 100644 index 000000000..a3c850e71 --- /dev/null +++ b/packages/mascot/src/rig.stories.tsx @@ -0,0 +1,127 @@ +import {createEffect, createSignal, onCleanup, onMount, For, type JSX} from 'solid-js' +import type {Meta, StoryObj} from 'storybook-solidjs-vite' +import {expect, within, userEvent} from 'storybook/test' +import {createFabRobotRig, robotLayers, type FabRobotRig, type RigState} from './rig.js' + +const meta: Meta = {title: 'mascot/FabRobotRig'} +export default meta +type Story = StoryObj + +const STAGE_SIZE_PX = 44 + +const STATES: RigState[] = ['closed', 'open', 'work'] + +const stageStyle: JSX.CSSProperties = { + 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', + 'will-change': 'transform', + } +} + +function RigStage(props: {state: RigState}): JSX.Element { + let headElement: HTMLSpanElement | undefined + let eyesElement: HTMLSpanElement | undefined + let antennaElement: HTMLSpanElement | undefined + let rig: FabRobotRig | undefined + + onMount(() => { + if (!headElement || !eyesElement || !antennaElement) return + rig = createFabRobotRig({head: headElement, eyes: eyesElement, antenna: antennaElement}) + createEffect(() => { + rig?.apply(props.state) + }) + }) + onCleanup(() => rig?.destroy()) + + return ( +