2026 10 13 hero builder - xopherdeep/do-it-for-the-xp GitHub Wiki
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).
- Tools:
chemx read/chemx qfor reading and searching; gates arechemx test <spec>,chemx typecheck,chemx lint. Never runpnpm typecheck|lint|test|jestornpx jestraw. If a.vuepage withsrc="./X.ts"gives stale results,npx jest <spec> --no-cacheonce is allowed. - Do not
git add,git commit,git stashorgit 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 duotonefadicons. - Sounds only through
play$fx(key)from@/assets/fx, keys:select,cursor,yes,no,closeMenu. OnlyXpHeroBuilder.vueplays 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.vuewithjest.mock(no WebGL in jsdom); the pattern is at the top oftests/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.
- Tapping Done twice, or tapping tiles during the 800ms victory pose → exactly one
save, draft frozen once Done starts (Task 6 test "saves once"). - No WebGL (school Chromebook, old phone) → tiles and lineup show names, Done still saves with no image (Tasks 5 and 6 fallback tests).
- A saved look from before (missing fields, or carrying
leftHand) → merged overDEFAULT_LOOK, hands stripped, counts as clean, X closes with no alert (Task 6 test "opens an old look clean"). - Starter lineup with a degenerate random source → still returns nine looks and never hangs (Task 1 test "terminates").
- Android back or swipe-down with unsaved changes → the "Leave without saving?" alert; after Done, no alert (Task 6
confirmLeavetests).
Needs: standard. Board deps: none.
Files:
- Modify:
src/components/organisms/HeroModel/lookEditor.ts - Create:
tests/unit/lookEditor.spec.ts(move thelookEditor helpersdescribe block out oftests/unit/XpLookEditor.spec.tsinto 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: bodyfull, hairbust+bareHead: true, facebust, outfitfull, hatbust. 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 withtab.styles.key = option, plushat: "none"whentab.bareHead -
stageLook(draft: HeroLook, tab: LookTab): HeroLook— draft withhat: "none"whentab.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;hairColorfromNATURAL_HAIRwhenrng() < 0.75else fromLOOK_COLORS;eyeColorfromEYE_COLORS;outfitColorfromLOOK_COLORS;outfitAccentfromLOOK_COLORSminusoutfitColor;hatis"none"whenrng() < 0.5else fromHAT_STYLESminus"none";hatColorfromLOOK_COLORS -
starterLineup(count: number, rng: Rng = Math.random): HeroLook[]—countlooks, no twosameLook; gives up aftercount * 20draws 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) whenlookissameLookas the last entry; drops the oldest pastcap popHistory(history: HeroLook[]): { history: HeroLook[]; look: HeroLook | undefined }- Existing
styleLabel,handIcon,withHands,withoutHands,Swatch,ColorGroup,HandSlotsstay 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. Thenchemx typecheck— expected: no new errors (the existingXpLookEditor.vuestill compiles becauseTABSkeeps 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).
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; extendsrc/components/organisms/HeroModel/__tests__/voxel.spec.ts(describe("animateHero"))
Interfaces:
-
Produces (in
heroCamera.ts, moved out ofuseHeroScene.tswith behaviour unchanged unless stated):-
FOV = 18,DEFAULT_YAW = 0.5,FRAME_WIDTH = 15 -
type SnapshotFraming = "bust" | "full"(re-exported fromuseHeroScene.tsso existing imports keep working) -
interface Frame { centerY: number; height: number; width?: number }—widthdefaults toFRAME_WIDTHinplaceCamera 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—tclamped to 0..1, each field linear; missingwidthcounts asFRAME_WIDTH easeOutCubic(t: number): number-
addHeroLights(scene: THREE.Scene): void— the hemisphere + sun lightsuseHeroScenesets up today -
pixelsToDataUrl(pixels: Uint8Array, size: number): string— the flip and un-premultiply loop fromsnapshot;""when there is no 2D context -
renderHeroSnapshot(renderer: THREE.WebGLRenderer, scene: THREE.Scene, hero: THREE.Object3D, size: number, framing: SnapshotFraming): string— today'ssnapshotbody: idle pose at t=0, yawDEFAULT_YAW - 0.1, render target, read pixels, restore rotation
-
-
Produces (
useHeroScene.ts):HeroSceneOptionsgainsfocus?: Ref<SnapshotFraming>. The live camera starts onstageFrame(hero, focus ?? "full"); whenfocuschanges it eases from the current frame to the new one over 300ms witheaseOutCubic;rebuildsnaps to the current focus's frame.HeroScenegainsflick(velocity?: number): void(sets the coasting spin; default9, which is about one turn). -
Produces (
XpHeroModel.vue): propfocus: SnapshotFraming(default"full") passed to the scene; exposessnapshot(size, framing)(unchanged) andflick(velocity?). -
Produces (
types.ts/animate.ts):HeroAnimationgains"hop": during the first 0.45s the rig rises1.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.tsandchemx test src/components/organisms/HeroModel/__tests__/voxel.spec.ts— expected: FAIL. - Step 3: Implement. Move the helpers into
heroCamera.ts;useHeroSceneimports them, keeps its public API, and itssnapshotbecomes a call torenderHeroSnapshot. Add the focus watcher (storetweenFrom,tweenTo,tweenStart;tickcallsplaceCamerawithlerpFrame(tweenFrom, tweenTo, easeOutCubic(elapsed / 0.3))until done) andflick. Add"hop"to the union and itscaseinanimateHero. - Step 4: Run both specs (PASS), then
chemx test tests/unit/XpLookEditor.spec.ts tests/unit/HeroLook.spec.ts(still PASS) andchemx typecheck(no new errors; if aswitchoverHeroAnimationelsewhere 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).
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 }—getis 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; defaultsize96 -
createLookThumbnailer(options?: { draw?: ThumbDraw | null; perFrame?: number; cacheSize?: number; schedule?: (run: () => void) => void }): LookThumbnailer— defaults:drawlazily fromcreateThreeDraw(),perFrame3,cacheSize200,schedulerequestAnimationFrame. Key:`${framing}|${size}|${JSON.stringify(withoutHands(look))}`. Queue is newest-first (a repeatedgetmoves the key to the front). Each scheduled run draws up toperFramekeys and schedules another run while keys remain. Adrawthat throws, or nodrawavailable, setssupportedfalse, clears the queue, and every latergetreturns"". Cache pastcacheSizedrops the oldest entry. Afterdispose()nothing more draws and the renderer is freed. -
createThreeDraw(): { draw: ThumbDraw; dispose: () => void } | null— oneTHREE.WebGLRendereron a detached canvas (alpha: true,powerPreference: "low-power"), a scene withaddHeroLights;drawbuilds the hero, renders it withrenderHeroSnapshot, removes and disposes it.nullwhen the renderer cannot start.disposecallsrenderer.dispose()andforceContextLoss(). -
useLookThumbnails(): LookThumbnailer—createLookThumbnailer()plusonBeforeUnmount(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"", afterflush()it equalsdraw's return;drawcalled with(DEFAULT_LOOK-without-hands, "bust", 96). -
it("draws a look once however often it is asked for")— twogets of equal looks (one withleftHand), onedrawcall. -
it("draws three per frame, newest first")— four different looks requested in order a,b,c,d; after oneflush()drawsaw d,c,b; after the next, a. -
it("gives up and reports unsupported when drawing throws")—drawthrows once;supported.valuefalse; latergetreturns""anddrawis not called again. -
it("forgets the oldest picture past the cache size")—cacheSize: 2, three looks drawn; the firstgetagain 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: afterget+flush,supported.valueis 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).
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:
XpHeroModelwithfocusprop,flick(),snapshot()(Task 2);TABS,LookTabId,LookFocus(Task 1). -
Produces:
-
XpBuilderStage— propslook: HeroLook(already the display look: hands and bare head applied by the caller),focus: LookFocus,animation: HeroAnimation,canUndo: boolean; emitsundo,random,pose; exposessnapshot(size?: number): stringandflick(): void. RendersXpHeroModel(interactive) filling a checker stage (bg-slide bg-slide-equipmentlook, asLookModalhas today) and three 44px round buttons: bottom-left Undo (fad fa-undo,aria-label="Undo",data-cy="builder-undo", dimmed when!canUndobut 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— propsmodelValue: LookTabId; emitsupdate:modelValue(id)(only when it changes),done. Arole="tablist"(aria-label="What to change") of onerole="tab"button perTABSentry (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").
- Nav:
-
Step 2: Run
chemx test tests/unit/XpBuilderNav.spec.tsandchemx 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).
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— propsmodelValue: HeroLook(the draft, no hands),tab: LookTab,mode: "style" | "colour",thumbnails: LookThumbnailer; emitsupdate:modelValue(look)(the draft with exactly one key changed),update:mode(mode). Withtab.styles: a Style | Colour segmented switch (role="radiogroup"or twoaria-pressedbuttons) and either the style grid or the colour grids. Withouttab.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>ofthumbnails.get(tileLook(modelValue, tab, option), tab.focus, 96)or, when that is"", thestyleLabeltext. Colour grids: one labelled group pertab.colorsentry, swatch buttons as today (aria-label= swatch name). The tray scrolls inside itself. -
XpStarterLineup— propsthumbnails: LookThumbnailer,allowPortrait: boolean,rng?: Rng; emitspick(look: HeroLook),portrait. A 3 x 3 grid of buttons (data-cy="starter-hero",aria-label="Hero 1"…"Hero 9") showingthumbnails.get(look, "full", 128)or the label; a footer with "Roll 9 more" (fad fa-dice,data-cy="starter-roll") that re-deals withstarterLineup(9, rng), and, whenallowPortrait, "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 whosegetreturns"").- 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;getcalled with a look whosehatis"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;pickpayload equalsstarterLineup(9, sameSeed)[4]);it("rolls nine new heroes");it("offers the 2D portrait only when allowed");it("shows labels when there are no pictures").
- Tray:
-
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).
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);alertControllerfrom@ionic/vue;play$fx;DEFAULT_LOOK,isHeroLook. -
Produces:
-
XpHeroBuilder(mounted only while the modal is open, so each open starts fresh) — propslook?: HeroLook | null,equipment?: HandSlots | null,allowPortrait?: boolean(default false); emitssave(look: HeroLook, image?: string),close-request,portrait; exposesconfirmLeave(): Promise<boolean>.- Start:
isHeroLook(look)→ editor phase, draft =withoutHands({ ...DEFAULT_LOOK, ...look }); else lineup phase, draft =DEFAULT_LOOK.startLook= that draft. Tabbody, modestyle, poseidle, history[]. - Layout:
ion-header(toolbarrpg-box: X (XpCloseButton) emitsclose-request; title "Pick a starting hero" / "Build your hero"; "2D" text buttondata-cy="look-use-portrait"whenallowPortraitand in the editor),ion-contentholding the lineup or stage + tray,ion-footerholdingXpBuilderNav(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 (afternextTick) focus moves to the tray's first button; mode changecursor; tray update (when notsameLook) → historypushHistory(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 onbody, hop,select; lineup/2D portrait →select,left = true, emitportrait. - Hop:
hopping = true, cleared after 450ms withuseTimers().startTimeout. - Done: ignored if
saving;saving = true(tiles, tabs, undo, random and pose do nothing from here),yes, posevictory, after 800ms takestage.snapshot(256)inside try/catch (a throw or""→undefined),left = true, emitsave(withoutHands(draft), image). - Dirty:
picked || !sameLook(draft, startLook). -
confirmLeave():leftor not dirty →true(playscloseMenuwhen notleft); dirty →alertController.create({ header: "Leave without saving?", buttons: [{ text: "Keep building", role: "cancel" }, { text: "Leave", role: "destructive" }] }), present, resolvetrueonly on"destructive"(thencloseMenu).
- Start:
-
XpHeroBuilderModal— propsisOpen: boolean,look?: HeroLook | null,equipment?: HandSlots | null,allowPortrait?: boolean; emitssave(look, image?),close,portrait. Anion-modalwithclass="hero-builder", full screen (--width/--height: 100%, no radius, dark purple),:can-dismiss="() => builder?.confirmLeave() ?? true",@didDismiss="emit('close')". Buildersave→ emitsavethen dismiss;close-request→modal.$el.dismiss()(socanDismissasks);portrait→ emitportraitthen dismiss.
-
-
Step 1: Write the failing tests against
XpHeroBuilder(stubXpHeroModelas in the Global Constraints; mock@/assets/fxplay$fx; mockuseLookThumbnailsto a fake; mockalertController.createto 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")—lookmissinghatColorand carryingleftHand: "fa-sword": draft hasDEFAULT_LOOK.hatColor, noleftHand;confirmLeave()resolvestruewith no alert. -
it("takes the hat off on the stage while on Hair, but saves it")— drafthat: "crown", switch to Hair: stage lookhat"none"; Done → saved lookhat"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 onesave, look unchanged by the tile tap,play$fxsawyesonce. -
it("saves without a picture when the snapshot fails")— stub snapshot throws →savesecond argundefined. -
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()resolvestruewithout 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 withpadding-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).
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) withtoastController. -
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> }—lookis the user's saved look only whenisHeroLook(elseundefined, which opens the lineup);savecallssetLook, then showsrpgToastOptions("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;savecallssetLook(id, look, image)andtoastController.createwith 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
LookModalforXpHeroBuilderModalwith:allow-portrait="true", samelook,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);openMyLookreplaces 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 themy-lookentry 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 outsidedocs/. -
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 forion-modal.hero-builder canvas, click the Hair tab, click[data-cy=look-tile][data-option=mohawk], click[data-cy=builder-done], then assertuser.look.hair === "mohawk"and theavatarImagePNG 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.jsand the same fornew-hero.spec.js— expected: all passing; attach the screenshot paths fromtests/e2e/screenshots. -
Step 9: Report files and screenshots (commit:
feat(hero-builder): one builder everywhere; retire LookModal and the My Look page).
-
chemx verifyon 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.