2026 10 13 hero builder design - xopherdeep/do-it-for-the-xp GitHub Wiki
Date: 2026-10-13. Status: approved flow (owner, in chat); this spec awaits review.
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).
-
Feel first, same parts. The look keeps its ten fields (
HeroLookinsrc/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-lookpage 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.
| 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) |
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:
selectsound, that look becomes the draft, the editor opens on the Body tab with the hero doing a hop.
Top to bottom, at 390 x 844:
- Header (~56px): X (left), "Build your hero"; right: a small "2D" link (only when portraits are allowed).
-
Stage (~46% of what is left; fixed, never resized by the tray): the live
XpHeroModelon 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).
- bottom-left Undo (disabled look when there is nothing to undo, still tappable: plays
-
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").
- 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.
| 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).
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.
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 |
-
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).
- 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 emitswithoutHands(draft). -
Save is the host's job: the Gamer Card emits
update:look/update:avatarImageto the form (as today); the portal hosts calluserStore.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).
- One
THREE.WebGLRendereron an offscreen canvas, created on first use and disposed (withforceContextLoss) 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 withbuildHero, poses it idle at the snapshot yaw, renders into a render target, reads pixels, returnspixelsToDataUrl. Cache:Mapkeyed byframing|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 (
supportedfalse, 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 }, plushat: "none"on the Hair tab. Outfit tiles usefullframing, every other tabbust.
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.
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.
- Bottom nav:
role="tablist", each categoryrole="tab"witharia-selected; Done is a plain button labelled "Done, save my hero". - Tiles: buttons with
aria-label= option name ("Ponytail") andaria-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.
Jest (via chemx test <spec>; three is already transformed for jest):
-
lookEditorhelpers: every tab has a focus; Body has no styles; tile looks set the key and go bare-headed on Hair;randomLookalways returns valid option values and obeys the accent and hat rules under a seeded rng;starterLineupreturns nine distinct looks; history push/pop/cap/no-op. -
heroCamera:lerpFrameends on the target;pixelsToDataUrlflips 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 emitsdone; 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 withallowPortrait. -
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 fromlook-modal/.look-donetoion-modal.hero-builder/[data-cy=builder-done];[data-cy=look-use-portrait]keeps its name. -
mylook.spec.jsrewritten: open the builder from My Profile, pick a hair tile, Done; the profile'slook.hairchanged andavatarImageis a PNG. -
visual.spec.js: shot 11 uses the new selectors; shot 34 (my-lookroute) becomes "My Profile → builder open".
Gate: chemx verify before each commit.
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.