2026 10 27 kid creations - xopherdeep/do-it-for-the-xp GitHub Wiki
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: 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).
- Run only the spec you touch:
chemx test tests/unit/Foo.spec.ts;chemx typecheck/chemx lintfor a quick read. Never rawpnpm test,pnpm jest,npx jest, and neverchemx verifylocally. Verification of the whole branch happens once, after every task, not during. - Work in the worktree
.claude/worktrees/feat+kid-creationson branchfeat/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>; nevergit add -A, nevergit stash. - Game rules are pure modules under
src/lib/services/**with jest specs; screens stay thin. New components use<script setup lang="ts">; pages keepdefineComponentwith 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 throughplay$fx(key)with keys exported bysrc/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 fromXpAppChips). - Ionic alerts render HTML; toasts use
cssClass: "rpg-toast", positionmiddle. 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).apfor a beast andsuggestedRewards(1).apfor 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.
- 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"). - Approve tapped twice, or on two devices: AP is paid once (Task 6
creationReview.spec"pays once"). - A proposed beast never fights: random encounters and temple room picks only see live beasts (Task 1 BestiaryService test with a proposed beast).
- A kid's own quest on a library beast still saves live, no proposal, as today (Task 4 test "D5").
- 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,portraitFromFilerejection).
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(proposalfield) - Modify:
src/lib/services/battle/BestiaryService.ts:64-98(listBeasts,loadRandomBeastread 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 whoseproposal.stateis instates; a beast whoseproposal.questIdnames a quest in the list carries it aspairedQuestand that quest gets no row of its own;keyisbeast:<id>/quest:<id>;nameis the beast's name or the quest'sachievementName; sorted oldestonfirst. -
BeastgainsmadeBy?: stringandproposal?: Proposal;Achievementgainsproposal?: Proposal.BestiaryDb.getLiveBeasts(): Promise<Beast[]>=getBeasts()filtered byisLiveBeast. -
Step 1: Write the failing tests in
tests/unit/creations.spec.ts:proposesets{by, on: now ISO, state: "proposed"}andquestIdwhen given;isLivetrue for no proposal and for approved, false for proposed and returned;approveCreationsets state,reviewedBy,reviewedOnand returns the same object when already approved;approveQuestclearsdraftand takes the assignee;returnCreationkeeps a trimmed note and drops an empty one;resendonly moves returned β proposed and clears the note;openCount/canProposeat 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;creditLinegives "Drawn by Ronan" for a beast withlocalImage, "Made by Ronan" without, "Suggested by Jack" for a quest, "" for no maker;creationRowspairs 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.tswith the signatures above (importsuggestedRewardsfrom@/lib/services/quests/sizeRewards). -
Step 4: Run
chemx test tests/unit/creations.spec.ts. Expected: PASS. -
Step 5: Add the fields and
getLiveBeasts; add totests/unit/BestiaryDb.spec.tsa case "getLiveBeasts leaves out a proposed beast and keeps an approved one"; add totests/unit/BestiaryService.spec.tsa case "loadRandomBeast never picks a proposed beast" (two beasts, one proposed,Math.randomstubbed to 0 and to 0.99). Switch the listed call sites togetLiveBeasts(). -
Step 6: Run
chemx test tests/unit/BestiaryDb.spec.tsandchemx test tests/unit/BestiaryService.spec.ts. Expected: PASS. Thenchemx typecheck. Expected: no errors. -
Step 7: Commit
git commit -m "feat(creations): proposal model and the live-beast filter (#1572)" -- <your paths>.
Files:
- Modify:
src/lib/services/family/familySettings.ts:13-33,65-105(makers,canMake) - Modify:
src/lib/store/stores/gameRules.ts(makersref,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 whenmakersholds the id as a string); store:makers: Ref<string[]>,setMaker(heroId: string, on: boolean): Promise<void>(saves{ makers }throughsave). -
Consumes: nothing from other tasks.
-
Step 1: Write the failing tests in
tests/unit/FamilySettings.spec.ts:DEFAULT_FAMILY_SETTINGS.makersis[]; 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; addmakersto the game rules store (take, return) andsetMaker(the pattern ofsetApproveAllPurchases). -
Step 4: Run the spec. Expected: PASS.
-
Step 5: The Party screen: replace the coming-soon body of
PartySettings.vuewith anion-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 notisAdult(fromuseUserStore().users) as anion-itemwith the avatar (the$getUserAvatarglobal, asFamilySettings.vuedoes), the nick and anion-toggledata-cy="maker-<id>"bound torules.makers.includes(id)callingrules.setMaker(id, checked); the toggle is disabled unlesscanAccessGameMaster()(import from@/router/_Guards/gm.guard). Keep the party line and the coming-soon card below it. Callrules.load()on setup. -
Step 6: Run
chemx typecheckandchemx 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.
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(exportloadImage) - Modify:
src/app/Admin/XpCompendium/views/XpBestiary/beastWizard.ts(localImageon the draft),src/app/Admin/XpCompendium/views/XpBestiary/components/XpAddBeast.vue:19-30,267-291(a Snap a drawing button, portrait fromlocalImage) - 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 URLPORTRAIT_SIDEsquare drawn from the frame (defaultlargestSquare), qualityPHOTO_QUALITYlowered bynextQualityuntildataUrlBytes <= PORTRAIT_MAX_BYTES; throwsPhotoRejectedError(fromclaimPhotoImage.ts) for a non-image, an unreadable file, or a picture still too big at the floor. Alsoexport 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 withmodalController.create({ component: BeastPhotoPicker, cssClass: "beast-photo-modal" })) that dismisses with the data URL and role"picked", ornullwith role"cancel".data-test:photo-input,photo-take,photo-frame,photo-fit,photo-bigger,photo-smaller,photo-use,photo-cancel,photo-error.BeastDraftgainslocalImage?: string;hasLookisavatar > 0 || Boolean(localImage);beastPatchcarrieslocalImage;draftFromBeastcopies it;beastSummarylook value is "Your drawing" whenlocalImageis set. -
Consumes:
fitPhoto,nextQuality,dataUrlBytes,PHOTO_QUALITY,isImageTypefromclaimPhotos.ts;PhotoRejectedError,loadImagefromclaimPhotoImage.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;moveFrameclamps at every edge;resizeFramegrows about the centre, never belowMIN_FRAMEnor beyond the short side, and comes back inside the picture. Intests/unit/beastWizard.spec.ts: a draft withlocalImageandavatar: 0has no problem;beastPatchkeepslocalImage; the summary look line reads "Your drawing". -
Step 2: Run both specs. Expected: FAIL.
-
Step 3: Implement
beastPhoto.ts, thebeastWizard.tschanges, and exportloadImage. -
Step 4: Run both specs. Expected: PASS.
-
Step 5: Implement
beastPhotoImage.tsand the picker. Picker flow: Take a picture (file inputaccept="image/*" capture="environment", asClaimPhotoPrompt.vue) β the picture shown fitted in a box with a square frame overlay (position: absolute, moved by pointer drag throughmoveFrame, sized by Bigger / Smaller throughresizeFrame, Fit βlargestSquare), the controls in a row at the bottom β Use it callsportraitFromImageand dismisses. Errors show inphoto-errorwith the catalog-free line "That picture didn't work. Try again or pick one from the library." Retro: the frame has therpg-boxborder. -
Step 6: The Game Master's form: under the beast art in the look step of
XpAddBeast.vue, a second buttondata-test="snap-drawing""Snap a drawing" opens the picker; onpicked,draft.localImage = dataanddraft.avataris left as is; theportraitcomputed prefersdraft.localImage. Picking a library beast afterwards clearslocalImage. -
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.
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,isMakerin the context),src/lib/services/quests/questPrefill.ts:23(beastIdon 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 2canMakeand the store'smakers. -
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 whenisGm || isMaker(else the creator alone); withdraft.beastIdthe quest gets{ beastId, adventureType: "beast", noBeast: false },beast: null, andstepsdefault to that beast's checklist when the draft's steps are empty;willProposeis!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 ispropose({β¦quest, draft: true}, creatorId, now)and, when the beast is proposed,linkBeast = { id, proposal: { β¦beast.proposal, questId } };requiresApprovalstaystruefor a kid.saveWizardQuestwriteslinkBeastwithsetBeastbefore the quest. Copy keys:questWizard.guide.whoalready exists; addquestWizard.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'sheroIdsis a set like today (tap toggles; Me alone by default). Withdraft.beastIdthe "what" step is skipped to the steps screen andchosenPortraitis the beast'slocalImageor its avatar portrait; the ready card'sqw-card__subis the beast's name. The ready note showsquestWizard.ready.makerNotewhenwillPropose(...), else today'skidNote. -
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"]andbeastId;buildWizardQuestcases: (D5) maker, self only, library entry β noproposal,draftfalsy; maker,heroIds: ["k1","k2"]βdraft: true,proposal.state "proposed",proposal.by "k1", assignee both; maker, self,beastIdof a waiting family beast β proposed andlinkBeast.proposal.questId === quest.id, questbeastIdset, steps = the beast's checklist; maker with an approved family beast, self β live.willProposethe same four. Newtests/unit/questWizardSave.spec.tswith plain-object stores (theWizardStoresshape): 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.tsadd 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.
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-370andMyQuests.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 importsCATALOG_PARTS)
Interfaces:
-
Consumes: Task 1 (
propose,canPropose,openCount,MAKER_CAP); Task 3 (BeastPhotoPicker,BeastDraft.localImage,newBeastDraft,addChore,moveChore,dropChore,beastProblem,beastPatchfrombeastWizard.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)andcalendarActions({ isToday, isMaker })append{ id: "draw-beast", text: "Draw a Beast", icon: "paint-brush" }whenisMaker; the id types gain"draw-beast". -
The modal: props
open: boolean,heroId: string; emitsclose,saved(beast: Beast),quest(beast: Beast). Built onXpWizardShelllikeXpQuestWizardModal.vue(steps frommakerSteps(voice), verb "Send it"). Look step: two big tiles (photo βBeastPhotoPickermodal; library β the Quest Wizard's library tiles (choreTiles/browseLibrary) pickingavatarandbg1/bg2from 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 toMAX_MAKER_CHORES. Ready: the card (portrait on its backdrop, name, chores, "Drawn by {nick}" viacreditLine), the note, Send it. Send:canPropose(beasts, heroId)first (else the cap line throughsay), thenBestiaryDb.setBeast(proposedBeast(draft, heroId, now)), fanfare like the Quest Wizard's, then two buttons: Make a quest with it (emitsquest(beast)) and Done. The parent (MyQuests,MyCalendar) opens the Quest Wizard withwizardPrefill = { ...prefill, beastId: beast.id }onquest. -
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;proposedBeastcarriesmadeBy,proposal.state "proposed",localImage);questLogActions(true)andcalendarActions({isToday: true, isMaker: true})end with the draw-beast action, and withoutisMakerare unchanged. -
Step 2: Run the specs. Expected: FAIL.
-
Step 3: Implement
beastMaker.ts, the catalog file (register it inindex.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.tsdatamakerOpen: false,runAction("draw-beast")βmakerOpen = true;presentActionSheetpassesisMaker;MyQuests.vuemounts<XpBeastMakerModal :open="makerOpen" :heroId="String(userId)" @close="makerOpen = false" @saved="loadBoard" @quest="onMakeQuest" />whereonMakeQuestsets the prefill'sbeastIdand opens the wizard; the same inMyCalendar.vue.isMakeron both pages =canMake(heroId, { makers: rules.makers })afterrules.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.
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),
recordAPGainon 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>;
approveRowre-reads the record (settledwhen its state is already approved,gonewhen missing), writesapproveCreation/approveQuestwithpaidOn = nowon the proposal, then payscreationAp(kind)to the maker throughrecordAPGainonly when the stored record had nopaidOn, pushescreationApprovedNotificationto the maker, and does the same forpairedQuest.returnRowwritesreturnCreationon the beast and its paired quest and pushescreationReturnedNotification. Idempotence rests onpaidOnon the stored record, read fresh. -
ApprovalCreationRow.vue: propsrow: CreationRow,makerName: string,avatar: string(the maker's),targetNames: string(quest: who it is for),portrait: string(beast:localImageor the avatar portrait URL the Beast Card uses); emitsapprove,reject,editwith 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'stimesOfDay,SIZE_CHOICESlabel) plus "+ a quest for Mia" when paired; waited line fromwaitedLabel(row.on); buttonsdata-testcreation-approve,creation-send-back,creation-editwith theclaim__btnclasses (--approve,--back, and--ghostfor Edit); swipe options Approve / Send back. -
XpApprovals.vue: acreationssegment with anion-badge color="primary"count, the list of rows, empty line "No creations waiting";loadApprovalsalso loadsloadCreationRows(["proposed"])intopendingCreationsandloadCreationRows(["approved","returned"])intorecentCreations;historyRowsadds 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 routeXpAchievements.tsuses to edit a quest for a quest. Reads?segment=creationson enter to open the segment. -
Edit path:
XpAddBeast.summonaftersetBeastcallsapproveStoredBeast(id, me)when the stored beast is not live;useAchievementForm's save callsapproveStoredQuest(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 andpaidOn, payscreationAp("beast")once to the maker (spy onrecordAPGain), a secondapproveRowanswerssettledand pays nothing; a paired quest is approved with the chosen assignee;returnRowwrites returned with the note on both;approveRowon a missing record answersgone.tests/unit/Notifications.spec.ts: the three kinds have icons and the builders carry the rightrefId, link and words.tests/unit/ApprovalCreationRow.spec.ts(theXpApprovals.spec.tsway): 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.tsadditions,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.
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 withchemx f approvalWatch; createtests/unit/approvalWatch.spec.tsthe wayapprovalReminders.spec.tssets 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:
runApprovalWatchloads the waiting creation rows for a Game Master, pushescreationWaitingNotificationper row (the feed's dedupe byrefIdkeeps it to once), counts them increations, 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 onecreation-waitingper waiting row and not twice across two runs, andcreationscounts 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.
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.tsor the profile delete path insrc/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 (creationReviewstores 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 (resendwritten withsetBeast); an In the game row opens the Beast Card (beast) or is plain (quest); swipe left on waiting / came-back rows: Delete (deleteBeast, anddeleteQuestfor a paired proposed quest).orphanCreationsis called where a profile is deleted. -
Step 1: Write the failing tests:
myCreationRowsstates and order;orphanCreationsreturns 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.
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(optionalbadgeprop),src/app/Console/MyPortal/UserHud/MyQuests/components/XpBeastCardModal.vue(credit line under the name),src/app/Console/MyPortal/UserHud/MyQuests/questLogRows.ts:155-217(crediton 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.tsif one exists (else none: the filter is a computed, cover it by extractingbestiaryFilter(beasts, segment, search)intosrc/app/Admin/XpCompendium/views/XpBestiary/bestiaryFilter.tswithtests/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:
bestiaryFilterkeeps proposed beasts out of "all" and lists them under "creations";toLogRowcarriescreditfor 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-segmentunder 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.
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.jsononly if the walk needs a second kid (prefercy.seedWorld()plus creating what the walk needs throughwindow.__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) aschemx 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 ifcurl -s localhost:8100answers; 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 throughwindow.__xpif asaveFamilySettingsseam is added tosrc/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.pngwithcy.get('[data-test=photo-input]').selectFile(...)if the Cypress version supportsselectFile; 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 bycreationAp("beast") + creationAp("quest")(readwindow.__xp.getUser). Screenshots at each phase intests/e2e/screenshots. -
Step 1: Write the walk as described; add any missing
data-testhooks 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 typecheckand 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.
- 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 lintover the branch, then the PR.