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

Battles follow the Game Style, and both attack menus get a usability pass

Date: 2026-10-22. Owner decisions: keep both menus (the retro toggle picks one); extend the theme token contract rather than write per-theme battle blocks.

Summary

Under any of the ten Game Styles the hero card and the beast's nameplate already take the theme's frame, but the message box, the command menu, the attack clock, the chores chips, the three sheets and the victory card stay Earthbound purple, because their colours are SCSS constants or hex literals. The modern radial menu is worse: its pills carry six flat web colours that belong to no theme at all.

This design adds four semantic tokens to the theme contract, makes Earthbound set the full token set like every other theme, and routes every battle surface through tokens. On top of that, both attack menus get the same usability pass: Tackle reads as the main move, Powers and Goods say what they can do and grey out when they cannot, and the theme's cursor marks the row under the finger.

The app-wide hardcoded colour sweep is out of scope and gets its own board task.

Goals

  • A hero who chose Zelda, Chrono Trigger, Pokémon or any other style fights in a battle whose every window, menu, bar and button matches that style.
  • Earthbound looks the same as today, give or take two accepted changes: the message text goes from pale yellow to the cream the sheets already use, and the list cursor now also acknowledges a touch, where today it shows only under a mouse.
  • Both menus tell the hero what Tackle, Powers and Goods will do before a tap, and never offer a move that cannot be made.
  • The retro list and the modern radial stay as two looks of one menu model; the toggle keeps switching between them.
  • Controls stay in thumb reach; the nav bars are untouched.

Non-goals

  • Theming the beast's painted arena, the red hit flash, or the XP, GP, AP, HP and MP point colours. Those mean gameplay, not theme.
  • The Earthbound odometer HUD and battleHud.scss, which are already theme-aware.
  • Sweeping hex literals outside the battle. A separate task adds a ratchet and sweeps by area.
  • New art. Every frame stays a CSS recreation, as the themes do today.

1. Token contract

1.1 Four new tokens

Every theme sets these beside its existing set, on its ion-app#<id> block:

Token Meaning Where the battle reads it
--gs-good success, healing, done finished chores chips, a fresh attack clock, the Done! glow, the Keep fighting ring
--gs-danger harm, urgency, refusal a critical attack clock, a sheet's "cannot" line, the Run ring in the confirm layout, the badge on a menu row
--gs-button fill of a chunky confirm button Not yet, Done!, Back, Drink, Cast
--gs-button-edge the hard shadow under that button the same buttons

Already in the contract and reused without change: --gs-window, --gs-window-color, --gs-frame, --gs-radius, --gs-text, --gs-text-dim (disabled rows), --gs-accent, --gs-divider, --gs-select, --gs-cursor, --gs-cursor-w, --gs-cursor-h, --gs-font, --gs-font-title, --gs-text-shadow.

1.2 Earthbound joins the contract

earthbound.scss gains an ion-app#earthbound { ... } block that sets the full token set. Its values are the Earthbound constants that battle code uses as fallbacks today, one to one, so adding the block changes nothing on screen by itself:

Token Earthbound value
--gs-font / --gs-font-title "Apple Kid", "StatusPlz", sans-serif / "Apple Kid", "Twoson", sans-serif
--gs-text / --gs-text-dim #e7e6b3 (cream) / rgba(231, 230, 179, 0.6)
--gs-accent #f7e8a8 (pale yellow)
--gs-text-shadow none
--gs-window / --gs-window-color #280828
--gs-frame 0 0 0 5px #383050, 0 0 0 10px #68d0b8, 0 0 0 12px #f7e8a8, 0 0 0 16px #3d3c55
--gs-radius 1px
--gs-bar, --gs-bar-frame, --gs-bar-frame-top the toolbar values earthbound.scss already applies to ion-toolbar.rpg-box
--gs-page / --gs-page-color #280828 for both. Earthbound never includes game-style-surfaces, so nothing reads them; the checkerboard stays where ion-content#container draws it today
--gs-divider / --gs-select rgba(231, 230, 179, 0.15) / rgba(231, 230, 179, 0.08)
--gs-cursor / -w / -h gs-arrow-cursor("f7e8a8") / 8px / 12px (the triangle XpRpgMenu draws in CSS today)
--gs-good / --gs-danger #80d890 (mint) / #ff4a4a (the red ring the enemy stage already uses)
--gs-button / --gs-button-edge #4a2a4a / #1a0a1a (the plum the sheets already use)

Earthbound does not include game-style-surfaces; it only sets tokens. Nothing in the app decides "not Earthbound" by probing a token (the HUD uses ion-app:not(#earthbound)), so no surface outside the battle changes.

eb-box() keeps its fallbacks for one release so component boxes outside the battle are unaffected; the battle files stop writing fallbacks.

1.3 Starting values for the other nine themes

The builder screenshots every theme and may tune a value inside that theme's palette. Monochrome styles keep good and danger inside their four shades and add a signature blink for the critical clock.

Theme good danger button button edge Signature touch
finalFantasy #a8f0b8 #f87070 #2020a8 #101060 none
chronoTrigger #90e8a0 #f87878 #1c4898 #0c2462 none
zelda #48b048 #e83030 #203060 #000000 none
dragonQuest #f8f8f8 #f8f8f8 #000000 #f8f8f8 critical clock blinks (steps(1))
pokemon #306230 #0f380f #8bac0f #0f380f critical clock blinks
secretOfMana #a8e890 #f08070 #1e3a7a #0c1c40 none
phantasyStar #60f0a0 #ff4080 #0c2240 #1a6a78 none
virtualBoy #ff5050 #ff0000 #3a0000 #ff2a2a critical clock blinks
marioRpg #40b848 #d03838 #f09818 #7a5a3a none

A signature touch is an ordinary rule inside that theme's file, scoped to .battlefield. No new mechanism.

1.4 Guard

tests/unit/gameStyle.spec.ts reads every file in src/styles/themes/ that declares an ion-app#<id> block, including earthbound.scss, and fails when any token in the contract list is missing from any of them. The contract list and the ten theme ids live at the top of that jest spec, so adding a theme or a token is one edit each.

2. Surfaces that switch to tokens

Surface File Change
Message box _BattleField.scss .battle-dialog box becomes @include eb-box(); text var(--gs-text); prompt var(--gs-accent); the victory glow keeps its gold
Retro list XpRpgMenu.vue fill var(--gs-window) (transparent when borderless); text var(--gs-text); hover and active var(--gs-select); cursor through gs-cursor (see 3); badge var(--gs-danger); disabled rows var(--gs-text-dim)
Radial pills XpBattleRadialMenu.vue all 18 literals go (see 4)
Attack clock _BattleEnemyStage.scss .attack-timer track var(--gs-window-color) at 85%, rim var(--gs-divider); fill var(--gs-good), var(--gs-danger) while critical; label var(--gs-text)
Chores chips _BattleEnemyStage.scss left chips var(--gs-accent), done chips var(--gs-good); the ink outline on the arena stays a constant, because the arena is the beast's art
Task sheet XpBattleTaskMenu.vue every $eb-color-* becomes its token; .rpg-button fill and edge from --gs-button / --gs-button-edge; Done! glow var(--gs-good)
Potion and Powers sheets, shared sheet BattlePotionSheet.vue, BattlePowersSheet.vue, BattleSheet.vue same substitution; the #ff6b6b "cannot" line becomes var(--gs-danger)
Victory card _BattleVictoryCard.scss the card frame through eb-box(); the gold title, the stars and the XP, GP and AP tiles keep their colours (loot is gold in every style)

Left alone: BattleHeroCard.vue (Earthbound odometer constants; other themes handled by battleHud.scss), the beast's arena canvas, .hit-flash.

3. Retro list

XpRpgMenu is shared by other screens, so the changes are additive and the defaults keep today's look elsewhere.

  • Rows. The battle passes Tackle with fullWidth: true so it spans both columns and leads; then Defend and Powers; then Goods and Run Away. The grid already supports a full-width row.
  • Notes. RpgMenuAction gains note?: string and disabled?: boolean. A note renders right-aligned on the row in --gs-text-dim, StatusPlz, one size down. A disabled row renders at --gs-text-dim, ignores taps, and sets aria-disabled. Existing callers pass neither, so nothing else changes.
  • Cursor. The theme's cursor image (--gs-cursor) replaces the CSS triangle, through the existing gs-cursor mixin. It shows on :focus-visible, on :active, and on a row that carries is-current for 150 ms after a tap, so a touch is acknowledged before the sheet opens. Earthbound's cursor is its triangle, so Earthbound looks as it does today.
  • Order of the confirm layout. Keep fighting and Run stay as they are; bribes follow.
  • Row height stays 48 px; the box stays at the bottom of the page.

4. Modern radial

  • Pills. Fill var(--gs-window), text var(--gs-text), font var(--gs-font), a 2 px ring in var(--gs-divider). Tackle's ring and icon are var(--gs-accent). In the confirm layout, Run's ring is var(--gs-danger) and Keep fighting's is var(--gs-good); bribes are accent. Icons elsewhere are var(--gs-text), Run Away's var(--gs-text-dim).
  • Notes. Each pill shows the same note under its label, in --gs-text-dim, one size down. A disabled pill fades to 45 % and ignores taps.
  • Centre. The portrait keeps its Tackle tap. Its ring becomes var(--gs-accent) and a small badge with the class weapon icon sits at its bottom right, in the accent, so the tap is visible. The centre fab's fill is var(--gs-window-color).
  • Prompt. var(--gs-accent) with var(--gs-text-shadow).

5. Menu status helper

src/lib/services/battle/BattleMenuStatus.ts, pure, with a jest spec.

export interface MenuSlotStatus {
  enabled: boolean;
  /** The note under the label: "3 chores", "25 MP", "x2", "no MP", "empty". */
  note: string;
}
export interface BattleMenuStatus {
  tackle: MenuSlotStatus;
  powers: MenuSlotStatus;
  goods: MenuSlotStatus;
}
export const battleMenuStatus = (input: {
  choresLeft: number;
  powers: BattlePowerEntry[];     // battlePowers(abilities, ctx)
  goods: (BattleItemCard | null)[]; // describeBattleItem(tile, ctx) for every bag tile
  potions: PotionCard[];          // potionShelf(userId)
}): BattleMenuStatus

Rules:

  • Tackle is always enabled. Note: "1 chore" or "<n> chores"; "all done" when none are left (the engine ends the fight before that shows, but the helper never throws).
  • Powers is enabled when at least one entry has card.reason === null. Note: the lowest card.mp among castable entries as "<n> MP"; when none casts, the first entry's reason; when there are no entries, "none".
  • Goods is enabled when at least one bag card or potion has reason === null. Note: "x<count>" of usable cards plus potion quantities; "empty" when the bag holds nothing usable.

BattleField.ts computes the status once per menu render from the same itemContext(), abilities and potionShelf() the sheets use, so the menu and the sheets never disagree. It copies note and disabled onto the matching BattleMenuAction entries. BattleMenuAction gains the same two optional fields.

Cost: the arrays are a handful of items; computing them on each turn is negligible.

6. Data flow

  1. The theme id goes on ion-app as today; the new block or tokens apply by cascade.
  2. BattleField.ts builds menuActions and merges battleMenuStatus(...) into them.
  3. BattleDialogBox passes the actions to XpRpgMenu (retro) or the page passes them to XpBattleRadialMenu (modern). Both read note and disabled.
  4. Every battle surface reads tokens only; nothing reads the theme id.

7. Edge cases

  • A disabled action still has a click; both menus refuse the tap before calling it.
  • A hero with no abilities: Powers note "none", disabled.
  • A bag with a held tool whose battle use is spent this fight: the tool's card has a reason, so it does not count; Goods may still be enabled by a potion.
  • The confirm layout has no notes.
  • prefers-reduced-motion: the critical blink of the monochrome themes and the Done! glow become static.
  • Notes never wrap. The helper produces short shapes only: counts ("999 chores" is the longest) and a fixed map from the engine's reason sentences to one or two words ("no MP", "used", "HP full", "MP full", "full", "wait", "not held", "no bombs"), any other reason cut to its first word; CSS clips anything longer with an ellipsis rather than growing the row. Goods reads "empty" only when the hero owns nothing at all; an unusable potion shows its reason instead.
  • Labels never lose words: under a wide theme font they wrap to a second line, and the retro list drops to one column when two cannot hold whole words (columns="auto").

8. Testing

  • tests/unit/BattleMenuStatus.spec.ts: every rule in section 5, including empty inputs.
  • tests/unit/gameStyle.spec.ts: the token guard of 1.4.
  • tests/unit/XpRpgMenu.spec.ts (new or extended): a note renders, a disabled row ignores clicks and carries aria-disabled, a full-width first row spans the grid.
  • tests/unit/XpBattleRadialMenu.spec.ts (new or extended): the same for pills, plus the centre badge icon equals the Tackle action's icon.
  • tests/e2e/specs/battle.spec.js: one themed pass sets localStorage["xp.themeChoice"] to Zelda and fabStyle to each value before the visit and screenshots the menu both ways. Screenshots are the visual baseline, reviewed by eye.
  • While building, only the spec being touched runs (chemx test tests/unit/Foo.spec.ts); chemx typecheck and chemx lint once after the wave. The PR's Tests check is the gate.

9. Delivery

  • Branch feat/battle-theme-tokens from master; private-index commits only; every file locked with chemx team lock before an edit.
  • One PR, gh pr merge --auto --squash.
  • Order of work: tokens and guard first (section 1), then surfaces (2), then the helper (5), then the two menus (3, 4), then the Cypress pass. Each step leaves Earthbound looking as it does today, so the branch can be stopped at any step.

10. Follow-ups logged, not built here

  • Board task: a chemx ratchet on hex literals under src/app and src/components (214 files today), then a sweep by area.
  • Later: drop the fallbacks from eb-box() once every theme has shipped with the full set for a release.
⚠️ **GitHub.com Fallback** ⚠️