feat(onboarding): ClipLab sleepy-to-wake hero and step hand-offs (#13629)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Agents have a persistent visual identity (#13171): a ClipLab
character in one of 17 palettes, rendered as cached PNGs in lists and as
a live character in larger placements.
> - The onboarding wizard is where a person meets that identity first,
and it showed a stock ClipLab expression on the previous engine while
the rest of the app would show a different character on a newer one.
> - The wizard's steps also cut from one screen to the next, so the arc
read as separate pages rather than one walk.
> - This pull request puts one character on one engine everywhere, gives
the wizard's hero the studio's sleepy → wink → idle sequence on Review,
and hands the steps over inside one presence.
> - The benefit is that what wakes on Review is exactly what the agent
looks like on the dashboard afterwards, and the walk to it reads as one
screen changing.

## Linked Issues or Issue Description

Refs #13171, now merged into master. This PR contains the onboarding and
ClipLab update on top of that foundation. Original feature work by
@tonio-alucema; merge preparation preserves the original commits.

**Problem or motivation**

The onboarding hero and the app's avatars were two different characters
on two different ClipLab engines. Steps 1 → 4 of the wizard cut between
screens, and the wizard mounted cold when a cloud-managed workspace
arrived from Cloud's naming screen.

**Proposed solution**

Vendor ClipLab v0.2.0 as the shared engine and render one
studio-exported character from it in every palette, for every pose and
size. Play the export's one-shot wake on Review with the palette fading
in over the gray dormant loop. Hand steps over inside one presence so
the footer slides instead of jumping, and play the arrival half of that
hand-off when the wizard opens directly on the agent step.

**Alternatives considered**

Exporting mp4/webm loops per size: no cursor following, no clean alpha,
and the palette "colour in" is a runtime blend. Minting a `cap-v2`
character version: nothing had shipped `cap-v1`, so the artwork is
regenerated in place instead of migrated. Keeping the separately
vendored runtime bundle for the hero: two engines and two characters in
one app.

## What Changed

- `packages/shared/src/cliplab`: re-vendored from ClipLab v0.2.0
(`987b6db0`) with the Paperclip adaptations replayed (optional graphics
backend for the Node SVG snapshot path, supersampled live textures,
character framing, deterministic SVG id prefixes); new upstream
`particles.ts`.
- `packages/shared/src/cliplab/character.ts`: the studio export,
mirrored from `ui/src/assets/cliplab/onboarding.character.json` by
`scripts/sync-cliplab-character.mjs` (drift caught by
`check:token-gates`). `characterDefinition` builds every palette from
it; the resting portrait is its idle beat.
- `OnboardingCharacter`: gray `sleepy` loop through the agent and
connect steps; on Review the one-shot sleepy → wink → idle plays on two
lock-step canvases while the palette fades in, then the `idle` loop.
Body-follows the pointer, page-scoped. 160px in the wizard.
- `OnboardingWizard`: steps 1 → 2 → 3 → 4 hand over inside one
`AnimatePresence` (departing content fades and gives its room back;
arriving content opens its room then fills); the hero has a room that
opens on the walk into the agent step; opening directly on the agent
step plays the arrival half; the self-hosted naming step uses the arc's
label and field.
- Motion vocabulary in `onboarding-motion.ts` (`stepContentMotion`,
`ledeMotion`, `heroRoomMotion`, `heroRoomArrival`, `titleSwapMotion`).
- Storybook: `Onboarding / Character` (Wake Up), `Onboarding / Agent
arc` walkable from the naming step plus `Arrive From Cloud`; the
companies fixture answers the wizard's create call with a company.
- Uses the shared runtime for onboarding; `doc/agent-personas.md`
documents the shared character.
- Releases both onboarding canvases after partial startup or transition
failure. Registers each canvas before seeking so synchronous render
errors can release it. Six component tests cover these failures and
palette changes before or during wake.
- Refreshes both sleeping canvases when the palette changes, including a
palette change in the same render as wake.
- Moves choreography values into the CSS token layer and preserves the
shared motion catalog drift check across the imported stylesheet.
- Repairs the static Storybook avatar route and uses accessible heading
names/current button labels in the wizard play functions.
- Closes the lazy avatar worker pool during application shutdown.

## Verification

- Merge-preparation checks: `pnpm -r typecheck`, `pnpm build`, `pnpm
build-storybook`, and `pnpm check:token-gates` pass. The final UI
typecheck and 123 focused onboarding, lifecycle, and token catalog tests
pass. All 55 checks on final head
`b4f5e201a1564083d163abc6f93f5b3da06ccefd` pass, including the full
sharded test suite, runner verification, and all eight browser shards
([CI
run](https://github.com/paperclipai/paperclip/actions/runs/35445430535)).
The duplicate monolithic local `pnpm test:run` was stopped after CI
completed; it is not claimed as a separate completed local run.
- Chromium walkthrough: palette change, wake, return to sleep, WebGL
failure fallback, Review step hand-offs and cloud arrival pass with
normal and reduced motion; no browser errors. The signoff happy-path
browser test also passes against a disposable instance.
- The final CI run confirms the catalog fix and a passing signoff
browser shard. The earlier signoff failure was a heartbeat-run
availability timeout; the focused local reproduction and final CI passed
without signoff code changes.
- Original author verification:
- `pnpm check:token-gates` (includes the new character sync check);
shared, server avatar/persona (17) and UI onboarding/persona (137)
suites pass; `pnpm build-storybook` packages all 3,564 avatar PNGs
through the worker pipeline.
- Storybook: `Agents / Personas` Sizes, Expressions and Palettes render
the studio character at every size and pose; `Onboarding / Character →
Wake Up` plays the wake on the shared engine; `Onboarding / Agent arc`
walks 1 → 4 with the hand-offs, and `Arrive From Cloud` plays the
arrival (measured: content room 6 → 65px over 320ms, fade to 1.0 by
~560ms, footer travel continuous).
- The original author walked the agent → connect → review flow and wake
after a real sign-in on staging.
- Not done here: the Linux Storybook visual baselines
(`tests/storybook-visual/agent-personas.spec.ts`) need re-baselining for
the new engine, hero size and naming-step changes.

## Risks

- Every avatar's pixels change (new engine, new character) under the
unchanged `cap-v1` name. Stacks that rendered avatars on the previous
engine keep those PNGs in their cache
(`generated-agent-avatars/cap-v1/...`, served immutable) until cleared;
only the two pinned staging stacks ever did.
- The one-shot handoff to the idle loop is timed from the sequence's
authored duration (the engine reports completion by continuing into idle
itself); presentation only, nothing in the wizard's state waits on it.
- Reduced motion skips the wake and the hand-offs; jsdom is treated the
same way, so the wizard tests see the next step's content immediately.
- The committed export differs from the studio by one animation (Loop
off, leading idle step removed); a re-export without that fix would play
a 5.6s idle before the wake.

## Model Used

Original feature: Anthropic Claude Fable 5.1 (`claude-fable-5-1`) in
Claude Code, with shell, browser, and file tools. The original context
window was not recorded.

Merge preparation and lifecycle regression fixes: OpenAI GPT-6 in Codex,
with reasoning, shell execution, file editing, GitHub CLI, and automated
tests. The session does not expose an exact runtime model ID or
context-window size.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Dotta <bippadotta@protonmail.com>
Co-authored-by: Paperclip <noreply@paperclip.ing>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
authored and GitHub committed 2026-09-19 08:30:14 -05:00
1 parent c9e8677979
commit 86b7ee992c
38 files changed
+5756 -225

No files matched your search

+16 -3
View File
@@ -3,13 +3,26 @@
"configurations": [
{
"name": "paperclip",
"port": 3121,
"url": "http://localhost:3121"
},
{
"name": "storybook",
"runtimeExecutable": "/bin/sh",
"runtimeArgs": [
"-c",
"TMPDIR=/tmp pnpm dev"
"PAPERCLIP_STORYBOOK_API_URL=http://localhost:3121 pnpm --filter @paperclipai/ui exec storybook dev -p 6016 -c storybook/.storybook --no-open --ci"
],
"port": 3108,
"autoPort": true
"port": 6016
},
{
"name": "cloud-web",
"runtimeExecutable": "/bin/sh",
"runtimeArgs": [
"-c",
"cd /Users/tonio/Projects/paperclip-cloud/.worktrees/deploy-stack-master/web && npx vite --port 4173 --strictPort"
],
"port": 4173
}
]
}
+13 -1
View File
@@ -3,7 +3,13 @@
Agents have a persisted visual identity independent of prompts and runtime
configuration: `{ schemaVersion: 1, characterVersion: "cap-v1", paletteId }`.
The cap-v1 library contains 17 permanent palettes and a presentation-only gray
Muted dream palette. New agents get one random assignment; existing rows are
Muted dream palette. The character itself is one ClipLab studio export —
`ui/src/assets/cliplab/onboarding.character.json` (end cap, custom idle,
sleepy-to-wake) — mirrored into the shared package as
`packages/shared/src/cliplab/character.ts` by
`node scripts/sync-cliplab-character.mjs` (`--check` detects drift); every
palette recolours its body. The onboarding hero and every avatar are the same
character on the same engine (ClipLab v0.2.0, see PROVENANCE.md). New agents get one random assignment; existing rows are
backfilled with the same ID-based mapping used by legacy clients. Duplicating
an agent generates another assignment. Export/import preserves it.
@@ -80,11 +86,17 @@ hashes and pixel dimensions; deployment verification fetches every image and che
its content type, PNG signature, dimensions, and hash. Dev Storybook still uses the
API proxy for cold-cache and regeneration testing.
The onboarding motion values live in `ui/src/motion-tokens.css`, imported by
`ui/src/index.css`. The JavaScript choreography reads these CSS tokens and uses
the same stylesheet for defaults before styles load. Reduced motion removes
transition durations; the connection status hold remains readable.
Focused checks:
```sh
pnpm exec vitest run packages/shared/src/agent-appearance.test.ts server/src/__tests__/agent-avatars.test.ts
node scripts/sync-agent-palette-tokens.mjs --check
node scripts/sync-cliplab-character.mjs --check
pnpm check:token-gates
pnpm build-storybook
```
+1 -1
View File
@@ -41,7 +41,7 @@
"release:rollback": "./scripts/rollback-latest.sh",
"release:bootstrap-package": "node scripts/bootstrap-npm-package.mjs",
"check:tokens": "node scripts/check-forbidden-tokens.mjs",
"check:token-gates": "node scripts/check-token-gates.mjs && node scripts/sync-agent-palette-tokens.mjs --check",
"check:token-gates": "node scripts/check-token-gates.mjs && node scripts/sync-agent-palette-tokens.mjs --check && node scripts/sync-cliplab-character.mjs --check",
"check:node-version": "node scripts/check-node-version-policy.mjs",
"check:no-git-push": "node scripts/check-no-git-push.mjs",
"check:module-boundaries": "node scripts/check-module-boundaries.mjs",
+3 -1
View File
@@ -16,9 +16,11 @@ describe("agent appearance", () => {
});
it("renders without a browser and preserves logical-size detail at high density", () => {
const appearance = appearanceForPalette("bubblegum-sky");
// ClipLab v0.2.0 draws an enlarged compact face from 16px; only 12px and
// below (not a logical size) is body-only.
const small = renderAgentSvg(appearance, 16, 2);
expect(small).toContain('width="32"');
expect(small).not.toContain('id="agent-face-visible"');
expect(small).toContain('id="agent-face-visible"');
const eyes = renderAgentSvg(appearance, 24, 2);
expect(eyes).toContain('width="48"');
expect(eyes).toContain('id="agent-face-visible"');
+2 -1
View File
@@ -1,7 +1,8 @@
# ClipLab cap-v1
Source: https://github.com/tonio-alucema/cliplab
Commit: a050f7246738db1d0763e286862747633293e2af
Release: v0.2.0 (runtime package 0.2.0)
Commit: 987b6db049471df815ebcb88bcd7abb46ed0c34b
License: MIT (see LICENSE). Three.js is MIT licensed.
Paperclip adaptations: ESM extensions, optional graphics backend for Node SVG
File diff suppressed because it is too large. Load diff
+15 -6
View File
@@ -1,18 +1,27 @@
import type { AgentAppearance, CharacterPaletteId, CharacterState } from "../agent-appearance.js";
import { defaultProject, definitionOf, sampleDefinition, BASE_POSE, type Definition, type Sample } from "./model.js";
import { definitionOf, sampleDefinition, type Definition, type Sample } from "./model.js";
import { CAP_V1_COLORS } from "./palette-tokens.js";
import { PAPERCLIP_CHARACTER } from "./character.js";
/**
* One character, every palette: the studio export's body, expressions and
* animations, recoloured with the agent's palette (or the presentation-only
* gray). Body and eye following are on for live placements; static
* portraits override them per render.
*/
export function characterDefinition(appearance: AgentAppearance, muted = false): Definition {
const project = defaultProject();
const colors = CAP_V1_COLORS[(muted ? "muted-dream" : appearance.paletteId) as CharacterPaletteId];
const character = { ...project.characters[1], id: "paperclip-cap-v1", name: "Agent", color: colors.a, color2: colors.b,
gradientAngle: 0, iris: false, toon: true, shadow: false, followCursor: true, followRotation: true };
const definition = definitionOf(project, character);
const character = { ...PAPERCLIP_CHARACTER.character, id: "paperclip-cap-v1", name: "Agent", color: colors.a, color2: colors.b, followCursor: true, followRotation: true };
const definition = definitionOf({ version: 1, name: "Paperclip", characters: [character], expressions: PAPERCLIP_CHARACTER.expressions, animations: PAPERCLIP_CHARACTER.animations }, character);
for (const animation of definition.animations) if (animation.id === "happy") animation.loop = false;
return definition;
}
export function animationId(state: CharacterState) { return state === "success" ? "happy" : state === "rest" ? "idle" : state; }
/** A fixed sample per state for still portraits; rest is the idle expression's first beat, held. */
export function characterStill(definition: Definition, state: CharacterState): Sample {
if (state === "rest") return { pose: { ...BASE_POSE }, blink: 0, bob: 0, breathe: 0, expressionId: "idle", beatIndex: 0, stepIndex: 0 };
if (state === "rest") {
const idle = definition.expressions.find((e) => e.id === "idle") ?? definition.expressions[0]!;
return { pose: { ...idle.beats[0]!.pose }, blink: 0, bob: 0, breathe: 0, expressionId: idle.id, beatIndex: 0, stepIndex: 0, faceLayers: undefined };
}
return sampleDefinition(definition, animationId(state), 0.6);
}
+9 -3
View File
@@ -1,4 +1,4 @@
// Vendored from ClipLab a050f724; see PROVENANCE.md and LICENSE.
// Vendored from ClipLab 987b6db0 (v0.2.0); see PROVENANCE.md and LICENSE.
import type { Detail, FaceLayer, Pose } from './model.js'
import { irisOffset, type Gaze } from './gaze.js'
@@ -183,6 +183,12 @@ export function drawMorphMouth(ctx: CanvasRenderingContext2D, pose: Pose, layers
if (tongue > 0) { ctx.globalAlpha = tongue; ctx.fillStyle = '#f37b83'; ctx.beginPath(); ctx.ellipse(10, h * .65, w * .65, h * .34, -.1, 0, TAU); ctx.fill() }
if (teeth > 0) { ctx.globalAlpha = teeth; ctx.fillStyle = '#fffef8'; ctx.beginPath(); ctx.roundRect(-w * .76, -23, w * 1.52, 30, 12); ctx.fill() }
ctx.restore()
// Match the still renderer: interior details must not paint over the inner
// half of the lip stroke. Clip to the blended outline during shape morphs.
if (tongue > 0 || teeth > 0) {
ctx.save(); trace(ctx, outline); ctx.clip(); trace(ctx, opening)
ctx.strokeStyle = ink; ctx.lineWidth = 17 * pose.mouthStroke; ctx.lineJoin = 'round'; ctx.stroke(); ctx.restore()
}
const out = traitWeight(layers, t => t.mouth === 'tongue-out')
if (out > 0) {
const w = 61 * pose.mouthWidth
@@ -192,14 +198,14 @@ export function drawMorphMouth(ctx: CanvasRenderingContext2D, pose: Pose, layers
if (drool > 0) drawDrool(ctx, droolAnchor(outline, w), drool)
}
export function drawMorphBrow(ctx: CanvasRenderingContext2D, layers: FaceLayer[], r: number, side: number, ink: string) {
export function drawMorphBrow(ctx: CanvasRenderingContext2D, layers: FaceLayer[], r: number, side: number, ink: string, stroke = 1, length = 1) {
if (!layers.some(layer => layer.traits.brows !== 'none')) return
const shapes = layers.map(({ traits }) => {
if (traits.brows === 'none') return contour([pt(0, 0)])
const path = traits.brows === 'raised' ? arc(0, 10, r * .85, r * .85, Math.PI * 1.15, Math.PI * 1.85)
: traits.brows === 'worried' ? quad(pt(side * r, 2), pt(-side * r * .1, 10), pt(-side * r * .75, -16))
: [pt(side * r, -10), pt(-side * r * .75, 8)]
return contour(ribbon(path, 12))
return contour(ribbon(path.map(point => ({ ...point, x: point.x * length })), 12 * stroke))
})
ctx.fillStyle = ink; trace(ctx, blendContours(shapes, layers)); ctx.fill()
}
+1 -1
View File
@@ -1,4 +1,4 @@
// Vendored from ClipLab a050f724; see PROVENANCE.md and LICENSE.
// Vendored from ClipLab 987b6db0 (v0.2.0); see PROVENANCE.md and LICENSE.
export interface Gaze { x: number; y: number }
export interface PointerLook extends Gaze { weight: number }
export interface EyeGazes { left: Gaze; right: Gaze }
+32 -14
View File
@@ -1,4 +1,4 @@
// Vendored from ClipLab a050f724; see PROVENANCE.md and LICENSE.
// Vendored from ClipLab 987b6db0 (v0.2.0); see PROVENANCE.md and LICENSE.
export type Shape = 'capsule' | 'cap' | 'sphere'
export type Eye = 'dot' | 'soft' | 'closed' | 'wink' | 'star' | 'heart' | 'squint' | 'wide' | 'arc-up' | 'arc-down' | 'half-lidded' | 'pupil'
export type Mouth = 'smile' | 'open' | 'line' | 'frown' | 'oh' | 'wave' | 'sleep' | 'grin' | 'cry' | 'u-smile' | 'kiss' | 'tongue-out'
@@ -6,8 +6,8 @@ export type Prop = 'none' | 'zzz' | 'sparkle' | 'heart' | 'question' | 'sweat' |
export type Detail = 'body' | 'eyes' | 'full'
export interface Pose {
eye: Eye; mouth: Mouth; prop: Prop
faceSet: 'set-1' | 'set-2'; brows: 'none' | 'raised' | 'worried' | 'angry'; blush: number
eye: Eye; mouth: Mouth; prop: Prop; propSize: number; propCount: number; propOutward: number
faceSet: 'set-1' | 'set-2'; brows: 'none' | 'raised' | 'worried' | 'angry'; browStroke: number; browLength: number; blush: number
eyeSize: number; eyeHeight: number; spacing: number; eyeTilt: number
leftScale: number; rightScale: number; gazeX: number; gazeY: number
leftX: number; rightX: number; leftY: number; rightY: number; leftRotation: number; rightRotation: number
@@ -15,7 +15,8 @@ export interface Pose {
rotationX: number; rotationY: number; rotationZ: number; squash: number
tongue: boolean; teeth: boolean; drool: boolean; cheeks: boolean; tears: boolean; mouthStroke: number
}
export interface Beat { id: string; name: string; duration: number; pose: Pose; gradientAction?: 'rotate' | 'hold' }
export interface Beat { id: string; name: string; duration: number; pose: Pose; gradientAction?: 'rotate' | 'hold'; gradientTurns?: number }
export interface FavoriteBeat { id: string; sourceBeatId: string; beat: Beat }
export interface Expression { id: string; name: string; description: string; beats: Beat[]; poseExpressionId?: string }
export interface Step { id: string; expressionId: string; duration: number }
export interface Animation { id: string; name: string; steps: Step[]; loop: boolean }
@@ -26,7 +27,19 @@ export interface Character {
motion: number; speed: number; blink: boolean; blinkInterval: number; followCursor: boolean; followRotation: boolean
}
export interface Definition { version: 1; character: Character; expressions: Expression[]; animations: Animation[] }
export interface Project { version: 1; name: string; characters: Character[]; expressions: Expression[]; animations: Animation[]; defaultsRevision?: number }
export interface Project { version: 1; name: string; characters: Character[]; expressions: Expression[]; animations: Animation[]; defaultsRevision?: number; favoriteBeats?: FavoriteBeat[] }
/** Remove references too, keeping every animation playable and the project importable. */
export function removeExpression(project: Project, id: string) {
if (project.expressions.length <= 1 || !project.expressions.some(e => e.id === id)) return false
project.expressions = project.expressions.filter(e => e.id !== id)
const fallback = project.expressions[0]!
for (const expression of project.expressions) if (expression.poseExpressionId === id) delete expression.poseExpressionId
for (const animation of project.animations) {
animation.steps = animation.steps.filter(step => step.expressionId !== id)
if (!animation.steps.length) animation.steps.push({ id: uid('step'), expressionId: fallback.id, duration: expressionDuration(fallback) })
}
return true
}
export type FaceTraits = Pick<Pose, 'eye' | 'mouth' | 'faceSet' | 'brows' | 'cheeks' | 'tongue' | 'teeth' | 'drool'>
export interface FaceLayer { traits: FaceTraits; weight: number }
export interface Sample { pose: Pose; blink: number; bob: number; breathe: number; expressionId: string; beatIndex: number; stepIndex: number; effectPhase?: number; propAmount?: number; tearAmount?: number; gradientRotation?: number; gradientMix?: number; faceLayers?: FaceLayer[] }
@@ -42,11 +55,11 @@ export const PALETTES = [
['#f0ce58', '#fff1a5'], ['#ee98b2', '#ffd2da'], ['#72b6de', '#b9e4f5']
]
export function detailAt(size: number): Detail { return size <= 16 ? 'body' : size <= 24 ? 'eyes' : 'full' }
export function detailAt(size: number): Detail { return size <= 12 ? 'body' : 'full' }
export function uid(prefix = 'item') { return `${prefix}-${crypto.randomUUID().slice(0, 8)}` }
export function clone<T>(value: T): T { return JSON.parse(JSON.stringify(value)) as T }
export const BASE_POSE: Pose = {
eye: 'dot', mouth: 'smile', prop: 'none', faceSet: 'set-1', brows: 'none', blush: 0, eyeSize: 1, eyeHeight: 1, spacing: 1, eyeTilt: 0,
eye: 'dot', mouth: 'smile', prop: 'none', propSize: 1, propCount: 0, propOutward: 1, faceSet: 'set-1', brows: 'none', browStroke: 1, browLength: 1, blush: 0, eyeSize: 1, eyeHeight: 1, spacing: 1, eyeTilt: 0,
leftScale: 1, rightScale: 1, gazeX: 0, gazeY: 0, mouthWidth: 1, mouthOpen: .5,
leftX: 0, rightX: 0, leftY: 0, rightY: 0, leftRotation: 0, rightRotation: 0,
faceScale: .75, faceY: 0, rotationX: 0, rotationY: 0, rotationZ: 0, squash: 1,
@@ -96,7 +109,7 @@ export function defaultExpressions(): Expression[] {
export function defaultProject(): Project {
const base: Character = {
id: 'milo', name: 'Milo', shape: 'capsule', color: '#ff986d', color2: '#ffd092', gradient: true,
gradientAngle: 20, toon: true, trueFront: false, lockPosition: false, eyeColor: '#080909', iris: false, elevated: false, elevation: .065,
gradientAngle: -24, toon: true, trueFront: false, lockPosition: false, eyeColor: '#080909', iris: false, elevated: false, elevation: .065,
shadow: true, motion: .45, speed: 1, blink: true, blinkInterval: 4.2, followCursor: false, followRotation: false
}
const expressions = defaultExpressions()
@@ -178,6 +191,7 @@ export function mixPose(a: Pose, b: Pose, t: number): Pose {
const smooth = (t: number) => { const v = Math.max(0, Math.min(1, t)); return v * v * (3 - 2 * v) }
function featureAmount(a: unknown, b: unknown, blend: number) { return a === b ? 1 : blend < .5 ? 1 - smooth(blend * 2) : smooth((blend - .5) * 2) }
const mod = (t: number, d: number) => ((t % d) + d) % d
export const gradientTurns = (beat: Beat) => Number.isFinite(beat.gradientTurns) ? Math.max(1, Math.min(8, Math.round(beat.gradientTurns!))) : 1
export function sampleExpression(expression: Expression, time: number, expressions: Expression[] = [], visited = new Set<string>()): { pose: Pose; beatIndex: number; propAmount: number; tearAmount: number; gradientRotation?: number; gradientMix?: number; faceLayers: FaceLayer[] } {
const duration = expressionDuration(expression)
let t = mod(time, duration)
@@ -195,9 +209,9 @@ export function sampleExpression(expression: Expression, time: number, expressio
const turnEased = (1 - Math.cos(Math.PI * turn)) / 2
const source = expressions.find(e => e.id === expression.poseExpressionId && !visited.has(e.id) && e.id !== expression.id)
const face = source ? sampleExpression(source, mod(time, duration) / duration * expressionDuration(source), expressions, new Set([...visited, expression.id])) : undefined
return { pose: face?.pose ?? mixPose(previous.pose, beat.pose, eased), faceLayers: face?.faceLayers ?? mixFaceLayers(faceLayers(previous.pose), faceLayers(beat.pose), eased), beatIndex: i, propAmount: face?.propAmount ?? featureAmount(previous.pose.prop, beat.pose.prop, eased), tearAmount: face?.tearAmount ?? featureAmount(previous.pose.tears, beat.pose.tears, eased), ...(gradient ? { gradientRotation: gradientStart + (beat.gradientAction === 'rotate' ? 360 * turnEased : 0), gradientMix: 1 } : {}) }
return { pose: face?.pose ?? mixPose(previous.pose, beat.pose, eased), faceLayers: face?.faceLayers ?? mixFaceLayers(faceLayers(previous.pose), faceLayers(beat.pose), eased), beatIndex: i, propAmount: face?.propAmount ?? featureAmount(previous.pose.prop, beat.pose.prop, eased), tearAmount: face?.tearAmount ?? featureAmount(previous.pose.tears, beat.pose.tears, eased), ...(gradient ? { gradientRotation: gradientStart + (beat.gradientAction === 'rotate' ? 360 * gradientTurns(beat) * turnEased : 0), gradientMix: 1 } : {}) }
}
if (beat.gradientAction === 'rotate') gradientStart += 360
if (beat.gradientAction === 'rotate') gradientStart += 360 * gradientTurns(beat)
t -= beatDuration
}
return { pose: p(), faceLayers: faceLayers(p()), beatIndex: 0, propAmount: 1, tearAmount: 1 }
@@ -266,13 +280,17 @@ function choice<T extends string>(v: unknown, options: T[], fallback: T) { retur
function boolean(v: unknown, fallback: boolean) { return typeof v === 'boolean' ? v : fallback }
function parsePose(value: unknown): Pose {
const v = obj(value), pose = { ...BASE_POSE }
const ranges: Record<string, [number, number]> = { blush: [0, 1], mouthStroke: [.5, 2.2], leftX: [-60, 60], rightX: [-60, 60], leftY: [-60, 60], rightY: [-60, 60], leftRotation: [-90, 90], rightRotation: [-90, 90], eyeSize: [.35, 2], eyeHeight: [.15, 2], spacing: [.5, 1.6], eyeTilt: [-45, 45], leftScale: [.3, 1.8], rightScale: [.3, 1.8], gazeX: [-1, 1], gazeY: [-1, 1], mouthWidth: [.3, 1.7], mouthOpen: [.1, 1], faceScale: [.6, 1.4], faceY: [-.3, .3], rotationX: [-180, 180], rotationY: [-180, 180], rotationZ: [-180, 180], squash: [.8, 1.2] }
const ranges: Record<string, [number, number]> = { propSize: [.5, 2], propCount: [0, 6], propOutward: [0, 3], browStroke: [.3, 2.5], browLength: [.5, 2], blush: [0, 1], mouthStroke: [.5, 2.2], leftX: [-60, 60], rightX: [-60, 60], leftY: [-60, 60], rightY: [-60, 60], leftRotation: [-90, 90], rightRotation: [-90, 90], eyeSize: [.35, 2], eyeHeight: [.15, 2], spacing: [.5, 1.6], eyeTilt: [-45, 45], leftScale: [.3, 1.8], rightScale: [.3, 1.8], gazeX: [-1, 1], gazeY: [-1, 1], mouthWidth: [.3, 1.7], mouthOpen: [.1, 1], faceScale: [.6, 1.4], faceY: [-.3, .3], rotationX: [-180, 180], rotationY: [-180, 180], rotationZ: [-180, 180], squash: [.8, 1.2] }
for (const key of numericKeys) { const [lo, hi] = ranges[key]!; (pose as unknown as Record<string, unknown>)[key] = num(v[key], BASE_POSE[key] as number, lo, hi) }
return { ...pose, faceSet: choice(v.faceSet, ['set-1', 'set-2'], 'set-1'), brows: choice(v.brows, ['none', 'raised', 'worried', 'angry'], 'none'), eye: choice(v.eye, EYES, 'dot'), mouth: choice(v.mouth, MOUTHS, 'smile'), prop: choice(v.prop, PROPS, 'none'), tongue: boolean(v.tongue, false), teeth: boolean(v.teeth, false), drool: boolean(v.drool, false), cheeks: boolean(v.cheeks, false), tears: boolean(v.tears, false) }
}
export function parseCharacter(value: unknown): Character {
const v = obj(value), d = defaultProject().characters[0]!
return { ...d, id: str(v.id, uid('character')), name: str(v.name, 'Character'), shape: choice(v.shape, ['capsule', 'cap', 'sphere'], 'capsule'), color: color(v.color, d.color), color2: color(v.color2, d.color2), gradient: boolean(v.gradient, true), gradientAngle: num(v.gradientAngle, 20, -180, 180), toon: boolean(v.toon, d.toon), trueFront: boolean(v.trueFront, false), lockPosition: boolean(v.lockPosition, false), eyeColor: color(v.eyeColor, d.eyeColor), iris: boolean(v.iris, false), elevated: boolean(v.elevated, false), elevation: num(v.elevation, .065, .005, .2), shadow: boolean(v.shadow, true), motion: num(v.motion, .45, 0, 1), speed: num(v.speed, 1, .25, 3), blink: boolean(v.blink, true), blinkInterval: num(v.blinkInterval, 4.2, 1, 12), followCursor: boolean(v.followCursor, false), followRotation: boolean(v.followRotation, false) }
return { ...d, id: str(v.id, uid('character')), name: str(v.name, 'Character'), shape: choice(v.shape, ['capsule', 'cap', 'sphere'], 'capsule'), color: color(v.color, d.color), color2: color(v.color2, d.color2), gradient: boolean(v.gradient, true), gradientAngle: num(v.gradientAngle, -24, -180, 180), toon: boolean(v.toon, d.toon), trueFront: boolean(v.trueFront, false), lockPosition: boolean(v.lockPosition, false), eyeColor: color(v.eyeColor, d.eyeColor), iris: boolean(v.iris, false), elevated: boolean(v.elevated, false), elevation: num(v.elevation, .065, .005, .2), shadow: boolean(v.shadow, true), motion: num(v.motion, .45, 0, 1), speed: num(v.speed, 1, .25, 3), blink: boolean(v.blink, true), blinkInterval: num(v.blinkInterval, 4.2, 1, 12), followCursor: boolean(v.followCursor, false), followRotation: boolean(v.followRotation, false) }
}
function parseBeat(item: unknown, index: number): Beat {
const b = obj(item)
return { id: str(b.id, `beat-${index}`), name: str(b.name, `Beat ${index + 1}`), duration: num(b.duration, 1.5, .2, 15), pose: parsePose(b.pose), ...(b.gradientAction === 'rotate' || b.gradientAction === 'hold' ? { gradientAction: b.gradientAction, ...(b.gradientTurns !== undefined ? { gradientTurns: Math.round(num(b.gradientTurns, 1, 1, 8)) } : {}) } : {}) }
}
export function parseProject(value: unknown): Project {
const v = obj(value)
@@ -282,7 +300,7 @@ export function parseProject(value: unknown): Project {
const expressions = v.expressions.map((item, index): Expression => {
const e = obj(item)
if (!Array.isArray(e.beats) || !e.beats.length || e.beats.length > 32) throw new Error('Each expression needs 1–32 beats.')
return { id: str(e.id, `expression-${index}`), name: str(e.name, 'Expression'), description: str(e.description, '', 160), ...(typeof e.poseExpressionId === 'string' && e.poseExpressionId ? { poseExpressionId: e.poseExpressionId } : {}), beats: e.beats.map((item, i) => { const b = obj(item); return { id: str(b.id, `beat-${i}`), name: str(b.name, `Beat ${i + 1}`), duration: num(b.duration, 1.5, .2, 15), pose: parsePose(b.pose), ...(b.gradientAction === 'rotate' || b.gradientAction === 'hold' ? { gradientAction: b.gradientAction } : {}) } }) }
return { id: str(e.id, `expression-${index}`), name: str(e.name, 'Expression'), description: str(e.description, '', 160), ...(typeof e.poseExpressionId === 'string' && e.poseExpressionId ? { poseExpressionId: e.poseExpressionId } : {}), beats: e.beats.map(parseBeat) }
})
if (new Set(expressions.map(e => e.id)).size !== expressions.length) throw new Error('Expression names in the file must have unique IDs.')
for (const expression of expressions) {
@@ -302,7 +320,7 @@ export function parseProject(value: unknown): Project {
})
const characters = charactersRaw.map(parseCharacter)
if (new Set(characters.map(c => c.id)).size !== characters.length || new Set(animations.map(a => a.id)).size !== animations.length) throw new Error('Characters and animations need unique IDs.')
return { version: 1, name: str(v.name, 'My character studio'), characters, expressions, animations, ...(typeof v.defaultsRevision === 'number' ? { defaultsRevision: num(v.defaultsRevision, 2, 0, 1000) } : {}) }
return { version: 1, name: str(v.name, 'My character studio'), characters, expressions, animations, ...(Array.isArray(v.favoriteBeats) ? { favoriteBeats: v.favoriteBeats.slice(0, 200).map((item, i) => { const f = obj(item); return { id: str(f.id, `favorite-${i}`), sourceBeatId: str(f.sourceBeatId, ''), beat: parseBeat(f.beat, i) } }) } : {}), ...(typeof v.defaultsRevision === 'number' ? { defaultsRevision: num(v.defaultsRevision, 2, 0, 1000) } : {}) }
}
/** Upgrade only untouched studio defaults, once; portable definitions stay literal. */
+26
View File
@@ -0,0 +1,26 @@
// Vendored from ClipLab 987b6db0 (v0.2.0); see PROVENANCE.md and LICENSE.
import type { Pose } from './model.js'
export type ParticleSettings = Pick<Pose, 'propSize' | 'propCount' | 'propOutward'>
export const defaultParticleCount = (prop: Pose['prop']) => ['zzz', 'sparkle', 'heart'].includes(prop) ? 3 : prop === 'none' ? 0 : 1
export const particleCount = (prop: Pose['prop'], count = 0) => prop === 'none' ? 0 : count > 0 ? Math.max(1, Math.min(6, Math.round(count))) : defaultParticleCount(prop)
export function particleLayout(prop: Pose['prop'], phase?: number, settings: Partial<ParticleSettings> = {}) {
const count = particleCount(prop, settings.propCount)
const size = settings.propSize ?? 1, outward = settings.propOutward ?? 1
// A fixed padded canvas for the entire cycle prevents large/outward particles
// clipping against the texture edge or changing size as the padding changes.
const radius = prop === 'crown' ? 100 : prop === 'question' ? 105 : prop === 'sweat' ? 80 : 48
const extent = Math.max(256, 2 * (110 + outward * 90 + radius * size))
const particles = Array.from({ length: count }, (_, i) => {
const index = count > 1 ? i * 2 / (count - 1) : 0
const p = (((phase ?? .34) + i * .29) % 1 + 1) % 1
const smooth = (x: number) => { const t = Math.max(0, Math.min(1, x)); return t * t * (3 - 2 * t) }
const ease = p * p
const x = count === 1 ? 128 : (prop === 'zzz' ? 45 + index * 64 : 51 + index * 69)
const y = count === 1 ? 132 : (prop === 'zzz' ? 204 - index * 58 : 198 - index * 60)
return { index, x: x + ease * 36 * outward, y: y - ease * (count === 1 ? 26 : prop === 'zzz' ? 54 : 38) * outward,
scale: size * (phase === undefined ? 1 : .62 + .38 * smooth(p / .38)),
alpha: phase === undefined ? 1 : smooth(p / .18) * (1 - smooth((p - .68) / .32)) }
})
return { extent, particles }
}
+66 -47
View File
@@ -1,6 +1,9 @@
// Vendored from ClipLab a050f724; see PROVENANCE.md and LICENSE.
// ClipLab renderer adapted for Paperclip (optional graphics backend for Node SVG snapshots,
// supersampled live textures, character framing). Geometry and face code remain upstream v0.2.0.
// Vendored from ClipLab 987b6db0 (v0.2.0); see PROVENANCE.md and LICENSE.
import * as THREE from 'three'
import { BASE_POSE, detailAt, type Character, type Detail, type FaceLayer, type Pose, type Sample, type Shape } from './model.js'
import { particleLayout, type ParticleSettings } from './particles.js'
import { BASE_POSE, detailAt, faceLayers, type Character, type Detail, type FaceLayer, type Pose, type Sample, type Shape } from './model.js'
import { clampGaze, gazeAtPoint, irisOffset, localGaze, type EyeGazes, type Gaze, type PointerLook } from './gaze.js'
import { drawMorphBrow, drawMorphEye, drawMorphMouth, drawDrool, droolAnchor, mouthGeometry, traitWeight } from './face-morph.js'
@@ -10,28 +13,31 @@ export interface RenderOptions {
pointerLook?: PointerLook; eyeGazes?: EyeGazes; reducedMotion?: boolean
}
/** Cursor pitch owns the vertical look direction, independent of the resting tilt. */
export function characterRotation(character: Pick<Character, 'trueFront' | 'followRotation'>, pose: Pick<Pose, 'rotationX' | 'rotationY' | 'rotationZ'>, rotation = { x: -5, y: -12, z: -7 }, cursor: Gaze = { x: 0, y: 0 }) {
export function characterRotation(character: Pick<Character, 'trueFront' | 'followRotation'>, pose: Pick<Pose, 'rotationX' | 'rotationY' | 'rotationZ'>, rotation = { x: -5.9, y: -24.2, z: 0 }, cursor: Gaze = { x: 0, y: 0 }) {
if (character.trueFront) return { x: 0, y: 0, z: 0 }
return {
x: character.followRotation ? -cursor.y * 16 : rotation.x + pose.rotationX,
y: rotation.y + pose.rotationY + (character.followRotation ? cursor.x * 28 : 0),
z: rotation.z + pose.rotationZ
y: character.followRotation ? cursor.x * 28 : rotation.y + pose.rotationY,
z: character.followRotation ? 0 : rotation.z + pose.rotationZ
}
}
/** One rounded light region; the body-following toggle activates
* a subtle light-source shift. The shared eased cursor keeps motion smooth. */
export function toonLightOffset(character: Pick<Character, 'followRotation'>, cursor: Gaze = { x: 0, y: 0 }, reducedMotion = false): Gaze {
const look = !reducedMotion && character.followRotation ? clampGaze(cursor) : { x: 0, y: 0 }
/** Anchor the rounded highlight to the face direction shown by the orbit marker.
* Match the former cursor travel at 28° yaw / 16° pitch, with a bounded rim. */
export function toonLightOffset(orientation: THREE.Quaternion): Gaze {
const facing = new THREE.Vector3(0, 0, 1).applyQuaternion(orientation)
const look = clampGaze({ x: facing.x / Math.sin(28 * Math.PI / 180), y: facing.y / Math.sin(16 * Math.PI / 180) })
return { x: -.015 + look.x * .045, y: .015 + look.y * .045 }
}
/** Size adaptation is render-only; larger sizes restore the authored appearance. */
export function faceForSize(character: Character, sample: Sample, size: number) {
const simpleEyes = size > 16 && size <= 48
const frontOnly = size <= 24
const flatFill = size < 48
const simpleEyes = size > 12 && size <= 48
const frontOnly = size <= 32
const flatFill = size < 40 && size !== 32
const smallerSpacedEyes = size === 24 || size === 32
if (size === 16) sample = { ...sample, pose: { ...sample.pose, faceScale: sample.pose.faceScale * 1.2 } }
return {
character: simpleEyes || flatFill ? { ...character, ...(simpleEyes ? { iris: false } : {}), ...(flatFill ? { toon: false, shadow: false } : {}), ...(frontOnly ? { trueFront: true, lockPosition: true, followRotation: false } : {}) } : character,
sample: simpleEyes || frontOnly ? { ...sample, pose: { ...sample.pose, faceScale: sample.pose.faceScale * (simpleEyes ? 1.3 : 1), eyeSize: sample.pose.eyeSize * (size === 24 ? 1.2 : 1), ...(frontOnly ? { squash: 1 } : {}), ...(size === 24 ? { gazeX: -4, gazeY: 0, leftX: 0, rightX: 0, leftY: 0, rightY: 0, spacing: BASE_POSE.spacing * .7, faceY: BASE_POSE.faceY } : {}) } } : sample,
character: simpleEyes || flatFill ? { ...character, ...(simpleEyes ? { iris: false } : {}), ...(flatFill ? { toon: false } : {}), ...(size < 40 ? { shadow: false } : {}), ...(frontOnly ? { trueFront: true, lockPosition: true, followRotation: false } : {}) } : character,
sample: simpleEyes || frontOnly ? { ...sample, ...(frontOnly ? { faceLayers: undefined } : {}), pose: { ...sample.pose, faceScale: sample.pose.faceScale * (simpleEyes ? 1.3 : 1), eyeSize: sample.pose.eyeSize * (size === 24 ? 1.2 : 1) * (size >= 16 && size <= 32 ? 1.1664 : 1) * (smallerSpacedEyes ? .8 : 1), ...(frontOnly ? { squash: 1 } : {}), ...(frontOnly ? { mouth: 'smile' as const, prop: 'none' as const, drool: false, tears: false, blush: 0, brows: 'none' as const, mouthWidth: BASE_POSE.mouthWidth * (size >= 16 ? 1.44 : 1), mouthStroke: BASE_POSE.mouthStroke * (size >= 16 ? 1.44 : 1), gazeX: -4, gazeY: 0, leftX: 0, rightX: 0, leftY: 0, rightY: 0, spacing: BASE_POSE.spacing * .77 * (smallerSpacedEyes ? 1.2 : 1), faceY: BASE_POSE.faceY + (size === 32 ? .025 * bodyHeight(character.shape) : 0) } : {}) } } : sample,
simpleEyes
}
}
@@ -74,7 +80,7 @@ const fragmentShader = `
float t = clamp(0.5 + vPosition.y / bodyHeight * cos(angle) + vPosition.x * sin(angle), 0.0, 1.0);
vec3 color = mix(colorA, mix(colorB, colorA, t), gradientOn);
// The light fill uses an inset copy of the silhouette, with one crisp shade step.
float shade = mix(0.8, 1.0, insetFill);
float shade = mix(0.82, 1.0, insetFill);
gl_FragColor = vec4(color * mix(1.0, shade, toonOn), 1.0);
#include <colorspace_fragment>
}
@@ -138,7 +144,10 @@ export function resolveEyeGazes(character: Character, pose: Pose, blink: number,
function drop(ctx: CanvasRenderingContext2D, x: number, y: number, r: number) {
ctx.beginPath(); ctx.moveTo(x, y - r * 1.5); ctx.bezierCurveTo(x - r * .25, y - r * .8, x - r, y - r * .2, x - r, y + r * .35); ctx.bezierCurveTo(x - r, y + r * 1.65, x + r, y + r * 1.65, x + r, y + r * .35); ctx.bezierCurveTo(x + r, y - r * .2, x + r * .25, y - r * .8, x, y - r * 1.5); ctx.fill()
}
export function drawFace(ctx: CanvasRenderingContext2D, pose: Pose, character: Character, blink: number, detail: Detail, gaze: { x: number; y: number }, effects: { phase?: number; tearAmount?: number; eyeGazes?: EyeGazes; faceLayers?: FaceLayer[]; simpleEyes?: boolean } = {}) {
export function compactMouthOffset(size: number, faceScale: number, viewHeight: number, outputHeight: number) {
return (size >= 16 && size < 32) ? 512 * viewHeight / outputHeight / (.57 * faceScale) : 0
}
export function drawFace(ctx: CanvasRenderingContext2D, pose: Pose, character: Character, blink: number, detail: Detail, gaze: { x: number; y: number }, effects: { phase?: number; tearAmount?: number; eyeGazes?: EyeGazes; faceLayers?: FaceLayer[]; simpleEyes?: boolean; mouthFollowsEyes?: boolean; mouthOffset?: number } = {}) {
ctx.clearRect(0, 0, 512, 512)
if (detail === 'body') return
const ink = character.eyeColor, eyeY = detail === 'eyes' ? 254 : 197, spacing = 103 * pose.spacing
@@ -202,15 +211,9 @@ export function drawFace(ctx: CanvasRenderingContext2D, pose: Pose, character: C
}
}
ctx.restore()
if (detail === 'full' && layers) {
if (detail === 'full' && (layers || pose.brows !== 'none')) {
ctx.save(); ctx.translate(x, y - r * (1.45 + .35 * pupilAmount)); ctx.scale(1, faceAspect)
drawMorphBrow(ctx, layers, r, side, ink); ctx.restore()
} else if (detail === 'full' && pose.brows !== 'none') {
ctx.save(); ctx.translate(x, y - (pupilEyes ? r * 1.8 : r * 1.45)); ctx.scale(1, faceAspect); ctx.strokeStyle = ink; ctx.lineWidth = 12; ctx.beginPath()
if (pose.brows === 'raised') { ctx.arc(0, 10, r * .85, Math.PI * 1.15, Math.PI * 1.85) }
else if (pose.brows === 'worried') { ctx.moveTo(side * r, 2); ctx.quadraticCurveTo(-side * r * .1, 10, -side * r * .75, -16) }
else { ctx.moveTo(side * r, -10); ctx.lineTo(-side * r * .75, 8) }
ctx.stroke(); ctx.restore()
drawMorphBrow(ctx, layers ?? faceLayers(pose), r, side, ink, pose.browStroke ?? 1, pose.browLength ?? 1); ctx.restore()
}
if (pose.tears && detail === 'full') {
const phase = fract((effects.phase ?? .35) + (side < 0 ? .46 : 0)), alpha = effectEnvelope(phase) * (effects.tearAmount ?? 1)
@@ -218,11 +221,13 @@ export function drawFace(ctx: CanvasRenderingContext2D, pose: Pose, character: C
}
}
if (detail === 'eyes') return
const mouthX = effects.mouthFollowsEyes ? 256 + gx * (1 - pupilAmount) + (pose.leftX + pose.rightX) / 2 : 256 + gx * .25
const mouthY = (effects.mouthFollowsEyes ? 275 + gy : 293 + gy * .25) + (effects.mouthOffset ?? 0)
if (layers) {
ctx.save(); ctx.translate(256 + gx * .25, 293 + gy * .25); ctx.scale(1, faceAspect)
ctx.save(); ctx.translate(mouthX, mouthY); ctx.scale(1, faceAspect)
drawMorphMouth(ctx, pose, layers, ink); ctx.restore(); return
}
const x = 256 + gx * .25, y = (pose.mouth === 'cry' ? 329 : 293) + gy * .25
const x = mouthX, y = pose.mouth === 'cry' ? 329 + gy * .25 : mouthY
const broad = ['open', 'grin', 'cry'].includes(pose.mouth), w = (broad ? 124 : pose.mouth === 'oh' ? 51 : 61) * pose.mouthWidth
const line = 17 * pose.mouthStroke
ctx.save(); ctx.translate(x, y); ctx.scale(1, faceAspect); ctx.fillStyle = ink; ctx.strokeStyle = ink; ctx.lineWidth = line; ctx.beginPath()
@@ -260,15 +265,15 @@ export function drawFace(ctx: CanvasRenderingContext2D, pose: Pose, character: C
ctx.restore()
}
export function drawProp(ctx: CanvasRenderingContext2D, prop: Pose['prop'], color: string, phase?: number) {
export function drawProp(ctx: CanvasRenderingContext2D, prop: Pose['prop'], color: string, phase?: number, settings: Partial<ParticleSettings> = {}) {
ctx.clearRect(0, 0, 256, 256)
const count = ['zzz', 'sparkle', 'heart'].includes(prop) ? 3 : 1
for (let i = 0; i < count; i++) {
const p = fract((phase ?? .34) + i * .29), ease = p * p
ctx.save(); ctx.globalAlpha = phase === undefined ? 1 : effectEnvelope(p)
ctx.translate(prop === 'zzz' ? 45 + i * 64 + ease * 36 : count === 1 ? 128 : 51 + i * 69, prop === 'zzz' ? 204 - i * 58 - ease * 54 : count === 1 ? 132 - ease * 26 : 198 - i * 60 - ease * 38)
const scale = phase === undefined ? 1 : .62 + .38 * smoothstep(0, .38, p)
ctx.scale(scale, scale); ctx.fillStyle = color; ctx.strokeStyle = color; ctx.lineWidth = 11; ctx.lineCap = 'round'; ctx.lineJoin = 'round'
const layout = particleLayout(prop, phase, settings)
ctx.save(); ctx.translate(128, 128); ctx.scale(256 / layout.extent, 256 / layout.extent); ctx.translate(-128, -128)
for (const particle of layout.particles) {
const i = particle.index
ctx.save(); ctx.globalAlpha = particle.alpha
ctx.translate(particle.x, particle.y); ctx.scale(particle.scale, particle.scale)
ctx.fillStyle = color; ctx.strokeStyle = color; ctx.lineWidth = 11; ctx.lineCap = 'round'; ctx.lineJoin = 'round'
if (prop === 'zzz') { const r = 14 + i * 5; ctx.beginPath(); ctx.moveTo(-r, -r); ctx.lineTo(r, -r); ctx.lineTo(-r, r); ctx.lineTo(r, r); ctx.stroke() }
else if (prop === 'sparkle') star(ctx, 0, 0, 23 + i * 4, 4)
else if (prop === 'heart') heart(ctx, 0, 0, 17 + i * 3)
@@ -277,12 +282,14 @@ export function drawProp(ctx: CanvasRenderingContext2D, prop: Pose['prop'], colo
else if (prop === 'crown') { ctx.beginPath(); ctx.moveTo(-78, 42); ctx.lineTo(-94, -45); ctx.lineTo(-35, -3); ctx.lineTo(0, -67); ctx.lineTo(35, -3); ctx.lineTo(94, -45); ctx.lineTo(78, 42); ctx.closePath(); ctx.fill() }
ctx.restore()
}
ctx.restore()
}
export class CharacterRenderer {
readonly canvas: HTMLCanvasElement | null
readonly gl: THREE.WebGLRenderer | null
private scene = new THREE.Scene()
private shadowScene = new THREE.Scene()
private camera = new THREE.OrthographicCamera(-1, 1, 1, -1, .1, 30)
private root = new THREE.Group()
private body: THREE.Mesh<THREE.BufferGeometry, THREE.ShaderMaterial>
@@ -335,7 +342,7 @@ export class CharacterRenderer {
}
this.prop = new THREE.Sprite(new THREE.SpriteMaterial({ map: this.propTexture, transparent: true, depthWrite: false, toneMapped: false }))
this.shadow = new THREE.Mesh(new THREE.CircleGeometry(.35, 64), new THREE.MeshBasicMaterial({ color: '#809299', transparent: true, opacity: .2, depthWrite: false }))
this.root.add(this.body, this.lightFill, this.face, this.prop); this.scene.add(this.root, this.shadow)
this.root.add(this.body, this.lightFill, this.face, this.prop); this.scene.add(this.root); this.shadowScene.add(this.shadow)
this.camera.position.set(0, 0, 8); this.camera.lookAt(0, 0, 0)
this.resize(options.width, options.height, options.displaySize)
}
@@ -367,9 +374,9 @@ export class CharacterRenderer {
const fillScale = this.shape === 'capsule' ? 1 : .85
this.lightFill.scale.setScalar(fillScale)
this.root.updateMatrixWorld(true)
// Shift the inset toward the pointer in the camera plane, not the tilted
// Shift the inset toward the face marker in the camera plane, not the tilted
// body's axes. Bound travel by the narrowest stretch to retain a dark rim.
const lightTarget = toonLightOffset(character, this.options.cursor, this.options.reducedMotion)
const lightTarget = toonLightOffset(this.root.quaternion)
const lightScale = Math.min(this.root.scale.x, this.root.scale.y, this.root.scale.z)
const lightOffset = new THREE.Vector3(lightTarget.x * lightScale, lightTarget.y * lightScale, 0).applyMatrix4(new THREE.Matrix4().copy(this.root.matrixWorld).invert())
.sub(new THREE.Vector3().applyMatrix4(new THREE.Matrix4().copy(this.root.matrixWorld).invert()))
@@ -383,7 +390,7 @@ export class CharacterRenderer {
// Small assets use a tight body fit instead of the companion preview padding.
const padding = this.options.framing === 'character' ? .82 : displaySize <= 128 ? .55 : .72
let half = Math.max(height * padding, padding / aspect) / zoom
if (displaySize === 48 || displaySize === 96) {
if (displaySize > 32 && displaySize <= 128) {
// A sphere bounds every rotation, avoiding angle-dependent zoom changes.
if (!this.body.geometry.boundingSphere) this.body.geometry.computeBoundingSphere()
const sphere = this.body.geometry.boundingSphere!
@@ -398,11 +405,12 @@ export class CharacterRenderer {
const reduced = !!this.options.reducedMotion
const screenGaze = reduced ? { x: 0, y: 0 } : gaze
const turnGaze = !reduced && !character.followCursor && character.followRotation && !character.trueFront ? this.options.cursor ?? { x: 0, y: 0 } : { x: 0, y: 0 }
const local = character.iris ? new THREE.Vector3(screenGaze.x + turnGaze.x * .5, screenGaze.y + turnGaze.y * .5, 0).applyQuaternion(this.root.quaternion.clone().invert()) : screenGaze
const faceGaze = displaySize === 24 ? { x: 0, y: 0 } : irisRestGaze(character, local, reduced)
const local = character.iris ? new THREE.Vector3(screenGaze.x + turnGaze.x * .575, screenGaze.y + turnGaze.y * .575, 0).applyQuaternion(this.root.quaternion.clone().invert()) : screenGaze
const faceGaze = displaySize <= 32 ? { x: 0, y: 0 } : irisRestGaze(character, local, reduced)
this.resolvedEyes = !appearance.simpleEyes && detail === 'full' && (character.iris || pose.eye === 'pupil' || sample.faceLayers?.some(l => l.traits.eye === 'pupil')) ? (!reduced ? this.options.eyeGazes : undefined) ?? resolveEyeGazes(character, reduced ? { ...pose, gazeX: 0, gazeY: 0 } : pose, sample.blink, this.root.matrixWorld, this.camera, faceGaze, !reduced && character.followCursor ? this.options.pointerLook : undefined, sample.faceLayers) : undefined
const key = JSON.stringify([pose, sample.faceLayers, character.eyeColor, character.iris, appearance.simpleEyes, sample.blink.toFixed(3), pose.tears ? sample.effectPhase?.toFixed(2) : 0, sample.tearAmount, detail, faceGaze.x.toFixed(3), faceGaze.y.toFixed(3), this.resolvedEyes])
if (this.faceCtx && this.faceTexture && key !== this.lastFaceKey) { drawFace(this.faceCtx, pose, character, sample.blink, detail, faceGaze, { phase: sample.effectPhase, tearAmount: sample.tearAmount, eyeGazes: this.resolvedEyes, faceLayers: sample.faceLayers, simpleEyes: appearance.simpleEyes }); this.faceTexture.needsUpdate = true; this.lastFaceKey = key }
const mouthOffset = compactMouthOffset(displaySize, pose.faceScale, half * 2, displaySize)
const key = JSON.stringify([mouthOffset, pose, sample.faceLayers, character.eyeColor, character.iris, appearance.simpleEyes, sample.blink.toFixed(3), pose.tears ? sample.effectPhase?.toFixed(2) : 0, sample.tearAmount, detail, faceGaze.x.toFixed(3), faceGaze.y.toFixed(3), this.resolvedEyes])
if (this.faceCtx && this.faceTexture && key !== this.lastFaceKey) { drawFace(this.faceCtx, pose, character, sample.blink, detail, faceGaze, { phase: sample.effectPhase, tearAmount: sample.tearAmount, eyeGazes: this.resolvedEyes, faceLayers: sample.faceLayers, simpleEyes: appearance.simpleEyes, mouthOffset, mouthFollowsEyes: displaySize >= 16 && displaySize <= 32 }); this.faceTexture.needsUpdate = true; this.lastFaceKey = key }
const vertices = this.face.geometry.attributes.position!
const uv = this.face.geometry.attributes.uv!
const valid = this.face.geometry.attributes.faceValid!
@@ -421,18 +429,29 @@ export class CharacterRenderer {
this.prop.visible = detail === 'full' && pose.prop !== 'none'
this.prop.material.depthTest = pose.prop !== 'zzz'
this.prop.renderOrder = pose.prop === 'zzz' ? 2 : 0
const propKey = `${pose.prop}:${sample.effectPhase?.toFixed(2) ?? 'still'}`
if (this.propCtx && this.propTexture && propKey !== this.lastProp) { drawProp(this.propCtx, pose.prop, pose.prop === 'heart' ? '#ff768c' : pose.prop === 'sweat' ? '#b7e9ff' : '#ffd362', sample.effectPhase); this.propTexture.needsUpdate = true; this.lastProp = propKey }
const propKey = `${pose.prop}:${pose.propSize}:${pose.propCount}:${pose.propOutward}:${sample.effectPhase?.toFixed(2) ?? 'still'}`
if (this.propCtx && this.propTexture && propKey !== this.lastProp) { drawProp(this.propCtx, pose.prop, pose.prop === 'heart' ? '#ff768c' : pose.prop === 'sweat' ? '#b7e9ff' : '#ffd362', sample.effectPhase, pose); this.propTexture.needsUpdate = true; this.lastProp = propKey }
const propSize = this.shape === 'capsule' ? .42 : .32
this.prop.scale.setScalar(propSize * (.7 + .3 * (sample.propAmount ?? 1))); this.prop.material.opacity = sample.propAmount ?? 1
this.prop.scale.setScalar(propSize * particleLayout(pose.prop, sample.effectPhase, pose).extent / 256 * (.7 + .3 * (sample.propAmount ?? 1))); this.prop.material.opacity = sample.propAmount ?? 1
this.prop.position.set(pose.prop === 'crown' ? 0 : .56, pose.prop === 'crown' ? height / 2 + .08 : height * .29, .15)
this.shadow.visible = character.shadow && detail === 'full'
this.shadow.position.set(0, -height * .58, -.2)
this.shadow.scale.set((1 - (character.lockPosition ? 0 : sample.bob) * .06) * (this.shape === 'capsule' ? 1 : .95), .12, 1)
const bg = this.options.background
if (bg) this.gl?.setClearColor(bg, 1); else this.gl?.setClearColor(0x000000, 0)
this.scene.updateMatrixWorld(true)
this.gl?.render(this.scene, this.camera)
// World matrices are updated explicitly: without a GL render (the Node
// SVG snapshot path) nothing else would, and snapshotScene reads them.
this.shadowScene.updateMatrixWorld(true); this.scene.updateMatrixWorld(true)
// Composite the ground shadow first: body rotation must never bring it
// in front of the silhouette or the toon fill, which does not write depth.
if (this.gl) {
this.gl.autoClear = true
this.gl.render(this.shadowScene, this.camera)
this.gl.autoClear = false
this.gl.clearDepth()
this.gl.render(this.scene, this.camera)
this.gl.autoClear = true
}
this.captured = { character, sample, gaze: faceGaze, simpleEyes: appearance.simpleEyes }
}
snapshotScene() {
+10 -6
View File
@@ -51,19 +51,23 @@ export function createCharacter(target: HTMLElement, definition: Definition, opt
if (destroyed) return
try { render() } catch { playing = false; sync(); options.onError?.() }
}
const resize = new ResizeObserver(() => {
try { renderer.resize(target.clientWidth || 256, target.clientHeight || 256, options.displaySize); if (visible) renderSafely() }
catch { playing = false; sync(); options.onError?.() }
})
const intersection = new IntersectionObserver(entries => { visible = entries[0]?.isIntersecting ?? false; sync() })
// Both observers are constructed inside the guarded block below: the renderer
// and its canvas already exist by then, so a missing observer must release
// them through destroy() rather than leak a WebGL context behind a fallback.
let resize: ResizeObserver | null = null, intersection: IntersectionObserver | null = null
const contextLost = (event: Event) => { event.preventDefault(); playing = false; sync(); options.onError?.() }
function destroy() {
if (destroyed) return
destroyed = true; sync(); resize.disconnect(); intersection.disconnect()
destroyed = true; sync(); resize?.disconnect(); intersection?.disconnect()
document.removeEventListener('visibilitychange', sync); reduced.removeEventListener('change', sync); coarse.removeEventListener('change', sync)
canvas.removeEventListener('webglcontextlost', contextLost); renderer.dispose(); canvas.remove()
}
try {
resize = new ResizeObserver(() => {
try { renderer.resize(target.clientWidth || 256, target.clientHeight || 256, options.displaySize); if (visible) renderSafely() }
catch { playing = false; sync(); options.onError?.() }
})
intersection = new IntersectionObserver(entries => { visible = entries[0]?.isIntersecting ?? false; sync() })
resize.observe(target); intersection.observe(target)
document.addEventListener('visibilitychange', sync); reduced.addEventListener('change', sync); coarse.addEventListener('change', sync)
canvas.addEventListener('webglcontextlost', contextLost)
+1 -1
View File
@@ -1,4 +1,4 @@
// Vendored from ClipLab a050f724; see PROVENANCE.md and LICENSE.
// Vendored from ClipLab 987b6db0 (v0.2.0); see PROVENANCE.md and LICENSE.
import { flatten, pathData, strokeOutline, type ProjectPoint } from './svg-path.js'
// A small vector recorder for the Canvas 2D operations used by ClipLab's faces.
// Paths stay editable; no bitmap or external asset is embedded in the SVG.
+1 -1
View File
@@ -1,4 +1,4 @@
// Vendored from ClipLab a050f724; see PROVENANCE.md and LICENSE.
// Vendored from ClipLab 987b6db0 (v0.2.0); see PROVENANCE.md and LICENSE.
/** Compact projected vector paths. Curves are flattened below a display pixel,
* then simplified after projection; strokes become filled outlines before warping. */
export type Point = { x: number; y: number }
+5 -5
View File
@@ -1,6 +1,6 @@
// Vendored from ClipLab a050f724; see PROVENANCE.md and LICENSE.
// Vendored from ClipLab 987b6db0 (v0.2.0); see PROVENANCE.md and LICENSE.
import * as THREE from 'three'
import { drawFace, drawProp, type CharacterRenderer } from './renderer.js'
import { compactMouthOffset, drawFace, drawProp, type CharacterRenderer } from './renderer.js'
import { detailAt } from './model.js'
import { SvgCanvas, number as n, xml } from './svg-canvas.js'
import { boundaryContours, pathData, type Point, type ProjectPoint } from './svg-path.js'
@@ -90,7 +90,7 @@ export function snapshotSvg(snapshot: Snapshot, prefix = `cliplab-${crypto.rando
const surface = (mesh: THREE.Mesh<THREE.BufferGeometry, THREE.ShaderMaterial>, label: string) => {
const data = triangles(mesh, true), u = mesh.material.uniforms
const colorA = u.colorA!.value as THREE.Color, colorB = (u.colorB!.value as THREE.Color).clone().lerp(colorA, 1 - u.gradientOn!.value)
const shade = u.toonOn!.value && !u.insetFill!.value ? .8 : 1
const shade = u.toonOn!.value && !u.insetFill!.value ? .82 : 1
const gradient = fitGradient(data.triangles), { x, y, t, gx, gy } = gradient, length = gx * gx + gy * gy
let fill = shadedColor(colorA, shade)
if (u.gradientOn!.value > 0 && !colorA.equals(colorB)) {
@@ -114,7 +114,7 @@ export function snapshotSvg(snapshot: Snapshot, prefix = `cliplab-${crypto.rando
const transparent: { z: number; markup: string }[] = []
if (face.visible) {
const art = new SvgCanvas('face', faceProjector(face, project))
drawFace(art as unknown as CanvasRenderingContext2D, sample.pose, character, sample.blink, detailAt(options.displaySize ?? Math.min(width, height)), snapshot.gaze, { phase: sample.effectPhase, tearAmount: sample.tearAmount, eyeGazes: snapshot.eyeGazes, faceLayers: sample.faceLayers, simpleEyes: snapshot.simpleEyes })
drawFace(art as unknown as CanvasRenderingContext2D, sample.pose, character, sample.blink, detailAt(options.displaySize ?? Math.min(width, height)), snapshot.gaze, { phase: sample.effectPhase, tearAmount: sample.tearAmount, eyeGazes: snapshot.eyeGazes, faceLayers: sample.faceLayers, simpleEyes: snapshot.simpleEyes, mouthOffset: compactMouthOffset(options.displaySize ?? Math.min(width, height), sample.pose.faceScale, camera.top - camera.bottom, options.displaySize ?? Math.min(width, height)), mouthFollowsEyes: (options.displaySize ?? Math.min(width, height)) >= 16 && (options.displaySize ?? Math.min(width, height)) <= 32 })
const valid = face.geometry.getAttribute('faceValid'), data = triangles(face)
const visible = data.triangles.filter(t => t.indices.every(i => valid.getX(i) >= .99))
defs.push(`<clipPath id="face-visible"><path d="${pathData(boundaryContours(visible.map(t => t.points)))}"/></clipPath>`)
@@ -124,7 +124,7 @@ export function snapshotSvg(snapshot: Snapshot, prefix = `cliplab-${crypto.rando
}
if (prop.visible && prop.material.opacity > 0) {
const art = new SvgCanvas('prop'), name = sample.pose.prop
drawProp(art as unknown as CanvasRenderingContext2D, name, name === 'heart' ? '#ff768c' : name === 'sweat' ? '#b7e9ff' : '#ffd362', sample.effectPhase)
drawProp(art as unknown as CanvasRenderingContext2D, name, name === 'heart' ? '#ff768c' : name === 'sweat' ? '#b7e9ff' : '#ffd362', sample.effectPhase, sample.pose)
const center = project(prop.getWorldPosition(new THREE.Vector3())), scale = prop.getWorldScale(new THREE.Vector3())
const w = scale.x * width / (camera.right - camera.left), h = scale.y * height / (camera.top - camera.bottom)
const foreground = !prop.material.depthTest
+1 -1
View File
@@ -66,7 +66,7 @@ const { createLocalDiskStorageProvider } = await import("../server/src/storage/l
const cache = await mkdtemp(join(tmpdir(), "paperclip-storybook-avatars-"));
const avatars = createAgentAvatarService(createLocalDiskStorageProvider(cache));
const api = express();
api.use("/api", agentAvatarRoutes(avatars));
api.use("/api", agentAvatarRoutes(avatars).router);
const server = createServer((req, res) => {
const url = new URL(req.url ?? "/", `http://localhost:${port}`);
if (url.pathname.startsWith("/api/agent-avatars/")) { api(req, res); return; }
+28
View File
@@ -0,0 +1,28 @@
#!/usr/bin/env node
// The persona character is the ClipLab studio export at
// ui/src/assets/cliplab/onboarding.character.json. The shared package cannot
// import JSON from ui, and its build is plain tsc, so this writes the export
// as a TypeScript module next to the engine. `--check` reports drift.
import { readFileSync, writeFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const source = path.join(root, "ui/src/assets/cliplab/onboarding.character.json");
const target = path.join(root, "packages/shared/src/cliplab/character.ts");
const definition = JSON.parse(readFileSync(source, "utf8"));
const body = `// Generated from ui/src/assets/cliplab/onboarding.character.json by scripts/sync-cliplab-character.mjs. Do not edit.
import type { Definition } from "./model.js";
/** The persona character: Tonio's ClipLab studio export, every palette recolours its body. */
export const PAPERCLIP_CHARACTER: Definition = ${JSON.stringify(definition, null, 2)} as unknown as Definition;
`;
if (process.argv.includes("--check")) {
let current = "";
try { current = readFileSync(target, "utf8"); } catch { /* missing counts as drift */ }
if (current !== body) { console.error(`packages/shared/src/cliplab/character.ts is out of date; run node scripts/sync-cliplab-character.mjs`); process.exit(1); }
console.log("cliplab character in sync");
} else {
writeFileSync(target, body);
console.log(`wrote ${path.relative(root, target)}`);
}
+1 -1
View File
@@ -26,7 +26,7 @@ async function storage() {
return createLocalDiskStorageProvider(dir);
}
async function serve(service: ReturnType<typeof createAgentAvatarService>) {
const app = express(); app.use("/api", agentAvatarRoutes(service));
const app = express(); app.use("/api", agentAvatarRoutes(service).router);
const server = await new Promise<Server>(resolve => { const running = app.listen(0, "127.0.0.1", () => resolve(running)); });
cleanups.push(() => new Promise<void>((resolve, reject) => server.close(error => error ? reject(error) : resolve())));
return `http://127.0.0.1:${(server.address() as { port: number }).port}/api/agent-avatars/cap-v1/arctic-blue/rest.png`;
+5 -1
View File
@@ -624,7 +624,8 @@ export async function createApp(
// Mount API routes
const api = Router();
api.use(agentAvatarRoutes());
const agentAvatars = agentAvatarRoutes();
api.use(agentAvatars.router);
api.use(boardMutationGuard());
api.use(
"/health",
@@ -1313,6 +1314,9 @@ export async function createApp(
hostServiceCleanup.teardown();
await emailChannels.shutdown();
await chatChannels.shutdown();
// End the avatar worker pool, if a request ever started one, so no
// render outlives the HTTP teardown.
await agentAvatars.close();
// Cancel every live setup-token login session and AWAIT the cancellation,
// so each direct child stops and the server releases each lease before the
// caller stops the database and the provider. A lease release that
+10 -3
View File
@@ -15,8 +15,15 @@ const requestSchema = z.object({
scale: z.enum(["1", "2"]).default("1"),
}).strict();
/** Public preset artwork only. No agent lookup or tenant data is exposed. */
export function agentAvatarRoutes(injected?: ReturnType<typeof createAgentAvatarService>) {
/**
* Public preset artwork only. No agent lookup or tenant data is exposed.
*
* The service (and its worker pool) is created on the first request, so an
* instance that never serves an avatar never starts a worker; `close` lets
* the application's orderly shutdown end the pool instead of leaving renders
* running past HTTP teardown.
*/
export function agentAvatarRoutes(injected?: ReturnType<typeof createAgentAvatarService>): { router: ReturnType<typeof Router>; close(): Promise<void> } {
const router = Router();
let service = injected;
router.get("/agent-avatars/:version/:palette/:file", async (req, res) => {
@@ -54,5 +61,5 @@ export function agentAvatarRoutes(injected?: ReturnType<typeof createAgentAvatar
res.status(error instanceof AvatarAdmissionError ? 429 : 503).json({ error: "Avatar temporarily unavailable" });
}
});
return router;
return { router, close: async () => { await service?.close(); } };
}
File diff suppressed because it is too large. Load diff
+116 -65
View File
@@ -7,11 +7,11 @@ import type { AiConnectionBinding } from "@paperclipai/shared";
import { storeProviderApiKey } from "../lib/provider-credential";
import { SavedProviderKeySelect, useSavedProviderKeys } from "./onboarding/SavedProviderKeySelect";
import { randomAgentAppearance, resolveAgentAppearance, agentAppearanceSchema } from "@paperclipai/shared";
import { AgentCharacter } from "./AgentCharacter";
import { OnboardingCharacter } from "./onboarding/OnboardingCharacter";
import { useEffect, useState, useMemo, useRef } from "react";
import type { ComponentType, CSSProperties } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { MotionConfig, motion } from "motion/react";
import { AnimatePresence, MotionConfig, motion } from "motion/react";
import type {
AdapterEnvironmentTestResult,
AgentRole,
@@ -132,7 +132,7 @@ import { CredentialModeLink } from "./onboarding/CredentialModeLink";
import { FooterNav, type FooterPrimaryIcon } from "./onboarding/FooterNav";
import { OnboardingHeading } from "./onboarding/OnboardingPrimitives";
import { DEFAULT_AGENT_ROLE } from "../lib/onboarding-agent-role";
import { capsuleHeroMotion } from "./onboarding/onboarding-motion";
import { capsuleHeroMotion, capsuleRoomEnter, capsuleRoomExit, heroRoomArrival, heroRoomMotion, ledeMotion, stepContentMotion, titleSwapMotion } from "./onboarding/onboarding-motion";
import { Badge } from "@/components/ui/badge";
import {
ArrowLeft,
@@ -545,6 +545,26 @@ function OnboardingWizardInner({
// customer mid-flow — and here that would quietly re-open the "create a
// company" step to a run that already holds one.
const [entryStep, setEntryStep] = useState<number>((saved?.step as Step) ?? initialStep);
/**
* A page that opens straight onto the agent step — a cloud-managed
* workspace arriving from Cloud's naming screen, or a reload — plays the
* hand-off's second half on its first frames rather than mounting cold: the
* first paint holds the naming step's layout (hero room and content closed),
* the next frame opens them. `arrival` is fixed for the mount; `arrived`
* flips once. See heroRoomArrival.
*/
const arrival = entryStep === 3 && beatDelay(1) > 0;
const [arrived, setArrived] = useState(!arrival);
useEffect(() => {
if (arrived) return;
const frame = requestAnimationFrame(() => setArrived(true));
return () => cancelAnimationFrame(frame);
}, [arrived]);
// The step before this render's, for choosing an entrance that matches
// where the capsule came from; updated after paint, so during the render
// in which the step just changed it still names the departed step.
const lastStep = useRef(step);
useEffect(() => { lastStep.current = step; }, [step]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [modelOpen, setModelOpen] = useState(false);
@@ -2345,6 +2365,15 @@ function OnboardingWizardInner({
const showsAgentArcStepper = isAgentArcStep && entryStep >= 3 && !enteredFromCloud;
const launchStateIncomplete = step === 5 && (!createdCompanyId || !createdAgentId);
/**
* Whether the step hand-off plays out. Under reduced motion — and where the
* platform cannot be asked, which `beatDelay` reads the same way — the next
* step's content simply takes the departing one's place: a sequence that
* holds the screen for a departure nobody sees is just a slower screen.
*/
const stepHandoff = beatDelay(1) > 0;
const heroRoomTarget = step === 1 || !arrived ? heroRoomMotion.closed : arrival && lastStep.current === entryStep ? heroRoomArrival : heroRoomMotion.open;
const capsuleTarget = step === 1 || !arrived ? capsuleRoomExit : step === 3 && stepHandoff && lastStep.current === 1 ? capsuleRoomEnter : capsuleHeroMotion.animate;
const visibleError = error ?? (launchStateIncomplete ? INCOMPLETE_ONBOARDING_STATE_MESSAGE : null);
return (
@@ -2465,7 +2494,7 @@ function OnboardingWizardInner({
slot across steps 3–5, so React reuses the DOM node and moving
between steps never replays the entrance. It is dormant while
the agent is being specified and wakes on Review. */}
{step >= 3 && step <= 5 && (
{(step === 1 || (step >= 3 && step <= 5)) && (
// reducedMotion="user" defers to the OS setting, so the hero
// arrives in place for anyone who asked for less movement. The
// token layer zeroes the CSS durations; this covers the JS half.
@@ -2480,82 +2509,103 @@ function OnboardingWizardInner({
the character and the title and belonged to neither. 24px
against the 36px used elsewhere, a little over a third
less. `mb-9` still holds the block off the step content. */}
<div className="mb-9 space-y-6">
<div className="mb-9">
{/* The hero's room. Closed on the naming step — there is
no agent yet — and opened by the hand-off into the
agent step, the capsule springing up inside it as it
grows. The 24px under the name lives inside the room so
a closed room takes no space. A reload straight onto
the arc mounts it open, as before. */}
<motion.div
className="overflow-hidden"
initial={false}
animate={heroRoomTarget}
aria-hidden={step === 1 || undefined}
>
<motion.div
initial={capsuleHeroMotion.initial}
animate={capsuleHeroMotion.animate}
animate={capsuleTarget}
transition={capsuleHeroMotion.transition}
className="flex flex-col items-center gap-2"
className="flex flex-col items-center gap-2 pb-6"
>
{/* Dormant until the agent is actually hired. Review is
the first step where one exists, so that is where it
wakes — the arc's payoff, not a flourish along it. */}
{/* `relative` is load-bearing: the sleep marks anchor
to this box and travel out past its top-right
corner. */}
<div className="relative size-(--sz-72px)">
<AgentCharacter appearance={agentAppearance} size={128} state={step === 5 ? "success" : adapterEnvLoading || loading || ["loading", "waiting", "connecting"].includes(connectPhase) ? "loading" : "sleepy"} muted={step < 5} className="size-full" />
{/* Dozing and gray until the agent is actually hired.
Review is the first step where one exists, so that is
where it wakes and takes its colour — the arc's
payoff, not a flourish along it. The sequence itself
is the studio's export; see OnboardingCharacter. */}
<div className="relative size-(--sz-160px)">
<OnboardingCharacter appearance={agentAppearance} awake={step === 5} className="size-full" />
</div>
<AgentPreview agentName={agentName} agentRole="" />
</motion.div>
</motion.div>
<OnboardingHeading
center
// Keyed by step so the new words fade in where the old
// ones stood. Entrance only: the h1 keeps its line the
// whole time, so nothing below it moves for the swap.
title={
step === 3
? "Create your first agent"
: step === 4
? "Connect a model"
: "Let's get started..."
}
// The agent step carries no lede, as the prototype has it:
// the capsule and the heading say what this is, and a
// sentence restating it only pushes the fields down.
lede={
step === 3 ? undefined : step === 4 ? (
<>Paperclip works with your subscription or API keys.</>
) : (
<>{agentName.trim() || "Your first agent"} is ready to work!</>
)
<motion.span key={step} {...titleSwapMotion} className="inline-block">
{step === 1
? "What is the name of your organization?"
: step === 3
? "Create your first agent"
: step === 4
? "Connect a model"
: "Let's get started..."}
</motion.span>
}
/>
{/* The lede lives outside the heading primitive so its
room can open and close. The agent step carries none,
as the prototype has it: the capsule and the heading
say what this is, and a sentence restating it only
pushes the fields down. The 8px gap sits inside the
clipped box so a closed lede takes no space at all.
The naming step carries none either: the question is
the whole screen, and Cloud's naming step (which most
walkers see instead) is drawn the same way. */}
<motion.div
className="overflow-hidden text-center"
initial={false}
animate={step === 1 || step === 3 ? ledeMotion.closed : ledeMotion.open}
aria-hidden={step === 1 || step === 3 || undefined}
>
<p className="pt-2 text-base leading-relaxed text-muted-foreground">
<motion.span key={step} {...titleSwapMotion} className="inline-block">
{step === 4
? "Paperclip works with your subscription or API keys."
: `${agentName.trim() || "Your first agent"} is ready to work!`}
</motion.span>
</p>
</motion.div>
</div>
</MotionConfig>
)}
{/* Step content */}
{/* Steps 1, 3 and 4 hand their content over inside one presence:
the departing step fades and gives its room back before the
next opens its own, so the footer slides rather than jumps.
See stepContentMotion. */}
{/* `initial` only for an arrival: the step's content opens its
room with the hero's instead of being there already. */}
<AnimatePresence mode={stepHandoff ? "wait" : "sync"} initial={arrival}>
{/* Step 1: name the organization — the wizard's first screen now
that the Build / Grow front door is gone. Dressed as the arc
steps that follow it (centred heading, same footer pair)
because a customer walks straight from here into them.
The lede carries the welcome the front door used to: one line,
above the field, so the customer lands somewhere that greets
them rather than on a bare question. */}
that the Build / Grow front door is gone. Its heading and
welcome sit in the shared block above, so the walk into the
agent step swaps words rather than screens. The field is the
agent step's field: same label, same filled surface, same
measure — the two questions the wizard asks present the same
target. */}
{step === 1 && (
<div className="mx-auto w-full space-y-9">
<OnboardingHeading
center
title="What is the name of your organization?"
lede="Welcome to Paperclip — let's set up your organization."
/>
{/* The field takes the agent step's measure rather than the
column's, so the two questions the wizard asks — name the
organization, name the agent — present the same target.
The heading stays full width above it, as it does there. */}
<div className="group mx-auto w-full max-w-(--sz-320px)">
<label
className={cn(
"text-xs mb-1 block transition-colors",
companyName.trim()
? "text-foreground"
: "text-muted-foreground group-focus-within:text-foreground"
)}
>
Name
</label>
<input
className="w-full rounded-md border border-border bg-transparent px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-ring placeholder:text-muted-foreground/50"
<motion.div key="step-1" {...stepContentMotion} exit={stepHandoff ? stepContentMotion.exit : undefined} className="mx-auto flex w-full flex-col gap-9">
<div className="flex flex-col gap-2">
<Label htmlFor="onboarding-company-name">Name</Label>
<Input
id="onboarding-company-name"
className="h-(--sz-44px) rounded-lg border-transparent bg-muted shadow-none dark:bg-muted"
placeholder="e.g. Northwind Labs"
value={companyName}
onChange={(e) => setCompanyName(e.target.value)}
@@ -2568,7 +2618,7 @@ function OnboardingWizardInner({
autoFocus
/>
</div>
</div>
</motion.div>
)}
{/* Step 3: the name, and only the name. The role picker went with
@@ -2578,7 +2628,7 @@ function OnboardingWizardInner({
`general` role; a specific one can be set later, where there
is context to choose it in. */}
{step === 3 && (
<div className="mx-auto flex w-full flex-col gap-9">
<motion.div key="step-3" {...stepContentMotion} exit={stepHandoff ? stepContentMotion.exit : undefined} className="mx-auto flex w-full flex-col gap-9">
<div className="flex flex-col gap-2">
<Label htmlFor="onboarding-agent-name">Agent name</Label>
{/*
@@ -2605,12 +2655,12 @@ function OnboardingWizardInner({
autoFocus
/>
</div>
</div>
</motion.div>
)}
{/* Step 4: Connect a model — adapter + model + env check (capsule above) */}
{step === 4 && (
<div className="space-y-8">
<motion.div key="step-4" {...stepContentMotion} exit={stepHandoff ? stepContentMotion.exit : undefined} className="space-y-8">
<div>
{/* Sources come from `recommendedAdapters`, not a list
written here — that filter is `recommended` in the
@@ -3002,8 +3052,9 @@ function OnboardingWizardInner({
/>
</div>
)}
</div>
</motion.div>
)}
</AnimatePresence>
{/* Step 5: Review — lead is online (shared capsule above) */}
{/* Step 5: nothing. The heading names the agent and says it is
@@ -0,0 +1,107 @@
// @vitest-environment jsdom
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { appearanceForPalette } from "@paperclipai/shared";
import { CAP_V1_COLORS } from "@paperclipai/shared/cliplab/palette-tokens";
import type { createCharacter as CreateCharacter } from "@paperclipai/shared/cliplab/runtime";
import { OnboardingCharacter } from "./OnboardingCharacter";
const createCharacter = vi.hoisted(() => vi.fn<typeof CreateCharacter>());
vi.mock("@paperclipai/shared/cliplab/runtime", () => ({ createCharacter }));
let root: Root;
let host: HTMLDivElement;
const appearance = appearanceForPalette("arctic-blue");
function player() {
return { destroy: vi.fn(), seek: vi.fn(), play: vi.fn(), pause: vi.fn(), setDefinition: vi.fn(), setAnimation: vi.fn() };
}
beforeEach(() => {
createCharacter.mockReset();
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
vi.stubGlobal("IntersectionObserver", class {});
vi.stubGlobal("ResizeObserver", class {});
vi.stubGlobal("WebGLRenderingContext", class {});
vi.stubGlobal("matchMedia", () => ({ matches: false }));
vi.spyOn(console, "warn").mockImplementation(() => {});
host = document.createElement("div");
document.body.appendChild(host);
root = createRoot(host);
});
afterEach(async () => {
await act(async () => root.unmount());
host.remove();
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
async function render(awake = false, selectedAppearance = appearance) {
await act(async () => {
root.render(<OnboardingCharacter appearance={selectedAppearance} awake={awake} />);
});
await act(async () => { await vi.dynamicImportSettled(); });
}
function expectFallback() {
expect(host.querySelector("img")?.classList.contains("invisible")).toBe(false);
}
it("releases the first canvas if its colored twin cannot start", async () => {
const gray = player();
createCharacter.mockReturnValueOnce(gray).mockImplementationOnce(() => { throw new Error("WebGL context limit"); });
await render();
expect(gray.destroy).toHaveBeenCalledOnce();
expectFallback();
await render(true);
expect(createCharacter).toHaveBeenCalledTimes(2);
});
it("owns the canvas before seeking so synchronous render errors can release it", async () => {
const gray = player();
createCharacter.mockImplementationOnce((_target, _definition, options) => {
gray.seek.mockImplementationOnce(() => options?.onError?.());
return gray;
});
await render();
expect(gray.destroy).toHaveBeenCalledOnce();
expect(createCharacter).toHaveBeenCalledOnce();
expectFallback();
});
it("stops the wake and releases both canvases after a synchronous render error", async () => {
const gray = player(), twin = player();
createCharacter.mockImplementationOnce((_target, _definition, options) => {
gray.setAnimation.mockImplementationOnce(() => options?.onError?.());
return gray;
}).mockReturnValueOnce(twin);
await render();
await render(true);
expect(gray.destroy).toHaveBeenCalledOnce();
expect(twin.destroy).toHaveBeenCalledOnce();
expect(gray.play).not.toHaveBeenCalled();
expect(twin.setAnimation).not.toHaveBeenCalled();
expectFallback();
});
it("releases both canvases if a wake transition throws", async () => {
const gray = player(), twin = player();
gray.setAnimation.mockImplementationOnce(() => { throw new Error("renderer unavailable"); });
createCharacter.mockReturnValueOnce(gray).mockReturnValueOnce(twin);
await render();
await render(true);
expect(gray.destroy).toHaveBeenCalledOnce();
expect(twin.destroy).toHaveBeenCalledOnce();
expectFallback();
});
it.each([false, true])("refreshes the sleeping twin when its palette changes (wake: %s)", async (awake) => {
const gray = player(), twin = player(), nextGray = player(), nextTwin = player();
createCharacter.mockReturnValueOnce(gray).mockReturnValueOnce(twin)
.mockReturnValueOnce(nextGray).mockReturnValueOnce(nextTwin);
await render();
await render(awake, appearanceForPalette("coral-mint"));
expect(gray.destroy).toHaveBeenCalledOnce();
expect(twin.destroy).toHaveBeenCalledOnce();
const definition = createCharacter.mock.calls[3][1];
expect(definition.character.color).toBe(CAP_V1_COLORS["coral-mint"].a);
expect(definition.character.color2).toBe(CAP_V1_COLORS["coral-mint"].b);
if (awake) expect(nextTwin.play).toHaveBeenCalledOnce();
});
@@ -0,0 +1,190 @@
import { useEffect, useRef, useState } from "react";
import { resolveAgentAppearance, type AgentAppearance } from "@paperclipai/shared";
import { cn } from "@/lib/utils";
import { AgentAvatar } from "../AgentAvatar";
import type { createCharacter } from "@paperclipai/shared/cliplab/runtime";
import type { Definition } from "@paperclipai/shared/cliplab/model";
import { colorOnboardingDefinition, resolveOnboardingSequences, sequenceDuration, sequenceLeadIn, type OnboardingSequences } from "./onboarding-character";
type Player = ReturnType<typeof createCharacter>;
type Phase = "asleep" | "awake";
export interface OnboardingCharacterProps {
appearance: AgentAppearance;
/** Review is the first step where an agent exists; that is where it wakes. */
awake: boolean;
className?: string;
}
/**
* The onboarding hero: gray and dozing while the agent is being specified,
* then — once on Review — it plays the studio's sleepy → wink → idle
* transition while its palette fades in over the gray, and settles into the
* idle loop.
*
* Played by the shared ClipLab engine directly rather than through
* `AgentCharacter`: the arc needs a one-shot that the runtime reports as
* complete (it then continues into the idle loop itself), a page-scoped
* pointer, and two canvases for the colouring-in below. The character is the
* same export every avatar renders, so what wakes here is what the agent
* looks like everywhere after.
*
* Colouring in: the runtime's `setDefinition` restarts playback, so the body
* cannot be recoloured mid-sequence. Instead a second canvas in the agent's
* palette plays the same transition in lock-step above the gray one and fades
* in; the gray canvas is destroyed once it is covered.
*
* Mounting straight onto Review (a reload, or a return to the wizard) shows
* the awake hero without replaying the wake. Reduced motion skips the
* sequence entirely: the runtime holds the first pose under that setting, so
* the hero switches straight to its coloured idle.
*/
export function OnboardingCharacter({ appearance, awake, className }: OnboardingCharacterProps) {
const identity = resolveAgentAppearance(appearance);
const base = useRef<HTMLSpanElement>(null), overlay = useRef<HTMLSpanElement>(null);
const players = useRef<{ base: Player | null; overlay: Player | null }>({ base: null, overlay: null });
const library = useRef<{ create: typeof createCharacter; definition: Definition; sequences: OnboardingSequences } | null>(null);
// What the live canvas currently shows, so a prop change is a transition
// from something rather than a re-mount. Starts where the props say.
const phase = useRef<Phase>(awake ? "awake" : "asleep");
const renderedPalette = useRef<string | null>(null);
const timers = useRef<number[]>([]);
const [ready, setReady] = useState(false), [failed, setFailed] = useState(false), [colored, setColored] = useState(awake);
const [wakeSeconds, setWakeSeconds] = useState(0);
const clearTimers = () => { for (const id of timers.current) window.clearTimeout(id); timers.current = []; };
const destroy = (key: "base" | "overlay") => { players.current[key]?.destroy(); players.current[key] = null; };
// A renderer failure (render, resize, context loss) hands the hero to the
// still portrait — and releases both canvases, observers and contexts, which
// otherwise stay allocated behind the fallback until the wizard unmounts.
const fail = () => { clearTimers(); destroy("overlay"); destroy("base"); setReady(false); setFailed(true); };
// Note: after a wake the live player sits in the overlay span; `mount` clears both spans.
const reducedMotion = () => typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches;
// Body-following, page-scoped: the engine turns the body toward the
// pointer only while eye-following is off. Framing is the engine's
// character framing, the same as every live AgentCharacter.
const FOLLOW = { followCursor: false, followRotation: true, trackingScope: "page" as const, displaySize: 160 };
/**
* A fresh canvas (or pair) in the given phase; no transition. The loops
* turn the body toward the pointer — body only, the eyes stay as authored
* (the runtime only turns the body when eye-following is off).
*
* Asleep also mounts the coloured twin at opacity 0, playing the same loop
* with the same options. The runtime fixes follow options and pointer state
* at creation, so a twin created later would face front while the gray
* canvas had already turned — a visible ghost through the crossfade. Created
* together, both hear the same pointer events and stay in lock-step.
* Each loop starts past its wrap-around ease so it opens on its own first beat.
*/
function mount(next: Phase) {
const lib = library.current;
if (!lib || !base.current || !overlay.current) return;
clearTimers(); destroy("overlay"); destroy("base");
const animation = next === "asleep" ? lib.sequences.asleep : lib.sequences.awake;
const leadIn = sequenceLeadIn(lib.definition, animation);
const player = lib.create(base.current, colorOnboardingDefinition(lib.definition, identity, next === "asleep"), { animation, ...FOLLOW, onError: fail });
players.current.base = player;
player.seek(leadIn);
if (players.current.base !== player) return;
if (next === "asleep") {
const twin = lib.create(overlay.current, colorOnboardingDefinition(lib.definition, identity, false), { animation, ...FOLLOW, onError: fail });
players.current.overlay = twin;
twin.seek(leadIn);
if (players.current.overlay !== twin) return;
}
phase.current = next; renderedPalette.current = identity.paletteId; setColored(next === "awake");
}
/** The arc's payoff: both canvases play the transition together while the palette fades in. */
function wake() {
const lib = library.current, gray = players.current.base, twin = players.current.overlay;
if (!lib || !gray || !twin || reducedMotion()) { mount("awake"); return; }
clearTimers();
// Skip the sequence's wrap-around ease so it opens asleep, not on a
// blend of its own idle end; see sequenceLeadIn.
const leadIn = sequenceLeadIn(lib.definition, lib.sequences.wake);
const seconds = Math.max(0, sequenceDuration(lib.definition, lib.sequences.wake) - leadIn);
setWakeSeconds(seconds);
// Same tick, same offset: the two canvases stay in lock-step for the fade.
for (const player of [gray, twin]) {
player.setAnimation(lib.sequences.wake);
if (players.current.base !== gray) return;
player.seek(leadIn);
if (players.current.base !== gray) return;
player.play();
}
phase.current = "awake";
// Next frame, so the fade transitions from the twin's opacity 0.
timers.current.push(window.setTimeout(() => setColored(true), 0));
// The engine continues a finished one-shot into the idle loop by itself.
// The gray canvas is fully covered by then; the twin becomes the live
// canvas as it is — same pointer state, no re-creation, so no snap — and
// the idle loop starts past its wrap-around ease.
timers.current.push(window.setTimeout(() => {
destroy("base");
players.current.base = players.current.overlay; players.current.overlay = null;
players.current.base?.setAnimation(lib.sequences.awake);
players.current.base?.seek(sequenceLeadIn(lib.definition, lib.sequences.awake));
}, Math.ceil(seconds * 1000) + 50));
}
useEffect(() => {
// The runtime observes its container and needs a GL canvas; where neither
// exists (jsdom, some embedded views) the still portrait is the whole hero.
if (typeof IntersectionObserver !== "function" || typeof ResizeObserver !== "function" || typeof WebGLRenderingContext === "undefined") { setFailed(true); return; }
let disposed = false;
setReady(false);
void Promise.all([import("@paperclipai/shared/cliplab/runtime"), import("@paperclipai/shared/cliplab/character")]).then(([runtime, exported]) => {
if (disposed) return;
const definition = exported.PAPERCLIP_CHARACTER;
library.current = { create: runtime.createCharacter, definition, sequences: resolveOnboardingSequences(definition) };
mount(phase.current);
if (players.current.base) setReady(true);
}).catch((error: unknown) => {
if (disposed) return;
console.warn("Onboarding character unavailable, showing the still portrait.", error);
fail();
});
return () => { disposed = true; clearTimers(); destroy("overlay"); destroy("base"); library.current = null; setReady(false); };
// Mount once; later prop changes are transitions handled below.
}, []);
// Refresh the hidden colored twin too, before a simultaneous wake. The gray
// canvas does not change color, but its twin must carry the new assignment.
useEffect(() => {
if (!ready || !library.current || renderedPalette.current === identity.paletteId) return;
try { mount(phase.current); } catch (error) {
console.warn("Onboarding character palette change failed, showing the still portrait.", error);
fail();
}
}, [identity.paletteId, ready]);
useEffect(() => {
if (!ready || !library.current || !players.current.base) return;
const next: Phase = awake ? "awake" : "asleep";
if (next === phase.current) return;
try {
if (next === "awake") wake(); else mount("asleep");
} catch (error) {
console.warn("Onboarding character transition failed, showing the still portrait.", error);
fail();
}
}, [awake, ready]);
const live = ready && !failed;
return (
<span className={cn("relative inline-block", className)} aria-hidden="true">
<AgentAvatar appearance={identity} size={256} pose={awake ? "rest" : "sleepy"} muted={!awake} className={cn("size-full", live && "invisible")} />
<span ref={base} className="absolute inset-0" />
<span
ref={overlay}
// Linear, over the whole sequence: the colour keeps arriving through
// the wink and lands exactly as the face settles into its idle smile.
className="absolute inset-0 transition-opacity ease-linear motion-reduce:transition-none"
style={{ opacity: colored ? 1 : 0, transitionDuration: `${Math.max(0, wakeSeconds)}s` }}
/>
</span>
);
}
@@ -0,0 +1,65 @@
import { describe, expect, it } from "vitest";
import { appearanceForPalette } from "@paperclipai/shared";
import { CAP_V1_COLORS } from "@paperclipai/shared/cliplab/palette-tokens";
import { PAPERCLIP_CHARACTER } from "@paperclipai/shared/cliplab/character";
import exported from "@/assets/cliplab/onboarding.character.json";
import { colorOnboardingDefinition, resolveOnboardingSequences, sequenceDuration, sequenceLeadIn } from "./onboarding-character";
const definition = PAPERCLIP_CHARACTER;
describe("onboarding character export", () => {
it("is the same export the shared engine renders everywhere", () => {
expect(JSON.parse(JSON.stringify(PAPERCLIP_CHARACTER))).toEqual(exported);
});
it("carries the three sequences the arc plays, with the wake as its one-shot", () => {
const sequences = resolveOnboardingSequences(definition);
expect(sequences).toEqual({ asleep: "sleepy", wake: expect.any(String), awake: "idle" });
const byId = new Map(definition.animations.map((a) => [a.id, a]));
expect(byId.get(sequences.asleep)?.loop).toBe(true);
expect(byId.get(sequences.awake)?.loop).toBe(true);
expect(byId.get(sequences.wake)?.loop).toBe(false);
// Every step points at an expression the export actually ships.
const expressions = new Set(definition.expressions.map((e) => e.id));
for (const animation of definition.animations) for (const step of animation.steps) expect(expressions.has(step.expressionId)).toBe(true);
});
it("refuses an export missing a role rather than guessing", () => {
expect(() => resolveOnboardingSequences({ animations: definition.animations.filter((a) => a.loop) })).toThrow(/non-looping wake/);
expect(() => resolveOnboardingSequences({ animations: [] })).toThrow(/found: none/);
});
it("times the wake from its authored steps at the character's speed", () => {
const { wake } = resolveOnboardingSequences(definition);
const authored = definition.animations.find((a) => a.id === wake)!.steps.reduce((n, s) => n + s.duration, 0);
expect(sequenceDuration(definition, wake)).toBeCloseTo(authored / definition.character.speed);
expect(sequenceDuration({ ...definition, character: { ...definition.character, speed: 2 } }, wake)).toBeCloseTo(authored / 2);
expect(sequenceDuration(definition, "missing")).toBe(0);
});
it("skips the wake's wrap-around ease so it opens on its first beat", () => {
const { wake } = resolveOnboardingSequences(definition);
const animation = definition.animations.find((a) => a.id === wake)!;
const expression = definition.expressions.find((e) => e.id === animation.steps[0]!.expressionId)!;
const beat = expression.beats[0]!;
const expressionSeconds = expression.beats.reduce((n, b) => n + b.duration, 0);
const transition = Math.min(0.45, beat.duration * 0.4);
expect(sequenceLeadIn(definition, wake)).toBeCloseTo((transition * animation.steps[0]!.duration) / expressionSeconds / definition.character.speed);
expect(sequenceLeadIn(definition, wake)).toBeLessThan(sequenceDuration(definition, wake));
expect(sequenceLeadIn(definition, "missing")).toBe(0);
});
it("recolours only the body, gray before the hire and the palette after", () => {
const appearance = appearanceForPalette("coral-mint");
const asleep = colorOnboardingDefinition(definition, appearance, true);
const awake = colorOnboardingDefinition(definition, appearance, false);
expect([asleep.character.color, asleep.character.color2]).toEqual([CAP_V1_COLORS["muted-dream"].a, CAP_V1_COLORS["muted-dream"].b]);
expect([awake.character.color, awake.character.color2]).toEqual([CAP_V1_COLORS["coral-mint"].a, CAP_V1_COLORS["coral-mint"].b]);
const { color: _a, color2: _b, ...rest } = awake.character;
const { color: _c, color2: _d, ...original } = definition.character;
expect(rest).toEqual(original);
expect(awake.expressions).toBe(definition.expressions);
// The source is never mutated.
expect(definition.character.color).not.toBe(awake.character.color);
});
});
@@ -0,0 +1,76 @@
import type { AgentAppearance, CharacterPaletteId } from "@paperclipai/shared";
import { CAP_V1_COLORS } from "@paperclipai/shared/cliplab/palette-tokens";
import type { Definition } from "@paperclipai/shared/cliplab/model";
/**
* The onboarding hero's ClipLab definition: the studio's App export
* (`ui/src/assets/cliplab/onboarding.character.json`, mirrored into the
* shared package as PAPERCLIP_CHARACTER) played by the shared engine. Three sequences carry the arc; they are found by role rather
* than by hard-coded id so a re-export from the studio only has to keep the
* shape below, not the names.
*
* - asleep: the loop shown while the agent is being specified.
* - wake: the one-shot sleepy → wink → idle transition, played once on Review.
* The runtime holds its final pose when it ends.
* - awake: the idle loop (breath and blink) the hero settles into afterwards.
*/
export interface OnboardingSequences { asleep: string; wake: string; awake: string }
const ASLEEP_ID = "sleepy";
const AWAKE_ID = "idle";
export function resolveOnboardingSequences(definition: Pick<Definition, "animations">): OnboardingSequences {
const ids = definition.animations.map((a) => a.id);
// The transition is the export's one-shot; the loops keep their studio ids.
const wake = definition.animations.find((a) => !a.loop)?.id;
const asleep = ids.includes(ASLEEP_ID) ? ASLEEP_ID : undefined;
const awake = ids.includes(AWAKE_ID) ? AWAKE_ID : undefined;
if (!wake || !asleep || !awake) {
throw new Error(`Onboarding character export needs a looping "${ASLEEP_ID}", a looping "${AWAKE_ID}" and one non-looping wake animation; found: ${ids.join(", ") || "none"}`);
}
return { asleep, wake, awake };
}
/** Seconds the sequence takes at the character's authored speed. */
export function sequenceDuration(definition: Definition, animationId: string): number {
const animation = definition.animations.find((a) => a.id === animationId);
if (!animation) return 0;
const seconds = animation.steps.reduce((total, step) => total + (Number.isFinite(step.duration) ? Math.max(0.2, step.duration) : 1), 0);
const speed = Number.isFinite(definition.character.speed) && definition.character.speed > 0 ? definition.character.speed : 1;
return seconds / speed;
}
/**
* Seconds into the sequence at which its first beat is fully reached.
*
* ClipLab eases every beat in from the one before it, and for the first beat
* "before" wraps to the last — expressions are authored as loops. Played once
* from zero, a sleepy → idle transition therefore opens on a blend of its
* *idle* end and only settles into the sleepy pose after that ease (0.4s of
* a 1s beat, capped at 0.45s). Seeking past it starts the one-shot asleep.
* Mirrors sampleExpression's transition window; the step scales its
* expression to the step duration, and speed scales everything.
*/
export function sequenceLeadIn(definition: Definition, animationId: string): number {
const animation = definition.animations.find((a) => a.id === animationId);
const step = animation?.steps[0];
const expression = step && definition.expressions.find((e) => e.id === step.expressionId);
const beat = expression?.beats[0];
if (!animation || !step || !expression || !beat) return 0;
const safe = (value: number) => (Number.isFinite(value) ? Math.max(0.2, value) : 1);
const expressionSeconds = expression.beats.reduce((total, b) => total + safe(b.duration), 0);
const transition = Math.min(0.45, safe(beat.duration) * 0.4);
const speed = Number.isFinite(definition.character.speed) && definition.character.speed > 0 ? definition.character.speed : 1;
return (transition * safe(step.duration)) / expressionSeconds / speed;
}
/**
* The export's body in the agent's palette (or the presentation-only gray
* while nothing has been hired). Everything else — shape, shading, motion,
* follow settings — is the studio's, untouched.
*/
export function colorOnboardingDefinition(definition: Definition, appearance: AgentAppearance, muted: boolean): Definition {
const palette: CharacterPaletteId = muted ? "muted-dream" : appearance.paletteId;
const colors = CAP_V1_COLORS[palette];
return { ...definition, character: { ...definition.character, color: colors.a, color2: colors.b } };
}
+127 -39
View File
@@ -1,18 +1,20 @@
import { motionEase, motionMilliseconds, motionNumber, motionSeconds } from "@/lib/onboarding-motion-tokens";
// Shared motion constants for the onboarding wizard's agent arc (steps 3–5).
// Ported from the onboarding prototype so the capsule choreography reads the
// same here as it does there. Reduced motion is honoured at the token layer
// (ui/src/index.css collapses --motion-duration-* under the media query) and
// by <MotionConfig reducedMotion="user"> where these are consumed.
// same here as it does there. Values come from the index.css token layer;
// the reader collapses durations for reduced motion and MotionConfig handles
// the spring transforms where these are consumed.
/** Step crossfade easing — the house signature curve, also used for in-step reveals. */
export const STEP_EASE = [0.16, 1, 0.3, 1] as const;
export const STEP_EASE = motionEase("--motion-ease-out-expo");
/** Per-step enter/exit crossfade for the keyed step container. */
export const stepMotion = {
initial: { opacity: 0, y: 8 },
initial: { opacity: 0, y: motionNumber("--onboarding-motion-step-travel") },
animate: { opacity: 1, y: 0 },
exit: { opacity: 0, y: -8 },
transition: { duration: 0.28, ease: STEP_EASE },
exit: { opacity: 0, y: -motionNumber("--onboarding-motion-step-travel") },
transition: { duration: motionSeconds("--onboarding-motion-step"), ease: STEP_EASE },
};
/**
@@ -20,19 +22,19 @@ export const stepMotion = {
* about its own centre. Deliberately no y offset — that would bias the growth
* upward and read as a drop-in rather than something forming in place.
*/
export const CAPSULE_ENTER_DURATION = 1.0;
export const CAPSULE_ENTER_DURATION = motionSeconds("--onboarding-motion-capsule");
export const capsuleMotion = {
initial: { opacity: 0, scale: 0.5 },
initial: { opacity: 0, scale: motionNumber("--onboarding-motion-capsule-scale") },
animate: { opacity: 1, scale: 1 },
transition: {
type: "spring" as const,
duration: CAPSULE_ENTER_DURATION,
bounce: 0.4,
bounce: motionNumber("--onboarding-motion-capsule-bounce"),
},
};
/** The name/role reveal: the label fade is staggered by 25% of this. */
export const PREVIEW_REVEAL_DURATION = 0.45;
export const PREVIEW_REVEAL_DURATION = motionSeconds("--onboarding-motion-preview");
/**
* The hand-off that makes the capsule read as one object across all three
@@ -42,18 +44,18 @@ export const PREVIEW_REVEAL_DURATION = 0.45;
* the two travel together.
*/
export const capsuleHandoffExit = {
scale: 0.5,
scale: motionNumber("--onboarding-motion-capsule-scale"),
opacity: 0,
transition: { duration: 0.28, ease: STEP_EASE },
transition: { duration: motionSeconds("--onboarding-motion-step"), ease: STEP_EASE },
};
export const capsuleHeroMotion = {
initial: { scale: 0.5, opacity: 0 },
initial: { scale: motionNumber("--onboarding-motion-capsule-scale"), opacity: 0 },
animate: { scale: 1, opacity: 1 },
transition: {
// A soft spring: slow enough that the scale-up is noticeable, damped
// enough that it still settles. The fade is lengthened to travel with it.
scale: { type: "spring" as const, stiffness: 150, damping: 16 },
opacity: { duration: 0.55, ease: STEP_EASE },
scale: { type: "spring" as const, stiffness: motionNumber("--onboarding-motion-hero-stiffness"), damping: motionNumber("--onboarding-motion-hero-damping") },
opacity: { duration: motionSeconds("--onboarding-motion-hero-fade"), ease: STEP_EASE },
},
};
@@ -80,10 +82,10 @@ export const capsuleHeroMotion = {
* offscreen; over 7px it just looked like the label snapped and then settled.
* Easing into the movement gives the swap a beginning.
*/
export const TAG_SWAP_TRAVEL = 7;
export const TAG_SWAP_EASE = [0.4, 0, 0.2, 1] as const;
export const TAG_SWAP_ENTER = { duration: 0.34, ease: TAG_SWAP_EASE } as const;
export const TAG_SWAP_EXIT = { duration: 0.26, ease: TAG_SWAP_EASE } as const;
export const TAG_SWAP_TRAVEL = motionNumber("--onboarding-motion-tag-travel");
export const TAG_SWAP_EASE = motionEase("--motion-ease-standard");
export const TAG_SWAP_ENTER = { duration: motionSeconds("--onboarding-motion-tag-enter"), ease: TAG_SWAP_EASE } as const;
export const TAG_SWAP_EXIT = { duration: motionSeconds("--onboarding-motion-tag-exit"), ease: TAG_SWAP_EASE } as const;
/**
* The credential-mode link's own label swap, when that control is a line of
@@ -102,12 +104,12 @@ export const TAG_SWAP_EXIT = { duration: 0.26, ease: TAG_SWAP_EASE } as const;
* finish together.
*/
export const LINK_LABEL_FADE_OUT = {
duration: 0.12,
duration: motionSeconds("--onboarding-motion-label-exit"),
ease: TAG_SWAP_EASE,
} as const;
export const LINK_LABEL_FADE_IN = {
duration: 0.22,
delay: 0.08,
duration: motionSeconds("--onboarding-motion-label-enter"),
delay: motionSeconds("--motion-duration-instant"),
ease: TAG_SWAP_EASE,
} as const;
@@ -156,7 +158,7 @@ export const CANVAS_EASE = TAG_SWAP_EASE;
* is a surface arriving where there was none, and it comes down from above so
* the movement reads as the tile above it opening out.
*/
export const CANVAS_ENTER_TRAVEL = 10;
export const CANVAS_ENTER_TRAVEL = motionNumber("--onboarding-motion-canvas-travel");
export const CANVAS_CONTENT_ENTER = TAG_SWAP_ENTER;
export const CANVAS_CONTENT_EXIT = TAG_SWAP_EXIT;
@@ -185,7 +187,7 @@ export const CANVAS_CONTENT_TRAVEL = TAG_SWAP_TRAVEL;
* on which tile was picked, and hard-coding it would send the right-hand tile
* the wrong way.
*/
export const SOURCE_COLLAPSE_MOVE = { duration: 0.42, ease: TAG_SWAP_EASE } as const;
export const SOURCE_COLLAPSE_MOVE = { duration: motionSeconds("--onboarding-motion-source-collapse"), ease: TAG_SWAP_EASE } as const;
export const SOURCE_COLLAPSE_FADE = TAG_SWAP_EXIT;
/**
@@ -196,7 +198,7 @@ export const SOURCE_COLLAPSE_FADE = TAG_SWAP_EXIT;
* switching to keys without cancelling — so it should be gone before the eye
* follows the tile, rather than travelling alongside it and inviting a press.
*/
export const SOURCE_LINK_EXIT = { duration: 0.16, ease: TAG_SWAP_EASE } as const;
export const SOURCE_LINK_EXIT = { duration: motionSeconds("--motion-duration-fast"), ease: TAG_SWAP_EASE } as const;
/**
* The card's staged reveal once the sign-in has something to show.
@@ -209,9 +211,9 @@ export const SOURCE_LINK_EXIT = { duration: 0.16, ease: TAG_SWAP_EASE } as const
* Both rise slightly, on the canvas's own travel, so the reveal belongs to the
* surface that opened rather than being a separate entrance inside it.
*/
export const CARD_REVEAL_TRAVEL = 6;
export const CARD_REVEAL_INSTRUCTION = { duration: 0.3, ease: STEP_EASE } as const;
export const CARD_REVEAL_FIELD = { duration: 0.3, delay: 0.12, ease: STEP_EASE } as const;
export const CARD_REVEAL_TRAVEL = motionNumber("--onboarding-motion-reveal-travel");
export const CARD_REVEAL_INSTRUCTION = { duration: motionSeconds("--onboarding-motion-card-enter"), ease: STEP_EASE } as const;
export const CARD_REVEAL_FIELD = { duration: motionSeconds("--onboarding-motion-card-enter"), delay: motionSeconds("--onboarding-motion-label-exit"), ease: STEP_EASE } as const;
/**
* The primary button changing label.
@@ -227,7 +229,7 @@ export const CARD_REVEAL_FIELD = { duration: 0.3, delay: 0.12, ease: STEP_EASE }
* word does. Reversing that reads as the button resizing and then, separately,
* changing its mind about what it says.
*/
export const CTA_WIDTH = { duration: 0.34, ease: TAG_SWAP_EASE } as const;
export const CTA_WIDTH = { duration: motionSeconds("--onboarding-motion-tag-enter"), ease: TAG_SWAP_EASE } as const;
export const CTA_LABEL_OUT = LINK_LABEL_FADE_OUT;
export const CTA_LABEL_IN = LINK_LABEL_FADE_IN;
@@ -241,7 +243,7 @@ export const CTA_LABEL_IN = LINK_LABEL_FADE_IN;
* wrong, because nothing acknowledged it. Two seconds is long enough to be read
* and short enough not to feel stalled.
*/
export const CONNECTED_HOLD_MS = 2000;
export const CONNECTED_HOLD_MS = motionMilliseconds("--onboarding-motion-connected-hold");
/**
* The sign-in card arriving and leaving, and the footer moving because of it.
@@ -257,9 +259,9 @@ export const CONNECTED_HOLD_MS = 2000;
* it. That is why the exit is quick and the settle that follows is separate:
* "card goes, then the bar comes back up" is two beats, not one.
*/
export const CARD_ENTER = { duration: 0.3, ease: STEP_EASE } as const;
export const CARD_EXIT = { duration: 0.18, ease: TAG_SWAP_EASE } as const;
export const FOOTER_SETTLE = { duration: 0.34, ease: TAG_SWAP_EASE } as const;
export const CARD_ENTER = { duration: motionSeconds("--onboarding-motion-card-enter"), ease: STEP_EASE } as const;
export const CARD_EXIT = { duration: motionSeconds("--onboarding-motion-card-exit"), ease: TAG_SWAP_EASE } as const;
export const FOOTER_SETTLE = { duration: motionSeconds("--onboarding-motion-tag-enter"), ease: TAG_SWAP_EASE } as const;
/**
* Milliseconds, for the timers that drive the sequence from one beat to the
@@ -284,7 +286,7 @@ export const FOOTER_SETTLE_MS = FOOTER_SETTLE.duration * 1000;
* layout the column can settle into, and the whole step slides. The card only
* fades in once the room exists.
*/
export const MAKE_ROOM = { duration: 0.34, ease: TAG_SWAP_EASE } as const;
export const MAKE_ROOM = { duration: motionSeconds("--onboarding-motion-tag-enter"), ease: TAG_SWAP_EASE } as const;
export const MAKE_ROOM_MS = MAKE_ROOM.duration * 1000;
/**
@@ -295,7 +297,7 @@ export const MAKE_ROOM_MS = MAKE_ROOM.duration * 1000;
* overlap was long enough to read as two tiles briefly occupying one another;
* at 180 the survivor is clear before it arrives.
*/
export const SOURCE_EXIT_FADE = { duration: 0.18, ease: TAG_SWAP_EASE } as const;
export const SOURCE_EXIT_FADE = { duration: motionSeconds("--onboarding-motion-card-exit"), ease: TAG_SWAP_EASE } as const;
/**
* "Copied!" arriving beside a code that was put on the clipboard for you.
@@ -306,8 +308,8 @@ export const SOURCE_EXIT_FADE = { duration: 0.18, ease: TAG_SWAP_EASE } as const
* statement about the clipboard's contents, and those are still true a second
* later.
*/
export const COPIED_REVEAL_TRAVEL = 6;
export const COPIED_REVEAL = { duration: 0.26, ease: STEP_EASE } as const;
export const COPIED_REVEAL_TRAVEL = motionNumber("--onboarding-motion-reveal-travel");
export const COPIED_REVEAL = { duration: motionSeconds("--onboarding-motion-tag-exit"), ease: STEP_EASE } as const;
/**
* How long "Copied!" waits before it appears.
@@ -323,7 +325,7 @@ export const COPIED_REVEAL = { duration: 0.26, ease: STEP_EASE } as const;
* noticeable at all.
*/
export const COPIED_REVEAL_DELAY_MS =
(CARD_REVEAL_FIELD.delay + CARD_REVEAL_FIELD.duration) * 1000 + 120;
(CARD_REVEAL_FIELD.delay + CARD_REVEAL_FIELD.duration) * 1000 + motionSeconds("--onboarding-motion-copied-gap") * 1000;
/**
* How long to wait before the next beat of the connect sequence.
@@ -343,3 +345,89 @@ export function beatDelay(ms: number): number {
if (typeof window === "undefined" || typeof window.matchMedia !== "function") return 0;
return window.matchMedia("(prefers-reduced-motion: reduce)").matches ? 0 : ms;
}
/**
* The step hand-off inside the agent arc (3 → 4, and back).
*
* The hero, heading and footer carry over; only the step's own content
* changes. So rather than crossfading the whole screen, the departing
* content fades where it stands and then gives its room back, the footer
* sliding up behind it, and the arriving content opens its room first and
* fills it second — the connect card's own rhythm ("room first, card second;
* reversed on the way out"), so the arc keeps one vocabulary for space
* changing hands. `overflow` is discrete: clipped while the room is moving,
* released once it has settled so focus rings and the tiles' strokes are
* not shaved at rest.
*/
export const stepContentMotion = {
initial: { height: 0, opacity: 0, overflow: "hidden" },
animate: {
height: "auto",
opacity: 1,
transition: { height: MAKE_ROOM, opacity: { ...CARD_ENTER, delay: MAKE_ROOM.duration } },
transitionEnd: { overflow: "visible" },
},
exit: {
height: 0,
opacity: 0,
overflow: "hidden",
transition: { opacity: CARD_EXIT, height: { ...MAKE_ROOM, delay: CARD_EXIT.duration } },
},
} as const;
/**
* The heading's lede opens on the connect step. It waits out the departing
* content's fade and collapse and opens with the arriving content's room, so
* the footer makes one sweep up and one sweep down rather than dipping,
* rising and dipping again. Closing needs no wait: it collapses with the
* content below it.
*/
const HANDOFF_EXIT_SECONDS = CARD_EXIT.duration + MAKE_ROOM.duration;
export const ledeMotion = {
open: {
height: "auto",
opacity: 1,
transition: { height: { ...MAKE_ROOM, delay: HANDOFF_EXIT_SECONDS }, opacity: { ...CARD_ENTER, delay: HANDOFF_EXIT_SECONDS + MAKE_ROOM.duration } },
},
closed: { height: 0, opacity: 0, transition: { opacity: CARD_EXIT, height: { ...MAKE_ROOM, delay: CARD_EXIT.duration } } },
} as const;
/** The title's text swap: the new words fade in over the old ones' place; no travel. */
export const titleSwapMotion = {
initial: { opacity: 0 },
animate: { opacity: 1 },
transition: LINK_LABEL_FADE_IN,
} as const;
/**
* The hero's room: closed on the naming step (no agent exists to show), open
* from the agent step on. It opens on the arriving beat of the hand-off, with
* the capsule springing up inside it, and closes after the departing fade
* when the customer walks back — the same room-first/room-last timing as the
* step content and the lede, so the footer's travel stays one movement.
*/
export const heroRoomMotion = {
open: { height: "auto", transition: { height: { ...MAKE_ROOM, delay: HANDOFF_EXIT_SECONDS } } },
closed: { height: 0, transition: { height: { ...MAKE_ROOM, delay: CARD_EXIT.duration } } },
} as const;
/** The capsule's spring, held until its room starts opening; on the way out it fades with the departing content. */
export const capsuleRoomEnter = {
...capsuleHeroMotion.animate,
transition: {
scale: { ...capsuleHeroMotion.transition.scale, delay: HANDOFF_EXIT_SECONDS },
opacity: { ...capsuleHeroMotion.transition.opacity, delay: HANDOFF_EXIT_SECONDS },
},
} as const;
export const capsuleRoomExit = { ...capsuleHeroMotion.initial, transition: CARD_EXIT } as const;
/**
* Arriving at the agent step directly — a cloud-managed workspace, whose
* organization was named in Cloud, lands here on a fresh page — plays the
* hand-off's second half with nothing to wait for: the hero's room and the
* step's content open together, the capsule springs up as its room grows,
* and the content fills once its room is open. What was on the other side
* of the page load is Cloud's naming screen, drawn to the same layout, so
* the arrival reads as that screen becoming this one.
*/
export const heroRoomArrival = { height: "auto", transition: { height: MAKE_ROOM } } as const;
@@ -3,9 +3,11 @@ import { describe, expect, it } from "vitest";
import { MOTION_TOKENS } from "./motion-tokens";
import { buildTweakExport } from "./TweakPanel";
/** Collect declared --motion-* custom properties from index.css (deduped). */
/** Collect shared --motion-* properties from the token layer (deduped). */
function declaredMotionTokens(): Set<string> {
const css = readFileSync(new URL("../../index.css", import.meta.url), "utf8");
const indexCss = readFileSync(new URL("../../index.css", import.meta.url), "utf8");
expect(indexCss).toContain('@import "./motion-tokens.css";');
const css = `${indexCss}\n${readFileSync(new URL("../../motion-tokens.css", import.meta.url), "utf8")}`;
const names = new Set<string>();
const re = /(--motion-[a-z0-9-]+)\s*:/g;
let m: RegExpExecArray | null;
@@ -14,15 +16,15 @@ function declaredMotionTokens(): Set<string> {
}
describe("motion token catalog", () => {
it("is 1:1 with the --motion-* tokens declared in index.css", () => {
it("is 1:1 with the --motion-* tokens declared in the token layer", () => {
const declared = declaredMotionTokens();
const catalog = new Set(MOTION_TOKENS.map((t) => t.name));
const missingFromCatalog = [...declared].filter((n) => !catalog.has(n));
const missingFromCss = [...catalog].filter((n) => !declared.has(n));
expect(missingFromCatalog, "tokens in index.css but not in the catalog").toEqual([]);
expect(missingFromCss, "tokens in the catalog but not in index.css").toEqual([]);
expect(missingFromCatalog, "tokens in the token layer but not in the catalog").toEqual([]);
expect(missingFromCss, "tokens in the catalog but not in the token layer").toEqual([]);
expect(catalog.size).toBe(declared.size);
});
});
+3 -3
View File
@@ -1,11 +1,11 @@
/**
* Catalog of the redesign's motion tokens. This mirrors the `--motion-*`
* custom properties declared in ui/src/index.css :root, and is the list the dev
* custom properties declared in ui/src/index.css and its motion token import, and is the list the dev
* tweak panel renders controls from.
*
* A finish-line test parses index.css for `--motion-*` declarations and asserts
* A finish-line test parses the token layer for `--motion-*` declarations and asserts
* this catalog is 1:1 with them, so the two can never silently drift: add a
* token to index.css and you must add it here (and vice-versa).
* shared --motion-* token to the token layer and you must add it here (and vice-versa).
*/
export type MotionTokenKind = "time" | "easing";
+4 -6
View File
@@ -1,3 +1,4 @@
@import "./motion-tokens.css";
@import "tailwindcss";
@plugin "@tailwindcss/typography";
@@ -245,14 +246,11 @@
hardcoded ms / cubic-bezier in components (enforced by a check script).
──────────────────────────────────────────────────────────────────────── */
/* Easing primitives (tunable). --motion-ease-out itself is declared by the
decision/quicklook motion block further down; the thread shares it. */
--motion-ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1); /* house signature curve */
--motion-ease-standard: cubic-bezier(0.4, 0, 0.2, 1); /* house material curve */
/* Shared easing and instant/fast duration primitives are in the imported
motion-tokens.css so JavaScript motion uses the same defaults.
--motion-ease-out is declared by the decision/quicklook block below. */
/* Duration primitives (tunable) */
--motion-duration-instant: 80ms;
--motion-duration-fast: 160ms;
--motion-duration-base: 240ms;
--motion-duration-slow: 360ms;
--motion-duration-deliberate: 520ms;
@@ -0,0 +1,26 @@
// @vitest-environment jsdom
import { afterEach, expect, it, vi } from "vitest";
import { motionEase, motionMilliseconds, motionNumber, motionSeconds } from "./onboarding-motion-tokens";
afterEach(() => {
document.documentElement.removeAttribute("style");
vi.unstubAllGlobals();
});
it("uses the CSS defaults before styles load and honors runtime token overrides", () => {
vi.stubGlobal("matchMedia", () => ({ matches: false }));
expect(motionSeconds("--onboarding-motion-step")).toBe(0.28);
expect(motionNumber("--onboarding-motion-hero-stiffness")).toBe(150);
document.documentElement.style.setProperty("--onboarding-motion-step", "0.7s");
document.documentElement.style.setProperty("--onboarding-motion-hero-stiffness", "190");
document.documentElement.style.setProperty("--motion-ease-out-expo", "cubic-bezier(0.1, 0.2, 0.3, 1)");
expect(motionSeconds("--onboarding-motion-step")).toBe(0.7);
expect(motionMilliseconds("--onboarding-motion-step")).toBe(700);
expect(motionNumber("--onboarding-motion-hero-stiffness")).toBe(190);
expect(motionEase("--motion-ease-out-expo")).toEqual([0.1, 0.2, 0.3, 1]);
});
it("removes animation durations under reduced motion without removing the connection status hold", () => {
vi.stubGlobal("matchMedia", () => ({ matches: true }));
expect(motionSeconds("--onboarding-motion-step")).toBe(0);
expect(motionSeconds("--onboarding-motion-label-exit")).toBe(0);
expect(motionMilliseconds("--onboarding-motion-connected-hold")).toBe(2000);
});
+32
View File
@@ -0,0 +1,32 @@
import tokenStyles from "../motion-tokens.css?raw";
// Use the same stylesheet as index.css for non-browser callers and before
// styles load. Do not maintain a second set of numeric defaults in JavaScript.
const defaults = new Map(
[...tokenStyles.matchAll(/(--[\w-]+):\s*([^;]+);/g)].map((match) => [match[1], match[2].trim()]),
);
function token(name: string): string {
const value = typeof document === "undefined"
? ""
: getComputedStyle(document.documentElement).getPropertyValue(name).trim();
return value || defaults.get(name) || "";
}
export function motionNumber(name: string): number {
return Number.parseFloat(token(name)) || 0;
}
export function motionMilliseconds(name: string): number {
const value = token(name);
return (Number.parseFloat(value) || 0) * (value.endsWith("ms") ? 1 : 1000);
}
export function motionSeconds(name: string): number {
if (typeof window !== "undefined" && typeof window.matchMedia === "function"
&& window.matchMedia("(prefers-reduced-motion: reduce)").matches) return 0;
return motionMilliseconds(name) / 1000;
}
export function motionEase(name: string): [number, number, number, number] | "linear" {
const match = token(name).match(/^cubic-bezier\(([^)]+)\)$/);
const points = match?.[1].split(",").map(Number);
return points?.length === 4 && points.every(Number.isFinite)
? points as [number, number, number, number]
: "linear";
}
+29
View File
@@ -0,0 +1,29 @@
/* Imported by index.css. Numeric Motion parameters and choreography timings
live here so CSS remains the source of truth for onboarding motion. */
:root {
--motion-ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1);
--motion-ease-standard: cubic-bezier(0.4, 0, 0.2, 1);
--motion-duration-instant: 80ms;
--motion-duration-fast: 160ms;
--onboarding-motion-step: 280ms;
--onboarding-motion-capsule: 1000ms;
--onboarding-motion-preview: 450ms;
--onboarding-motion-hero-fade: 550ms;
--onboarding-motion-tag-enter: 340ms;
--onboarding-motion-tag-exit: 260ms;
--onboarding-motion-label-exit: 120ms;
--onboarding-motion-label-enter: 220ms;
--onboarding-motion-source-collapse: 420ms;
--onboarding-motion-card-enter: 300ms;
--onboarding-motion-card-exit: 180ms;
--onboarding-motion-connected-hold: 2000ms;
--onboarding-motion-copied-gap: 120ms;
--onboarding-motion-step-travel: 8;
--onboarding-motion-tag-travel: 7;
--onboarding-motion-canvas-travel: 10;
--onboarding-motion-reveal-travel: 6;
--onboarding-motion-capsule-scale: 0.5;
--onboarding-motion-capsule-bounce: 0.4;
--onboarding-motion-hero-stiffness: 150;
--onboarding-motion-hero-damping: 16;
}
+8
View File
@@ -114,6 +114,14 @@ function installStorybookApiFixtures() {
}
if (url.pathname === "/api/companies") {
// The wizard's naming step creates a company here and reads its id back;
// answering the POST with the list instead gave it an array, and the
// walk crashed on the step after. Hand back the fixture company as the
// one just created, so the arc can be walked from its first screen.
if ((init?.method ?? "GET").toUpperCase() === "POST") {
const body = init?.body ? (JSON.parse(String(init.body)) as { name?: string }) : {};
return Response.json({ ...storybookCompanies[0], name: body.name ?? storybookCompanies[0]?.name });
}
return Response.json(storybookCompanies);
}
@@ -129,13 +129,10 @@ const STEP_TIMEOUT_MS = 15_000;
* captured a moment earlier can be detached by the time it is clicked — a click
* that raises no error and does nothing.
*
* The arrival is waited on by *heading*, not by the next button's label. The arc
* labels its forward button "Next" on every step it has one, so a story that
* waited for a button name would be satisfied by the button it just clicked and
* report arriving somewhere it never left. This is not hypothetical: these
* stories waited for a button named "Connect" until the label changed, at which
* point Review sat on the connect step looking like a story written to open
* there — the exact failure `STEP_TIMEOUT_MS` is commented against.
* Wait for the destination's accessible heading, since button labels repeat
* across steps and the heading's animated text lives inside a nested span.
* A direct text query restricted to h1/h2 misses that span and stops the story
* before it can pick a source or reach Review.
*/
async function advance(to: string) {
await waitFor(
@@ -143,11 +140,11 @@ async function advance(to: string) {
{ timeout: STEP_TIMEOUT_MS },
);
await userEvent.click(screen.getByRole("button", { name: PRIMARY }));
await screen.findByText(to, { selector: "h2, h1" }, { timeout: STEP_TIMEOUT_MS });
await screen.findByRole("heading", { name: to }, { timeout: STEP_TIMEOUT_MS });
}
/** The arc's forward button, which reads the same on every step but the last. */
const PRIMARY = "Next";
/** Naming advances with Next; the selected model source advances with Connect. */
const PRIMARY = /^(Next|Connect)$/;
/**
* Pick a model source, which the connect step needs before it will go forward.
@@ -201,6 +198,26 @@ export const CreateYourAgent: StoryObj = {
render: () => <WizardArc />,
};
/**
* The arrival a cloud-managed workspace makes: its organization was named in
* Cloud, so the wizard opens on a fresh page straight at the agent step and
* plays the hand-off's second half (hero room opening, content making room
* then filling) rather than mounting cold. Mounted on a press so the arrival
* can be watched — and measured — from its first frame.
*/
function ArrivalStage() {
const [mounted, setMounted] = useState(false);
if (mounted) return <WizardArc />;
return (
<button type="button" className="rounded-full border px-4 py-2 text-sm" onClick={() => setMounted(true)}>
Arrive at the agent step
</button>
);
}
export const ArriveFromCloud: StoryObj = {
render: () => <ArrivalStage />,
};
/**
* The connect step as a signed-out cloud tenant meets it: a managed sandbox
* resolves, and the provider sign-in panel is offered because the auth signal
@@ -0,0 +1,44 @@
import { useState, type ReactNode } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { AGENT_PALETTE_IDS, appearanceForPalette, type AgentPaletteId } from "@paperclipai/shared";
import { OnboardingCharacter } from "../../src/components/onboarding/OnboardingCharacter";
import { Button } from "../../src/components/ui/button";
const meta = {
title: "Onboarding/Character",
component: OnboardingCharacter,
args: { appearance: appearanceForPalette("bubblegum-sky"), awake: false },
parameters: { docs: { description: { component: "The onboarding hero: the persona character (the studio's App export) on the shared ClipLab v0.2.0 engine. Gray and dozing until Review, where it plays the one-shot sleepy → wink → idle transition while its palette fades in, then settles into the idle loop. Reduced motion skips the sequence." } } },
argTypes: { awake: { control: "boolean" } },
} satisfies Meta<typeof OnboardingCharacter>;
export default meta;
type Story = StoryObj<typeof meta>;
/** The wizard's frame: a 160px box, the same as steps 3–5. */
function Frame({ children }: { children: ReactNode }) {
return <div className="relative size-(--sz-160px)">{children}</div>;
}
export const Asleep: Story = { render: (args) => <Frame><OnboardingCharacter {...args} className="size-full" /></Frame> };
export const Awake: Story = { args: { awake: true }, render: (args) => <Frame><OnboardingCharacter {...args} className="size-full" /></Frame> };
/** Step from the connect step to Review and back, as the wizard does; the palette is picked here since a real hire assigns it at random. */
export const WakeUp: Story = {
render: (args) => {
const [awake, setAwake] = useState(false);
const [palette, setPalette] = useState<AgentPaletteId>(args.appearance.paletteId);
return <div className="flex flex-col items-start gap-6">
<div className="flex items-end gap-8">
<Frame><OnboardingCharacter appearance={appearanceForPalette(palette)} awake={awake} className="size-full" /></Frame>
<div className="size-32"><OnboardingCharacter appearance={appearanceForPalette(palette)} awake={awake} className="size-full" /></div>
<div className="size-64"><OnboardingCharacter appearance={appearanceForPalette(palette)} awake={awake} className="size-full" /></div>
</div>
<div className="flex items-center gap-3">
<Button onClick={() => setAwake((v) => !v)}>{awake ? "Back to sleep" : "Reach Review (wake)"}</Button>
<select className="rounded border px-2 py-1 text-sm" value={palette} onChange={(e) => setPalette(e.target.value as AgentPaletteId)}>
{AGENT_PALETTE_IDS.map((id) => <option key={id} value={id}>{id}</option>)}
</select>
</div>
</div>;
},
};
+1
View File
@@ -10,6 +10,7 @@ export default defineConfig({
},
test: {
environment: "node",
css: { include: [/motion-tokens\.css/] },
setupFiles: ["./vitest.setup.ts"],
},
});