Use browser navigation for reliable production page links

This commit is contained in:
Quintine committed 2026-09-21 19:25:32 +10:00
1 parent b14593fa40
commit c5661ec255
15 files changed
+40 -42

No files matched your search

+1
View File
@@ -35,6 +35,7 @@
],
"rules": {
"no-array-constructor": "error",
"nextjs/no-html-link-for-pages": "off",
"no-var": "error",
"prefer-const": "error",
"prefer-rest-params": "error",
+3 -4
View File
@@ -1,5 +1,4 @@
'use client';
import Link from 'next/link';
import { useState, useEffect, useCallback, useRef } from 'react';
import { TableTalk } from '@/components/table-talk';
import { GameTable } from '@/components/game-table';
@@ -914,11 +913,11 @@ export default function Home() {
</div>
<div className="masthead-right">
<span className="status-dot" /> Multiplayer table{' '}
<Link href="/learn">Learn to play</Link>
<Link href="/rules">
<a href="/learn">Learn to play</a>
<a href="/rules">
<BookOpen size={17} />
Rulebook
</Link>
</a>
</div>
</header>
<div className="lobby-grid">
+1 -2
View File
@@ -1,8 +1,7 @@
import Link from 'next/link';
export default function Rules() {
return (
<main className="rule-page">
<Link href="/">← Return to the table</Link>
<a href="/">← Return to the table</a>
<h1>Rules coverage ledger</h1>
<p>
This project targets classic Gale Force Nine Dune, the advanced game,
+1 -2
View File
@@ -1,6 +1,5 @@
'use client';
import { useId, useState } from 'react';
import Link from 'next/link';
import type { Action, GameView } from '@/game/engine';
import { bribeAction } from '@/game/bribe-options';
import { faction } from '@/game/catalog';
@@ -47,7 +46,7 @@ export function Bribes({ game: g, act, busy }: {
<p>{recipient ? `You may pay up to ${recipient.maximum} spice to this recipient.` : `Uncommitted spice: ${options.available}. Choose a recipient to see the payment limit.`}</p>
{recipient?.limitation ? <p>{recipient.limitation}</p> : null}
{recipient && !options.blocked && !action ? <output className="bribe-status">{recipient.maximum < 1 ? 'No spice is available for this payment.' : `Enter a whole number from 1 to ${recipient.maximum}.`}</output> : null}
<p>Eligible Bureaucrats may divert two spice to the Bank. AI opponents can receive spice, but do not negotiate deals yet. <Link href="/rules?topic=bribes#bribes">Bribe rules</Link></p>
<p>Eligible Bureaucrats may divert two spice to the Bank. AI opponents can receive spice, but do not negotiate deals yet. <a href="/rules?topic=bribes#bribes">Bribe rules</a></p>
</details>
);
}
+1 -2
View File
@@ -1,4 +1,3 @@
import Link from 'next/link';
/** Counts only: this component never receives deck contents or private cards. */
export function DrawPiles({ piles }: {
@@ -19,7 +18,7 @@ export function DrawPiles({ piles }: {
</dl>
<p>Draw piles exclude hands, auction cards and discards.
{(piles.treachery === 0 || piles.spice === 0) && ' An empty pile may be replenished from eligible discards.'}
{' '}<Link href="/rules?topic=draw-piles#draw-piles">About these piles</Link>
{' '}<a href="/rules?topic=draw-piles#draw-piles">About these piles</a>
</p>
</section>
);
+1 -2
View File
@@ -1,4 +1,3 @@
import Link from 'next/link';
import { leaders } from '@/game/cards';
import type { GameView } from '@/game/engine';
import { LeaderInspector } from './leader-inspector';
@@ -16,7 +15,7 @@ export function EcazLoyaltyCard({ loyalty }: { loyalty: GameView['ecazLoyalty']
<p className="text-base! leading-7!">This Ecaz Traitor Card was randomly set aside face up before dealing. It stays outside the deck for this game and cannot be drawn as a Traitor or Face Dancer. Its leader remains in play under the normal leader rules.</p>
<div className="flex flex-wrap items-center gap-3">
<LeaderInspector kind="loyalty" identity={{ id: leader.id, name: leader.name, factionName: 'Ecaz', strength: leader.strength }} />
<Link className="py-3 text-base underline" href="/rules?topic=ecaz-loyalty#ecaz-loyalty">Loyalty rules</Link>
<a className="py-3 text-base underline" href="/rules?topic=ecaz-loyalty#ecaz-loyalty">Loyalty rules</a>
</div>
</div>
</section>;
+3 -4
View File
@@ -2,7 +2,6 @@
import { useState } from 'react';
import Image from 'next/image';
import Link from 'next/link';
import { Dialog as DialogPrimitive } from '@base-ui/react/dialog';
import { Eye, X } from 'lucide-react';
import { FACTIONS, faction, type FactionId } from '@/game/catalog';
@@ -34,8 +33,8 @@ export function FactionSheet({ factionId, advanced }: { factionId: FactionId; ad
</section>}
<footer>
<p>The rules and game are still being completed. This sheet describes faction powers; table controls show the choices currently available. Optional modules can add or change powers.</p>
<Link href={`/rules?topic=faction-${factionId}#faction-${factionId}`}>Full {house.name} reference</Link>
{advanced && <Link href={`/rules?topic=advanced-${factionId}#advanced-${factionId}`}>Advanced {house.name} reference</Link>}
<a href={`/rules?topic=faction-${factionId}#faction-${factionId}`}>Full {house.name} reference</a>
{advanced && <a href={`/rules?topic=advanced-${factionId}#advanced-${factionId}`}>Advanced {house.name} reference</a>}
</footer>
</article>;
}
@@ -64,7 +63,7 @@ export function FactionInspector({ factionId, advanced }: { factionId: FactionId
<DialogTitle>{house.name} faction sheet</DialogTitle>
<DialogDescription>Read public faction powers and alliance guidance without changing the game.</DialogDescription>
<FactionSheetReader key={`${factionId}:${advanced}`} factionId={factionId} tableAdvanced={advanced} />
<Link className="faction-sheet-help" href="/rules?topic=faction-sheets#faction-sheets">Faction sheet help</Link>
<a className="faction-sheet-help" href="/rules?topic=faction-sheets#faction-sheets">Faction sheet help</a>
</section>
</DialogPrimitive.Popup>
</DialogPortal>
+1 -2
View File
@@ -1,7 +1,6 @@
'use client';
import { useState, type CSSProperties } from 'react';
import Link from 'next/link';
import { Dialog as DialogPrimitive } from '@base-ui/react/dialog';
import { Eye, Star, X } from 'lucide-react';
import { FACTIONS, faction, type FactionId } from '@/game/catalog';
@@ -64,7 +63,7 @@ export function ForceInventoryDetails({ player, worlds = null }: { player: Publi
<div><h3>Concealed No-Field</h3><p>{inventory.marker.name}</p><p>Counts as one force for board presence until revealed. It is a separate marker, not an extra physical force counter. Its hidden force value is not shown here. Revelation draws from the reserves available then.</p></div>
</section>}
<p>Inspecting counters does not move them or commit a Battle Plan. Spice support, temporary powers and leaders may change battle strength without adding counters.</p>
<Link href="/rules?topic=force-counters#force-counters">Force counter guide</Link>
<a href="/rules?topic=force-counters#force-counters">Force counter guide</a>
</div>;
}
+6 -7
View File
@@ -137,7 +137,6 @@ import {
import { validBattleSlotPair } from '@/game/battle-card-slots';
import { stoneBurnerPlanBlock } from '@/game/stone-burner';
import { TECH_TOKENS, ownedTech } from '@/game/tech-tokens';
import Link from 'next/link';
import {
canUseAsKaramaRole,
canUseAsTruthtranceRole,
@@ -2627,9 +2626,9 @@ export function GameTable({
comparison. Other weapon effects and traitor precedence
still apply.
</p>
<Link href="/rules?topic=stone-burner">
<a href="/rules?topic=stone-burner">
Read Stone Burner rules
</Link>
</a>
{actionButton('Kill both leaders', {
type: 'decision',
event: g.decision.event,
@@ -3286,13 +3285,13 @@ export function GameTable({
<details className="phase-guidance">
<summary>What happens in this phase?</summary>
<p>{PHASE_HELP[g.phase]}</p>
<Link
<a
href={`/rules#${phaseRuleId(g.phase)}`}
target="_blank"
rel="noreferrer"
>
Open phase guide ↗
</Link>
</a>
</details>
<ChoamBaliset game={g} act={act} busy={busy} />
<ChoamGamont game={g} act={act} busy={busy} />
@@ -5529,9 +5528,9 @@ export function GameTable({
several special cards, optional faction reactions, ally support,
auction deadlines, and a printed-board connectivity audit.
</p>
<Link href="/rules">
<a href="/rules">
Open the rules coverage ledger <ArrowRight size={14} />
</Link>
</a>
</div>
)}
</section>
+1 -2
View File
@@ -1,6 +1,5 @@
'use client';
import { Fragment, useId, useState, type ReactNode } from 'react';
import Link from 'next/link';
import type { Card } from '@/game/cards';
import { browseHand, type HandCategory, type HandSort } from '@/game/hand-browsing';
import { Button } from './ui/button';
@@ -48,7 +47,7 @@ export function HandBrowser({ cards, empty, children }: {
<div className="hand-browse-results">
<output aria-live="polite">{shown.length} of {cards.length} {cards.length === 1 ? 'card' : 'cards'} shown</output>
{filtered && <Button type="button" variant="outline" onClick={() => { setQuery(''); setCategory('all'); }}>Show all cards</Button>}
<Link href="/rules?topic=hand-browsing#hand-browsing">Hand browsing help</Link>
<a href="/rules?topic=hand-browsing#hand-browsing">Hand browsing help</a>
</div>
</form>
</search>
+3 -4
View File
@@ -1,6 +1,5 @@
'use client';
import Link from 'next/link';
import { useEffect, useRef, useState, useSyncExternalStore } from 'react';
import { BattleWheel } from './battle-wheel';
import { CardInspector, CardRules } from './card-inspector';
@@ -25,7 +24,7 @@ export function Introduction() {
const hydrated = useSyncExternalStore(subscribeHydration, clientSnapshot, serverSnapshot);
return <main className={styles.page}>
<header className={styles.header}>
<Link href="/">← Return to Arrakis</Link>
<a href="/">← Return to Arrakis</a>
<p className="eyebrow">LEARN TO PLAY · BASIC GAME</p>
<h1>Your first steps on Arrakis</h1>
<p>Practice the Basic phases, from the storm and spice blow to alliances, resources, bidding, movement, battle and collection, then take a seat at a real table. These are separate examples; they do not change any saved game.</p>
@@ -164,11 +163,11 @@ function IntroductionLessons() {
<li>Use <strong>Protect your saved seat</strong> and keep its recovery kit private. An invitation identifies the room; the kit restores your seat after cookie loss.</li>
</ul>
<p>Refresh restores a live game through its saved seat. If a request has an uncertain result, use the offered retry rather than repeating the game action.</p>
<div className={styles.links}><Link href="/">Create or join a game →</Link><Link href="/rules">Explore the full reference →</Link></div>
<div className={styles.links}><a href="/">Create or join a game →</a><a href="/rules">Explore the full reference →</a></div>
</>}
<footer className={styles.footer}>
<button disabled={state.step === 0} onClick={() => go(state.step - 1)}>Previous lesson</button>
<Link href={`/rules?topic=${lesson.topic}#${lesson.topic}`}>Read the related rules</Link>
<a href={`/rules?topic=${lesson.topic}#${lesson.topic}`}>Read the related rules</a>
{state.step < INTRODUCTION_STEPS.length - 1 && <button onClick={() => go(state.step + 1)}>Next lesson</button>}
</footer>
</section>
+1 -2
View File
@@ -1,5 +1,4 @@
import type { GameView } from '@/game/engine';
import Link from 'next/link';
import { faction } from '@/game/catalog';
import { leaderStrengthLabel } from '@/game/cards';
import { isStoneBurner } from '@/game/battle-cards';
@@ -37,7 +36,7 @@ export function RevealedBattle({ game }: { game: GameView }) {
<section className="revealed-plans" id="revealed-battle-plans" tabIndex={-1} aria-label="Revealed battle plans">
<h3>Revealed battle plans</h3>
<a className="battle-display-link" href="#table-decisions">Return to battle decisions</a>
<p className="revealed-plans-help">Both plans are public. Inspect any played component; use the decision controls to resolve the battle. <Link href="/rules?topic=revealed-battle-components#revealed-battle-components">Component guide</Link> · <Link href="/rules?topic=battle#battle">Battle rules</Link></p>
<p className="revealed-plans-help">Both plans are public. Inspect any played component; use the decision controls to resolve the battle. <a href="/rules?topic=revealed-battle-components#revealed-battle-components">Component guide</a> · <a href="/rules?topic=battle#battle">Battle rules</a></p>
<div className="revealed-plan-pair">
{[battle.attacker, battle.defender].map((id) => {
const plan = battle.plans[id];
+6 -7
View File
@@ -1,5 +1,4 @@
'use client';
import Link from 'next/link';
import { StrongholdCardGallery } from './stronghold-cards';
import { HomeworldCardGallery } from './homeworld-cards';
import { NexusCardGallery } from './nexus-cards';
@@ -94,7 +93,7 @@ function RulesReferenceContent() {
return (
<main className="reference-page">
<header className="reference-header">
<Link href="/">← Return to Arrakis</Link>
<a href="/">← Return to Arrakis</a>
<span className="eyebrow">
<BookOpen size={14} /> THE MENTAT’S REFERENCE
</span>
@@ -107,17 +106,17 @@ function RulesReferenceContent() {
Find a phase, card or faction. Follow the action guides, inspect
examples and move directly between related rules.
</p>
<p><Link href="/learn">New to Dune? Try the interactive introduction →</Link></p>
<p><a href="/learn">New to Dune? Try the interactive introduction →</a></p>
<p className="reference-status">
This reference is growing with the game. Advanced play and expansion
rules are unfinished.{' '}
<Link href="/rules/coverage">
<a href="/rules/coverage">
View the implementation ledger <ArrowUpRight size={13} />
</Link>
</a>
{' · '}
<Link href="/rules?topic=implementation-checklist#implementation-checklist">
<a href="/rules?topic=implementation-checklist#implementation-checklist">
Open feature checklists <ArrowUpRight size={13} />
</Link>
</a>
</p>
</header>
<div className="reference-layout">
+1 -2
View File
@@ -7,7 +7,6 @@ import { ForceCountFields, forceCountInputError } from './truthtrance-force-coun
import { isKnowledgeFact, knowledgeFactInputError } from '@/game/truthtrance-knowledge';
import type { PlanClaim } from '@/game/battle-promises';
import { useId, useState } from 'react';
import Link from 'next/link';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { CHEAP_HERO_TRAITOR } from '@/game/traitors';
@@ -130,7 +129,7 @@ export function Truthtrance({
<section className="truthtrance-panel" aria-label="Truthtrance question">
<h2>Truthtrance</h2>
<p className="fine">
<Link href="/rules#card-truthtrance">Question rules</Link> · The table
<a href="/rules#card-truthtrance">Question rules</a> · The table
resumes its previous decision after these questions.
</p>
{w.stage === 'priority' ? (
+10
View File
@@ -52,6 +52,16 @@ public hosting remains separately gated. HTTP does not encrypt cookies or seat
recovery kits. Browser operation IDs now use cryptographic `getRandomValues`,
which also works at LAN HTTP origins where `randomUUID` is unavailable.
## Production navigation
Use native anchors for app page links. The pinned Vinext/Vite production build
renames navigation exports while its Link runtime dynamically looks up their
original names, breaking clicks and prefetch despite successful HTTP responses.
Native navigation keeps query strings, rule anchors, browser history and saved
seat cookies. The Next.js lint preference for client-side Link is disabled for
this intentional choice. Verify page links in the production browser when
changing framework versions; development navigation alone does not cover it.
## Update and preserve games
### Existing NGINX HTTPS termination