diff --git a/docs/superpowers/plans/2026-09-06-cocktail-mixer-pixi-pour.md b/docs/superpowers/plans/2026-09-06-cocktail-mixer-pixi-pour.md new file mode 100644 index 0000000..9c93c78 --- /dev/null +++ b/docs/superpowers/plans/2026-09-06-cocktail-mixer-pixi-pour.md @@ -0,0 +1,1250 @@ +# Cocktail Mixer Pixi Pour Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the SVG GSAP pour on `/work/cocktail-mixer` with a painted PixiJS v8 stage conducted by a GSAP story clock, without changing recipes, unlock, or XP. + +**Architecture:** Pure `pour-script.ts` emits timed cues and `PourSnapshot`s. `BarStage` `await`s Pixi `Application.init` + `Assets.load`, then mounts `PourDirector` (the only `useGSAP` caller). A Pixi `rig` applies uniforms on the ticker. WebGL failure and reduced motion share `applyFinished` / a CSS still. Selection-grid SVG card icons stay. + +**Tech Stack:** Next.js 15 App Router, React 19, PixiJS `^8.8.0`, `gsap` + `@gsap/react` (already in the mixer), Vitest + Testing Library, `sharp` (devDep) to rasterize plates. + +**Spec:** `docs/superpowers/specs/2026-09-05-cocktail-mixer-pixi-pour-design.md` + +## Global Constraints + +- Add **only** `pixi.js ^8.8.0`. Do not add `pixi-filters`, `@pixi/particle-emitter`, Rive, or Three wrappers. +- Do not import Pixi from the root layout. The demo stays `next/dynamic(..., { ssr: false })` in `src/components/demos/demo-loader.tsx`. +- Do not go 3D. Do not polish the current SVG glasses as the pour picture. +- Recipes, Cosmonaut unlock, XP, localStorage (`useCocktailProgress`) stay. +- GSAP is the story clock. Pixi ticker only applies uniforms (vertices, ropes, particles). +- Finished liquid is `cocktail.color` (one volume). No stacked ingredient bands. +- Two `MeshRope`s: air neck→rim (unmasked), inner rim→surface (interior mask group). +- `stream.png` is **128×8** at resolution 2 (4px CSS thick). Never 8×64. +- `PourDirector` mounts only when Pixi is `ready`. Reduced motion never mounts it. +- `app.destroy({ removeView: true }, { children: true, texture: false, textureSource: false })`. +- Canvas CSS: `width: 100%; max-width: 280px; height: auto`. Stage 280×420. +- Exhaustive `switch` on `GarnishType` / `MixMethod` with a `never` default. +- Path alias `@/*` → `./src/*`. Commands: `npx vitest run `, `npm test`, `npm run build`. +- Commit on this branch (`cursor/cocktail-mixer-pixi-pour-5c83`). Do not commit to main until the pour is done. + +## File map + +```text +src/components/demos/cocktail-mixer/ + glass-bounds.ts Task 1 + pour-script.ts Task 2 + garnish-map.ts Task 3 + pixi/application.ts Task 5 + pixi/assets.ts Task 5 + pixi/bar-stage.tsx Task 5, extended 6–10 + pixi/css-still.tsx Task 5 + pixi/liquid.ts Task 6 + pixi/stream.ts Task 6 + pixi/particles.ts Task 7 + pixi/rig.ts Task 6–7 + pixi/pour-director.tsx Task 8 + assets/*.png Task 4 +scripts/generate-mixer-plates.ts Task 4 +``` + +Delete after Task 9: `glass-scene.tsx`, `pour-timeline.ts`, `svg/glasses.tsx`, `svg/bottles.tsx`, `svg/garnishes.tsx`, `svg/decorations.tsx`, `components/glass-visualization.tsx`, `components/cosmonaut-reveal.tsx`, `__tests__/pour-timeline.test.ts`, `__tests__/pour-sequence.test.ts`, `usePourSequence` in `hooks.ts`. + +Keep: `svg/card-icons.tsx`, `index.tsx` behavior, `data.ts` recipes, `components/selection-grid.tsx`, `components/recipe-details.tsx`. + +--- + +### Task 1: Glass bounds (pure) + +**Files:** +- Create: `src/components/demos/cocktail-mixer/glass-bounds.ts` +- Create: `src/components/demos/cocktail-mixer/__tests__/glass-bounds.test.ts` + +**Interfaces:** +- Consumes: `GlassType` from `./types` +- Produces: `STAGE`, `GLASS_RECT`, `GlassBounds`, `IceCube`, `GLASS_BOUNDS`, `ICE_LAYOUT`, `CONDENSATION_LAYOUT` + +- [ ] **Step 1: Write the failing test** + +```ts +/** @vitest-environment node */ +import { describe, it, expect } from "vitest"; +import { + GLASS_BOUNDS, + ICE_LAYOUT, + CONDENSATION_LAYOUT, + STAGE, + GLASS_RECT, +} from "../glass-bounds"; +import type { GlassType } from "../types"; + +const TYPES: GlassType[] = ["rocks", "highball", "coupe", "margarita"]; + +describe("glass-bounds", () => { + it("defines a 280×420 stage and a 200×300 glass rect", () => { + expect(STAGE).toEqual({ width: 280, height: 420 }); + expect(GLASS_RECT).toEqual({ x: 40, y: 48, width: 200, height: 300 }); + }); + + it.each(TYPES)("%s has liquidTop < liquidBottom and rimY ≤ liquidTop", (type) => { + const b = GLASS_BOUNDS[type]; + expect(b.liquidTop).toBeLessThan(b.liquidBottom); + expect(b.rimY).toBeLessThanOrEqual(b.liquidTop); + expect(b.bowlWidth).toBeGreaterThan(0); + }); + + it("gives ice only to rocks and highball", () => { + expect(GLASS_BOUNDS.rocks.hasIce).toBe(true); + expect(GLASS_BOUNDS.highball.hasIce).toBe(true); + expect(GLASS_BOUNDS.coupe.hasIce).toBe(false); + expect(GLASS_BOUNDS.margarita.hasIce).toBe(false); + expect(ICE_LAYOUT.rocks).toHaveLength(2); + expect(ICE_LAYOUT.highball).toHaveLength(3); + }); + + it("places bottle neck in local sprite space, not stage 100,40", () => { + for (const type of TYPES) { + expect(GLASS_BOUNDS[type].bottle.neckX).toBeLessThan(48); + expect(GLASS_BOUNDS[type].bottle.neckY).toBeLessThan(48); + } + }); + + it("has condensation dots for every glass", () => { + for (const type of TYPES) { + expect(CONDENSATION_LAYOUT[type].length).toBeGreaterThan(0); + } + }); +}); +``` + +- [ ] **Step 2: Run the test and verify it fails** + +Run: `npx vitest run src/components/demos/cocktail-mixer/__tests__/glass-bounds.test.ts` + +Expected: FAIL — `glass-bounds` is not defined. + +- [ ] **Step 3: Implement `glass-bounds.ts`** + +Copy the seed tables verbatim from the spec (`STAGE`, `GLASS_RECT`, `GLASS_BOUNDS`, `ICE_LAYOUT`, `CONDENSATION_LAYOUT`). Export: + +```ts +import type { GlassType } from "./types"; + +export const STAGE = { width: 280, height: 420 } as const; +export const GLASS_RECT = { x: 40, y: 48, width: 200, height: 300 } as const; + +export type IceCube = { + dx: number; + dy: number; + angle: number; + scale: number; +}; + +export type GlassBounds = { + liquidTop: number; + liquidBottom: number; + rimY: number; + bowlCenterX: number; + bowlWidth: number; + hasIce: boolean; + garnishX: number; + garnishY: number; + bottle: { x: number; y: number; neckX: number; neckY: number }; +}; +``` + +Fill `GLASS_BOUNDS`, `ICE_LAYOUT`, `CONDENSATION_LAYOUT` with the spec numbers. `ICE_LAYOUT` is `Record<"rocks" | "highball", IceCube[]>`. + +- [ ] **Step 4: Run the test and verify it passes** + +Run: `npx vitest run src/components/demos/cocktail-mixer/__tests__/glass-bounds.test.ts` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/components/demos/cocktail-mixer/glass-bounds.ts src/components/demos/cocktail-mixer/__tests__/glass-bounds.test.ts +git commit -m "feat: add cocktail mixer glass bounds" +``` + +--- + +### Task 2: Pour script (pure clock) + +**Files:** +- Create: `src/components/demos/cocktail-mixer/pour-script.ts` +- Create: `src/components/demos/cocktail-mixer/__tests__/pour-script.test.ts` + +**Interfaces:** +- Consumes: nothing from Pixi. `hasIce` / `isSecret` / `ingredientCount` are arguments. +- Produces: `POUR_INTRO`, `POUR_SLOT`, `POUR_FINISH`, `SECRET_EXTRA`, `PourSnapshot`, `PourCue`, `pourDuration`, `buildPourCues` + +- [ ] **Step 1: Write the failing test** + +```ts +/** @vitest-environment node */ +import { describe, it, expect } from "vitest"; +import { + POUR_FINISH, + POUR_INTRO, + POUR_SLOT, + SECRET_EXTRA, + buildPourCues, + pourDuration, +} from "../pour-script"; + +describe("pour-script", () => { + it("matches the duration formula", () => { + expect(pourDuration(3, false)).toBeCloseTo( + POUR_INTRO + 3 * POUR_SLOT + POUR_FINISH + ); + expect(pourDuration(3, false)).toBeCloseTo(3.4); + expect(pourDuration(4, true)).toBeCloseTo( + POUR_INTRO + 4 * POUR_SLOT + POUR_FINISH + SECRET_EXTRA + ); + expect(pourDuration(4, true)).toBeCloseTo(4.65); + }); + + it("increments pouredCount at each slot start", () => { + const cues = buildPourCues({ + ingredientCount: 3, + hasIce: false, + isSecret: false, + reducedMotion: false, + }); + const snaps = cues.filter((c) => c.kind === "snapshot").map((c) => c.snapshot); + expect(snaps[0]).toEqual({ + pouredCount: 0, + activePour: null, + allDone: false, + }); + expect(snaps.some((s) => s?.pouredCount === 1 && s.activePour === 0)).toBe( + true + ); + expect(snaps.at(-1)).toEqual({ + pouredCount: 3, + activePour: null, + allDone: true, + }); + }); + + it("emits allDone immediately for reduced motion", () => { + const cues = buildPourCues({ + ingredientCount: 3, + hasIce: true, + isSecret: true, + reducedMotion: true, + }); + expect(cues).toEqual([ + { + at: 0, + kind: "snapshot", + snapshot: { pouredCount: 3, activePour: null, allDone: true }, + }, + ]); + }); + + it("skips ice cues when hasIce is false", () => { + const noIce = buildPourCues({ + ingredientCount: 2, + hasIce: false, + isSecret: false, + reducedMotion: false, + }); + const iced = buildPourCues({ + ingredientCount: 2, + hasIce: true, + isSecret: false, + reducedMotion: false, + }); + expect(noIce.some((c) => c.kind === "ice")).toBe(false); + expect(iced.some((c) => c.kind === "ice")).toBe(true); + }); + + it("places Cosmonaut allDone after SECRET_EXTRA", () => { + const cues = buildPourCues({ + ingredientCount: 4, + hasIce: false, + isSecret: true, + reducedMotion: false, + }); + const secret = cues.find((c) => c.kind === "secret"); + const done = cues.find((c) => c.kind === "done"); + expect(secret).toBeTruthy(); + expect(done?.at).toBeCloseTo(pourDuration(4, true)); + expect(done?.at).toBeGreaterThan(secret!.at); + expect(done?.snapshot?.allDone).toBe(true); + }); +}); +``` + +- [ ] **Step 2: Run the test and verify it fails** + +Run: `npx vitest run src/components/demos/cocktail-mixer/__tests__/pour-script.test.ts` + +Expected: FAIL — module missing. + +- [ ] **Step 3: Implement `pour-script.ts`** + +```ts +export const POUR_INTRO = 0.2; +export const POUR_SLOT = 0.85; +export const POUR_FINISH = 0.65; +export const SECRET_EXTRA = 0.4; + +export interface PourSnapshot { + pouredCount: number; + activePour: number | null; + allDone: boolean; +} + +export type PourCueKind = + | "snapshot" + | "bottleTip" + | "streamOn" + | "fill" + | "meniscus" + | "splash" + | "streamOff" + | "bottleHide" + | "ice" + | "garnish" + | "method" + | "secret" + | "done"; + +export interface PourCue { + at: number; + kind: PourCueKind; + ingredientIndex?: number; + snapshot?: PourSnapshot; +} + +export function pourDuration( + ingredientCount: number, + isSecret = false +): number { + return ( + POUR_INTRO + + ingredientCount * POUR_SLOT + + POUR_FINISH + + (isSecret ? SECRET_EXTRA : 0) + ); +} + +export function buildPourCues({ + ingredientCount, + hasIce, + isSecret, + reducedMotion, +}: { + ingredientCount: number; + hasIce: boolean; + isSecret: boolean; + reducedMotion: boolean; +}): PourCue[] { + const n = ingredientCount; + const doneSnap: PourSnapshot = { + pouredCount: n, + activePour: null, + allDone: true, + }; + if (reducedMotion) { + return [{ at: 0, kind: "snapshot", snapshot: doneSnap }]; + } + + const cues: PourCue[] = [ + { + at: 0, + kind: "snapshot", + snapshot: { pouredCount: 0, activePour: null, allDone: false }, + }, + ]; + + for (let i = 0; i < n; i++) { + const at = POUR_INTRO + i * POUR_SLOT; + cues.push( + { + at, + kind: "snapshot", + ingredientIndex: i, + snapshot: { pouredCount: i + 1, activePour: i, allDone: false }, + }, + { at, kind: "bottleTip", ingredientIndex: i }, + { at: at + 0.18, kind: "streamOn", ingredientIndex: i }, + { at: at + 0.2, kind: "fill", ingredientIndex: i }, + { at: at + 0.25, kind: "meniscus", ingredientIndex: i }, + { at: at + 0.38, kind: "splash", ingredientIndex: i }, + { at: at + 0.58, kind: "streamOff", ingredientIndex: i }, + { + at: at + 0.62, + kind: "bottleHide", + ingredientIndex: i, + snapshot: { pouredCount: i + 1, activePour: null, allDone: false }, + } + ); + } + + const finishAt = POUR_INTRO + n * POUR_SLOT; + if (hasIce) cues.push({ at: finishAt, kind: "ice" }); + cues.push({ at: finishAt + 0.12, kind: "garnish" }); + cues.push({ at: finishAt + 0.2, kind: "method" }); + if (isSecret) { + cues.push({ at: finishAt + POUR_FINISH, kind: "secret" }); + cues.push({ + at: finishAt + POUR_FINISH + SECRET_EXTRA, + kind: "done", + snapshot: doneSnap, + }); + } else { + cues.push({ + at: finishAt + POUR_FINISH, + kind: "done", + snapshot: doneSnap, + }); + } + return cues; +} +``` + +Slot relative times must match the spec table (0.00 / 0.18 / 0.20 / 0.25 / 0.38 / 0.58 / 0.62). `bottleHide` also carries the `activePour: null` snapshot. + +- [ ] **Step 4: Run the test and verify it passes** + +Run: `npx vitest run src/components/demos/cocktail-mixer/__tests__/pour-script.test.ts` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/components/demos/cocktail-mixer/pour-script.ts src/components/demos/cocktail-mixer/__tests__/pour-script.test.ts +git commit -m "feat: add mixer pour cue script" +``` + +--- + +### Task 3: Garnish map, foam rule, data tests + +**Files:** +- Create: `src/components/demos/cocktail-mixer/garnish-map.ts` +- Create: `src/components/demos/cocktail-mixer/__tests__/garnish-map.test.ts` +- Modify: `src/components/demos/cocktail-mixer/__tests__/cocktail-data.test.ts` + +**Interfaces:** +- Consumes: `GarnishType`, `GlassType` from `./types` +- Produces: `garnishPlates(type, glass)`, `SODA_INGREDIENT_NAME`, `isFoamIngredient(name)` + +- [ ] **Step 1: Write failing garnish/foam tests** + +```ts +/** @vitest-environment node */ +import { describe, it, expect } from "vitest"; +import { + garnishPlates, + isFoamIngredient, + SODA_INGREDIENT_NAME, +} from "../garnish-map"; +import { COCKTAILS, THE_COSMONAUT } from "../data"; + +describe("garnish-map", () => { + it("puts salt on from the first frame for salt_* types", () => { + expect(garnishPlates("salt_lime", "margarita")).toEqual([ + "rim-salt-margarita.png", + "garnish-lime-wheel.png", + ]); + expect(garnishPlates("salt_grapefruit", "highball")).toEqual([ + "rim-salt-highball.png", + "garnish-grapefruit-wedge.png", + ]); + }); + + it("maps rocket for the Cosmonaut", () => { + expect(garnishPlates("rocket", "coupe")).toEqual(["garnish-rocket.png"]); + }); + + it("foams only Grapefruit Soda, not Salty Dog juice", () => { + expect(SODA_INGREDIENT_NAME).toBe("Grapefruit Soda"); + const paloma = COCKTAILS.find((c) => c.name === "Paloma")!; + const dog = COCKTAILS.find((c) => c.name === "Salty Dog")!; + expect(paloma.ingredients.some((i) => isFoamIngredient(i.name))).toBe(true); + expect(dog.ingredients.some((i) => isFoamIngredient(i.name))).toBe(false); + expect(THE_COSMONAUT.ingredients.some((i) => isFoamIngredient(i.name))).toBe( + false + ); + }); +}); +``` + +In `cocktail-data.test.ts` replace `GLASS_CONFIGS` with `GLASS_BOUNDS`: + +```ts +import { GLASS_BOUNDS } from "../glass-bounds"; +const VALID_GLASSES = Object.keys(GLASS_BOUNDS); +``` + +Replace the “glass configs have valid liquid ranges” test with: + +```ts +it("every cocktail glass exists in GLASS_BOUNDS", () => { + for (const c of ALL_COCKTAILS) { + expect(GLASS_BOUNDS[c.glass]).toBeDefined(); + } +}); +``` + +- [ ] **Step 2: Run tests and verify RED** + +Run: `npx vitest run src/components/demos/cocktail-mixer/__tests__/garnish-map.test.ts src/components/demos/cocktail-mixer/__tests__/cocktail-data.test.ts` + +Expected: garnish-map FAIL (missing module). cocktail-data FAIL if you already swapped the import; otherwise do the swap in Step 3. + +- [ ] **Step 3: Implement `garnish-map.ts`** + +```ts +import type { GarnishType, GlassType } from "./types"; + +export const SODA_INGREDIENT_NAME = "Grapefruit Soda"; + +export function isFoamIngredient(name: string): boolean { + return name === SODA_INGREDIENT_NAME; +} + +function saltPlate(glass: GlassType): string { + switch (glass) { + case "margarita": + return "rim-salt-margarita.png"; + case "highball": + return "rim-salt-highball.png"; + case "rocks": + case "coupe": + return "rim-salt-highball.png"; + default: { + const _exhaustive: never = glass; + return _exhaustive; + } + } +} + +export function garnishPlates( + type: GarnishType, + glass: GlassType +): string[] { + switch (type) { + case "lime_wheel": + return ["garnish-lime-wheel.png"]; + case "cherry": + return ["garnish-cherry.png"]; + case "orange_slice": + return ["garnish-orange-slice.png"]; + case "grapefruit_wedge": + return ["garnish-grapefruit-wedge.png"]; + case "salt_rim": + return [saltPlate(glass)]; + case "cherry_orange": + return ["garnish-cherry-orange.png"]; + case "salt_grapefruit": + return [saltPlate(glass), "garnish-grapefruit-wedge.png"]; + case "salt_lime": + return [saltPlate(glass), "garnish-lime-wheel.png"]; + case "rocket": + return ["garnish-rocket.png"]; + default: { + const _exhaustive: never = type; + return _exhaustive; + } + } +} +``` + +Then apply the `cocktail-data.test.ts` import swap. + +- [ ] **Step 4: Run tests and verify GREEN** + +Run: `npx vitest run src/components/demos/cocktail-mixer/__tests__/garnish-map.test.ts src/components/demos/cocktail-mixer/__tests__/cocktail-data.test.ts` + +Expected: PASS. `GLASS_CONFIGS` may still exist in `data.ts` until Task 9; tests must not import it. + +- [ ] **Step 5: Commit** + +```bash +git add src/components/demos/cocktail-mixer/garnish-map.ts src/components/demos/cocktail-mixer/__tests__/garnish-map.test.ts src/components/demos/cocktail-mixer/__tests__/cocktail-data.test.ts +git commit -m "feat: map mixer garnishes and Paloma foam" +``` + +--- + +### Task 4: `pixi.js` + painted plates + +**Files:** +- Modify: `package.json` (add `pixi.js`) +- Create: `scripts/generate-mixer-plates.ts` +- Create: `src/components/demos/cocktail-mixer/assets/` (PNG output) +- Modify: `package.json` scripts — `"plates:mixer": "npx tsx scripts/generate-mixer-plates.ts"` + +**Interfaces:** +- Produces: every file listed in the spec Asset pack, plus `ASSETS` keys in Task 5. + +- [ ] **Step 1: Install Pixi** + +```bash +npm install pixi.js@^8.8.0 +``` + +Confirm `package.json` has `"pixi.js": "^8.8.0"` (or a resolved 8.8.x / 8.9.x). Do not add other Pixi packages. + +- [ ] **Step 2: Write `scripts/generate-mixer-plates.ts`** + +Use `sharp` (already a devDependency) to rasterize inline SVGs. Requirements the script must meet: + +- Glass plates **400×600**. Interior of the bowl is **fully transparent** on back and front. Mask PNG: opaque white = interior hole, transparent = glass/stem/outside. Align the hole to the painted bowl. +- One key light, camera-left. Warm `#3a2418` bar, cool glass `#cfe8f4` rims, no emoji. +- `bar-top.png` 560×840 (or 280×420 @2x) with a baked caustic strip under the bowl — no extra caustic file. +- `stream.png` **128×8** (not 8×64). Soft white-to-transparent highlight. +- `bottle.png` near-white 96×192 (2× of 48×96). +- `displace-noise.png` 128×128 grayscale noise. +- `rim-salt-margarita.png` wide arc; `rim-salt-highball.png` tall thin arc. +- Dots: `splash-dot.png`, `foam-dot.png` (softer), `star-mote.png`, `condensation-dot.png`, `ice-cube.png`, `frost.png`, `rim-highlight.png`, garnish plates named exactly as the spec. + +Put output in `src/components/demos/cocktail-mixer/assets/`. Do not commit 1.5px line-art glasses. + +Include a `main()` that writes every spec filename and `console.log`s the list. + +- [ ] **Step 3: Run the generator** + +```bash +npx tsx scripts/generate-mixer-plates.ts +ls src/components/demos/cocktail-mixer/assets | wc -l +``` + +Expected: all spec files present (29 names in the spec asset list). `stream.png` identified via `file` / sharp metadata as width > height. + +- [ ] **Step 4: Smoke-check stream orientation** + +```bash +npx tsx -e "import sharp from 'sharp'; const i = await sharp('src/components/demos/cocktail-mixer/assets/stream.png').metadata(); if (!i.width || !i.height || i.width < i.height * 4) { console.error(i); process.exit(1); } console.log(i.width, i.height);" +``` + +Expected: prints `128 8` (or 256 16). Exit 0. + +- [ ] **Step 5: Commit** + +```bash +git add package.json package-lock.json scripts/generate-mixer-plates.ts src/components/demos/cocktail-mixer/assets +git commit -m "feat: add pixi.js and mixer pour plates" +``` + +--- + +### Task 5: Application, asset loader, BarStage shell, CSS still + +**Files:** +- Create: `src/components/demos/cocktail-mixer/pixi/assets.ts` +- Create: `src/components/demos/cocktail-mixer/pixi/application.ts` +- Create: `src/components/demos/cocktail-mixer/pixi/css-still.tsx` +- Create: `src/components/demos/cocktail-mixer/pixi/bar-stage.tsx` +- Create: `src/components/demos/cocktail-mixer/__tests__/bar-stage.test.tsx` +- Modify: `src/components/demos/cocktail-mixer/components/recipe-view.tsx` (swap import; keep working if tests need a stage) + +**Interfaces:** +- Consumes: `Cocktail`, `PourSnapshot`, `STAGE`, plates from `assets/` +- Produces: `createMixerApp()`, `destroyMixerApp(app)`, `loadMixerAssets()`, `BarStage` props `{ cocktail, reducedMotion, onSnapshot }` + +Pixi constructors live behind **dynamic import inside `useEffect`**. Top-level `import "pixi.js"` in `bar-stage.tsx` will crash jsdom mixer tests. + +- [ ] **Step 1: Write failing BarStage tests** + +```tsx +/** @vitest-environment jsdom */ +import { describe, it, expect, vi } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import { BarStage } from "../pixi/bar-stage"; +import { COCKTAILS } from "../data"; + +describe("BarStage", () => { + it("exposes bar-stage test id and cocktail name", async () => { + const onSnapshot = vi.fn(); + render( + + ); + const root = await screen.findByTestId("bar-stage"); + expect(root).toHaveAttribute("aria-label", "Margarita"); + expect(root).toHaveAttribute("role", "img"); + await waitFor(() => { + expect(onSnapshot).toHaveBeenCalledWith({ + pouredCount: 3, + activePour: null, + allDone: true, + }); + }); + }); +}); +``` + +jsdom has no WebGL: this test must pass via the **CSS still** path (`data-testid="bar-stage-css"` inside the wrapper is OK as long as the wrapper is `bar-stage`). + +- [ ] **Step 2: Run and verify RED** + +Run: `npx vitest run src/components/demos/cocktail-mixer/__tests__/bar-stage.test.tsx` + +Expected: FAIL — `BarStage` missing. + +- [ ] **Step 3: Implement loader + app + CSS still + BarStage shell** + +`pixi/assets.ts` — list every PNG as a `Record` of `new URL("../assets/", import.meta.url).href` (or static imports). Export `MIXER_ASSET_URLS` and `async function loadMixerAssets(Assets: typeof import("pixi.js").Assets)`. + +`pixi/application.ts`: + +```ts +import type { Application } from "pixi.js"; +import { STAGE } from "../glass-bounds"; + +export async function createMixerApp( + PIXI: typeof import("pixi.js") +): Promise { + const app = new PIXI.Application(); + await app.init({ + width: STAGE.width, + height: STAGE.height, + resolution: Math.min(2, window.devicePixelRatio || 1), + backgroundAlpha: 0, + antialias: true, + autoDensity: true, + }); + return app; +} + +export function destroyMixerApp(app: Application): void { + app.destroy( + { removeView: true }, + { children: true, texture: false, textureSource: false } + ); +} +``` + +`pixi/css-still.tsx` — 280×420 stack: `cocktail.color` fill with `mask-image: url(glass-{type}-mask.png)`, then `glass-{type}-front.png`, salt if `garnishType.startsWith("salt_")`, garnish PNG from `garnishPlates` (skip the salt plate already shown), Paloma foam dots if `cocktail.name === "Paloma"`. No Cosmonaut frost. Wrapper CSS: `width: 100%; max-width: 280px; height: auto`. + +`pixi/bar-stage.tsx` (shell for this task): + +- Props as spec. +- `useEffect` with `let cancelled = false`. +- Dynamic `import("pixi.js")` → `createMixerApp` → `loadMixerAssets`. +- On failure or `cancelled`, if not cancelled: render CSS still and `onSnapshot({ pouredCount: n, activePour: null, allDone: true })`. +- On success this task: still call `applyFinished` **later** (Task 6). For now, if init succeeds, append `app.canvas`, set `data-testid="bar-stage"`, and if `reducedMotion` emit `allDone` without mounting a director. +- Cleanup: `cancelled = true`; `destroyMixerApp` if app exists. +- Outer: `
` + +Do not mount `PourDirector` yet. + +- [ ] **Step 4: Run BarStage test GREEN** + +Run: `npx vitest run src/components/demos/cocktail-mixer/__tests__/bar-stage.test.tsx` + +Expected: PASS (CSS still + allDone). + +- [ ] **Step 5: Commit** + +```bash +git add src/components/demos/cocktail-mixer/pixi src/components/demos/cocktail-mixer/__tests__/bar-stage.test.tsx +git commit -m "feat: mount mixer Pixi stage with CSS still fallback" +``` + +--- + +### Task 6: Rig, liquid MeshPlane, two streams, rocks ice + +**Files:** +- Create: `src/components/demos/cocktail-mixer/pixi/liquid.ts` +- Create: `src/components/demos/cocktail-mixer/pixi/stream.ts` +- Create: `src/components/demos/cocktail-mixer/pixi/rig.ts` +- Modify: `src/components/demos/cocktail-mixer/pixi/bar-stage.tsx` + +**Interfaces:** +- Consumes: `GlassBounds`, cocktail colors, textures from `Assets` +- Produces: `MixerRig` with `uniforms`, `applyFinished(cocktail)`, `setFillHeight(h)`, `setStream(on, color)`, `neckWorld()`, `tick(deltaMs)`, `destroy()` + +Uniform object (GSAP tweens these numbers): + +```ts +export type MixerUniforms = { + fillHeight: number; + fillColor: string; + flashColor: string; + flashAmount: number; + meniscusAmp: number; + swirl: number; + vortex: number; + streamOn: number; + streamColor: string; + bottleAngle: number; + bottleAlpha: number; + iceAlpha: number; + garnishAlpha: number; + frostAlpha: number; + displacementOn: boolean; +}; +``` + +- [ ] **Step 1: Write a failing uniform/rest-pose unit test (no WebGL)** + +Create `src/components/demos/cocktail-mixer/pixi/meniscus.ts` **or** export `writeMeniscusVertices` from `liquid.ts` as a pure function: + +```ts +export function writeMeniscusVertices( + positions: Float32Array, + cols: number, + rows: number, + width: number, + height: number, + amp: number, + swirl: number, + vortex: number, + bowlCenterX: number +): void +``` + +Test: `verticesX: 12`, `verticesY: 8`; at `swirl=0, vortex=0, amp=2` the top row Y varies by ~2px; at `vortex=1` top-row X shifts up to 8px around center; at `swirl=0.4` top-row X jitter magnitude ≤ 3px; lower rows stay on the grid. + +```ts +/** @vitest-environment node */ +import { describe, it, expect } from "vitest"; +import { writeMeniscusVertices } from "../pixi/liquid"; + +describe("writeMeniscusVertices", () => { + it("keeps lower rows on the grid and bends only the top row", () => { + const cols = 12; + const rows = 8; + const pos = new Float32Array(cols * rows * 2); + writeMeniscusVertices(pos, cols, rows, 130, 154, 2, 0, 0, 65); + const lastRow = (rows - 1) * cols; + // bottom-left y ≈ height + expect(pos[lastRow * 2 + 1]).toBeCloseTo(154, 0); + const topYs = Array.from({ length: cols }, (_, i) => pos[i * 2 + 1]); + const spread = Math.max(...topYs) - Math.min(...topYs); + expect(spread).toBeGreaterThan(0); + expect(spread).toBeLessThanOrEqual(5); + }); +}); +``` + +- [ ] **Step 2: Run and verify RED** + +Run: `npx vitest run src/components/demos/cocktail-mixer/__tests__/liquid-vertices.test.ts` + +Put the test at `__tests__/liquid-vertices.test.ts`. Expected: FAIL. + +- [ ] **Step 3: Implement liquid + stream + rig for rocks** + +`liquid.ts`: codegen a 64×32 canvas gradient texture from `fillColor` (use `document.createElement("canvas")` in the browser; for vertex tests, `writeMeniscusVertices` must not need DOM). MeshPlane `verticesX: 12`, `verticesY: 8`. DisplacementFilter `scale: { x: 4, y: 4 }`, map `addressMode: "repeat"`, `addChild` the sprite, do not set `visible = false`. Off when `displacementOn` is false. + +`stream.ts`: two MeshRopes, 20 points, quadratic sag 6px toward +Y, texture `stream.png`. `rebuild(neck, rimY, surfaceY, on)`. + +`rig.ts` display list **back to front** as the spec sandwich. One interior `Container` + one mask sprite (`glass-{type}-mask.png`). Ice sprites from `ICE_LAYOUT` for rocks, `alpha` from `iceAlpha`, already inside the bowl. `applyFinished` sets the spec checklist (fillHeight 1, stream off, bottle hidden, swirl/vortex 0, meniscusAmp rest 2, displacement off, ice visible if hasIce, garnish on, Paloma foam on, frost off). + +Wire BarStage success path: `createRig` → if `reducedMotion`, `applyFinished` then emit allDone **before** appending canvas. + +- [ ] **Step 4: Run vertex test GREEN and mixer tests** + +Run: `npx vitest run src/components/demos/cocktail-mixer/__tests__/liquid-vertices.test.ts src/components/demos/cocktail-mixer/__tests__/bar-stage.test.tsx src/components/demos/cocktail-mixer/__tests__/cocktail-mixer.test.tsx` + +Expected: PASS. Mixer tests still use `GlassScene` until Task 8 — they must keep passing. + +- [ ] **Step 5: Commit** + +```bash +git add src/components/demos/cocktail-mixer/pixi src/components/demos/cocktail-mixer/__tests__/liquid-vertices.test.ts +git commit -m "feat: add mixer liquid mesh, streams, and rocks rig" +``` + +--- + +### Task 7: Remaining glasses, garnishes, bottle, salt, particles + +**Files:** +- Create: `src/components/demos/cocktail-mixer/pixi/particles.ts` +- Modify: `src/components/demos/cocktail-mixer/pixi/rig.ts` + +**Interfaces:** +- Consumes: `garnishPlates`, `isFoamIngredient`, `CONDENSATION_LAYOUT` +- Produces: `emitSplash(x, y, color)`, `pinFoamTo(surfaceY)`, `emitMotes()`, `killEphemeral()` (splash/motes/frost) + +- [ ] **Step 1: Write failing foam/salt pure tests** + +Extend `garnish-map.test.ts` is already green. Add `src/components/demos/cocktail-mixer/__tests__/particles-policy.test.ts`: + +```ts +/** @vitest-environment node */ +import { describe, it, expect } from "vitest"; +import { shouldEmitFoam, shouldShowSalt } from "../pixi/particles"; +import { COCKTAILS } from "../data"; + +describe("particle policy", () => { + it("foams Paloma soda pour only", () => { + const paloma = COCKTAILS.find((c) => c.name === "Paloma")!; + expect(shouldEmitFoam(paloma, 0)).toBe(false); + expect(shouldEmitFoam(paloma, 1)).toBe(true); + expect(shouldEmitFoam(paloma, 2)).toBe(false); + const dog = COCKTAILS.find((c) => c.name === "Salty Dog")!; + expect(dog.ingredients.every((_, i) => !shouldEmitFoam(dog, i))).toBe(true); + }); + + it("shows salt when garnishType starts with salt_", () => { + expect(shouldShowSalt("salt_lime")).toBe(true); + expect(shouldShowSalt("cherry")).toBe(false); + }); +}); +``` + +Export `shouldEmitFoam` / `shouldShowSalt` from `particles.ts` as pure functions (no Pixi types). + +- [ ] **Step 2: Run RED** + +Run: `npx vitest run src/components/demos/cocktail-mixer/__tests__/particles-policy.test.ts` + +Expected: FAIL. + +- [ ] **Step 3: Implement particles + rest of rig** + +```ts +export function shouldShowSalt(garnishType: string): boolean { + return garnishType.startsWith("salt_"); +} + +export function shouldEmitFoam( + cocktail: Cocktail, + ingredientIndex: number +): boolean { + return isFoamIngredient(cocktail.ingredients[ingredientIndex]?.name ?? ""); +} +``` + +ParticleContainer `boundsArea` = `{ x: 0, y: 0, width: 280, height: 420 }` (Pixi `Rectangle`). Splash ≤40, gravity, life <400ms, tint from ingredient, `dynamicProperties.color: true`. Foam ≤20, **no gravity**, pin Y to surface, stay after `allDone` for Paloma. Motes ≤12 + `frost.png` for secret extra, then `killEphemeral`. + +Garnish: salt sprite visible from frame one; other garnish `alpha` from `garnishAlpha`. Bottle 48×96, pivot neck, `rotation` from `bottleAngle` (degrees → radians), `alpha` from `bottleAlpha`. Condensation static. `rim-highlight.png` on front glass, camera-left, under salt. Hide highlight if fps fallback flag is set. + +- [ ] **Step 4: Run GREEN** + +Run: `npx vitest run src/components/demos/cocktail-mixer/__tests__/particles-policy.test.ts src/components/demos/cocktail-mixer/__tests__/garnish-map.test.ts` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/components/demos/cocktail-mixer/pixi src/components/demos/cocktail-mixer/__tests__/particles-policy.test.ts +git commit -m "feat: add mixer foam, salt, garnishes, and bottle" +``` + +--- + +### Task 8: PourDirector + RecipeView + +**Files:** +- Create: `src/components/demos/cocktail-mixer/pixi/pour-director.tsx` +- Modify: `src/components/demos/cocktail-mixer/pixi/bar-stage.tsx` +- Modify: `src/components/demos/cocktail-mixer/components/recipe-view.tsx` +- Create: `src/components/demos/cocktail-mixer/__tests__/recipe-view.test.tsx` + +**Interfaces:** +- Consumes: `buildPourCues`, `MixerRig`, `useGSAP` from `../gsap-setup` +- Produces: GSAP timeline from cues; `onSnapshot` via `gsap.call` + +Cue → tween map (durations from spec): + +| kind | tween | +|------|--------| +| bottleTip | `bottleAlpha` 1, `bottleAngle` 0 → −50, 0.28s `power3.inOut`; set `streamColor` / `flashColor` to ingredient color | +| streamOn | `streamOn` 1 | +| fill | `fillHeight` to `(i+1)/n`, 0.45s `power2.out` | +| meniscus | `meniscusAmp` up, `flashAmount` 1 → 0 over 0.2s | +| splash | `rig.emitSplash(...)` | +| streamOff | `streamOn` 0 | +| bottleHide | `bottleAngle` 0 in 0.16s, then `bottleAlpha` 0 | +| ice | `iceAlpha` 0 → 1, 0.28s | +| garnish | `garnishAlpha` 0 → 1, 0.28s | +| method | shaken: `swirl` 0.4 → 0 in 0.28s `power2.out`; stirred: `vortex` 1 → 0 in 0.40s `sine.inOut`; built: none | +| secret | `frostAlpha` 1 → 0 over 0.4s + `emitMotes()` | +| done | `applyFinished` (keep Paloma foam), snapshot `allDone` | +| snapshot | `onSnapshot(cue.snapshot)` | + +GSAP tweens **uniform numbers**. `rig.tick` reads them and writes meshes. + +- [ ] **Step 1: Write failing RecipeView test with mocked BarStage** + +```tsx +/** @vitest-environment jsdom */ +vi.mock("../pixi/bar-stage", () => ({ + BarStage: ({ + onSnapshot, + cocktail, + }: { + onSnapshot: (s: { pouredCount: number; activePour: null; allDone: boolean }) => void; + cocktail: { ingredients: unknown[] }; + }) => { + useEffect(() => { + onSnapshot({ + pouredCount: cocktail.ingredients.length, + activePour: null, + allDone: true, + }); + }, [cocktail, onSnapshot]); + return
; + }, +})); +``` + +Assert `onPourComplete` fires once. Import `useEffect` in the mock factory file via a real test component pattern: prefer injecting by rendering `RecipeView` with the mock module. + +- [ ] **Step 2: Run RED** (RecipeView still imports GlassScene — mock unused, complete callback may still depend on GlassScene/GSAP) + +Run: `npx vitest run src/components/demos/cocktail-mixer/__tests__/recipe-view.test.tsx` + +Expected: FAIL until RecipeView imports BarStage. + +- [ ] **Step 3: Implement PourDirector and swap RecipeView** + +`pour-director.tsx`: + +```tsx +"use client"; +import { useGSAP, gsap } from "../gsap-setup"; +import { buildPourCues } from "../pour-script"; +import type { MixerRig } from "./rig"; +import type { Cocktail } from "../types"; +import type { PourSnapshot } from "../pour-script"; +import { GLASS_BOUNDS } from "../glass-bounds"; + +export function PourDirector({ + cocktail, + rig, + onSnapshot, +}: { + cocktail: Cocktail; + rig: MixerRig; + onSnapshot: (s: PourSnapshot) => void; +}) { + useGSAP( + () => { + const cues = buildPourCues({ + ingredientCount: cocktail.ingredients.length, + hasIce: GLASS_BOUNDS[cocktail.glass].hasIce, + isSecret: Boolean(cocktail.isSecret), + reducedMotion: false, + }); + const tl = gsap.timeline(); + for (const cue of cues) { + // switch (cue.kind) with never default — map table above + } + return () => tl.kill(); + }, + { dependencies: [cocktail.name], revertOnUpdate: true } + ); + return null; +} +``` + +`BarStage`: when `ready && !reducedMotion && rig`, render ``. Ticker: `app.ticker.add(() => rig.tick(app.ticker.deltaMS))`. After done, freeze displacement / kill ephemeral as `applyFinished`. + +`recipe-view.tsx`: import `BarStage` from `../pixi/bar-stage` and `PourSnapshot` from `../pour-script`. Replace `GlassScene`. Keep `useReducedMotion`. + +- [ ] **Step 4: Run GREEN** + +Run: `npx vitest run src/components/demos/cocktail-mixer/__tests__/recipe-view.test.tsx src/components/demos/cocktail-mixer/__tests__/cocktail-mixer.test.tsx src/components/demos/cocktail-mixer/__tests__/bar-stage.test.tsx` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/components/demos/cocktail-mixer/pixi/pour-director.tsx src/components/demos/cocktail-mixer/pixi/bar-stage.tsx src/components/demos/cocktail-mixer/components/recipe-view.tsx src/components/demos/cocktail-mixer/__tests__/recipe-view.test.tsx +git commit -m "feat: drive mixer pour with GSAP director" +``` + +--- + +### Task 9: Delete SVG pour stack + +**Files:** +- Delete: `glass-scene.tsx`, `pour-timeline.ts`, `svg/glasses.tsx`, `svg/bottles.tsx`, `svg/garnishes.tsx`, `svg/decorations.tsx`, `components/glass-visualization.tsx`, `components/cosmonaut-reveal.tsx`, `__tests__/pour-timeline.test.ts`, `__tests__/pour-sequence.test.ts` +- Modify: `hooks.ts` (remove `usePourSequence` and pour constants; keep `useCocktailProgress`) +- Modify: `data.ts` (remove `GLASS_CONFIGS`, `WAVE_PATH`, `generateWavePath`, `ICE_POSITIONS`, `BUBBLE_SETS`, `CONDENSATION_DROPS`; keep `WAVE_STYLES` as **only** `cosmonaut-glow`) +- Modify: `gsap-setup.ts` (keep `gsap` + `useGSAP`; drop DrawSVG, MorphSVG, Physics2D, Flip, CustomWiggle unless still imported) +- Modify: `types.ts` (remove `GlassConfig` if unused) +- Modify: `index.tsx` (still injects `WAVE_STYLES`) + +- [ ] **Step 1: Run tests that still import deleted modules — expect RED after deletes, so grep first** + +```bash +rg "pour-timeline|GlassScene|usePourSequence|GLASS_CONFIGS|glass-visualization|CosmonautReveal|WAVE_PATH" src/components/demos/cocktail-mixer +``` + +Expected: only files you are about to delete / update. + +- [ ] **Step 2: Delete and slim as listed. `WAVE_STYLES` becomes:** + +```ts +export const WAVE_STYLES = ` +@keyframes cosmonaut-glow { + 0%, 100% { box-shadow: 0 0 8px #8b5cf640, 0 0 24px #8b5cf620; } + 50% { box-shadow: 0 0 16px #8b5cf680, 0 0 40px #8b5cf640; } +} +`; +``` + +`gsap-setup.ts`: + +```ts +"use client"; +import gsap from "gsap"; +import { useGSAP } from "@gsap/react"; + +let registered = false; +export function registerMixerGsap(): void { + if (registered) return; + gsap.registerPlugin(useGSAP); + registered = true; +} +registerMixerGsap(); +export { gsap, useGSAP }; +``` + +- [ ] **Step 3: Run the mixer test suite** + +Run: `npx vitest run src/components/demos/cocktail-mixer` + +Expected: PASS. No `pour-timeline` / `pour-sequence` files. + +- [ ] **Step 4: Commit** + +```bash +git add -A src/components/demos/cocktail-mixer +git commit -m "refactor: remove SVG pour stage from cocktail mixer" +``` + +--- + +### Task 10: FPS fallback, WebGL still, method/secret already in director + +**Files:** +- Modify: `src/components/demos/cocktail-mixer/pixi/rig.ts` +- Modify: `src/components/demos/cocktail-mixer/pixi/bar-stage.tsx` + +**Interfaces:** +- Consumes: ticker FPS +- Produces: after 30 consecutive frames `< 50fps`, `displacementOn = false` and hide `rim-highlight`. Keep the pour. + +- [ ] **Step 1: Write failing fps helper test** + +```ts +/** @vitest-environment node */ +import { describe, it, expect } from "vitest"; +import { FpsBudget } from "../pixi/fps-budget"; + +describe("FpsBudget", () => { + it("trips after 30 frames under 50fps", () => { + const b = new FpsBudget(50, 30); + for (let i = 0; i < 29; i++) expect(b.sample(40)).toBe(false); + expect(b.sample(40)).toBe(true); + }); + it("resets when fps recovers", () => { + const b = new FpsBudget(50, 30); + for (let i = 0; i < 10; i++) b.sample(40); + expect(b.sample(60)).toBe(false); + }); +}); +``` + +- [ ] **Step 2: RED** + +Run: `npx vitest run src/components/demos/cocktail-mixer/__tests__/fps-budget.test.ts` + +- [ ] **Step 3: Implement `pixi/fps-budget.ts` and sample `1000/deltaMS` in `rig.tick`. Skip displacement when `devicePixelRatio < 1.5` or reduced motion or `allDone`.** + +Confirm CSS still path already emits `allDone` (Task 5). Confirm `applyFinished` kills frost/motes/splash and keeps Paloma foam. + +- [ ] **Step 4: GREEN + full mixer tests** + +Run: `npx vitest run src/components/demos/cocktail-mixer` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/components/demos/cocktail-mixer/pixi/fps-budget.ts src/components/demos/cocktail-mixer/pixi/rig.ts src/components/demos/cocktail-mixer/__tests__/fps-budget.test.ts +git commit -m "feat: drop mixer displacement under 50fps" +``` + +--- + +### Task 11: Full verify + browser pass + +**Files:** none new unless tests/build fail. + +- [ ] **Step 1: `npm test`** + +Expected: all Vitest suites PASS (including existing 889-ish tests plus new ones). Fix failures in this task. + +- [ ] **Step 2: `npm run build`** + +Expected: Next.js production build PASS. Pixi must not leak into the homepage bundle (mixer is dynamic). If the build traces Pixi on `/`, fix the import boundary. + +- [ ] **Step 3: Browser pass on `/work/cocktail-mixer`** + +Start `npm run dev`. Exercise: + +1. Margarita — shaken, salt from frame one, no ice +2. Old Fashioned — stirred vortex on liquid, rocks, ice stays in the bowl +3. Paloma — built, foam still there when paused +4. Salty Dog — salt, **no** foam +5. Whiskey Sour — shaken coupe, no ice +6. Unlock Cosmonaut (or seed localStorage `cocktails_made` with six names) — frost + motes, then still; XP after frost +7. 320px width +8. `prefers-reduced-motion: reduce` — finished still on first painted frame + +Fail the task if ice leaves the bowl, the stream is a fat ribbon, or the paused frame looks like wireframe SVG. + +- [ ] **Step 4: Commit any fixes** + +```bash +git add -A +git commit -m "fix: mixer pour browser-pass polish" +``` + +(Skip this commit if nothing changed.) + +- [ ] **Step 5: Push** + +```bash +git push -u origin cursor/cocktail-mixer-pixi-pour-5c83 +``` + +--- + +## Self-review + +**Spec coverage** + +| Spec requirement | Task | +|---|---| +| `glass-bounds.ts` seeds | 1 | +| `pourDuration` / snapshots / secret extra | 2 | +| Garnish plates + Paloma foam name | 3 | +| Asset pack + stream 128×8 + pixi.js | 4 | +| v8 `init`, abort, CSS still, `bar-stage` | 5 | +| MeshPlane, two ropes, rocks ice, sandwich | 6 | +| Particles, salt, bottle, other glasses | 7 | +| PourDirector / useGSAP child | 8 | +| Delete SVG pour, slim GSAP | 9 | +| FPS + displacement off after allDone | 10 | +| npm test, build, browser drinks | 11 | + +**Placeholder scan:** no TBD/TODO. Stream size locked. `PourSnapshot` lives in `pour-script.ts`. `never` defaults on garnish/glass switches. + +**Type consistency:** `MixerRig`, `MixerUniforms`, `PourCue`, `PourSnapshot`, `BarStage` props match across tasks 2, 5, 6, 8. + +**Out of scope left out:** Three/R3F, site-wide Pixi, oz-weighted fill, audio, card-icon Pixi rewrite. diff --git a/docs/superpowers/specs/2026-09-05-cocktail-mixer-motion-design.md b/docs/superpowers/specs/2026-09-05-cocktail-mixer-motion-design.md index fb11a01..7a6ddb3 100644 --- a/docs/superpowers/specs/2026-09-05-cocktail-mixer-motion-design.md +++ b/docs/superpowers/specs/2026-09-05-cocktail-mixer-motion-design.md @@ -1,5 +1,9 @@ # Cocktail Mixer Motion Design +**Superseded by** +`docs/superpowers/specs/2026-09-05-cocktail-mixer-pixi-pour-design.md`. +This document shipped GSAP on the existing SVG. Do not implement from it. + Raise The Cosmonaut’s Bar from a working SVG illustration to award-site 2D motion: one GSAP clock, Flip between grid and recipe, light physics on pour. Keep the current art, recipes, and unlock. Do not go 3D. diff --git a/docs/superpowers/specs/2026-09-05-cocktail-mixer-pixi-pour-design.md b/docs/superpowers/specs/2026-09-05-cocktail-mixer-pixi-pour-design.md new file mode 100644 index 0000000..0aaf5e0 --- /dev/null +++ b/docs/superpowers/specs/2026-09-05-cocktail-mixer-pixi-pour-design.md @@ -0,0 +1,691 @@ +# Cocktail Mixer Pixi Pour Design + +Supersedes `docs/superpowers/specs/2026-09-05-cocktail-mixer-motion-design.md`. +That spec shipped GSAP on the existing 1.5px SVG. The pour looked lousy: +ice drifted out of the glass, liquid was stacked rects, the stream never +hit the meniscus. The ceiling was the art, not the clock. + +This spec replaces the **picture**. Keep the recipes, unlock, XP, and GSAP +as the conductor. Do not go 3D. + +## Decisions (locked) + +- Bar: tenth-pass 2D cabinet illustration. Stripe / Linear discipline on a + bar card. The paused end frame must look like a finished cocktail painting, + not an animation that only works in motion. +- Engine: PixiJS v8 owns every pixel of the pour stage. GSAP + `@gsap/react` + owns the **story clock** (slot times, snapshots, bottle angle, fill + height). The Pixi ticker only applies those uniforms to meshes and + particles. Motion stays on the rest of the site. +- Art: full redraw. Painted glassware, ice, garnish, bottle, bar surface. + Finished liquid is `cocktail.color` as **one volume**. Stream and a brief + surface flash use the active `ingredient.color`. Do not polish the + current SVG glasses as the pour picture. Do not ship stacked ingredient + bands (that is a bar chart, which was the last failure). +- Scope: the pour stage on `/work/cocktail-mixer`. The selection grid + stays Motion `AnimatePresence` plus SVG card icons. Not a site-wide + Pixi or GSAP rewrite. +- Pour feel: choreographed 2D craft with light physics. Stream lands on the + rising surface. Ice stays inside the bowl. Splash at contact. Method + (shake / stir / build) is a liquid uniform, not a glass rotation. + Not a Navier–Stokes sim. Not a Three.js spirits microsite. +- Architecture: a pour **script** (pure timing) drives a Pixi **rig**. + GSAP tweens rig uniforms. React mounts one Application, loads assets, + then starts the timeline. Destroy on unmount with an abort token. + +## Why this, not the last spike + +GSAP DrawSVG / MorphSVG / Physics2D on line-art cannot look expensive. +Pixi v8 can: `MeshPlane` meniscus, `MeshRope` stream, `DisplacementFilter` +wet wobble, particles for splash/foam, clip the ice to the bowl. + +`three` is already in the repo for other work. It is the wrong lead here. +The product bar is craft, not a transmission-material gin ad. Rive needs a +`.riv` from an editor we do not have. + +This is an explicit exception to `docs/anti-goals.md` (“no new deps for +one-off effects”), scoped to this demo, adding **only** `pixi.js` (pin +**8.8 or newer** in the 8.x line). GSAP is already a mixer dependency. + +## Out of scope + +- Three.js, React Three Fiber, MeshTransmissionMaterial, Rive, Lottie +- Site-wide Pixi or removing Motion elsewhere +- Recipe / unlock / XP / localStorage / Cosmonaut gate changes +- Oz-weighted fill or Tequila Sunrise grenadine-sink special case +- Audio +- Global nav, typography, theme +- Auth, middleware, database +- Rewriting the selection-grid card icons into Pixi +- A second pour engine for Cosmonaut + +## Wow bar (falsifiable) + +A demanding visitor who saw the SVG spike and said it was lousy should, +on the first pour, believe this is a commercial interactive. Pause at the +end. If the still looks like wireframe SVG, the work failed. + +Must be true: + +1. Ice never leaves the glass interior. Local coordinates, clipped to the + bowl after settle. No GSAP `y` on a world-space group. +2. The stream contacts the **rising meniscus**, not a fixed glass-top + coordinate. Each ingredient’s stream shortens as the fill rises. + Stream start is the bottle neck’s world point each ticker frame. +3. Liquid is one volume with a curved surface, tinted `cocktail.color`. + Not stacked HTML/SVG rects. Not equal bands of sugar / bitters / spirit. +4. Splash particles emit at the stream–surface hit, then die. They do not + spawn in empty air beside the glass. +5. Garnish lands last, after ice. Salt is a rim overlay, already on the + glass, not thrown in at the end. +6. `shaken` settles (quick residual swirl on the **liquid**, then still). + `stirred` shows a brief vortex on the liquid. `built` stays still. + The glass itself does not cartoon-tilt. +7. Cosmonaut gets one extra beat (frost + a few star motes), then the + same still-frame discipline. `allDone` fires **after** that beat. + Not a shader demo. +8. Recipe text stays readable during the pour. `pouredCount` increments + as each slot starts so `RecipeDetails` can reveal rows (`i < pouredCount`). + Motion rewards the pour; it does not hide the recipe (`docs/taste-bar.md`). +9. At `320px` width the glass, garnish, and recipe still read. Canvas is + `width: 100%; max-width: 280px; height: auto`. +10. `prefers-reduced-motion: reduce` shows the painted finished drink on + the first painted frame after assets load. No tween, no particles, + no displacement. + +## Visual craft (the picture) + +One key light, camera-left. Warm bar top. Cool rim on the glass. No +second competing light. No rainbow filter bloom. No UI chrome inside +the canvas. + +The glass is a **sandwich**. A single precomposed bowl with liquid +painted in front of it looks like stickers on glass. Do not do that. + +Layer order, back to front: + +1. Bar surface. Caustic strip is **baked into** `bar-top.png`. No extra plate. +2. Back glass (stem, base, back wall — interior of the bowl is transparent) +3. Interior group (one mask): liquid MeshPlane, ice, inner stream + (rim → surface), Paloma foam (pinned to surface Y) +4. Condensation (static dots on the front wall) +5. Front glass (near wall, rim) +6. `rim-highlight.png` on the front glass, camera-left, under salt +7. Salt overlay when `garnishType` starts with `salt_` +8. Air stream (neck → rim), unmasked, in front of the glass +9. Splash around the rim (unclipped, short life) +10. Frost overlay (Cosmonaut extra only, then gone) +11. Garnish (wheels, wedges, cherry, rocket) +12. Bottle — pour only, in the 48px headroom above `GLASS_RECT`, + camera-right of the rim + +The canvas is a pour stage, not a scene graph of the whole bar. No +bartender, no bottles on shelves, no neon sign. + +Composed frame (CSS pixels). Pixi clips to the canvas; do not rely on +overflow. + +```ts +export const STAGE = { width: 280, height: 420 }; +export const GLASS_RECT = { x: 40, y: 48, width: 200, height: 300 }; +// 48px headroom for the bottle; remaining 72px is the bar. +``` + +### Glassware + +Four painted silhouettes, matching `GlassType`. Each type is **two** +plates plus a mask: + +| Type | Ice | Notes | +|-----------|-----------------------------|--------------------------------------------| +| rocks | 1 large + 1 small | Thick bowl. Ice must read. | +| highball | 3 cubes | Tall column. Condensation on the lower 2/3 | +| coupe | none | Stem + wide bowl. Liquid stays in the cup | +| margarita | none | Wide rim. Salt is the overlay plate | + +Files per type: `glass-{type}-back.png`, `glass-{type}-front.png`, +`glass-{type}-mask.png`. The mask is the interior hole, pixel-aligned +to the painted bowl. Do not reconstruct the bowl from the old SVG +`outline` / `clip` paths as the hero art. + +### Liquid + +- Finished fill: `cocktail.color`. +- The liquid **is** one `MeshPlane` covering the bowl bbox from + `liquidBottom` to the current surface (`verticesX: 12`, `verticesY: 8`). + Only the **top vertex row** takes the sine / method offsets. That is + the volume and the meniscus. Do not draw a separate surface strip over + a hole (sticker look). +- During pour `i`: stream tint and a 200ms surface flash use + `ingredients[i].color`, then ease back to `cocktail.color`. +- Fill height is equal slots (`(i + 1) / n` of the bowl), not oz-weighted. + `fillHeight` eases over **0.45s** (`power2.out`) starting at slot 0.20. +- Extents live in `glass-bounds.ts` (pure module, CSS pixels on the + 200×300 glass rect). Seed from the current SVG numbers, then remeasure + from mask alpha bbox once plates exist and **commit the numbers**. + Runtime bbox is a debug assert, not a load blocker. + +```ts +export type GlassBounds = { + liquidTop: number; + liquidBottom: number; + rimY: number; // air stream ends here; inner stream starts + bowlCenterX: number; + bowlWidth: number; // interior width at mid-bowl + hasIce: boolean; + garnishX: number; + garnishY: number; + bottle: { x: number; y: number; neckX: number; neckY: number }; +}; + +// seed (CSS px inside GLASS_RECT); replace after plates +export const GLASS_BOUNDS: Record = { + rocks: { + liquidTop: 100, liquidBottom: 254, rimY: 95, + bowlCenterX: 100, bowlWidth: 130, hasIce: true, + garnishX: 140, garnishY: 90, + bottle: { x: 168, y: -8, neckX: 24, neckY: 8 }, + }, + highball: { + liquidTop: 50, liquidBottom: 255, rimY: 45, + bowlCenterX: 100, bowlWidth: 82, hasIce: true, + garnishX: 132, garnishY: 42, + bottle: { x: 168, y: -8, neckX: 24, neckY: 8 }, + }, + coupe: { + liquidTop: 70, liquidBottom: 180, rimY: 65, + bowlCenterX: 100, bowlWidth: 150, hasIce: false, + garnishX: 148, garnishY: 62, + bottle: { x: 168, y: -8, neckX: 24, neckY: 8 }, + }, + margarita: { + liquidTop: 60, liquidBottom: 175, rimY: 55, + bowlCenterX: 100, bowlWidth: 170, hasIce: false, + garnishX: 150, garnishY: 52, + bottle: { x: 168, y: -8, neckX: 24, neckY: 8 }, + }, +}; +``` + +`bottle.neckX/Y` are local to the bottle sprite (pivot = neck). The +ticker converts them to stage space as the bottle tips. + +One Pixi mask sprite, one target: a single interior `Container` holding +liquid, ice, and the inner stream. Do not reuse one mask on two +containers. + +- MeshPlane texture: codegen a 64×32 vertical gradient from the current + fill color (no extra PNG). Rest pose: top-row sine amplitude **2px**. + `meniscusAmp` is a GSAP uniform; the ticker writes vertices. +- Method offsets (also ticker, from GSAP uniforms). Rest / `allDone` = + 0. Displacement is **not** the method. + + | method | uniform | draw (top vertex row only) | duration | ease | + |---------|-----------------|----------------------------------------------------|----------|------------| + | shaken | `swirl` 0.4 → 0 | chaotic X jitter, max **3px** at 0.4 | 0.28s | power2.out | + | stirred | `vortex` 1 → 0 | angular spin around `bowlCenterX`, max **8px** at 1 | 0.40s | sine.inOut | + | built | none | rest pose only | 0 | — | + +- `DisplacementFilter` on the liquid (tiny, wet). Map: `displace-noise.png` + with `addressMode: 'repeat'`. `addChild` the sprite so + `worldTransform` updates; do **not** set `visible = false` (scroll + dies). Leave `renderable` to the filter (v8 sets it false). Filter + `scale` = `{ x: 4, y: 4 }` — not the default 20. Always off under + reduced motion and after `allDone`. Skip at `devicePixelRatio < 1.5`. + Drop it if the ticker stays under 50fps for 30 frames. + +### Stream + +Two `MeshRope`s. One rope cannot be half-masked. + +- **Air rope:** neck → `rimY`. Unmasked. In front of the glass. +- **Inner rope:** `rimY` → current `surfaceY`. Child of the interior + mask group, behind the front glass. +- Texture: `stream.png` is a **horizontal** strip, along × across: + **128×8** pixels at `resolution: 2` so CSS thickness is **4px**. + Pixi v8 maps texture X along the path and texture Y as thickness + (`texture.height`). Do **not** ship an 8×64 vertical strip (that is a + 64px-thick ribbon). Do not depend on a `MeshRope` `width` override + (not in 8.8). Keep ~3–5px CSS by sizing the texture. +- Point count **20**. Sag: midpoint control **6px** toward gravity + (stage +Y). Rebuild points each ticker frame from + `neckX/Y`, `rimY`, `surfaceY`, `streamOn`. +- Tinted to the active ingredient. On at slot 0.18, off at 0.58. + +### Ice + +- Painted `ice-cube.png`, scaled. Counts from the glass table above. + Local positions committed in `glass-bounds.ts` (`ICE_LAYOUT`). +- Spawn **already inside** the bowl (opacity 0 → 1, short settle). + Do not drop from world-space above the mask — that was the SVG bug + (cubes vanish until they enter, or they escape). +- After settle, parent/mask is the interior. No idle bob after + `allDone` (the paused frame is a painting). Reduced motion: static. +- Local layout (CSS px, relative to `bowlCenterX` and mid-bowl + ` (liquidTop + liquidBottom) / 2 `; seed, remeasure with plates). + Highball cubes stack down the column, not in a mid-bowl cluster: + +```ts +export const ICE_LAYOUT: Record<"rocks" | "highball", IceCube[]> = { + rocks: [ + { dx: -8, dy: 10, angle: 12, scale: 1.35 }, + { dx: 18, dy: 22, angle: -8, scale: 0.85 }, + ], + highball: [ + { dx: -10, dy: -36, angle: 10, scale: 1 }, + { dx: 12, dy: -8, angle: -12, scale: 0.9 }, + { dx: -4, dy: 28, angle: 18, scale: 0.8 }, + ], +}; +``` + +They do not bounce out. + +### Particles + +- Splash: `splash-dot.png`, ≤40, emit at contact, gravity down, life + < 400ms, then gone from the finished still. `boundsArea` = the stage + rect. Tint from the active ingredient (`dynamicProperties.color: true`). +- Foam: Paloma only. Emit in the slot where + `ingredient.name === "Grapefruit Soda"`. Texture: `foam-dot.png`. + ≤20 sprites **pinned to surface Y** (no gravity). They rise with the + meniscus through the lime pour and **stay on the finished still**. + Salty Dog is salt-highball with juice, not soda: salt, no foam. +- Cosmonaut extra: ≤12 star motes + `frost.png` on the coupe, 400ms, + then gone from the still. No soda foam on the Cosmonaut. +- Use Pixi v8 `Particle` / `ParticleContainer` from `pixi.js`. + No `@pixi/particle-emitter`. No DOM particles. No GSAP Physics2D. + +Condensation: static `condensation-dot.png`. Positions in +`glass-bounds.ts` (CSS px, relative to `bowlCenterX` / `liquidTop`): + +```ts +export const CONDENSATION_LAYOUT: Record = { + rocks: [{ dx: -28, dy: 36 }, { dx: 26, dy: 54 }, { dx: -12, dy: 72 }, { dx: 18, dy: 90 }], + highball: [{ dx: -22, dy: 40 }, { dx: 20, dy: 70 }, { dx: -16, dy: 100 }, { dx: 14, dy: 130 }, { dx: -8, dy: 160 }, { dx: 10, dy: 190 }], + coupe: [{ dx: -40, dy: 18 }, { dx: 36, dy: 28 }, { dx: -12, dy: 40 }], + margarita: [{ dx: -48, dy: 16 }, { dx: 44, dy: 26 }, { dx: -10, dy: 36 }], +}; +``` + +### Bottle + +- `bottle.png` is near-white (so `tint` from `ingredient.color` works). + Sprite size **48×96** CSS. Pivot = neck (`bottle.neckX/Y`). +- Tips from upright to **−50°** over **0.28s** (`power3.inOut`), holds + while the stream is on, rights over **0.16s**, hides. +- It is a prop, not a product shot. No label typography. + +### Garnish + +Keep `GarnishType`. Map to plates (no current SVG garnishes): + +| garnishType | plates | +|-------------------|---------------------------------------------| +| lime_wheel | garnish-lime-wheel.png | +| cherry | garnish-cherry.png | +| orange_slice | garnish-orange-slice.png | +| grapefruit_wedge | garnish-grapefruit-wedge.png | +| salt_rim | rim-salt-{glass}.png | +| cherry_orange | garnish-cherry-orange.png | +| salt_grapefruit | rim-salt-{glass}.png + garnish-grapefruit-wedge.png | +| salt_lime | rim-salt-{glass}.png + garnish-lime-wheel.png | +| rocket | garnish-rocket.png | + +Salt (`rim-salt-margarita.png` or `rim-salt-highball.png`) sits on the +front-glass layer from the first frame. Wheels / wedges / cherry / +rocket drop in last at `garnishX/Y`. + +Unused types (`orange_slice`, `grapefruit_wedge`, `salt_rim` as +standalone) still get plates so the switch stays exhaustive. + +## Motion clock + +GSAP is the only **story** clock. Pixi ticker only applies uniforms. + +`BarStage` owns async init/load/abort in `useEffect`. It mounts a child +`PourDirector` **only when `ready`**. That child is the only `useGSAP` +caller. Its callback is synchronous and builds the timeline against a +live rig. Reduced motion never mounts `PourDirector`. + +Do not create the timeline inside `BarStage` after `await` and expect +`useGSAP` / `revertOnUpdate` to see it. Tweens recorded outside the +`useGSAP` callback are not in that context. + +On unmount of `BarStage`: abort in-flight `init`/`load`. If the first +`init` resolves after abort, do not append or start a ticker. +`PourDirector` unmount kills the timeline via `useGSAP`. Then: + +```ts +app.destroy( + { removeView: true }, + { children: true, texture: false, textureSource: false } +); +``` + +Keep the `Assets` cache across drink remounts (`RecipeView` keys on +cocktail name). Do not pass `releaseGlobalResources: true`. + +Pixi ticker (not a second story clock): write mesh vertices, rope +points, and particle physics from rig uniforms GSAP is already tweening. +Displacement map may scroll **during** the pour only. After `allDone`, +the ticker does not bob ice or scroll the map. + +### Timing + +```text +POUR_INTRO = 0.2s +POUR_SLOT = 0.85s per ingredient +POUR_FINISH = 0.65s ice + garnish + method (stirred vortex needs ~0.4s) +SECRET_EXTRA = 0.40s Cosmonaut frost + motes, after POUR_FINISH +``` + +```ts +pourDuration(n, isSecret) = + POUR_INTRO + n * POUR_SLOT + POUR_FINISH + (isSecret ? SECRET_EXTRA : 0) +``` + +A three-ingredient drink is **3.4s**. Cosmonaut (4 ingredients, secret) +is **4.65s**. Do not keep the old “under 3.5s for three ingredients” +assertion if FINISH grows; update the test to the formula. + +Per ingredient, inside the slot (relative): + +| t | beat | +|------|--------------------------------------------------------------| +| 0.00 | bottle appears; tip −50° over 0.28s; snapshot `{ pouredCount: i + 1, activePour: i, allDone: false }` | +| 0.18 | stream on | +| 0.20 | `fillHeight` eases over 0.45s to `(i + 1) / n` | +| 0.25 | meniscusAmp up; 200ms surface flash of `ingredient.color` | +| 0.38 | splash at current surface | +| 0.58 | stream off | +| 0.62 | bottle rights (0.16s) and hides; snapshot `{ pouredCount: i + 1, activePour: null, allDone: false }` | + +`onSnapshot` is fired from `pour-director.tsx` via GSAP `call`, not from +the React wrapper. Opening snapshot before slot 0: +`{ pouredCount: 0, activePour: null, allDone: false }`. + +Finish (from last slot end): + +| t | beat | +|---------------------------|-------------------------------------------| +| 0.00 | ice settle (if `hasIce`) | +| +0.12 | garnish | +| +0.20 | method uniforms (liquid table) | +| + POUR_FINISH | snapshot `allDone` if not secret | +| + POUR_FINISH (secret) | frost + star motes | +| + POUR_FINISH + SECRET_EXTRA | snapshot `allDone` for Cosmonaut | + +Method uniforms: see the liquid table. `pour-director.tsx` tweens +`swirl` / `vortex` at finish `+0.20` with those durations. Built is a +no-op. + +`PourSnapshot` lives in `pour-script.ts` (not `pour-timeline.ts`). +Shape stays `{ pouredCount, activePour, allDone }`. `RecipeView` +imports the type from `pour-script.ts`. + +### `applyFinished` checklist + +Same still for reduced motion, WebGL fallback, and paused `allDone`. +`rig.applyFinished(cocktail)` sets: + +- `fillHeight = 1`, tint `cocktail.color` +- stream off, bottle hidden +- splash / motes / frost dead (alpha 0, not ticking) +- `swirl = 0`, `vortex = 0`, `meniscusAmp` at rest (2px sine) +- displacement filter off (not scrolling) +- ice visible if `hasIce`, **no bob** +- Paloma foam on; every other drink foam off +- salt on if `garnishType` starts with `salt_` +- garnish on + +After `allDone` with motion allowed: freeze/remove displacement, kill +splash/motes/frost. Paloma foam stays. The paused frame is a painting. + +Reduced motion: after load, `applyFinished`, emit +`{ pouredCount: n, activePour: null, allDone: true }`, append canvas. +Never on an empty WebGL view. + +If `app.init` or `Assets.load` fails, or WebGL is unavailable: CSS +still, then emit `allDone`. Stack: `cocktail.color` fill with +`glass-{type}-mask.png` as a CSS `mask-image`, then +`glass-{type}-front.png`, salt/garnish/foam as needed. Same +finished-state rules (Paloma foam on, Cosmonaut frost off). Do not +leave a blank canvas. Do not put a color rect behind an opaque back +plate (sticker sandwich). + +Do **not** use DrawSVG, MorphSVG, or Physics2D against SVG pour targets. +When `glass-scene.tsx` dies, slim `gsap-setup.ts` to `gsap` + `useGSAP` +(+ `CustomEase` only if a named ease is still used). Drop DrawSVG, +MorphSVG, Physics2D, Flip, and CustomWiggle unless a mixer file still +imports them. The selection grid keeps Motion `AnimatePresence`; do not +add GSAP Flip as part of this pour rewrite. + +## File layout + +Pixi owns the picture. The script is testable without WebGL. + +```text +src/components/demos/cocktail-mixer/ + index.tsx select / unlock / XP. Keep cosmonaut-glow CSS. + data.ts recipes, colors, garnish, method. + Delete SVG-only exports: WAVE_PATH, + ICE_POSITIONS, BUBBLE_SETS, + CONDENSATION_DROPS, GLASS_CONFIGS. + types.ts Cocktail, MixMethod, GarnishType (keep) + hooks.ts keep useCocktailProgress only. + Delete usePourSequence. + glass-bounds.ts NEW: GLASS_BOUNDS + ICE_LAYOUT + + CONDENSATION_LAYOUT (pure) + pour-script.ts NEW: PourSnapshot type + timing + snapshots + pixi/ + bar-stage.tsx NEW: async init/load; mounts PourDirector when ready + application.ts NEW: init / destroy helpers + abort + rig.ts NEW: display list + uniform setters + applyFinished + pour-director.tsx NEW: useGSAP child; timeline → rig uniforms + liquid.ts NEW: MeshPlane + fill + stream.ts NEW: MeshRope + particles.ts NEW: splash / foam / motes + assets.ts NEW: texture keys + load + assets/ NEW: PNG plates (see Asset pack) + components/recipe-view.tsx GlassScene → BarStage + components/recipe-details.tsx keep + components/selection-grid.tsx keep (SVG card icons OK) + svg/card-icons.tsx keep for the grid + svg/glasses.tsx delete with the SVG stage + svg/bottles.tsx delete + svg/garnishes.tsx delete + svg/decorations.tsx delete + glass-scene.tsx delete after BarStage lands + pour-timeline.ts delete after pour-script + director land + components/glass-visualization.tsx unused; delete + components/cosmonaut-reveal.tsx unused; delete + __tests__/pour-timeline.test.ts replace with pour-script.test.ts + __tests__/pour-sequence.test.ts delete with usePourSequence +``` + +`BarStage` props (same contract as `GlassScene`): + +```ts +{ + cocktail: Cocktail; + reducedMotion: boolean; + onSnapshot: (snapshot: PourSnapshot) => void; +} +``` + +Wrapper: `data-testid="bar-stage"`, `role="img"`, +`aria-label="{cocktail.name}"`. Update mixer tests off `glass-scene`. + +`demo-loader.tsx` stays `next/dynamic(..., { ssr: false })`. Pixi loads +only on `/work/cocktail-mixer`. + +Keep `cosmonaut-glow` on the Cosmonaut grid card. Delete `wave-drift` +and `condensation-drip` from `WAVE_STYLES`. Condensation on the Pixi +glass is static dots, not a CSS keyframe. If `WAVE_STYLES` shrinks to +only `cosmonaut-glow`, keep injecting it from `index.tsx`. + +## Asset pack + +All pour-stage art lives under +`src/components/demos/cocktail-mixer/assets/` as 2x PNG. Stage CSS: +**280×420**. Glass plates: 400×600 (2× the 200×300 content rect). + +Required files: + +```text +bar-top.png +glass-rocks-back.png +glass-rocks-front.png +glass-rocks-mask.png +glass-highball-back.png +glass-highball-front.png +glass-highball-mask.png +glass-coupe-back.png +glass-coupe-front.png +glass-coupe-mask.png +glass-margarita-back.png +glass-margarita-front.png +glass-margarita-mask.png +ice-cube.png +bottle.png (near-white, tinted in code) +stream.png (128×8 horizontal strip, resolution 2) +displace-noise.png (DisplacementFilter map, repeat) +frost.png (Cosmonaut coupe overlay) +condensation-dot.png +rim-highlight.png (on front glass, camera-left) +garnish-lime-wheel.png +garnish-cherry.png +garnish-orange-slice.png +garnish-grapefruit-wedge.png +garnish-cherry-orange.png +garnish-rocket.png +rim-salt-margarita.png +rim-salt-highball.png +splash-dot.png +foam-dot.png (Paloma surface foam) +star-mote.png +``` + +Meniscus texture is codegen from fill color. Do not add a meniscus PNG. + +Palette: warm wood bar, cool glass, liquid tints from data. No emoji on +the stage (`cocktail.emoji` stays on cards only). + +One key light, camera-left, on every plate. Do not ship mismatched +lighting. + +## Pixi bootstrap (v8) + +Constructor options are deprecated. Required sequence in `BarStage`: + +1. `const app = new Application()` +2. `await app.init({ width: 280, height: 420, resolution: Math.min(2, devicePixelRatio), backgroundAlpha: 0, antialias: true, autoDensity: true })` + — do **not** `resizeTo` the window +3. `await Assets.load(...)` (cache hits on remount) +4. Build rig. If `reducedMotion`, `rig.applyFinished(cocktail)` and emit + `allDone` **before** appending the canvas. Do not mount `PourDirector`. +5. Append `app.canvas`. If motion is allowed, mount `PourDirector` (the + only `useGSAP` caller). +6. Unmount: abort if still on step 2–3. `PourDirector` unmount kills the + timeline. Then `app.destroy({ removeView: true }, { children: true, texture: false, textureSource: false })`. + +React Strict Mode will mount/unmount/mount. The abort token must ignore +the first `init` resolving after unmount so it cannot leak a ticker. + +## Performance + +- One `Application` per `BarStage` mount. +- Canvas CSS size ≤ 280×420, fluid down to 320px pages. +- Particle caps above. No displacement when reduced motion. +- If the ticker reports `< 50fps` for 30 consecutive frames, drop + displacement and hide `rim-highlight.png`. Keep the pour. +- Do not add `@pixi/filter-*`. Core `BlurFilter` is unused; the highlight + is a sprite. Core `DisplacementFilter` only. +- Do not import Pixi from the root layout. + +## Reduced motion and a11y + +- `useReducedMotion()` from `motion/react` (already used in `RecipeView`). +- Finished painted still after load. Recipe list shows every ingredient + as poured (`pouredCount === n`). +- Canvas `role="img"` and `aria-label` with the cocktail name. +- Keyboard: Back to drinks unchanged. No canvas key traps. +- 320px: stage stacks above recipe, same grid as today. + +## Tests + +jsdom cannot prove the wow bar. Split what tests can prove. + +Pure (jsdom, no Pixi): + +- `pour-script.ts`: `pourDuration(n, isSecret)` matches the formula; + snapshot times include `pouredCount: i + 1` at slot start; reduced + motion emits `allDone` immediately; Cosmonaut extra beat exists and + `allDone` is after it; ice skipped when `hasIce` is false. +- `glass-bounds.ts`: every `GlassType` has `liquidTop < liquidBottom`; + rocks/highball `hasIce`, coupe/margarita not. +- `cocktail-data.test.ts`: drop `GLASS_CONFIGS`; assert `cocktail.glass` + against `GlassType` / `GLASS_BOUNDS` keys. Keep recipe integrity tests. + +React: + +- `RecipeView` still calls `onPourComplete` once when the snapshot + completes (mock `BarStage` if Pixi cannot init). +- `data-testid="bar-stage"` (not `glass-scene`). +- Delete `pour-timeline.test.ts` and `pour-sequence.test.ts`. Do not + assert GSAP tweens on `[data-pour="glass"]` — that was the cartoon + tilt this spec forbids. Method tests, if any, assert shaken/stirred + set liquid uniforms and built does not. + +Do not add WebGL screenshot tests in Vitest. Visual proof is a browser +pass on `/work/cocktail-mixer` for: + +- Margarita (shaken, salt rim from frame one, no ice) +- Old Fashioned (stirred vortex on liquid, rocks, ice stays in the bowl) +- Paloma (built, highball, soda foam **still there** when paused) +- Salty Dog (salt-highball, **no** foam) +- Whiskey Sour (shaken, coupe, no ice) +- Cosmonaut after unlock (frost + star motes, then still; XP after) +- 320px width +- `prefers-reduced-motion: reduce` (finished still, first painted frame) + +## Dependencies + +Add: + +```text +pixi.js ^8.8.0 +``` + +Keep: + +```text +gsap +@gsap/react +``` + +Do not add `pixi-filters`, `@pixi/particle-emitter`, Rive, or extra +Three wrappers for this demo. + +## Implementation order (after this spec is approved) + +1. `glass-bounds.ts` + `pour-script.ts` + tests (TDD). No Pixi yet. +2. Asset plates for rocks (back, front, mask) + `BarStage` hello + (v8 `init` / load / destroy, static still). +3. Liquid mesh + stream + ice spawn-inside on rocks. +4. Remaining three glasses + garnishes + bottle + salt overlay. +5. `pour-director.tsx` GSAP wired to rig uniforms. Delete SVG + `glass-scene` / `pour-timeline` / `usePourSequence`. +6. Method uniforms + Cosmonaut extra + fps fallback + WebGL failure + still. +7. `npm test` + `npm run build` + browser pass on the drinks listed + under Tests. + +Do not start this list until the spec file is approved. + +## Success + +The Cosmonaut’s Bar pour looks like someone iterated a 2D cabinet piece +until it was done. Pause it: it still looks like a painted cocktail, not +a tween. Not a 3D gin ad. Not another SVG spike. diff --git a/package-lock.json b/package-lock.json index e83bd1e..13ad6e0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,7 @@ "motion": "^12.34.4", "next": "15.5.19", "next-themes": "^0.4.6", + "pixi.js": "^8.8.0", "radix-ui": "^1.4.3", "react": "19.1.0", "react-dom": "19.1.0", @@ -2808,6 +2809,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@pixi/colord": { + "version": "2.9.6", + "resolved": "https://registry.npmjs.org/@pixi/colord/-/colord-2.9.6.tgz", + "integrity": "sha512-nezytU2pw587fQstUu1AsJZDVEynjskwOL+kibwcdxsMBFqPsFFNA7xl0ii/gXuDi6M0xj3mfRJj8pBSc2jCfA==", + "license": "MIT" + }, "node_modules/@radix-ui/number": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz", @@ -5349,6 +5356,12 @@ "assertion-error": "^2.0.1" } }, + "node_modules/@types/css-font-loading-module": { + "version": "0.0.12", + "resolved": "https://registry.npmjs.org/@types/css-font-loading-module/-/css-font-loading-module-0.0.12.tgz", + "integrity": "sha512-x2tZZYkSxXqWvTDgveSynfjq/T2HyiZHXb00j/+gy19yp70PHCizM48XFdjBCWH7eHBD0R5i/pw9yMBP/BH5uA==", + "license": "MIT" + }, "node_modules/@types/debug": { "version": "4.1.12", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", @@ -5365,6 +5378,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/earcut": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@types/earcut/-/earcut-2.1.4.tgz", + "integrity": "sha512-qp3m9PPz4gULB9MhjGID7wpo3gJ4bTGXm7ltNDsmOvsPduTeHp8wSW9YckBj3mljeOh4F0m2z/0JKAALRKbmLQ==", + "license": "MIT" + }, "node_modules/@types/eslint": { "version": "9.6.1", "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", @@ -6403,9 +6422,17 @@ "version": "0.1.69", "resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.69.tgz", "integrity": "sha512-RPmm6kgRbI8e98zSD3RVACvnuktIja5+yLgDAkTmxLr90BEwdTXRQWNLF3ETTTyH/8mKhznZuN5AveXYFEsMGQ==", - "dev": true, "license": "BSD-3-Clause" }, + "node_modules/@xmldom/xmldom": { + "version": "0.8.15", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.15.tgz", + "integrity": "sha512-/5NV/vDALVFDXgLmfsy9TRCBlKwO2LNBFzpzvb9iIj+jR+eSc6DLYYvVOdivT/jm7MtU6TebYuRmzEOI7w40UA==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/@xtuc/ieee754": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", @@ -7906,6 +7933,12 @@ "node": ">= 0.4" } }, + "node_modules/earcut": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/earcut/-/earcut-2.2.4.tgz", + "integrity": "sha512-/pjZsA1b4RPHbeWZQn66SWS8nZZWLQQ23oE3Eam7aroEFGEvwKAsJfZ9ytiEMycfzXWpca4FA9QIOehf7PocBQ==", + "license": "ISC" + }, "node_modules/eciesjs": { "version": "0.4.17", "resolved": "https://registry.npmjs.org/eciesjs/-/eciesjs-0.4.17.tgz", @@ -8743,6 +8776,12 @@ "node": ">= 0.6" } }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, "node_modules/events": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", @@ -10497,6 +10536,12 @@ "dev": true, "license": "ISC" }, + "node_modules/ismobilejs": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ismobilejs/-/ismobilejs-1.1.1.tgz", + "integrity": "sha512-VaFW53yt8QO61k2WJui0dHf4SlL8lxBofUuUmwBo0ljPk0Drz2TiuDW4jo3wDcv41qy/SxrJ+VAzJ/qYqsmzRw==", + "license": "MIT" + }, "node_modules/iterator.prototype": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz", @@ -12995,6 +13040,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/parse-svg-path": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/parse-svg-path/-/parse-svg-path-0.1.2.tgz", + "integrity": "sha512-JyPSBnkTJ0AI8GGJLfMXvKq42cj5c006fnLz6fXy6zfoVjJizi8BNTpu8on8ziI1cKy9d9DGNuY17Ce7wuejpQ==", + "license": "MIT" + }, "node_modules/parse5": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz", @@ -13085,6 +13136,23 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pixi.js": { + "version": "8.8.0", + "resolved": "https://registry.npmjs.org/pixi.js/-/pixi.js-8.8.0.tgz", + "integrity": "sha512-0xW8tKa+uF28mi1SwvnNscMpYJSQrqLN7jJs6Ore37FZoXmIRzQNrGA6drpHDVTuEmoqJlSiGLCk5cUgz3ODgQ==", + "license": "MIT", + "dependencies": { + "@pixi/colord": "^2.9.6", + "@types/css-font-loading-module": "^0.0.12", + "@types/earcut": "^2.1.4", + "@webgpu/types": "^0.1.40", + "@xmldom/xmldom": "^0.8.10", + "earcut": "^2.2.4", + "eventemitter3": "^5.0.1", + "ismobilejs": "^1.1.1", + "parse-svg-path": "^0.1.2" + } + }, "node_modules/pkce-challenge": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", diff --git a/package.json b/package.json index 28c7486..aa8a072 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,8 @@ "lint": "eslint", "test": "vitest run", "test:watch": "vitest", - "sprites:process": "npx tsx scripts/process-voxel-sprites.ts" + "sprites:process": "npx tsx scripts/process-voxel-sprites.ts", + "plates:mixer": "npx tsx scripts/generate-mixer-plates.ts" }, "dependencies": { "@gsap/react": "^2.1.2", @@ -24,6 +25,7 @@ "motion": "^12.34.4", "next": "15.5.19", "next-themes": "^0.4.6", + "pixi.js": "^8.8.0", "radix-ui": "^1.4.3", "react": "19.1.0", "react-dom": "19.1.0", diff --git a/scripts/generate-mixer-plates.ts b/scripts/generate-mixer-plates.ts new file mode 100644 index 0000000..c0e7cf3 --- /dev/null +++ b/scripts/generate-mixer-plates.ts @@ -0,0 +1,554 @@ +/** + * Generates the painted 2× raster plates used by Cocktail Mixer's Pixi stage. + * + * Usage: npx tsx scripts/generate-mixer-plates.ts + */ + +import { mkdir } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import sharp from "sharp"; + +const ROOT = dirname(dirname(fileURLToPath(import.meta.url))); +const DEFAULT_OUTPUT_DIRECTORY = join( + ROOT, + "src", + "components", + "demos", + "cocktail-mixer", + "assets", +); + +type PlateSpec = { + filename: string; + width: number; + height: number; + render: () => string | Buffer; +}; + +type GlassDefinition = { + interior: string; + rim: string; + reflections: string; + stem: string; +}; + +const GLASS_DEFINITIONS: Record = { + rocks: { + interior: + "M70 192 Q200 174 330 192 L310 492 Q304 518 270 528 Q200 544 130 528 Q96 518 90 492 Z", + rim: "M70 192 Q200 174 330 192", + reflections: + '', + stem: + '', + }, + highball: { + interior: + "M118 88 Q200 76 282 88 L268 502 Q266 528 240 536 Q200 546 160 536 Q134 528 132 502 Z", + rim: "M118 88 Q200 76 282 88", + reflections: + '', + stem: + '', + }, + coupe: { + interior: + "M45 128 Q200 106 355 128 Q338 246 290 320 Q258 361 200 365 Q142 361 110 320 Q62 246 45 128 Z", + rim: "M45 128 Q200 106 355 128", + reflections: + '', + stem: + '', + }, + margarita: { + interior: + "M28 108 Q200 88 372 108 L300 212 Q264 266 222 292 L216 340 L184 340 L178 292 Q136 266 100 212 Z", + rim: "M28 108 Q200 88 372 108", + reflections: + '', + stem: + '', + }, +}; + +function svg( + width: number, + height: number, + content: string, + definitions = "", +): string { + return ` + ${definitions} + ${content} +`; +} + +const GLASS_GRADIENTS = ` + + + + + + + + + + + + + + + + + + + + + + + + + +`; + +function outsideMask(interior: string): string { + return ` + + + `; +} + +function glassBack(definition: GlassDefinition): string { + return svg( + 400, + 600, + ` + + + + + ${definition.reflections} + + ${definition.stem}`, + `${GLASS_GRADIENTS}${outsideMask(definition.interior)}`, + ); +} + +function glassFront(definition: GlassDefinition): string { + return svg( + 400, + 600, + ` + + + + + ${definition.reflections} + + ${definition.stem}`, + `${GLASS_GRADIENTS}${outsideMask(definition.interior)}`, + ); +} + +function glassMask(definition: GlassDefinition): string { + return svg(400, 600, ``); +} + +function barTop(): string { + const grain = Array.from({ length: 22 }, (_, index) => { + const y = 38 + index * 38; + const opacity = 0.08 + (index % 4) * 0.018; + return ``; + }).join(""); + + return svg( + 560, + 840, + ` + + ${grain} + + + `, + ` + + + + + + + + + + + `, + ); +} + +function iceCube(): string { + return svg( + 96, + 96, + ` + + + `, + ``, + ); +} + +function bottle(): string { + return svg( + 96, + 192, + ` + + + + `, + ``, + ); +} + +function stream(): string { + return svg( + 128, + 8, + '', + '', + ); +} + +function displaceNoise(): Buffer { + const width = 128; + const height = 128; + const data = Buffer.alloc(width * height * 3); + let state = 0x5f3759df; + + for (let offset = 0; offset < data.length; offset += 3) { + state ^= state << 13; + state ^= state >>> 17; + state ^= state << 5; + const value = 88 + ((state >>> 0) % 81); + data[offset] = value; + data[offset + 1] = value; + data[offset + 2] = value; + } + + return data; +} + +function frost(): string { + const crystals = Array.from({ length: 26 }, (_, index) => { + const x = 58 + ((index * 67) % 286); + const y = 134 + ((index * 89) % 218); + const radius = 4 + (index % 7); + return ``; + }).join(""); + + return svg( + 400, + 600, + ` + ${crystals} + `, + ``, + ); +} + +function condensationDot(): string { + return svg( + 24, + 32, + '', + '', + ); +} + +function rimHighlight(): string { + return svg( + 96, + 240, + '', + '', + ); +} + +function limeWheel(): string { + const segments = Array.from({ length: 8 }, (_, index) => { + const angle = index * 45; + return ``; + }).join(""); + return svg( + 128, + 128, + `${segments}`, + '', + ); +} + +function cherry(): string { + return svg( + 112, + 128, + '', + '', + ); +} + +function orangeSlice(): string { + const rays = [ + [17, 99], + [26, 72], + [44, 53], + [66, 48], + [90, 57], + [109, 79], + ] + .map( + ([x, y]) => + ``, + ) + .join(""); + return svg( + 128, + 128, + `${rays}`, + '', + ); +} + +function grapefruitWedge(): string { + return svg( + 144, + 128, + '', + '', + ); +} + +function cherryOrange(): string { + return svg( + 176, + 144, + `${orangeSlice().replace(/^.*?]*>|<\/svg>$/gs, "")} + ${cherry().replace(/^.*?]*>|<\/svg>$/gs, "")}`, + ); +} + +function rocket(): string { + return svg( + 96, + 176, + '', + '', + ); +} + +function saltOverlay(type: "margarita" | "highball"): string { + const margarita = type === "margarita"; + const rim = margarita + ? "M28 108 Q200 88 372 108" + : "M118 88 Q200 76 282 88"; + const grains = Array.from({ length: margarita ? 48 : 30 }, (_, index) => { + const span = margarita ? 330 : 154; + const start = margarita ? 35 : 123; + const x = start + ((index * 47) % span); + const normalized = (x - 200) / (margarita ? 172 : 82); + const y = (margarita ? 99 : 82) + normalized * normalized * (margarita ? 10 : 7) + ((index % 3) - 1) * 4; + const radius = 2 + (index % 4); + return ``; + }).join(""); + + return svg( + 400, + 600, + `${grains}`, + ); +} + +function softDot( + size: number, + centerColor: string, + edgeColor: string, + edgeOpacity: number, +): string { + return svg( + size, + size, + ``, + ``, + ); +} + +function starMote(): string { + return svg( + 32, + 32, + '', + '', + ); +} + +const GLASS_SPECS: PlateSpec[] = Object.entries(GLASS_DEFINITIONS).flatMap( + ([type, definition]) => [ + { + filename: `glass-${type}-back.png`, + width: 400, + height: 600, + render: () => glassBack(definition), + }, + { + filename: `glass-${type}-front.png`, + width: 400, + height: 600, + render: () => glassFront(definition), + }, + { + filename: `glass-${type}-mask.png`, + width: 400, + height: 600, + render: () => glassMask(definition), + }, + ], +); + +export const PLATE_SPECS: readonly PlateSpec[] = [ + { filename: "bar-top.png", width: 560, height: 840, render: barTop }, + ...GLASS_SPECS, + { filename: "ice-cube.png", width: 96, height: 96, render: iceCube }, + { filename: "bottle.png", width: 96, height: 192, render: bottle }, + { filename: "stream.png", width: 128, height: 8, render: stream }, + { + filename: "displace-noise.png", + width: 128, + height: 128, + render: displaceNoise, + }, + { filename: "frost.png", width: 400, height: 600, render: frost }, + { + filename: "condensation-dot.png", + width: 24, + height: 32, + render: condensationDot, + }, + { + filename: "rim-highlight.png", + width: 96, + height: 240, + render: rimHighlight, + }, + { + filename: "garnish-lime-wheel.png", + width: 128, + height: 128, + render: limeWheel, + }, + { + filename: "garnish-cherry.png", + width: 112, + height: 128, + render: cherry, + }, + { + filename: "garnish-orange-slice.png", + width: 128, + height: 128, + render: orangeSlice, + }, + { + filename: "garnish-grapefruit-wedge.png", + width: 144, + height: 128, + render: grapefruitWedge, + }, + { + filename: "garnish-cherry-orange.png", + width: 176, + height: 144, + render: cherryOrange, + }, + { + filename: "garnish-rocket.png", + width: 96, + height: 176, + render: rocket, + }, + { + filename: "rim-salt-margarita.png", + width: 400, + height: 600, + render: () => saltOverlay("margarita"), + }, + { + filename: "rim-salt-highball.png", + width: 400, + height: 600, + render: () => saltOverlay("highball"), + }, + { + filename: "splash-dot.png", + width: 32, + height: 32, + render: () => softDot(32, "#ffffff", "#d8f3ff", 0), + }, + { + filename: "foam-dot.png", + width: 24, + height: 24, + render: () => softDot(24, "#fffdf2", "#f6ead2", 0), + }, + { + filename: "star-mote.png", + width: 32, + height: 32, + render: starMote, + }, +]; + +async function writePlate(spec: PlateSpec, outputDirectory: string): Promise { + const source = spec.render(); + let image: sharp.Sharp; + + if (typeof source === "string") { + image = sharp(Buffer.from(source)); + } else { + image = sharp(source, { + raw: { width: spec.width, height: spec.height, channels: 3 }, + }).blur(0.45); + } + + await image + .resize(spec.width, spec.height, { fit: "fill" }) + .png({ compressionLevel: 9, palette: false }) + .toFile(join(outputDirectory, spec.filename)); +} + +export async function generateMixerPlates( + outputDirectory = DEFAULT_OUTPUT_DIRECTORY, +): Promise { + await mkdir(outputDirectory, { recursive: true }); + + for (const spec of PLATE_SPECS) { + await writePlate(spec, outputDirectory); + } + + return PLATE_SPECS.map(({ filename }) => filename); +} + +async function main(): Promise { + const filenames = await generateMixerPlates(); + console.log(`Generated ${filenames.length} Cocktail Mixer plates:`); + for (const filename of filenames) { + console.log(`- ${filename}`); + } +} + +const entrypoint = process.argv[1]; +if (entrypoint && import.meta.url === pathToFileURL(entrypoint).href) { + main().catch((error: unknown) => { + console.error("Failed to generate Cocktail Mixer plates:", error); + process.exitCode = 1; + }); +} diff --git a/src/components/demos/cocktail-mixer/__tests__/assets-workers.test.ts b/src/components/demos/cocktail-mixer/__tests__/assets-workers.test.ts new file mode 100644 index 0000000..a24c87d --- /dev/null +++ b/src/components/demos/cocktail-mixer/__tests__/assets-workers.test.ts @@ -0,0 +1,27 @@ +/** @vitest-environment node */ +import { describe, expect, it, vi } from "vitest"; +import { loadMixerAssets, MIXER_ASSET_URLS } from "../pixi/assets"; + +type MixerAssets = Parameters[0]; + +describe("loadMixerAssets", () => { + it("disables Pixi texture workers before loading plates", async () => { + const setPreferences = vi.fn(); + const load = vi.fn().mockResolvedValue({}); + + await loadMixerAssets({ + setPreferences, + load, + } as unknown as MixerAssets); + + expect(setPreferences).toHaveBeenCalledWith({ preferWorkers: false }); + expect(load).toHaveBeenCalledTimes(1); + expect(setPreferences.mock.invocationCallOrder[0]).toBeLessThan( + load.mock.invocationCallOrder[0], + ); + const loaded = load.mock.calls[0]?.[0] as { alias: string }[]; + expect(loaded.map((asset) => asset.alias)).toEqual( + Object.keys(MIXER_ASSET_URLS), + ); + }); +}); diff --git a/src/components/demos/cocktail-mixer/__tests__/bar-stage.test.tsx b/src/components/demos/cocktail-mixer/__tests__/bar-stage.test.tsx new file mode 100644 index 0000000..1e40193 --- /dev/null +++ b/src/components/demos/cocktail-mixer/__tests__/bar-stage.test.tsx @@ -0,0 +1,36 @@ +/** @vitest-environment jsdom */ +import { render, screen, waitFor } from "@testing-library/react"; +import { AbstractRenderer } from "pixi.js"; +import { describe, expect, it, vi } from "vitest"; +import { COCKTAILS } from "../data"; +import { BarStage } from "../pixi/bar-stage"; + +describe("BarStage", () => { + it("installs Pixi's CSP-safe renderer implementation", () => { + expect( + AbstractRenderer.prototype._unsafeEvalCheck.toString(), + ).not.toContain("unsafeEvalSupported"); + }); + + it("exposes bar-stage test id and cocktail name", async () => { + const onSnapshot = vi.fn(); + render( + , + ); + + const root = await screen.findByTestId("bar-stage"); + expect(root).toHaveAttribute("aria-label", "Margarita"); + expect(root).toHaveAttribute("role", "img"); + await waitFor(() => { + expect(onSnapshot).toHaveBeenCalledWith({ + pouredCount: 3, + activePour: null, + allDone: true, + }); + }); + }); +}); diff --git a/src/components/demos/cocktail-mixer/__tests__/bottle-pivot.test.ts b/src/components/demos/cocktail-mixer/__tests__/bottle-pivot.test.ts new file mode 100644 index 0000000..9ca2032 --- /dev/null +++ b/src/components/demos/cocktail-mixer/__tests__/bottle-pivot.test.ts @@ -0,0 +1,12 @@ +/** @vitest-environment node */ +import { describe, expect, it } from "vitest"; +import { bottlePivot } from "../pixi/bottle-pivot"; + +describe("bottlePivot", () => { + it("converts the CSS neck point to texture-space pixels", () => { + expect(bottlePivot(96, 192, 48, 96, 24, 8)).toEqual({ + x: 48, + y: 16, + }); + }); +}); diff --git a/src/components/demos/cocktail-mixer/__tests__/cocktail-data.test.ts b/src/components/demos/cocktail-mixer/__tests__/cocktail-data.test.ts index 50e394f..e8ed068 100644 --- a/src/components/demos/cocktail-mixer/__tests__/cocktail-data.test.ts +++ b/src/components/demos/cocktail-mixer/__tests__/cocktail-data.test.ts @@ -1,10 +1,11 @@ import { describe, it, expect } from "vitest"; -import { COCKTAILS, THE_COSMONAUT, GLASS_CONFIGS } from "../data"; +import { COCKTAILS, THE_COSMONAUT } from "../data"; +import { GLASS_BOUNDS } from "../glass-bounds"; import type { Cocktail } from "../types"; const ALL_COCKTAILS: Cocktail[] = [...COCKTAILS, THE_COSMONAUT]; const HEX_RE = /^#[0-9a-fA-F]{6}$/; -const VALID_GLASSES = Object.keys(GLASS_CONFIGS); +const VALID_GLASSES = Object.keys(GLASS_BOUNDS); const VALID_METHODS = ["shaken", "stirred", "built"]; describe("cocktail data integrity", () => { @@ -68,13 +69,9 @@ describe("cocktail data integrity", () => { } ); - it("glass configs have valid liquid ranges", () => { - for (const [name, config] of Object.entries(GLASS_CONFIGS)) { - expect(config.liquidTop).toBeLessThan(config.liquidBottom); - expect(config.liquidTop).toBeGreaterThan(0); - expect(config.liquidBottom).toBeLessThan(300); - // Suppress unused var warning - void name; + it("every cocktail glass exists in GLASS_BOUNDS", () => { + for (const c of ALL_COCKTAILS) { + expect(GLASS_BOUNDS[c.glass]).toBeDefined(); } }); }); diff --git a/src/components/demos/cocktail-mixer/__tests__/csp-particles.test.ts b/src/components/demos/cocktail-mixer/__tests__/csp-particles.test.ts new file mode 100644 index 0000000..1e04a3e --- /dev/null +++ b/src/components/demos/cocktail-mixer/__tests__/csp-particles.test.ts @@ -0,0 +1,26 @@ +/** @vitest-environment node */ +import { ParticleBuffer } from "pixi.js"; +import { describe, expect, it } from "vitest"; +import { installMixerPixiCsp } from "../pixi/csp"; + +describe("mixer Pixi CSP install", () => { + it("lets the particle polyfill accept a property Record", () => { + installMixerPixiCsp(); + + expect(() => + ParticleBuffer.prototype.generateParticleUpdate.call( + { + _generateParticleUpdateCache: {}, + } as ParticleBuffer, + { + position: { + attributeName: "aPosition", + format: "float32x2", + code: "", + dynamic: true, + }, + }, + ), + ).not.toThrow(); + }); +}); diff --git a/src/components/demos/cocktail-mixer/__tests__/fps-budget.test.ts b/src/components/demos/cocktail-mixer/__tests__/fps-budget.test.ts new file mode 100644 index 0000000..6929294 --- /dev/null +++ b/src/components/demos/cocktail-mixer/__tests__/fps-budget.test.ts @@ -0,0 +1,16 @@ +/** @vitest-environment node */ +import { describe, it, expect } from "vitest"; +import { FpsBudget } from "../pixi/fps-budget"; + +describe("FpsBudget", () => { + it("trips after 30 frames under 50fps", () => { + const b = new FpsBudget(50, 30); + for (let i = 0; i < 29; i++) expect(b.sample(40)).toBe(false); + expect(b.sample(40)).toBe(true); + }); + it("resets when fps recovers", () => { + const b = new FpsBudget(50, 30); + for (let i = 0; i < 10; i++) b.sample(40); + expect(b.sample(60)).toBe(false); + }); +}); diff --git a/src/components/demos/cocktail-mixer/__tests__/garnish-map.test.ts b/src/components/demos/cocktail-mixer/__tests__/garnish-map.test.ts new file mode 100644 index 0000000..642614a --- /dev/null +++ b/src/components/demos/cocktail-mixer/__tests__/garnish-map.test.ts @@ -0,0 +1,55 @@ +/** @vitest-environment node */ +import { describe, it, expect } from "vitest"; +import { + GARNISH_POSE, + garnishPlates, + isFoamIngredient, + SODA_INGREDIENT_NAME, +} from "../garnish-map"; +import { COCKTAILS, THE_COSMONAUT } from "../data"; + +describe("garnish-map", () => { + it("puts salt on from the first frame for salt_* types", () => { + expect(garnishPlates("salt_lime", "margarita")).toEqual([ + "rim-salt-margarita.png", + "garnish-lime-wheel.png", + ]); + expect(garnishPlates("salt_grapefruit", "highball")).toEqual([ + "rim-salt-highball.png", + "garnish-grapefruit-wedge.png", + ]); + }); + + it("maps rocket for the Cosmonaut", () => { + expect(garnishPlates("rocket", "coupe")).toEqual(["garnish-rocket.png"]); + }); + + it("foams only Grapefruit Soda, not Salty Dog juice", () => { + expect(SODA_INGREDIENT_NAME).toBe("Grapefruit Soda"); + const paloma = COCKTAILS.find((c) => c.name === "Paloma")!; + const dog = COCKTAILS.find((c) => c.name === "Salty Dog")!; + expect(paloma.ingredients.some((i) => isFoamIngredient(i.name))).toBe(true); + expect(dog.ingredients.some((i) => isFoamIngredient(i.name))).toBe(false); + expect(THE_COSMONAUT.ingredients.some((i) => isFoamIngredient(i.name))).toBe( + false + ); + }); + + it("anchors every garnish plate so it can sit on a rim", () => { + const plates = [ + "garnish-lime-wheel.png", + "garnish-grapefruit-wedge.png", + "garnish-cherry.png", + "garnish-orange-slice.png", + "garnish-cherry-orange.png", + "garnish-rocket.png", + ]; + for (const plate of plates) { + const pose = GARNISH_POSE[plate]; + expect(pose.anchorX).toBeGreaterThan(0); + expect(pose.anchorX).toBeLessThan(1); + expect(pose.anchorY).toBeGreaterThan(0.4); + expect(pose.width).toBeGreaterThan(20); + } + }); +}); diff --git a/src/components/demos/cocktail-mixer/__tests__/glass-bounds.test.ts b/src/components/demos/cocktail-mixer/__tests__/glass-bounds.test.ts new file mode 100644 index 0000000..8c7bf5a --- /dev/null +++ b/src/components/demos/cocktail-mixer/__tests__/glass-bounds.test.ts @@ -0,0 +1,107 @@ +/** @vitest-environment node */ +import { describe, it, expect } from "vitest"; +import { + GLASS_BOUNDS, + ICE_LAYOUT, + CONDENSATION_LAYOUT, + STAGE, + GLASS_RECT, + bowlWidthAt, + condensationPoints, + foamOffsetsForWidth, + pourContactX, + rimGarnishPoint, +} from "../glass-bounds"; +import type { GlassType } from "../types"; + +const TYPES: GlassType[] = ["rocks", "highball", "coupe", "margarita"]; + +describe("glass-bounds", () => { + it("defines a 280×420 stage and a 200×300 glass rect", () => { + expect(STAGE).toEqual({ width: 280, height: 420 }); + expect(GLASS_RECT).toEqual({ x: 40, y: 48, width: 200, height: 300 }); + }); + + it.each(TYPES)("%s has liquidTop < liquidBottom and rimY ≤ liquidTop", (type) => { + const b = GLASS_BOUNDS[type]; + expect(b.liquidTop).toBeLessThan(b.liquidBottom); + expect(b.rimY).toBeLessThanOrEqual(b.liquidTop); + expect(b.bowlWidth).toBeGreaterThan(0); + }); + + it("keeps liquid out of the margarita and coupe stems", () => { + expect(GLASS_BOUNDS.margarita.liquidBottom).toBeLessThanOrEqual(152); + expect(GLASS_BOUNDS.coupe.liquidBottom).toBeLessThanOrEqual(172); + }); + + it("gives ice only to rocks and highball, stacked from the bowl floor", () => { + expect(GLASS_BOUNDS.rocks.hasIce).toBe(true); + expect(GLASS_BOUNDS.highball.hasIce).toBe(true); + expect(GLASS_BOUNDS.coupe.hasIce).toBe(false); + expect(GLASS_BOUNDS.margarita.hasIce).toBe(false); + expect(ICE_LAYOUT.rocks).toHaveLength(2); + expect(ICE_LAYOUT.highball).toHaveLength(3); + for (const cube of [...ICE_LAYOUT.rocks, ...ICE_LAYOUT.highball]) { + expect(cube.dy).toBeLessThan(0); + } + expect(ICE_LAYOUT.highball[2].dy).toBeLessThan(-120); + }); + + it("docks the bottle neck above the rim in glass space", () => { + for (const type of TYPES) { + const bounds = GLASS_BOUNDS[type]; + expect(bounds.bottle.neckX).toBeLessThan(48); + expect(bounds.bottle.neckY).toBeLessThan(48); + expect(bounds.bottle.y).toBeLessThan(bounds.rimY); + expect(bounds.bottle.x).toBeGreaterThan(bounds.bowlCenterX); + } + }); + + it("perches garnish on the right rim", () => { + for (const type of TYPES) { + const point = rimGarnishPoint(type); + const bounds = GLASS_BOUNDS[type]; + expect(point.x).toBeGreaterThan(bounds.bowlCenterX + 20); + expect(point.y).toBeGreaterThanOrEqual(bounds.rimY); + expect(point.y).toBeLessThan(bounds.liquidTop + 12); + } + }); + + it("narrows margarita width toward the bowl floor", () => { + expect(bowlWidthAt("margarita", 60)).toBeGreaterThan( + bowlWidthAt("margarita", 140), + ); + expect(bowlWidthAt("margarita", 140)).toBeLessThan(50); + }); + + it("keeps the pour contact inside the live bowl", () => { + const contact = pourContactX("highball", 120); + const half = bowlWidthAt("highball", 120) / 2; + expect(contact).toBeGreaterThan(100); + expect(contact).toBeLessThan(100 + half); + }); + + it("keeps foam dots inside the highball bowl", () => { + const width = bowlWidthAt("highball", GLASS_BOUNDS.highball.liquidTop); + for (const offset of foamOffsetsForWidth(width)) { + expect(Math.abs(offset)).toBeLessThan(width / 2); + } + }); + + it("has condensation dots for every glass", () => { + for (const type of TYPES) { + expect(CONDENSATION_LAYOUT[type].length).toBeGreaterThan(0); + } + }); + + it("keeps condensation inside the live bowl", () => { + for (const type of TYPES) { + for (const drop of condensationPoints(type)) { + const half = bowlWidthAt(type, drop.y) / 2; + expect(Math.abs(drop.x - GLASS_BOUNDS[type].bowlCenterX)).toBeLessThan( + half, + ); + } + } + }); +}); diff --git a/src/components/demos/cocktail-mixer/__tests__/liquid-vertices.test.ts b/src/components/demos/cocktail-mixer/__tests__/liquid-vertices.test.ts new file mode 100644 index 0000000..dbe7435 --- /dev/null +++ b/src/components/demos/cocktail-mixer/__tests__/liquid-vertices.test.ts @@ -0,0 +1,120 @@ +/** @vitest-environment node */ +import { describe, expect, it } from "vitest"; +import { writeMeniscusVertices } from "../pixi/liquid"; + +const COLS = 12; +const ROWS = 8; +const WIDTH = 130; +const HEIGHT = 154; +const BOWL_CENTER_X = 65; + +function vertices( + amp: number, + swirl: number, + vortex: number, +): Float32Array { + const positions = new Float32Array(COLS * ROWS * 2); + writeMeniscusVertices( + positions, + COLS, + ROWS, + WIDTH, + HEIGHT, + amp, + swirl, + vortex, + BOWL_CENTER_X, + ); + return positions; +} + +describe("writeMeniscusVertices", () => { + it("keeps lower rows on the grid and bends only the top row", () => { + const positions = vertices(2, 0, 0); + + for (let row = 1; row < ROWS; row += 1) { + for (let col = 0; col < COLS; col += 1) { + const index = (row * COLS + col) * 2; + expect(positions[index]).toBeCloseTo( + (col / (COLS - 1)) * WIDTH, + 5, + ); + expect(positions[index + 1]).toBeCloseTo( + (row / (ROWS - 1)) * HEIGHT, + 5, + ); + } + } + + const bottomLeft = (ROWS - 1) * COLS * 2; + expect(positions[bottomLeft + 1]).toBeCloseTo(HEIGHT, 5); + const topYs = Array.from( + { length: COLS }, + (_, col) => positions[col * 2 + 1], + ); + const spread = Math.max(...topYs) - Math.min(...topYs); + expect(spread).toBeGreaterThan(3.5); + expect(spread).toBeLessThanOrEqual(4); + }); + + it("shifts the top row by at most eight pixels for a full vortex", () => { + const rest = vertices(2, 0, 0); + const vortex = vertices(2, 0, 1); + const shifts = Array.from( + { length: COLS }, + (_, col) => Math.abs(vortex[col * 2] - rest[col * 2]), + ); + + expect(Math.max(...shifts)).toBeGreaterThan(7.5); + expect(Math.max(...shifts)).toBeLessThanOrEqual(8); + }); + + it("caps shaken top-row jitter at three pixels", () => { + const rest = vertices(2, 0, 0); + const shaken = vertices(2, 0.4, 0); + const shifts = Array.from( + { length: COLS }, + (_, col) => Math.abs(shaken[col * 2] - rest[col * 2]), + ); + + expect(Math.max(...shifts)).toBeGreaterThan(0); + expect(Math.max(...shifts)).toBeLessThanOrEqual(3); + }); + + it("dips the meniscus at the stream contact", () => { + const rest = new Float32Array(COLS * ROWS * 2); + const pouring = new Float32Array(COLS * ROWS * 2); + writeMeniscusVertices( + rest, + COLS, + ROWS, + WIDTH, + HEIGHT, + 2, + 0, + 0, + BOWL_CENTER_X, + ); + writeMeniscusVertices( + pouring, + COLS, + ROWS, + WIDTH, + HEIGHT, + 2, + 0, + 0, + BOWL_CENTER_X, + 1, + WIDTH * 0.7, + ); + + const restYs = Array.from({ length: COLS }, (_, col) => rest[col * 2 + 1]); + const pourYs = Array.from( + { length: COLS }, + (_, col) => pouring[col * 2 + 1], + ); + const dips = pourYs.map((y, col) => y - restYs[col]); + expect(Math.max(...dips)).toBeGreaterThan(3); + }); +}); diff --git a/src/components/demos/cocktail-mixer/__tests__/mixer-plates.test.ts b/src/components/demos/cocktail-mixer/__tests__/mixer-plates.test.ts new file mode 100644 index 0000000..66e2c3a --- /dev/null +++ b/src/components/demos/cocktail-mixer/__tests__/mixer-plates.test.ts @@ -0,0 +1,130 @@ +/** @vitest-environment node */ +import { mkdtemp, readdir, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import sharp from "sharp"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { + generateMixerPlates, + PLATE_SPECS, +} from "../../../../../scripts/generate-mixer-plates"; + +const REQUIRED_FILES = [ + "bar-top.png", + "glass-rocks-back.png", + "glass-rocks-front.png", + "glass-rocks-mask.png", + "glass-highball-back.png", + "glass-highball-front.png", + "glass-highball-mask.png", + "glass-coupe-back.png", + "glass-coupe-front.png", + "glass-coupe-mask.png", + "glass-margarita-back.png", + "glass-margarita-front.png", + "glass-margarita-mask.png", + "ice-cube.png", + "bottle.png", + "stream.png", + "displace-noise.png", + "frost.png", + "condensation-dot.png", + "rim-highlight.png", + "garnish-lime-wheel.png", + "garnish-cherry.png", + "garnish-orange-slice.png", + "garnish-grapefruit-wedge.png", + "garnish-cherry-orange.png", + "garnish-rocket.png", + "rim-salt-margarita.png", + "rim-salt-highball.png", + "splash-dot.png", + "foam-dot.png", + "star-mote.png", +] as const; + +const INTERIOR_POINTS = { + rocks: { x: 200, y: 350 }, + highball: { x: 200, y: 300 }, + coupe: { x: 200, y: 250 }, + margarita: { x: 200, y: 230 }, +} as const; + +let outputDirectory: string; + +async function alphaAt(filename: string, x: number, y: number) { + const { data } = await sharp(join(outputDirectory, filename)) + .ensureAlpha() + .extract({ left: x, top: y, width: 1, height: 1 }) + .raw() + .toBuffer({ resolveWithObject: true }); + + return data[3]; +} + +beforeAll(async () => { + outputDirectory = await mkdtemp(join(tmpdir(), "mixer-plates-")); + await generateMixerPlates(outputDirectory); +}, 30_000); + +afterAll(async () => { + await rm(outputDirectory, { recursive: true, force: true }); +}); + +describe("mixer plate generator", () => { + it("declares and writes every required plate", async () => { + expect(PLATE_SPECS.map(({ filename }) => filename).sort()).toEqual( + [...REQUIRED_FILES].sort(), + ); + expect((await readdir(outputDirectory)).sort()).toEqual( + [...REQUIRED_FILES].sort(), + ); + }); + + it("writes the required fixed dimensions", async () => { + for (const type of Object.keys(INTERIOR_POINTS)) { + for (const layer of ["back", "front", "mask"]) { + const metadata = await sharp( + join(outputDirectory, `glass-${type}-${layer}.png`), + ).metadata(); + expect([metadata.width, metadata.height]).toEqual([400, 600]); + } + } + + const bar = await sharp(join(outputDirectory, "bar-top.png")).metadata(); + const bottle = await sharp(join(outputDirectory, "bottle.png")).metadata(); + const stream = await sharp(join(outputDirectory, "stream.png")).metadata(); + + expect([bar.width, bar.height]).toEqual([560, 840]); + expect([bottle.width, bottle.height]).toEqual([96, 192]); + expect([stream.width, stream.height]).toEqual([128, 8]); + }); + + it("keeps glass interiors transparent on painted layers and opaque on masks", async () => { + for (const [type, point] of Object.entries(INTERIOR_POINTS)) { + expect(await alphaAt(`glass-${type}-mask.png`, point.x, point.y)).toBe(255); + expect(await alphaAt(`glass-${type}-back.png`, point.x, point.y)).toBe(0); + expect(await alphaAt(`glass-${type}-front.png`, point.x, point.y)).toBe(0); + expect(await alphaAt(`glass-${type}-mask.png`, 0, 0)).toBe(0); + } + }); + + it("makes the bottle neutral enough to tint", async () => { + const { data, info } = await sharp(join(outputDirectory, "bottle.png")) + .ensureAlpha() + .raw() + .toBuffer({ resolveWithObject: true }); + const opaquePixels: number[][] = []; + + for (let offset = 0; offset < data.length; offset += info.channels) { + if (data[offset + 3] > 127) { + opaquePixels.push([data[offset], data[offset + 1], data[offset + 2]]); + } + } + + const average = opaquePixels + .flatMap((pixel) => pixel) + .reduce((sum, channel) => sum + channel, 0) / (opaquePixels.length * 3); + expect(average).toBeGreaterThan(200); + }); +}); diff --git a/src/components/demos/cocktail-mixer/__tests__/particles-policy.test.ts b/src/components/demos/cocktail-mixer/__tests__/particles-policy.test.ts new file mode 100644 index 0000000..15b0c1f --- /dev/null +++ b/src/components/demos/cocktail-mixer/__tests__/particles-policy.test.ts @@ -0,0 +1,24 @@ +/** @vitest-environment node */ +import { describe, expect, it } from "vitest"; +import { COCKTAILS } from "../data"; +import { shouldEmitFoam, shouldShowSalt } from "../pixi/particles"; + +describe("particle policy", () => { + it("foams Paloma soda pour only", () => { + const paloma = COCKTAILS.find((cocktail) => cocktail.name === "Paloma")!; + expect(shouldEmitFoam(paloma, 0)).toBe(false); + expect(shouldEmitFoam(paloma, 1)).toBe(true); + expect(shouldEmitFoam(paloma, 2)).toBe(false); + const dog = COCKTAILS.find((cocktail) => cocktail.name === "Salty Dog")!; + expect( + dog.ingredients.every( + (_, ingredientIndex) => !shouldEmitFoam(dog, ingredientIndex), + ), + ).toBe(true); + }); + + it("shows salt when garnishType starts with salt_", () => { + expect(shouldShowSalt("salt_lime")).toBe(true); + expect(shouldShowSalt("cherry")).toBe(false); + }); +}); diff --git a/src/components/demos/cocktail-mixer/__tests__/pour-director.test.ts b/src/components/demos/cocktail-mixer/__tests__/pour-director.test.ts new file mode 100644 index 0000000..4867ffa --- /dev/null +++ b/src/components/demos/cocktail-mixer/__tests__/pour-director.test.ts @@ -0,0 +1,165 @@ +/** @vitest-environment jsdom */ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { COCKTAILS } from "../data"; +import { GLASS_BOUNDS, GLASS_RECT } from "../glass-bounds"; +import { gsap } from "../gsap-setup"; +import { buildPourCues } from "../pour-script"; +import type { Cocktail } from "../types"; +import { buildDirectorTimeline } from "../pixi/pour-director"; +import type { MixerRig, MixerUniforms } from "../pixi/rig"; + +const timelines: gsap.core.Timeline[] = []; + +function createFakeRig(cocktail: Cocktail): MixerRig { + const uniforms: MixerUniforms = { + fillHeight: 0, + fillColor: cocktail.color, + flashColor: cocktail.color, + flashAmount: 0, + meniscusAmp: 2, + swirl: 0, + vortex: 0, + streamOn: 0, + streamColor: cocktail.color, + bottleAngle: 0, + bottleAlpha: 0, + iceAlpha: 0, + iceDrop: 0, + garnishAlpha: 0, + garnishDrop: 0, + frostAlpha: 0, + displacementOn: false, + }; + + return { + uniforms, + applyFinished: vi.fn(), + setFillHeight: vi.fn(), + setStream: vi.fn(), + emitSplash: vi.fn(), + pinFoamTo: vi.fn(), + emitMotes: vi.fn(), + killEphemeral: vi.fn(), + neckWorld: vi.fn(() => ({ x: 0, y: 0 })), + tick: vi.fn(), + destroy: vi.fn(), + }; +} + +function methodCueAt(cocktail: Cocktail): number { + const cue = buildPourCues({ + ingredientCount: cocktail.ingredients.length, + hasIce: GLASS_BOUNDS[cocktail.glass].hasIce, + isSecret: Boolean(cocktail.isSecret), + reducedMotion: false, + }).find(({ kind }) => kind === "method"); + + if (!cue) { + throw new Error(`Missing method cue for ${cocktail.name}`); + } + + return cue.at; +} + +function splashCueAt(cocktail: Cocktail, ingredientIndex: number): number { + const cue = buildPourCues({ + ingredientCount: cocktail.ingredients.length, + hasIce: GLASS_BOUNDS[cocktail.glass].hasIce, + isSecret: Boolean(cocktail.isSecret), + reducedMotion: false, + }).find( + ({ kind, ingredientIndex: index }) => + kind === "splash" && index === ingredientIndex, + ); + + if (!cue) { + throw new Error( + `Missing splash cue ${ingredientIndex} for ${cocktail.name}`, + ); + } + + return cue.at; +} + +function surfaceYFromFill(cocktail: Cocktail, fillHeight: number): number { + const bounds = GLASS_BOUNDS[cocktail.glass]; + const clamped = Math.max(0, Math.min(1, fillHeight)); + const liquidTop = GLASS_RECT.y + bounds.liquidTop; + const liquidBottom = GLASS_RECT.y + bounds.liquidBottom; + return liquidBottom - (liquidBottom - liquidTop) * clamped; +} + +function buildTimeline(cocktail: Cocktail, rig: MixerRig) { + const timeline = gsap.timeline({ paused: true }); + timelines.push(timeline); + buildDirectorTimeline(timeline, rig, cocktail, vi.fn()); + return timeline; +} + +afterEach(() => { + for (const timeline of timelines.splice(0)) { + timeline.kill(); + } +}); + +describe("buildDirectorTimeline", () => { + it.each([ + ["shaken", COCKTAILS.find(({ method }) => method === "shaken")!], + ["stirred", COCKTAILS.find(({ method }) => method === "stirred")!], + ])("keeps %s motion dormant until the method cue", (_method, cocktail) => { + const rig = createFakeRig(cocktail); + const timeline = buildTimeline(cocktail, rig); + const methodAt = methodCueAt(cocktail); + + timeline.seek(methodAt - 0.001, false); + + expect(rig.uniforms.swirl).toBe(0); + expect(rig.uniforms.vortex).toBe(0); + + timeline.seek(methodAt, false); + + if (cocktail.method === "shaken") { + expect(rig.uniforms.swirl).toBeCloseTo(0.4); + expect(rig.uniforms.vortex).toBe(0); + } else { + expect(rig.uniforms.swirl).toBe(0); + expect(rig.uniforms.vortex).toBeCloseTo(1); + } + }); + + it("leaves built drinks without swirl or vortex at the method cue", () => { + const cocktail = COCKTAILS.find(({ method }) => method === "built")!; + const rig = createFakeRig(cocktail); + const timeline = buildTimeline(cocktail, rig); + + timeline.seek(methodCueAt(cocktail), false); + + expect(rig.uniforms.swirl).toBe(0); + expect(rig.uniforms.vortex).toBe(0); + }); + + it("emits splash at the live meniscus, not the slot target fill", () => { + const cocktail = COCKTAILS.find( + ({ ingredients }) => ingredients.length === 3, + )!; + const rig = createFakeRig(cocktail); + const timeline = buildTimeline(cocktail, rig); + const splashAt = splashCueAt(cocktail, 0); + const slotTarget = 1 / cocktail.ingredients.length; + + timeline.seek(splashAt, false); + + expect(rig.emitSplash).toHaveBeenCalledTimes(1); + const [splashX, splashY, splashColor] = vi.mocked(rig.emitSplash).mock + .calls[0]; + const liveFill = rig.uniforms.fillHeight; + const bounds = GLASS_BOUNDS[cocktail.glass]; + + expect(liveFill).toBeGreaterThan(0); + expect(liveFill).toBeLessThan(slotTarget); + expect(splashY).toBeCloseTo(surfaceYFromFill(cocktail, liveFill)); + expect(splashY).not.toBeCloseTo(surfaceYFromFill(cocktail, slotTarget)); + expect(splashX).toBeGreaterThan(GLASS_RECT.x + bounds.bowlCenterX); + expect(splashColor).toBe(cocktail.ingredients[0].color); + }); +}); diff --git a/src/components/demos/cocktail-mixer/__tests__/pour-script.test.ts b/src/components/demos/cocktail-mixer/__tests__/pour-script.test.ts new file mode 100644 index 0000000..00fc08a --- /dev/null +++ b/src/components/demos/cocktail-mixer/__tests__/pour-script.test.ts @@ -0,0 +1,94 @@ +/** @vitest-environment node */ +import { describe, it, expect } from "vitest"; +import { + POUR_FINISH, + POUR_INTRO, + POUR_SLOT, + SECRET_EXTRA, + buildPourCues, + pourDuration, +} from "../pour-script"; + +describe("pour-script", () => { + it("matches the duration formula", () => { + expect(pourDuration(3, false)).toBeCloseTo( + POUR_INTRO + 3 * POUR_SLOT + POUR_FINISH + ); + expect(pourDuration(3, false)).toBeCloseTo(3.4); + expect(pourDuration(4, true)).toBeCloseTo( + POUR_INTRO + 4 * POUR_SLOT + POUR_FINISH + SECRET_EXTRA + ); + expect(pourDuration(4, true)).toBeCloseTo(4.65); + }); + + it("increments pouredCount at each slot start", () => { + const cues = buildPourCues({ + ingredientCount: 3, + hasIce: false, + isSecret: false, + reducedMotion: false, + }); + const snaps = cues.filter((c) => c.kind === "snapshot").map((c) => c.snapshot); + expect(snaps[0]).toEqual({ + pouredCount: 0, + activePour: null, + allDone: false, + }); + expect(snaps.some((s) => s?.pouredCount === 1 && s.activePour === 0)).toBe( + true + ); + expect(snaps.at(-1)).toEqual({ + pouredCount: 3, + activePour: null, + allDone: true, + }); + }); + + it("emits allDone immediately for reduced motion", () => { + const cues = buildPourCues({ + ingredientCount: 3, + hasIce: true, + isSecret: true, + reducedMotion: true, + }); + expect(cues).toEqual([ + { + at: 0, + kind: "snapshot", + snapshot: { pouredCount: 3, activePour: null, allDone: true }, + }, + ]); + }); + + it("skips ice cues when hasIce is false", () => { + const noIce = buildPourCues({ + ingredientCount: 2, + hasIce: false, + isSecret: false, + reducedMotion: false, + }); + const iced = buildPourCues({ + ingredientCount: 2, + hasIce: true, + isSecret: false, + reducedMotion: false, + }); + expect(noIce.some((c) => c.kind === "ice")).toBe(false); + expect(iced.some((c) => c.kind === "ice")).toBe(true); + }); + + it("places Cosmonaut allDone after SECRET_EXTRA", () => { + const cues = buildPourCues({ + ingredientCount: 4, + hasIce: false, + isSecret: true, + reducedMotion: false, + }); + const secret = cues.find((c) => c.kind === "secret"); + const done = cues.find((c) => c.kind === "done"); + expect(secret).toBeTruthy(); + expect(done?.at).toBeCloseTo(pourDuration(4, true)); + expect(done?.at).toBeGreaterThan(secret!.at); + expect(done?.snapshot?.allDone).toBe(true); + }); +}); diff --git a/src/components/demos/cocktail-mixer/__tests__/pour-sequence.test.ts b/src/components/demos/cocktail-mixer/__tests__/pour-sequence.test.ts deleted file mode 100644 index 98ccc1c..0000000 --- a/src/components/demos/cocktail-mixer/__tests__/pour-sequence.test.ts +++ /dev/null @@ -1,109 +0,0 @@ -/** @vitest-environment jsdom */ -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { renderHook, act } from "@testing-library/react"; -import { usePourSequence } from "../hooks"; - -describe("usePourSequence", () => { - beforeEach(() => { - vi.useFakeTimers(); - }); - - afterEach(() => { - vi.useRealTimers(); - }); - - it("starts with nothing poured", () => { - const { result } = renderHook(() => usePourSequence(3)); - expect(result.current.pouredCount).toBe(0); - expect(result.current.activePour).toBeNull(); - expect(result.current.bubbleIndex).toBeNull(); - expect(result.current.allDone).toBe(false); - }); - - it("pours first ingredient after initial delay", () => { - const { result } = renderHook(() => usePourSequence(3)); - - act(() => { - vi.advanceTimersByTime(300); // past 250ms delay - }); - - expect(result.current.pouredCount).toBe(1); - expect(result.current.activePour).toBe(0); - }); - - it("transitions from pour stream to bubbles", () => { - const { result } = renderHook(() => usePourSequence(3)); - - // Advance past pour start + stream duration - act(() => { - vi.advanceTimersByTime(250 + 500); // 250 delay + 450 stream + buffer - }); - - expect(result.current.activePour).toBeNull(); - expect(result.current.bubbleIndex).toBe(0); - }); - - it("pours all ingredients sequentially", () => { - const { result } = renderHook(() => usePourSequence(3)); - - // Advance past all 3 compact pours. - act(() => { - vi.advanceTimersByTime(250 + 850 * 3); - }); - - expect(result.current.pouredCount).toBe(3); - }); - - it("sets allDone after all ingredients are poured", () => { - const { result } = renderHook(() => usePourSequence(2)); - - // Compact timing keeps the proof moving without skipping the sequence. - act(() => { - vi.advanceTimersByTime(250 + 2 * 850 + 300); - }); - - expect(result.current.allDone).toBe(true); - }); - - it("works with single ingredient", () => { - const { result } = renderHook(() => usePourSequence(1)); - - act(() => { - vi.advanceTimersByTime(250 + 850 + 300); - }); - - expect(result.current.pouredCount).toBe(1); - expect(result.current.allDone).toBe(true); - }); - - it("finishes a three-ingredient recipe in under 3.5 seconds", () => { - const { result } = renderHook(() => usePourSequence(3)); - - act(() => { - vi.advanceTimersByTime(3400); - }); - - expect(result.current.pouredCount).toBe(3); - expect(result.current.allDone).toBe(true); - }); - - it("shows the completed recipe immediately for reduced motion", () => { - const { result } = renderHook(() => usePourSequence(3, true)); - - expect(result.current.pouredCount).toBe(3); - expect(result.current.activePour).toBeNull(); - expect(result.current.bubbleIndex).toBeNull(); - expect(result.current.allDone).toBe(true); - }); - - it("cleans up timers on unmount", () => { - const { unmount } = renderHook(() => usePourSequence(3)); - const clearSpy = vi.spyOn(global, "clearTimeout"); - - unmount(); - - // Should have cleared multiple timers (3 per ingredient + 1 done) - expect(clearSpy.mock.calls.length).toBeGreaterThan(0); - clearSpy.mockRestore(); - }); -}); diff --git a/src/components/demos/cocktail-mixer/__tests__/pour-timeline.test.ts b/src/components/demos/cocktail-mixer/__tests__/pour-timeline.test.ts deleted file mode 100644 index ac8fb06..0000000 --- a/src/components/demos/cocktail-mixer/__tests__/pour-timeline.test.ts +++ /dev/null @@ -1,200 +0,0 @@ -/** @vitest-environment jsdom */ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { COCKTAILS, THE_COSMONAUT } from "../data"; -import { - buildPourTimeline, - pourDuration, - type PourSnapshot, -} from "../pour-timeline"; - -function svgEl(tag: string): SVGElement { - return document.createElementNS("http://www.w3.org/2000/svg", tag); -} - -function mountRoot(ingredientCount: number): SVGSVGElement { - const root = svgEl("svg") as SVGSVGElement; - const glass = svgEl("g"); - glass.setAttribute("data-pour", "glass"); - root.append(glass); - - for (let i = 0; i < ingredientCount; i++) { - const liquid = svgEl("rect"); - liquid.setAttribute("data-pour", `liquid-${i}`); - liquid.setAttribute("x", "0"); - liquid.setAttribute("width", "200"); - liquid.setAttribute("y", "250"); - liquid.setAttribute("height", "0"); - glass.append(liquid); - } - - const wave = svgEl("path"); - wave.setAttribute("data-pour", "wave"); - wave.setAttribute("d", "M 0,3 L 240,3 L 240,12 L 0,12 Z"); - glass.append(wave); - - const ice = svgEl("g"); - ice.setAttribute("data-pour", "ice"); - glass.append(ice); - - const garnish = svgEl("g"); - garnish.setAttribute("data-pour", "garnish"); - glass.append(garnish); - - const bottle = svgEl("g"); - bottle.setAttribute("data-pour", "bottle"); - const bottleBody = svgEl("rect"); - bottleBody.setAttribute("data-pour", "bottle-body"); - bottle.append(bottleBody); - root.append(bottle); - - const stream = svgEl("path"); - stream.setAttribute("data-pour", "stream"); - stream.setAttribute( - "d", - "M 100,75 Q 102,120 100,180" - ); - stream.setAttribute("stroke", "#fff"); - stream.setAttribute("stroke-width", "3"); - stream.setAttribute("fill", "none"); - root.append(stream); - - for (let i = 0; i < 3; i++) { - const splash = svgEl("circle"); - splash.setAttribute("data-pour", "splash"); - splash.setAttribute("cx", "100"); - splash.setAttribute("cy", "140"); - splash.setAttribute("r", "2"); - root.append(splash); - } - - for (let i = 0; i < 4; i++) { - const bubble = svgEl("circle"); - bubble.setAttribute("data-pour", "bubble"); - bubble.setAttribute("cx", "100"); - bubble.setAttribute("cy", "200"); - bubble.setAttribute("r", "2"); - root.append(bubble); - } - - document.body.append(root); - return root; -} - -function collectSnapshots( - cocktail: (typeof COCKTAILS)[number], - reducedMotion = false -): { snapshots: PourSnapshot[]; duration: number } { - const root = mountRoot(cocktail.ingredients.length); - const snapshots: PourSnapshot[] = []; - const timeline = buildPourTimeline({ - root, - cocktail, - reducedMotion, - onSnapshot: (snapshot) => snapshots.push({ ...snapshot }), - }); - const duration = timeline.duration(); - if (!reducedMotion) { - timeline.progress(1); - } - timeline.kill(); - root.remove(); - return { snapshots, duration }; -} - -describe("buildPourTimeline", () => { - beforeEach(() => { - document.body.innerHTML = ""; - }); - - it("starts from an empty glass", () => { - const margarita = COCKTAILS[0]; - const root = mountRoot(margarita.ingredients.length); - const snapshots: PourSnapshot[] = []; - const timeline = buildPourTimeline({ - root, - cocktail: margarita, - reducedMotion: false, - onSnapshot: (snapshot) => snapshots.push({ ...snapshot }), - }); - expect(snapshots[0]).toEqual({ - pouredCount: 0, - activePour: null, - allDone: false, - }); - timeline.kill(); - root.remove(); - }); - - it("finishes a three-ingredient recipe in under 3.5 seconds", () => { - const margarita = COCKTAILS[0]; - expect(margarita.ingredients).toHaveLength(3); - const { snapshots, duration } = collectSnapshots(margarita); - expect(duration).toBeLessThan(3.5); - expect(pourDuration(3)).toBeLessThan(3.5); - expect(snapshots.at(-1)).toEqual({ - pouredCount: 3, - activePour: null, - allDone: true, - }); - }); - - it("emits a snapshot when the first pour starts", () => { - const margarita = COCKTAILS[0]; - const { snapshots } = collectSnapshots(margarita); - expect( - snapshots.some((s) => s.pouredCount === 1 && s.activePour === 0) - ).toBe(true); - }); - - it("shows the completed recipe immediately for reduced motion", () => { - const margarita = COCKTAILS[0]; - const { snapshots, duration } = collectSnapshots(margarita, true); - expect(duration).toBe(0); - expect(snapshots).toEqual([ - { pouredCount: 3, activePour: null, allDone: true }, - ]); - }); - - it("keeps built drinks still and shakes shaken drinks", () => { - const paloma = COCKTAILS.find((c) => c.method === "built"); - const margarita = COCKTAILS.find((c) => c.method === "shaken"); - expect(paloma).toBeTruthy(); - expect(margarita).toBeTruthy(); - - const builtRoot = mountRoot(paloma!.ingredients.length); - const shakenRoot = mountRoot(margarita!.ingredients.length); - const built = buildPourTimeline({ - root: builtRoot, - cocktail: paloma!, - reducedMotion: false, - onSnapshot: vi.fn(), - }); - const shaken = buildPourTimeline({ - root: shakenRoot, - cocktail: margarita!, - reducedMotion: false, - onSnapshot: vi.fn(), - }); - - expect(shaken.getTweensOf('[data-pour="glass"]').length).toBeGreaterThan( - built.getTweensOf('[data-pour="glass"]').length - ); - - built.kill(); - shaken.kill(); - builtRoot.remove(); - shakenRoot.remove(); - }); - - it("kills without throwing", () => { - const root = mountRoot(THE_COSMONAUT.ingredients.length); - const timeline = buildPourTimeline({ - root, - cocktail: THE_COSMONAUT, - reducedMotion: false, - onSnapshot: vi.fn(), - }); - expect(() => timeline.kill()).not.toThrow(); - root.remove(); - }); -}); diff --git a/src/components/demos/cocktail-mixer/__tests__/recipe-view.test.tsx b/src/components/demos/cocktail-mixer/__tests__/recipe-view.test.tsx new file mode 100644 index 0000000..40c1039 --- /dev/null +++ b/src/components/demos/cocktail-mixer/__tests__/recipe-view.test.tsx @@ -0,0 +1,49 @@ +/** @vitest-environment jsdom */ +import { useEffect } from "react"; +import { render, screen, waitFor } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { COCKTAILS } from "../data"; +import { RecipeView } from "../components/recipe-view"; + +vi.mock("../pixi/bar-stage", () => ({ + BarStage: ({ + onSnapshot, + cocktail, + }: { + onSnapshot: (snapshot: { + pouredCount: number; + activePour: null; + allDone: boolean; + }) => void; + cocktail: { ingredients: unknown[] }; + }) => { + useEffect(() => { + onSnapshot({ + pouredCount: cocktail.ingredients.length, + activePour: null, + allDone: true, + }); + }, [cocktail, onSnapshot]); + + return
; + }, +})); + +describe("RecipeView", () => { + it("completes the pour once when BarStage finishes", async () => { + const onPourComplete = vi.fn(); + + render( + , + ); + + expect(screen.getByTestId("bar-stage")).toBeInTheDocument(); + await waitFor(() => { + expect(onPourComplete).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/src/components/demos/cocktail-mixer/__tests__/rig-layers.test.ts b/src/components/demos/cocktail-mixer/__tests__/rig-layers.test.ts new file mode 100644 index 0000000..1986a27 --- /dev/null +++ b/src/components/demos/cocktail-mixer/__tests__/rig-layers.test.ts @@ -0,0 +1,17 @@ +/** @vitest-environment node */ +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +const rigSource = readFileSync( + fileURLToPath(new URL("../pixi/rig.ts", import.meta.url)), + "utf8", +); + +describe("mixer rig layers", () => { + it("does not mount rim-highlight.png as a free sprite", () => { + // That plate is an S-curve. As a sibling sprite it hangs in empty air + // beside the bowl. Front glass already bakes the key light. + expect(rigSource).not.toContain('"rim-highlight.png"'); + }); +}); diff --git a/src/components/demos/cocktail-mixer/__tests__/stream-points.test.ts b/src/components/demos/cocktail-mixer/__tests__/stream-points.test.ts new file mode 100644 index 0000000..c066db2 --- /dev/null +++ b/src/components/demos/cocktail-mixer/__tests__/stream-points.test.ts @@ -0,0 +1,84 @@ +/** @vitest-environment node */ +import { describe, expect, it } from "vitest"; +import { + collapseStreamPoints, + rimCrossingTime, + streamSag, + wobbleStreamPoints, + writeSplitStream, + writeStreamPoints, + type StreamPointData, +} from "../pixi/stream-points"; + +const POINT_COUNT = 20; + +function blankPoints(): StreamPointData[] { + return Array.from({ length: POINT_COUNT }, () => ({ x: 0, y: 0 })); +} + +describe("writeStreamPoints", () => { + it("sags the midpoint toward gravity, scaled to stream length", () => { + const start = { x: 210, y: 90 }; + const end = { x: 160, y: 180 }; + const points = blankPoints(); + const sag = streamSag(start, end); + writeStreamPoints(points, start, end, sag); + + const midpointIndex = POINT_COUNT / 2; + const midpointT = midpointIndex / (POINT_COUNT - 1); + const chordY = start.y + (end.y - start.y) * midpointT; + expect(sag).toBeGreaterThan(4); + expect(sag).toBeLessThanOrEqual(18); + expect(points[midpointIndex].y - chordY).toBeCloseTo(sag, 1); + }); +}); + +describe("writeSplitStream", () => { + it("joins air and inner ropes on one neck-to-surface curve", () => { + const air = blankPoints(); + const inner = blankPoints(); + const neck = { x: 210, y: 88 }; + const surface = { x: 168, y: 196 }; + const rimY = 102; + const rim = writeSplitStream(air, inner, neck, surface, rimY); + + expect(rim.y).toBeCloseTo(rimY, 1); + expect(air[0].x).toBeCloseTo(neck.x, 1); + expect(air[0].y).toBeCloseTo(neck.y, 1); + expect(inner.at(-1)?.x).toBeCloseTo(surface.x, 1); + expect(inner.at(-1)?.y).toBeCloseTo(surface.y, 1); + expect(air.at(-1)?.x).toBeCloseTo(inner[0].x, 1); + expect(air.at(-1)?.y).toBeCloseTo(inner[0].y, 1); + expect(rimCrossingTime(neck, surface, streamSag(neck, surface), rimY)).toBeGreaterThan(0); + expect(rimCrossingTime(neck, surface, streamSag(neck, surface), rimY)).toBeLessThan(1); + }); +}); + +describe("collapseStreamPoints", () => { + it("pins every vertex onto the neck so a hidden rope has no leftover ribbon", () => { + const points = blankPoints(); + writeStreamPoints(points, { x: 210, y: 90 }, { x: 160, y: 180 }); + collapseStreamPoints(points, { x: 210, y: 42 }); + + for (const point of points) { + expect(point).toEqual({ x: 210, y: 42 }); + } + }); +}); + +describe("wobbleStreamPoints", () => { + it("leaves endpoints pinned and only nudges the interior", () => { + const start = { x: 210, y: 90 }; + const end = { x: 160, y: 180 }; + const rest = blankPoints(); + const live = blankPoints(); + writeStreamPoints(rest, start, end); + writeStreamPoints(live, start, end); + wobbleStreamPoints(live, 0.4, 1); + + expect(live[0]).toEqual(rest[0]); + expect(live.at(-1)).toEqual(rest.at(-1)); + expect(live[1].x).not.toBe(rest[1].x); + expect(live[1].y).toBe(rest[1].y); + }); +}); diff --git a/src/components/demos/cocktail-mixer/assets/bar-top.png b/src/components/demos/cocktail-mixer/assets/bar-top.png new file mode 100644 index 0000000..0c2c6c7 Binary files /dev/null and b/src/components/demos/cocktail-mixer/assets/bar-top.png differ diff --git a/src/components/demos/cocktail-mixer/assets/bottle.png b/src/components/demos/cocktail-mixer/assets/bottle.png new file mode 100644 index 0000000..c96c180 Binary files /dev/null and b/src/components/demos/cocktail-mixer/assets/bottle.png differ diff --git a/src/components/demos/cocktail-mixer/assets/condensation-dot.png b/src/components/demos/cocktail-mixer/assets/condensation-dot.png new file mode 100644 index 0000000..febb4c3 Binary files /dev/null and b/src/components/demos/cocktail-mixer/assets/condensation-dot.png differ diff --git a/src/components/demos/cocktail-mixer/assets/displace-noise.png b/src/components/demos/cocktail-mixer/assets/displace-noise.png new file mode 100644 index 0000000..604adf6 Binary files /dev/null and b/src/components/demos/cocktail-mixer/assets/displace-noise.png differ diff --git a/src/components/demos/cocktail-mixer/assets/foam-dot.png b/src/components/demos/cocktail-mixer/assets/foam-dot.png new file mode 100644 index 0000000..71ad492 Binary files /dev/null and b/src/components/demos/cocktail-mixer/assets/foam-dot.png differ diff --git a/src/components/demos/cocktail-mixer/assets/frost.png b/src/components/demos/cocktail-mixer/assets/frost.png new file mode 100644 index 0000000..e6d1f0c Binary files /dev/null and b/src/components/demos/cocktail-mixer/assets/frost.png differ diff --git a/src/components/demos/cocktail-mixer/assets/garnish-cherry-orange.png b/src/components/demos/cocktail-mixer/assets/garnish-cherry-orange.png new file mode 100644 index 0000000..a318c14 Binary files /dev/null and b/src/components/demos/cocktail-mixer/assets/garnish-cherry-orange.png differ diff --git a/src/components/demos/cocktail-mixer/assets/garnish-cherry.png b/src/components/demos/cocktail-mixer/assets/garnish-cherry.png new file mode 100644 index 0000000..6d8c721 Binary files /dev/null and b/src/components/demos/cocktail-mixer/assets/garnish-cherry.png differ diff --git a/src/components/demos/cocktail-mixer/assets/garnish-grapefruit-wedge.png b/src/components/demos/cocktail-mixer/assets/garnish-grapefruit-wedge.png new file mode 100644 index 0000000..ef64dbf Binary files /dev/null and b/src/components/demos/cocktail-mixer/assets/garnish-grapefruit-wedge.png differ diff --git a/src/components/demos/cocktail-mixer/assets/garnish-lime-wheel.png b/src/components/demos/cocktail-mixer/assets/garnish-lime-wheel.png new file mode 100644 index 0000000..0139586 Binary files /dev/null and b/src/components/demos/cocktail-mixer/assets/garnish-lime-wheel.png differ diff --git a/src/components/demos/cocktail-mixer/assets/garnish-orange-slice.png b/src/components/demos/cocktail-mixer/assets/garnish-orange-slice.png new file mode 100644 index 0000000..4bb34aa Binary files /dev/null and b/src/components/demos/cocktail-mixer/assets/garnish-orange-slice.png differ diff --git a/src/components/demos/cocktail-mixer/assets/garnish-rocket.png b/src/components/demos/cocktail-mixer/assets/garnish-rocket.png new file mode 100644 index 0000000..228bb3d Binary files /dev/null and b/src/components/demos/cocktail-mixer/assets/garnish-rocket.png differ diff --git a/src/components/demos/cocktail-mixer/assets/glass-coupe-back.png b/src/components/demos/cocktail-mixer/assets/glass-coupe-back.png new file mode 100644 index 0000000..3e6e232 Binary files /dev/null and b/src/components/demos/cocktail-mixer/assets/glass-coupe-back.png differ diff --git a/src/components/demos/cocktail-mixer/assets/glass-coupe-front.png b/src/components/demos/cocktail-mixer/assets/glass-coupe-front.png new file mode 100644 index 0000000..a8df64a Binary files /dev/null and b/src/components/demos/cocktail-mixer/assets/glass-coupe-front.png differ diff --git a/src/components/demos/cocktail-mixer/assets/glass-coupe-mask.png b/src/components/demos/cocktail-mixer/assets/glass-coupe-mask.png new file mode 100644 index 0000000..0f9ac3f Binary files /dev/null and b/src/components/demos/cocktail-mixer/assets/glass-coupe-mask.png differ diff --git a/src/components/demos/cocktail-mixer/assets/glass-highball-back.png b/src/components/demos/cocktail-mixer/assets/glass-highball-back.png new file mode 100644 index 0000000..8d4c8d4 Binary files /dev/null and b/src/components/demos/cocktail-mixer/assets/glass-highball-back.png differ diff --git a/src/components/demos/cocktail-mixer/assets/glass-highball-front.png b/src/components/demos/cocktail-mixer/assets/glass-highball-front.png new file mode 100644 index 0000000..40cdf6a Binary files /dev/null and b/src/components/demos/cocktail-mixer/assets/glass-highball-front.png differ diff --git a/src/components/demos/cocktail-mixer/assets/glass-highball-mask.png b/src/components/demos/cocktail-mixer/assets/glass-highball-mask.png new file mode 100644 index 0000000..6ab2a34 Binary files /dev/null and b/src/components/demos/cocktail-mixer/assets/glass-highball-mask.png differ diff --git a/src/components/demos/cocktail-mixer/assets/glass-margarita-back.png b/src/components/demos/cocktail-mixer/assets/glass-margarita-back.png new file mode 100644 index 0000000..87b9a70 Binary files /dev/null and b/src/components/demos/cocktail-mixer/assets/glass-margarita-back.png differ diff --git a/src/components/demos/cocktail-mixer/assets/glass-margarita-front.png b/src/components/demos/cocktail-mixer/assets/glass-margarita-front.png new file mode 100644 index 0000000..cd5307a Binary files /dev/null and b/src/components/demos/cocktail-mixer/assets/glass-margarita-front.png differ diff --git a/src/components/demos/cocktail-mixer/assets/glass-margarita-mask.png b/src/components/demos/cocktail-mixer/assets/glass-margarita-mask.png new file mode 100644 index 0000000..1ee551e Binary files /dev/null and b/src/components/demos/cocktail-mixer/assets/glass-margarita-mask.png differ diff --git a/src/components/demos/cocktail-mixer/assets/glass-rocks-back.png b/src/components/demos/cocktail-mixer/assets/glass-rocks-back.png new file mode 100644 index 0000000..8759612 Binary files /dev/null and b/src/components/demos/cocktail-mixer/assets/glass-rocks-back.png differ diff --git a/src/components/demos/cocktail-mixer/assets/glass-rocks-front.png b/src/components/demos/cocktail-mixer/assets/glass-rocks-front.png new file mode 100644 index 0000000..31df9a5 Binary files /dev/null and b/src/components/demos/cocktail-mixer/assets/glass-rocks-front.png differ diff --git a/src/components/demos/cocktail-mixer/assets/glass-rocks-mask.png b/src/components/demos/cocktail-mixer/assets/glass-rocks-mask.png new file mode 100644 index 0000000..e5e9f93 Binary files /dev/null and b/src/components/demos/cocktail-mixer/assets/glass-rocks-mask.png differ diff --git a/src/components/demos/cocktail-mixer/assets/ice-cube.png b/src/components/demos/cocktail-mixer/assets/ice-cube.png new file mode 100644 index 0000000..cf59a40 Binary files /dev/null and b/src/components/demos/cocktail-mixer/assets/ice-cube.png differ diff --git a/src/components/demos/cocktail-mixer/assets/rim-highlight.png b/src/components/demos/cocktail-mixer/assets/rim-highlight.png new file mode 100644 index 0000000..b8b860c Binary files /dev/null and b/src/components/demos/cocktail-mixer/assets/rim-highlight.png differ diff --git a/src/components/demos/cocktail-mixer/assets/rim-salt-highball.png b/src/components/demos/cocktail-mixer/assets/rim-salt-highball.png new file mode 100644 index 0000000..2843ae7 Binary files /dev/null and b/src/components/demos/cocktail-mixer/assets/rim-salt-highball.png differ diff --git a/src/components/demos/cocktail-mixer/assets/rim-salt-margarita.png b/src/components/demos/cocktail-mixer/assets/rim-salt-margarita.png new file mode 100644 index 0000000..10b93f8 Binary files /dev/null and b/src/components/demos/cocktail-mixer/assets/rim-salt-margarita.png differ diff --git a/src/components/demos/cocktail-mixer/assets/splash-dot.png b/src/components/demos/cocktail-mixer/assets/splash-dot.png new file mode 100644 index 0000000..22cd6a5 Binary files /dev/null and b/src/components/demos/cocktail-mixer/assets/splash-dot.png differ diff --git a/src/components/demos/cocktail-mixer/assets/star-mote.png b/src/components/demos/cocktail-mixer/assets/star-mote.png new file mode 100644 index 0000000..776e67a Binary files /dev/null and b/src/components/demos/cocktail-mixer/assets/star-mote.png differ diff --git a/src/components/demos/cocktail-mixer/assets/stream.png b/src/components/demos/cocktail-mixer/assets/stream.png new file mode 100644 index 0000000..c1646e4 Binary files /dev/null and b/src/components/demos/cocktail-mixer/assets/stream.png differ diff --git a/src/components/demos/cocktail-mixer/components/cosmonaut-reveal.tsx b/src/components/demos/cocktail-mixer/components/cosmonaut-reveal.tsx deleted file mode 100644 index 6322346..0000000 --- a/src/components/demos/cocktail-mixer/components/cosmonaut-reveal.tsx +++ /dev/null @@ -1,69 +0,0 @@ -"use client"; - -import { motion } from "motion/react"; -import type { Cocktail } from "../types"; -import { CARD_ICONS } from "../svg/card-icons"; - -export function CosmonautReveal({ - cocktail, - onSelect, -}: { - cocktail: Cocktail; - onSelect: (c: Cocktail) => void; -}) { - const Icon = CARD_ICONS[cocktail.name]; - - return ( - onSelect(cocktail)} - initial={{ opacity: 0, scale: 0.6, y: 20 }} - animate={{ opacity: 1, scale: 1, y: 0 }} - whileHover={{ - scale: 1.05, - boxShadow: "0 8px 30px #8b5cf640", - }} - whileTap={{ scale: 0.97 }} - transition={{ - type: "spring", - stiffness: 200, - damping: 15, - delay: 0.3, - }} - className="glass-card relative flex cursor-pointer flex-col items-center gap-3 rounded-2xl p-6 transition-colors duration-300" - style={{ animation: "cosmonaut-glow 3s ease-in-out infinite" }} - > - {/* Star sparkle accents */} - {[ - { x: "10%", y: "15%", delay: 0 }, - { x: "85%", y: "20%", delay: 0.5 }, - { x: "50%", y: "5%", delay: 1 }, - ].map((star, i) => ( - - ✦ - - ))} - - {Icon ? ( - - ) : ( - {cocktail.emoji} - )} - - {cocktail.name} - Secret Recipe - - ); -} diff --git a/src/components/demos/cocktail-mixer/components/glass-visualization.tsx b/src/components/demos/cocktail-mixer/components/glass-visualization.tsx deleted file mode 100644 index 1c28da6..0000000 --- a/src/components/demos/cocktail-mixer/components/glass-visualization.tsx +++ /dev/null @@ -1,118 +0,0 @@ -"use client"; - -import { useId } from "react"; -import { AnimatePresence } from "motion/react"; -import type { Cocktail } from "../types"; -import { GLASS_CONFIGS } from "../data"; -import { GlassSvg } from "../svg/glasses"; -import { BottlePour } from "../svg/bottles"; -import { GarnishOverlay } from "../svg/garnishes"; -import { - LiquidLayers, - LiquidWave, - BubbleParticles, - FacetedIceCubes, - CondensationDrips, -} from "../svg/decorations"; - -export function GlassVisualization({ - cocktail, - pouredCount, - activePour, - bubbleIndex, - allDone, -}: { - cocktail: Cocktail; - pouredCount: number; - activePour: number | null; - bubbleIndex: number | null; - allDone: boolean; -}) { - const rawId = useId(); - const clipId = `glass${rawId.replace(/:/g, "")}`; - const config = GLASS_CONFIGS[cocktail.glass]; - - const topIngredientIdx = Math.min( - pouredCount - 1, - cocktail.ingredients.length - 1 - ); - const topColor = - pouredCount > 0 - ? cocktail.ingredients[topIngredientIdx].color - : "transparent"; - - return ( -
- - - - - - - - {/* Liquid fill */} - - - {/* Wave on liquid surface */} - - - {/* Animated bottle pour */} - - {activePour !== null && ( - - )} - - - {/* Bubbles */} - - {bubbleIndex !== null && ( - - )} - - - {/* Faceted ice cubes */} - - - {/* Glass outline (rendered on top) */} - - - {/* Garnish overlay */} - - - {/* Condensation drips (after pour) */} - - -
- ); -} diff --git a/src/components/demos/cocktail-mixer/components/recipe-view.tsx b/src/components/demos/cocktail-mixer/components/recipe-view.tsx index 446c1c7..86d183f 100644 --- a/src/components/demos/cocktail-mixer/components/recipe-view.tsx +++ b/src/components/demos/cocktail-mixer/components/recipe-view.tsx @@ -4,10 +4,10 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { motion, useReducedMotion } from "motion/react"; import { ArrowLeft } from "lucide-react"; import { Button } from "@/components/ui/button"; +import { BarStage } from "../pixi/bar-stage"; +import type { PourSnapshot } from "../pour-script"; import type { Cocktail } from "../types"; -import { GlassScene } from "../glass-scene"; import { RecipeDetails } from "./recipe-details"; -import type { PourSnapshot } from "../pour-timeline"; const INITIAL_SNAPSHOT: PourSnapshot = { pouredCount: 0, @@ -68,7 +68,7 @@ export function RecipeView({
- = { - rocks: { - outline: - "M24,95 L38,248 Q40,260 55,262 L145,262 Q160,260 162,248 L176,95", - clip: "M28,97 L40,246 Q42,256 55,258 L145,258 Q158,256 160,246 L172,97 Z", - rim: "M20,95 L180,95", - base: "M55,262 L145,262", - highlight: "M30,100 L42,244", - liquidTop: 100, - liquidBottom: 254, - hasIce: true, - }, - coupe: { - outline: - "M15,65 Q30,68 55,135 Q70,175 95,185 L95,242 L80,242 Q72,242 72,250 L72,260 L128,260 L128,250 Q128,242 120,242 L105,242 L105,185 Q130,175 145,135 Q170,68 185,65", - clip: "M20,67 Q35,70 58,135 Q72,172 95,183 L105,183 Q128,172 142,135 Q165,70 180,67 Z", - rim: "M12,65 L188,65", - base: "M72,260 L128,260", - highlight: "M22,70 Q38,72 60,135", - liquidTop: 70, - liquidBottom: 180, - hasIce: false, - }, - highball: { - outline: - "M55,45 L60,252 Q62,262 75,262 L125,262 Q138,262 140,252 L145,45", - clip: "M58,47 L62,250 Q64,258 75,258 L125,258 Q136,258 138,250 L142,47 Z", - rim: "M52,45 L148,45", - base: "M75,262 L125,262", - highlight: "M60,50 L64,248", - liquidTop: 50, - liquidBottom: 255, - hasIce: true, - }, - margarita: { - outline: - "M5,55 Q20,58 45,120 Q60,160 90,180 L90,242 L75,242 Q67,242 67,250 L67,260 L133,260 L133,250 Q133,242 125,242 L110,242 L110,180 Q140,160 155,120 Q180,58 195,55", - clip: "M12,57 Q25,60 48,120 Q62,158 92,178 L108,178 Q138,158 152,120 Q175,60 188,57 Z", - rim: "M2,55 L198,55", - base: "M67,260 L133,260", - highlight: "M15,60 Q28,62 50,120", - liquidTop: 60, - liquidBottom: 175, - hasIce: false, - }, -}; +import type { Cocktail } from "./types"; /* ─── Cocktail Data ─────────────────────────────────────────────────── */ @@ -168,67 +119,9 @@ export const THE_COSMONAUT: Cocktail = { "Shake all ingredients vigorously with ice. Double strain into a chilled coupe glass. Float an edible flower and express a lemon twist.", }; -/* ─── Wave Path ─────────────────────────────────────────────────────── */ - -function generateWavePath(width: number, amplitude: number): string { - let d = `M 0,${amplitude}`; - for (let x = 0; x <= width + 40; x += 4) { - const y = amplitude + Math.sin((x / 40) * Math.PI * 2) * amplitude; - d += ` L ${x},${y}`; - } - d += ` L ${width + 40},${amplitude * 4} L 0,${amplitude * 4} Z`; - return d; -} - -export const WAVE_PATH = generateWavePath(200, 3); - export const WAVE_STYLES = ` -@keyframes wave-drift { - from { transform: translateX(0); } - to { transform: translateX(-40px); } -} -@keyframes condensation-drip { - 0% { opacity: 0.3; transform: translateY(0); } - 70% { opacity: 0.5; } - 100% { opacity: 0; transform: translateY(12px); } -} @keyframes cosmonaut-glow { 0%, 100% { box-shadow: 0 0 8px #8b5cf640, 0 0 24px #8b5cf620; } 50% { box-shadow: 0 0 16px #8b5cf680, 0 0 40px #8b5cf640; } } `; - -/* ─── Decoration Data ───────────────────────────────────────────────── */ - -export const BUBBLE_SETS = [ - [ - { dx: -20, dy: 0, r: 2.5 }, - { dx: 10, dy: -4, r: 3 }, - { dx: -5, dy: -2, r: 2 }, - { dx: 22, dy: -6, r: 2.5 }, - ], - [ - { dx: 15, dy: 0, r: 3 }, - { dx: -12, dy: -3, r: 2 }, - { dx: 25, dy: -5, r: 2.5 }, - { dx: -18, dy: -1, r: 2 }, - ], - [ - { dx: -8, dy: 0, r: 2 }, - { dx: 18, dy: -2, r: 3 }, - { dx: -25, dy: -4, r: 2.5 }, - { dx: 5, dy: -6, r: 2 }, - ], -]; - -export const ICE_POSITIONS = [ - { dx: -25, dy: 0, angle: 12, w: 16, h: 13 }, - { dx: 18, dy: 8, angle: -8, w: 14, h: 11 }, - { dx: -5, dy: 4, angle: 22, w: 12, h: 10 }, -]; - -export const CONDENSATION_DROPS = [ - { dx: -30, dy: 40, delay: 0 }, - { dx: 28, dy: 60, delay: 1.2 }, - { dx: -15, dy: 80, delay: 2.4 }, -]; diff --git a/src/components/demos/cocktail-mixer/garnish-map.ts b/src/components/demos/cocktail-mixer/garnish-map.ts new file mode 100644 index 0000000..b4c3f5f --- /dev/null +++ b/src/components/demos/cocktail-mixer/garnish-map.ts @@ -0,0 +1,106 @@ +import type { GarnishType, GlassType } from "./types"; + +export type GarnishPose = { + anchorX: number; + anchorY: number; + angle: number; + width: number; + height: number; +}; + +export const GARNISH_POSE: Record = { + "garnish-lime-wheel.png": { + anchorX: 0.22, + anchorY: 0.55, + angle: -26, + width: 52, + height: 52, + }, + "garnish-grapefruit-wedge.png": { + anchorX: 0.12, + anchorY: 0.58, + angle: -38, + width: 60, + height: 52, + }, + "garnish-cherry.png": { + anchorX: 0.48, + anchorY: 0.88, + angle: 14, + width: 40, + height: 48, + }, + "garnish-orange-slice.png": { + anchorX: 0.18, + anchorY: 0.55, + angle: -28, + width: 52, + height: 40, + }, + "garnish-cherry-orange.png": { + anchorX: 0.2, + anchorY: 0.7, + angle: -16, + width: 76, + height: 60, + }, + "garnish-rocket.png": { + anchorX: 0.5, + anchorY: 0.92, + angle: 8, + width: 36, + height: 70, + }, +}; + +export const SODA_INGREDIENT_NAME = "Grapefruit Soda"; + +export function isFoamIngredient(name: string): boolean { + return name === SODA_INGREDIENT_NAME; +} + +function saltPlate(glass: GlassType): string { + switch (glass) { + case "margarita": + return "rim-salt-margarita.png"; + case "highball": + return "rim-salt-highball.png"; + case "rocks": + case "coupe": + return "rim-salt-highball.png"; + default: { + const _exhaustive: never = glass; + return _exhaustive; + } + } +} + +export function garnishPlates( + type: GarnishType, + glass: GlassType +): string[] { + switch (type) { + case "lime_wheel": + return ["garnish-lime-wheel.png"]; + case "cherry": + return ["garnish-cherry.png"]; + case "orange_slice": + return ["garnish-orange-slice.png"]; + case "grapefruit_wedge": + return ["garnish-grapefruit-wedge.png"]; + case "salt_rim": + return [saltPlate(glass)]; + case "cherry_orange": + return ["garnish-cherry-orange.png"]; + case "salt_grapefruit": + return [saltPlate(glass), "garnish-grapefruit-wedge.png"]; + case "salt_lime": + return [saltPlate(glass), "garnish-lime-wheel.png"]; + case "rocket": + return ["garnish-rocket.png"]; + default: { + const _exhaustive: never = type; + return _exhaustive; + } + } +} diff --git a/src/components/demos/cocktail-mixer/glass-bounds.ts b/src/components/demos/cocktail-mixer/glass-bounds.ts new file mode 100644 index 0000000..c64b0b5 --- /dev/null +++ b/src/components/demos/cocktail-mixer/glass-bounds.ts @@ -0,0 +1,231 @@ +import type { GlassType } from "./types"; + +export const STAGE = { width: 280, height: 420 } as const; +export const GLASS_RECT = { x: 40, y: 48, width: 200, height: 300 } as const; + +export type IceCube = { + dx: number; + dy: number; + angle: number; + scale: number; +}; + +export type GlassBounds = { + liquidTop: number; + liquidBottom: number; + rimY: number; + bowlCenterX: number; + bowlWidth: number; + hasIce: boolean; + garnishX: number; + garnishY: number; + bottle: { x: number; y: number; neckX: number; neckY: number }; +}; + +type WidthSample = readonly [number, number]; + +/** + * Mask-alpha width samples (CSS px inside GLASS_RECT). Measured from + * glass-{type}-mask.png at 2×, then halved. Last sample is the usable + * bowl floor — not the stem nub. + */ +const BOWL_WIDTH_SAMPLES: Record = { + rocks: [ + [92, 130], + [100, 129.5], + [120, 127], + [140, 124], + [160, 121.5], + [180, 119], + [200, 116], + [220, 113.5], + [240, 111], + [252, 104], + ], + highball: [ + [42, 82], + [50, 82], + [80, 80], + [110, 78], + [140, 76], + [170, 73], + [200, 71], + [230, 69], + [250, 68], + [252, 66], + ], + coupe: [ + [64, 155], + [70, 153], + [90, 146], + [110, 135], + [130, 121], + [150, 102], + [168, 74], + ], + margarita: [ + [54, 172], + [60, 164], + [80, 136], + [100, 109], + [120, 80], + [140, 40], + [148, 24], + ], +}; + +export const GLASS_BOUNDS: Record = { + rocks: { + liquidTop: 98, + liquidBottom: 252, + rimY: 92, + bowlCenterX: 100, + bowlWidth: 119, + hasIce: true, + garnishX: 154, + garnishY: 96, + bottle: { x: 154, y: 80, neckX: 24, neckY: 8 }, + }, + highball: { + liquidTop: 48, + liquidBottom: 252, + rimY: 42, + bowlCenterX: 100, + bowlWidth: 74, + hasIce: true, + garnishX: 138, + garnishY: 46, + bottle: { x: 136, y: 30, neckX: 24, neckY: 8 }, + }, + coupe: { + liquidTop: 70, + liquidBottom: 168, + rimY: 64, + bowlCenterX: 100, + bowlWidth: 128, + hasIce: false, + garnishX: 168, + garnishY: 66, + bottle: { x: 164, y: 52, neckX: 24, neckY: 8 }, + }, + margarita: { + liquidTop: 60, + liquidBottom: 148, + rimY: 54, + bowlCenterX: 100, + bowlWidth: 95, + hasIce: false, + garnishX: 172, + garnishY: 58, + bottle: { x: 170, y: 42, neckX: 24, neckY: 8 }, + }, +}; + +/** Ice positions relative to bowlCenterX and liquidBottom (floor of the bowl). */ +export const ICE_LAYOUT: Record<"rocks" | "highball", IceCube[]> = { + rocks: [ + { dx: -16, dy: -30, angle: 14, scale: 1.12 }, + { dx: 15, dy: -22, angle: -10, scale: 0.76 }, + ], + highball: [ + { dx: -6, dy: -36, angle: 10, scale: 0.66 }, + { dx: 8, dy: -96, angle: -12, scale: 0.7 }, + { dx: -4, dy: -156, angle: 16, scale: 0.62 }, + ], +}; + +export const CONDENSATION_LAYOUT: Record = { + rocks: [ + { dx: -28, dy: 36 }, + { dx: 26, dy: 54 }, + { dx: -12, dy: 72 }, + { dx: 18, dy: 90 }, + ], + highball: [ + { dx: -22, dy: 40 }, + { dx: 20, dy: 70 }, + { dx: -16, dy: 100 }, + { dx: 14, dy: 130 }, + { dx: -8, dy: 160 }, + { dx: 10, dy: 190 }, + ], + coupe: [ + { dx: -40, dy: 18 }, + { dx: 36, dy: 28 }, + { dx: -12, dy: 40 }, + ], + margarita: [ + { dx: -48, dy: 16 }, + { dx: 44, dy: 26 }, + { dx: -10, dy: 36 }, + ], +}; + +const CONDENSATION_INSET = 8; + +/** Condensation in glass-local pixels, clipped to the live bowl. */ +export function condensationPoints( + glass: GlassType, +): { x: number; y: number }[] { + const bounds = GLASS_BOUNDS[glass]; + return CONDENSATION_LAYOUT[glass].map((drop) => { + const y = bounds.liquidTop + drop.dy; + const half = Math.max(4, bowlWidthAt(glass, y) / 2 - CONDENSATION_INSET); + return { + x: bounds.bowlCenterX + Math.max(-half, Math.min(half, drop.dx)), + y, + }; + }); +} + +const WALL_INSET = 6; + +export function bowlWidthAt(glass: GlassType, yInGlass: number): number { + const samples = BOWL_WIDTH_SAMPLES[glass]; + const first = samples[0]; + const last = samples[samples.length - 1]; + if (yInGlass <= first[0]) return Math.max(10, first[1] - WALL_INSET); + if (yInGlass >= last[0]) return Math.max(10, last[1] - WALL_INSET); + + for (let index = 1; index < samples.length; index += 1) { + const previous = samples[index - 1]; + const next = samples[index]; + if (yInGlass <= next[0]) { + const t = (yInGlass - previous[0]) / (next[0] - previous[0]); + return Math.max(10, previous[1] + (next[1] - previous[1]) * t - WALL_INSET); + } + } + + return Math.max(10, last[1] - WALL_INSET); +} + +export function pourContactX(glass: GlassType, yInGlass: number): number { + const bounds = GLASS_BOUNDS[glass]; + return bounds.bowlCenterX + bowlWidthAt(glass, yInGlass) * 0.18; +} + +export function rimGarnishPoint(glass: GlassType): { x: number; y: number } { + const bounds = GLASS_BOUNDS[glass]; + const width = bowlWidthAt(glass, bounds.rimY + 6); + return { + x: bounds.bowlCenterX + width / 2 - 2, + y: bounds.rimY + 4, + }; +} + +export function surfaceYForFill(glass: GlassType, fillHeight: number): number { + const bounds = GLASS_BOUNDS[glass]; + const clamped = Math.max(0, Math.min(1, fillHeight)); + const liquidTop = GLASS_RECT.y + bounds.liquidTop; + const liquidBottom = GLASS_RECT.y + bounds.liquidBottom; + return liquidBottom - (liquidBottom - liquidTop) * clamped; +} + +export function foamOffsetsForWidth(bowlWidth: number): number[] { + const span = Math.max(10, (bowlWidth / 2) * 0.7); + const count = 11; + return Array.from( + { length: count }, + (_, index) => -span + ((2 * span) * index) / (count - 1), + ); +} diff --git a/src/components/demos/cocktail-mixer/glass-scene.tsx b/src/components/demos/cocktail-mixer/glass-scene.tsx deleted file mode 100644 index 7ff8e46..0000000 --- a/src/components/demos/cocktail-mixer/glass-scene.tsx +++ /dev/null @@ -1,216 +0,0 @@ -"use client"; - -import { useId, useRef } from "react"; -import type { Cocktail } from "./types"; -import { GLASS_CONFIGS, ICE_POSITIONS } from "./data"; -import { GlassSvg } from "./svg/glasses"; -import { GarnishOverlay } from "./svg/garnishes"; -import { useGSAP } from "./gsap-setup"; -import { - buildPourTimeline, - type PourSnapshot, -} from "./pour-timeline"; - -export function GlassScene({ - cocktail, - reducedMotion, - onSnapshot, -}: { - cocktail: Cocktail; - reducedMotion: boolean; - onSnapshot: (snapshot: PourSnapshot) => void; -}) { - const rawId = useId(); - const clipId = `glass${rawId.replace(/:/g, "")}`; - const rootRef = useRef(null); - const config = GLASS_CONFIGS[cocktail.glass]; - const totalHeight = config.liquidBottom - config.liquidTop; - const layerHeight = totalHeight / cocktail.ingredients.length; - const iceCenterY = (config.liquidTop + config.liquidBottom) / 2; - const onSnapshotRef = useRef(onSnapshot); - onSnapshotRef.current = onSnapshot; - - useGSAP( - () => { - const root = rootRef.current; - if (!root) return; - const timeline = buildPourTimeline({ - root, - cocktail, - reducedMotion: Boolean(reducedMotion), - onSnapshot: (snapshot) => onSnapshotRef.current(snapshot), - }); - return () => { - timeline.kill(); - }; - }, - { - dependencies: [cocktail.name, reducedMotion], - revertOnUpdate: true, - } - ); - - return ( -
- - - - - - - - - - {cocktail.ingredients.map((ing, i) => { - const layerBottom = config.liquidBottom - i * layerHeight; - return ( - - ); - })} - - - - {config.hasIce - ? ICE_POSITIONS.map((ice, i) => { - const cx = 100 + ice.dx; - const cy = iceCenterY + ice.dy; - const w = ice.w; - const h = ice.h; - const topFace = `${cx},${cy - h / 2} ${cx + w / 2},${cy - h / 4} ${cx},${cy} ${cx - w / 2},${cy - h / 4}`; - const leftFace = `${cx - w / 2},${cy - h / 4} ${cx},${cy} ${cx},${cy + h / 2} ${cx - w / 2},${cy + h / 4}`; - const rightFace = `${cx + w / 2},${cy - h / 4} ${cx},${cy} ${cx},${cy + h / 2} ${cx + w / 2},${cy + h / 4}`; - return ( - - - - - - ); - }) - : null} - - - - - - - - - - - - - - - - {[ - { dx: -6, dy: 8 }, - { dx: 5, dy: 4 }, - { dx: -2, dy: 12 }, - ].map((dot, i) => ( - - ))} - - {[ - { dx: -18, r: 2.4 }, - { dx: 8, r: 3 }, - { dx: -4, r: 2 }, - { dx: 20, r: 2.5 }, - ].map((bubble, i) => ( - - ))} - -
- ); -} diff --git a/src/components/demos/cocktail-mixer/gsap-setup.ts b/src/components/demos/cocktail-mixer/gsap-setup.ts index 0910882..55b7a12 100644 --- a/src/components/demos/cocktail-mixer/gsap-setup.ts +++ b/src/components/demos/cocktail-mixer/gsap-setup.ts @@ -2,27 +2,12 @@ import gsap from "gsap"; import { useGSAP } from "@gsap/react"; -import { CustomEase } from "gsap/CustomEase"; -import { CustomWiggle } from "gsap/CustomWiggle"; -import { DrawSVGPlugin } from "gsap/DrawSVGPlugin"; -import { Flip } from "gsap/Flip"; -import { MorphSVGPlugin } from "gsap/MorphSVGPlugin"; -import { Physics2DPlugin } from "gsap/Physics2DPlugin"; let registered = false; export function registerMixerGsap(): void { if (registered) return; - gsap.registerPlugin( - useGSAP, - Flip, - DrawSVGPlugin, - MorphSVGPlugin, - Physics2DPlugin, - CustomEase, - CustomWiggle - ); - CustomWiggle.create("barShake", { wiggles: 12, type: "easeOut" }); + gsap.registerPlugin(useGSAP); registered = true; } diff --git a/src/components/demos/cocktail-mixer/hooks.ts b/src/components/demos/cocktail-mixer/hooks.ts index d88c175..7919d85 100644 --- a/src/components/demos/cocktail-mixer/hooks.ts +++ b/src/components/demos/cocktail-mixer/hooks.ts @@ -2,76 +2,8 @@ import { useState, useEffect, useCallback } from "react"; -const POUR_DELAY = 250; -const POUR_INTERVAL = 850; -const STREAM_DURATION = 450; -const BUBBLE_LINGER = 450; - const STORAGE_KEY = "cocktails_made"; -/* ─── Pour Sequence Hook ────────────────────────────────────────────── */ - -export interface PourState { - pouredCount: number; - activePour: number | null; - bubbleIndex: number | null; - allDone: boolean; -} - -export function usePourSequence( - ingredientCount: number, - showImmediately = false -): PourState { - const [pouredCount, setPouredCount] = useState( - showImmediately ? ingredientCount : 0 - ); - const [activePour, setActivePour] = useState(null); - const [bubbleIndex, setBubbleIndex] = useState(null); - const [allDone, setAllDone] = useState(showImmediately); - - useEffect(() => { - if (showImmediately) return; - - const timers: ReturnType[] = []; - - for (let i = 0; i < ingredientCount; i++) { - // Start pour stream + fill - timers.push( - setTimeout(() => { - setActivePour(i); - setPouredCount(i + 1); - }, POUR_DELAY + i * POUR_INTERVAL) - ); - - // End stream, start bubbles - timers.push( - setTimeout(() => { - setActivePour((prev) => (prev === i ? null : prev)); - setBubbleIndex(i); - }, POUR_DELAY + i * POUR_INTERVAL + STREAM_DURATION) - ); - - // Clear bubbles - timers.push( - setTimeout(() => { - setBubbleIndex((prev) => (prev === i ? null : prev)); - }, POUR_DELAY + i * POUR_INTERVAL + STREAM_DURATION + BUBBLE_LINGER) - ); - } - - // Mark done - timers.push( - setTimeout(() => { - setAllDone(true); - }, POUR_DELAY + ingredientCount * POUR_INTERVAL + 200) - ); - - return () => timers.forEach(clearTimeout); - }, [ingredientCount, showImmediately]); - - return { pouredCount, activePour, bubbleIndex, allDone }; -} - /* ─── Cocktail Progress Hook ────────────────────────────────────────── */ export interface CocktailProgress { diff --git a/src/components/demos/cocktail-mixer/pixi/application.ts b/src/components/demos/cocktail-mixer/pixi/application.ts new file mode 100644 index 0000000..a15fb61 --- /dev/null +++ b/src/components/demos/cocktail-mixer/pixi/application.ts @@ -0,0 +1,24 @@ +import type { Application } from "pixi.js"; +import { STAGE } from "../glass-bounds"; + +export async function createMixerApp( + PIXI: typeof import("pixi.js"), +): Promise { + const app = new PIXI.Application(); + await app.init({ + width: STAGE.width, + height: STAGE.height, + resolution: Math.min(2, window.devicePixelRatio || 1), + backgroundAlpha: 0, + antialias: true, + autoDensity: true, + }); + return app; +} + +export function destroyMixerApp(app: Application): void { + app.destroy( + { removeView: true }, + { children: true, texture: false, textureSource: false }, + ); +} diff --git a/src/components/demos/cocktail-mixer/pixi/assets.ts b/src/components/demos/cocktail-mixer/pixi/assets.ts new file mode 100644 index 0000000..6857fe3 --- /dev/null +++ b/src/components/demos/cocktail-mixer/pixi/assets.ts @@ -0,0 +1,117 @@ +export const MIXER_ASSET_URLS: Record = { + "bar-top.png": new URL("../assets/bar-top.png", import.meta.url).href, + "bottle.png": new URL("../assets/bottle.png", import.meta.url).href, + "condensation-dot.png": new URL( + "../assets/condensation-dot.png", + import.meta.url, + ).href, + "displace-noise.png": new URL( + "../assets/displace-noise.png", + import.meta.url, + ).href, + "foam-dot.png": new URL("../assets/foam-dot.png", import.meta.url).href, + "frost.png": new URL("../assets/frost.png", import.meta.url).href, + "garnish-cherry-orange.png": new URL( + "../assets/garnish-cherry-orange.png", + import.meta.url, + ).href, + "garnish-cherry.png": new URL( + "../assets/garnish-cherry.png", + import.meta.url, + ).href, + "garnish-grapefruit-wedge.png": new URL( + "../assets/garnish-grapefruit-wedge.png", + import.meta.url, + ).href, + "garnish-lime-wheel.png": new URL( + "../assets/garnish-lime-wheel.png", + import.meta.url, + ).href, + "garnish-orange-slice.png": new URL( + "../assets/garnish-orange-slice.png", + import.meta.url, + ).href, + "garnish-rocket.png": new URL( + "../assets/garnish-rocket.png", + import.meta.url, + ).href, + "glass-coupe-back.png": new URL( + "../assets/glass-coupe-back.png", + import.meta.url, + ).href, + "glass-coupe-front.png": new URL( + "../assets/glass-coupe-front.png", + import.meta.url, + ).href, + "glass-coupe-mask.png": new URL( + "../assets/glass-coupe-mask.png", + import.meta.url, + ).href, + "glass-highball-back.png": new URL( + "../assets/glass-highball-back.png", + import.meta.url, + ).href, + "glass-highball-front.png": new URL( + "../assets/glass-highball-front.png", + import.meta.url, + ).href, + "glass-highball-mask.png": new URL( + "../assets/glass-highball-mask.png", + import.meta.url, + ).href, + "glass-margarita-back.png": new URL( + "../assets/glass-margarita-back.png", + import.meta.url, + ).href, + "glass-margarita-front.png": new URL( + "../assets/glass-margarita-front.png", + import.meta.url, + ).href, + "glass-margarita-mask.png": new URL( + "../assets/glass-margarita-mask.png", + import.meta.url, + ).href, + "glass-rocks-back.png": new URL( + "../assets/glass-rocks-back.png", + import.meta.url, + ).href, + "glass-rocks-front.png": new URL( + "../assets/glass-rocks-front.png", + import.meta.url, + ).href, + "glass-rocks-mask.png": new URL( + "../assets/glass-rocks-mask.png", + import.meta.url, + ).href, + "ice-cube.png": new URL("../assets/ice-cube.png", import.meta.url).href, + "rim-highlight.png": new URL( + "../assets/rim-highlight.png", + import.meta.url, + ).href, + "rim-salt-highball.png": new URL( + "../assets/rim-salt-highball.png", + import.meta.url, + ).href, + "rim-salt-margarita.png": new URL( + "../assets/rim-salt-margarita.png", + import.meta.url, + ).href, + "splash-dot.png": new URL("../assets/splash-dot.png", import.meta.url).href, + "star-mote.png": new URL("../assets/star-mote.png", import.meta.url).href, + "stream.png": new URL("../assets/stream.png", import.meta.url).href, +}; + +export async function loadMixerAssets( + Assets: typeof import("pixi.js").Assets, +): Promise { + // Site CSP has no worker-src / blob: — Pixi's default ImageBitmap workers + // would fail the mount and leave the CSS still on screen. + Assets.setPreferences({ preferWorkers: false }); + + const assets = Object.entries(MIXER_ASSET_URLS).map(([alias, src]) => ({ + alias, + src, + })); + + await Assets.load(assets); +} diff --git a/src/components/demos/cocktail-mixer/pixi/bar-stage.tsx b/src/components/demos/cocktail-mixer/pixi/bar-stage.tsx new file mode 100644 index 0000000..deb90ae --- /dev/null +++ b/src/components/demos/cocktail-mixer/pixi/bar-stage.tsx @@ -0,0 +1,145 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import "./csp"; +import type { Application } from "pixi.js"; +import type { PourSnapshot } from "../pour-script"; +import type { Cocktail } from "../types"; +import { createMixerApp, destroyMixerApp } from "./application"; +import { loadMixerAssets } from "./assets"; +import { CssStill } from "./css-still"; +import { PourDirector } from "./pour-director"; +import { createRig } from "./rig"; +import type { MixerRig } from "./rig"; + +export interface BarStageProps { + cocktail: Cocktail; + reducedMotion: boolean; + onSnapshot: (snapshot: PourSnapshot) => void; +} + +export function BarStage({ + cocktail, + reducedMotion, + onSnapshot, +}: BarStageProps) { + const canvasHostRef = useRef(null); + const onSnapshotRef = useRef(onSnapshot); + const [showCssStill, setShowCssStill] = useState(true); + const [activeRig, setActiveRig] = useState<{ + cocktail: Cocktail; + reducedMotion: boolean; + rig: MixerRig; + } | null>(null); + onSnapshotRef.current = onSnapshot; + const rig = + activeRig?.cocktail === cocktail && + activeRig.reducedMotion === reducedMotion + ? activeRig.rig + : null; + const ready = !showCssStill && rig !== null; + + useEffect(() => { + setShowCssStill(true); + setActiveRig(null); + + let cancelled = false; + let app: Application | null = null; + let mountedRig: MixerRig | null = null; + + const doneSnapshot: PourSnapshot = { + pouredCount: cocktail.ingredients.length, + activePour: null, + allDone: true, + }; + + function destroyApp(): void { + if (!app) return; + app.ticker.remove(tickRig); + mountedRig?.destroy(); + mountedRig = null; + destroyMixerApp(app); + app = null; + } + + function tickRig(): void { + if (app && mountedRig) { + mountedRig.tick(app.ticker.deltaMS); + } + } + + async function mountStage(): Promise { + try { + const PIXI = await import("pixi.js"); + if (cancelled) return; + + app = await createMixerApp(PIXI); + if (cancelled) { + destroyApp(); + return; + } + + await loadMixerAssets(PIXI.Assets); + if (cancelled) { + destroyApp(); + return; + } + + const canvasHost = canvasHostRef.current; + if (!canvasHost) { + destroyApp(); + return; + } + + mountedRig = createRig(app.stage, cocktail, { reducedMotion }); + if (reducedMotion) { + mountedRig.applyFinished(cocktail); + onSnapshotRef.current(doneSnapshot); + } else { + app.ticker.add(tickRig); + } + + app.canvas.style.width = "100%"; + app.canvas.style.maxWidth = "280px"; + app.canvas.style.height = "auto"; + canvasHost.replaceChildren(app.canvas); + setActiveRig({ cocktail, reducedMotion, rig: mountedRig }); + setShowCssStill(false); + } catch (error) { + console.error("Cocktail mixer Pixi mount failed", error); + destroyApp(); + if (cancelled) return; + + setActiveRig(null); + setShowCssStill(true); + onSnapshotRef.current(doneSnapshot); + } + } + + void mountStage(); + + return () => { + cancelled = true; + destroyApp(); + }; + }, [cocktail, reducedMotion]); + + return ( +
+ {showCssStill ? : null} +
+ {ready && !reducedMotion && rig ? ( + + ) : null} +
+ ); +} diff --git a/src/components/demos/cocktail-mixer/pixi/bottle-pivot.ts b/src/components/demos/cocktail-mixer/pixi/bottle-pivot.ts new file mode 100644 index 0000000..3b8cfc4 --- /dev/null +++ b/src/components/demos/cocktail-mixer/pixi/bottle-pivot.ts @@ -0,0 +1,13 @@ +export function bottlePivot( + textureWidth: number, + textureHeight: number, + cssWidth: number, + cssHeight: number, + neckX: number, + neckY: number, +): { x: number; y: number } { + return { + x: neckX * (textureWidth / cssWidth), + y: neckY * (textureHeight / cssHeight), + }; +} diff --git a/src/components/demos/cocktail-mixer/pixi/csp.ts b/src/components/demos/cocktail-mixer/pixi/csp.ts new file mode 100644 index 0000000..36e5863 --- /dev/null +++ b/src/components/demos/cocktail-mixer/pixi/csp.ts @@ -0,0 +1,33 @@ +import "pixi.js/unsafe-eval"; +import { ParticleBuffer } from "pixi.js"; + +type ParticleUpdate = ReturnType; +type ParticleProperties = Parameters< + ParticleBuffer["generateParticleUpdate"] +>[0]; + +let installed = false; + +/** + * Pixi's CSP-safe particle polyfill calls `properties.filter`, but + * ParticleBuffer passes a Record. Convert to an array so splash/foam/motes + * can render without `'unsafe-eval'` in site CSP. + */ +export function installMixerPixiCsp(): void { + if (installed) return; + installed = true; + + const original = ParticleBuffer.prototype.generateParticleUpdate; + ParticleBuffer.prototype.generateParticleUpdate = + function generateParticleUpdate( + this: ParticleBuffer, + properties: ParticleProperties, + ): ParticleUpdate { + const list = Array.isArray(properties) + ? properties + : Object.values(properties); + return original.call(this, list as ParticleProperties); + }; +} + +installMixerPixiCsp(); diff --git a/src/components/demos/cocktail-mixer/pixi/css-still.tsx b/src/components/demos/cocktail-mixer/pixi/css-still.tsx new file mode 100644 index 0000000..2418499 --- /dev/null +++ b/src/components/demos/cocktail-mixer/pixi/css-still.tsx @@ -0,0 +1,144 @@ +import type { CSSProperties } from "react"; +import { GARNISH_POSE, garnishPlates } from "../garnish-map"; +import { + GLASS_BOUNDS, + GLASS_RECT, + STAGE, + bowlWidthAt, + foamOffsetsForWidth, + rimGarnishPoint, +} from "../glass-bounds"; +import type { Cocktail } from "../types"; +import { MIXER_ASSET_URLS } from "./assets"; + +function percent(value: number, total: number): string { + return `${(value / total) * 100}%`; +} + +function imageLayer(url: string): CSSProperties { + return { + position: "absolute", + inset: 0, + backgroundImage: `url("${url}")`, + backgroundPosition: "center", + backgroundRepeat: "no-repeat", + backgroundSize: "100% 100%", + }; +} + +export function CssStill({ cocktail }: { cocktail: Cocktail }) { + const glassFrame: CSSProperties = { + position: "absolute", + left: percent(GLASS_RECT.x, STAGE.width), + top: percent(GLASS_RECT.y, STAGE.height), + width: percent(GLASS_RECT.width, STAGE.width), + height: percent(GLASS_RECT.height, STAGE.height), + }; + const bounds = GLASS_BOUNDS[cocktail.glass]; + const plates = garnishPlates(cocktail.garnishType, cocktail.glass); + const saltPlate = cocktail.garnishType.startsWith("salt_") + ? plates.find((plate) => plate.startsWith("rim-salt-")) + : undefined; + const garnishes = plates.filter((plate) => !plate.startsWith("rim-salt-")); + const maskUrl = MIXER_ASSET_URLS[`glass-${cocktail.glass}-mask.png`]; + const frontUrl = MIXER_ASSET_URLS[`glass-${cocktail.glass}-front.png`]; + const garnishPoint = rimGarnishPoint(cocktail.glass); + const foamOffsets = foamOffsetsForWidth( + bowlWidthAt(cocktail.glass, bounds.liquidTop), + ); + + return ( +