Files
Crystal/src/ui/shell.ts
T
samyandClaude Opus 5.5 ce8129703f
Checks / web (push) Failing after 16s
Private ROM checks / private-rom (push) Failing after 17m47s
Make Crystal an installable web app with an offline shell (#39)
A manifest and icons rendered from icon.svg make the app installable.
The native build emits a service worker that precaches exactly the
built files and passes everything else to the network, so ROMs never
reach its cache. New versions wait for the player's Update, which saves
before reloading. Savegames offers installation, iPhone steps and
persistent storage, and a full disk gets its own save error.

Acceptance on physical iPhone and Android devices remains.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 20:51:57 +02:00

87 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const key = (name: string, label: string) =>
`<button class="game-key" data-game-button="${name}" aria-label="${name === "a" || name === "b" ? name.toUpperCase() : name}" aria-pressed="false" disabled>${label}</button>`;
const tab = (id: string, label: string, selected = false) =>
`<button id="tab-${id}" role="tab" aria-selected="${selected}" aria-controls="panel-${id}" tabindex="${selected ? "0" : "-1"}" data-tab="${id}">${selected ? '<span aria-hidden="true">▶</span>' : ""}${label}</button>`;
export function renderShell(root: HTMLElement) {
root.innerHTML = `
<header class="site-header">
<a class="brand" href="./" aria-label="Crystal home">CRYSTAL</a>
<div class="header-state"><span class="power"><i id="power-light"></i><span id="power-label">STANDBY</span></span><button id="settings" class="text-button">Settings</button><button id="help" class="text-button">Help</button></div>
</header>
<div id="update-banner" class="update-banner" role="status" hidden><span>A new version of Crystal is ready.</span><button id="update-app" class="text-button">Update</button></div>
<main class="app-shell">
<nav class="tabs" role="tablist" aria-label="Crystal sections">
${tab("game", "Game", true)}${tab("saves", "Savegames")}${tab("map", "Map")}${tab("pokedex", "Pokédex")}${tab("link", "Link")}${tab("friends", "Friends")}
</nav>
<section id="panel-game" class="tab-panel game-panel" role="tabpanel" aria-labelledby="tab-game" data-panel="game">
<div class="console">
<div class="screen-bezel">
<div class="screen">
<canvas id="game-screen" width="160" height="144" tabindex="0" aria-label="Game screen"></canvas>
<div id="screen-empty" class="screen-empty">
<form class="load-card" onsubmit="return false">
<p class="menu-title">NEW SESSION</p><h1>Load your Crystal ROM</h1><p>English US/Europe · Revision 1.1</p>
<label class="primary-button file-button" id="rom-label" for="rom-file"><span aria-hidden="true">▶</span> Choose ROM</label>
<p class="privacy-note">Opened locally. Never uploaded.</p>
</form>
</div>
<div id="battle" class="battle" hidden aria-label="Battle">
<div class="battle-foe"><div class="battle-hud"><div><strong id="battle-foe-name"></strong><span id="battle-foe-level"></span></div><div class="hp-row"><span>HP</span><span class="hp-bar"><i id="battle-foe-hp"></i></span></div><span id="battle-foe-status" class="battle-status"></span></div><canvas id="battle-foe-pic" width="56" height="56" role="img"></canvas></div>
<div id="battle-player" class="battle-player"><canvas id="battle-player-pic" width="48" height="48" role="img"></canvas><div class="battle-hud"><div><strong id="battle-player-name"></strong><span id="battle-player-level"></span></div><div class="hp-row"><span>HP</span><span class="hp-bar"><i id="battle-player-hp"></i></span></div><div class="battle-numbers"><span id="battle-player-status" class="battle-status"></span><span id="battle-player-hp-text"></span></div></div></div>
<div class="battle-box"><p id="battle-text" aria-live="polite"></p><div id="battle-menu" class="battle-menu"></div></div>
</div>
<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="mail-view" class="mail-view" hidden role="dialog" aria-labelledby="mail-type"><p id="mail-type" class="mail-type"></p><p id="mail-message" class="mail-message"></p><p id="mail-author" class="mail-author"></p><button id="mail-close">Close</button></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="pokedex-screen" class="pokedex-screen" hidden aria-label="Pokédex"></div>
<div id="options" class="options-screen" hidden aria-label="Options"><ol id="options-list"></ol></div>
<div id="fly-map" class="fly-map" hidden aria-label="Fly map"><p id="fly-map-bubble" class="fly-map-bubble" aria-live="polite"></p><canvas id="fly-map-canvas" width="160" height="144" role="img"></canvas></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>
<div class="mart-box"><p id="mart-prompt" aria-live="polite"></p><div id="mart-tabs" class="pack-tabs" aria-label="Pockets" hidden></div><div id="mart-menu" class="mart-menu"></div></div>
</div>
<div id="intro-screen" class="intro-screen" hidden></div>
<div id="pokepic" class="pokepic" hidden><canvas id="pokepic-canvas" width="56" height="56" role="img"></canvas><button id="pokepic-next" hidden>Continue <span aria-hidden="true">▶</span></button></div>
<div id="native-dialogue" class="native-dialogue" aria-label="Dialogue" hidden><p id="dialogue-text" aria-live="polite"></p><div id="dialogue-choice" class="dialogue-choice" hidden></div><div><span id="dialogue-page"></span><button id="dialogue-next">Continue <span aria-hidden="true">▶</span></button></div></div>
<div id="pause-overlay" class="pause-overlay" hidden><span>PAUSED</span><button id="screen-resume">Continue playing</button></div>
<aside class="minimap-overlay" aria-label="Current area map"><canvas id="minimap" width="160" height="128"></canvas><p id="minimap-position" hidden>Map unavailable</p></aside>
</div>
<div class="screen-caption"><span id="location">YOUR WORLD</span><span id="tiles-walked">0 tiles walked</span></div>
</div>
<div class="controls" aria-label="Game controls">
<div class="dpad" aria-label="Directional controls">${key("up", "▲")}${key("left", "◀")}<span class="dpad-center" aria-hidden="true"></span>${key("right", "▶")}${key("down", "▼")}</div>
<div class="action-keys">${key("b", "B")}${key("a", "A")}</div>
<div class="system-keys">${key("select", "SELECT")}${key("start", "START")}</div>
</div>
</div>
</section>
<section id="panel-saves" class="tab-panel menu-panel" role="tabpanel" aria-labelledby="tab-saves" data-panel="saves" hidden>
<header><p class="menu-title">SAVEGAMES</p><h2>Your local session</h2></header>
<div class="save-actions"><button id="save" disabled>Save session</button><button id="backup" disabled>Export backup</button><button id="import-save" disabled>Import native backup</button><input id="save-file" class="file-input" type="file" accept=".json"></div>
<div class="save-copy"><p>Native backups include your character, position and completed interactions. They are separate from cartridge .sav files.</p><p>Export before clearing browser data.</p></div>
<div class="replace-rom"><h3>Cartridge</h3><p>Select a ROM again after reloading, or choose another compatible copy.</p><label class="menu-button file-button" for="rom-file"><span aria-hidden="true">▶</span> Choose or replace ROM</label></div><div class="app-settings"><h3>App</h3><p id="install-status">Install Crystal to open it from your home screen and play the app offline. Select your ROM again each time you open it.</p><button id="install-app" class="menu-button" hidden>Install Crystal</button><h3>Storage</h3><p id="storage-status">Checking this browser's storage…</p><button id="persist-storage" class="menu-button" hidden>Keep saves on this device</button></div>
</section>
<section id="panel-map" class="tab-panel menu-panel map-panel" role="tabpanel" aria-labelledby="tab-map" data-panel="map" hidden>
<header><p class="menu-title">MAP</p><h2 id="map-location">No area loaded</h2><p id="map-position">Choose a ROM to see your position.</p></header>
<div class="map-panel-body"><div class="world-map-frame"><canvas id="world-map" width="160" height="128"></canvas><p id="map-empty">Your current area and the region map will appear here after loading a game.</p></div>
<section id="atlas" class="atlas" hidden aria-labelledby="atlas-title"><header class="atlas-header"><h3 id="atlas-title">Region</h3><div id="atlas-regions" class="atlas-regions"></div></header><canvas id="atlas-map" width="160" height="144" role="img"></canvas><p id="atlas-here" class="atlas-here"></p><div class="atlas-lists"><div><h4>Fly destinations</h4><ul id="atlas-fly"></ul></div><div><h4>Open in this port</h4><ul id="atlas-open"></ul></div></div><p class="atlas-legend"><span class="legend-player"></span> You <span class="legend-visited"></span> Visited flypoint</p></section></div>
</section>
<section id="panel-pokedex" class="tab-panel menu-panel pokedex-panel" role="tabpanel" aria-labelledby="tab-pokedex" data-panel="pokedex" hidden><header><p class="menu-title">POKÉDEX</p><h2>No game loaded</h2></header><p class="pokedex-tab-status">Choose a ROM to read the Pokémon you have seen and caught.</p></section>
<section id="panel-link" class="tab-panel menu-panel planned-panel" role="tabpanel" aria-labelledby="tab-link" data-panel="link" hidden><p class="menu-title">LINK</p><h2>Planned</h2><p>Trade and battle with other players. Online Link is planned.</p></section>
<section id="panel-friends" class="tab-panel menu-panel planned-panel" role="tabpanel" aria-labelledby="tab-friends" data-panel="friends" hidden><p class="menu-title">FRIENDS</p><h2>Planned</h2><p>Keep your friends together and send private Link invites. Friends are planned.</p></section>
</main>
<div class="global-bar">
<button id="play" class="play-button" disabled>Play</button>
<p id="game-notice" role="status" class="game-notice"></p>
<p id="status" class="status" role="status" aria-live="polite">Ready when you are. Choose your ROM to get started.</p>
<p id="save-status" class="save-status">Session backups stay in this browser.</p>
</div>
<input id="rom-file" class="file-input" type="file" accept=".gbc,.gb,.rom,application/octet-stream">
<dialog id="help-dialog" aria-labelledby="help-title"><button id="close-help" class="dialog-close" aria-label="Close help">×</button><p class="menu-title">HOW TO PLAY</p><h2 id="help-title">Playing Crystal in your browser</h2><p>Choose your own English US/Europe revision 1.1 ROM, then press Play. Graphics, maps, sprites and dialogue are read locally from your ROM.</p><dl id="help-keys"></dl><p>On a touch screen, use the on-screen pad and buttons. Settings changes the keys and the text size.</p><p>Sessions save every 10 seconds and when paused. Switching tabs or apps pauses play. The Map tab lists the areas open in this port; the others appear at the edges but cannot be entered yet.</p></dialog>
<dialog id="settings-dialog" aria-labelledby="settings-title"><button id="close-settings" class="dialog-close" aria-label="Close settings">×</button><p class="menu-title">SETTINGS</p><h2 id="settings-title">Display and controls</h2><fieldset class="text-size"><legend>Text size</legend><label><input type="radio" name="text-scale" value="1"> Standard</label><label><input type="radio" name="text-scale" value="1.25"> Large</label><label><input type="radio" name="text-scale" value="1.5"> Largest</label></fieldset><section class="key-settings" aria-labelledby="keys-title"><h3 id="keys-title">Keyboard controls</h3><p>Choose Change, then press the key to use. Tab, Escape and Space keep their usual roles.</p><table><thead><tr><th scope="col">Button</th><th scope="col">Key</th><th scope="col"><span class="visually-hidden">Change</span></th></tr></thead><tbody id="key-table"></tbody></table><button id="reset-keys" class="menu-button">Reset controls</button><p id="settings-status" class="settings-status" role="status" aria-live="polite"></p></section></dialog>`;
}