2026 10 22 battle theme tokens - xopherdeep/do-it-for-the-xp GitHub Wiki

Battle Theme Tokens 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: 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

Global Constraints

  • Work in an isolated git worktree on branch feat/battle-theme-tokens (superpowers:using-git-worktrees). In the worktree, git add <explicit paths> then git commit is fine; never git add -A, never git stash, never touch the shared checkout's branch.
  • Never run pnpm test, pnpm jest, npx jest, chemx test with no path, or chemx verify. Run only the spec you touch: chemx test tests/unit/Foo.spec.ts. chemx typecheck and chemx lint once, in the last task.
  • A page whose .vue has <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 $ink and $outline in _BattleEnemyStage.scss and the loot gold in _BattleVictoryCard.scss stay (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).

Review Focus

  • 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 effect role and potions count. (Task 3 test "a container tile never counts".)
  • A free Power (mp: 0) that is castable must read 0 MP, not none. (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-current timer outlives the component when the sheet opens at once: it is cleared on unmount. (Task 2 test.)

Task 1: Theme token contract and guard

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-edge on every ion-app#<id>, and the full existing set on ion-app#earthbound. Later tasks read them with var(--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]>"

Task 2: XpRpgMenu reads tokens and learns notes, disabled rows and the touch cursor

Files:

  • Modify: src/components/molecules/RpgMenu/XpRpgMenu.vue
  • Create: tests/unit/XpRpgMenu.spec.ts

Interfaces:

  • Produces: RpgMenuAction gains note?: string and disabled?: boolean. A row renders <span class="item-note"> when note is set, carries aria-disabled="true" and disabled when disabled, and the class is-current for 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]>"

Task 3: The menu status helper

Files:

  • Create: src/lib/services/battle/BattleMenuStatus.ts
  • Create: tests/unit/BattleMenuStatus.spec.ts

Interfaces:

  • Consumes: BattlePowerEntry ({ ability, card: { mp, effect, reason } }) from BattlePowers.ts; BattleItemCard ({ name, faIcon, mp, effect, reason }) and BattleBagRole ("effect" | "potions" | "powers" | "potion" | "none") from BattleItems.ts; PotionCard ({ id, name, qty, ..., reason }) from BattlePotions.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 battleMenuStatus in BattleMenuStatus.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]>"

Task 4: The page passes the status to both menus; the message box reads tokens

Files:

  • Modify: src/app/Console/BattleField/battle/battleMenu.ts
  • Modify: tests/unit/battleMenu.spec.ts
  • Modify: src/app/Console/BattleField/BattleField.ts:558-655 (the menuActions computed) and the imports at the top
  • Modify: src/app/Console/BattleField/_BattleField.scss:88-140 (.battle-dialog)

Interfaces:

  • Consumes: battleMenuStatus (Task 3); useEquipmentItems from @/app/Console/MyPortal/UserHud/components/equipment/EquipmentItems; isItemUnlocked from @/lib/services/quests/ItemEffects; battleBagRole, describeBattleItem and potionShelf, battlePowers already imported in the page.

  • Produces: BattleMenuAction gains note?: string and disabled?: boolean; applyMenuStatus(actions: BattleMenuAction[], status: BattleMenuStatus): BattleMenuAction[] in battleMenu.ts. The page's menuActions come 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 applyMenuStatus and the two fields in battleMenu.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(), after useHeroEquipment, add const { specialItems } = useEquipmentItems();.

  • Add a computed menuStatus that returns battleMenuStatus({ 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) }). choresLeft is the existing computed the enemy stage receives; use it. The helper, not the page, decides which roles count.

  • In menuActions, remove fullWidth: true from the run-away entry and return applyMenuStatus([...], 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]>"

Task 5: The modern radial reads tokens, shows notes, fades disabled pills and badges the centre

Files:

  • Modify: src/app/Console/BattleField/components/XpBattleRadialMenu.vue
  • Create: tests/unit/XpBattleRadialMenu.spec.ts

Interfaces:

  • Consumes: BattleMenuAction.note and .disabled (Task 4).

  • Produces: each pill renders <span class="pill-note"> when its action has a note, :disabled="disabled || action.disabled", and class is-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]>"

Task 6: Chores chips and the attack clock read tokens; monochrome themes blink

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]>"

Task 7: The sheets and the victory card read tokens

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]>"

Task 8: Themed Cypress pass, gates, PR

Files:

  • Modify: tests/e2e/specs/battle.spec.js (new describe)
  • Modify: tests/e2e/support/commands.js (one command)

Interfaces:

  • Consumes: everything above. The dev server on :8100 must answer curl -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 --squash

Then read the PR's Tests check; a red check is fixed on the branch and pushed again.

⚠️ **GitHub.com Fallback** ⚠️