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

Hero Builder (Mii-style 3D look editor) — design

Date: 2026-10-13. Status: approved flow (owner, in chat); this spec awaits review.

Why

The 3D look editor feels like an afterthought. There are two of it: the "Build your hero" modal in Add/Edit Profile (LookModal.vue) and the boxed, scrolling My Look page in the portal (MyLook.vue), both around XpLookEditor.vue. On a phone the hero shrinks when a long option list opens, styles are text buttons ("Mohawk"), colours sit below the fold, and two footers stack (the tab strip, then Reset / 2D portrait / Looks good). The owner wants one full-screen builder with its own bottom nav that feels like the Nintendo Mii editor, kept retro (Earthbound skin).

Decisions (owner, 2026-10-12/13)

  • Feel first, same parts. The look keeps its ten fields (HeroLook in src/lib/hero-look/types.ts). No new voxel parts, no face split, no sliders, no saved-data migration.
  • One builder everywhere. The Gamer Card (Add/Edit Profile), the profile modal's avatar and My Profile's "My Look" button all open the same full-screen modal. The my-look page is retired.
  • Layout "C + Done in the nav": a Style | Colour switch in the tray, Undo and Random (dice) on the stage corners, Done as the last tab of the bottom nav, nothing on the header's right except the 2D portrait link (profile flow only).
  • Starter lineup for a hero with no look yet: nine random heroes, tap one to start, "Roll 9 more". Editing a hero that has a look opens the editor directly; the dice is the "start over".
  • Picture tiles drawn live from one hidden WebGL renderer: every tile is your hero with that option.

This screen is the one sanctioned exception to "leave the nav bars alone": it gets its own bottom nav because the owner asked for it. The portal's shared bottom nav is not touched.

Screens and flow

Ways in

Entry Today After
Gamer Card avatar (Add/Edit Profile) LookModal XpHeroBuilderModal, result goes back to the form
Profile modal avatar (UserProfileModal.vue) routes to my-look opens XpHeroBuilderModal, saves with userStore.setLook
My Profile "My Look" button (UserProfile.vue/.ts) routes to my-look opens XpHeroBuilderModal, saves with userStore.setLook
/my-portal/:userId/my-look URL My Look page redirects to my-profile (stale links and bookmarks keep working)

Starter lineup (new heroes only)

Shown when the modal opens with no valid look (!isHeroLook(look)).

  • Header: X, title "Pick a starting hero".
  • Body: a 3 x 3 grid of full-body hero snapshots on the sliding checker (bg-slide bg-slide-equipment).
  • Footer (thumb reach): "Roll 9 more" (dice icon) re-deals the nine; "Use a 2D portrait instead" under it, only when the host allows portraits (the profile flow).
  • Tapping a hero: select sound, that look becomes the draft, the editor opens on the Body tab with the hero doing a hop.

Editor

Top to bottom, at 390 x 844:

  1. Header (~56px): X (left), "Build your hero"; right: a small "2D" link (only when portraits are allowed).
  2. Stage (~46% of what is left; fixed, never resized by the tray): the live XpHeroModel on the checker, drag to turn. Corner buttons, 44px round, Earthbound styled:
    • bottom-left Undo (disabled look when there is nothing to undo, still tappable: plays no),
    • bottom-right Random (dice): a whole random look, the hero spins once,
    • top-right Pose: cycles Idle → Wave → Dance → Victory → Idle (replaces the old animation chips).
  3. Tray (fills the rest; scrolls inside itself, never the page):
    • a Style | Colour segmented switch, hidden on tabs that have only colours (Body);
    • Style: a 4-column grid of square picture tiles, the chosen one ringed in pale yellow;
    • Colour: one labelled swatch grid per colour of the tab (Outfit shows "Main" and "Trim").
  4. Bottom nav: Body | Hair | Face | Outfit | Hat | Done. Font Awesome duotone icons over StatusPlz labels; the active category in pale yellow; Done is a gold tab with a small gap before it.

Camera: Hair, Face and Hat ease the live camera to the head (bust framing); Body and Outfit ease back to the whole hero (full). The ease takes ~300ms.

While the Hair tab is open, the stage shows the hero bare-headed (hat off, display only; the draft keeps the hat), so hair choices are visible. Hair tiles are drawn bare-headed for the same reason.

Interactions

Action Effect Sound
Category tab switches tray, camera eases cursor
Style / Colour switch switches tray body cursor
Tile or swatch draft changes, hero hops (~450ms), pushes undo select
Random draft = random look, hero spins, pushes undo select
Undo draft = previous; nothing to undo: no change cursor / no
Pose cycles the stage animation cursor
Done victory pose ~800ms, snapshot, emit save, close yes
X / Android back / swipe dirty: RPG alert "Leave without saving?" [Keep building] [Leave]; clean: close closeMenu
2D link throws the draft away, asks the host for the portrait picker select

"Dirty" means the draft differs from the look the modal opened with (or, for a new hero, a starter was picked).

Look and feel

The mockups were wireframes. The real screen keeps the current modal's Earthbound skin: dark purple ($eb-color-dark-purple), the sliding checker stage, pale-yellow highlights, $eb-font-status labels, square corners, Font Awesome duotone icons, rpg-box on the header toolbar. No Ionic tab bar component: the bottom nav is plain buttons in an ion-footer, like the current look dock.

Architecture

Units

All under src/components/organisms/HeroModel/ unless noted. New components use <script setup lang="ts">.

Unit Kind Job
lookEditor.ts pure (extended) tabs gain focus: "bust" | "full" and bareHead?: boolean; option tiles per tab; randomLook(rng), starterLineup(n, rng); undo history helpers
heroCamera.ts pure-ish (new) placeCamera, fullFrame, bustFrame, lerpFrame, pixelsToDataUrl moved out of useHeroScene.ts so the stage and the thumbnails share them
useHeroScene.ts composable (changed) takes an optional focus ref; the live camera eases between frames; imports the helpers above
XpHeroModel.vue component (changed) focus prop passed to the scene
voxel/animate.ts, types.ts changed HeroAnimation gains "hop" (a short bounce and arm flick)
useLookThumbnails.ts composable (new) one offscreen renderer; thumbnail(look, framing, size) → PNG data URL, cached; batched drawing; supported flag; dispose()
builder/XpBuilderStage.vue component (new) XpHeroModel + Undo / Random / Pose corner buttons; props: look, focus, animation, canUndo; emits undo, random, pose
builder/XpBuilderTray.vue component (new) Style/Colour switch, tile grid, swatch grids for one tab; v-model look; uses thumbnails
builder/XpBuilderNav.vue component (new) category tabs + Done; v-model tab id; emits done
builder/XpStarterLineup.vue component (new) 3 x 3 grid from starterLineup, roll again, optional portrait link; emits pick(look), portrait
XpHeroBuilderModal.vue component (new) the ion-modal shell: header, lineup ↔ editor, draft, undo, dirty check (canDismiss), Done sequence; props isOpen, look, equipment, allowPortrait; emits save(look, image), close, portrait

Retired

  • src/app/Console/MyPortal/UserProfile/MyLook/ (page, .ts, .scss) and its route (replaced by a redirect).
  • src/app/SideMenu/SwitchProfile/AddProfile/components/LookModal.vue.
  • XpLookEditor.vue, _XpLookEditor.scss (no other users once the two above are gone).
  • tests/unit/XpLookEditor.spec.ts (its still-true cases move to the new specs).

Data flow

  • The modal owns the draft (ref<HeroLook>), the history (array of looks, capped at 50), the tab id, the tray mode (style/colour) and the pose. Children get props and emit; none of them touch the store.
  • The draft never holds hands. The stage draws withHands(draft, equipment); save emits withoutHands(draft).
  • Save is the host's job: the Gamer Card emits update:look / update:avatarImage to the form (as today); the portal hosts call userStore.setLook(userId, look, image) (unchanged action).
  • The snapshot is taken from the stage model (snapshot(256), bust framing, canonical idle pose: unchanged). A failed snapshot saves the look without a new picture (unchanged rule).

Thumbnails

  • One THREE.WebGLRenderer on an offscreen canvas, created on first use and disposed (with forceContextLoss) when the modal closes, so the app holds two WebGL contexts at most while building (stage + thumbnails).
  • thumbnail(look, framing, size = 96): builds the hero with buildHero, poses it idle at the snapshot yaw, renders into a render target, reads pixels, returns pixelsToDataUrl. Cache: Map keyed by framing|size|JSON(look), LRU-trimmed at 200 entries.
  • The tray asks for its tiles in batches (3 per animation frame) so a tab opens at once; a tile without its picture yet shows the option name in StatusPlz. When colours change, the current tab's tiles re-request; the queue draws the newest requests first, so the tiles you are looking at land before stale ones.
  • No WebGL (supported false, or a throw): tiles show the option names (today's behaviour, restyled as tiles), the lineup shows nine labelled cards ("Hero 1"…) that still work. Nothing blocks building or saving.
  • Tile looks: { ...draft, [key]: option }, plus hat: "none" on the Hair tab. Outfit tiles use full framing, every other tab bust.

Random looks

randomLook(rng = Math.random) returns a valid HeroLook with simple taste rules so the lineup looks like people, not noise: hair colour from a natural set (browns, black, blonde, ginger, grey) 75% of the time and any palette colour otherwise; eye colour from the darker palette colours; outfitAccent never equal to outfitColor; hat: "none" half the time. starterLineup(9, rng) returns nine looks with no two identical. Both take an injectable rng so specs are deterministic.

Undo

Pure helpers in lookEditor.ts: pushHistory(history, look, cap = 50) returns a new array (a no-op when look equals the last entry); popHistory(history) returns { history, look | undefined }. The modal pushes the pre-change draft before every pick and every random.

Accessibility

  • Bottom nav: role="tablist", each category role="tab" with aria-selected; Done is a plain button labelled "Done, save my hero".
  • Tiles: buttons with aria-label = option name ("Ponytail") and aria-pressed. Swatches keep their colour names.
  • Corner buttons: icon buttons with aria-label (Undo, Random hero, Change pose); 44px minimum.
  • Focus moves to the tray's first control when the tab changes.

Testing

Jest (via chemx test <spec>; three is already transformed for jest):

  • lookEditor helpers: every tab has a focus; Body has no styles; tile looks set the key and go bare-headed on Hair; randomLook always returns valid option values and obeys the accent and hat rules under a seeded rng; starterLineup returns nine distinct looks; history push/pop/cap/no-op.
  • heroCamera: lerpFrame ends on the target; pixelsToDataUrl flips rows (existing snapshot behaviour preserved).
  • XpBuilderTray: Style/Colour switch hidden on Body; tapping a tile emits the look with only that key changed; falls back to names when thumbnails are unsupported.
  • XpBuilderNav: one tab per category plus Done; Done emits done; tab change emits the id.
  • XpHeroBuilderModal: lineup only when the look is missing; picking a starter opens the editor on Body; undo restores the previous draft; X on a clean draft closes without asking; save emits the look without hands plus the image; the 2D link shows only with allowPortrait.
  • useLookThumbnails: caches by look and framing; reports unsupported when the renderer cannot start (jsdom).

Cypress (--browser chrome, 390 x 844):

  • new-hero.spec.js: selectors move from look-modal / .look-done to ion-modal.hero-builder / [data-cy=builder-done]; [data-cy=look-use-portrait] keeps its name.
  • mylook.spec.js rewritten: open the builder from My Profile, pick a hair tile, Done; the profile's look.hair changed and avatarImage is a PNG.
  • visual.spec.js: shot 11 uses the new selectors; shot 34 (my-look route) becomes "My Profile → builder open".

Gate: chemx verify before each commit.

Out of scope

New look parts, face split, size/position sliders, height/build, saved-look migration, a "Start over" back to the lineup for existing heroes, sharing or QR codes for looks, any change to the portal's bottom nav.

⚠️ **GitHub.com Fallback** ⚠️