2026 10 22 battle theme tokens - 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: Every battle surface takes its colours from the chosen Game Style through four new theme tokens, and both attack menus show notes, grey out unusable moves and mark the active row with the theme's cursor.
Architecture: The ten theme files each set --gs-good, --gs-danger, --gs-button and --gs-button-edge beside their existing tokens, and Earthbound gets its own ion-app#earthbound block so it stops being the silent fallback. A pure helper (battleMenuStatus) derives what Tackle, Powers and Goods can do from the same card functions the sheets use; applyMenuStatus copies that onto the menu actions both menus render. Battle SCSS reads tokens only.
Tech Stack: Vue 3 + Ionic 7, SCSS, jest with @vue/test-utils, Cypress 8 (chrome). All checks through chemx.
Spec: docs/superpowers/specs/2026-10-22-battle-theme-tokens-design.md
- Work in an isolated git worktree on branch
feat/battle-theme-tokens(superpowers:using-git-worktrees). In the worktree,git add <explicit paths>thengit commitis fine; nevergit add -A, nevergit stash, never touch the shared checkout's branch. - Never run
pnpm test,pnpm jest,npx jest,chemx testwith no path, orchemx verify. Run only the spec you touch:chemx test tests/unit/Foo.spec.ts.chemx typecheckandchemx lintonce, in the last task. - A page whose
.vuehas<script src="./X.ts">caches in jest: not relevant here (no page spec is written). - Keep the SNES look. No new dependencies, no new art. Controls stay at the bottom of the phone; the nav bars are untouched.
- Hex literals: none new in battle files. Only
$inkand$outlinein_BattleEnemyStage.scssand the loot gold in_BattleVictoryCard.scssstay (spec 2). - Every commit message ends with
Co-Authored-By: Claude Fable 5.1 <[email protected]>. - Earthbound must look the same after every task, except the two accepted changes in the spec's Goals (message text cream, cursor on touch).
- A theme sets a token to an empty value (
--gs-good: ;) and the guard passes: the guard must require a non-empty value. (Task 1 test.) - The bag's container tiles (Bottles, Book of Spells) have no reason and would count as usable Goods: only tiles with the
effectrole and potions count. (Task 3 test "a container tile never counts".) - A free Power (
mp: 0) that is castable must read0 MP, notnone. (Task 3 test.) - A disabled pill or row reached by keyboard and pressed with Enter must not fire the action. (Tasks 2 and 5 tests.)
- The retro list's 150 ms
is-currenttimer outlives the component when the sheet opens at once: it is cleared on unmount. (Task 2 test.)
Files:
- Modify:
tests/unit/gameStyle.spec.ts - Modify:
src/styles/themes/_gameStyle.scss:1-35(header comment: document the four tokens) - Modify:
src/styles/themes/earthbound.scss:1-40(add@use "gameStyle" as *;after@use "sass:color";, and the new block after.ion-color-rpg {}) - Modify: the nine theme files
chronoTrigger.scss,dragonQuest.scss,finalfantasy.scss,marioRpg.scss,phantasyStar.scss,pokemon.scss,secretOfMana.scss,virtualBoy.scss,zelda.scss(four lines each, after--gs-select)
Interfaces:
-
Produces: CSS custom properties
--gs-good,--gs-danger,--gs-button,--gs-button-edgeon everyion-app#<id>, and the full existing set onion-app#earthbound. Later tasks read them withvar(--gs-...)and no fallback. -
Step 1: Write the failing guard
Append to tests/unit/gameStyle.spec.ts:
import fs from "fs";
import path from "path";
const THEME_DIR = path.resolve(__dirname, "../../src/styles/themes");
const TOKENS = [
"font", "font-title", "text", "text-dim", "accent", "text-shadow", "page", "page-color", "window", "window-color",
"frame", "radius", "bar", "bar-frame", "bar-frame-top", "divider", "select", "cursor", "cursor-w", "cursor-h",
"good", "danger", "button", "button-edge",
];
/** The theme's file, found without caring about case (finalFantasy lives in finalfantasy.scss). */
const themeFile = (id: string) =>
fs.readdirSync(THEME_DIR).find((name) => name.toLowerCase() === `${id.toLowerCase()}.scss`);
describe("theme token contract", () => {
it.each(GAME_STYLES)("%s sets every token with a value", (id) => {
const file = themeFile(id);
expect(file).toBeDefined();
const scss = fs.readFileSync(path.join(THEME_DIR, file as string), "utf8");
const block = scss.slice(scss.indexOf(`ion-app#${id}`));
expect(block.length).toBeGreaterThan(0);
const missing = TOKENS.filter((token) => !new RegExp(`--gs-${token}:\\s*[^;\\s][^;]*;`).test(block));
expect(missing).toEqual([]);
});
});- Step 2: Run it to see it fail
Run: chemx test tests/unit/gameStyle.spec.ts
Expected: FAIL for all ten styles (Earthbound has no block; the others miss good, danger, button, button-edge).
- Step 3: Add the four tokens to the nine themes
In each file, after the --gs-select: line, add the four tokens with the values of spec table 1.3 for that theme.
- Step 4: Add the Earthbound block
In earthbound.scss, add @use "gameStyle" as *; as the second line, then after the .ion-color-rpg rule:
// Earthbound sets the same Game Style tokens as the other themes, with the values its boxes have always used
ion-app#earthbound {
--gs-font: "Apple Kid", "StatusPlz", sans-serif;
--gs-font-title: "Apple Kid", "Twoson", sans-serif;
--gs-text: #{$eb-color-cream};
--gs-text-dim: rgba(231, 230, 179, 0.6);
--gs-accent: #{$eb-color-pale-yellow};
--gs-text-shadow: none;
--gs-page-color: #{$eb-color-dark-purple};
--gs-page: #{$eb-color-dark-purple};
--gs-window-color: #{$eb-color-dark-purple};
--gs-window: #{$eb-color-dark-purple};
--gs-radius: 1px;
--gs-frame: 0 0 0 5px #{$eb-color-medium-purple}, 0 0 0 10px #{$eb-color-minty-blue}, 0 0 0 12px #{$eb-color-pale-yellow}, 0 0 0 16px #{$eb-color-slate};
--gs-bar: #{$eb-color-slate};
--gs-bar-frame: var(--gs-frame);
--gs-bar-frame-top: var(--gs-frame);
--gs-divider: rgba(231, 230, 179, 0.15);
--gs-select: rgba(231, 230, 179, 0.08);
--gs-cursor: #{gs-arrow-cursor("f7e8a8")};
--gs-cursor-w: 8px;
--gs-cursor-h: 12px;
--gs-good: #{$eb-color-mint-green};
--gs-danger: #ff4a4a;
--gs-button: #4a2a4a;
--gs-button-edge: #1a0a1a;
}The --gs-page value is the plain colour, not the checkerboard: Earthbound never includes game-style-surfaces, so nothing reads it; the token exists to satisfy the contract.
- Step 5: Document the tokens
In the header comment of _gameStyle.scss, add four lines to the token list: --gs-good (success, healing, done), --gs-danger (harm, urgency, refusal), --gs-button / --gs-button-edge (a chunky confirm button's fill and hard shadow).
- Step 6: Run the guard
Run: chemx test tests/unit/gameStyle.spec.ts
Expected: PASS, ten cases.
- Step 7: Commit
git add tests/unit/gameStyle.spec.ts src/styles/themes/
git commit -m "style(themes): four battle tokens (good, danger, button, button-edge) in every Game Style; Earthbound sets the full contract; a guard spec
Co-Authored-By: Claude Fable 5.1 <[email protected]>"Files:
- Modify:
src/components/molecules/RpgMenu/XpRpgMenu.vue - Create:
tests/unit/XpRpgMenu.spec.ts
Interfaces:
-
Produces:
RpgMenuActiongainsnote?: stringanddisabled?: boolean. A row renders<span class="item-note">whennoteis set, carriesaria-disabled="true"anddisabledwhen disabled, and the classis-currentfor 150 ms after a tap. Existing callers (SwitchProfile, XpCardMenu, XpFabUserHud, BattleDialogBox) pass neither field and see no change. -
Step 1: Write the failing component spec
import { mount } from "@vue/test-utils";
import XpRpgMenu from "@/components/molecules/RpgMenu/XpRpgMenu.vue";
const action = (id: string, extra = {}) => ({ id, label: id, faIcon: "fist-raised", click: jest.fn(), ...extra });
describe("XpRpgMenu", () => {
beforeEach(() => jest.useFakeTimers());
afterEach(() => jest.useRealTimers());
it("renders a note on the row that has one and none on the others", () => {
const w = mount(XpRpgMenu, { props: { actions: [action("attack", { note: "3 chores" }), action("defend")] } });
expect(w.find("#attack .item-note").text()).toBe("3 chores");
expect(w.find("#defend .item-note").exists()).toBe(false);
});
it("a disabled row is greyed, says so to screen readers and never fires", async () => {
const powers = action("powers", { disabled: true, note: "no MP" });
const w = mount(XpRpgMenu, { props: { actions: [powers] } });
const row = w.find("#powers");
expect(row.attributes("aria-disabled")).toBe("true");
expect(row.attributes("disabled")).toBeDefined();
await row.trigger("click");
await row.trigger("keydown.enter");
expect(powers.click).not.toHaveBeenCalled();
expect(w.emitted("action-click")).toBeUndefined();
});
it("a tapped row is current for 150 ms, then not", async () => {
const w = mount(XpRpgMenu, { props: { actions: [action("attack")] } });
await w.find("#attack").trigger("click");
expect(w.find("#attack").classes()).toContain("is-current");
jest.advanceTimersByTime(150);
await w.vm.$nextTick();
expect(w.find("#attack").classes()).not.toContain("is-current");
});
it("clears the current-row timer on unmount", async () => {
const w = mount(XpRpgMenu, { props: { actions: [action("attack")] } });
await w.find("#attack").trigger("click");
w.unmount();
expect(jest.getTimerCount()).toBe(0);
});
it("a fullWidth action spans both columns", () => {
const w = mount(XpRpgMenu, { props: { actions: [action("attack", { fullWidth: true }), action("defend")] } });
expect((w.find("#attack").element.parentElement as HTMLElement).style.gridColumn).toBe("1 / -1");
});
});- Step 2: Run it to see it fail
Run: chemx test tests/unit/XpRpgMenu.spec.ts
Expected: FAIL on the note, disabled and current-row cases; the fullWidth case already passes.
- Step 3: Implement in
XpRpgMenu.vue
Template: the <button class="menu-item"> gains :disabled="action.disabled", :aria-disabled="action.disabled ? 'true' : undefined", :class="{ 'is-current': currentId === action.id }"; inside .item-inner, after .item-label, add <span v-if="action.note" class="item-note">{{ action.note }}</span> before the badge.
Script: handleAction(action) returns at once when action.disabled; otherwise sets currentId = action.id, starts window.setTimeout(() => (currentId = null), CURRENT_MS) with CURRENT_MS = 150, clearing any earlier timer, then calls action.click?.() and emits. onBeforeUnmount clears the timer. Add note?: string; disabled?: boolean; to RpgMenuAction.
Styles (scoped): @use "@/styles/themes/gameStyle" as *; at the top. Replace background-color: #280828 with background: var(--gs-window) (and transparent when the root lacks .rpg-box, keep the existing borderless behaviour), color: var(--ion-color-rpg) with color: var(--gs-text), the four ring colours in &.rpg-box with box-shadow: var(--gs-frame); border-radius: var(--gs-radius);. Hover and active backgrounds become var(--gs-select). Replace the CSS triangle ::before with @include gs-cursor; shown on :hover (pointer devices), :focus-visible, :active and .is-current; set padding-left: calc(8px + var(--gs-cursor-w, 20px)) on .menu-item so the cursor has room. .item-note: margin-left: auto; font-family: "StatusPlz", monospace; font-size: 0.85em; color: var(--gs-text-dim); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 40%;. &[disabled] { color: var(--gs-text-dim); cursor: default; }. Badge background var(--gs-danger), its ring var(--gs-window-color).
- Step 4: Run the spec
Run: chemx test tests/unit/XpRpgMenu.spec.ts
Expected: PASS, five cases.
- Step 5: Commit
git add src/components/molecules/RpgMenu/XpRpgMenu.vue tests/unit/XpRpgMenu.spec.ts
git commit -m "feat(rpg-menu): rows take the Game Style tokens and cursor; a note under a label; disabled rows; the tapped row stays current for 150 ms
Co-Authored-By: Claude Fable 5.1 <[email protected]>"Files:
- Create:
src/lib/services/battle/BattleMenuStatus.ts - Create:
tests/unit/BattleMenuStatus.spec.ts
Interfaces:
- Consumes:
BattlePowerEntry({ ability, card: { mp, effect, reason } }) fromBattlePowers.ts;BattleItemCard({ name, faIcon, mp, effect, reason }) andBattleBagRole("effect" | "potions" | "powers" | "potion" | "none") fromBattleItems.ts;PotionCard({ id, name, qty, ..., reason }) fromBattlePotions.ts. - Produces:
export interface MenuSlotStatus { enabled: boolean; note: string }
export interface BattleMenuStatus { tackle: MenuSlotStatus; powers: MenuSlotStatus; goods: MenuSlotStatus }
/** One bag tile as the page sees it: its role in a fight and the card the bag would show for it. */
export interface MenuGoodsTile { role: BattleBagRole; card: BattleItemCard | null }
export interface BattleMenuStatusInput {
choresLeft: number;
powers: BattlePowerEntry[];
goods: MenuGoodsTile[];
potions: PotionCard[];
}
export const battleMenuStatus = (input: BattleMenuStatusInput): BattleMenuStatus- Step 1: Write the failing spec
import { battleMenuStatus } from "@/lib/services/battle/BattleMenuStatus";
const power = (mp: number, reason: string | null = null) => ({ ability: { id: `p${mp}` } as never, card: { mp, effect: "", reason } });
const card = (reason: string | null = null, role = "effect" as const) => ({ role, card: { name: "Bomb", faIcon: "bomb", mp: 0, effect: "", reason } });
const container = () => ({ role: "potions" as const, card: { name: "Bottles", faIcon: "flask", mp: 0, effect: "Your potions", reason: null } });
const potion = (qty: number, reason: string | null = null) => ({ id: "hp", name: "Health Potion", qty, faIcon: "flask", effect: "", reason });
const base = { choresLeft: 3, powers: [], goods: [], potions: [] };
describe("battleMenuStatus", () => {
it("Tackle counts the chores left and is always enabled", () => {
expect(battleMenuStatus({ ...base, choresLeft: 3 }).tackle).toEqual({ enabled: true, note: "3 chores" });
expect(battleMenuStatus({ ...base, choresLeft: 1 }).tackle).toEqual({ enabled: true, note: "1 chore" });
expect(battleMenuStatus({ ...base, choresLeft: 0 }).tackle).toEqual({ enabled: true, note: "all done" });
});
it("Powers shows the cheapest castable cost, a free one included", () => {
expect(battleMenuStatus({ ...base, powers: [power(40), power(25), power(10, "spent")] }).powers).toEqual({ enabled: true, note: "25 MP" });
expect(battleMenuStatus({ ...base, powers: [power(0)] }).powers).toEqual({ enabled: true, note: "0 MP" });
});
it("Powers is disabled with the first reason when nothing casts, and 'none' with no Powers", () => {
expect(battleMenuStatus({ ...base, powers: [power(25, "no MP"), power(40, "spent")] }).powers).toEqual({ enabled: false, note: "no MP" });
expect(battleMenuStatus(base).powers).toEqual({ enabled: false, note: "none" });
});
it("Goods counts usable effect tiles plus potion quantities", () => {
expect(battleMenuStatus({ ...base, goods: [card(), card("spent"), { role: "none", card: null }], potions: [potion(2), potion(1, "full HP")] }).goods).toEqual({ enabled: true, note: "x3" });
});
it("a container tile (Bottles, Book of Spells) never counts, even with no reason", () => {
expect(battleMenuStatus({ ...base, goods: [container()] }).goods).toEqual({ enabled: false, note: "empty" });
});
it("Goods is 'empty' when nothing can be used", () => {
expect(battleMenuStatus({ ...base, goods: [card("not held"), { role: "none", card: null }], potions: [potion(2, "full HP")] }).goods).toEqual({ enabled: false, note: "empty" });
expect(battleMenuStatus(base).goods).toEqual({ enabled: false, note: "empty" });
});
});- Step 2: Run it to see it fail
Run: chemx test tests/unit/BattleMenuStatus.spec.ts
Expected: FAIL, module not found.
- Step 3: Implement
battleMenuStatusinBattleMenuStatus.ts
Pure, no imports beyond the four types. Tackle: "all done" at 0, "1 chore" at 1, "<n> chores" otherwise. Powers: castable = card.reason === null; note is the minimum card.mp among castable as "<n> MP"; else the first entry's card.reason; "none" with no entries. Goods: count = tiles with role === "effect" and a card whose reason === null, plus the sum of qty over potions with reason === null; "x<count>" when above 0, else "empty".
- Step 4: Run the spec
Run: chemx test tests/unit/BattleMenuStatus.spec.ts
Expected: PASS, six cases.
- Step 5: Commit
git add src/lib/services/battle/BattleMenuStatus.ts tests/unit/BattleMenuStatus.spec.ts
git commit -m "feat(battle): battleMenuStatus says what Tackle, Powers and Goods can do from the sheets' own cards
Co-Authored-By: Claude Fable 5.1 <[email protected]>"Files:
- Modify:
src/app/Console/BattleField/battle/battleMenu.ts - Modify:
tests/unit/battleMenu.spec.ts - Modify:
src/app/Console/BattleField/BattleField.ts:558-655(themenuActionscomputed) and the imports at the top - Modify:
src/app/Console/BattleField/_BattleField.scss:88-140(.battle-dialog)
Interfaces:
-
Consumes:
battleMenuStatus(Task 3);useEquipmentItemsfrom@/app/Console/MyPortal/UserHud/components/equipment/EquipmentItems;isItemUnlockedfrom@/lib/services/quests/ItemEffects;battleBagRole,describeBattleItemandpotionShelf,battlePowersalready imported in the page. -
Produces:
BattleMenuActiongainsnote?: stringanddisabled?: boolean;applyMenuStatus(actions: BattleMenuAction[], status: BattleMenuStatus): BattleMenuAction[]inbattleMenu.ts. The page'smenuActionscome out in the order attack (fullWidth), defend, powers, item, run-away (not fullWidth), with notes and disabled flags set. Task 5 renders the same fields. -
Step 1: Write the failing spec for
applyMenuStatus
Append to tests/unit/battleMenu.spec.ts:
import { applyMenuStatus, type BattleMenuAction } from "@/app/Console/BattleField/battle/battleMenu";
describe("applyMenuStatus", () => {
const act = (id: string): BattleMenuAction => ({ id, label: id, faIcon: "x", click: () => undefined });
const status = {
tackle: { enabled: true, note: "2 chores" },
powers: { enabled: false, note: "no MP" },
goods: { enabled: true, note: "x1" },
};
it("copies notes and disabled flags onto attack, powers and item, and makes attack span the row", () => {
const out = applyMenuStatus([act("attack"), act("defend"), act("powers"), act("item"), act("run-away")], status);
expect(out.map((a) => [a.id, a.note, a.disabled ?? false, a.fullWidth ?? false])).toEqual([
["attack", "2 chores", false, true],
["defend", undefined, false, false],
["powers", "no MP", true, false],
["item", "x1", false, false],
["run-away", undefined, false, false],
]);
});
it("leaves a confirm layout untouched", () => {
const confirm = [act("keep-fighting"), act("run")];
expect(applyMenuStatus(confirm, status)).toEqual(confirm);
});
});- Step 2: Run it to see it fail
Run: chemx test tests/unit/battleMenu.spec.ts
Expected: FAIL, applyMenuStatus is not exported.
- Step 3: Implement
applyMenuStatusand the two fields inbattleMenu.ts
Returns new objects for the three ids (attack also gets fullWidth: true), passes other actions through unchanged, and returns the input array itself when it holds no attack action.
- Step 4: Run the spec
Run: chemx test tests/unit/battleMenu.spec.ts
Expected: PASS.
- Step 5: Wire the page
In BattleField.ts:
-
Import
battleMenuStatus,useEquipmentItems,isItemUnlocked,applyMenuStatus. -
In
setup(), afteruseHeroEquipment, addconst { specialItems } = useEquipmentItems();. -
Add a computed
menuStatusthat returnsbattleMenuStatus({ choresLeft: choresLeft.value, powers: service.value ? battlePowers(abilities, itemContext()) : [], goods: service.value ? specialItems.filter((item) => isItemUnlocked(hero.value, item.name)).map((item) => ({ role: battleBagRole(item), card: describeBattleItem(item, itemContext()) })) : [], potions: potionShelf(ctx.userId) }).choresLeftis the existing computed the enemy stage receives; use it. The helper, not the page, decides which roles count. -
In
menuActions, removefullWidth: truefrom therun-awayentry and returnapplyMenuStatus([...], menuStatus.value)for the normal layout. The confirm layout is returned as today. -
Step 6: The message box reads tokens
In _BattleField.scss .battle-dialog: replace the four-ring box-shadow, background-color: #280828 and border-radius: 1px with @include eb-box(); (add @use "@/styles/themes/earthbound.scss" as *; at the top of the file); color: var(--gs-text); .dialog-prompt { color: var(--gs-accent); text-shadow: var(--gs-text-shadow); }; .dialog-more-indicator { color: var(--gs-accent); }; drop the --ion-color-primary* overrides only if nothing inside the box uses Ionic colours (XpTypingText and XpRpgMenu do not; remove them). Keep .is-victory as it is.
- Step 7: Type check the page
Run: chemx typecheck
Expected: no errors in BattleField.ts or battleMenu.ts.
- Step 8: Commit
git add src/app/Console/BattleField/battle/battleMenu.ts tests/unit/battleMenu.spec.ts src/app/Console/BattleField/BattleField.ts src/app/Console/BattleField/_BattleField.scss
git commit -m "feat(battle): the command menu carries notes and disabled flags from battleMenuStatus; Tackle leads the list; the message box reads the Game Style tokens
Co-Authored-By: Claude Fable 5.1 <[email protected]>"Files:
- Modify:
src/app/Console/BattleField/components/XpBattleRadialMenu.vue - Create:
tests/unit/XpBattleRadialMenu.spec.ts
Interfaces:
-
Consumes:
BattleMenuAction.noteand.disabled(Task 4). -
Produces: each pill renders
<span class="pill-note">when its action has a note,:disabled="disabled || action.disabled", and classis-disabled; the centre button renders<xp-icon class="center-badge" :icon="attackAction.faIcon">in the normal layout. -
Step 1: Write the failing component spec
Stubs: IonButton and IonFabButton as <button :disabled="disabled" @click="$emit('click')"><slot /></button> with a disabled prop, IonLabel and IonImg as plain elements, XpIcon as <i :data-icon="icon" />, XpHeroModel as <div />.
import { mount } from "@vue/test-utils";
import XpBattleRadialMenu from "@/app/Console/BattleField/components/XpBattleRadialMenu.vue";
const action = (id: string, extra = {}) => ({ id, label: id, faIcon: `${id}-icon`, click: jest.fn(), ...extra });
const five = () => [action("attack", { note: "3 chores" }), action("defend"), action("powers", { disabled: true, note: "no MP" }), action("item", { note: "x2" }), action("run-away")];
const mountMenu = (actions = five()) => mount(XpBattleRadialMenu, { props: { actions }, global: { stubs } });
describe("XpBattleRadialMenu", () => {
it("shows a note under the pills that have one", () => {
const w = mountMenu();
expect(w.find("[data-cy=action-attack] .pill-note").text()).toBe("3 chores");
expect(w.find("[data-cy=action-goods] .pill-note").text()).toBe("x2");
expect(w.find("[data-cy=action-defend] .pill-note").exists()).toBe(false);
});
it("a disabled pill is marked, disabled and never fires", async () => {
const actions = five();
const w = mountMenu(actions);
const pill = w.find("[data-cy=action-powers]");
expect(pill.classes()).toContain("is-disabled");
expect(pill.attributes("disabled")).toBeDefined();
await pill.trigger("click");
expect(actions[2].click).not.toHaveBeenCalled();
expect(w.emitted("action")).toBeUndefined();
});
it("the centre badge wears the Tackle icon and the centre tap tackles", async () => {
const actions = five();
const w = mountMenu(actions);
expect(w.find(".center-badge").attributes("data-icon")).toBe("attack-icon");
await w.find("[data-cy=radial-avatar-btn]").trigger("click");
expect(actions[0].click).toHaveBeenCalled();
});
it("the confirm layout has no badge", () => {
const w = mountMenu([action("keep-fighting"), action("run")]);
expect(w.find(".center-badge").exists()).toBe(false);
});
});- Step 2: Run it to see it fail
Run: chemx test tests/unit/XpBattleRadialMenu.spec.ts
Expected: FAIL on notes, disabled and badge; the confirm case passes.
- Step 3: Implement in
XpBattleRadialMenu.vue
Template: every battle-pill gets :disabled="disabled || action.disabled" and :class="{ 'is-disabled': action.disabled }" (where action is the matching computed), and after .pill-label a <span v-if="action.note" class="pill-note">{{ action.note }}</span>. Wrap label and note in a <span class="pill-text"> so they stack. In the normal layout's centre button, after the model or image, add <xp-icon v-if="attackAction" :icon="attackAction.faIcon" size="sm" class="center-badge" />. onActionClick also returns when action.disabled.
Styles: replace every hex literal. .radial-prompt { color: var(--gs-accent); text-shadow: var(--gs-text-shadow); font-family: var(--gs-font); }. .center-avatar-btn { --background: var(--gs-window-color); border: 3px solid var(--gs-accent); }; .center-badge { position: absolute; right: -2px; bottom: -2px; width: 24px; height: 24px; border-radius: 50%; background: var(--gs-window-color); color: var(--gs-accent); box-shadow: 0 0 0 2px var(--gs-accent); display: flex; align-items: center; justify-content: center; } (the fab button needs overflow: visible for it, so move the circular clip to the model wrapper). .battle-pill { --background: var(--gs-window); --color: var(--gs-text); font-family: var(--gs-font); box-shadow: 0 0 0 2px var(--gs-divider), 0 3px 8px rgba(0, 0, 0, 0.6); border-radius: 24px; }, .pill-attack, .pill-keep-fighting { box-shadow: 0 0 0 2px var(--gs-accent) ...; .pill-icon { color: var(--gs-accent); } }, .pill-keep-fighting ring var(--gs-good), .pill-run-confirm ring var(--gs-danger), .pill-bribe-full, .pill-bribe-low ring var(--gs-accent), .pill-run .pill-icon { color: var(--gs-text-dim); }. .pill-text { display: flex; flex-direction: column; align-items: flex-start; line-height: 1.05; }, .pill-note { font-family: "StatusPlz", monospace; font-size: 0.72em; color: var(--gs-text-dim); font-weight: normal; white-space: nowrap; max-width: 9ch; overflow: hidden; text-overflow: ellipsis; }. .is-disabled { opacity: 0.45; }. Remove the per-pill --background/--color blocks. The --box-shadow custom property of ion-button is set to none so the outer box-shadow rings show.
- Step 4: Run the spec
Run: chemx test tests/unit/XpBattleRadialMenu.spec.ts
Expected: PASS, four cases.
- Step 5: Commit
git add src/app/Console/BattleField/components/XpBattleRadialMenu.vue tests/unit/XpBattleRadialMenu.spec.ts
git commit -m "feat(battle): the radial menu wears the Game Style: token pills with notes, disabled pills fade, the centre portrait badges the Tackle weapon
Co-Authored-By: Claude Fable 5.1 <[email protected]>"Files:
- Modify:
src/app/Console/BattleField/components/_BattleEnemyStage.scss:1-10($ember,$green) and:282-420(.chore-pip,.chore-bar,.chores-left-label,.attack-timer*) - Modify:
src/styles/themes/dragonQuest.scss,pokemon.scss,virtualBoy.scss(one rule each)
Interfaces:
-
Consumes: tokens from Task 1.
-
Step 1: Replace the chip and bar colours
$ember becomes var(--gs-accent), $green becomes var(--gs-good) (plain colours; drop the gradients and the #0d3d1d icon colours, which become var(--gs-window-color)). .chore-pip glow: 0 0 8px 1px color-mix(in srgb, var(--gs-accent) 60%, transparent); done glow the same with --gs-good. .chore-bar { background: rgba(0, 0, 0, 0.65); } stays; its fill var(--gs-good). .chores-left-label { color: var(--gs-text); } keeps $outline.
- Step 2: Replace the clock colours
.attack-timer { background: color-mix(in srgb, var(--gs-window-color) 85%, transparent); box-shadow: 0 0 0 2px var(--gs-divider); }. The three tone blocks collapse to two custom properties each: .tone-ok { --tone: var(--gs-good); }, .tone-warn { --tone: var(--gs-accent); }, .tone-danger { --tone: var(--gs-danger); }, with --tone-glow: color-mix(in srgb, var(--tone) 65%, transparent). The icon's --fa-primary-color/--fa-secondary-color and the bar fill read var(--tone); the bar border var(--gs-divider); the label var(--gs-text). .is-critical ring and glow use var(--gs-danger).
- Step 3: Signature blink for the monochrome styles
In each of dragonQuest.scss, pokemon.scss and virtualBoy.scss, inside the ion-app#<id> block:
// Good and danger share one colour here: the critical clock blinks instead
.battlefield .attack-timer.is-critical .attack-timer-bar {
animation: gs-critical-blink 0.5s steps(1, end) infinite;
}and once, at the bottom of _gameStyle.scss:
@keyframes gs-critical-blink {
50% { opacity: 0.25; }
}
@media (prefers-reduced-motion: reduce) {
.battlefield .attack-timer.is-critical .attack-timer-bar {
animation: none;
}
}- Step 4: Lint the SCSS
Run: chemx lint
Expected: no errors in the touched files (warnings tolerated).
- Step 5: Commit
git add src/app/Console/BattleField/components/_BattleEnemyStage.scss src/styles/themes/_gameStyle.scss src/styles/themes/dragonQuest.scss src/styles/themes/pokemon.scss src/styles/themes/virtualBoy.scss
git commit -m "style(battle): chores chips and the attack clock read the Game Style tokens; monochrome styles blink the critical clock
Co-Authored-By: Claude Fable 5.1 <[email protected]>"Files:
- Modify:
src/app/Console/BattleField/components/XpBattleTaskMenu.vue(style block) - Modify:
src/app/Console/BattleField/components/BattleSheet.vue:58-210(style block) - Modify:
src/app/Console/BattleField/components/BattlePotionSheet.vue:69-170(style block) - Modify:
src/app/Console/BattleField/components/BattlePowersSheet.vue:60-172(style block) - Modify:
src/app/Console/BattleField/components/_BattleVictoryCard.scss
Interfaces:
-
Consumes: tokens from Task 1.
-
Step 1: Substitute in the four sheets
In every style block: $eb-color-cream as a plain colour becomes var(--gs-text); rgba($eb-color-cream, a) becomes color-mix(in srgb, var(--gs-text) <a*100>%, transparent); $eb-color-pale-yellow becomes var(--gs-accent) (its rgba(..., a) forms become the same color-mix); $button-fill / $button-edge become var(--gs-button) / var(--gs-button-edge) (delete the two SCSS variables); #ff6b6b becomes var(--gs-danger); the Done! glow's rgba($eb-color-pale-yellow, 0.5) becomes color-mix(in srgb, var(--gs-good) 50%, transparent) and its border var(--gs-good); in BattlePowersSheet.vue the MP pill ($eb-color-dark-purple on $eb-color-sky-blue) becomes var(--gs-window-color) on var(--gs-accent), and $eb-color-slate ring becomes var(--gs-divider). eb-box() and eb-scrollbar() includes stay. Fonts: "Apple Kid", "StatusPlz" stacks become var(--gs-font); titles var(--gs-font-title) with text-shadow: var(--gs-text-shadow).
- Step 2: Substitute in the victory card
Only the frame and the card text: .battle-victory-card already includes eb-box(); $eb-color-cream text becomes var(--gs-text), $eb-color-pale-yellow labels become var(--gs-accent), the dashed outline becomes var(--gs-accent). $vc-gold, $vc-amber, $vc-ink, the star colours and the title gradient stay (loot is gold in every style).
- Step 3: Check that no Earthbound constant is left in the battle sheets
Run: grep -nE '\$eb-color|#[0-9a-fA-F]{3,8}\b' src/app/Console/BattleField/components/XpBattleTaskMenu.vue src/app/Console/BattleField/components/BattleSheet.vue src/app/Console/BattleField/components/BattlePotionSheet.vue src/app/Console/BattleField/components/BattlePowersSheet.vue
Expected: no output.
- Step 4: Lint
Run: chemx lint
Expected: no errors in the touched files.
- Step 5: Commit
git add src/app/Console/BattleField/components/XpBattleTaskMenu.vue src/app/Console/BattleField/components/BattleSheet.vue src/app/Console/BattleField/components/BattlePotionSheet.vue src/app/Console/BattleField/components/BattlePowersSheet.vue src/app/Console/BattleField/components/_BattleVictoryCard.scss
git commit -m "style(battle): the checklist, potion and Powers sheets and the victory card frame read the Game Style tokens
Co-Authored-By: Claude Fable 5.1 <[email protected]>"Files:
- Modify:
tests/e2e/specs/battle.spec.js(newdescribe) - Modify:
tests/e2e/support/commands.js(one command)
Interfaces:
-
Consumes: everything above. The dev server on
:8100must answercurl -s localhost:8100; never start a second one in the shared checkout. A worktree may serve its own on another port and stops it when done. -
Step 1: Add a
cy.useGameStyle(rpg, fabStyle)command
In commands.js: sets localStorage["xp.themeChoice"] to JSON.stringify({ ui: "nintendo", rpg }) and localStorage.fabStyle to fabStyle on cy.window().
- Step 2: Add the themed pass
describe("Battle Field under a Game Style", () => {
before(() => cy.seedWorld());
["retro", "modern"].forEach((fabStyle) => {
it(`Zelda, ${fabStyle} menu: the menu, the checklist and the run prompt`, () => {
cy.useGameStyle("zelda", fabStyle);
cy.visit("/battle/hero-1/free/beast-socktopus", { timeout: 120000 });
cy.tapToWakeAudio();
cy.get("ion-app#zelda", { timeout: 60000 }).should("exist");
if (fabStyle === "modern") {
waitForMenu();
cy.get("[data-cy=action-attack] .pill-note").should("have.text", "3 chores");
} else {
cy.get("#attack", { timeout: 30000 }).should("be.visible").find(".item-note").should("have.text", "3 chores");
}
shot(`20-zelda-${fabStyle}-menu`);
cy.get(fabStyle === "modern" ? "[data-cy=action-attack]" : "#attack").click();
cy.get("[data-cy=task-sheet]", { timeout: 10000 }).should("be.visible");
shot(`21-zelda-${fabStyle}-checklist`);
cy.get("[data-cy=task-back]").click();
cy.get(fabStyle === "modern" ? "[data-cy=action-run]" : "#run-away").click();
shot(`22-zelda-${fabStyle}-run-confirm`);
});
});
});shot and waitForMenu already exist in the file.
- Step 3: Run the battle walkthrough
Run: pnpm vue-cli-service test:e2e --headless --browser chrome --url http://localhost:8100 --spec tests/e2e/specs/battle.spec.js
Expected: every case passes. Open tests/e2e/screenshots/battle.spec.js/20-zelda-*.png, 21-*, 22-* and the existing 03-menu.png: Zelda frames on every box, pill and button; Earthbound unchanged apart from cream message text.
- Step 4: Look at every theme once
In the browser against the dev server, set localStorage["xp.themeChoice"] to each of the ten ids in turn and reload the battle; the message box, menu, clock, chips and sheets must match the hero card. Tune a token value from spec table 1.3 only inside that theme's palette, and keep the guard green.
- Step 5: Gates
Run: chemx typecheck, then chemx lint.
Expected: no errors.
- Step 6: Commit, push, PR with auto-merge
git add tests/e2e/specs/battle.spec.js tests/e2e/support/commands.js
git commit -m "test(e2e): the battle under Zelda with both menus, as the visual baseline
Co-Authored-By: Claude Fable 5.1 <[email protected]>"
git push -u origin feat/battle-theme-tokens
gh pr create --title "Battles follow the Game Style; both attack menus get notes, disabled moves and the theme cursor" --body-file - <<'EOF'
Spec: docs/superpowers/specs/2026-10-22-battle-theme-tokens-design.md
- Four new theme tokens (good, danger, button, button-edge) in all ten Game Styles; Earthbound sets the full contract; a guard spec.
- Message box, retro list, radial pills, attack clock, chores chips, the three sheets and the victory card read tokens only.
- battleMenuStatus + applyMenuStatus: Tackle leads with the chores left, Powers and Goods show what they can do and grey out when they cannot.
- Theme cursor on the active row; the radial centre badges the Tackle weapon.
- Cypress: the battle under Zelda with both menus.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
EOF
gh pr merge --auto --squashThen read the PR's Tests check; a red check is fixed on the branch and pushed again.