Port the options screen
Checks / web (push) Canceled after 0s

OPTION in the start menu runs _Option with the ROM's labels and settings,
OptionsControl's cursor wrapping and each option's bit handling. The four
option bytes live in the script state and saves, defaulting to
DefaultOptions. The battle style now decides whether a trainer's next
Pokémon brings an offer to switch, and the menu account whether the start
menu describes its items; the other settings are kept for later.

Closes #24

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Sébastien QUEROLandClaude Opus 5.5 committed 2026-10-02 10:47:11 +02:00
1 parent 47698565ab
commit c4cc0e756c
19 files changed
+732 -21

No files matched your search

+5 -2
View File
@@ -182,6 +182,8 @@ export interface BattleOptions {
/** wMomSavingMoney's MOM_SAVING_MONEY_MASK bits and wMomsMoney: how much
* of a trainer's prize goes to Mom. */
mom?: { saving: number; money: number };
/** wOptions' BATTLE_SHIFT clear: the SHIFT battle style (the default). */
shift?: boolean;
}
/** BATTLERESULT_BOX_FULL: a catch filled the current box. */
@@ -2954,11 +2956,12 @@ export class Battle {
this.curOTMon = index;
this.loadEnemyMon();
}
/** CheckWhetherToAskSwitch: Shift mode (the only battle style so far)
* asks when the player has another Pokémon and its own is still up. */
/** CheckWhetherToAskSwitch: the SHIFT battle style asks when the player
* has another Pokémon and its own is still up. */
private checkWhetherToAskSwitch() {
return (
!this.battleHasJustStarted &&
this.options.shift !== false &&
this.options.party.length > 1 &&
this.partyMon.hp > 0
);
+22
View File
@@ -749,6 +749,28 @@ export const TRAINER_CARD = {
PredefPals: 0x9df6,
} as const;
/** _Option (engine/menus/options_menu.asm): its screen and setting
* strings, keyed by their PRET labels. */
export const OPTIONS = {
StringOptions: 0xe4241,
"Options_TextSpeed.Fast": 0xe4337,
"Options_TextSpeed.Mid": 0xe433c,
"Options_TextSpeed.Slow": 0xe4341,
"Options_BattleScene.On": 0xe4398,
"Options_BattleScene.Off": 0xe439c,
"Options_BattleStyle.Shift": 0xe43d1,
"Options_BattleStyle.Set": 0xe43d7,
"Options_Sound.Mono": 0xe4416,
"Options_Sound.Stereo": 0xe441d,
"Options_Print.Lightest": 0xe4464,
"Options_Print.Lighter": 0xe446d,
"Options_Print.Normal": 0xe4476,
"Options_Print.Darker": 0xe447f,
"Options_Print.Darkest": 0xe4488,
"Options_MenuAccount.Off": 0xe44f2,
"Options_MenuAccount.On": 0xe44f6,
} as const;
export const MAP_GROUP_COUNT = 26;
export const NUM_POKEMON = 251;
export const NUM_TRAINER_CLASSES = 67;
+12
View File
@@ -53,6 +53,11 @@ import { PcSession } from "./pc";
import { bankOfMom, type MomBankView } from "./mom";
import type { PokegearView } from "./pokegear";
import type { TrainerCardView } from "./trainer-card";
import {
defaultOptions,
type GameOptions,
type OptionsView,
} from "./options";
import {
defaultTextContext,
readString,
@@ -118,6 +123,8 @@ export interface ScriptState {
/** wWhichRegisteredItem (pocket in the top two bits, 1-based slot below)
* and wRegisteredItem: the item Select uses; zeros when none. */
registeredItem: { which: number; item: number };
/** wOptions, wTextboxFrame, wGBPrinterBrightness and wOptions2. */
options: GameOptions;
}
export const emptyScriptState = (): ScriptState => ({
decorations: emptyDecorations(),
@@ -146,6 +153,7 @@ export const emptyScriptState = (): ScriptState => ({
flash: false,
noWildEncounters: false,
registeredItem: { which: 0, item: 0 },
options: defaultOptions(),
});
export const copyScriptState = (state: ScriptState): ScriptState => ({
decorations: { ...state.decorations },
@@ -177,6 +185,7 @@ export const copyScriptState = (state: ScriptState): ScriptState => ({
flash: state.flash,
noWildEncounters: state.noWildEncounters,
registeredItem: { ...state.registeredItem },
options: { ...state.options },
});
/** Object operands keep the script's numbering: 0 is the player, 2+ are map
@@ -280,6 +289,9 @@ export type ScriptEffect =
/** A trainer card page, answered with the button pressed; without a
* view the card closes. */
| { kind: "trainercard"; view?: TrainerCardView }
/** The options screen, answered with the button pressed; without a
* view it closes. */
| { kind: "options"; view?: OptionsView }
/** BankOfMom's deposit or withdrawal box with the textbox's pages,
* answered with the button pressed (MomBankButton). */
| { kind: "mombank"; pages: string[]; view: MomBankView }
+245
View File
@@ -0,0 +1,245 @@
import type { RomReader } from "../rom/reader";
import { OPTIONS } from "../rom/profile";
import { OW } from "../world/constants";
import type { ScriptEffect } from "./interpreter";
import { readString, type TextContext } from "./text";
type Input = number | string | undefined;
type Gen<T = void> = Generator<ScriptEffect, T, Input>;
/** The option bytes as the original keeps them. */
export interface GameOptions {
/** wOptions: the text delay in bits 0-2, then STEREO, BATTLE_SHIFT (set
* for SET) and BATTLE_SCENE (set for OFF). */
options: number;
/** wTextboxFrame: FRAME_1 to FRAME_8. */
textboxFrame: number;
/** wGBPrinterBrightness: a GBPRINTER_* value. */
printerBrightness: number;
/** wOptions2: MENU_ACCOUNT. */
options2: number;
}
/** DefaultOptions: medium text, scene on, SHIFT, mono, frame 1, normal
* printing and the menu account on. */
export const defaultOptions = (): GameOptions => ({
options: OW.TEXT_DELAY_MED,
textboxFrame: OW.FRAME_1,
printerBrightness: OW.GBPRINTER_NORMAL,
options2: 1 << OW.MENU_ACCOUNT,
});
export function validOptions(value: unknown): value is GameOptions {
const options = value as GameOptions | undefined;
const byte = (value: unknown, max = 0xff) =>
Number.isInteger(value) && Number(value) >= 0 && Number(value) <= max;
return (
Boolean(options) &&
typeof options === "object" &&
byte(options!.options) &&
byte(options!.textboxFrame, OW.NUM_FRAMES - 1) &&
byte(options!.printerBrightness) &&
byte(options!.options2)
);
}
/** CheckWhetherToAskSwitch's BATTLE_SHIFT test: SHIFT offers a switch. */
export const shiftBattleStyle = (options: GameOptions) =>
!(options.options & (1 << OW.BATTLE_SHIFT));
/** StartMenu's MENU_ACCOUNT test: describe the highlighted item. */
export const menuAccount = (options: GameOptions) =>
Boolean(options.options2 & (1 << OW.MENU_ACCOUNT));
export const OPTIONS_BUTTONS: readonly string[] = [
"a",
"b",
"start",
"select",
"up",
"down",
"left",
"right",
];
/** The options screen: each option's label and setting, and the ▶. */
export interface OptionsView {
rows: { label: string; value: string }[];
cursor: number;
}
const OPT_TEXT_SPEED = 0,
OPT_BATTLE_SCENE = 1,
OPT_BATTLE_STYLE = 2,
OPT_SOUND = 3,
OPT_PRINT = 4,
OPT_MENU_ACCOUNT = 5,
OPT_FRAME = 6,
OPT_CANCEL = 7,
NUM_OPTIONS = 8;
/** _Option (engine/menus/options_menu.asm). Start or B leaves; up and down
* move the cursor with OptionsControl's wrapping; left and right change
* the option under it; A on CANCEL leaves. It changes `options` in place,
* which is the start menu's draft. Each screen is an `options` effect
* answered with a button. */
export class OptionsMenu {
/** wJumptableIndex: the option under the cursor. */
private cursor = OPT_TEXT_SPEED;
constructor(
private options: GameOptions,
private host: { reader: RomReader; text(): TextContext },
) {}
*run(): Gen {
for (;;) {
const button = yield { kind: "options", view: this.view() };
if (typeof button !== "string" || !OPTIONS_BUTTONS.includes(button))
throw new Error("Invalid options button.");
if (button === "start" || button === "b") break;
if (button === "up" || button === "down") this.control(button);
else if (this.change(button)) break;
}
yield { kind: "options" };
}
private string(address: number, limit?: number) {
return readString(this.host.reader, address, this.host.text(), limit).trim();
}
/** OptionsControl. */
private control(button: "up" | "down") {
if (button === "down") {
if (this.cursor === OPT_CANCEL) this.cursor = OPT_TEXT_SPEED;
// .CheckMenuAccount sets the index it already has.
else this.cursor++;
} else if (this.cursor === OPT_FRAME) this.cursor = OPT_MENU_ACCOUNT;
else this.cursor = (this.cursor || NUM_OPTIONS) - 1;
}
/** GetTextSpeed: the OPT_TEXT_SPEED_* index and the delays left and
* right of it. */
private textSpeed(): [number, number, number] {
const delay = this.options.options & OW.TEXT_DELAY_MASK;
if (delay === OW.TEXT_DELAY_SLOW)
return [2, OW.TEXT_DELAY_MED, OW.TEXT_DELAY_FAST];
if (delay === OW.TEXT_DELAY_FAST)
return [0, OW.TEXT_DELAY_SLOW, OW.TEXT_DELAY_MED];
return [1, OW.TEXT_DELAY_FAST, OW.TEXT_DELAY_SLOW];
}
/** GetPrinterSetting, in the same way. */
private printer(): [number, number, number] {
switch (this.options.printerBrightness) {
case OW.GBPRINTER_LIGHTEST:
return [0, OW.GBPRINTER_DARKEST, OW.GBPRINTER_LIGHTER];
case OW.GBPRINTER_LIGHTER:
return [1, OW.GBPRINTER_LIGHTEST, OW.GBPRINTER_NORMAL];
case OW.GBPRINTER_DARKER:
return [3, OW.GBPRINTER_NORMAL, OW.GBPRINTER_DARKEST];
case OW.GBPRINTER_DARKEST:
return [4, OW.GBPRINTER_DARKER, OW.GBPRINTER_LIGHTEST];
default:
return [2, OW.GBPRINTER_LIGHTER, OW.GBPRINTER_DARKER];
}
}
private toggle(field: "options" | "options2", bit: number) {
this.options[field] ^= 1 << bit;
}
/** The option's routine with left or right pressed; true leaves. */
private change(button: string): boolean {
const left = button === "left",
right = button === "right";
const o = this.options;
switch (this.cursor) {
case OPT_TEXT_SPEED:
if (left || right) {
const [, previous, next] = this.textSpeed();
// .Save keeps the high nybble only.
o.options = (o.options & 0xf0) | (left ? previous : next);
}
return false;
// Each of these toggles with either direction. Sound restarts the
// map music, which has no audio yet.
case OPT_BATTLE_SCENE:
if (left || right) this.toggle("options", OW.BATTLE_SCENE);
return false;
case OPT_BATTLE_STYLE:
if (left || right) this.toggle("options", OW.BATTLE_SHIFT);
return false;
case OPT_SOUND:
if (left || right) this.toggle("options", OW.STEREO);
return false;
case OPT_PRINT:
if (left || right) {
const [, previous, next] = this.printer();
o.printerBrightness = left ? previous : next;
}
return false;
case OPT_MENU_ACCOUNT:
if (left || right) this.toggle("options2", OW.MENU_ACCOUNT);
return false;
case OPT_FRAME:
if (left || right)
o.textboxFrame =
(o.textboxFrame + (right ? 1 : -1)) & (OW.NUM_FRAMES - 1);
return false;
default:
// Options_Cancel.
return button === "a";
}
}
private view(): OptionsView {
// StringOptions holds every label, beyond readString's usual limit.
const lines = this.string(OPTIONS.StringOptions, 256).split("\n");
const o = this.options;
const bit = (bit: number) => Boolean(o.options & (1 << bit));
const values = [
this.string(
[
OPTIONS["Options_TextSpeed.Fast"],
OPTIONS["Options_TextSpeed.Mid"],
OPTIONS["Options_TextSpeed.Slow"],
][this.textSpeed()[0]],
),
this.string(
bit(OW.BATTLE_SCENE)
? OPTIONS["Options_BattleScene.Off"]
: OPTIONS["Options_BattleScene.On"],
),
this.string(
bit(OW.BATTLE_SHIFT)
? OPTIONS["Options_BattleStyle.Set"]
: OPTIONS["Options_BattleStyle.Shift"],
),
this.string(
bit(OW.STEREO)
? OPTIONS["Options_Sound.Stereo"]
: OPTIONS["Options_Sound.Mono"],
),
this.string(
[
OPTIONS["Options_Print.Lightest"],
OPTIONS["Options_Print.Lighter"],
OPTIONS["Options_Print.Normal"],
OPTIONS["Options_Print.Darker"],
OPTIONS["Options_Print.Darkest"],
][this.printer()[0]],
),
this.string(
menuAccount(o)
? OPTIONS["Options_MenuAccount.On"]
: OPTIONS["Options_MenuAccount.Off"],
),
// StringOptions' ":TYPE", then UpdateFrame's digit.
`${lines[13].trim().replace(/^:/, "")} ${o.textboxFrame + 1}`,
"",
];
return {
// Labels on even lines, the ":" lines between them.
rows: values.map((value, i) => ({ label: lines[i * 2].trim(), value })),
cursor: this.cursor,
};
}
}
+12 -2
View File
@@ -20,6 +20,7 @@ import {
} from "./interpreter";
import { Pokegear } from "./pokegear";
import { TrainerCard } from "./trainer-card";
import { OptionsMenu, menuAccount } from "./options";
import { readString, readText, type TextContext } from "./text";
type Input = number | string | undefined;
@@ -278,8 +279,10 @@ export class StartMenuRun {
const choice = yield* this.menu(
[],
items.map((item) => this.itemString(item, 1)),
// The menu account (on by default) describes the highlighted item.
items.map((item) => this.itemString(item, 2).replace("\n", " ")),
// The menu account (wOptions2) describes the highlighted item.
menuAccount(this.state.options)
? items.map((item) => this.itemString(item, 2).replace("\n", " "))
: undefined,
Math.min(items.length - 1, Math.max(0, this.cursors.start - 1)),
);
if (choice < 0) return;
@@ -311,6 +314,13 @@ export class StartMenuRun {
gameTime: () => this.host.gameTime(),
}).run();
break;
case STARTMENUITEM_OPTION:
// StartMenu_Option returns to the start menu.
yield* new OptionsMenu(this.state.options, {
reader: this.reader,
text: () => this.text(),
}).run();
break;
case STARTMENUITEM_EXIT:
return;
case STARTMENUITEM_SAVE:
+17
View File
@@ -58,6 +58,23 @@ export const OW = {
// wJohtoBadges (constants/engine_flags.asm) and the trainer card's
// palettes (constants/scgb_constants.asm, constants/trainer_constants.asm).
NUM_JOHTO_BADGES: 8,
// wOptions, wTextboxFrame, wGBPrinterBrightness and wOptions2
// (constants/ram_constants.asm).
TEXT_DELAY_MASK: 7,
TEXT_DELAY_FAST: 1,
TEXT_DELAY_MED: 3,
TEXT_DELAY_SLOW: 5,
STEREO: 5,
BATTLE_SHIFT: 6,
BATTLE_SCENE: 7,
FRAME_1: 0,
NUM_FRAMES: 8,
GBPRINTER_LIGHTEST: 0x00,
GBPRINTER_LIGHTER: 0x20,
GBPRINTER_NORMAL: 0x40,
GBPRINTER_DARKER: 0x60,
GBPRINTER_DARKEST: 0x7f,
MENU_ACCOUNT: 0,
PREDEFPAL_CGB_BADGE: 0x24,
FALKNER: 1,
WHITNEY: 2,
+32 -3
View File
@@ -26,6 +26,14 @@ import {
refreshTrainerCard,
type TrainerCardView,
} from "../scripts/trainer-card";
import {
OPTIONS_BUTTONS,
shiftBattleStyle,
validOptions,
defaultOptions,
type GameOptions,
type OptionsView,
} from "../scripts/options";
import {
advanceGameTime,
emptyGameTime,
@@ -138,6 +146,8 @@ export interface NativeSave {
noWildEncounters: boolean;
/** wWhichRegisteredItem and wRegisteredItem, for Select. */
registeredItem: { which: number; item: number };
/** The option bytes, saved with the game as sOptions is. */
options: GameOptions;
}
/** A random byte from the platform's secure generator. */
@@ -259,6 +269,8 @@ export type Choice =
| { kind: "mombank"; view: MomBankView; selected: 0 }
/** A trainer card page, answered with the button pressed. */
| { kind: "trainercard"; view: TrainerCardView; selected: 0 }
/** The options screen, answered with the button pressed. */
| { kind: "options"; view: OptionsView; selected: 0 }
/** The mart's 1-99 quantity stepper; `selected` is the current amount. */
| {
kind: "martquantity";
@@ -275,7 +287,8 @@ export type Choice =
const buttonChoice = (choice: Choice) =>
choice.kind === "pokegear" ||
choice.kind === "mombank" ||
choice.kind === "trainercard";
choice.kind === "trainercard" ||
choice.kind === "options";
export interface Dialogue {
pages: string[];
index: number;
@@ -2178,6 +2191,7 @@ export class GameWorld {
saving: Number(draft.flags.has(OW.ENGINE_MOM_SAVING_MONEY)),
money: draft.momsMoney,
},
shift: shiftBattleStyle(draft.options),
});
this.battle = { engine, selected: 1 };
this.battleRun = engine.run();
@@ -2237,6 +2251,14 @@ export class GameWorld {
choice: { kind: "trainercard", view: effect.view, selected: 0 },
};
return true;
case "options":
if (!effect.view) return false;
this.dialogue = {
pages: [""],
index: 0,
choice: { kind: "options", view: effect.view, selected: 0 },
};
return true;
case "mombank":
this.dialogue = {
pages: effect.pages.length ? effect.pages : [""],
@@ -3754,7 +3776,9 @@ export class GameWorld {
(dialogue.choice.kind === "mombank" &&
!MOM_BANK_BUTTONS.includes(answer as never)) ||
(dialogue.choice.kind === "trainercard" &&
!TRAINER_CARD_BUTTONS.includes(answer as never))
!TRAINER_CARD_BUTTONS.includes(answer as never)) ||
(dialogue.choice.kind === "options" &&
!OPTIONS_BUTTONS.includes(answer as never))
)
return;
this.townMap = false;
@@ -3827,6 +3851,7 @@ export class GameWorld {
flash: state.flash,
noWildEncounters: state.noWildEncounters,
registeredItem: { ...state.registeredItem },
options: { ...state.options },
};
}
restore(value: unknown) {
@@ -3929,7 +3954,8 @@ export class GameWorld {
typeof save.noWildEncounters !== "boolean" ||
!save.registeredItem ||
!integer(save.registeredItem.which, 0, 255) ||
!integer(save.registeredItem.item, 0, 255)
!integer(save.registeredItem.item, 0, 255) ||
!validOptions(save.options)
) {
throw new Error(
"Invalid native save. Emulator snapshots cannot be imported into this port.",
@@ -3995,6 +4021,7 @@ export class GameWorld {
flash: save.flash,
noWildEncounters: save.noWildEncounters,
registeredItem: { ...save.registeredItem },
options: { ...save.options },
specialPhoneCall: save.specialPhoneCall,
rivalName: save.rivalName,
};
@@ -4105,6 +4132,7 @@ export function migrateSave(
}),
...(!Object.hasOwn(save, "intro") && { intro: false }),
...(!Object.hasOwn(save, "gameTime") && { gameTime: emptyGameTime() }),
...(!Object.hasOwn(save, "options") && { options: defaultOptions() }),
// Clocks saved before InitClock was ported follow the device time.
...(save.clock !== null &&
typeof save.clock === "object" &&
@@ -4151,5 +4179,6 @@ export function migrateSave(
registeredItem: { which: 0, item: 0 },
intro: false,
gameTime: emptyGameTime(),
options: defaultOptions(),
} as unknown as NativeSave;
}
+37 -2
View File
@@ -54,6 +54,7 @@ function showWorld(world: GameWorld) {
showStatsScreen(world);
showPokegear(world);
showTrainerCard(world);
showOptions(world);
get("town-map-view").hidden = !world.townMap;
if (world.townMap)
drawTownMap(get<HTMLCanvasElement>("town-map-canvas"), world);
@@ -70,6 +71,7 @@ function showWorld(world: GameWorld) {
// The Pokégear's map and radio cards have no textbox.
(dialogue.choice?.kind === "pokegear" && !dialogue.pages[dialogue.index]) ||
dialogue.choice?.kind === "trainercard" ||
dialogue.choice?.kind === "options" ||
Boolean(mart) ||
world.townMap ||
Boolean(world.statsScreen);
@@ -123,8 +125,13 @@ function showChoice(choice: Choice | undefined) {
return element;
};
if (!choice) container.replaceChildren();
else if (choice.kind === "pokegear" || choice.kind === "trainercard") {
// The Pokégear and the trainer card read the controls directly.
else if (
choice.kind === "pokegear" ||
choice.kind === "trainercard" ||
choice.kind === "options"
) {
// The Pokégear, the trainer card and the options read the controls
// directly.
container.replaceChildren();
container.hidden = true;
} else if (choice.kind === "yesno")
@@ -952,6 +959,34 @@ function showPokegear(world: GameWorld) {
} else body.replaceChildren();
}
let optionsKey = "";
/** _Option's screen: each option with its setting, and the ▶ cursor. */
function showOptions(world: GameWorld) {
const choice = world.dialogue?.choice;
const view = choice?.kind === "options" ? choice.view : undefined;
get("options").hidden = !view;
if (!view) {
optionsKey = "";
return;
}
const key = JSON.stringify(view);
if (key === optionsKey) return;
optionsKey = key;
get("options-list").replaceChildren(
...view.rows.map((row, index) => {
const item = document.createElement("li"),
label = document.createElement("span"),
value = document.createElement("strong");
label.textContent = row.label;
value.textContent = row.value;
item.append(label, value);
item.dataset.mark = index === view.cursor ? "▶" : " ";
if (index === view.cursor) item.setAttribute("aria-current", "true");
return item;
}),
);
}
let trainerCardKey = "";
/** TrainerCard (engine/menus/trainer_card.asm): the top half with the
* name, ID, money and card picture, then page 1's Pokédex count and play
+1
View File
@@ -34,6 +34,7 @@ export function renderShell(root: HTMLElement) {
<div id="stats-screen" class="stats-screen" hidden aria-label="Pokémon stats"><div class="stats-top"><canvas id="stats-pic" width="56" height="56" role="img"></canvas><div id="stats-header" class="stats-header"></div></div><div id="stats-page" class="stats-page" aria-live="polite"></div><div class="stats-nav"><button id="stats-prev" aria-label="Previous page">◀</button><span id="stats-page-number"></span><button id="stats-next" aria-label="Next page">▶</button><button id="stats-close">Close</button></div></div>
<div id="pokegear" class="pokegear" hidden aria-label="Pokégear"><div id="pokegear-cards" class="pokegear-cards" aria-label="Cards"></div><div id="pokegear-card" class="pokegear-card" aria-live="polite"></div></div>
<div id="trainer-card" class="trainer-card" hidden aria-label="Trainer card"><div class="trainer-card-top"><dl id="trainer-card-info"></dl><canvas id="trainer-card-pic" width="40" height="56" role="img"></canvas></div><div id="trainer-card-page" class="trainer-card-page"></div></div>
<div id="options" class="options-screen" hidden aria-label="Options"><ol id="options-list"></ol></div>
<div id="town-map-view" class="town-map-view" hidden aria-label="Town Map"><canvas id="town-map-canvas" width="160" height="144" role="img" aria-label="Town Map"></canvas><button id="town-map-close">Close map</button></div>
<div id="mart" class="mart" hidden aria-label="Poké Mart">
<p id="mart-money" class="mart-money"></p>
+37 -1
View File
@@ -540,7 +540,8 @@ dialog h2 {
}
.intro-screen:not([hidden]) ~ .minimap-overlay,
.pokegear:not([hidden]) ~ .minimap-overlay,
.trainer-card:not([hidden]) ~ .minimap-overlay {
.trainer-card:not([hidden]) ~ .minimap-overlay,
.options-screen:not([hidden]) ~ .minimap-overlay {
display: none;
}
.pokepic {
@@ -1326,3 +1327,38 @@ dialog dd {
aspect-ratio: 128 / 48;
image-rendering: pixelated;
}
/* _Option: one framed box of options, settings beside their labels. */
.options-screen {
position: absolute;
inset: 0;
z-index: 2;
display: grid;
padding: 10px;
overflow-y: auto;
color: var(--ink);
background: var(--cream);
}
.options-screen ol {
margin: 0;
padding: 10px 14px;
list-style: none;
border: 5px double var(--ink);
display: grid;
align-content: start;
gap: 8px;
}
.options-screen li {
display: grid;
grid-template-columns: 1.4em 13ch auto;
justify-content: start;
gap: 8px;
}
.options-screen li::before {
content: attr(data-mark);
}
.options-screen strong {
min-width: 9ch;
font-weight: normal;
white-space: pre;
}