The hall comes first, in the shape of wizwar's landing
The home page is a centred card: title and pitch, your name, play the bot or continue the duel under way, create or join a duel by code, a sample ledger, the duels this browser holds with whose move it is, and the story of the game. The single-player board lives at /play. The droplet gains the duel server as a systemd service behind Caddy. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
e1bda87987
commit
6c6edca4be
+24
-16
@@ -1,16 +1,18 @@
|
|||||||
# Deploying Waving Hands
|
# Deploying Waving Hands
|
||||||
|
|
||||||
The game runs entirely in the browser: the duel state lives in local storage
|
The single-player game runs entirely in the browser. Duels between people
|
||||||
and nothing is rendered or stored on a server. Production is therefore a
|
go through a small Node process that keeps each room as an append-only
|
||||||
single DigitalOcean droplet running only Caddy, which terminates TLS with
|
ledger of moves in /var/lib/waving-hands/rooms. Production is one
|
||||||
automatic certificates and serves the static build from
|
DigitalOcean droplet: Caddy terminates TLS with automatic certificates,
|
||||||
/opt/waving-hands/build. No Node, no service, no database.
|
serves the static build from /opt/waving-hands/build, and proxies /api and
|
||||||
|
/ws to the duel server on port 8788, which runs as the `waving-hands` user
|
||||||
|
under systemd from /opt/waving-hands/app.
|
||||||
|
|
||||||
## Sizing
|
## Sizing
|
||||||
|
|
||||||
The smallest droplet is enough: `s-1vcpu-512mb-10gb` ($4 a month). The build
|
The smallest droplet, `s-1vcpu-512mb-10gb` ($4 a month), carries both Caddy
|
||||||
is under half a megabyte and Caddy idles in about 30 MB of memory. Move up to
|
and the duel server comfortably: the server is one small Node process capped
|
||||||
`s-1vcpu-1gb` only if the site ever gains a server side.
|
at 300 MB by its unit file, and a room is a few kilobytes of ledger.
|
||||||
|
|
||||||
The zero-cost alternative is a second site block in an existing Caddy
|
The zero-cost alternative is a second site block in an existing Caddy
|
||||||
server's configuration pointing at a second directory; the deploy script
|
server's configuration pointing at a second directory; the deploy script
|
||||||
@@ -34,7 +36,8 @@ uptime and upgrades independent of anything else.
|
|||||||
2. `scp deploy/setup-droplet.sh root@<ip>:/root/ && ssh root@<ip> \
|
2. `scp deploy/setup-droplet.sh root@<ip>:/root/ && ssh root@<ip> \
|
||||||
"bash /root/setup-droplet.sh 'hands.kestrelsnest.social, waving-hands.<ip>.sslip.io'"`
|
"bash /root/setup-droplet.sh 'hands.kestrelsnest.social, waving-hands.<ip>.sslip.io'"`
|
||||||
(point the A record at the new IP first, or leave the real name out until it is).
|
(point the A record at the new IP first, or leave the real name out until it is).
|
||||||
3. `deploy/deploy.sh <ip>`
|
3. `scp deploy/setup-server.sh root@<ip>:/root/ && ssh root@<ip> "bash /root/setup-server.sh"`
|
||||||
|
4. `deploy/deploy.sh <ip>`
|
||||||
|
|
||||||
The sslip.io hostname works with no DNS at all. To add a real name, point an
|
The sslip.io hostname works with no DNS at all. To add a real name, point an
|
||||||
A record at the droplet and add the name to the first line of
|
A record at the droplet and add the name to the first line of
|
||||||
@@ -45,13 +48,18 @@ fetches the certificate on first request.
|
|||||||
|
|
||||||
deploy/deploy.sh <ip>
|
deploy/deploy.sh <ip>
|
||||||
|
|
||||||
Runs the type-check, the tests and the build locally, rsyncs `build/` to the
|
Runs the type-checks, the tests and the build locally, rsyncs `build/` to
|
||||||
droplet keeping the previous week's hashed assets, and reloads Caddy. A
|
the droplet keeping the previous week's hashed assets, rsyncs the server and
|
||||||
player mid-duel loses nothing: their game is in their own browser, and the
|
engine sources, installs dependencies, and restarts the duel server. A
|
||||||
next page load picks up the new build.
|
single-player game is in the player's own browser and loses nothing. A room
|
||||||
|
is replayed from its ledger when the server comes back, which takes a few
|
||||||
|
seconds; Caddy holds requests that land in the gap.
|
||||||
|
|
||||||
## Operations
|
## Operations
|
||||||
|
|
||||||
- Logs: `ssh root@<ip> journalctl -u caddy -f`, access log in
|
- Logs: `ssh root@<ip> journalctl -u waving-hands -f` for the duel server,
|
||||||
/var/lib/caddy/access.log
|
`journalctl -u caddy -f` and /var/lib/caddy/access.log for the web side.
|
||||||
- Nothing to back up on the server. Player duels are in their browsers.
|
- Restart: `ssh root@<ip> systemctl restart waving-hands`
|
||||||
|
- Rooms: `/var/lib/waving-hands/rooms/<CODE>.jsonl`, one ledger per duel.
|
||||||
|
Copy that directory to back them up; single-player games are in players'
|
||||||
|
browsers.
|
||||||
|
|||||||
+22
-5
@@ -1,10 +1,11 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Build locally and push the static site to the droplet.
|
# Build locally, push the static site and the duel server to the droplet,
|
||||||
# Usage: deploy/deploy.sh <droplet-ip-or-host>
|
# and restart the server. Usage: deploy/deploy.sh <droplet-ip-or-host>
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
HOST="${1:?usage: deploy.sh <droplet-ip-or-host>}"
|
HOST="${1:?usage: deploy.sh <droplet-ip-or-host>}"
|
||||||
|
|
||||||
npm run check
|
npm run check
|
||||||
|
npm run check:server
|
||||||
npm test
|
npm test
|
||||||
npm run build
|
npm run build
|
||||||
|
|
||||||
@@ -12,11 +13,27 @@ npm run build
|
|||||||
# fetch the module it was built against instead of failing mid-duel.
|
# fetch the module it was built against instead of failing mid-duel.
|
||||||
rsync -az --delete --filter='P _app/immutable/*' \
|
rsync -az --delete --filter='P _app/immutable/*' \
|
||||||
build/ "root@$HOST:/opt/waving-hands/build/"
|
build/ "root@$HOST:/opt/waving-hands/build/"
|
||||||
|
|
||||||
|
# The server runs from source with tsx; it needs the engine, its own code,
|
||||||
|
# and the dependency manifest. Ledgers live outside this tree and survive.
|
||||||
|
rsync -az --delete \
|
||||||
|
--include='/server/***' --include='/src/' --include='/src/lib/' --include='/src/lib/game/***' \
|
||||||
|
--include='/package.json' --include='/package-lock.json' --include='/deploy/' --include='/deploy/waving-hands.service' \
|
||||||
|
--exclude='*' \
|
||||||
|
./ "root@$HOST:/opt/waving-hands/app/"
|
||||||
|
|
||||||
ssh "root@$HOST" '
|
ssh "root@$HOST" '
|
||||||
find /opt/waving-hands/build/_app/immutable -type f -mtime +7 -delete
|
find /opt/waving-hands/build/_app/immutable -type f -mtime +7 -delete
|
||||||
chown -R root:caddy /opt/waving-hands
|
chown -R root:caddy /opt/waving-hands/build
|
||||||
chmod -R g+rX /opt/waving-hands
|
chmod -R g+rX /opt/waving-hands/build
|
||||||
|
cd /opt/waving-hands/app && npm install --no-audit --no-fund --omit=optional
|
||||||
|
chown -R waving-hands:waving-hands /opt/waving-hands/app
|
||||||
|
cp /opt/waving-hands/app/deploy/waving-hands.service /etc/systemd/system/waving-hands.service
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl enable --now waving-hands
|
||||||
|
systemctl restart waving-hands
|
||||||
systemctl reload caddy
|
systemctl reload caddy
|
||||||
systemctl --no-pager -l status caddy | head -3
|
sleep 1
|
||||||
|
systemctl --no-pager -l status waving-hands | head -3
|
||||||
'
|
'
|
||||||
echo "deployed."
|
echo "deployed."
|
||||||
|
|||||||
Executable
+37
@@ -0,0 +1,37 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Adds the duel server to a droplet that setup-droplet.sh already prepared.
|
||||||
|
# Run ON the droplet as root; safe to run again. Installs Node 22, creates
|
||||||
|
# the service user and data directory, and teaches Caddy to hand /api and
|
||||||
|
# /ws to the server while it keeps serving the static site itself.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
if ! command -v node >/dev/null || [[ "$(node -v)" != v22* ]]; then
|
||||||
|
curl -fsSL https://deb.nodesource.com/setup_22.x | bash -
|
||||||
|
apt-get install -qy nodejs
|
||||||
|
fi
|
||||||
|
|
||||||
|
id -u waving-hands &>/dev/null || useradd -r -m -d /opt/waving-hands-home waving-hands
|
||||||
|
mkdir -p /opt/waving-hands/app /var/lib/waving-hands/rooms
|
||||||
|
chown -R waving-hands:waving-hands /var/lib/waving-hands
|
||||||
|
|
||||||
|
# Caddy: the two proxied paths go in before the static file handling.
|
||||||
|
if ! grep -q 'reverse_proxy' /etc/caddy/Caddyfile; then
|
||||||
|
python3 - <<'PY'
|
||||||
|
import pathlib
|
||||||
|
p = pathlib.Path('/etc/caddy/Caddyfile'); s = p.read_text()
|
||||||
|
marker = 'try_files {path} {path}.html /index.html'
|
||||||
|
proxy = '''@duel path /api/* /ws
|
||||||
|
reverse_proxy @duel localhost:8788 {
|
||||||
|
lb_try_duration 30s
|
||||||
|
lb_try_interval 250ms
|
||||||
|
}
|
||||||
|
|
||||||
|
'''
|
||||||
|
assert marker in s
|
||||||
|
p.write_text(s.replace(marker, proxy + marker))
|
||||||
|
PY
|
||||||
|
caddy validate --config /etc/caddy/Caddyfile
|
||||||
|
systemctl reload caddy
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "server prerequisites ready: now run deploy/deploy.sh <ip> from your machine"
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Waving Hands duel server
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=waving-hands
|
||||||
|
WorkingDirectory=/opt/waving-hands/app
|
||||||
|
Environment=PORT=8788
|
||||||
|
# Caddy terminates TLS; the plaintext port must not face the internet.
|
||||||
|
Environment=HOST=127.0.0.1
|
||||||
|
Environment=WH_DATA_DIR=/var/lib/waving-hands/rooms
|
||||||
|
ExecStart=/opt/waving-hands/app/node_modules/.bin/tsx server/src/index.ts
|
||||||
|
Restart=always
|
||||||
|
RestartSec=3
|
||||||
|
|
||||||
|
# Sandbox: the process reads /opt/waving-hands and writes only its data dir.
|
||||||
|
NoNewPrivileges=yes
|
||||||
|
PrivateTmp=yes
|
||||||
|
ProtectSystem=strict
|
||||||
|
ProtectHome=yes
|
||||||
|
ReadWritePaths=/var/lib/waving-hands
|
||||||
|
ProtectKernelTunables=yes
|
||||||
|
ProtectKernelModules=yes
|
||||||
|
ProtectControlGroups=yes
|
||||||
|
RestrictSUIDSGID=yes
|
||||||
|
# A runaway process gets killed and restarted before it can take the box down.
|
||||||
|
MemoryMax=300M
|
||||||
|
LimitNOFILE=4096
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
+2
-1
@@ -46,7 +46,8 @@ p {
|
|||||||
}
|
}
|
||||||
|
|
||||||
button,
|
button,
|
||||||
select {
|
select,
|
||||||
|
input {
|
||||||
font: inherit;
|
font: inherit;
|
||||||
color: inherit;
|
color: inherit;
|
||||||
}
|
}
|
||||||
|
|||||||
+228
-38
@@ -1,16 +1,20 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { allSeats, api, forgetSeat } from '$lib/game/client';
|
import { allSeats, api, forgetSeat } from '$lib/game/client';
|
||||||
import { Duel } from '$lib/game/duel.svelte';
|
import { Duel, duel, NAME_MAX, playerName, rememberName } from '$lib/game/duel.svelte';
|
||||||
import type { RoomView } from '$lib/game/view';
|
import type { RoomView } from '$lib/game/view';
|
||||||
|
|
||||||
let { name }: { name: string } = $props();
|
let name = $state(playerName() || duel.you.name);
|
||||||
|
|
||||||
let code = $state('');
|
let code = $state('');
|
||||||
let busy = $state(false);
|
let busy = $state(false);
|
||||||
let error = $state('');
|
let error = $state('');
|
||||||
let seats = $state(allSeats());
|
let seats = $state(allSeats());
|
||||||
let games = $state<Record<string, RoomView | null>>({});
|
let games = $state<Record<string, RoomView | null>>({});
|
||||||
|
let aboutOpen = $state(false);
|
||||||
|
|
||||||
|
/** The single-player duel saved in this browser, if one is under way. */
|
||||||
|
const local = $derived(duel.inProgress ? duel : null);
|
||||||
|
const ready = $derived(name.trim().length > 0);
|
||||||
|
|
||||||
// Each held seat is looked up once, so the ledger can say whose move it is.
|
// Each held seat is looked up once, so the ledger can say whose move it is.
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
@@ -27,11 +31,17 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
async function playBot() {
|
||||||
|
rememberName(name);
|
||||||
|
await goto('/play');
|
||||||
|
}
|
||||||
|
|
||||||
async function create() {
|
async function create() {
|
||||||
busy = true;
|
busy = true;
|
||||||
error = '';
|
error = '';
|
||||||
try {
|
try {
|
||||||
const remote = await Duel.createRoom(name, 2);
|
rememberName(name);
|
||||||
|
const remote = await Duel.createRoom(name.trim(), 2);
|
||||||
await goto(`/join/${remote.roomId}`);
|
await goto(`/join/${remote.roomId}`);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error = e instanceof Error ? e.message : 'The duel could not be opened.';
|
error = e instanceof Error ? e.message : 'The duel could not be opened.';
|
||||||
@@ -42,6 +52,7 @@
|
|||||||
|
|
||||||
function join(e: SubmitEvent) {
|
function join(e: SubmitEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
rememberName(name);
|
||||||
const c = code.trim().toUpperCase();
|
const c = code.trim().toUpperCase();
|
||||||
if (c) void goto(`/join/${c}`);
|
if (c) void goto(`/join/${c}`);
|
||||||
}
|
}
|
||||||
@@ -71,24 +82,78 @@
|
|||||||
const g = games[held.roomId];
|
const g = games[held.roomId];
|
||||||
return !!g?.state && !g.state.over && g.waitingOn.includes(held.seat);
|
return !!g?.state && !g.state.over && g.waitingOn.includes(held.seat);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* A few turns of a duel, as the ledger writes them. */
|
||||||
|
const sample = [
|
||||||
|
{ turn: 1, a: ['W', 'S'], b: ['P', 'W'], notes: { b0: 'Shield' } },
|
||||||
|
{ turn: 2, a: ['W', 'D'], b: ['P', 'D'], notes: { a1: 'Missile', b0: 'Shield' } },
|
||||||
|
{ turn: 3, a: ['S', 'F'], b: ['F', 'D'], notes: { a0: 'Counter-spell' } },
|
||||||
|
{ turn: 4, a: ['F', 'S'], b: ['F', 'D'], notes: { b0: 'Paralysis', b1: 'Lightning bolt' } }
|
||||||
|
];
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<section class="hall" id="hall">
|
<section class="lid">
|
||||||
<h2>Duel a friend</h2>
|
<div class="lid-head">
|
||||||
<p class="pitch">A duel between people is played by turns, whenever each of you has a moment. Open one and send the link, or join with a code.</p>
|
<h1>Waving Hands</h1>
|
||||||
|
<p class="pitch">Two wizards. Two hands each. The right run of gestures is a spell.</p>
|
||||||
|
<p class="tag">Richard Bartle's 1977 pencil-and-paper duel, free in your browser. Each turn both wizards choose a gesture per hand and reveal them together; a Fireball is F-S-S-D-D on one hand, a Shield is a single palm. Play the bot, or a friend by turns.</p>
|
||||||
|
<a class="about-link" href="#about" onclick={() => (aboutOpen = true)}>about this game — a labor of love</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="lid-play">
|
||||||
|
<label class="name">
|
||||||
|
<span>Your wizard's name</span>
|
||||||
|
<input type="text" bind:value={name} maxlength={NAME_MAX} placeholder="e.g. Morwenna" autocomplete="nickname" />
|
||||||
|
</label>
|
||||||
|
<button type="button" class="reveal primary" disabled={!ready} onclick={playBot}>
|
||||||
|
{local ? `Continue against ${local.foe.name} (turn ${local.turnNumber})` : 'Play now against the bot'}
|
||||||
|
</button>
|
||||||
|
{#if local}
|
||||||
|
<p class="muted small">Or start afresh from the board's "New duel".</p>
|
||||||
|
{/if}
|
||||||
<div class="ways">
|
<div class="ways">
|
||||||
<button type="button" class="reveal small" disabled={busy} onclick={create}>Create a duel as {name}</button>
|
<button type="button" class="quiet" disabled={!ready || busy} onclick={create}>Create a duel</button>
|
||||||
<form class="joinform" onsubmit={join}>
|
<form class="joinform" onsubmit={join}>
|
||||||
<span class="or">or join one</span>
|
<span class="or">or join one</span>
|
||||||
<input type="text" class="code" bind:value={code} maxlength="4" placeholder="CODE" autocapitalize="characters" autocomplete="off" aria-label="room code" />
|
<input type="text" class="code" bind:value={code} maxlength="4" placeholder="CODE" autocapitalize="characters" autocomplete="off" aria-label="room code" />
|
||||||
<button type="submit" class="quiet" disabled={code.trim().length < 4}>Join</button>
|
<button type="submit" class="quiet" disabled={!ready || code.trim().length < 4}>Join</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
{#if error}<p class="warning">{error}</p>{/if}
|
{#if error}<p class="warning">{error}</p>{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
{#if seats.length}
|
<div class="lid-look" aria-hidden="true">
|
||||||
|
<table class="sample">
|
||||||
|
<thead>
|
||||||
|
<tr><th></th><th colspan="2">Corwin</th><th colspan="2">Ysolde</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each sample as row (row.turn)}
|
||||||
|
<tr>
|
||||||
|
<td class="t">{row.turn}</td>
|
||||||
|
{#each ['a0', 'a1', 'b0', 'b1'] as cell, i (cell)}
|
||||||
|
{@const g = i < 2 ? row.a[i] : row.b[i - 2]}
|
||||||
|
{@const note = (row.notes as Record<string, string | undefined>)[cell]}
|
||||||
|
<td class:cast={!!note}><span class="letter">{g}</span>{#if note}<span class="note">{note}</span>{/if}</td>
|
||||||
|
{/each}
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<p class="caption">The ledger: every gesture both wizards have made, and the spells they finished.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if seats.length || local}
|
||||||
<div class="ledger">
|
<div class="ledger">
|
||||||
<div class="ledger-head">your duels</div>
|
<div class="ledger-head">your duels</div>
|
||||||
|
{#if local}
|
||||||
|
<div class="ledger-row your-move">
|
||||||
|
<a class="ledger-resume" href="/play">
|
||||||
|
<span class="ledger-code">bot</span>
|
||||||
|
<span class="ledger-info">your move · turn {local.turnNumber} · against {local.foe.name}</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
{#each seats as held (held.roomId)}
|
{#each seats as held (held.roomId)}
|
||||||
<div class="ledger-row" class:your-move={yourMove(held)}>
|
<div class="ledger-row" class:your-move={yourMove(held)}>
|
||||||
<a class="ledger-resume" href={`/join/${held.roomId}`}>
|
<a class="ledger-resume" href={`/join/${held.roomId}`}>
|
||||||
@@ -101,45 +166,108 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<details class="about">
|
<details class="about" id="about" bind:open={aboutOpen}>
|
||||||
<summary>about this game — a labor of love</summary>
|
<summary>about this game</summary>
|
||||||
<div class="about-body">
|
<div class="about-body">
|
||||||
<p>Waving Hands is Richard Bartle's game from 1977, played with pencil, paper and two hands. Each wizard writes down a gesture for each hand, both reveal at once, and the right run of gestures on one hand is a spell. It was later known as Spellbinder and Spellcaster, and Andrew Plotkin's <em>Spellcast</em> brought it to university X terminals in 1993, which is where the maker of this page first met it.</p>
|
<p>Waving Hands is Richard Bartle's game from 1977, played with pencil, paper and two hands. It was later known as Spellbinder and Spellcaster, and Andrew Plotkin's <em>Spellcast</em> brought it to university X terminals in 1993, which is where the maker of this page first met it.</p>
|
||||||
<p>This version keeps the game as written: all forty spells, the same simultaneous resolution, the same ledger of letters. The rules text it follows is the fanzine transcription, kept in full on the <a href="/rules">rules page</a>. The bot is a heuristic that reads your gestures as you would read its; the hand silhouettes on the buttons are adapted from Plotkin's bitmaps, with his notice retained.</p>
|
<p>This version keeps the game as written: all forty spells, the same simultaneous resolution, the same ledger of letters. The rules text it follows is the fanzine transcription, kept in full on the <a href="/rules">rules page</a>. The bot reads your gestures as you would read its; the hand silhouettes on the buttons are adapted from Plotkin's bitmaps, with his notice retained.</p>
|
||||||
<p>It is free, keeps no accounts, and stores your single-player duel in your own browser. Duels between people live on a small server as an append-only ledger of moves, so a game can be replayed from its first gesture.</p>
|
<p>It is free and keeps no accounts. A duel against the bot lives in your own browser. A duel between people lives on a small server as an append-only ledger of moves, so it can be replayed from its first gesture, and each wizard is shown only what the rules let them see.</p>
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.hall {
|
.lid {
|
||||||
max-width: 1280px;
|
width: 100%;
|
||||||
margin: 0 auto;
|
max-width: 54rem;
|
||||||
padding: 1.5rem var(--gutter) 0.5rem;
|
background: var(--slate);
|
||||||
border-top: 1px solid var(--rule);
|
border: 1px solid var(--rule-strong);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: clamp(1.4rem, 3vw, 2.2rem) clamp(1.2rem, 3vw, 2.6rem) 1.8rem;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
grid-template-areas: 'head' 'play' 'look' 'ledger' 'about';
|
||||||
|
gap: 1.2rem 2.2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
h2 {
|
@media (min-width: 901px) {
|
||||||
font-size: 1.4rem;
|
.lid {
|
||||||
margin-bottom: 0.3rem;
|
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||||
|
grid-template-areas: 'head head' 'play look' 'ledger ledger' 'about about';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.lid-head {
|
||||||
|
grid-area: head;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: clamp(2.6rem, 6vw, 3.6rem);
|
||||||
|
font-weight: 300;
|
||||||
|
font-variation-settings: 'opsz' 144;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pitch {
|
.pitch {
|
||||||
max-width: 38em;
|
font-style: italic;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
margin-top: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag {
|
||||||
color: var(--bone-dim);
|
color: var(--bone-dim);
|
||||||
margin-bottom: 0.9rem;
|
max-width: 40em;
|
||||||
|
margin: 0.6rem auto 0.4rem;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-link {
|
||||||
|
display: inline-block;
|
||||||
|
color: var(--frost);
|
||||||
|
font-style: italic;
|
||||||
|
text-decoration: underline dotted;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lid-play {
|
||||||
|
grid-area: play;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.name {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.name span {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
color: var(--bone-dim);
|
||||||
|
margin-bottom: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.name input {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reveal.primary {
|
||||||
|
width: 100%;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.small {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
margin-top: -0.4rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ways {
|
.ways {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.6rem 1.25rem;
|
gap: 0.6rem 1rem;
|
||||||
}
|
|
||||||
|
|
||||||
.reveal.small {
|
|
||||||
padding: 0.45rem 1rem;
|
|
||||||
font-size: 1rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.joinform {
|
.joinform {
|
||||||
@@ -151,6 +279,7 @@
|
|||||||
.or {
|
.or {
|
||||||
color: var(--bone-dim);
|
color: var(--bone-dim);
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
|
font-size: 0.95rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.code {
|
.code {
|
||||||
@@ -165,7 +294,7 @@
|
|||||||
background: none;
|
background: none;
|
||||||
border: 1px solid var(--rule-strong);
|
border: 1px solid var(--rule-strong);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
padding: 0.4rem 0.9rem;
|
padding: 0.45rem 0.9rem;
|
||||||
color: var(--bone);
|
color: var(--bone);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -175,15 +304,73 @@
|
|||||||
|
|
||||||
.warning {
|
.warning {
|
||||||
color: var(--blood);
|
color: var(--blood);
|
||||||
margin-top: 0.6rem;
|
}
|
||||||
|
|
||||||
|
.lid-look {
|
||||||
|
grid-area: look;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sample {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
background: var(--slate-deep);
|
||||||
|
border: 1px solid var(--rule-strong);
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sample th {
|
||||||
|
font-weight: 500;
|
||||||
|
font-style: italic;
|
||||||
|
padding: 0.4rem 0 0.2rem;
|
||||||
|
border-bottom: 1px solid var(--rule-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sample td {
|
||||||
|
text-align: center;
|
||||||
|
padding: 0.3rem 0.2rem;
|
||||||
|
border-bottom: 1px solid var(--rule);
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sample td.t {
|
||||||
|
width: 2rem;
|
||||||
|
color: var(--bone-faint);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sample td:nth-child(3) {
|
||||||
|
border-right: 1px solid var(--rule-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sample .letter {
|
||||||
|
display: block;
|
||||||
|
font-size: 1.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sample td.cast .letter {
|
||||||
|
color: var(--ember);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sample .note {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.65rem;
|
||||||
|
font-style: italic;
|
||||||
|
color: var(--ember);
|
||||||
|
}
|
||||||
|
|
||||||
|
.caption {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--bone-dim);
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* the games ledger */
|
/* the games ledger */
|
||||||
.ledger {
|
.ledger {
|
||||||
margin-top: 1.4rem;
|
grid-area: ledger;
|
||||||
border-top: 1px solid var(--rule-strong);
|
border-top: 1px solid var(--rule-strong);
|
||||||
padding-top: 0.5rem;
|
padding-top: 0.5rem;
|
||||||
max-width: 40em;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.ledger-head {
|
.ledger-head {
|
||||||
@@ -222,6 +409,7 @@
|
|||||||
.ledger-code {
|
.ledger-code {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
letter-spacing: 0.12em;
|
letter-spacing: 0.12em;
|
||||||
|
min-width: 3.5em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ledger-info {
|
.ledger-info {
|
||||||
@@ -245,7 +433,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.about {
|
.about {
|
||||||
margin-top: 1.4rem;
|
grid-area: about;
|
||||||
|
border-top: 1px solid var(--rule);
|
||||||
|
padding-top: 0.6rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.about summary {
|
.about summary {
|
||||||
@@ -255,9 +445,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.about-body {
|
.about-body {
|
||||||
max-width: 40em;
|
max-width: 44em;
|
||||||
color: var(--bone-dim);
|
color: var(--bone-dim);
|
||||||
padding: 0.6rem 0 0.4rem;
|
padding: 0.6rem 0 0.2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.about-body p + p {
|
.about-body p + p {
|
||||||
|
|||||||
@@ -827,3 +827,8 @@ export const duel = new Duel();
|
|||||||
export function playerName(): string {
|
export function playerName(): string {
|
||||||
return readName();
|
return readName();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Keep a chosen name for every duel to come; the local duel takes it at once. */
|
||||||
|
export function rememberName(raw: string): void {
|
||||||
|
duel.setName(raw);
|
||||||
|
}
|
||||||
|
|||||||
+11
-178
@@ -1,204 +1,37 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { goto } from '$app/navigation';
|
|
||||||
import Board from '$lib/components/Board.svelte';
|
|
||||||
import Hall from '$lib/components/Hall.svelte';
|
import Hall from '$lib/components/Hall.svelte';
|
||||||
import { COMPACT_QUERY, duel, NAME_MAX } from '$lib/game/duel.svelte';
|
|
||||||
|
|
||||||
let confirmingNew = $state(false);
|
|
||||||
let renaming = $state(false);
|
|
||||||
let nameDraft = $state('');
|
|
||||||
|
|
||||||
function startRename() {
|
|
||||||
nameDraft = duel.you.name;
|
|
||||||
renaming = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function saveName(e: SubmitEvent) {
|
|
||||||
e.preventDefault();
|
|
||||||
duel.setName(nameDraft);
|
|
||||||
renaming = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
function startNew() {
|
|
||||||
if (duel.inProgress && !confirmingNew) {
|
|
||||||
confirmingNew = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
confirmingNew = false;
|
|
||||||
duel.newGame();
|
|
||||||
}
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
duel.persist();
|
|
||||||
});
|
|
||||||
// The store's idea of a narrow screen must agree with the stylesheets' breakpoint.
|
|
||||||
$effect(() => {
|
|
||||||
const query = window.matchMedia(COMPACT_QUERY);
|
|
||||||
const apply = () => (duel.compact = query.matches);
|
|
||||||
apply();
|
|
||||||
query.addEventListener('change', apply);
|
|
||||||
return () => query.removeEventListener('change', apply);
|
|
||||||
});
|
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>Waving Hands</title>
|
<title>Waving Hands</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<header class="masthead">
|
<main class="hall-page">
|
||||||
<div>
|
<Hall />
|
||||||
<h1>Waving Hands</h1>
|
|
||||||
{#if renaming}
|
|
||||||
<form class="rename" onsubmit={saveName}>
|
|
||||||
<label class="field">
|
|
||||||
<span>Your name</span>
|
|
||||||
<!-- svelte-ignore a11y_autofocus -->
|
|
||||||
<input type="text" bind:value={nameDraft} maxlength={NAME_MAX} autofocus autocomplete="nickname" placeholder="Leave empty for a drawn name" />
|
|
||||||
</label>
|
|
||||||
<button type="submit" class="reveal small">Save</button>
|
|
||||||
<button type="button" class="quiet" onclick={() => (renaming = false)}>Cancel</button>
|
|
||||||
</form>
|
|
||||||
{:else}
|
|
||||||
<p class="standfirst">
|
|
||||||
You are <em>{duel.you.name}</em><button type="button" class="link" onclick={startRename}>change name</button>, duelling <em>{duel.foe.name}</em>.
|
|
||||||
{#if duel.state.turn === 0}Each turn, both wizards choose one gesture per hand and reveal them together. The right run of gestures on one hand is a spell.{/if}
|
|
||||||
</p>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
<nav class="actions">
|
|
||||||
<a class="quiet" href="/rules">Rules</a>
|
|
||||||
<button type="button" class="quiet" onclick={startNew}>New duel</button>
|
|
||||||
<a class="quiet" href="#hall">Duel a friend</a>
|
|
||||||
</nav>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{#if confirmingNew}
|
|
||||||
<div class="confirm" role="alertdialog" aria-labelledby="confirm-heading">
|
|
||||||
<p id="confirm-heading">Abandon the duel against {duel.foe.name}? Turn {duel.turnNumber} will be lost.</p>
|
|
||||||
<div class="choices">
|
|
||||||
<button type="button" class="reveal small" onclick={startNew}>Start a new duel</button>
|
|
||||||
<button type="button" class="quiet" onclick={() => (confirmingNew = false)}>Keep playing</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<Board {duel} />
|
|
||||||
|
|
||||||
<Hall name={duel.you.name} />
|
|
||||||
|
|
||||||
<footer class="colophon">
|
<footer class="colophon">
|
||||||
<p>After Richard Bartle's <em>Waving Hands</em> (1977), also known as Spellbinder and Spellcaster. <a href="/rules">Read the full rules</a>.</p>
|
<p>After Richard Bartle's <em>Waving Hands</em> (1977), also known as Spellbinder and Spellcaster. <a href="/rules">Read the full rules</a>.</p>
|
||||||
</footer>
|
</footer>
|
||||||
|
</main>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.masthead {
|
.hall-page {
|
||||||
|
min-height: 100vh;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
flex-direction: column;
|
||||||
justify-content: space-between;
|
|
||||||
gap: 1rem;
|
|
||||||
padding: 1rem var(--gutter) 0.6rem;
|
|
||||||
max-width: 1280px;
|
|
||||||
margin: 0 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1 {
|
|
||||||
font-size: clamp(2rem, 4vw, 2.7rem);
|
|
||||||
font-weight: 300;
|
|
||||||
font-variation-settings: 'opsz' 144;
|
|
||||||
letter-spacing: -0.01em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.standfirst {
|
|
||||||
max-width: 38em;
|
|
||||||
color: var(--bone-dim);
|
|
||||||
margin-top: 0.4rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.standfirst em {
|
|
||||||
color: var(--bone);
|
|
||||||
}
|
|
||||||
|
|
||||||
.standfirst .link {
|
|
||||||
margin-left: 0.5em;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rename {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem 0.75rem;
|
padding: clamp(1rem, 4vh, 3rem) var(--gutter) 2rem;
|
||||||
margin-top: 0.4rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rename input {
|
|
||||||
width: 14em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.5rem;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.quiet {
|
|
||||||
background: none;
|
|
||||||
border: 1px solid var(--rule-strong);
|
|
||||||
border-radius: 4px;
|
|
||||||
padding: 0.4rem 0.9rem;
|
|
||||||
white-space: nowrap;
|
|
||||||
color: var(--bone);
|
|
||||||
text-decoration: none;
|
|
||||||
line-height: 1.55;
|
|
||||||
}
|
|
||||||
|
|
||||||
.quiet:hover {
|
|
||||||
border-color: var(--bone);
|
|
||||||
}
|
|
||||||
|
|
||||||
.confirm {
|
|
||||||
max-width: 1280px;
|
|
||||||
margin: 0 auto 0.75rem;
|
|
||||||
padding: 0.75rem var(--gutter);
|
|
||||||
}
|
|
||||||
|
|
||||||
.confirm p {
|
|
||||||
color: var(--blood);
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.choices {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.6rem;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.reveal.small {
|
|
||||||
padding: 0.4rem 0.9rem;
|
|
||||||
font-size: 1rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.colophon {
|
.colophon {
|
||||||
max-width: 1280px;
|
max-width: 54rem;
|
||||||
margin: 0 auto;
|
width: 100%;
|
||||||
padding: 2rem var(--gutter) 3rem;
|
padding: 1.5rem 0 0;
|
||||||
color: var(--bone-faint);
|
color: var(--bone-faint);
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.colophon a {
|
.colophon a {
|
||||||
color: var(--frost);
|
color: var(--frost);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Must match COMPACT_QUERY in duel.svelte.ts. */
|
|
||||||
@media (max-width: 860px) {
|
|
||||||
.colophon {
|
|
||||||
padding-bottom: 6rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.masthead {
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,201 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import Board from '$lib/components/Board.svelte';
|
||||||
|
import { COMPACT_QUERY, duel, NAME_MAX } from '$lib/game/duel.svelte';
|
||||||
|
|
||||||
|
let confirmingNew = $state(false);
|
||||||
|
let renaming = $state(false);
|
||||||
|
let nameDraft = $state('');
|
||||||
|
|
||||||
|
function startRename() {
|
||||||
|
nameDraft = duel.you.name;
|
||||||
|
renaming = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveName(e: SubmitEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
duel.setName(nameDraft);
|
||||||
|
renaming = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function startNew() {
|
||||||
|
if (duel.inProgress && !confirmingNew) {
|
||||||
|
confirmingNew = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
confirmingNew = false;
|
||||||
|
duel.newGame();
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
duel.persist();
|
||||||
|
});
|
||||||
|
// The store's idea of a narrow screen must agree with the stylesheets' breakpoint.
|
||||||
|
$effect(() => {
|
||||||
|
const query = window.matchMedia(COMPACT_QUERY);
|
||||||
|
const apply = () => (duel.compact = query.matches);
|
||||||
|
apply();
|
||||||
|
query.addEventListener('change', apply);
|
||||||
|
return () => query.removeEventListener('change', apply);
|
||||||
|
});
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Waving Hands: against the bot</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<header class="masthead">
|
||||||
|
<div>
|
||||||
|
<h1>Waving Hands</h1>
|
||||||
|
{#if renaming}
|
||||||
|
<form class="rename" onsubmit={saveName}>
|
||||||
|
<label class="field">
|
||||||
|
<span>Your name</span>
|
||||||
|
<!-- svelte-ignore a11y_autofocus -->
|
||||||
|
<input type="text" bind:value={nameDraft} maxlength={NAME_MAX} autofocus autocomplete="nickname" placeholder="Leave empty for a drawn name" />
|
||||||
|
</label>
|
||||||
|
<button type="submit" class="reveal small">Save</button>
|
||||||
|
<button type="button" class="quiet" onclick={() => (renaming = false)}>Cancel</button>
|
||||||
|
</form>
|
||||||
|
{:else}
|
||||||
|
<p class="standfirst">
|
||||||
|
You are <em>{duel.you.name}</em><button type="button" class="link" onclick={startRename}>change name</button>, duelling <em>{duel.foe.name}</em>.
|
||||||
|
{#if duel.state.turn === 0}Each turn, both wizards choose one gesture per hand and reveal them together. The right run of gestures on one hand is a spell.{/if}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<nav class="actions">
|
||||||
|
<a class="quiet" href="/rules">Rules</a>
|
||||||
|
<button type="button" class="quiet" onclick={startNew}>New duel</button>
|
||||||
|
<a class="quiet" href="/">The hall</a>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{#if confirmingNew}
|
||||||
|
<div class="confirm" role="alertdialog" aria-labelledby="confirm-heading">
|
||||||
|
<p id="confirm-heading">Abandon the duel against {duel.foe.name}? Turn {duel.turnNumber} will be lost.</p>
|
||||||
|
<div class="choices">
|
||||||
|
<button type="button" class="reveal small" onclick={startNew}>Start a new duel</button>
|
||||||
|
<button type="button" class="quiet" onclick={() => (confirmingNew = false)}>Keep playing</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<Board {duel} />
|
||||||
|
|
||||||
|
<footer class="colophon">
|
||||||
|
<p>After Richard Bartle's <em>Waving Hands</em> (1977), also known as Spellbinder and Spellcaster. <a href="/rules">Read the full rules</a>.</p>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.masthead {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 1rem var(--gutter) 0.6rem;
|
||||||
|
max-width: 1280px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: clamp(2rem, 4vw, 2.7rem);
|
||||||
|
font-weight: 300;
|
||||||
|
font-variation-settings: 'opsz' 144;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.standfirst {
|
||||||
|
max-width: 38em;
|
||||||
|
color: var(--bone-dim);
|
||||||
|
margin-top: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.standfirst em {
|
||||||
|
color: var(--bone);
|
||||||
|
}
|
||||||
|
|
||||||
|
.standfirst .link {
|
||||||
|
margin-left: 0.5em;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rename {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem 0.75rem;
|
||||||
|
margin-top: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rename input {
|
||||||
|
width: 14em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quiet {
|
||||||
|
background: none;
|
||||||
|
border: 1px solid var(--rule-strong);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 0.4rem 0.9rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
color: var(--bone);
|
||||||
|
text-decoration: none;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quiet:hover {
|
||||||
|
border-color: var(--bone);
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm {
|
||||||
|
max-width: 1280px;
|
||||||
|
margin: 0 auto 0.75rem;
|
||||||
|
padding: 0.75rem var(--gutter);
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm p {
|
||||||
|
color: var(--blood);
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.choices {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.6rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reveal.small {
|
||||||
|
padding: 0.4rem 0.9rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.colophon {
|
||||||
|
max-width: 1280px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2rem var(--gutter) 3rem;
|
||||||
|
color: var(--bone-faint);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.colophon a {
|
||||||
|
color: var(--frost);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Must match COMPACT_QUERY in duel.svelte.ts. */
|
||||||
|
@media (max-width: 860px) {
|
||||||
|
.colophon {
|
||||||
|
padding-bottom: 6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.masthead {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user