Serve the rulebook as a static HTML page from FastAPI

Replace the SPA Rules route with a self-contained src/pirats/rules.html
served at GET /rules (registered before the SPA mount, included in
package data for the Nix build). Plain anchor links replace the
scrollIntoView workaround since there's no hash router to fight. The
corner Rules link and splash-page link now point at /rules, and the
Vite dev server proxies /rules to the backend like /api.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-12 01:35:04 -07:00
parent 69e7d24e10
commit 683c60fff9
8 changed files with 237 additions and 274 deletions

View File

@@ -4,18 +4,37 @@
import Join from './pages/Join.svelte'; import Join from './pages/Join.svelte';
import Dashboard from './pages/Dashboard.svelte'; import Dashboard from './pages/Dashboard.svelte';
import Admin from './pages/Admin.svelte'; import Admin from './pages/Admin.svelte';
import Rules from './pages/Rules.svelte';
const routes = { const routes = {
'/': Home, '/': Home,
'/game/:id/join': Join, '/game/:id/join': Join,
'/game/:id/player/:pid': Dashboard, '/game/:id/player/:pid': Dashboard,
'/game/:id/admin': Admin, '/game/:id/admin': Admin,
'/rules': Rules,
}; };
</script> </script>
<main> <main>
<Router {routes} /> <Router {routes} />
<a class="rules-corner-link" href="#/rules" target="_blank" rel="noopener" title="Open the rulebook in a new tab">📖 Rules</a> <a class="rules-corner-link" href="/rules" target="_blank" rel="noopener" title="Open the rulebook in a new tab">📖 Rules</a>
</main> </main>
<style>
.rules-corner-link {
position: fixed;
top: 12px;
left: 16px;
z-index: 1001;
padding: 6px 14px;
border-radius: 16px;
background: rgba(10, 15, 25, 0.9);
border: 1px solid var(--glass-border-focus);
color: var(--neon-cyan);
font-size: 0.85rem;
font-weight: 700;
text-decoration: none;
font-family: var(--font-heading);
}
.rules-corner-link:hover {
background: rgba(0, 242, 254, 0.1);
}
</style>

View File

@@ -9,4 +9,3 @@
@import './assets/css/card.css'; @import './assets/css/card.css';
@import './assets/css/upkeep.css'; @import './assets/css/upkeep.css';
@import './assets/css/admin.css'; @import './assets/css/admin.css';
@import './assets/css/rules.css';

View File

@@ -1,242 +0,0 @@
/* --- Rules Page --- */
.rules-page {
max-width: 860px;
margin: 0 auto;
padding: 2rem 1rem 4rem;
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.rules-page .glass-panel {
padding: 1.5rem 2rem;
}
.rules-header {
text-align: center;
}
.rules-header h1 {
font-family: var(--font-heading);
color: var(--gold);
font-size: 2.4rem;
margin-bottom: 0.25rem;
}
.rules-subtitle {
color: var(--text-primary);
font-size: 1.15rem;
margin-bottom: 0.75rem;
}
.rules-credits {
color: var(--text-muted);
font-size: 0.85rem;
margin-bottom: 0.5rem;
}
.rules-credits a {
color: var(--neon-cyan);
}
.playtest-note {
margin-top: 0.75rem;
padding: 0.5rem 1rem;
border: 1px dashed var(--glass-border);
border-radius: 8px;
color: var(--gold);
font-size: 0.9rem;
}
.rules-toc h2 {
font-family: var(--font-heading);
color: var(--gold);
margin-bottom: 0.75rem;
}
.rules-toc ul {
list-style: none;
padding-left: 0;
}
.rules-toc ul ul {
padding-left: 1.5rem;
}
.rules-toc li {
margin: 0.2rem 0;
}
.rules-toc button {
background: none;
border: none;
color: var(--neon-cyan);
cursor: pointer;
font-family: var(--font-body);
font-size: 1rem;
padding: 0;
text-align: left;
}
.rules-toc button:hover {
text-decoration: underline;
}
.rules-body {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.rules-body section {
scroll-margin-top: 1rem;
}
.rules-body h2 {
font-family: var(--font-heading);
color: var(--gold);
font-size: 1.8rem;
margin-bottom: 1rem;
border-bottom: 1px solid var(--glass-border);
padding-bottom: 0.5rem;
}
.rules-body h3 {
font-family: var(--font-heading);
color: var(--text-primary);
font-size: 1.35rem;
margin: 1.5rem 0 0.75rem;
scroll-margin-top: 1rem;
}
.rules-body h4 {
color: var(--gold);
font-size: 1.1rem;
margin: 1.25rem 0 0.5rem;
}
.rules-body h5 {
color: var(--text-primary);
font-size: 1rem;
margin: 1rem 0 0.5rem;
}
.objective-note {
color: var(--text-muted);
font-weight: normal;
font-style: italic;
font-size: 0.9em;
}
.rules-body p {
margin-bottom: 0.75rem;
}
.rules-body ul,
.rules-body ol {
margin: 0 0 0.75rem 1.5rem;
}
.rules-body li {
margin-bottom: 0.35rem;
}
.rules-body .aside {
color: var(--text-muted);
font-style: italic;
font-size: 0.95em;
}
.technique-examples {
list-style: none;
margin-left: 0.5rem;
}
.rules-body table {
width: 100%;
border-collapse: collapse;
margin: 0.75rem 0 1rem;
background: rgba(7, 11, 18, 0.5);
border: 1px solid var(--glass-border);
border-radius: 8px;
}
.rules-body th,
.rules-body td {
padding: 0.5rem 0.75rem;
border-bottom: 1px solid rgba(212, 175, 55, 0.1);
text-align: left;
vertical-align: top;
}
.rules-body thead th {
color: var(--gold);
font-family: var(--font-heading);
}
.obstacle-chart th {
width: 2.5rem;
text-align: center;
color: var(--gold);
font-family: var(--font-heading);
}
.suit-ways th {
width: 7.5rem;
white-space: nowrap;
}
.suit-red {
color: var(--red-suit);
}
.suit-black {
color: var(--black-suit);
}
.rules-body details {
border: 1px solid var(--glass-border);
border-radius: 8px;
padding: 0.75rem 1rem;
}
.rules-body details summary {
cursor: pointer;
color: var(--neon-cyan);
font-weight: 600;
padding: 0.25rem 0;
}
.rules-body details[open] summary {
margin-bottom: 0.75rem;
}
.signature {
color: var(--gold);
font-style: italic;
}
.back-to-top {
text-align: center;
}
/* --- Always-available rules corner link (rendered by App.svelte) --- */
.rules-corner-link {
position: fixed;
top: 12px;
left: 16px;
z-index: 1001;
padding: 6px 14px;
border-radius: 16px;
background: rgba(10, 15, 25, 0.9);
border: 1px solid var(--glass-border-focus);
color: var(--neon-cyan);
font-size: 0.85rem;
font-weight: 700;
text-decoration: none;
font-family: var(--font-heading);
}
.rules-corner-link:hover {
background: rgba(0, 242, 254, 0.1);
}

View File

@@ -56,6 +56,6 @@
</div> </div>
<div class="links mt-4"> <div class="links mt-4">
<a href="#/rules" class="text-muted">Read the Rules</a> <a href="/rules" class="text-muted">Read the Rules</a>
</div> </div>
</div> </div>

View File

@@ -9,6 +9,10 @@ export default defineConfig({
'/api': { '/api': {
target: 'http://127.0.0.1:8000', target: 'http://127.0.0.1:8000',
changeOrigin: true, changeOrigin: true,
},
'/rules': {
target: 'http://127.0.0.1:8000',
changeOrigin: true,
} }
} }
} }

View File

@@ -27,7 +27,7 @@ pirats = "pirats.main:main"
where = ["src"] where = ["src"]
[tool.setuptools.package-data] [tool.setuptools.package-data]
pirats = ["static/**/*"] pirats = ["static/**/*", "rules.html"]
[tool.pytest.ini_options] [tool.pytest.ini_options]
pythonpath = ["src"] pythonpath = ["src"]

View File

@@ -1,6 +1,7 @@
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from typing import Optional from typing import Optional
from fastapi import FastAPI, Form, Depends, HTTPException, APIRouter from fastapi import FastAPI, Form, Depends, HTTPException, APIRouter
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from sqlmodel import Session from sqlmodel import Session
import uvicorn import uvicorn
@@ -105,6 +106,11 @@ api.include_router(admin_router)
# Mount API at /api # Mount API at /api
app.include_router(api, prefix="/api") app.include_router(api, prefix="/api")
# Static rulebook page (registered before the SPA mount so it takes precedence)
@app.get("/rules", include_in_schema=False)
def rules_page():
return FileResponse(BASE_DIR / "rules.html", media_type="text/html")
# Mount SPA # Mount SPA
static_dir = BASE_DIR / "static" static_dir = BASE_DIR / "static"
if os.path.exists(static_dir): if os.path.exists(static_dir):

View File

@@ -1,11 +1,186 @@
<script> <!doctype html>
// In-page anchors can't use href="#id" because the hash is owned by the SPA router. <html lang="en">
function jump(id) { <head>
const el = document.getElementById(id); <meta charset="UTF-8">
if (el) el.scrollIntoView({ block: 'start' }); <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Rats with Gats — Rulebook</title>
<style>
/* Standalone rulebook page — palette mirrors the SPA's core.css tokens */
:root {
--bg-dark: #070b12;
--bg-ocean: #0d1726;
--gold: #d4af37;
--neon-cyan: #00f2fe;
--red-suit: #ff2a5f;
--black-suit: #e2e8f0;
--glass-bg: rgba(13, 23, 38, 0.7);
--glass-border: rgba(212, 175, 55, 0.2);
--text-primary: #f1f5f9;
--text-muted: #94a3b8;
--font-heading: 'Cinzel', serif;
--font-body: 'Outfit', sans-serif;
} }
</script>
* { margin: 0; padding: 0; box-sizing: border-box; }
html { scroll-behavior: smooth; }
body {
background: radial-gradient(circle at 50% 50%, var(--bg-ocean) 0%, var(--bg-dark) 100%);
color: var(--text-primary);
font-family: var(--font-body);
font-size: 16px;
line-height: 1.6;
min-height: 100vh;
}
a { color: var(--neon-cyan); }
.glass-panel {
background: var(--glass-bg);
border: 1px solid var(--glass-border);
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
}
.rules-page {
max-width: 860px;
margin: 0 auto;
padding: 2rem 1rem 4rem;
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.rules-page .glass-panel { padding: 1.5rem 2rem; }
.rules-header { text-align: center; }
.rules-header h1 {
font-family: var(--font-heading);
color: var(--gold);
font-size: 2.4rem;
margin-bottom: 0.25rem;
}
.rules-subtitle { font-size: 1.15rem; margin-bottom: 0.75rem; }
.rules-credits { color: var(--text-muted); font-size: 0.85rem; margin-bottom: 0.5rem; }
.playtest-note {
margin-top: 0.75rem;
padding: 0.5rem 1rem;
border: 1px dashed var(--glass-border);
border-radius: 8px;
color: var(--gold);
font-size: 0.9rem;
}
.rules-toc h2 { font-family: var(--font-heading); color: var(--gold); margin-bottom: 0.75rem; }
.rules-toc ul { list-style: none; padding-left: 0; }
.rules-toc ul ul { padding-left: 1.5rem; }
.rules-toc li { margin: 0.2rem 0; }
.rules-toc a { text-decoration: none; }
.rules-toc a:hover { text-decoration: underline; }
.rules-body { display: flex; flex-direction: column; gap: 1.5rem; }
.rules-body section { scroll-margin-top: 1rem; }
.rules-body h2 {
font-family: var(--font-heading);
color: var(--gold);
font-size: 1.8rem;
margin-bottom: 1rem;
border-bottom: 1px solid var(--glass-border);
padding-bottom: 0.5rem;
}
.rules-body h3 {
font-family: var(--font-heading);
color: var(--text-primary);
font-size: 1.35rem;
margin: 1.5rem 0 0.75rem;
scroll-margin-top: 1rem;
}
.rules-body h4 { color: var(--gold); font-size: 1.1rem; margin: 1.25rem 0 0.5rem; }
.rules-body h5 { color: var(--text-primary); font-size: 1rem; margin: 1rem 0 0.5rem; }
.objective-note {
color: var(--text-muted);
font-weight: normal;
font-style: italic;
font-size: 0.9em;
}
.rules-body p { margin-bottom: 0.75rem; }
.rules-body ul, .rules-body ol { margin: 0 0 0.75rem 1.5rem; }
.rules-body li { margin-bottom: 0.35rem; }
.rules-body .aside { color: var(--text-muted); font-style: italic; font-size: 0.95em; }
.technique-examples { list-style: none; margin-left: 0.5rem; }
.rules-body table {
width: 100%;
border-collapse: collapse;
margin: 0.75rem 0 1rem;
background: rgba(7, 11, 18, 0.5);
border: 1px solid var(--glass-border);
border-radius: 8px;
}
.rules-body th, .rules-body td {
padding: 0.5rem 0.75rem;
border-bottom: 1px solid rgba(212, 175, 55, 0.1);
text-align: left;
vertical-align: top;
}
.rules-body thead th { color: var(--gold); font-family: var(--font-heading); }
.obstacle-chart th {
width: 2.5rem;
text-align: center;
color: var(--gold);
font-family: var(--font-heading);
}
.suit-ways th { width: 7.5rem; white-space: nowrap; }
.suit-red { color: var(--red-suit); }
.suit-black { color: var(--black-suit); }
.rules-body details {
border: 1px solid var(--glass-border);
border-radius: 8px;
padding: 0.75rem 1rem;
}
.rules-body details summary {
cursor: pointer;
color: var(--neon-cyan);
font-weight: 600;
padding: 0.25rem 0;
}
.rules-body details[open] summary { margin-bottom: 0.75rem; }
.signature { color: var(--gold); font-style: italic; }
.back-to-top { text-align: center; }
.top-link {
display: inline-block;
padding: 6px 14px;
border-radius: 16px;
border: 1px solid var(--glass-border);
background: rgba(10, 15, 25, 0.9);
text-decoration: none;
font-size: 0.85rem;
}
</style>
</head>
<body>
<div class="rules-page"> <div class="rules-page">
<header class="rules-header glass-panel"> <header class="rules-header glass-panel">
<h1>Rats with Gats</h1> <h1>Rats with Gats</h1>
@@ -25,30 +200,30 @@
<nav class="rules-toc glass-panel"> <nav class="rules-toc glass-panel">
<h2>Table of Contents</h2> <h2>Table of Contents</h2>
<ul> <ul>
<li><button on:click={() => jump('introduction')}>Part 1: Introduction</button></li> <li><a href="#introduction">Part 1: Introduction</a></li>
<li><button on:click={() => jump('creating')}>Part 2: Creating a Pi-Rat</button> <li><a href="#creating">Part 2: Creating a Pi-Rat</a>
<ul> <ul>
<li><button on:click={() => jump('rank-hand')}>Rank and Hand Size</button></li> <li><a href="#rank-hand">Rank and Hand Size</a></li>
<li><button on:click={() => jump('techniques')}>Secret Pirate Techniques</button></li> <li><a href="#techniques">Secret Pirate Techniques</a></li>
<li><button on:click={() => jump('objectives')}>Personal and Crew Objectives</button></li> <li><a href="#objectives">Personal and Crew Objectives</a></li>
<li><button on:click={() => jump('captains')}>Pi-Rat Captains</button></li> <li><a href="#captains">Pi-Rat Captains</a></li>
</ul> </ul>
</li> </li>
<li><button on:click={() => jump('playing')}>Part 3: Playing the Game</button> <li><a href="#playing">Part 3: Playing the Game</a>
<ul> <ul>
<li><button on:click={() => jump('setting-scenes')}>Setting Scenes</button></li> <li><a href="#setting-scenes">Setting Scenes</a></li>
<li><button on:click={() => jump('playing-scene')}>Playing Out a Scene</button></li> <li><a href="#playing-scene">Playing Out a Scene</a></li>
<li><button on:click={() => jump('ending-scene')}>Ending a Scene</button></li> <li><a href="#ending-scene">Ending a Scene</a></li>
<li><button on:click={() => jump('between-scenes')}>Between Scenes</button></li> <li><a href="#between-scenes">Between Scenes</a></li>
<li><button on:click={() => jump('obstacles')}>Obstacle List &amp; Type Charts</button></li> <li><a href="#obstacles">Obstacle List &amp; Type Charts</a></li>
<li><button on:click={() => jump('challenges')}>Resolving Challenges</button></li> <li><a href="#challenges">Resolving Challenges</a></li>
<li><button on:click={() => jump('face-cards')}>Using Face Cards</button></li> <li><a href="#face-cards">Using Face Cards</a></li>
<li><button on:click={() => jump('advanced')}>Advanced Mechanics &amp; Questions</button></li> <li><a href="#advanced">Advanced Mechanics &amp; Questions</a></li>
</ul> </ul>
</li> </li>
<li><button on:click={() => jump('example')}>Example of Play</button></li> <li><a href="#example">Example of Play</a></li>
<li><button on:click={() => jump('setting-info')}>Part 4: Setting Information</button></li> <li><a href="#setting-info">Part 4: Setting Information</a></li>
<li><button on:click={() => jump('final-thoughts')}>Final Thoughts</button></li> <li><a href="#final-thoughts">Final Thoughts</a></li>
</ul> </ul>
</nav> </nav>
@@ -1122,6 +1297,8 @@
<p class="signature">— Nick</p> <p class="signature">— Nick</p>
</section> </section>
<p class="back-to-top"><button class="btn btn-secondary btn-small" on:click={() => jump('introduction')}>⬆ Back to top</button></p> <p class="back-to-top"><a class="top-link" href="#introduction">⬆ Back to top</a></p>
</article> </article>
</div> </div>
</body>
</html>