Light mode update

This commit is contained in:
2026-06-12 16:57:07 -07:00
parent fa37cffe47
commit ba32c96b7b
7 changed files with 211 additions and 47 deletions

27
TODO.md
View File

@@ -1,26 +1,21 @@
## Major features ## Major features
- [ ] **Light mode theme**. I like the "Lantern & Brine" look (see git log 0744893e7b82fe for your reasoning/description), and want to keep it as an option, but Yeld is generally speaking a lighter, more whimsical setting. I'd like the light theme to reflect a world of blue skies, high adventure, crashing waves, and bright red Xs on parchment treasure maps. Think "Wind Waker", "One Piece", or even "Skies of Arcadia". Add an option to the hamburger menu to toggle between light and dark themes. Light should probably be the default. Remember to update the CSS in the rulebook as well.
- [ ] **Rollback of game state.** This may require some updates to how game state is handled in the backend (my understanding is that the frontend always gets full state updates rather than deltas, so it should be unaffected). Feel free to propose significant overhauls/refactors to enable this cleanly, I am not attached to the current database architecture at all. Also feel free to ask clarifying questions before going ahead with implementation. I think a clean UI for this would involve the event log. Each event gets a "rollback" button to roll back the state of the game to a checkpoint just after that event was posted. Until a new event is posted, the later entries in the log remain in a greyed out state so that the rollback can be undone if it was accidental. These buttons appear to the admin player if dev mode is on, and also to Deep players, but only going back as far as the start of the current scene if they aren't the admin. - [ ] **Rollback of game state.** This may require some updates to how game state is handled in the backend (my understanding is that the frontend always gets full state updates rather than deltas, so it should be unaffected). Feel free to propose significant overhauls/refactors to enable this cleanly, I am not attached to the current database architecture at all. Also feel free to ask clarifying questions before going ahead with implementation. I think a clean UI for this would involve the event log. Each event gets a "rollback" button to roll back the state of the game to a checkpoint just after that event was posted. Until a new event is posted, the later entries in the log remain in a greyed out state so that the rollback can be undone if it was accidental. These buttons appear to the admin player if dev mode is on, and also to Deep players, but only going back as far as the start of the current scene if they aren't the admin.
## Minor Gameplay Polish
- [ ] **Uncommit feature in character creation**. While character creation is ongoing, add buttons to allow players to unlock/revise any choice that can be reasonably unlocked/revised - [ ] **Uncommit feature in character creation**. While character creation is ongoing, add buttons to allow players to unlock/revise any choice that can be reasonably unlocked/revised
## Minor Polish
- [ ] **Suggestions.** Take a pass through all of the suggestions in suggestions.js, rewrite ones that are stiff, awkward, or don't fit the theme/setting as described in the rulebook. Move the suggestion pool from suggestions.js into a backend API endpoint.
- [ ] **Example Play formatting.** The Example Play section of the Rules page could use a bit more formatting for legibility. Make diagrams of the card state at each step rather than just a text listing.
- [ ] **Dead Pi-Rat rank voting streamlined**. Don't allow voting for dead Pi-Rats. If a player's only vote options are dead, then they skip voting entirely.
- [ ] **TLDR rules**. A one page summary sheet of the rulebook, for the impatient
- [ ] **More detailed card tooltips.** Currently cards have a tooltip that show what they represent when played on obstacles, but they don't show what they represent *as* obstacles, which is relevant when issuing challenges to other Pi-Rats.
## Preventing blocked play
- [ ] **Player kicking.** Admins should be able to kick players out of the game so that it doesn't get stuck if a player vanishes. Admins can kick other admins. A kicked player's cards go to the discard. - [ ] **Player kicking.** Admins should be able to kick players out of the game so that it doesn't get stuck if a player vanishes. Admins can kick other admins. A kicked player's cards go to the discard.
- [ ] **Dead Pi-Rat rank voting streamlined**. Don't allow voting for dead Pi-Rats. If a player's only vote options are dead, then they skip voting entirely.
## Session management - [ ] **More detailed card tooltips.** Currently cards have a tooltip that show what they represent when played on obstacles, but they don't show what they represent *as* obstacles, which is relevant when issuing challenges to other Pi-Rats.
- [ ] **Add invite link to Admin panel** - [ ] **Add invite link to Admin panel**
- [ ] **Save active sessions in browser local storage**. Whenever you create or join a game, your browser local storage should be updated with the game ID, player ID, crew name, and player/rat names (if available). Going to the home page with saved sessions should provide a menu of games to rejoin in addition to the ability to start a new game. It should be possible to delete entries from this list, which only affects the browser local storage, not the backend database, and is undoable (the entry doesn't disappear immediately, it's just marked as deleted until page refresh).
- [ ] **Add crew/player/rat name to page title, if available**. This will make it easier to distinguish tabs/browser history entries - [ ] **Add crew/player/rat name to page title, if available**. This will make it easier to distinguish tabs/browser history entries
- [ ] **Save active sessions in browser local storage**. Whenever you create or join a game, your browser local storage should be updated with the game ID, player ID, crew name, and player/rat names (if available). Going to the home page with saved sessions should provide a menu of games to rejoin in addition to the ability to start a new game. It should be possible to delete entries from this list, which only affects the browser local storage, not the backend database, and is undoable (the entry doesn't disappear immediately, it's just marked as deleted until page refresh).
## Words Words Words
- [ ] **Example Play formatting.** The Example Play section of the Rules page could use a bit more formatting for legibility. Make diagrams of the card state at each step rather than just a text listing.
- [ ] **Suggestions.** Take a pass through all of the suggestions in suggestions.js, rewrite ones that are stiff, awkward, or don't fit the theme/setting as described in the rulebook. Move the suggestion pool from suggestions.js into a backend API endpoint.
- [ ] **TLDR rules**. A one page summary sheet of the rulebook, for the impatient
## Security ## Security

View File

@@ -5,6 +5,12 @@
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Rats with Gats</title> <title>Rats with Gats</title>
<script>
// Apply the saved theme before first paint to avoid a flash of the
// wrong palette. lib/theme.js owns this key afterwards.
document.documentElement.dataset.theme =
localStorage.getItem('pirats-theme') === 'dark' ? 'dark' : 'light';
</script>
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>

View File

@@ -76,7 +76,7 @@
width: 75px; width: 75px;
height: 112px; height: 112px;
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
box-shadow: 0 3px 10px rgba(0, 0, 0, 0.4); box-shadow: var(--shadow-card);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: space-between; justify-content: space-between;
@@ -114,7 +114,7 @@
height: 165px; height: 165px;
border-radius: var(--radius-md); border-radius: var(--radius-md);
border-width: 2px; border-width: 2px;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.5); box-shadow: var(--shadow-card-raised);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: space-between; justify-content: space-between;
@@ -125,7 +125,7 @@
.card-large:hover { .card-large:hover {
transform: translateY(-8px) scale(1.05); transform: translateY(-8px) scale(1.05);
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.55), 0 0 14px color-mix(in srgb, var(--accent) 30%, transparent); box-shadow: var(--shadow-card-lifted), 0 0 14px color-mix(in srgb, var(--accent) 30%, transparent);
} }
.card-large .card-corner { .card-large .card-corner {

View File

@@ -1,16 +1,99 @@
/* ============================================================ /* ============================================================
RATS WITH GATS — THEME TOKENS RATS WITH GATS — THEME TOKENS
"Lantern & Brine": a lantern-lit pirate ship at night.
Two themes:
- "Fair Winds" (default, light): blue skies, bright seas, and
parchment treasure maps with big red Xs. Wind Waker energy.
- "Lantern & Brine" (data-theme="dark"): a lantern-lit pirate
ship at night.
This file is the ONLY place colors, fonts, radii, and shadows This file is the ONLY place colors, fonts, radii, and shadows
are defined. Every other stylesheet derives translucent are defined. Every other stylesheet derives translucent
variants with color-mix(), so re-theming the app means variants with color-mix(), so re-theming the app means
editing this file and nothing else. editing this file and nothing else. The active theme is the
data-theme attribute on <html>, set from localStorage by an
inline script in index.html and toggled via lib/theme.js.
(Font files are bundled via @fontsource imports in main.js.) (Font files are bundled via @fontsource imports in main.js.)
============================================================ */ ============================================================ */
:root { :root {
/* --- Raw palette ---------------------------------------- */ /* --- "Fair Winds" raw palette (light, default) ----------- */
--sky: #cde6f5; /* page background: midday sky */
--sky-deep: #9fc9e2; /* page edges: deeper blue toward the horizon */
--sail: #f8f1dd; /* panel surface: weathered sailcloth */
--sail-bright: #fffaec; /* raised surface */
--map-ink: #33291a; /* primary text: sepia chart ink */
--map-faded: #7d6e54; /* muted text */
--doubloon: #a8770f; /* primary accent: antique gold */
--doubloon-bright: #c08a13;
--doubloon-dark: #7c560a;
--lagoon: #0c8d96; /* the Deep: tropical shallows */
--palm: #2f8f4e; /* Pi-Rats, success */
--x-red: #c92a35; /* danger: the X that marks the spot */
--cannonball: #2c3245; /* black suits: dark navy iron */
--sunset: #c2640e; /* warnings, jokers */
--sea-orchid: #7757c9; /* votes, objectives, misc arcana */
--sea-mist: #69808f; /* dead & ghostly things */
/* Playing-card faces (crisp cards on the sailcloth table) */
--card-face: #fffdf2;
--card-face-shade: #e7d7ac;
--card-ink-red: #b03546;
--card-ink-black: #2a3040;
/* --- Semantic assignments -------------------------------- */
--bg: var(--sky);
--bg-deep: var(--sky-deep);
--surface: var(--sail);
--surface-raised: var(--sail-bright);
--well: color-mix(in srgb, var(--map-ink) 8%, transparent);
--text: var(--map-ink);
--text-muted: var(--map-faded);
--text-inverse: #fff8e6;
--accent: var(--doubloon);
--accent-bright: var(--doubloon-bright);
--accent-dark: var(--doubloon-dark);
--deep: var(--lagoon);
--pirat: var(--palm);
--success: var(--palm);
--danger: var(--x-red);
--warning: var(--sunset);
--mystic: var(--sea-orchid);
--ghost: var(--sea-mist);
--suit-red: var(--x-red);
--suit-black: var(--cannonball);
/* Borders */
--edge: color-mix(in srgb, var(--map-ink) 22%, transparent);
--edge-soft: color-mix(in srgb, var(--map-ink) 11%, transparent);
--edge-accent: color-mix(in srgb, var(--accent) 40%, transparent);
/* --- Type ------------------------------------------------ */
--font-display: 'Pirata One', 'Alegreya SC', serif;
--font-heading: 'Alegreya SC', serif;
--font-body: 'Alegreya Sans', sans-serif;
/* --- Shape & motion -------------------------------------- */
--radius-sm: 6px;
--radius-md: 10px;
--radius-lg: 14px;
--shadow-soft: 0 4px 14px rgba(36, 62, 87, 0.18);
--shadow-deep: 0 10px 30px rgba(36, 62, 87, 0.28);
--shadow-card: 0 3px 10px rgba(36, 62, 87, 0.22);
--shadow-card-raised: 0 4px 15px rgba(36, 62, 87, 0.3);
--shadow-card-lifted: 0 10px 25px rgba(36, 62, 87, 0.32);
--transition-smooth: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
}
:root[data-theme="dark"] {
/* --- "Lantern & Brine" raw palette ----------------------- */
--ink-deep: #0a0d13; /* darkest: page edges, sunken wells */ --ink-deep: #0a0d13; /* darkest: page edges, sunken wells */
--ink: #131822; /* page background */ --ink: #131822; /* page background */
--hull: #1b212e; /* panel surface */ --hull: #1b212e; /* panel surface */
@@ -28,14 +111,10 @@
--coral: #e8606e; /* danger, red suits */ --coral: #e8606e; /* danger, red suits */
--bone: #dfe6ef; /* black suits */ --bone: #dfe6ef; /* black suits */
--ember: #f0954c; /* warnings, jokers */ --ember: #f0954c; /* warnings, jokers */
--mystic: #a08cd8; /* votes, objectives, misc arcana */
--ghost: #8fa3b8; /* dead & ghostly things */
/* Playing-card faces (light parchment cards on a dark table) */ /* Playing-card faces (light parchment cards on a dark table) */
--card-face: #f2e9d2; --card-face: #f2e9d2;
--card-face-shade: #ddcfa9; --card-face-shade: #ddcfa9;
--card-ink-red: #b03546;
--card-ink-black: #2a3040;
/* --- Semantic assignments -------------------------------- */ /* --- Semantic assignments -------------------------------- */
--bg: var(--ink); --bg: var(--ink);
@@ -57,6 +136,8 @@
--success: var(--kelp); --success: var(--kelp);
--danger: var(--coral); --danger: var(--coral);
--warning: var(--ember); --warning: var(--ember);
--mystic: #a08cd8;
--ghost: #8fa3b8;
--suit-red: var(--coral); --suit-red: var(--coral);
--suit-black: var(--bone); --suit-black: var(--bone);
@@ -66,16 +147,10 @@
--edge-soft: color-mix(in srgb, var(--parchment) 8%, transparent); --edge-soft: color-mix(in srgb, var(--parchment) 8%, transparent);
--edge-accent: color-mix(in srgb, var(--accent) 35%, transparent); --edge-accent: color-mix(in srgb, var(--accent) 35%, transparent);
/* --- Type ------------------------------------------------ */ /* --- Shadows --------------------------------------------- */
--font-display: 'Pirata One', 'Alegreya SC', serif;
--font-heading: 'Alegreya SC', serif;
--font-body: 'Alegreya Sans', sans-serif;
/* --- Shape & motion -------------------------------------- */
--radius-sm: 6px;
--radius-md: 10px;
--radius-lg: 14px;
--shadow-soft: 0 4px 14px rgba(0, 0, 0, 0.35); --shadow-soft: 0 4px 14px rgba(0, 0, 0, 0.35);
--shadow-deep: 0 10px 30px rgba(0, 0, 0, 0.5); --shadow-deep: 0 10px 30px rgba(0, 0, 0, 0.5);
--transition-smooth: all 0.25s cubic-bezier(0.4, 0, 0.2, 1); --shadow-card: 0 3px 10px rgba(0, 0, 0, 0.4);
--shadow-card-raised: 0 4px 15px rgba(0, 0, 0, 0.5);
--shadow-card-lifted: 0 10px 25px rgba(0, 0, 0, 0.55);
} }

View File

@@ -1,10 +1,16 @@
<script> <script>
import { extraMenuLinks } from '../lib/menu'; import { extraMenuLinks } from '../lib/menu';
import { theme, toggleTheme } from '../lib/theme';
let open = false; let open = false;
const baseLinks = [ $: themeLink = $theme === 'dark'
? { label: '☀️ Light Mode', action: toggleTheme, keepOpen: true, title: 'Switch to the light theme' }
: { label: '🌙 Dark Mode', action: toggleTheme, keepOpen: true, title: 'Switch to the dark theme' };
$: baseLinks = [
{ label: '📖 Rules', href: '/rules', external: true, title: 'Open the rulebook in a new tab' }, { label: '📖 Rules', href: '/rules', external: true, title: 'Open the rulebook in a new tab' },
themeLink,
]; ];
$: links = [...$extraMenuLinks, ...baseLinks]; $: links = [...$extraMenuLinks, ...baseLinks];
@@ -29,7 +35,7 @@
<button <button
class="menu-item" class="menu-item"
title={link.title} title={link.title}
on:click={() => { open = false; link.action(); }} on:click={() => { if (!link.keepOpen) open = false; link.action(); }}
>{link.label}</button> >{link.label}</button>
{:else} {:else}
<a <a

23
frontend/src/lib/theme.js Normal file
View File

@@ -0,0 +1,23 @@
import { writable } from 'svelte/store';
// Light/dark theme preference, shared across games via localStorage (the
// rulebook page reads the same key). index.html applies the saved theme
// before first paint; this store keeps the <html> attribute and storage in
// sync when the user toggles from the corner menu.
const STORAGE_KEY = 'pirats-theme';
function savedTheme() {
const value = localStorage.getItem(STORAGE_KEY);
return value === 'dark' ? 'dark' : 'light';
}
export const theme = writable(savedTheme());
theme.subscribe((value) => {
document.documentElement.dataset.theme = value;
localStorage.setItem(STORAGE_KEY, value);
});
export function toggleTheme() {
theme.update((value) => (value === 'dark' ? 'light' : 'dark'));
}

View File

@@ -7,10 +7,37 @@
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Alegreya+SC:wght@500;700&family=Alegreya+Sans:wght@400;500;700&family=Pirata+One&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Alegreya+SC:wght@500;700&family=Alegreya+Sans:wght@400;500;700&family=Pirata+One&display=swap" rel="stylesheet">
<script>
// Apply the saved theme before first paint (same localStorage key as the SPA).
document.documentElement.dataset.theme =
localStorage.getItem('pirats-theme') === 'dark' ? 'dark' : 'light';
</script>
<style> <style>
/* Standalone rulebook page — palette mirrors the SPA's theme.css tokens /* Standalone rulebook page — palette mirrors the SPA's theme.css tokens:
("Lantern & Brine"). If you re-theme the app, update these values too. */ "Fair Winds" (light, default) and "Lantern & Brine" (data-theme="dark").
If you re-theme the app, update these values too. The theme is read from
the same localStorage key the SPA uses ("pirats-theme"). */
:root { :root {
--bg-dark: #9fc9e2;
--bg-ocean: #cde6f5;
--gold: #a8770f;
--neon-cyan: #0c8d96;
--red-suit: #c92a35;
--black-suit: #2c3245;
--glass-bg: rgba(248, 241, 221, 0.92);
--glass-border: rgba(168, 119, 15, 0.35);
--text-primary: #33291a;
--text-muted: #7d6e54;
--well-bg: rgba(51, 41, 26, 0.06);
--row-border: rgba(168, 119, 15, 0.2);
--solid-bg: rgba(255, 250, 236, 0.95);
--shadow-panel: 0 8px 32px rgba(36, 62, 87, 0.2);
--font-display: 'Pirata One', 'Alegreya SC', serif;
--font-heading: 'Alegreya SC', serif;
--font-body: 'Alegreya Sans', sans-serif;
}
:root[data-theme="dark"] {
--bg-dark: #0a0d13; --bg-dark: #0a0d13;
--bg-ocean: #131822; --bg-ocean: #131822;
--gold: #d9a23c; --gold: #d9a23c;
@@ -21,9 +48,10 @@
--glass-border: rgba(217, 162, 60, 0.25); --glass-border: rgba(217, 162, 60, 0.25);
--text-primary: #efe7d3; --text-primary: #efe7d3;
--text-muted: #a89e8a; --text-muted: #a89e8a;
--font-display: 'Pirata One', 'Alegreya SC', serif; --well-bg: rgba(10, 13, 19, 0.5);
--font-heading: 'Alegreya SC', serif; --row-border: rgba(217, 162, 60, 0.12);
--font-body: 'Alegreya Sans', sans-serif; --solid-bg: rgba(10, 13, 19, 0.9);
--shadow-panel: 0 8px 32px rgba(0, 0, 0, 0.4);
} }
* { margin: 0; padding: 0; box-sizing: border-box; } * { margin: 0; padding: 0; box-sizing: border-box; }
@@ -45,7 +73,7 @@ a { color: var(--neon-cyan); }
background: var(--glass-bg); background: var(--glass-bg);
border: 1px solid var(--glass-border); border: 1px solid var(--glass-border);
border-radius: 12px; border-radius: 12px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4); box-shadow: var(--shadow-panel);
} }
.rules-page { .rules-page {
@@ -131,14 +159,14 @@ a { color: var(--neon-cyan); }
width: 100%; width: 100%;
border-collapse: collapse; border-collapse: collapse;
margin: 0.75rem 0 1rem; margin: 0.75rem 0 1rem;
background: rgba(10, 13, 19, 0.5); background: var(--well-bg);
border: 1px solid var(--glass-border); border: 1px solid var(--glass-border);
border-radius: 8px; border-radius: 8px;
} }
.rules-body th, .rules-body td { .rules-body th, .rules-body td {
padding: 0.5rem 0.75rem; padding: 0.5rem 0.75rem;
border-bottom: 1px solid rgba(217, 162, 60, 0.12); border-bottom: 1px solid var(--row-border);
text-align: left; text-align: left;
vertical-align: top; vertical-align: top;
} }
@@ -184,10 +212,26 @@ a { color: var(--neon-cyan); }
padding: 6px 14px; padding: 6px 14px;
border-radius: 16px; border-radius: 16px;
border: 1px solid var(--glass-border); border: 1px solid var(--glass-border);
background: rgba(10, 13, 19, 0.9); background: var(--solid-bg);
text-decoration: none; text-decoration: none;
font-size: 0.85rem; font-size: 0.85rem;
} }
.theme-toggle {
position: fixed;
top: 12px;
right: 16px;
z-index: 10;
padding: 6px 14px;
border-radius: 16px;
border: 1px solid var(--glass-border);
background: var(--solid-bg);
color: var(--gold);
font-size: 0.85rem;
font-weight: 700;
font-family: var(--font-heading);
cursor: pointer;
}
</style> </style>
</head> </head>
<body> <body>
@@ -1312,7 +1356,22 @@ a { color: var(--neon-cyan); }
<p class="back-to-top"><a class="top-link" href="#introduction">⬆ Back to top</a></p> <p class="back-to-top"><a class="top-link" href="#introduction">⬆ Back to top</a></p>
</article> </article>
</div> </div>
<button class="theme-toggle" id="theme-toggle" type="button"></button>
<script> <script>
// Theme toggle: shares the "pirats-theme" localStorage key with the SPA.
const themeToggle = document.getElementById('theme-toggle');
function refreshThemeToggle() {
const dark = document.documentElement.dataset.theme === 'dark';
themeToggle.textContent = dark ? '☀️ Light Mode' : '🌙 Dark Mode';
}
themeToggle.addEventListener('click', () => {
const next = document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark';
document.documentElement.dataset.theme = next;
localStorage.setItem('pirats-theme', next);
refreshThemeToggle();
});
refreshThemeToggle();
// Expand the collapsed obstacle reference when the TOC link (or a deep link) targets it // Expand the collapsed obstacle reference when the TOC link (or a deep link) targets it
function openObstaclesForHash() { function openObstaclesForHash() {
if (location.hash === '#obstacles') { if (location.hash === '#obstacles') {