Credibility pass: one conflict check, one hostile list, one set of chrome

Duplicated logic and styling that had drifted apart is unified: the
gesture-sharing check, the hostile spell list, the enchanted test, the
plan limits, and the shared button, link, field and disabled styles.
Dead plumbing is gone: the always-true implemented flag, the unread
cast length, an unreachable guard, an impossible time-stop condition,
the unused sequence formatter and utility class, the template
placeholder. The counter-spell tests now put a counter-spell in front
of a spell, the test helpers live in one file, and the resolver's
sections are numbered in order. The deploy script's cache headers now
do what its comment says, and the README describes the screen rather
than listing features in the order they arrived.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Eric WagonerandClaude Fable 5.1 committed 2026-09-22 16:37:54 -04:00
1 parent 527820ee95
commit 5de352db43
22 files changed
+415 -485

No files matched your search

+36 -92
View File
@@ -6,7 +6,7 @@
import SpellSheet from '$lib/components/SpellSheet.svelte';
import TurnSummary from '$lib/components/TurnSummary.svelte';
import WizardStatus from '$lib/components/WizardStatus.svelte';
import { duel, FOE, YOU, NAME_MAX, castKey, tokensText } from '$lib/game/duel.svelte';
import { COMPACT_QUERY, duel, FOE, YOU, NAME_MAX, castKey, tokensText } from '$lib/game/duel.svelte';
import { GESTURE_SHORT, glyph } from '$lib/game/glyphs';
import { MAGIC_GESTURES } from '$lib/game/spells';
@@ -34,12 +34,12 @@
duel.newGame();
}
// Save the duel whenever anything about it changes, and follow the window width.
$effect(() => {
duel.persist();
});
// The store's idea of a narrow screen must agree with the stylesheets' breakpoint.
$effect(() => {
const query = window.matchMedia('(max-width: 860px)');
const query = window.matchMedia(COMPACT_QUERY);
const apply = () => (duel.compact = query.matches);
apply();
query.addEventListener('change', apply);
@@ -60,7 +60,7 @@
<h1>Waving Hands</h1>
{#if renaming}
<form class="rename" onsubmit={saveName}>
<label>
<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" />
@@ -71,7 +71,7 @@
{: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 && !duel.timeStopped}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}
{#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>
@@ -111,9 +111,7 @@
<h2 id="move-heading">Turn {duel.turnNumber}: write your gestures{#if duel.hasted}, twice{/if}</h2>
{/if}
{#if duel.timeStopped}
<!-- nobody else moves -->
{:else if duel.threats.length}
{#if duel.threats.length && !duel.timeStopped}
<div class="threats">
{#if duel.threatsNow.length}
<p>Their hands could finish this turn:</p>
@@ -132,23 +130,23 @@
{/if}
{#if duel.threatsNext.length}
<details class="next" open={!duel.compact}>
<summary class="muted">Two gestures away ({duel.threatsNext.length})</summary>
<ul>
{#each duel.threatsNext as t (t.hand + t.spell.id)}
{@const active = duel.highlight?.hand === t.hand && duel.highlight.from === t.from && duel.highlight.to === t.to}
<li>
<button type="button" class="threat" class:active aria-pressed={active} onclick={() => duel.toggleHighlight(t)}>
<span class="thand">{t.hand}</span>
<span class="tseq">{tokensText(t.done)} → {tokensText(t.remaining)}</span>
<span class="tname">{t.spell.name}</span>
</button>
</li>
{/each}
</ul>
<summary class="muted">Two gestures away ({duel.threatsNext.length})</summary>
<ul>
{#each duel.threatsNext as t (t.hand + t.spell.id)}
{@const active = duel.highlight?.hand === t.hand && duel.highlight.from === t.from && duel.highlight.to === t.to}
<li>
<button type="button" class="threat" class:active aria-pressed={active} onclick={() => duel.toggleHighlight(t)}>
<span class="thand">{t.hand}</span>
<span class="tseq">{tokensText(t.done)} → {tokensText(t.remaining)}</span>
<span class="tname">{t.spell.name}</span>
</button>
</li>
{/each}
</ul>
</details>
{/if}
</div>
{:else}
{:else if !duel.timeStopped}
<p class="threats muted">Nothing hostile is one gesture away.</p>
{/if}
@@ -176,7 +174,7 @@
<div class="orders">
{#if duel.stabbing}
<label>
<label class="field">
<span>Stab</span>
<select bind:value={duel.stabTarget}>
<option value={FOE}>{duel.foe.name}</option>
@@ -187,7 +185,7 @@
</label>
{/if}
{#each duel.yourMonsters as m (m.id)}
<label>
<label class="field">
<span>{m.name} attacks</span>
<select bind:value={duel.monsterOrders[m.id]}>
<option value={FOE}>{duel.foe.name}</option>
@@ -198,7 +196,7 @@
</label>
{/each}
{#if duel.bankedSpell}
<label>
<label class="field">
<span>Release the banked {duel.bankedSpell.name.toLowerCase()}</span>
<select bind:value={duel.release}>
<option value="">not yet</option>
@@ -209,7 +207,7 @@
</label>
{/if}
{#if duel.bankCandidates.length > 1}
<label>
<label class="field">
<span>Bank</span>
<select bind:value={duel.bankPick}>
{#each duel.bankCandidates as p (castKey(p.set, p.hand))}
@@ -219,7 +217,7 @@
</label>
{/if}
{#if duel.permanentCandidates.length > 1}
<label>
<label class="field">
<span>Make permanent</span>
<select bind:value={duel.permanentPick}>
{#each duel.permanentCandidates as p (castKey(p.set, p.hand))}
@@ -229,7 +227,7 @@
</label>
{/if}
{#if duel.youCharmedFoe}
<label>
<label class="field">
<span>{duel.foe.name}'s charmed {duel.foe.constraints.charmed?.hand} hand makes</span>
<select bind:value={duel.charmGesture}>
{#each MAGIC_GESTURES as g (g)}
@@ -296,15 +294,9 @@
color: var(--bone);
}
.link {
background: none;
border: 0;
padding: 0;
.standfirst .link {
margin-left: 0.5em;
font-size: 0.8rem;
color: var(--frost);
text-decoration: underline;
vertical-align: baseline;
}
.rename {
@@ -315,29 +307,8 @@
margin-top: 0.4rem;
}
.rename label {
display: inline-flex;
align-items: baseline;
gap: 0.5rem;
color: var(--bone-dim);
font-style: italic;
}
.rename input {
font: inherit;
font-style: normal;
color: var(--bone);
background: var(--slate-deep);
border: 1px solid var(--rule-strong);
border-radius: 4px;
padding: 0.3rem 0.6rem;
width: 14em;
max-width: 100%;
}
.rename input:focus-visible {
outline: 2px solid var(--frost);
outline-offset: 1px;
}
.actions {
@@ -378,12 +349,6 @@
flex-wrap: wrap;
}
.reveal.small {
margin: 0;
padding: 0.4rem 0.9rem;
font-size: 1rem;
}
details.next summary {
cursor: pointer;
list-style: none;
@@ -491,10 +456,6 @@
color: var(--bone-dim);
}
.colophon a {
color: var(--frost);
}
.hands {
display: grid;
grid-template-columns: 1fr 1fr;
@@ -508,17 +469,6 @@
margin-top: 0.9rem;
}
.orders label {
display: inline-flex;
gap: 0.4rem;
align-items: baseline;
}
.orders label span {
color: var(--bone-dim);
font-style: italic;
}
.warning {
color: var(--blood);
margin-top: 0.8rem;
@@ -530,24 +480,13 @@
margin: 1rem 0 0.6rem;
}
.reveal {
.move > .reveal {
margin-top: 0.8rem;
background: var(--bone);
color: var(--slate-deep);
border: 0;
border-radius: 4px;
padding: 0.6rem 1.4rem;
font-size: 1.05rem;
font-weight: 500;
}
.reveal:hover:not(:disabled) {
background: #fff;
}
.reveal:disabled {
opacity: 0.35;
cursor: not-allowed;
.reveal.small {
padding: 0.4rem 0.9rem;
font-size: 1rem;
}
.verdict {
@@ -577,6 +516,11 @@
font-size: 0.85rem;
}
.colophon a {
color: var(--frost);
}
/* Must match COMPACT_QUERY in duel.svelte.ts. */
@media (max-width: 860px) {
.board {
grid-template-columns: minmax(0, 1fr);