2026 10 19 class outfits - xopherdeep/do-it-for-the-xp GitHub Wiki

Class Outfits 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. In this repo the plan is also filed on the chemx board (epic + one task per plan task); builders read their task with chemx team task show <id>.

Goal: Every class dresses the voxel hero in its own outfit at class level 1, 5 and 10; the outfit goes on at once with a fanfare; the builder's Outfit tab becomes the wardrobe of everything a hero has earned.

Architecture: A pure catalogue of look pieces (catalog.ts) and a pure ownership rule (unlocks.ts) from the 2026-10-13 Look Unlocks spec, with 45 class outfits as the first content. A pure rule (classOutfits.ts) answers which outfit to put on when class progress changes; the user store calls it at its two class write points. The voxel outfits live in voxel/classOutfits/ and plug into the existing body() builder through a map. The builder learns ownership (locked tiles, try-on, New badges) and the portal shows the fanfare for a worn, unseen outfit.

Tech Stack: Vue 3 + Ionic 7 + Pinia, TypeScript, three.js voxel rig, jest through chemx test, Cypress 8 with --browser chrome.

Spec: docs/superpowers/specs/2026-10-18-class-outfits-design.md (builds on 2026-10-13-look-unlocks-design.md).

Global Constraints

  • Every rule is a pure module under src/lib/hero-look/ with a jest spec written first; screens stay thin.
  • Piece ids are outfit:<class-slug>-<level>; class slugs are the class name lower-cased with spaces as hyphens (white-mage, martial-artist); levels are 1, 5 and 10.
  • The 45 names are the spec's table, verbatim. The 15 colour pairs (main, trim) are fixed below and all come from LOOK_COLORS, so no new colour piece is needed.
  • Class outfits are computed from jobClass and stats.classes[<name>].level, never written to lookUnlocks. The current class counts as level 1. Old profiles need no migration.
  • Skin is never locked. A saved look's pieces are always owned (grandfathering).
  • Files stay under 500 lines; a function makes at most 5 direct use*() calls; hook return functions carry a verb prefix; compound conditions are named; no em dashes anywhere.
  • Builders run only the spec they touch: chemx test tests/unit/<Name>.spec.ts. No typecheck, verify or build mid-wave.
  • Copy is in-world: "Learned as a Warrior (class level 5)", "Not yours yet", " earned".

Colour pairs (outfitColor, outfitAccent): Warrior #b8342b/#8d8d8d; Mage #2f5fb3/#e8b22b; Thief #2b2b3a/#8d8d8d; Monk #e3732c/#e8d5a8; Knight #8d8d8d/#2f5fb3; Berserker #5a3a1e/#b8342b; White Mage #f5f5f5/#b8342b; Black Mage #1f1f1f/#6b3fa0; Assassin #2b2b3a/#c54a8a; Ranger #3f7d4e/#5a3a1e; Martial Artist #f5f5f5/#1f1f1f; Mystic #6b3fa0/#2b8a8a; Paladin #e8d5a8/#e8b22b; Ninja #1f1f1f/#b8342b; Sage #2b8a8a/#e8d5a8.

Review Focus

  1. A profile whose stats.classes is missing, null or holds a non-numeric level: ownership returns the starter set plus the current class's level 1 outfit and never throws (Task 2 test).
  2. A saved look whose outfit is a class value the hero no longer reaches (class changed, record edited): it stays owned and worn (Task 2 test).
  3. A class level that jumps past two thresholds in one save (4 to 10): the level 10 outfit goes on, both are owned (Task 3 test).
  4. A class change to a class whose outfits were already earned and seen: the outfit goes on, no second fanfare (Task 3 and Task 9 tests).
  5. The builder opened for a brand-new profile that has no id yet (the Gamer Card): starter pieces plus the class's level 1 outfit are owned, seen is ignored, Done never shows "Not yours yet" (Task 8 test).

Task 1: The catalogue

Files:

  • Modify: src/lib/hero-look/types.ts (OutfitStyle, OUTFIT_STYLES)
  • Create: src/lib/hero-look/catalog.ts
  • Test: tests/unit/lookCatalog.spec.ts

Interfaces:

  • Consumes: JOB_CLASS_OPTIONS (src/constants), LOOK_COLORS, OUTFIT_STYLES.

  • Produces (used by Tasks 2, 3, 5 to 9):

    export type PieceSlot = "hair" | "face" | "outfit" | "hat" | "color";
    export type LookSource = { kind: "starter" } | { kind: "class"; jobClass: string; level: 1 | 5 | 10 } | { kind: "gift" };
    export interface LookPiece { id: string; slot: PieceSlot; value: string; name: string; source: LookSource; colors?: { outfitColor: string; outfitAccent: string } }
    export const STARTER_OUTFIT_STYLES: OutfitStyle[]            // the eight that exist today
    export const CLASS_OUTFIT_STYLES: OutfitStyle[]              // the 45 new values, class order then level order
    export const LOOK_CATALOG: readonly LookPiece[]
    export const classSlug = (jobClass: string): string
    export const pieceById = (id: string): LookPiece | undefined
    export const piecesForSlot = (slot: PieceSlot): LookPiece[]  // starter first, then class pieces grouped by class
    export const classOutfitPieces = (jobClass: string): LookPiece[]   // the class's three, level order
  • OutfitStyle becomes StarterOutfitStyle | ClassOutfitStyle, where ClassOutfitStyle is the template-literal union over the 15 slugs and the three levels (${ClassSlug}-${1 | 5 | 10}). OUTFIT_STYLES stays the eight starters (the builder's starter row); CLASS_OUTFIT_STYLES is new.

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

    • has one outfit piece per OutfitStyle value and 53 outfit pieces in all
    • gives every class in JOB_CLASS_OPTIONS three pieces at levels 1, 5 and 10 with the spec's names (assert the Warrior row: Leather Jerkin, Chainmail Hauberk, Battle Plate; the Sage row: Scholar's Robe, Sage's Vestments, Worldtree Mantle; and that classOutfitPieces(name).length === 3 for every JOB_CLASS_OPTIONS[i].name)
    • ids are outfit:<slug>-<level> and unique (outfit:white-mage-5, outfit:martial-artist-10)
    • every class piece carries a colour pair from LOOK_COLORS
    • piecesForSlot("outfit") lists the eight starters first, then classes in JOB_CLASS_OPTIONS order
    • pieceById returns undefined for an unknown id
  • Step 2: Run chemx test tests/unit/lookCatalog.spec.ts. Expected: FAIL, module not found.

  • Step 3: Implement types.ts (the unions, OUTFIT_STYLES unchanged, CLASS_OUTFIT_STYLES exported from catalog.ts, not from types.ts, to avoid a cycle) and catalog.ts: a CLASS_OUTFIT_NAMES: Record<ClassSlug, [string, string, string]> table and CLASS_OUTFIT_COLORS: Record<ClassSlug, { outfitColor: string; outfitAccent: string }> from the constraints, LOOK_CATALOG built from them plus the starter pieces (names: Tunic, Overalls, Armor, Robe, Hoodie, Dress, Tracksuit, Pyjamas; starter hair, face, hat and colour pieces as in the Look Unlocks spec with source: { kind: "starter" }).

  • Step 4: Run the spec. Expected: PASS. Also run chemx test tests/unit/HeroLook.spec.ts and chemx test src/components/organisms/HeroModel/__tests__/voxel.spec.ts (the type widening must not break them).

  • Step 5: Report on the board task; no commit (Fable commits).

Task 2: What a hero owns

Files:

  • Create: src/lib/hero-look/unlocks.ts
  • Test: tests/unit/lookUnlocks.spec.ts

Interfaces:

  • Consumes: Task 1 (LOOK_CATALOG, pieceById, classSlug), getLook (look.ts), DEFAULT_LOOK.

  • Produces (used by Tasks 3, 8, 9):

    export interface LookHero { id?: string; look?: unknown; jobClass?: string; stats?: { classes?: Record<string, { level?: unknown }> }; lookUnlocks?: string[]; lookSeen?: string[] }
    export const classLevelsOf = (hero: LookHero | null | undefined): Record<string, number>   // jobClass counts as at least 1
    export const ownedPieces = (hero: LookHero | null | undefined): ReadonlySet<string>
    export const isOwned = (owned: ReadonlySet<string>, slot: PieceSlot, value: string): boolean  // skin always true
    export const lockedHint = (piece: LookPiece): string   // class: "Learned as a Warrior (class level 5)"; gift: "A gift from the Game Master"; else "Not unlocked yet"
    export const newPieces = (hero: LookHero | null | undefined): ReadonlySet<string>   // owned, not starter, not in the saved look, not in lookSeen
    export const keepOwned = (draft: HeroLook, fallback: HeroLook, owned: ReadonlySet<string>): { look: HeroLook; returned: LookPiece[] }
  • Step 1: Write the failing tests in tests/unit/lookUnlocks.spec.ts:

    • an empty hero owns the starter set only
    • the current class counts as level 1 ({ jobClass: "Warrior" } owns outfit:warrior-1, not outfit:warrior-5)
    • class level 5 and 10 own their outfits; another class's stay locked
    • a changed class keeps the old class's earned outfits (jobClass: "Mage", stats.classes.Warrior.level = 10 owns all three Warrior pieces)
    • malformed classes never throw (stats: { classes: null }, { Warrior: { level: "x" } })
    • a saved look's class outfit is owned even when the class level is not reached (Review Focus 2)
    • stored lookUnlocks are owned and unknown ids are ignored
    • skin is always owned
    • lockedHint copy for a class piece
    • newPieces excludes starter, grandfathered and seen pieces
    • keepOwned returns locked slots to the fallback and lists them in slot order
  • Step 2: Run chemx test tests/unit/lookUnlocks.spec.ts. Expected: FAIL.

  • Step 3: Implement unlocks.ts with the signatures above. keepOwned checks hair, face, outfit, hat and the four colour keys the way the Look Unlocks spec says.

  • Step 4: Run the spec. Expected: PASS.

  • Step 5: Report on the board task.

Task 3: Wearing it at once (the rule)

Files:

  • Create: src/lib/hero-look/classOutfits.ts
  • Test: tests/unit/classOutfits.spec.ts

Interfaces:

  • Consumes: Task 1 (classOutfitPieces, pieceById), Task 2 (classLevelsOf, ownedPieces).

  • Produces (used by Tasks 4 and 9):

    export interface ClassProgress { jobClass?: string; classLevels: Record<string, number> }
    export const classProgressOf = (hero: LookHero | null | undefined): ClassProgress
    export const classOutfitFor = (jobClass: string | undefined, classLevel: number): LookPiece | null   // highest earned of that class
    export interface OutfitChange { piece: LookPiece; look: HeroLook }
    export const outfitOnClassProgress = (before: ClassProgress, after: ClassProgress, look: HeroLook): OutfitChange | null
    export const startingLookFor = (jobClass: string | undefined, base: HeroLook = DEFAULT_LOOK): HeroLook   // the class's level 1 outfit and colours
    export const catchUpOutfit = (hero: LookHero | null | undefined): OutfitChange | null   // heroes who passed the levels before this shipped (spec section)
  • Step 1: Write the failing tests:

    • picking a class puts on its level 1 outfit with the class colours (before { classLevels: {} }, after { jobClass: "Mage", classLevels: { Mage: 1 } } gives outfit: "mage-1", outfitColor: "#2f5fb3", outfitAccent: "#e8b22b")
    • crossing class level 5 puts on the level 5 outfit; crossing 10 the level 10 one
    • a jump from 4 to 10 puts on the level 10 outfit (Review Focus 3)
    • a level-up that crosses nothing answers null (2 to 3)
    • a level-up in a class that is not current answers null (owned, not worn)
    • changing to a class already at level 5 puts on that class's level 5 outfit
    • the same progress twice answers null
    • never answers an outfit the hero does not own
    • startingLookFor("Warrior") wears outfit:warrior-1 and keeps hair, face and skin from the base
    • catchUpOutfit dresses a Warrior 10 who wears a tunic and has seen no class piece in outfit:warrior-10
    • catchUpOutfit answers null once a class piece is seen, or when a class outfit is already worn, or when nothing is owned
  • Step 2: Run chemx test tests/unit/classOutfits.spec.ts. Expected: FAIL.

  • Step 3: Implement classOutfits.ts. The rule: if after.jobClass !== before.jobClass the piece is classOutfitFor(after.jobClass, after.classLevels[after.jobClass] ?? 1); else if the current class's level crossed 5 or 10 the piece is classOutfitFor(after.jobClass, newLevel); a piece equal to look.outfit answers null.

  • Step 4: Run the spec. Expected: PASS.

  • Step 5: Report on the board task.

Task 4: The store writes it

Files:

  • Modify: src/lib/store/stores/user/profileFields.ts (createProfileSetters gains grantLook, markLookSeen)
  • Modify: src/lib/store/stores/user.ts (setJobClass, updateClassXp dress the hero; both export the new setters)
  • Modify: src/app/SideMenu/SwitchProfile/AddProfile/AddProfile.ts:264 and :375 (a new profile starts in startingLookFor(jobClass) when it has no look of its own)
  • Test: tests/unit/HeroLook.spec.ts (extend; it runs the real store with ProfileDb faked), the AddProfile spec found with chemx q AddProfile -l in tests/unit (extend if it exists)

Interfaces:

  • Consumes: Task 3 (classProgressOf, outfitOnClassProgress, startingLookFor), Task 1 (pieceById).

  • Produces: userStore.grantLook(userId: string, pieceId: string): boolean, userStore.markLookSeen(userId: string, pieceIds: readonly string[]): void, userStore.dressHero(userId: string, look: HeroLook): void (writes the look and persists; the catch-up and nothing else calls it).

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

    • setJobClass dresses the hero in the new class's outfit and persists the look
    • updateClassXp that crosses class level 5 dresses the hero in the level 5 outfit
    • updateClassXp that crosses nothing leaves the look alone
    • grantLook adds a catalogue piece once and refuses unknown or owned ids
    • markLookSeen persists lookSeen
    • and in the AddProfile spec: a new Warrior starts in outfit:warrior-1
  • Step 2: Run chemx test tests/unit/HeroLook.spec.ts. Expected: FAIL.

  • Step 3: Implement. In user.ts, a private dressForClassProgress(userId, before: ClassProgress) called at the end of setJobClass and inside updateClassXp after applyClassXp, before persistUser: computes after = classProgressOf(users[userId]), and when outfitOnClassProgress(before, after, getLook(users[userId])) answers, writes users[userId].look = change.look (through the existing setLook path if one exists, else directly, then syncCurrentUser). In profileFields.ts the two setters follow the pattern of the setters already there (write the field, persistUser(userId)).

  • Step 4: Run the specs. Expected: PASS. Also chemx test tests/unit/PlayerStats.spec.ts if it exists (class XP paths).

  • Step 5: Report on the board task.

Task 5: Voxel content, the shared parts and the base classes

Files:

  • Create: src/components/organisms/HeroModel/voxel/classOutfits/parts.ts
  • Create: src/components/organisms/HeroModel/voxel/classOutfits/base.ts (Warrior, Mage, Thief, Monk: 12 outfits)
  • Modify: src/components/organisms/HeroModel/voxel/outfits.ts (export OutfitSpec, arm, leg, footwear, LEG_X0, LEG_X1, and the slice helper the armor uses, so the class files reuse them)
  • Test: src/components/organisms/HeroModel/__tests__/classOutfits.spec.ts

Interfaces:

  • Consumes: box, mirrorX, shade, lighten, mix, PALETTE (voxel/shape.ts), NECK_Y, SHOULDER_Y, HIP_Y, ARM_X (voxel/outfits.ts).

  • Produces (used by Tasks 6 and 7):

    // parts.ts: each takes the two outfit colours and returns boxes placed on the torso unless named otherwise
    export const collar = (C: string, A: string): Box[]
    export const cape = (C: string, A: string, length: "short" | "long"): Box[]
    export const mantle = (C: string, A: string): Box[]
    export const pauldrons = (C: string, A: string): Box[]        // boxes for the left arm; the caller mirrors
    export const sash = (C: string, A: string): Box[]
    export const skirt = (C: string, A: string, length: number): Box[]   // boxes for the left leg
    export const belt = (C: string, A: string): Box[]
    export const pouch = (C: string, A: string): Box[]
    export const hood = (C: string, A: string): Box[]             // headGear
    export const scarf = (C: string, A: string): Box[]
    export const crest = (C: string, A: string): Box[]            // headGear, above the collar
    // base.ts
    export type OutfitSpecFn = (skin: string, C: string, A: string) => OutfitSpec
    export const BASE_OUTFITS: Partial<Record<ClassOutfitStyle, OutfitSpecFn>>   // the 12 base-class values; index.ts merges the maps into the exhaustive one
  • Step 1: Write the failing tests in __tests__/classOutfits.spec.ts (follow voxel.spec.ts: look({ outfit }), buildHero, expectWellFormed, animateHero on walk and victory):

    • every base class outfit builds a well-formed hero and animates (loop the 12 values)
    • level 5 and 10 outfits carry more boxes than level 1 (torso box count rises per level, so each level visibly adds)
    • a level 10 outfit adds nothing above the neck except headGear (hair stays visible: no torso box with y > NECK_Y)
  • Step 2: Run chemx test src/components/organisms/HeroModel/__tests__/classOutfits.spec.ts. Expected: FAIL.

  • Step 3: Implement parts.ts and base.ts. Direction from the spec: level 1 a simple two-colour cut; level 5 adds trim and one carried or worn piece; level 10 adds a large silhouette piece (cape, mantle, crest, long coat). Warrior: jerkin / hauberk with pauldrons / plate with cape. Mage: plain robe / star robe with sash / mantle with long cape. Thief: vest with hood / leathers with belt and pouch / cloak (long cape, hood). Monk: gi with belt / sash robes / vestments with scarf and mantle. Keep each file under 500 lines; the map is the only export besides the spec functions.

  • Step 4: Run the spec. Expected: PASS.

  • Step 5: Report on the board task.

Task 6: Voxel content, the advanced classes

Files:

  • Create: src/components/organisms/HeroModel/voxel/classOutfits/advancedA.ts (Knight, Berserker, White Mage, Black Mage)
  • Create: src/components/organisms/HeroModel/voxel/classOutfits/advancedB.ts (Assassin, Ranger, Martial Artist, Mystic)
  • Test: src/components/organisms/HeroModel/__tests__/classOutfits.spec.ts (extend)

Interfaces:

  • Consumes: Task 5 (parts.ts, OutfitSpecFn, the helpers exported from outfits.ts).

  • Produces: ADVANCED_A_OUTFITS, ADVANCED_B_OUTFITS (same shape as BASE_OUTFITS, 12 each).

  • Step 1: Extend the tests: the three Task 5 cases loop all 36 values from base and both advanced maps.

  • Step 2: Run the spec. Expected: FAIL (the maps do not exist).

  • Step 3: Implement the two files. Knight: tabard / mail with pauldrons / crested plate (crest headGear, cape). Berserker: harness / warpaint hide with belt / wolfpelt mantle. White Mage: smock / white robe with sash / dawn vestments (mantle, collar). Black Mage: dark tunic / night robe with hood / void mantle (long cape, hood). Assassin: wrap with scarf / leathers with belt and pouch / nightshade cloak. Ranger: jacket / forest mail with belt / hawkfeather cloak (short cape, collar). Martial Artist: gi / belted gi with sash / dragon gi (mantle, sash). Mystic: shawl / moon robe / celestial mantle (mantle, long cape).

  • Step 4: Run the spec. Expected: PASS.

  • Step 5: Report on the board task.

Task 7: Voxel content, the crown classes and the map into body()

Files:

  • Create: src/components/organisms/HeroModel/voxel/classOutfits/crown.ts (Paladin, Ninja, Sage: 9 outfits)
  • Create: src/components/organisms/HeroModel/voxel/classOutfits/index.ts
  • Modify: src/components/organisms/HeroModel/voxel/outfits.ts:233 (specFor looks a class outfit up in the map before its switch)
  • Test: src/components/organisms/HeroModel/__tests__/classOutfits.spec.ts (extend), src/components/organisms/HeroModel/__tests__/voxel.spec.ts (run)

Interfaces:

  • Consumes: Tasks 5 and 6.

  • Produces: CLASS_OUTFIT_SPECS: Record<ClassOutfitStyle, OutfitSpecFn> (index.ts); body(skin, "<class>-<level>", C, A) builds the class outfit.

  • Step 1: Extend the tests: CLASS_OUTFIT_SPECS has exactly the 45 CLASS_OUTFIT_STYLES keys; the three cases loop all 45; body("peach", "paladin-10", C, A) builds the Paladin plate (named groups present).

  • Step 2: Run the spec. Expected: FAIL.

  • Step 3: Implement crown.ts (Paladin: oath tabard / radiant plate with pauldrons / sunfire regalia (mantle, cape, crest). Ninja: shinobi wrap with scarf / shadow shozoku with belt and hood / storm shozoku (long cape, hood). Sage: scholar's robe / sage's vestments with sash / worldtree mantle (mantle, long cape)), index.ts (the merged map), and in specFor: const classSpec = CLASS_OUTFIT_SPECS[outfit as ClassOutfitStyle]; if (classSpec) return classSpec(skin, C, A); before the switch.

  • Step 4: Run both specs. Expected: PASS.

  • Step 5: Report on the board task.

Task 8: The builder is the wardrobe

Files:

  • Modify: src/components/organisms/HeroModel/lookEditor.ts (the outfit tab's options and headings from the catalogue; randomLook(rng, owned?), starterLineup(count, rng, owned?); optionLabel reads catalogue names)
  • Modify: src/components/organisms/HeroModel/builder/XpBuilderTray.vue (locked tiles, New badge, seen emit)
  • Modify: src/components/organisms/HeroModel/XpHeroBuilder.vue and XpHeroBuilderModal.vue (hero prop, owned, newPieces, try-on banner, Done through keepOwned, the "Not yours yet" alert, seen emit)
  • Test: tests/unit/lookEditor.spec.ts (find the existing builder specs with chemx q lookEditor -l and chemx q XpBuilderTray -l in tests/unit; extend them)

Interfaces:

  • Consumes: Tasks 1 and 2.

  • Produces: XpHeroBuilderModal and XpHeroBuilder take hero?: LookHero | null and emit seen(pieceIds: string[]); XpBuilderTray takes owned: ReadonlySet<string> and fresh: ReadonlySet<string> and emits seen(pieceId); tiles carry data-cy="look-tile", data-option, data-locked="true|false", data-new="true|false"; the stage banner carries data-cy="try-on-banner"; the alert's header is "Not yours yet".

  • Step 1: Write the failing tests:

    • lookEditor: the outfit tab lists the eight starters under Starter and then one heading per class in JOB_CLASS_OPTIONS order; optionLabel("outfit", "warrior-5") is Chainmail Hauberk; randomLook with an owned set never picks a locked value (100 draws); starterLineup with an owned set never picks a locked value
    • tray: a locked tile is dimmed, carries data-locked and an aria-label ending in ", locked"; a New tile shows the badge until tapped, then emits seen
    • builder: a draft holding a locked outfit shows the try-on banner with lockedHint; Done with a locked outfit shows Not yours yet; Save keeps the owned pieces and emits save with the fallback outfit; a hero with no id owns the starter set and its class's level 1 outfit; Done never shows the alert for them (Review Focus 5)
  • Step 2: Run the specs. Expected: FAIL.

  • Step 3: Implement. lookEditor.ts: the outfit tab's styles.options becomes [...OUTFIT_STYLES, ...CLASS_OUTFIT_STYLES] with headings built from piecesForSlot("outfit") ({ tunic: "Starter", "warrior-1": "Warrior", "mage-1": "Mage", ... }). The builder computes owned = ownedPieces(hero) and fresh = newPieces(hero) once per open; the tray renders a lock glyph (fad fa-lock) and a "New" chip from them; the thumbnailer is unchanged but the class tiles must render lazily (only tiles scrolled into view ask for a thumbnail) if the tray does not already do so, so 53 tiles do not queue 53 renders at once.

  • Step 4: Run the specs. Expected: PASS. Also chemx test tests/unit/HeroLook.spec.ts.

  • Step 5: Report on the board task.

Task 9: Hosts and the fanfare

Files:

  • Modify: src/app/SideMenu/SwitchProfile/AddProfile/GamerCard.vue, src/app/Console/MyPortal/UserHud/components/UserProfileModal.vue, src/app/Console/MyPortal/UserProfile/UserProfile.ts (pass hero, handle seen with userStore.markLookSeen; the Gamer Card ignores seen for a profile not yet saved)
  • Create: src/app/Console/MyPortal/useOutfitFanfare.ts
  • Create: src/lib/hero-look/outfitFanfare.ts
  • Modify: src/app/Console/MyPortal/MyPortal.ts and MyPortal.vue (one use* call: useOutfitFanfare, rendering XpHeroFanfare)
  • Test: tests/unit/outfitFanfare.spec.ts, the MyPortal spec found with chemx q MyPortal -l (extend if it exists)

Interfaces:

  • Consumes: Task 2 (newPieces), Task 1 (pieceById), Task 4 (markLookSeen), XpHeroFanfare (open, name, jobClass, title, look, actionText).

  • Produces:

    // outfitFanfare.ts (pure)
    export const outfitFanfareFor = (hero: LookHero | null | undefined): LookPiece | null   // the worn, unseen class outfit, else null
    export const wardrobeCountFor = (hero: LookHero | null | undefined, worn: LookPiece): number   // other owned class outfits, for "and N more in your wardrobe"
    // useOutfitFanfare.ts
    export function useOutfitFanfare(heroId: () => string): { fanfare: Ref<{ piece: LookPiece; look: HeroLook } | null>; dismissFanfare: () => void }
  • Step 1: Write the failing tests:

    • outfitFanfareFor answers the worn outfit when it is owned and unseen
    • answers null when the worn outfit is seen, starter, or not a class piece
    • answers null for an owned but unworn class outfit (Review Focus 4)
    • useOutfitFanfare opens once on enter and marks the piece seen on dismiss
    • on enter, a hero catchUpOutfit answers for is dressed through the store first, then the fanfare opens with "and N more in your wardrobe"
  • Step 2: Run chemx test tests/unit/outfitFanfare.spec.ts. Expected: FAIL.

  • Step 3: Implement. The composable reads the hero on onIonViewDidEnter (and on mount); if catchUpOutfit(hero) answers it calls a store action dressHero(heroId, look) (Task 4 adds it beside grantLook: writes the look and persists) and re-reads the hero; then it sets fanfare from outfitFanfareFor, and dismissFanfare calls markLookSeen(heroId(), [piece.id]). MyPortal.vue renders <xp-hero-fanfare :open="Boolean(fanfare)" :name="fanfare?.piece.name" :job-class="user.jobClass" title="New outfit earned" action-text="Wear it" :look="fanfare?.look" @close="dismissFanfare" /> (match the fanfare's real close event name; read the component).

  • Step 4: Run the specs. Expected: PASS.

  • Step 5: Report on the board task.

Task 10: The Cypress case and the help line

Files:

  • Modify: tests/e2e/specs/mylook.spec.js (one new case)

  • Modify: tests/e2e/fixtures/battle-backup.json only if the seeded hero needs class levels (prefer setting them through win.__xp seams if one exists for stats; otherwise extend the fixture's hero-1 with stats.classes.Warrior.level = 5 and jobClass: "Warrior")

  • Modify: src/app/SideMenu/XpSupport/helpContent.ts (one entry: "How do I get new outfits?")

  • Test: tests/unit/helpContent.spec.ts (run)

  • Step 1: Write the Cypress case: a Warrior at class level 5 opens My Look; [data-cy=look-tile][data-option=warrior-1] and warrior-5 have data-locked="false", warrior-10 has data-locked="true"; tapping warrior-10 shows [data-cy=try-on-banner] containing "Learned as a Warrior (class level 10)"; Done shows the "Not yours yet" alert; Save; win.__xp.getUser("hero-1").look.outfit is warrior-5.

  • Step 2: Run it: pnpm vue-cli-service test:e2e --headless --browser chrome --url http://localhost:8100 --spec tests/e2e/specs/mylook.spec.js (the dev server on :8100 must already be running; never start a second). Expected: PASS.

  • Step 3: Add the help entry in the hero's section of helpContent.ts: picking a class dresses you in its outfit; class level 5 and 10 earn the next two; everything you have earned stays in the builder's Outfit tab; a locked one can be tried on but goes back on the shelf. Run chemx test tests/unit/helpContent.spec.ts.

  • Step 4: Report on the board task.


Waves (for the board)

  • Wave 1 (parallel): Task 1, Task 5 (parts and base; it can start from the spec's names before Task 1 lands because it keys on string values).
  • Wave 2 (parallel, after Task 1): Task 2, Task 6, Task 7 (Task 7 waits for 5 and 6).
  • Wave 3 (after 2): Task 3, then Task 4; Task 8 (after 1, 2, 7).
  • Wave 4 (after 4 and 8): Task 9, Task 10.
  • One chemx typecheck, chemx lint and production build after the last wave, then the PR.
⚠️ **GitHub.com Fallback** ⚠️