2026 10 27 kid creations - xopherdeep/do-it-for-the-xp GitHub Wiki

Kid Creations 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: A Maker kid draws a beast or suggests a quest for anyone in the family; nothing is live until a Game Master approves it from a Creations lane in Approvals; the kid gets credit and a little AP.

Architecture: A proposal block on the beast and quest records themselves (no new store): proposed quests also carry draft: true, which the hero lists already hide; proposed beasts are kept out of play by one isLiveBeast filter behind BestiaryDb.getLiveBeasts(). The kid's side is a portal modal (the Beast Maker) plus a who step in the Quest Wizard; the Game Master's side is a Creations segment in Approvals with a review service that approves, returns, pays AP once and notifies.

Tech Stack: Vue 3 + Ionic 7 + Pinia, TypeScript, jest (chemx test <spec>), Cypress 8 for the walk; copy through src/lib/services/copy in three voices.

Spec: docs/superpowers/specs/2026-10-27-kid-creations-design.md (board epic #1568; tasks #1572–#1581).

Global Constraints

  • Run only the spec you touch: chemx test tests/unit/Foo.spec.ts; chemx typecheck / chemx lint for a quick read. Never raw pnpm test, pnpm jest, npx jest, and never chemx verify locally. Verification of the whole branch happens once, after every task, not during.
  • Work in the worktree .claude/worktrees/feat+kid-creations on branch feat/kid-creations (node_modules is a symlink to the main checkout's). Board commands name the main board: chemx team task <...> --board /home/xopher/www/x/do-it-for-the-xp.
  • Commit only your own paths: git commit -- <paths>; never git add -A, never git stash.
  • Game rules are pure modules under src/lib/services/** with jest specs; screens stay thin. New components use <script setup lang="ts">; pages keep defineComponent with a sibling .ts / .scss.
  • Kid-facing words go through the copy catalog in the three voices (simple, adventure, classic); a voice that reads the same as adventure is left out. Sounds through play$fx(key) with keys exported by src/assets/fx/index.ts (select, cursor, no, openMenu, yes).
  • Phone first: pickers, tiles and action buttons sit at the bottom of the screen; content reads above. Keep the SNES look (existing classes xp-app-btn, claim__btn, qw-tile, chips from XpAppChips).
  • Ionic alerts render HTML; toasts use cssClass: "rpg-toast", position middle. Approvals keeps its own existing toast helper.
  • Values from the spec: MAKER_CAP = 10, PORTRAIT_SIDE = 512, PORTRAIT_MAX_BYTES = 150 * 1024, AP = suggestedRewards(3).ap for a beast and suggestedRewards(1).ap for a quest, AP source string "creation", credit lines "Drawn by {nick}", "Made by {nick}", "Suggested by {nick}".
  • Decision D5: a kid's quest for themselves on a library (or approved) beast saves live exactly as today.

Review Focus

  1. A maker with 10 waiting or returned creations taps Send: the Maker refuses with the cap line and saves nothing (Task 1 canPropose, Task 5 test "refuses at the cap").
  2. Approve tapped twice, or on two devices: AP is paid once (Task 6 creationReview.spec "pays once").
  3. A proposed beast never fights: random encounters and temple room picks only see live beasts (Task 1 BestiaryService test with a proposed beast).
  4. A kid's own quest on a library beast still saves live, no proposal, as today (Task 4 test "D5").
  5. A photo of a drawing that is tall and thin, or no image at all: the largest centred square is taken, and a non-image says why and saves nothing (Task 3 largestSquare, portraitFromFile rejection).

Task 1: Proposal model and the live-beast filter (board #1572, KC1)

Files:

  • Create: src/lib/services/creations/creations.ts
  • Modify: src/lib/databases/BestiaryDb.ts:19-33 (Beast fields), src/lib/databases/BestiaryDb.ts:55-100 (getLiveBeasts)
  • Modify: src/lib/databases/AchievementDb.ts:17-76 (proposal field)
  • Modify: src/lib/services/battle/BestiaryService.ts:64-98 (listBeasts, loadRandomBeast read live beasts)
  • Modify (one line each, getBeasts() β†’ getLiveBeasts()): src/app/Admin/XpCompendium/views/XpBestiary/XpBeastSelectionPage.vue:65, src/app/Admin/XpCompendium/views/XpTemples/components/XpTempleCreator/hooks/useRoomQuests.ts:122, .../useTempleRoomEditor.ts:342, src/app/Admin/XpCompendium/views/XpTemples/hooks/useTempleData.ts:289, .../useTempleQuests.ts:160,180, src/app/Console/TempleGrounds/templeGrounds/useRoomBeast.ts:195, src/app/SideMenu/XpSettings/components/HomeChoresModal.vue:214
  • Leave on getBeasts(): XpBestiary.vue, XpAddBeast.vue, deleteContent.ts, MyQuests.ts, XpBeastCardModal.vue (they must see every record).
  • Test: tests/unit/creations.spec.ts, tests/unit/BestiaryService.spec.ts (one added case), tests/unit/BestiaryDb.spec.ts (one added case)

Interfaces:

  • Produces (all exported from creations.ts):

    export type ProposalState = "proposed" | "returned" | "approved";
    export interface Proposal { by: string; on: string; state: ProposalState; note?: string; reviewedBy?: string; reviewedOn?: string; questId?: string; paidOn?: string }
    export interface Proposed { proposal?: Proposal }
    export type CreationKind = "beast" | "quest";
    export const MAKER_CAP = 10;
    export const propose = <T extends Proposed>(record: T, by: string, now: Date, questId?: string): T;   // state "proposed", on = now ISO
    export const isLive = (record: Proposed | null | undefined): boolean;                                  // no proposal, or approved
    export const isLiveBeast = (beast: Pick<Beast, "proposal"> | null | undefined): boolean;
    export const isWaiting = (record: Proposed): boolean;   // state proposed
    export const isReturned = (record: Proposed): boolean;
    export const approveCreation = <T extends Proposed>(record: T, reviewerId: string, now: Date): T;      // idempotent: an approved record comes back unchanged (same reference)
    export const approveQuest = (quest: Achievement, reviewerId: string, now: Date, assignee?: string[]): Achievement; // approveCreation + draft:false + assignee when given
    export const returnCreation = <T extends Proposed>(record: T, reviewerId: string, note: string | undefined, now: Date): T; // state returned, note kept (trimmed, empty β†’ undefined)
    export const resend = <T extends Proposed>(record: T, now: Date): T;                                   // returned β†’ proposed, on = now, note cleared; not returned β†’ unchanged
    export const openCount = (records: readonly Proposed[], by: string): number;                            // proposed or returned by this hero
    export const canPropose = (records: readonly Proposed[], by: string): boolean;                          // openCount < MAKER_CAP
    export const creationAp = (kind: CreationKind): number;                                                  // suggestedRewards(3).ap | suggestedRewards(1).ap
    export const makerOf = (record: { madeBy?: string; createdBy?: string; proposal?: Proposal }): string;  // madeBy ?? proposal.by ?? createdBy ?? ""
    export const creditLine = (record: { madeBy?: string; createdBy?: string; proposal?: Proposal; localImage?: string }, kind: CreationKind, nickOf: (id: string) => string): string; // "" when no maker
    export interface CreationRow { key: string; kind: CreationKind; id: string; name: string; by: string; on: string; state: ProposalState; note?: string; beast?: Beast; quest?: Achievement; pairedQuest?: Achievement }
    export const creationRows = (beasts: readonly Beast[], quests: readonly Achievement[], states: readonly ProposalState[] = ["proposed"]): CreationRow[];

    creationRows: one row per beast or quest whose proposal.state is in states; a beast whose proposal.questId names a quest in the list carries it as pairedQuest and that quest gets no row of its own; key is beast:<id> / quest:<id>; name is the beast's name or the quest's achievementName; sorted oldest on first.

  • Beast gains madeBy?: string and proposal?: Proposal; Achievement gains proposal?: Proposal. BestiaryDb.getLiveBeasts(): Promise<Beast[]> = getBeasts() filtered by isLiveBeast.

  • Step 1: Write the failing tests in tests/unit/creations.spec.ts: propose sets {by, on: now ISO, state: "proposed"} and questId when given; isLive true for no proposal and for approved, false for proposed and returned; approveCreation sets state, reviewedBy, reviewedOn and returns the same object when already approved; approveQuest clears draft and takes the assignee; returnCreation keeps a trimmed note and drops an empty one; resend only moves returned β†’ proposed and clears the note; openCount/canPropose at 9 and 10 records by the same hero (returned ones count, approved ones do not); creationAp("beast") === suggestedRewards(3).ap, creationAp("quest") === suggestedRewards(1).ap; creditLine gives "Drawn by Ronan" for a beast with localImage, "Made by Ronan" without, "Suggested by Jack" for a quest, "" for no maker; creationRows pairs a beast with its quest, skips the paired quest's own row, filters by state, sorts oldest first.

  • Step 2: Run chemx test tests/unit/creations.spec.ts. Expected: FAIL (module not found).

  • Step 3: Implement creations.ts with the signatures above (import suggestedRewards from @/lib/services/quests/sizeRewards).

  • Step 4: Run chemx test tests/unit/creations.spec.ts. Expected: PASS.

  • Step 5: Add the fields and getLiveBeasts; add to tests/unit/BestiaryDb.spec.ts a case "getLiveBeasts leaves out a proposed beast and keeps an approved one"; add to tests/unit/BestiaryService.spec.ts a case "loadRandomBeast never picks a proposed beast" (two beasts, one proposed, Math.random stubbed to 0 and to 0.99). Switch the listed call sites to getLiveBeasts().

  • Step 6: Run chemx test tests/unit/BestiaryDb.spec.ts and chemx test tests/unit/BestiaryService.spec.ts. Expected: PASS. Then chemx typecheck. Expected: no errors.

  • Step 7: Commit git commit -m "feat(creations): proposal model and the live-beast filter (#1572)" -- <your paths>.

Task 2: The Maker setting (board #1573, KC2)

Files:

  • Modify: src/lib/services/family/familySettings.ts:13-33,65-105 (makers, canMake)
  • Modify: src/lib/store/stores/gameRules.ts (makers ref, setMaker)
  • Modify: src/app/SideMenu/XpSettings/components/PartySettings.vue (a Makers card)
  • Test: tests/unit/FamilySettings.spec.ts

Interfaces:

  • Produces: FamilySettings.makers: string[] (default [], normalised to non-empty strings); export const canMake = (heroId: string | number | null | undefined, settings: Pick<FamilySettings, "makers">, isGm = false): boolean (true for a Game Master, else when makers holds the id as a string); store: makers: Ref<string[]>, setMaker(heroId: string, on: boolean): Promise<void> (saves { makers } through save).

  • Consumes: nothing from other tasks.

  • Step 1: Write the failing tests in tests/unit/FamilySettings.spec.ts: DEFAULT_FAMILY_SETTINGS.makers is []; normalising { makers: ["hero-1", 7, "", null] } gives ["hero-1", "7"]; canMake("hero-1", {makers: ["hero-1"]}) true, canMake("hero-2", …) false, canMake("hero-2", …, true) true.

  • Step 2: Run chemx test tests/unit/FamilySettings.spec.ts. Expected: FAIL.

  • Step 3: Implement the field, normalisation and canMake; add makers to the game rules store (take, return) and setMaker (the pattern of setApproveAllPurchases).

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

  • Step 5: The Party screen: replace the coming-soon body of PartySettings.vue with an ion-card "Makers" ("A Maker can draw beasts and suggest quests. A Game Master checks every one before it enters the game.") listing every profile that is not isAdult (from useUserStore().users) as an ion-item with the avatar (the $getUserAvatar global, as FamilySettings.vue does), the nick and an ion-toggle data-cy="maker-<id>" bound to rules.makers.includes(id) calling rules.setMaker(id, checked); the toggle is disabled unless canAccessGameMaster() (import from @/router/_Guards/gm.guard). Keep the party line and the coming-soon card below it. Call rules.load() on setup.

  • Step 6: Run chemx typecheck and chemx lint. Expected: no errors.

  • Step 7: Commit -- src/lib/services/family/familySettings.ts src/lib/store/stores/gameRules.ts src/app/SideMenu/XpSettings/components/PartySettings.vue tests/unit/FamilySettings.spec.ts.

Task 3: A photo of a drawing becomes a portrait (board #1574, KC3)

Files:

  • Create: src/lib/services/creations/beastPhoto.ts (pure), src/lib/services/creations/beastPhotoImage.ts (canvas), src/components/organisms/BeastPhoto/BeastPhotoPicker.vue, src/components/organisms/BeastPhoto/_BeastPhotoPicker.scss
  • Modify: src/lib/services/quests/claimPhotoImage.ts:24-30 (export loadImage)
  • Modify: src/app/Admin/XpCompendium/views/XpBestiary/beastWizard.ts (localImage on the draft), src/app/Admin/XpCompendium/views/XpBestiary/components/XpAddBeast.vue:19-30,267-291 (a Snap a drawing button, portrait from localImage)
  • Test: tests/unit/beastPhoto.spec.ts, tests/unit/beastWizard.spec.ts (added cases)

Interfaces:

  • Produces (beastPhoto.ts):

    export const PORTRAIT_SIDE = 512; export const PORTRAIT_MAX_BYTES = 150 * 1024; export const MIN_FRAME = 32;
    export interface CropFrame { x: number; y: number; size: number }           // source pixels, top-left and side
    export const largestSquare = (width: number, height: number): CropFrame;    // centred; {0,0,0} when a side is not > 0
    export const moveFrame = (frame: CropFrame, dx: number, dy: number, width: number, height: number): CropFrame; // clamped inside the picture
    export const resizeFrame = (frame: CropFrame, delta: number, width: number, height: number): CropFrame;         // keeps the centre; size clamped to [MIN_FRAME, min(width, height)], then moved inside

    (beastPhotoImage.ts): export const portraitFromFile = (file: File, frame?: (size: {width: number; height: number}) => CropFrame): Promise<string> β†’ a JPEG data URL PORTRAIT_SIDE square drawn from the frame (default largestSquare), quality PHOTO_QUALITY lowered by nextQuality until dataUrlBytes <= PORTRAIT_MAX_BYTES; throws PhotoRejectedError (from claimPhotoImage.ts) for a non-image, an unreadable file, or a picture still too big at the floor. Also export const portraitFromImage = (image: HTMLImageElement, frame: CropFrame): string (the drawing part, so the picker re-crops a loaded image without re-reading the file). BeastPhotoPicker.vue: a modal body (opened with modalController.create({ component: BeastPhotoPicker, cssClass: "beast-photo-modal" })) that dismisses with the data URL and role "picked", or null with role "cancel". data-test: photo-input, photo-take, photo-frame, photo-fit, photo-bigger, photo-smaller, photo-use, photo-cancel, photo-error. BeastDraft gains localImage?: string; hasLook is avatar > 0 || Boolean(localImage); beastPatch carries localImage; draftFromBeast copies it; beastSummary look value is "Your drawing" when localImage is set.

  • Consumes: fitPhoto, nextQuality, dataUrlBytes, PHOTO_QUALITY, isImageType from claimPhotos.ts; PhotoRejectedError, loadImage from claimPhotoImage.ts.

  • Step 1: Write the failing tests in tests/unit/beastPhoto.spec.ts: largestSquare(100, 300) β†’ {x: 0, y: 100, size: 100}; largestSquare(0, 10) β†’ zeros; moveFrame clamps at every edge; resizeFrame grows about the centre, never below MIN_FRAME nor beyond the short side, and comes back inside the picture. In tests/unit/beastWizard.spec.ts: a draft with localImage and avatar: 0 has no problem; beastPatch keeps localImage; the summary look line reads "Your drawing".

  • Step 2: Run both specs. Expected: FAIL.

  • Step 3: Implement beastPhoto.ts, the beastWizard.ts changes, and export loadImage.

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

  • Step 5: Implement beastPhotoImage.ts and the picker. Picker flow: Take a picture (file input accept="image/*" capture="environment", as ClaimPhotoPrompt.vue) β†’ the picture shown fitted in a box with a square frame overlay (position: absolute, moved by pointer drag through moveFrame, sized by Bigger / Smaller through resizeFrame, Fit β†’ largestSquare), the controls in a row at the bottom β†’ Use it calls portraitFromImage and dismisses. Errors show in photo-error with the catalog-free line "That picture didn't work. Try again or pick one from the library." Retro: the frame has the rpg-box border.

  • Step 6: The Game Master's form: under the beast art in the look step of XpAddBeast.vue, a second button data-test="snap-drawing" "Snap a drawing" opens the picker; on picked, draft.localImage = data and draft.avatar is left as is; the portrait computed prefers draft.localImage. Picking a library beast afterwards clears localImage.

  • Step 7: Run chemx typecheck, chemx lint. Expected: no errors. Open the form in the running app (/game-master/... beast create route, dev server on :8100 if it answers) and snap a file once if a browser is available; otherwise say so in the report.

  • Step 8: Commit with the task's paths.

Task 4: The Quest Wizard for a maker (board #1576, KC5)

Files:

  • Modify: src/lib/services/quests/questWizard.ts (wizardSteps, newDraft, WizardDraft.beastId, BuildContext.isMaker, buildWizardQuest, willPropose), src/lib/services/quests/questWizardSave.ts (link the beast, isMaker in the context), src/lib/services/quests/questPrefill.ts:23 (beastId on the prefill), src/lib/services/copy/catalog/questWizard.ts (maker lines), src/app/Console/MyPortal/UserHud/MyQuests/components/XpQuestWizardModal.vue (who step for a maker, family beast as the chosen picture, the ready note)
  • Test: tests/unit/questWizard.spec.ts, tests/unit/questWizardSave.spec.ts (new), tests/unit/XpQuestWizardModal.spec.ts (one added case)

Interfaces:

  • Consumes: Task 1 propose, isWaiting, isReturned, Proposal; Task 2 canMake and the store's makers.

  • Produces:

    export const wizardSteps = (isGm: boolean, isMaker = false): WizardStepId[];        // "who" present when isGm || isMaker
    export const questWizardSteps = (isGm: boolean, voice?: Voice, isMaker?: boolean): WizardStep<WizardDraft>[];
    interface WizardDraft { …; beastId?: string }                                           // a family beast chosen outright (the Maker's); entry stays null then
    interface NewDraftOptions { …; isMaker?: boolean }                                      // heroIds from prefill.assignee are kept for a maker too; prefill.beastId β†’ draft.beastId
    interface BuildContext { …; isMaker?: boolean; familyBeasts: ReadonlyArray<Pick<Beast, "id" | "proposal" | "checklist" | "name">> }
    export const willPropose = (draft: Pick<WizardDraft, "heroIds" | "beastId">, ctx: Pick<BuildContext, "isGm" | "isMaker" | "creatorId" | "familyBeasts">): boolean;
    interface BuiltQuest { quest; beast; linkBeast?: { id: string; proposal: Proposal } }  // the proposed family beast to point at this quest
    export type QuestPrefill = Partial<Pick<Achievement, "startsOn" | "timesOfDay" | "assignee" | "type">> & { beastId?: string };
    export type WizardSaveContext = Pick<BuildContext, "isGm" | "isMaker" | "creatorId" | "today" | "questId">;

    Rules in buildWizardQuest: heroIds come from the draft when isGm || isMaker (else the creator alone); with draft.beastId the quest gets { beastId, adventureType: "beast", noBeast: false }, beast: null, and steps default to that beast's checklist when the draft's steps are empty; willPropose is !isGm && isMaker && (heroIds has an id other than the creator || the family beast named by beastId is waiting or returned); when it is true the quest is propose({…quest, draft: true}, creatorId, now) and, when the beast is proposed, linkBeast = { id, proposal: { …beast.proposal, questId } }; requiresApproval stays true for a kid. saveWizardQuest writes linkBeast with setBeast before the quest. Copy keys: questWizard.guide.who already exists; add questWizard.who.me ("Me"), questWizard.who.everyone ("Everyone"), questWizard.who.parent ("A parent"), questWizard.ready.makerNote (simple "A grown-up looks at it first.", adventure "A Game Master looks at it first.", classic "Awaits Game Master review.").

  • Modal: isMaker = canMake(me, { makers: rules.makers }) (not a GM); the who step for a maker shows tiles Me, one per other profile (kids and parents alike, nick + avatar), Everyone (heroIds = every profile id); the draft's heroIds is a set like today (tap toggles; Me alone by default). With draft.beastId the "what" step is skipped to the steps screen and chosenPortrait is the beast's localImage or its avatar portrait; the ready card's qw-card__sub is the beast's name. The ready note shows questWizard.ready.makerNote when willPropose(...), else today's kidNote.

  • Step 1: Write the failing tests in tests/unit/questWizard.spec.ts: wizardSteps(false, true) holds "who"; newDraft({isMaker: true, heroId: "k1", prefill: {assignee: ["k2"], beastId: "b9"}}) keeps ["k2"] and beastId; buildWizardQuest cases: (D5) maker, self only, library entry β†’ no proposal, draft falsy; maker, heroIds: ["k1","k2"] β†’ draft: true, proposal.state "proposed", proposal.by "k1", assignee both; maker, self, beastId of a waiting family beast β†’ proposed and linkBeast.proposal.questId === quest.id, quest beastId set, steps = the beast's checklist; maker with an approved family beast, self β†’ live. willPropose the same four. New tests/unit/questWizardSave.spec.ts with plain-object stores (the WizardStores shape): the link beast is written before the quest.

  • Step 2: Run both specs. Expected: FAIL.

  • Step 3: Implement the pure changes and the save.

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

  • Step 5: The modal. Add the maker's who step, the family-beast picture and the ready note (above). In tests/unit/XpQuestWizardModal.spec.ts add one case the way its existing cases mount: a maker sees the who step with a Me tile and an Everyone tile.

  • Step 6: Run chemx test tests/unit/XpQuestWizardModal.spec.ts, chemx typecheck, chemx lint. Expected: PASS, no errors.

  • Step 7: Commit with the task's paths.

Task 5: The Beast Maker modal (board #1575, KC4)

Files:

  • Create: src/lib/services/creations/beastMaker.ts (pure), src/lib/services/copy/catalog/beastMaker.ts, src/app/Console/MyPortal/UserHud/MyQuests/components/XpBeastMakerModal.vue, .../_XpBeastMakerModal.scss
  • Modify: src/lib/services/copy/catalog/index.ts (register), src/app/Console/MyPortal/UserHud/MyQuests/questLogActions.ts, src/app/Console/MyPortal/MyHome/MyCalendar/calendarActions.ts, src/app/Console/MyPortal/UserHud/MyQuests/MyQuests.ts:346-370 and MyQuests.vue:64-70, src/app/Console/MyPortal/MyHome/MyCalendar/MyCalendar.vue:327-360
  • Test: tests/unit/beastMaker.spec.ts, tests/unit/questLogActions.spec.ts (new, or the existing spec for these two action lists if one exists), the copy catalog spec (keys unique: run the spec that imports CATALOG_PARTS)

Interfaces:

  • Consumes: Task 1 (propose, canPropose, openCount, MAKER_CAP); Task 3 (BeastPhotoPicker, BeastDraft.localImage, newBeastDraft, addChore, moveChore, dropChore, beastProblem, beastPatch from beastWizard.ts); Task 4 (QuestPrefill.beastId); Task 2 (canMake, rules.makers).

  • Produces (beastMaker.ts):

    export type MakerStepId = "look" | "name" | "chores" | "ready";
    export const makerSteps = (voice: Voice): WizardStep<BeastDraft>[];                    // guide from the catalog keys beastMaker.guide.<step>; isValid as beastWizard's, "ready" always valid
    export const MAX_MAKER_CHORES = 5;
    export const suggestNames = (chores: readonly string[], random: () => number = Math.random): string[]; // three names: the first chore's first keyword (librarySuggest.keywords) capitalised + one of NAME_SUFFIXES ["Beast", "Goblin", "Fiend", "Dragon", "Ghoul", "Gremlin"], no repeats; ["Mystery Beast", "The Lurker", "Chore Fiend"] when there is no keyword
    export const proposedBeast = (draft: BeastDraft, by: string, now: Date): Beast;         // beastPatch(draft) + madeBy + propose(...)

    Catalog keys (adventure fallback; simple is read aloud): beastMaker.title "Beast Maker"; beastMaker.guide.look (simple "Show me your beast!", adventure "Show the village your beast."), beastMaker.guide.name ("What is it called?"), beastMaker.guide.chores (simple "What chores beat it?", adventure "Which chores bring it down?"), beastMaker.guide.ready (simple "Send it to a grown-up.", adventure "Here it stands. Send it to the Game Master."); beastMaker.look.photo "Take a picture of your drawing"; beastMaker.look.library "Pick one from the library"; beastMaker.name.suggest "Suggest a name"; beastMaker.ready.note (simple "A grown-up looks at it first.", adventure "A Game Master looks at it first."); beastMaker.ready.send "Send it"; beastMaker.done.title (simple "Sent!", adventure "Sent to the Game Master!"); beastMaker.done.quest "Make a quest with it"; beastMaker.done.close "Done"; beastMaker.cap "Ask a Game Master to look at your creations first."; beastMaker.error.notSaved "Your beast could not be saved. Try again."; beastMaker.error.photo "That picture didn't work. Try again or pick one from the library." Actions: questLogActions(isMaker = false) and calendarActions({ isToday, isMaker }) append { id: "draw-beast", text: "Draw a Beast", icon: "paint-brush" } when isMaker; the id types gain "draw-beast".

  • The modal: props open: boolean, heroId: string; emits close, saved(beast: Beast), quest(beast: Beast). Built on XpWizardShell like XpQuestWizardModal.vue (steps from makerSteps(voice), verb "Send it"). Look step: two big tiles (photo β†’ BeastPhotoPicker modal; library β†’ the Quest Wizard's library tiles (choreTiles / browseLibrary) picking avatar and bg1/bg2 from the entry, then the background chooser button the Game Master's form uses). Name step: nameplate + input at the bottom + Suggest a name (three chips). Chores step: the Quest Wizard's step-list control, up to MAX_MAKER_CHORES. Ready: the card (portrait on its backdrop, name, chores, "Drawn by {nick}" via creditLine), the note, Send it. Send: canPropose(beasts, heroId) first (else the cap line through say), then BestiaryDb.setBeast(proposedBeast(draft, heroId, now)), fanfare like the Quest Wizard's, then two buttons: Make a quest with it (emits quest(beast)) and Done. The parent (MyQuests, MyCalendar) opens the Quest Wizard with wizardPrefill = { ...prefill, beastId: beast.id } on quest.

  • Step 1: Write the failing tests: tests/unit/beastMaker.spec.ts (steps ids and validity; suggestNames(["Sort the socks"]) gives three distinct names each ending in a suffix and starting with "Sock"; the fallback names; proposedBeast carries madeBy, proposal.state "proposed", localImage); questLogActions(true) and calendarActions({isToday: true, isMaker: true}) end with the draw-beast action, and without isMaker are unchanged.

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

  • Step 3: Implement beastMaker.ts, the catalog file (register it in index.ts), the two action lists.

  • Step 4: Run the specs and the catalog spec. Expected: PASS.

  • Step 5: Build the modal and wire it: MyQuests.ts data makerOpen: false, runAction("draw-beast") β†’ makerOpen = true; presentActionSheet passes isMaker; MyQuests.vue mounts <XpBeastMakerModal :open="makerOpen" :heroId="String(userId)" @close="makerOpen = false" @saved="loadBoard" @quest="onMakeQuest" /> where onMakeQuest sets the prefill's beastId and opens the wizard; the same in MyCalendar.vue. isMaker on both pages = canMake(heroId, { makers: rules.makers }) after rules.load(), false for a Game Master (they have the Compendium).

  • Step 6: Run chemx typecheck, chemx lint; open the quests page as a maker kid in the app if the dev server answers and walk Look β†’ Send once (report what you saw, or that no browser was available).

  • Step 7: Commit with the task's paths.

Task 6: The Creations lane in Approvals (board #1577, KC6)

Files:

  • Create: src/lib/services/creations/creationReview.ts, src/app/Admin/XpApprovals/components/ApprovalCreationRow.vue
  • Modify: src/lib/services/notifications/Notifications.ts:11-25,134-165,205-259 (three kinds, icons, builders), src/app/Admin/XpApprovals/XpApprovals.vue (segment, loader, handlers, history), src/app/Admin/XpCompendium/views/XpBestiary/components/XpAddBeast.vue:484-516 (saving a proposed beast approves it), src/app/Admin/XpCompendium/views/XpAchievements/XpConfigAchievement/hooks/useAchievementForm.ts (saving a proposed quest approves it)
  • Test: tests/unit/creationReview.spec.ts, tests/unit/ApprovalCreationRow.spec.ts, tests/unit/Notifications.spec.ts (added cases)

Interfaces:

  • Consumes: Task 1 (everything), Task 4 (a proposed quest's shape), recordAPGain on the user store ({ userId, amount, source: "creation", at }), useNotificationsStore().push.

  • Produces:

    // Notifications.ts
    type XpNotificationKind = … | "creation-waiting" | "creation-approved" | "creation-returned";
    export const CREATIONS_LINK = "/game-master/approvals?segment=creations";
    export const creationWaitingNotification = (gmId: string, row: { key: string; name: string; kind: CreationKind }, makerName: string, now?: Date): NewXpNotification; // refId row.key, title "A new creation awaits", body `${makerName} made a beast: "${name}".` / `${makerName} suggested a quest: "${name}".`
    export const creationApprovedNotification = (heroId: string, row: { key; name; kind }, ap: number): NewXpNotification; // title "Your creation is in the game!", body `"${name}" was approved. +${ap} AP.`, link questBoardLink(heroId)
    export const creationReturnedNotification = (heroId: string, row: { key; name; kind }, note?: string): NewXpNotification; // title "Your creation came back", body with the note or "Open the Beast Maker to fix it."
    // creationReview.ts (stores passed in like WizardStores; real ones by default)
    export interface ReviewStores { bestiaryDb: Pick<BestiaryDb, "getBeasts" | "setBeast">; achievementDb: Pick<AchievementDb, "getTasks" | "setTask"> }
    export const loadCreationRows = (states?: readonly ProposalState[], stores?: ReviewStores): Promise<CreationRow[]>;
    export const approveRow = (row: CreationRow, reviewerId: string, options?: { assignee?: string[]; now?: Date }, stores?: ReviewStores): Promise<{ ok: true; ap: number } | { ok: false; reason: "gone" | "settled" }>;
    export const returnRow = (row: CreationRow, reviewerId: string, note: string | undefined, stores?: ReviewStores): Promise<{ ok: boolean }>;
    export const approveStoredBeast = (beastId: string, reviewerId: string, stores?: ReviewStores): Promise<boolean>;   // used by the edit path: approves a stored beast whose proposal is not approved, pays, notifies; false when nothing to do
    export const approveStoredQuest = (questId: string, reviewerId: string, stores?: ReviewStores): Promise<boolean>;

    approveRow re-reads the record (settled when its state is already approved, gone when missing), writes approveCreation / approveQuest with paidOn = now on the proposal, then pays creationAp(kind) to the maker through recordAPGain only when the stored record had no paidOn, pushes creationApprovedNotification to the maker, and does the same for pairedQuest. returnRow writes returnCreation on the beast and its paired quest and pushes creationReturnedNotification. Idempotence rests on paidOn on the stored record, read fresh.

  • ApprovalCreationRow.vue: props row: CreationRow, makerName: string, avatar: string (the maker's), targetNames: string (quest: who it is for), portrait: string (beast: localImage or the avatar portrait URL the Beast Card uses); emits approve, reject, edit with the row; meta line "Drawn by Ronan Β· 3 chores" or "Suggested by Jack Β· for Mia Β· size 2 Β· mornings" (creditLine, whenLine-style words from the quest's timesOfDay, SIZE_CHOICES label) plus "+ a quest for Mia" when paired; waited line from waitedLabel(row.on); buttons data-test creation-approve, creation-send-back, creation-edit with the claim__btn classes (--approve, --back, and --ghost for Edit); swipe options Approve / Send back.

  • XpApprovals.vue: a creations segment with an ion-badge color="primary" count, the list of rows, empty line "No creations waiting"; loadApprovals also loads loadCreationRows(["proposed"]) into pendingCreations and loadCreationRows(["approved","returned"]) into recentCreations; historyRows adds them (key: row.key, title: row.name, userId: row.by, at: row.proposal reviewedOn ?? on, good: state === "approved", detail: "Beast" | "Quest" + " Β· " + creditLine, note, badge Approved / Sent back); approve: alert "Approve {name}? {maker} earns {ap} AP." and, for a quest (or paired quest) whose assignee is not only the maker, an alert with radio inputs (every profile, the row's assignee checked) first; send back: the textarea alert as claims; edit: router.push({ name: "xp-create-update-beast", params: { id } }) for a beast, and the route XpAchievements.ts uses to edit a quest for a quest. Reads ?segment=creations on enter to open the segment.

  • Edit path: XpAddBeast.summon after setBeast calls approveStoredBeast(id, me) when the stored beast is not live; useAchievementForm's save calls approveStoredQuest(id, me) when the stored quest has a proposal not approved (toast "Approved, with changes").

  • Step 1: Write the failing tests tests/unit/creationReview.spec.ts (pinia active; plain-object stores): approve writes approved state and paidOn, pays creationAp("beast") once to the maker (spy on recordAPGain), a second approveRow answers settled and pays nothing; a paired quest is approved with the chosen assignee; returnRow writes returned with the note on both; approveRow on a missing record answers gone. tests/unit/Notifications.spec.ts: the three kinds have icons and the builders carry the right refId, link and words. tests/unit/ApprovalCreationRow.spec.ts (the XpApprovals.spec.ts way): shows name, maker, meta, the three buttons with aria-labels, emits approve / reject / edit with the row, swipe options.

  • Step 2: Run the three specs. Expected: FAIL.

  • Step 3: Implement Notifications.ts additions, creationReview.ts, the row.

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

  • Step 5: Wire XpApprovals.vue (segment, loader, handlers, history, the query segment) and the two edit paths.

  • Step 6: Run chemx typecheck, chemx lint, chemx test tests/unit/XpApprovals.spec.ts. Expected: no errors, PASS.

  • Step 7: Commit with the task's paths.

Task 7: Watch, reminders and the dashboard tile (board #1578, KC7)

Files:

  • Modify: src/lib/services/notifications/approvalWatch.ts:20-78, src/app/Admin/XpDashboard/dashboardTiles.ts:37-38, src/app/Admin/XpDashboard/XpDashboard.ts:320-335 (and the tile's template line if it names the two counts)
  • Test: tests/unit/XpDashboard.spec.ts, the approval watch spec (find it with chemx f approvalWatch; create tests/unit/approvalWatch.spec.ts the way approvalReminders.spec.ts sets up stores if none exists)

Interfaces:

  • Consumes: Task 6 loadCreationRows, creationWaitingNotification.

  • Produces: ApprovalWatchRun.creations: number; approvalsTotal(chores: unknown, rewards: unknown, creations: unknown = 0): number; approvals.value = { chores, rewards, creations, total } on the dashboard.

  • Rule: runApprovalWatch loads the waiting creation rows for a Game Master, pushes creationWaitingNotification per row (the feed's dedupe by refId keeps it to once), counts them in creations, and still answers early as today when there are no claims and no creations; the device alert and the daily summary stay about claims.

  • Step 1: Write the failing tests: approvalsTotal(2, 1, 3) === 6, approvalsTotal(2, 1) === 3; the watch pushes one creation-waiting per waiting row and not twice across two runs, and creations counts them.

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

  • Step 3: Implement.

  • Step 4: Run the specs, chemx typecheck. Expected: PASS, no errors.

  • Step 5: Commit.

Task 8: My creations (board #1579, KC8)

Files:

  • Create: src/lib/services/creations/myCreations.ts
  • Modify: src/app/Console/MyPortal/UserHud/MyQuests/components/XpBeastMakerModal.vue (an opening list), src/lib/services/copy/catalog/beastMaker.ts (list lines), src/lib/services/compendium/deleteContent.ts or the profile delete path in src/app/SideMenu/XpSettings/components/FamilySettings.vue (maker gone)
  • Test: tests/unit/myCreations.spec.ts

Interfaces:

  • Consumes: Task 1 (creationRows, resend, returnCreation), Task 5 (the modal, draftFromBeast + localImage), Task 6 (creationReview stores shape).

  • Produces:

    export type MyCreationState = "waiting" | "back" | "inGame";
    export interface MyCreationRow { key: string; kind: CreationKind; id: string; name: string; state: MyCreationState; note?: string; on: string; portrait?: string; beast?: Beast; quest?: Achievement }
    export const myCreationRows = (beasts: readonly Beast[], quests: readonly Achievement[], heroId: string): MyCreationRow[];  // every record made by the hero with a proposal, newest on first
    export const stateChip = (state: MyCreationState, voice: Voice): string;   // catalog keys beastMaker.state.waiting ("Waiting"), .back ("Came back"), .inGame ("In the game")
    export const orphanCreations = (heroId: string, now: Date, stores?: ReviewStores): Promise<number>;  // every waiting creation by a deleted hero becomes returned with note "maker gone"; answers how many
  • The modal opens on the list when the hero has any creation, with Draw a new beast at the bottom; a row shows portrait, name, chip; a Came back row shows the note and Fix it (opens the steps with draftFromBeast) and Send again (resend written with setBeast); an In the game row opens the Beast Card (beast) or is plain (quest); swipe left on waiting / came-back rows: Delete (deleteBeast, and deleteQuest for a paired proposed quest). orphanCreations is called where a profile is deleted.

  • Step 1: Write the failing tests: myCreationRows states and order; orphanCreations returns the waiting ones with the note and counts them.

  • Step 2: Run. Expected: FAIL.

  • Step 3: Implement the module and the list screen, wire delete and orphaning.

  • Step 4: Run the spec, chemx typecheck, chemx lint. Expected: PASS, no errors.

  • Step 5: Commit.

Task 9: Credit everywhere (board #1580, KC9)

Files:

  • Modify: src/app/Admin/XpCompendium/views/XpBestiary/XpBestiary.vue:11-41,403-409 (All | Creations filter, a badge), src/app/Admin/XpCompendium/views/XpBestiary/components/XpBeastSelectorItem.vue (optional badge prop), src/app/Console/MyPortal/UserHud/MyQuests/components/XpBeastCardModal.vue (credit line under the name), src/app/Console/MyPortal/UserHud/MyQuests/questLogRows.ts:155-217 (credit on the row, shown by the quest list row component), src/lib/services/copy/catalog/beastCard.ts (nothing new unless a voice differs: credit lines are names, same in every voice)
  • Test: src/app/Console/MyPortal/UserHud/MyQuests/__tests__/questLogRows.spec.ts (added case), tests/unit/XpBestiary.spec.ts if one exists (else none: the filter is a computed, cover it by extracting bestiaryFilter(beasts, segment, search) into src/app/Admin/XpCompendium/views/XpBestiary/bestiaryFilter.ts with tests/unit/bestiaryFilter.spec.ts)

Interfaces:

  • Consumes: Task 1 creditLine, isLive, isWaiting, isReturned.

  • Produces: bestiaryFilter(beasts: readonly Beast[], segment: "all" | "creations", search: string): Beast[] ("all" = live beasts matching the search; "creations" = every beast with a proposal, matching the search); QuestLogRow.credit?: string ("Suggested by Jack" when the quest has a proposal; the list row prints it after the subtitle); badge text on a bestiary card: "Waiting", "Came back", or the credit line for an approved kid beast.

  • Step 1: Write the failing tests: bestiaryFilter keeps proposed beasts out of "all" and lists them under "creations"; toLogRow carries credit for a proposed-then-approved quest and none for a Compendium quest.

  • Step 2: Run. Expected: FAIL.

  • Step 3: Implement the filter module and segment (an ion-segment under the search box), the badge, the card's credit line, the row's credit.

  • Step 4: Run the specs, chemx typecheck, chemx lint. Expected: PASS, no errors.

  • Step 5: Commit.

Task 10: The Cypress walk and the docs (board #1581, KC10)

Files:

  • Create: tests/e2e/specs/kid-creations.spec.js, tests/e2e/fixtures/drawing.png (a 300x400 PNG with a simple shape, generated with Python/PIL or any tool; under 50 KB)
  • Modify: tests/e2e/fixtures/battle-backup.json only if the walk needs a second kid (prefer cy.seedWorld() plus creating what the walk needs through window.__xp), docs/Roadmap.md (an entry under the current milestone), the spec's follow-ups filed on the board (pixel pad, paper cut-out, Scriptorium building, free kid phone proposals) as chemx team task add --parent 1568 --board /home/xopher/www/x/do-it-for-the-xp
  • Test: the walk itself, run against the shared dev server: pnpm vue-cli-service test:e2e --headless --browser chrome --url http://localhost:8100 --spec tests/e2e/specs/kid-creations.spec.js (only if curl -s localhost:8100 answers; never start a second server)

Interfaces:

  • Consumes: everything. Seams: window.__xp.loginUser, restoreBackup, getUser; the family settings store (turn the kid into a maker through the Party settings screen in the walk, or through window.__xp if a saveFamilySettings seam is added to src/main.ts's dev seams in this task).

  • Walk: sign in as the kid (hero-1, Max) β†’ quests page FAB β†’ Draw a Beast β†’ pick from the library (the file-input path is covered by selecting tests/e2e/fixtures/drawing.png with cy.get('[data-test=photo-input]').selectFile(...) if the Cypress version supports selectFile; else the library path only, and say so) β†’ name β†’ two chores β†’ Send it β†’ Make a quest with it β†’ Everyone β†’ Send β†’ sign in as the Game Master β†’ Approvals β†’ Creations shows the paired row β†’ Approve (chooser β†’ keep) β†’ the bestiary lists the beast under All with "Drawn by Max" β†’ the kid's AP rose by creationAp("beast") + creationAp("quest") (read window.__xp.getUser). Screenshots at each phase in tests/e2e/screenshots.

  • Step 1: Write the walk as described; add any missing data-test hooks to the screens it touches (small, named additions only).

  • Step 2: Run it against the shared server if it answers. Expected: PASS with screenshots. If no server answers, run chemx typecheck and leave the walk ready, saying so in the report.

  • Step 3: Docs and follow-ups: the Roadmap entry; the four follow-up tasks on the board; confirm the note on #1508 is there (comment 7231).

  • Step 4: Commit.


Waves (for the dispatcher)

  • Wave 1: Task 1, Task 2, Task 3 (independent).
  • Wave 2: Task 4.
  • Wave 3: Task 5 and Task 6 (both need Task 4; independent of each other).
  • Wave 4: Task 7, Task 8, Task 9 (need Task 6; Task 8 also Task 5).
  • Wave 5: Task 10, then one chemx typecheck + chemx lint over the branch, then the PR.
⚠️ **GitHub.com Fallback** ⚠️