2026 10 13 hero builder - xopherdeep/do-it-for-the-xp GitHub Wiki

Hero Builder 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 two 3D look editors (Add/Edit Profile LookModal, portal MyLook page) with one full-screen, Mii-style Hero Builder modal: its own bottom nav, live picture tiles, a starter lineup, undo, random and poses.

Architecture: Pure rules in lookEditor.ts (tabs, tile looks, random looks, undo history) and heroCamera.ts (frames, snapshot rendering). One hidden WebGL renderer (useLookThumbnails) draws every tile. Four small presentational parts (Stage, Tray, Nav, Starter Lineup) emit events; XpHeroBuilder.vue owns the draft, history and sounds; XpHeroBuilderModal.vue is the ion-modal shell. Hosts decide what Save does.

Tech Stack: Vue 3 (<script setup lang="ts">), Ionic 7, three.js, Pinia, Jest + @vue/test-utils, Cypress 8 (Chrome).

Spec: docs/superpowers/specs/2026-10-13-hero-builder-design.md (read it before your task).

Global Constraints

  • Tools: chemx read / chemx q for reading and searching; gates are chemx test <spec>, chemx typecheck, chemx lint. Never run pnpm typecheck|lint|test|jest or npx jest raw. If a .vue page with src="./X.ts" gives stale results, npx jest <spec> --no-cache once is allowed.
  • Do not git add, git commit, git stash or git checkout. The checkout is shared by many sessions; the coordinator commits each reviewed task with a private index.
  • Edit only the files your task lists. Other sessions are editing other parts of the tree.
  • New components: <script setup lang="ts">; styles @use "@/styles/themes/earthbound.scss" as *; (dark purple $eb-color-dark-purple, pale yellow $eb-color-pale-yellow, cream $eb-color-cream, $eb-font-status), square corners (border-radius: 1px), Font Awesome duotone fad icons.
  • Sounds only through play$fx(key) from @/assets/fx, keys: select, cursor, yes, no, closeMenu. Only XpHeroBuilder.vue plays sounds; the parts emit.
  • Exact copy: "Pick a starting hero", "Build your hero", "Roll 9 more", "Use a 2D portrait instead", "2D", "Leave without saving?", "Keep building", "Leave", "Done", "Looking good!".
  • Exact numbers: stage 46% of the space under the header; corner buttons 44px; style tiles 4 columns at 96px thumbnails; lineup 3 x 3 at 128px; thumbnail cache 200; 3 thumbnails per animation frame; hop 450ms; victory 800ms before saving; camera ease 300ms; undo history cap 50.
  • Controls in thumb reach; never touch the portal's shared bottom nav (XpBottomNav.vue).
  • Component specs stub XpHeroModel.vue with jest.mock (no WebGL in jsdom); the pattern is at the top of tests/unit/XpLookEditor.spec.ts.
  • Deviation from the spec, decided here: the thumbnail queue draws newest requests first (LIFO) instead of debouncing colour changes; stale requests just draw later.

Review Focus

  1. Tapping Done twice, or tapping tiles during the 800ms victory pose → exactly one save, draft frozen once Done starts (Task 6 test "saves once").
  2. No WebGL (school Chromebook, old phone) → tiles and lineup show names, Done still saves with no image (Tasks 5 and 6 fallback tests).
  3. A saved look from before (missing fields, or carrying leftHand) → merged over DEFAULT_LOOK, hands stripped, counts as clean, X closes with no alert (Task 6 test "opens an old look clean").
  4. Starter lineup with a degenerate random source → still returns nine looks and never hangs (Task 1 test "terminates").
  5. Android back or swipe-down with unsaved changes → the "Leave without saving?" alert; after Done, no alert (Task 6 confirmLeave tests).

Task 1: Look rules (tabs, tile looks, random looks, undo history)

Needs: standard. Board deps: none.

Files:

  • Modify: src/components/organisms/HeroModel/lookEditor.ts
  • Create: tests/unit/lookEditor.spec.ts (move the lookEditor helpers describe block out of tests/unit/XpLookEditor.spec.ts into it; leave the rest of that file alone, Task 7 deletes it)

Interfaces:

  • Produces:

    • type LookTabId = "body" | "hair" | "face" | "outfit" | "hat"; type LookFocus = "bust" | "full"; type Rng = () => number
    • interface LookTab { id: LookTabId; label: string; icon: string; focus: LookFocus; bareHead?: boolean; styles?: { key: keyof HeroLook; options: string[] }; colors: ColorGroup[] }
    • TABS: LookTab[] in order body, hair, face, outfit, hat. Focus: body full, hair bust + bareHead: true, face bust, outfit full, hat bust. Outfit colour groups relabelled "Main" and "Trim"; others keep their labels. Icons unchanged.
    • tabById(id: LookTabId): LookTab
    • tileLook(draft: HeroLook, tab: LookTab, option: string): HeroLook — draft with tab.styles.key = option, plus hat: "none" when tab.bareHead
    • stageLook(draft: HeroLook, tab: LookTab): HeroLook — draft with hat: "none" when tab.bareHead, else the draft unchanged (same object is fine)
    • NATURAL_HAIR: string[] = ["#5a3a1e", "#2b2b3a", "#1f1f1f", "#e8b22b", "#e3732c", "#8d8d8d", "#e8d5a8"]
    • EYE_COLORS: string[] = ["#2b2b3a", "#1f1f1f", "#5a3a1e", "#2f5fb3", "#3f7d4e", "#6b3fa0"]
    • randomLook(rng: Rng = Math.random): HeroLook — skin/hair/face/outfit uniform from their lists; hairColor from NATURAL_HAIR when rng() < 0.75 else from LOOK_COLORS; eyeColor from EYE_COLORS; outfitColor from LOOK_COLORS; outfitAccent from LOOK_COLORS minus outfitColor; hat is "none" when rng() < 0.5 else from HAT_STYLES minus "none"; hatColor from LOOK_COLORS
    • starterLineup(count: number, rng: Rng = Math.random): HeroLook[] — count looks, no two sameLook; gives up after count * 20 draws and pads with what it drew
    • sameLook(a: HeroLook, b: HeroLook): boolean — compares the ten look fields only (hands ignored)
    • pushHistory(history: HeroLook[], look: HeroLook, cap = 50): HeroLook[] — new array; no-op (same contents) when look is sameLook as the last entry; drops the oldest past cap
    • popHistory(history: HeroLook[]): { history: HeroLook[]; look: HeroLook | undefined }
    • Existing styleLabel, handIcon, withHands, withoutHands, Swatch, ColorGroup, HandSlots stay as they are.
  • Step 1: Write the failing tests in tests/unit/lookEditor.spec.ts (keep the moved helper tests). Use a seeded rng in the spec:

const seeded = (seed: number): Rng => () => ((seed = (seed * 1103515245 + 12345) % 2147483648) / 2147483648);

it("gives every tab a focus and only Body has no styles", () => {
  expect(TABS.map((t) => [t.id, t.focus])).toEqual([["body","full"],["hair","bust"],["face","bust"],["outfit","full"],["hat","bust"]]);
  expect(TABS.filter((t) => !t.styles).map((t) => t.id)).toEqual(["body"]);
  expect(tabById("outfit").colors.map((c) => c.label)).toEqual(["Main", "Trim"]);
});
it("draws hair tiles bare-headed and other tiles with the hat on", () => {
  const draft = { ...DEFAULT_LOOK, hat: "crown" as const };
  expect(tileLook(draft, tabById("hair"), "mohawk")).toMatchObject({ hair: "mohawk", hat: "none" });
  expect(tileLook(draft, tabById("face"), "wink")).toMatchObject({ face: "wink", hat: "crown" });
  expect(stageLook(draft, tabById("hair")).hat).toBe("none");
  expect(stageLook(draft, tabById("hat")).hat).toBe("crown");
  expect(draft.hat).toBe("crown"); // the draft itself is never changed
});
it("rolls valid looks that follow the taste rules", () => {
  const rng = seeded(7); const looks = Array.from({ length: 500 }, () => randomLook(rng));
  for (const l of looks) {
    expect(HAIR_STYLES).toContain(l.hair); expect(OUTFIT_STYLES).toContain(l.outfit); expect(HAT_STYLES).toContain(l.hat);
    expect(FACE_STYLES).toContain(l.face); expect(Object.keys(SKIN_TONES)).toContain(l.skin);
    expect(EYE_COLORS).toContain(l.eyeColor); expect(l.outfitAccent).not.toBe(l.outfitColor);
  }
  const bare = looks.filter((l) => l.hat === "none").length / 500;
  expect(bare).toBeGreaterThan(0.4); expect(bare).toBeLessThan(0.6);
  expect(looks.filter((l) => NATURAL_HAIR.includes(l.hairColor)).length / 500).toBeGreaterThan(0.65);
});
it("deals nine different starters", () => {
  const lineup = starterLineup(9, seeded(3));
  expect(lineup).toHaveLength(9);
  for (let i = 0; i < 9; i++) for (let j = i + 1; j < 9; j++) expect(sameLook(lineup[i], lineup[j])).toBe(false);
});
it("terminates with a random source that never changes", () => {
  expect(starterLineup(9, () => 0)).toHaveLength(9);
});
it("keeps an undo history that ignores repeats and stops at the cap", () => {
  const a = DEFAULT_LOOK, b = { ...DEFAULT_LOOK, hair: "bob" as const };
  expect(pushHistory([a], { ...a, leftHand: "fa-sword" })).toEqual([a]);
  expect(pushHistory([a], b)).toEqual([a, b]);
  const full = Array.from({ length: 50 }, (_, i) => ({ ...a, hairColor: `#0000${String(i).padStart(2, "0")}` }));
  const next = pushHistory(full, b); expect(next).toHaveLength(50); expect(next[49]).toBe(b); expect(next[0]).toBe(full[1]);
  expect(popHistory([a, b])).toEqual({ history: [a], look: b });
  expect(popHistory([])).toEqual({ history: [], look: undefined });
});
  • Step 2: Run chemx test tests/unit/lookEditor.spec.ts — expected: FAIL (missing exports).
  • Step 3: Implement the Interfaces above in lookEditor.ts. Keep it free of Vue and Ionic.
  • Step 4: Run chemx test tests/unit/lookEditor.spec.ts — expected: PASS. Then chemx typecheck — expected: no new errors (the existing XpLookEditor.vue still compiles because TABS keeps its old fields).
  • Step 5: Report the changed files to the coordinator (who commits: feat(hero-builder): look rules for tabs, tiles, random heroes and undo).

Task 2: Camera frames, focus easing, hop and flick

Needs: standard. Board deps: none.

Files:

  • Create: src/components/organisms/HeroModel/heroCamera.ts
  • Modify: src/components/organisms/HeroModel/useHeroScene.ts, src/components/organisms/HeroModel/XpHeroModel.vue, src/components/organisms/HeroModel/voxel/animate.ts, src/lib/hero-look/types.ts
  • Test: create tests/unit/heroCamera.spec.ts; extend src/components/organisms/HeroModel/__tests__/voxel.spec.ts (describe("animateHero"))

Interfaces:

  • Produces (in heroCamera.ts, moved out of useHeroScene.ts with behaviour unchanged unless stated):

    • FOV = 18, DEFAULT_YAW = 0.5, FRAME_WIDTH = 15
    • type SnapshotFraming = "bust" | "full" (re-exported from useHeroScene.ts so existing imports keep working)
    • interface Frame { centerY: number; height: number; width?: number } — width defaults to FRAME_WIDTH in placeCamera
    • placeCamera(camera: THREE.PerspectiveCamera, frame: Frame, aspect: number): void
    • fullFrame(hero: THREE.Object3D): Frame, bustFrame(hero: THREE.Object3D): Frame (unchanged; no width, so avatar snapshots keep today's framing)
    • stageFrame(hero: THREE.Object3D, focus: SnapshotFraming): Frame — full → fullFrame; bust → { ...bustFrame(hero), width: 11 } so the live stage really zooms in
    • lerpFrame(from: Frame, to: Frame, t: number): Frame — t clamped to 0..1, each field linear; missing width counts as FRAME_WIDTH
    • easeOutCubic(t: number): number
    • addHeroLights(scene: THREE.Scene): void — the hemisphere + sun lights useHeroScene sets up today
    • pixelsToDataUrl(pixels: Uint8Array, size: number): string — the flip and un-premultiply loop from snapshot; "" when there is no 2D context
    • renderHeroSnapshot(renderer: THREE.WebGLRenderer, scene: THREE.Scene, hero: THREE.Object3D, size: number, framing: SnapshotFraming): string — today's snapshot body: idle pose at t=0, yaw DEFAULT_YAW - 0.1, render target, read pixels, restore rotation
  • Produces (useHeroScene.ts): HeroSceneOptions gains focus?: Ref<SnapshotFraming>. The live camera starts on stageFrame(hero, focus ?? "full"); when focus changes it eases from the current frame to the new one over 300ms with easeOutCubic; rebuild snaps to the current focus's frame. HeroScene gains flick(velocity?: number): void (sets the coasting spin; default 9, which is about one turn).

  • Produces (XpHeroModel.vue): prop focus: SnapshotFraming (default "full") passed to the scene; exposes snapshot(size, framing) (unchanged) and flick(velocity?).

  • Produces (types.ts / animate.ts): HeroAnimation gains "hop": during the first 0.45s the rig rises 1.2 * |sin(π t / 0.45)| and both arms flick out a little; from 0.45s on it is the rest pose.

  • Step 1: Write the failing tests. tests/unit/heroCamera.spec.ts:

it("lerpFrame starts on from, ends on to, and clamps", () => {
  const a = { centerY: 8, height: 20 }, b = { centerY: 12, height: 13, width: 11 };
  expect(lerpFrame(a, b, 0)).toEqual({ centerY: 8, height: 20, width: 15 });
  expect(lerpFrame(a, b, 1)).toEqual({ centerY: 12, height: 13, width: 11 });
  expect(lerpFrame(a, b, 2)).toEqual(lerpFrame(a, b, 1));
});
it("a narrower frame puts the camera closer on a square stage", () => {
  const cam = () => new THREE.PerspectiveCamera();
  const wide = cam(), narrow = cam();
  placeCamera(wide, { centerY: 10, height: 13 }, 1); placeCamera(narrow, { centerY: 10, height: 13, width: 11 }, 1);
  expect(narrow.position.length()).toBeLessThan(wide.position.length());
});
it("stageFrame zooms to the head for bust and keeps the whole hero for full", () => {
  const hero = buildHero(DEFAULT_LOOK);
  expect(stageFrame(hero, "full")).toEqual(fullFrame(hero));
  expect(stageFrame(hero, "bust")).toEqual({ ...bustFrame(hero), width: 11 });
});
it("pixelsToDataUrl flips rows and un-premultiplies", () => { /* 2x2 pixels: bottom-left opaque red, others transparent; spy on CanvasRenderingContext2D.putImageData (jsdom has no canvas: mock getContext to return { createImageData: (w,h)=>({data:new Uint8ClampedArray(w*h*4)}), putImageData: jest.fn() } and toDataURL to return "data:image/png;x"); assert the red pixel lands on the top-left of the image data and the result is "data:image/png;x" */ });

In voxel.spec.ts describe("animateHero"): it("hops up and lands back on the rest pose") — after animateHero(hero, "hop", 0.225) the rig group's position.y is at least rest + 1; after animateHero(hero, "hop", 0.6) it equals the rest y (use the same rest-pose helpers the existing animation tests use).

  • Step 2: Run chemx test tests/unit/heroCamera.spec.ts and chemx test src/components/organisms/HeroModel/__tests__/voxel.spec.ts — expected: FAIL.
  • Step 3: Implement. Move the helpers into heroCamera.ts; useHeroScene imports them, keeps its public API, and its snapshot becomes a call to renderHeroSnapshot. Add the focus watcher (store tweenFrom, tweenTo, tweenStart; tick calls placeCamera with lerpFrame(tweenFrom, tweenTo, easeOutCubic(elapsed / 0.3)) until done) and flick. Add "hop" to the union and its case in animateHero.
  • Step 4: Run both specs (PASS), then chemx test tests/unit/XpLookEditor.spec.ts tests/unit/HeroLook.spec.ts (still PASS) and chemx typecheck (no new errors; if a switch over HeroAnimation elsewhere is now non-exhaustive, give "hop" the "idle" branch there).
  • Step 5: Report files (commit: feat(hero-builder): shared camera frames, focus easing, hop and flick).

Task 3: The thumbnail renderer

Needs: standard. Board deps: Task 2.

Files:

  • Create: src/components/organisms/HeroModel/useLookThumbnails.ts
  • Test: tests/unit/useLookThumbnails.spec.ts

Interfaces:

  • Consumes: addHeroLights, renderHeroSnapshot, SnapshotFraming (Task 2); buildHero, disposeHero (voxel/build.ts); withoutHands (lookEditor.ts).

  • Produces:

    • type ThumbDraw = (look: HeroLook, framing: SnapshotFraming, size: number) => string
    • interface LookThumbnailer { supported: Ref<boolean>; get(look: HeroLook, framing: SnapshotFraming, size?: number): string; dispose(): void } — get is reactive (reads a reactive map, so a template that calls it re-renders when the picture lands); returns the cached data URL or "" and queues a draw; default size 96
    • createLookThumbnailer(options?: { draw?: ThumbDraw | null; perFrame?: number; cacheSize?: number; schedule?: (run: () => void) => void }): LookThumbnailer — defaults: draw lazily from createThreeDraw(), perFrame 3, cacheSize 200, schedule requestAnimationFrame. Key: `${framing}|${size}|${JSON.stringify(withoutHands(look))}`. Queue is newest-first (a repeated get moves the key to the front). Each scheduled run draws up to perFrame keys and schedules another run while keys remain. A draw that throws, or no draw available, sets supported false, clears the queue, and every later get returns "". Cache past cacheSize drops the oldest entry. After dispose() nothing more draws and the renderer is freed.
    • createThreeDraw(): { draw: ThumbDraw; dispose: () => void } | null — one THREE.WebGLRenderer on a detached canvas (alpha: true, powerPreference: "low-power"), a scene with addHeroLights; draw builds the hero, renders it with renderHeroSnapshot, removes and disposes it. null when the renderer cannot start. dispose calls renderer.dispose() and forceContextLoss().
    • useLookThumbnails(): LookThumbnailer — createLookThumbnailer() plus onBeforeUnmount(dispose).
  • Step 1: Write the failing tests (manual scheduler: const runs: (() => void)[] = []; const schedule = (r) => runs.push(r); const flush = () => runs.shift()?.();):

    • it("returns nothing at first, then the picture once a frame has run") — get(DEFAULT_LOOK, "bust") is "", after flush() it equals draw's return; draw called with (DEFAULT_LOOK-without-hands, "bust", 96).
    • it("draws a look once however often it is asked for") — two gets of equal looks (one with leftHand), one draw call.
    • it("draws three per frame, newest first") — four different looks requested in order a,b,c,d; after one flush() draw saw d,c,b; after the next, a.
    • it("gives up and reports unsupported when drawing throws") — draw throws once; supported.value false; later get returns "" and draw is not called again.
    • it("forgets the oldest picture past the cache size") — cacheSize: 2, three looks drawn; the first get again queues a new draw.
    • it("stops drawing after dispose").
    • it("reports unsupported in a browser with no WebGL") — createLookThumbnailer({ schedule }) with the default draw in jsdom: after get + flush, supported.value is false.
  • Step 2: Run chemx test tests/unit/useLookThumbnails.spec.ts — expected: FAIL.

  • Step 3: Implement the Interfaces above.

  • Step 4: Run the spec (PASS) and chemx typecheck.

  • Step 5: Report files (commit: feat(hero-builder): one hidden renderer draws the look tiles).


Task 4: Stage and bottom nav

Needs: standard. Board deps: Tasks 1, 2.

Files:

  • Create: src/components/organisms/HeroModel/builder/XpBuilderStage.vue, src/components/organisms/HeroModel/builder/XpBuilderNav.vue
  • Test: tests/unit/XpBuilderStage.spec.ts, tests/unit/XpBuilderNav.spec.ts

Interfaces:

  • Consumes: XpHeroModel with focus prop, flick(), snapshot() (Task 2); TABS, LookTabId, LookFocus (Task 1).

  • Produces:

    • XpBuilderStage — props look: HeroLook (already the display look: hands and bare head applied by the caller), focus: LookFocus, animation: HeroAnimation, canUndo: boolean; emits undo, random, pose; exposes snapshot(size?: number): string and flick(): void. Renders XpHeroModel (interactive) filling a checker stage (bg-slide bg-slide-equipment look, as LookModal has today) and three 44px round buttons: bottom-left Undo (fad fa-undo, aria-label="Undo", data-cy="builder-undo", dimmed when !canUndo but still emits), bottom-right Random (fad fa-dice, aria-label="Random hero", data-cy="builder-random"), top-right Pose (fad fa-running, aria-label="Change pose", data-cy="builder-pose").
    • XpBuilderNav — props modelValue: LookTabId; emits update:modelValue(id) (only when it changes), done. A role="tablist" (aria-label="What to change") of one role="tab" button per TABS entry (icon over StatusPlz label, aria-selected), then after a small gap a gold Done button (fad fa-check-circle, label "Done", aria-label="Done, save my hero", data-cy="builder-done"). Active tab pale yellow on dark purple.
  • Step 1: Write the failing tests.

    • Nav: it("has one tab per category in order, then Done") (tab texts ["Body","Hair","Face","Outfit","Hat"], Done outside the tablist); it("marks the active tab and emits the new one") (click Hair → update:modelValue ["hair"]; click the active tab → no emit); it("emits done").
    • Stage: it("draws the look it is given with the focus and animation") (stub props); it("emits undo, random and pose from the corner buttons"); it("still emits undo when there is nothing to undo") (canUndo: false); it("hands out the model's snapshot").
  • Step 2: Run chemx test tests/unit/XpBuilderNav.spec.ts and chemx test tests/unit/XpBuilderStage.spec.ts — FAIL.

  • Step 3: Implement both components (scoped SCSS in the SFC).

  • Step 4: Run both specs — PASS; chemx typecheck.

  • Step 5: Report files (commit: feat(hero-builder): stage with undo, random and pose; the builder's own bottom nav).


Task 5: Tray and starter lineup

Needs: standard. Board deps: Tasks 1, 3.

Files:

  • Create: src/components/organisms/HeroModel/builder/XpBuilderTray.vue, src/components/organisms/HeroModel/builder/XpStarterLineup.vue
  • Test: tests/unit/XpBuilderTray.spec.ts, tests/unit/XpStarterLineup.spec.ts

Interfaces:

  • Consumes: LookTab, tileLook, styleLabel, starterLineup, Rng (Task 1); LookThumbnailer (Task 3).

  • Produces:

    • XpBuilderTray — props modelValue: HeroLook (the draft, no hands), tab: LookTab, mode: "style" | "colour", thumbnails: LookThumbnailer; emits update:modelValue(look) (the draft with exactly one key changed), update:mode(mode). With tab.styles: a Style | Colour segmented switch (role="radiogroup" or two aria-pressed buttons) and either the style grid or the colour grids. Without tab.styles: no switch, colour grids only. Style grid: 4 columns of square buttons, one per option, aria-label=styleLabel(option), aria-pressed, data-cy="look-tile", data-option=option; inside, <img> of thumbnails.get(tileLook(modelValue, tab, option), tab.focus, 96) or, when that is "", the styleLabel text. Colour grids: one labelled group per tab.colors entry, swatch buttons as today (aria-label = swatch name). The tray scrolls inside itself.
    • XpStarterLineup — props thumbnails: LookThumbnailer, allowPortrait: boolean, rng?: Rng; emits pick(look: HeroLook), portrait. A 3 x 3 grid of buttons (data-cy="starter-hero", aria-label="Hero 1"…"Hero 9") showing thumbnails.get(look, "full", 128) or the label; a footer with "Roll 9 more" (fad fa-dice, data-cy="starter-roll") that re-deals with starterLineup(9, rng), and, when allowPortrait, "Use a 2D portrait instead" (data-cy="look-use-portrait").
  • Step 1: Write the failing tests (fake thumbnailer: { supported: ref(true), get: jest.fn(() => "data:image/png;t"), dispose: jest.fn() }, and one whose get returns "").

    • Tray: it("hides the Style/Colour switch on Body and shows the skin tones"); it("shows one picture tile per hair style, drawn bare-headed at head framing") (10 tiles; get called with a look whose hat is "none" and "bust", 96); it("emits the draft with only that key changed when a tile is tapped"); it("emits the colour key from the Colour side") (Outfit, Colour, Trim swatch → outfitAccent); it("falls back to the option names when there is no picture"); it("switches sides through update:mode").
    • Lineup: it("deals nine heroes and picks the tapped one") (seeded rng; pick payload equals starterLineup(9, sameSeed)[4]); it("rolls nine new heroes"); it("offers the 2D portrait only when allowed"); it("shows labels when there are no pictures").
  • Step 2: Run both specs — FAIL.

  • Step 3: Implement both components.

  • Step 4: Run both specs — PASS; chemx typecheck.

  • Step 5: Report files (commit: feat(hero-builder): picture-tile tray and the starter lineup).


Task 6: The builder screen and its modal

Needs: standard. Board deps: Tasks 4, 5.

Files:

  • Create: src/components/organisms/HeroModel/XpHeroBuilder.vue, src/components/organisms/HeroModel/XpHeroBuilderModal.vue
  • Test: tests/unit/XpHeroBuilder.spec.ts

Interfaces:

  • Consumes: Tasks 1–5 (TABS, tabById, stageLook, withHands, withoutHands, sameLook, randomLook, pushHistory, popHistory, useLookThumbnails, the four parts); useTimers (src/hooks/useTimers.ts); alertController from @ionic/vue; play$fx; DEFAULT_LOOK, isHeroLook.

  • Produces:

    • XpHeroBuilder (mounted only while the modal is open, so each open starts fresh) — props look?: HeroLook | null, equipment?: HandSlots | null, allowPortrait?: boolean (default false); emits save(look: HeroLook, image?: string), close-request, portrait; exposes confirmLeave(): Promise<boolean>.
      • Start: isHeroLook(look) → editor phase, draft = withoutHands({ ...DEFAULT_LOOK, ...look }); else lineup phase, draft = DEFAULT_LOOK. startLook = that draft. Tab body, mode style, pose idle, history [].
      • Layout: ion-header (toolbar rpg-box: X (XpCloseButton) emits close-request; title "Pick a starting hero" / "Build your hero"; "2D" text button data-cy="look-use-portrait" when allowPortrait and in the editor), ion-content holding the lineup or stage + tray, ion-footer holding XpBuilderNav (editor only).
      • Stage gets look = withHands(stageLook(draft, tab), equipment), focus = tab.focus, animation = hopping ? "hop" : pose, canUndo = history.length > 0.
      • Events and sounds: tab change cursor, then (after nextTick) focus moves to the tray's first button; mode change cursor; tray update (when not sameLook) → history pushHistory(history, draft), draft = new, hop, select; random → push, randomLook(), stage.flick(), select; undo → popHistory: a look → draft, hop, cursor; none → no; pose → next of ["idle","wave","dance","victory"], cursor; lineup pick → draft = look, picked = true, editor phase on body, hop, select; lineup/2D portrait → select, left = true, emit portrait.
      • Hop: hopping = true, cleared after 450ms with useTimers().startTimeout.
      • Done: ignored if saving; saving = true (tiles, tabs, undo, random and pose do nothing from here), yes, pose victory, after 800ms take stage.snapshot(256) inside try/catch (a throw or "" → undefined), left = true, emit save(withoutHands(draft), image).
      • Dirty: picked || !sameLook(draft, startLook).
      • confirmLeave(): left or not dirty → true (plays closeMenu when not left); dirty → alertController.create({ header: "Leave without saving?", buttons: [{ text: "Keep building", role: "cancel" }, { text: "Leave", role: "destructive" }] }), present, resolve true only on "destructive" (then closeMenu).
    • XpHeroBuilderModal — props isOpen: boolean, look?: HeroLook | null, equipment?: HandSlots | null, allowPortrait?: boolean; emits save(look, image?), close, portrait. An ion-modal with class="hero-builder", full screen (--width/--height: 100%, no radius, dark purple), :can-dismiss="() => builder?.confirmLeave() ?? true", @didDismiss="emit('close')". Builder save → emit save then dismiss; close-request → modal.$el.dismiss() (so canDismiss asks); portrait → emit portrait then dismiss.
  • Step 1: Write the failing tests against XpHeroBuilder (stub XpHeroModel as in the Global Constraints; mock @/assets/fx play$fx; mock useLookThumbnails to a fake; mock alertController.create to return { present: jest.fn(), onDidDismiss: () => Promise.resolve({ role }) }; use jest fake timers):

    • it("opens on the lineup for a hero with no look and on Body after a pick")
    • it("opens straight into the editor for a hero with a look")
    • it("opens an old look clean") — look missing hatColor and carrying leftHand: "fa-sword": draft has DEFAULT_LOOK.hatColor, no leftHand; confirmLeave() resolves true with no alert.
    • it("takes the hat off on the stage while on Hair, but saves it") — draft hat: "crown", switch to Hair: stage look hat "none"; Done → saved look hat "crown".
    • it("undoes the last pick, and plays no when there is nothing left")
    • it("hops for 450ms after a pick") — stage animation "hop", then the pose after advancing 450ms.
    • it("saves once") — click Done twice and a tile in between; advance 800ms; exactly one save, look unchanged by the tile tap, play$fx saw yes once.
    • it("saves without a picture when the snapshot fails") — stub snapshot throws → save second arg undefined.
    • it("asks before leaving with changes and not after Done") — after a pick, confirmLeave() creates the alert and resolves per role (cancel → false, destructive → true); after Done completes, confirmLeave() resolves true without an alert.
    • it("shows the 2D link only when portraits are allowed")
    • it("moves focus to the tray when the tab changes")
  • Step 2: Run chemx test tests/unit/XpHeroBuilder.spec.ts — FAIL.

  • Step 3: Implement both components. Layout per the spec's Editor section: header ~56px, stage flex: 0 0 46% of the content, tray fills the rest, nav in the footer with padding-bottom: var(--ion-safe-area-bottom, 0px).

  • Step 4: Run the spec — PASS; chemx typecheck; chemx lint (no errors in the new files).

  • Step 5: Report files (commit: feat(hero-builder): the full-screen builder and its modal).


Task 7: Wire it in, retire the old editors, end-to-end

Needs: standard. Board deps: Task 6.

Files:

  • Create: src/hooks/useHeroBuilder.ts, tests/unit/useHeroBuilder.spec.ts
  • Modify: src/app/SideMenu/SwitchProfile/AddProfile/GamerCard.vue, src/app/Console/MyPortal/UserProfile/UserProfile.vue, src/app/Console/MyPortal/UserProfile/UserProfile.ts, src/app/Console/MyPortal/UserHud/components/UserProfileModal.vue, src/router/Console/my-portal.routes.ts, tests/e2e/specs/new-hero.spec.js, tests/e2e/specs/mylook.spec.js, tests/e2e/specs/visual.spec.js
  • Delete: src/app/Console/MyPortal/UserProfile/MyLook/ (all three files), src/app/SideMenu/SwitchProfile/AddProfile/components/LookModal.vue, src/components/organisms/HeroModel/XpLookEditor.vue, src/components/organisms/HeroModel/_XpLookEditor.scss, tests/unit/XpLookEditor.spec.ts (after Task 1 moved its helper tests)

Interfaces:

  • Consumes: XpHeroBuilderModal (Task 6); useUserStore().setLook(userId, look, image?), getUserById; getLook/isHeroLook; rpgToastOptions (src/hooks/useToast.ts) with toastController.

  • Produces: useHeroBuilder(userId: Ref<string | undefined>): { isOpen: Ref<boolean>; open(): void; close(): void; look: ComputedRef<HeroLook | undefined>; equipment: ComputedRef<HandSlots | null>; save(look: HeroLook, image?: string): Promise<void> } — look is the user's saved look only when isHeroLook (else undefined, which opens the lineup); save calls setLook, then shows rpgToastOptions("Looking good!").

  • Step 1: Write the failing test tests/unit/useHeroBuilder.spec.ts: it("saves the look on the hero and says so") (Pinia test store with a user; save calls setLook(id, look, image) and toastController.create with message "Looking good!"); it("offers no look for a hero who never built one").

  • Step 2: Run chemx test tests/unit/useHeroBuilder.spec.ts — FAIL. Step 3: implement the hook; Step 4: run — PASS.

  • Step 5: Wire the hosts. GamerCard: swap LookModal for XpHeroBuilderModal with :allow-portrait="true", same look, equipment, @save="saveLook", @portrait="usePortrait", @close="isLookOpen = false". UserProfile and UserProfileModal: destructure the hook so the refs are top-level setup returns (templates only unwrap top-level refs): const { isOpen: isBuilderOpen, open: openMyLook, close: closeBuilder, look: builderLook, equipment: builderEquipment, save: saveBuilderLook } = useHeroBuilder(userIdRef); openMyLook replaces the old router push (UserProfileModal no longer dismisses itself or routes; the builder stacks over it); each template gets <xp-hero-builder-modal :is-open="isBuilderOpen" :look="builderLook" :equipment="builderEquipment" @save="saveBuilderLook" @close="closeBuilder" />. Route: replace the my-look entry with { path: "my-look", redirect: (to) => ({ name: "my-profile", params: to.params }) }.

  • Step 6: Delete the retired files listed above; chemx q XpLookEditor, chemx q LookModal, chemx q MyLook — expected: no hits outside docs/.

  • Step 7: Update e2e. new-hero.spec.js: ion-modal.look-modal → ion-modal.hero-builder; the done click → pick a starter ([data-cy=starter-hero] first) then [data-cy=builder-done]; wait ≥1s for the victory pose before checking the modal hid; [data-cy=look-use-portrait] stays. mylook.spec.js: visit /my-portal/hero-1/my-profile, click the "My Look" button, wait for ion-modal.hero-builder canvas, click the Hair tab, click [data-cy=look-tile][data-option=mohawk], click [data-cy=builder-done], then assert user.look.hair === "mohawk" and the avatarImage PNG checks it has today. visual.spec.js: shot 11 uses the new selectors; shot 34 becomes a screen that opens the builder from My Profile.

  • Step 8: Run chemx typecheck, chemx lint, then the e2e specs against the owner's running dev server: pnpm vue-cli-service test:e2e --headless --browser chrome --url http://localhost:8100 --spec tests/e2e/specs/mylook.spec.js and the same for new-hero.spec.js — expected: all passing; attach the screenshot paths from tests/e2e/screenshots.

  • Step 9: Report files and screenshots (commit: feat(hero-builder): one builder everywhere; retire LookModal and the My Look page).


Task 8 (coordinator): review, gate, look at it

  • chemx verify on the whole tree; judge by typecheck + suite (the AST gate is red project-wide; critical/high counts must not rise).
  • Drive it in the browser pane at 390 x 844: new hero (lineup → pick → every tab → Done), existing hero from My Profile and from the profile modal, X with and without changes, no-change Done.
  • Commit each task with the private-index protocol; push only if the owner asks.
⚠️ **GitHub.com Fallback** ⚠️