2026 10 22 battle theme tokens design - xopherdeep/do-it-for-the-xp GitHub Wiki
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.
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.
- 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.
- 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.
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.
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.
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.
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.
| 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.
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: trueso it spans both columns and leads; then Defend and Powers; then Goods and Run Away. The grid already supports a full-width row. -
Notes.
RpgMenuActiongainsnote?: stringanddisabled?: 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 setsaria-disabled. Existing callers pass neither, so nothing else changes. -
Cursor. The theme's cursor image (
--gs-cursor) replaces the CSS triangle, through the existinggs-cursormixin. It shows on:focus-visible, on:active, and on a row that carriesis-currentfor 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.
-
Pills. Fill
var(--gs-window), textvar(--gs-text), fontvar(--gs-font), a 2 px ring invar(--gs-divider). Tackle's ring and icon arevar(--gs-accent). In the confirm layout, Run's ring isvar(--gs-danger)and Keep fighting's isvar(--gs-good); bribes are accent. Icons elsewhere arevar(--gs-text), Run Away'svar(--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 isvar(--gs-window-color). -
Prompt.
var(--gs-accent)withvar(--gs-text-shadow).
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)
}): BattleMenuStatusRules:
-
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 lowestcard.mpamong 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.
- The theme id goes on
ion-appas today; the new block or tokens apply by cascade. -
BattleField.tsbuildsmenuActionsand mergesbattleMenuStatus(...)into them. -
BattleDialogBoxpasses the actions toXpRpgMenu(retro) or the page passes them toXpBattleRadialMenu(modern). Both readnoteanddisabled. - Every battle surface reads tokens only; nothing reads the theme id.
- 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").
-
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 carriesaria-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 setslocalStorage["xp.themeChoice"]to Zelda andfabStyleto 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 typecheckandchemx lintonce after the wave. The PR's Tests check is the gate.
- Branch
feat/battle-theme-tokensfrommaster; private-index commits only; every file locked withchemx team lockbefore 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.
- Board task: a
chemxratchet on hex literals undersrc/appandsrc/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.