# Fellow Mascot — handoff

The Fellow mascot is the two-figure mark, alive: cut-out eyes that morph per expression, an outline
version whose face lines move, spring physics, randomised blinks, saccades, a parallax head turn, and
animations composed from expression steps. It ships as one framework-free JavaScript file.

**Default look is Outline + Expressive.** That is what the runtime renders when you pass nothing.

---

## What's in this handoff

| file | what it is | who needs it |
|---|---|---|
| `fellow-mascot.js` | **The runtime. This is the deliverable.** One file, no dependencies, ~40 KB. | every project |
| `mascot-generator.html` | The playground: try expressions, animations, colours; export PNG, SVG, GIF, WebM. | designers, anyone picking states |
| `tokens.css` | Design tokens the generator needs. The runtime does not need it. | only to run the generator |
| `MASCOT-HANDOFF.md` | This document. | everyone |

Keep the four files in one folder and open `mascot-generator.html` from a local server (`python3 -m
http.server`, or any static host). Opened straight from disk it will still work in most browsers, but
clip export needs a server.

The generator and the runtime are the same code: what you see in the playground is exactly what
`fellow-mascot.js` renders in your product.

---

## Quick start

### Plain HTML

```html
<script src="fellow-mascot.js"></script>
<svg id="mascot" width="160" aria-label="Fellow"></svg>
<script>
  const m = FellowMascot.create(document.getElementById('mascot'), { animation: 'idle' });
  m.play('thinking');
</script>
```

### React + Vite + TypeScript (Fellow Signal, Live, Cert Studio, Purrsona)

1. Copy `fellow-mascot.js` to `public/fellow-mascot.js` and add
   `<script src="/fellow-mascot.js"></script>` to `index.html` before your app script.
2. Add the two files below to `src/mascot/`.

`src/mascot/fellow-mascot.d.ts`

```ts
export type MascotColour = 'line' | 'lineEmber' | 'lineSupport' | 'ember' | 'ink' | 'duo' | 'support' | 'voltage' | 'paper' | 'sunset' | 'grain';
export type MascotForm = 'pair' | 'mirror' | 'lead' | 'side' | 'huddle' | 'apart';
export type MascotMode = 'loop' | 'once' | 'pingpong';
export interface MascotOptions {
  definition?: object; colour?: MascotColour; form?: MascotForm; expression?: string; animation?: string;
  motion?: boolean; blink?: boolean; saccades?: boolean; parallax?: boolean; springSpeed?: number;
  lineFreedom?: number; lineCurves?: boolean; lineParts?: Partial<Record<'brow' | 'nose' | 'sharp' | 'notch' | 'cheek' | 'leg', boolean>>;
  seed?: number; autoplay?: boolean;
}
export interface Mascot {
  play(name: string, o?: { mode?: MascotMode }): Mascot;
  pause(): Mascot; resume(): Mascot; stop(): Mascot;
  expression(id: string, snap?: boolean): Mascot;
  setColour(id: MascotColour): Mascot; setForm(id: MascotForm): Mascot;
  look(nx: number, ny: number): Mascot;
  set(o: Partial<Pick<MascotOptions, 'motion' | 'saccades' | 'parallax' | 'springSpeed' | 'lineFreedom' | 'lineCurves' | 'lineParts'> & { blinkOn: boolean }>): Mascot;
  snapshot(o?: { width?: number; bg?: string; frame?: { zoom?: number; x?: number; y?: number; radius?: number } }): { svg: string; width: number; height: number };
  step(dt: number): void; destroy(): void;
  player: { name: string; paused: boolean } | null;
}
declare global {
  interface Window { FellowMascot: { version: number; create(el: Element, o?: MascotOptions): Mascot; definition(): object } }
}
export {};
```

`src/mascot/FellowMascot.tsx`

```tsx
import { useEffect, useImperativeHandle, useRef, forwardRef } from 'react';
import type { Mascot, MascotOptions } from './fellow-mascot';

export type MascotHandle = Mascot;
type Props = MascotOptions & { size?: number | string; className?: string; label?: string };

export const FellowMascot = forwardRef<MascotHandle, Props>(function FellowMascot(
  { size = 160, className, label = 'Fellow', animation, expression, colour, form, ...opts }, ref
) {
  const svgRef = useRef<SVGSVGElement>(null);
  const inst = useRef<Mascot | null>(null);

  useEffect(() => {
    if (!svgRef.current) return;
    inst.current = window.FellowMascot.create(svgRef.current, { animation, expression, colour, form, ...opts });
    return () => { inst.current?.destroy(); inst.current = null; };
    // create once; live changes go through the effects below
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);
  useEffect(() => { if (animation) inst.current?.play(animation); else inst.current?.stop(); }, [animation]);
  useEffect(() => { if (expression) inst.current?.expression(expression); }, [expression]);
  useEffect(() => { if (colour) inst.current?.setColour(colour); }, [colour]);
  useEffect(() => { if (form) inst.current?.setForm(form); }, [form]);
  useImperativeHandle(ref, () => inst.current as Mascot, []);

  return <svg ref={svgRef} className={className} style={{ width: size, height: 'auto' }} role="img" aria-label={label} />;
});

/** Map app status to a mascot animation. Use for loaders, empty states, results. */
export const MASCOT_STATE: Record<string, string> = {
  idle: 'idle', loading: 'thinking', searching: 'searching', working: 'working', listening: 'listening',
  empty: 'sleeping', focus: 'waking', success: 'celebrate', error: 'confused', warning: 'suspicious', done: 'happy'
};
```

Use it:

```tsx
<FellowMascot animation={MASCOT_STATE[status]} size={140} />
```

For the cursor parallax, forward pointer moves from the surrounding panel:

```tsx
const ref = useRef<MascotHandle>(null);
<div onPointerMove={e => { const r = e.currentTarget.getBoundingClientRect();
  ref.current?.look((e.clientX - r.left) / r.width * 2 - 1, (e.clientY - r.top) / r.height * 2 - 1); }}
  onPointerLeave={() => ref.current?.look(0, 0)}>
  <FellowMascot ref={ref} animation="idle" />
</div>
```

---

## Colour on light surfaces

The outline is mono and follows the surface. It reads the CSS variable `--fm-line` and falls back to
`#F7F7F7` (light line, for dark surfaces). On a light surface set the dark line once on an ancestor:

```css
html.light, .light { --fm-line: #252525; }   /* Tailwind: add to the same selector as your dark/light toggle */
```

Those are the exact brand colours from `symbol-boldLine.svg`. The two accent outlines are `lineEmber`
and `lineSupport`. The filled treatments (`ember`, `duo`, `support`, …) carry hex fallbacks and work
without `tokens.css`; inside a page that loads `tokens.css` they follow the palette automatically.

---

## Animations

Pick by name with `play()` or the `animation` prop. Once-animations chain into `idle` when they finish.

| group | names | meant for |
|---|---|---|
| Life cycle | `sleeping` `waking` `idle` `listening` `thinking` `searching` `working` | product states: idle screen, focus, default, input focused, request in flight, query running, long task |
| Reactions | `excited` `happy` `laughing` `proud` `playful` `celebrate` `curious` `surprised` `confused` `suspicious` `bored` `drowsy` `shy` `sad` `angry` `scared` | one-off feedback |
| Gestures | `talking` `wave` `peek` `nudge` `wink` `pop` `assemble` | arrivals, nudges, greetings |

Expressions for a still pose via `expression()`: `neutral` `attentive` `surprised` `excited` `happy`
`laughing` `angry` `sad` `scared` `suspicious` `confused` `curious` `proud` `shy` `unimpressed` `sleepy`.

---

## API reference

```js
const m = FellowMascot.create(svgOrContainer, options)
```

| option | default | meaning |
|---|---|---|
| `colour` | `line` | see list above |
| `form` | `pair` | `pair` `mirror` (outline); filled marks also take `lead` `side` `huddle` `apart` |
| `expression` | `neutral` | starting pose |
| `animation` | none | start playing |
| `motion` | follows `prefers-reduced-motion` | `false` freezes everything |
| `blink` `saccades` `parallax` | `true` | toggle each behaviour |
| `springSpeed` | `1` | 0.5 lazy → 2 snappy |
| `lineFreedom` | `1.7` (Expressive) | 0 off · 0.7 subtle · 1 restrained · 1.7 expressive |
| `lineCurves` | `true` | `false` keeps the face lines straight |
| `lineParts` | all on | `{ brow, nose, notch, cheek, leg }` booleans |
| `seed` | random | fixed seed = identical blink and gaze timing every run |
| `definition` | built-in | JSON from the generator's Developer tab, merged over the defaults |

Methods: `play(name, {mode})` · `pause()` · `resume()` · `stop()` · `expression(id, snap)` ·
`setColour(id)` · `setForm(id)` · `look(nx, ny)` with -1..1 · `set({...})` · `snapshot({width, bg, frame})`
returns a standalone SVG string · `destroy()`.

All instances share one `requestAnimationFrame` loop; thirty on a page is fine.

---

## Rules

- **One mascot per view.** It is a character, not an icon set.
- **Never the ember colour on an ember background.** The eyes are cut-outs; they vanish.
- **Small marks calm down.** Under about 48 px use `parallax:false, saccades:false`.
- **Respect reduced motion.** The runtime does this by default; don't override it with `motion:true`.
- **Neutral is the logo.** Do not restyle the resting mark; expressions are the only variation.
- **Outline forms.** The outline is one merged shape, so only `pair` and `mirror` apply.
- **Sizing.** The SVG carries a fixed viewBox with padding for hops; set width only, height follows.

---

## Stills, clips and sheets

Use the generator's Photo mode for assets rather than screenshots: PNG or SVG at 512 to 2048 with
framing and background, an animated GIF or WebM of any animation (rendered from a seeded start, so
re-exports are identical), and a 16-up expression sheet. The Developer tab exports a `fellow-mascot.json`
definition with colours resolved for the chosen palette and mode, for projects outside the design system.

---

## Updating

The runtime is versioned by `FellowMascot.version`. To update a project, replace `fellow-mascot.js`;
the API is additive. If a project uses a custom definition JSON, re-export it from the generator so
new animations and face-line values come along.
