Initial commit

This commit is contained in:
Quintine committed 2026-09-08 07:37:52 +10:00
commit 8c709fb52f
771 files changed
+222983

No files matched your search

+38
View File
@@ -0,0 +1,38 @@
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/.vinext/
/out/
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# local environment
.env*
# deployment and generated files
.vercel
next-env.d.ts
/dist/
/.wrangler/
/outputs/
/work/
+5
View File
@@ -0,0 +1,5 @@
{
"project_id": "appgprj_6a9b7996c1a4819182205bafb527a97f",
"d1": "DB",
"r2": null
}
+13
View File
@@ -0,0 +1,13 @@
{
"$schema": "./node_modules/oxfmt/configuration_schema.json",
"singleQuote": true,
"printWidth": 80,
"sortPackageJson": false,
"ignorePatterns": [
"package-lock.json",
"pnpm-lock.yaml",
"yarn.lock",
"bun.lock",
"bun.lockb"
]
}
+68
View File
@@ -0,0 +1,68 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": [
"eslint",
"typescript",
"unicorn",
"oxc",
"react",
"import",
"jsx-a11y",
"nextjs"
],
"categories": {
"correctness": "error"
},
"env": {
"builtin": true,
"browser": true,
"node": true
},
"options": {
"typeAware": true,
"typeCheck": true
},
"ignorePatterns": [
".next/**",
".vinext/**",
"out/**",
"build/**",
"dist/**",
"coverage/**",
"next-env.d.ts",
"components/ui/**",
"hooks/use-mobile.ts"
],
"rules": {
"no-array-constructor": "error",
"no-var": "error",
"prefer-const": "error",
"prefer-rest-params": "error",
"prefer-spread": "error",
"import/no-anonymous-default-export": "warn",
"react/display-name": "error",
"react/jsx-no-comment-textnodes": "error",
"react/no-unescaped-entities": "error",
"react/react-compiler": "error",
"react/require-render-return": "error",
"react/rules-of-hooks": "error",
"typescript/ban-ts-comment": "error",
"typescript/no-deprecated": "error",
"typescript/no-empty-object-type": "error",
"typescript/no-explicit-any": "error",
"typescript/no-namespace": "error",
"typescript/no-require-imports": "error",
"typescript/no-unnecessary-type-constraint": "error",
"typescript/no-unsafe-function-type": "error"
},
"overrides": [
{
"files": [
"components/game-table.tsx"
],
"rules": {
"jsx-a11y/prefer-tag-over-role": "off"
}
}
]
}
+57
View File
@@ -0,0 +1,57 @@
# Dune — Arrakis Table
An unofficial multiplayer adaptation of Gale Force Nine’s classic 2019 Dune, built with React, TypeScript, Vinext, and Cloudflare Workers/D1. **Development is ongoing; this is not yet a complete rules implementation.** Advanced and unfinished expansion starts remain disabled. See [implementation status](docs/IMPLEMENTATION_STATUS.md) for implemented systems, evidence, outstanding rules questions and release gates.
## Run locally
Use Node.js 22.13 or newer and npm. Install the locked dependencies and apply local migrations before starting the server:
```sh
npm ci
npm run db:local
npm run dev -- --host 0.0.0.0
```
Open `http://localhost:3000`. Create a room, share its eight-character invitation, and add human or AI seats. The host can configure Easy, Medium, Hard and Brutal AI opponents. Current calibration demonstrates a substantially weaker Easy policy; it does not yet demonstrate a consistent ordering among the upper three levels.
Local games persist in `.wrangler/state`. Keep this directory across restarts. Apply new additive migrations before running code that depends on them. Never delete the local database to resolve a connection or rules problem. `.openai/hosting.json` belongs to the existing Sites project; do not recreate the hosting project.
## Saved seats and reconnecting
The room cookie identifies your seat. Refreshing or reopening the invitation with that cookie restores your private hand and pending decisions. A failed restoration offers an explicit retry. Requests time out after 15 seconds; uncertain game actions are reconciled with a read rather than automatically repeated.
Use **Protect your saved seat** to generate and privately save a recovery kit before enabling its key. Keep the complete kit separate from the invitation. **Recover a saved seat** uses that kit to restore the same faction after cookie loss and revokes the previous browser sessions for that seat. Recovery keeps existing forces, resources and sealed commitments. Anyone holding the kit can control that seat and see its private information.
If recovery receives no confirmed response, keep the page open and retry the same recovery request. The server recognizes the receipt and does not rotate twice. Replacing the recovery key invalidates the older kit. Host-initiated takeover remains unfinished. An owning human seat can enable or disable its own AI control during a started game.
New create/join requests save a private, tab-scoped retry record before sending. If the response is uncertain, keep the tab open and use the explicit retry; refresh preserves the same request. Confirmed success removes that record. Clearly rejected first requests return to the form for correction. A retry can recover the original room and seat without creating duplicates, but closing the tab or clearing its data can lose the proof. This record is separate from the long-term seat recovery kit.
An uncertain saved-kit recovery keeps its exact retry details in memory and prevents conflicting navigation. If that recovery has become obsolete, deliberate abandonment releases the controls after explaining that it cannot undo a completed recovery. Keep a valid saved kit before discarding retry details.
## Verify changes
Run the development server before the persisted multiplayer suite:
```sh
npm test
npm run test:multiplayer
npm run typecheck
npm run lint
npm run build
```
The production build is written to `dist`. `npm start` runs that build with Wrangler; apply its database migrations to the intended storage environment before use. The included local migration command targets the development database, not a remote deployment.
Reproduce the current AI study with:
```sh
node --import tsx tools/ai-calibration.ts --games 120 --seed 20260907 --out /tmp/dune-ai-calibration.json
```
See [AI calibration](docs/AI_CALIBRATION.md), [multiplayer audit](docs/MULTIPLAYER_AUDIT.md), [component inventory](docs/COMPONENT_INVENTORY.md), and [visual playtest](docs/VISUAL_PLAYTEST.md) for scope and limitations. Passing subsystem tests does not certify every rules combination.
## Development maintenance
During active development, preserve database state and perform a controlled server restart approximately hourly. Warn before interrupting human play and defer until a safe decision point. Verify server health and restoration afterward. No recurring automation is installed; the user requested leaving it removed. Fix causes of crashes and lost progress rather than relying on restarts.
The application bundles its artwork, fonts and rules guidance. Source provenance and third-party notices live in developer documentation; the player-facing reference uses internal links. Publication remains gated on completion and integrated verification of the requested rules, expansions and critical player journeys.
+23
View File
@@ -0,0 +1,23 @@
Board geometry and connectivity adapted from https://github.com/marekyggdrasil/truthsayer (main). No raster artwork is included.
MIT License
Copyright (c) 2021 Marek
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+110
View File
@@ -0,0 +1,110 @@
import {
authenticate,
recoverSeat,
SeatControlError,
setRecoveryKey,
} from '@/db/rooms';
import { RuleError } from '@/game/engine';
const noStore = { 'Cache-Control': 'no-store' };
export async function POST(req: Request) {
try {
const url = new URL(req.url);
if (req.headers.get('origin') && req.headers.get('origin') !== url.origin)
throw new SeatControlError(
'Invalid request origin.',
'INVALID_ORIGIN',
403,
);
const code = url.pathname.split('/').at(-2)!.toUpperCase();
if (!/^[A-Z2-9]{8}$/.test(code))
throw new SeatControlError(
'Enter the eight-character room code.',
'INVALID_CONTROL_REQUEST',
400,
);
const raw = await req.text();
if (raw.length > 4000)
throw new SeatControlError(
'Request too large.',
'REQUEST_TOO_LARGE',
413,
);
const body: unknown = JSON.parse(raw);
if (!body || typeof body !== 'object' || Array.isArray(body))
throw new SeatControlError(
'Choose a recovery operation.',
'INVALID_CONTROL_REQUEST',
400,
);
const input = body as Record<string, unknown>;
if (input.type === 'setRecoveryKey') {
const token =
req.headers
.get('cookie')
?.split(';')
.map((part) => part.trim())
.find((part) => part.startsWith(`dune_${code}=`))
?.split('=')[1] ?? '';
const auth = await authenticate(code, token);
return Response.json(
await setRecoveryKey(code, auth, input.version, input.recoverySecret),
{ headers: noStore },
);
}
if (input.type !== 'recoverSeat')
throw new SeatControlError(
'Choose a recovery operation.',
'INVALID_CONTROL_REQUEST',
400,
);
const result = await recoverSeat(code, {
playerId: input.playerId,
recoverySecret: input.recoverySecret,
operationId: input.operationId,
newSessionToken: input.newSessionToken,
});
return Response.json(
{
view: result.view,
recovered: result.recovered,
replayed: result.replayed,
},
{
headers: {
...noStore,
'Set-Cookie': `dune_${code}=${result.token}; HttpOnly; SameSite=Strict; Path=/api/rooms/${code}; Max-Age=2592000${url.protocol === 'https:' ? '; Secure' : ''}`,
},
},
);
} catch (error) {
return Response.json(
{
error:
error instanceof RuleError
? error.message
: error instanceof SyntaxError
? 'Invalid JSON.'
: 'The server could not complete seat recovery.',
code:
error instanceof SeatControlError
? error.code
: error instanceof RuleError
? 'INVALID_SESSION'
: error instanceof SyntaxError
? 'INVALID_CONTROL_REQUEST'
: 'SERVER_ERROR',
},
{
status:
error instanceof SeatControlError
? error.status
: error instanceof RuleError
? 409
: error instanceof SyntaxError
? 400
: 500,
headers: noStore,
},
);
}
}
+158
View File
@@ -0,0 +1,158 @@
import {
readSeatView,
authenticate,
joinRoom,
act,
RoomEntryError,
continueRoomBots,
continueRoomAutomatic,
needsAutomaticRoomRecovery,
type SeatAuth,
} from '@/db/rooms';
import { RuleError } from '@/game/engine';
import type { FactionId } from '@/game/catalog';
import { waitUntil } from 'cloudflare:workers';
// Coalesce overlapping polls inside this isolate. Database CAS still fences
// other isolates, restarts, seat recovery, and human actions.
const continuations = new Map<string, Promise<void>>();
function resumeRoom(code: string): Promise<void> {
const existing = continuations.get(code);
if (existing) return existing;
const pending = (async () => {
await continueRoomAutomatic(code);
await continueRoomBots(code);
})().finally(() => {
if (continuations.get(code) === pending) continuations.delete(code);
});
continuations.set(code, pending);
return pending;
}
const codeOf = (req: Request) => {
const code = new URL(req.url).pathname.split('/').pop()!.toUpperCase();
if (!/^[A-Z2-9]{8}$/.test(code))
throw new RuleError('Enter the eight-character room code.');
return code;
};
const tokenOf = (req: Request, code: string) =>
req.headers
.get('cookie')
?.split(';')
.map((x) => x.trim())
.find((x) => x.startsWith(`dune_${code}=`))
?.split('=')[1] ?? '';
const failure = (e: unknown) =>
Response.json(
{
...(e instanceof RoomEntryError ? { code: e.code } : {}),
error:
e instanceof RuleError
? e.message
: e instanceof SyntaxError
? 'Invalid JSON.'
: 'The game server could not complete this request.',
},
{
status:
e instanceof RoomEntryError
? e.status
: e instanceof RuleError
? 409
: e instanceof SyntaxError
? 400
: 500,
headers: { 'Cache-Control': 'no-store' },
},
);
export async function GET(req: Request) {
try {
const code = codeOf(req),
id = await authenticate(code, tokenOf(req, code));
const view = await readSeatView(code, id);
// Reconnect resumes persisted automatic effects/acknowledgements and AI work
// after an interruption. Actual human choices remain engine-owned decisions.
if (view.botsPending || needsAutomaticRoomRecovery(view))
waitUntil(resumeRoom(code));
return Response.json(view, {
headers: { 'Cache-Control': 'no-store' },
});
} catch (e) {
return failure(e);
}
}
export async function POST(req: Request) {
try {
if (
req.headers.get('origin') &&
req.headers.get('origin') !== new URL(req.url).origin
)
return Response.json(
{ error: 'Invalid request origin.' },
{ status: 403 },
);
const code = codeOf(req);
const raw = await req.text();
if (raw.length > 8000)
return Response.json({ error: 'Request too large.' }, { status: 413 });
const body = JSON.parse(raw);
if (!body || typeof body !== 'object' || Array.isArray(body))
throw new RuleError('The request is invalid.');
if (body.type === 'join') {
const existing = tokenOf(req, code);
let auth: SeatAuth | null = null;
if (existing) {
try {
auth = await authenticate(code, existing);
} catch (error) {
if (!(error instanceof RuleError)) throw error;
}
}
if (body.entry === undefined && auth) {
return Response.json(await readSeatView(code, auth), {
headers: { 'Cache-Control': 'no-store' },
});
}
if (typeof body.name !== 'string' || typeof body.faction !== 'string')
throw new RuleError('Enter your name and faction.');
const r = await joinRoom(
code,
body.name,
body.faction as FactionId,
body.entry,
auth,
);
return Response.json(
{
...r.view,
...(r.entryReceipt ? { entryReceipt: r.entryReceipt } : {}),
...(r.alreadySeated ? { alreadySeated: true } : {}),
},
{
headers: {
...(r.token
? {
'Set-Cookie': `dune_${code}=${r.token}; HttpOnly; SameSite=Strict; Path=/api/rooms/${code}; Max-Age=2592000${new URL(req.url).protocol === 'https:' ? '; Secure' : ''}`,
}
: {}),
'Cache-Control': 'no-store',
},
},
);
}
const id = await authenticate(code, tokenOf(req, code));
if (
!Number.isSafeInteger(body.version) ||
!body.action ||
typeof body.action !== 'object'
)
throw new RuleError('The action is invalid.');
const view = await act(code, id, body.version, body.action);
if (view.botsPending || needsAutomaticRoomRecovery(view))
waitUntil(resumeRoom(code));
return Response.json(view, {
headers: { 'Cache-Control': 'no-store' },
});
} catch (e) {
return failure(e);
}
}
+70
View File
@@ -0,0 +1,70 @@
import { createRoom, RoomEntryError } from '@/db/rooms';
import { RuleError } from '@/game/engine';
import type { FactionId } from '@/game/catalog';
export async function POST(req: Request) {
try {
if (
req.headers.get('origin') &&
req.headers.get('origin') !== new URL(req.url).origin
)
return Response.json(
{ error: 'Invalid request origin.' },
{ status: 403 },
);
const raw = await req.text();
if (raw.length > 4000)
return Response.json({ error: 'Request too large.' }, { status: 413 });
const body = JSON.parse(raw);
if (
!body ||
typeof body !== 'object' ||
Array.isArray(body) ||
typeof body.name !== 'string' ||
typeof body.faction !== 'string' ||
!Array.isArray(body.expansions) ||
typeof body.advanced !== 'boolean'
)
throw new RuleError('Enter a name, faction and rules selection.');
const result = await createRoom(
body.name,
body.faction as FactionId,
body.advanced,
body.expansions,
body.entry,
);
return Response.json(
{
...result.view,
...(result.entryReceipt ? { entryReceipt: result.entryReceipt } : {}),
},
{
status: 201,
headers: {
'Set-Cookie': `dune_${result.view.code}=${result.token}; HttpOnly; SameSite=Strict; Path=/api/rooms/${result.view.code}; Max-Age=2592000${new URL(req.url).protocol === 'https:' ? '; Secure' : ''}`,
'Cache-Control': 'no-store',
},
},
);
} catch (e) {
return Response.json(
{
...(e instanceof RoomEntryError ? { code: e.code } : {}),
error:
e instanceof RuleError
? e.message
: e instanceof SyntaxError
? 'Invalid JSON.'
: 'Unable to create the room. Please retry.',
},
{
status:
e instanceof RoomEntryError
? e.status
: e instanceof RuleError || e instanceof SyntaxError
? 400
: 500,
headers: { 'Cache-Control': 'no-store' },
},
);
}
}
+1901
View File
File diff suppressed because it is too large. Load diff
+19
View File
@@ -0,0 +1,19 @@
import type { Metadata } from 'next';
import './globals.css';
import { TooltipProvider } from '@/components/ui/tooltip';
export const metadata: Metadata = {
title: 'Dune — Arrakis Table',
description:
'A multiplayer table for classic Dune. Gather your rivals and battle for Arrakis.',
};
export default function RootLayout({
children,
}: Readonly<{ children: React.ReactNode }>) {
return (
<html lang="en">
<body>
<TooltipProvider delay={200}>{children}</TooltipProvider>
</body>
</html>
);
}
+1049
View File
File diff suppressed because it is too large. Load diff
+122
View File
@@ -0,0 +1,122 @@
import Link from 'next/link';
export default function Rules() {
return (
<main className="rule-page">
<Link href="/">← Return to the table</Link>
<h1>Rules coverage ledger</h1>
<p>
This project targets classic Gale Force Nine Dune, the advanced game,
and all three faction expansions. The current build is in development
and does not yet provide full rules compliance. Playable tables use
Basic rules with the six base factions; optional tech tokens require at
least three players. Advanced and expansion table starts remain disabled
while their remaining rules and complete games are verified.
</p>
<table>
<thead>
<tr>
<th>Area</th>
<th>Current implementation</th>
<th>Verification remaining</th>
</tr>
</thead>
<tbody>
{[
[
'Multiplayer',
'Persistent rooms, private seats, version checks, reconnects',
'Hosted deployment and broader game scenarios',
],
[
'Setup',
'Staged base setup: secret Bene Gesserit prediction before traitor dealing, all traitor choices before force placement, and starting treachery after placement. Advanced Fremen and advisor setup verified offline; existing saved setups keep their dealt cards',
'Complete Advanced rules and browser acceptance, expansion setup and optional-module setup; sampled complete games alone do not enable Advanced play',
],
[
'Storm & spice',
'Printed player circles and first-storm ordering audited; secret dials, double spice blow, Fremen forecasts, storm casualty choices, worms, Nexus and rides implemented',
'Full Advanced-game integration and remaining expansion storm/spice interactions',
],
[
'Economy',
'Charity, auctions, revival, Fremen ally revival, ally funding, Guild and Emperor income, and Advanced stronghold income',
'Auction clock and remaining power/payment timing windows',
],
[
'Movement',
'Territories, sector groups, routes, ornithopters, per-move Fremen Karama cancellation, Guild transport with private price/funding previews and contributor-based income, Advanced turn timing, BG free shipments, advisor stances and intrusion choices',
'Temporary allied entry with enforced departure, printed-board graph audit, remaining advisor timing and Guild cancellation cases, and combined expansion entry effects',
],
[
'Battle',
'Sealed plans, weapons, defenses, ties, traitors, Harkonnen ally support, Voice, element-only prescience, lasgun/shield, optional winner discards, spice combat, elite casualties, Kwisatz Haderach, captured leaders and advisor exclusions',
'Remaining advisor, special-power and timing interactions; complete Advanced battles within full games',
],
[
'Victory',
'Basic stronghold targets, alliances, Fremen/Guild special wins, prediction',
'Rulebook scenarios and contested stronghold checks',
],
[
'Treachery cards',
'Base inventory; Hajr, Ghola, Weather Control, Family Atomics, Harvester, Karama auctions, shipment benefit, faction response windows and Truthtrance card, traitor and current-spice facts, battle commitments with Ghola preparation and freeform questions',
'Remaining Karama powers, Truthtrance arbitrary commitments, other future card/power sequences and timing windows',
],
[
'Special Karama',
'Atreides plan inspection, Emperor revival, Fremen worm, Harkonnen hand exchange, Guild shipment stop, Tleilaxu revival prevention, Ixian stronghold relocation, CHOAM cash-in and Richese acquisition',
'Expansion powers; pre-reveal timing, worm/card sequencing, auction recovery and canceled-shipment settlement clarification',
],
[
'Ixians & Tleilaxu',
'Full fourteen-card inventory, tech tokens, special battle cards, Sandtrout, Thumper, Cheap Hero traitor, Amal, Tleilaxu Face Dancers, Zoal, revival commerce, foreign gholas and Ixian combat/substitution, movement, revival pricing, mobile stronghold and setup/bidding technology',
'Special-Karama timing/exception audit, mobile-stronghold collection/entry audits, complete-game setup, multi-Harvester/card-face audits and other auction modes, two-player tokens and expansion interactions',
],
[
'CHOAM & Richese',
'Partial mechanics: CHOAM charity, revival, Inflation, sales, allied exchanges, battle funding, five Worthless powers and Auditor inspection; Richese auctions, Black Market, No-Fields, allied shipments, card gifts and seven card effects; all six Stronghold Card effects, custody, inspectors and mobile choices',
'Kull Wahad, Auditor repeat-revival cycles, Semuta Drug, Mirror Weapon, Juice of Sapho, leader skills, complete Stronghold module combinations; remaining timing, payment and full-game checks',
],
[
'Ecaz & Moritani',
'Partial mechanics: Ambassador placement and five entry effects, Moritani Terror placement and supported reactions, Extortion, alliance powers, advanced leader assassination, and Moritani acquisition and battle use of Duke Vidal',
'Remaining Ambassador effects, Atomics/Aftermath, Ecaz setup and Vidal revival/control, competing entry effects, homeworlds, Nexus cards, discoveries and complete games',
],
[
'Presentation',
'Original desert artwork, interactive map, private hand, phase animations, portraits for all 60 ordinary leaders and a separate Duke Vidal portrait',
'Other special components, reveal animations, and broader responsive and accessibility review',
],
].map(([a, b, c]) => (
<tr key={a}>
<td>{a}</td>
<td>{b}</td>
<td>{c}</td>
</tr>
))}
</tbody>
</table>
<p>
Verification checkpoint: 1,631 rules, client and component tests and 152
persistence/API tests pass. Advanced setup has 456 roster, difficulty
and seating-order cases. A separate study completed 456 Advanced base
games across two through six players. Another 40 sampled base-faction
games with Stronghold Cards completed across every count and AI level,
with no rejected actions or stalls. Remaining Advanced rules and browser
acceptance, expansion games and optional modules still need
verification; these checks do not establish full rules compliance.
</p>
<h2>About this adaptation</h2>
<p>
Rules are being checked against the GF9 base rulebook, November 2020
FAQ, and the Ixians &amp; Tleilaxu, CHOAM &amp; Richese, and Ecaz &amp;
Moritani expansion rulebooks.
</p>
<p>
Board geometry adapted from the MIT-licensed Truthsayer project. Faction
and game names identify the board game being implemented. This is an
unofficial fan project.
</p>
</main>
);
}
+4
View File
@@ -0,0 +1,4 @@
import { RulesReference } from '@/components/rules-reference';
export default function Rules() {
return <RulesReference />;
}
+25
View File
@@ -0,0 +1,25 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "base-nova",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"rtl": false,
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"menuColor": "default",
"menuAccent": "subtle",
"registries": {}
}
+308
View File
@@ -0,0 +1,308 @@
'use client';
import { useId, useState } from 'react';
import type { Action, GameView } from '@/game/engine';
import { location, splitLocation, territory } from '@/game/board';
import { Button } from './ui/button';
import { Input } from './ui/input';
const bounded = (value: number | undefined, minimum: number, maximum: number) =>
Math.max(
minimum,
Math.min(maximum, Number.isSafeInteger(value) ? value! : minimum),
);
/** A draft belongs to this Ambassador event, never to the entrant's normal move. */
export function AmbassadorMovement({
game,
act,
busy,
}: {
game: GameView;
act: (action: Action) => void;
busy: boolean;
}) {
const id = useId();
const [sourceId, setSourceId] = useState('');
const [destinationKey, setDestinationKey] = useState('');
const [forces, setForces] = useState<Record<string, number>>({});
const [elites, setElites] = useState<Record<string, number>>({});
const [takeMarker, setTakeMarker] = useState(false);
const [fighters, setFighters] = useState(false);
const entry = game.ambassadorEntry;
if (
entry?.stage !== 'move' ||
entry.beneficiary !== game.me ||
game.decision?.kind !== 'ecazAmbassador' ||
game.decision.player !== game.me ||
!entry.movement
)
return null;
const sources = entry.movement.sources;
const source = sources.find((s) => s.territory === sourceId) ?? sources[0];
if (!source)
return (
<p className="m-0 text-sm leading-6">
No legal relocation is currently available.
</p>
);
const destinations = source.destinations;
const destination =
destinations.find(
(d) => location(d.territory, d.sector) === destinationKey,
) ??
destinations.find((d) => !d.blocked) ??
destinations[0];
const target = destination
? location(destination.territory, destination.sector)
: '';
const rows = source.sectors.map((s) => {
const count = s.key === target ? 0 : bounded(forces[s.key], 0, s.forces);
const minimum = Math.max(0, count - (s.forces - s.elites));
return {
...s,
count,
minimum,
maximum: Math.min(count, s.elites),
elite: bounded(elites[s.key], minimum, Math.min(count, s.elites)),
};
});
const selectedForces = Object.fromEntries(
rows.filter((s) => s.count > 0).map((s) => [s.key, s.count]),
);
const selectedElites = Object.fromEntries(
rows.filter((s) => s.count > 0).map((s) => [s.key, s.elite]),
);
const total = rows.reduce((n, s) => n + s.count, 0);
const markerAtTarget =
!!source.marker &&
location(source.territory, source.marker.sector) === target;
const includesMarker = !!source.marker && takeMarker && !markerAtTarget;
const reason =
destination?.blocked ??
(!destination
? 'Choose an available destination.'
: !total && !includesMarker
? 'Choose physical forces or the concealed No-Field to relocate.'
: null);
const chooseDestination = (key: string) => {
setDestinationKey(key);
setForces((old) => ({ ...old, [key]: 0 }));
setElites((old) => ({ ...old, [key]: 0 }));
setFighters(false);
};
const targetTerritories = [...new Set(destinations.map((d) => d.territory))];
return (
<div
className="flex min-w-0 flex-col gap-4"
aria-label="Ambassador force relocation"
>
<p className="m-0 text-sm leading-6">
Relocate a group from one territory. This does not spend your ordinary
shipment or movement. The Ambassador has already been triggered.
</p>
<label className="flex flex-col gap-2" htmlFor={`${id}-source`}>
Source territory
<select
id={`${id}-source`}
className="min-h-11"
disabled={busy}
value={source.territory}
onChange={(event) => {
setSourceId(event.target.value);
setDestinationKey('');
setForces({});
setElites({});
setTakeMarker(false);
setFighters(false);
}}
>
{sources.map((s) => (
<option key={s.territory} value={s.territory}>
{territory(s.territory).name}
</option>
))}
</select>
</label>
{rows.map((s) => (
<fieldset
className="flex min-w-0 flex-col gap-3 rounded-lg border p-3"
key={s.key}
disabled={busy}
>
<legend className="px-1 text-sm">
Sector {splitLocation(s.key).sector} · {s.forces} physical forces
available
</legend>
<label
className="flex flex-col gap-2"
htmlFor={`${id}-forces-${s.key}`}
>
Physical forces to relocate
<Input
id={`${id}-forces-${s.key}`}
className="min-h-11"
type="number"
min={0}
max={s.forces}
step={1}
value={s.count}
disabled={s.key === target}
onChange={(e) =>
setForces({
...forces,
[s.key]: bounded(Number(e.target.value), 0, s.forces),
})
}
/>
</label>
{s.elites > 0 && (
<label
className="flex flex-col gap-2"
htmlFor={`${id}-elites-${s.key}`}
>
Elite forces included · {s.minimum}–{s.maximum}
<Input
id={`${id}-elites-${s.key}`}
className="min-h-11"
type="number"
min={s.minimum}
max={s.maximum}
step={1}
value={s.elite}
disabled={s.key === target || !s.count}
onChange={(e) =>
setElites({
...elites,
[s.key]: bounded(
Number(e.target.value),
s.minimum,
s.maximum,
),
})
}
/>
</label>
)}
{s.key === target && (
<p className="m-0 text-sm">
Forces already in the destination sector stay in place.
</p>
)}
</fieldset>
))}
{source.marker && (
<div className="flex flex-col gap-2">
<label className="flex min-h-11 items-center gap-3">
<input
type="checkbox"
disabled={busy || markerAtTarget}
checked={includesMarker}
onChange={(e) => setTakeMarker(e.target.checked)}
/>
Include concealed No-Field from sector {source.marker.sector}
</label>
<p className="m-0 text-sm">
The marker can move alone or with physical forces. Its concealed
value stays unchanged.
{markerAtTarget
? ' It is already in the destination sector and stays in place.'
: ''}
</p>
</div>
)}
<label className="flex flex-col gap-2" htmlFor={`${id}-destination`}>
Destination territory
<select
id={`${id}-destination`}
className="min-h-11"
disabled={busy}
value={destination?.territory ?? ''}
onChange={(e) => {
const options = destinations.filter(
(d) => d.territory === e.target.value,
);
const chosen = options.find((d) => !d.blocked) ?? options[0];
if (chosen)
chooseDestination(location(chosen.territory, chosen.sector));
}}
>
{targetTerritories.map((t) => (
<option key={t} value={t}>
{territory(t).name}
</option>
))}
</select>
</label>
<label className="flex flex-col gap-2" htmlFor={`${id}-sector`}>
Destination sector
<select
id={`${id}-sector`}
className="min-h-11"
disabled={busy}
value={target}
onChange={(e) => chooseDestination(e.target.value)}
>
{destinations
.filter((d) => d.territory === destination?.territory)
.map((d) => (
<option key={d.sector} value={location(d.territory, d.sector)}>
{d.sector === 0 ? 'Sheltered interior' : d.sector}
{d.blocked ? ' · Unavailable' : ''}
</option>
))}
</select>
</label>
{destination?.advisors && (
<p className="m-0 text-sm">This group arrives as advisors.</p>
)}
{destination?.canFight && (
<label className="flex min-h-11 items-center gap-3">
<input
type="checkbox"
checked={fighters}
disabled={busy}
onChange={(e) => setFighters(e.target.checked)}
/>
Request a flip to fighters on arrival
</label>
)}
<p className="m-0 text-sm" aria-live="polite">
Selected: {total} physical forces
{includesMarker ? ' and one concealed No-Field' : ''}.
</p>
{reason && (
<p className="m-0 text-sm leading-6" id={`${id}-reason`}>
{reason}
</p>
)}
<Button
className="min-h-11 whitespace-normal"
disabled={busy || !!reason}
aria-describedby={reason ? `${id}-reason` : undefined}
onClick={() => {
if (busy || reason || !destination) return;
act({
type: 'decision',
event: entry.event,
forces: selectedForces,
eliteForces: selectedElites,
territory: destination.territory,
sector: destination.sector,
...(includesMarker && source.marker
? {
noField: {
tokenId: source.marker.tokenId,
event: source.marker.event,
},
}
: {}),
...(fighters && destination.canFight ? { fighters: true } : {}),
});
}}
>
Relocate selected forces
</Button>
</div>
);
}
+196
View File
@@ -0,0 +1,196 @@
'use client';
import { useId, useState } from 'react';
import type { Action, GameView } from '@/game/engine';
import { location, territory } from '@/game/board';
import { Button } from './ui/button';
import { Input } from './ui/input';
const bounded = (value: number, min: number, max: number) =>
Math.max(min, Math.min(max, Number.isSafeInteger(value) ? value : min));
/** Mounted with the Ambassador event key so an earlier shipment draft cannot carry over. */
export function AmbassadorShipment({
game,
act,
busy,
}: {
game: GameView;
act: (action: Action) => void;
busy: boolean;
}) {
const id = useId();
const [destinationKey, setDestinationKey] = useState('');
const [count, setCount] = useState<number | null>(null);
const [eliteCount, setEliteCount] = useState(0);
const entry = game.ambassadorEntry;
if (
entry?.stage !== 'ship' ||
entry.beneficiary !== game.me ||
game.decision?.kind !== 'ecazAmbassador' ||
game.decision.player !== game.me ||
!entry.shipment
)
return null;
const me = game.players.find((p) => p.id === game.me)!;
const shipment = entry.shipment;
const maximum = Math.min(shipment.maximum, me.reserves);
const amount = maximum ? bounded(count ?? maximum, 1, maximum) : 0;
const minimumElite = Math.max(
0,
amount - (me.reserves - shipment.eliteReserves),
);
const maximumElite = Math.min(amount, shipment.eliteReserves);
const elite = bounded(eliteCount, minimumElite, maximumElite);
const destination =
shipment.destinations.find(
(d) => location(d.territory, d.sector) === destinationKey,
) ??
shipment.destinations.find((d) => !d.blocked) ??
shipment.destinations[0];
const reason = !amount
? 'You have no reserve forces available for this shipment.'
: !destination
? 'No shipment destination is currently available.'
: destination.blocked;
const territories = [
...new Set(shipment.destinations.map((d) => d.territory)),
];
return (
<div
className="flex min-w-0 flex-col gap-4"
aria-label="Ambassador reserve shipment"
>
<p className="m-0 text-sm leading-6">
Send up to four of your reserve forces for free, or send no forces. This
does not spend your ordinary shipment or movement. The Ambassador has
already been triggered.
</p>
<label className="flex flex-col gap-2" htmlFor={`${id}-amount`}>
Physical forces to ship · up to {maximum}
<Input
id={`${id}-amount`}
className="min-h-11"
type="number"
min={maximum ? 1 : 0}
max={maximum}
step={1}
value={amount}
disabled={busy || !maximum}
onChange={(e) =>
setCount(bounded(Number(e.target.value), 1, maximum))
}
/>
</label>
{shipment.eliteReserves > 0 && (
<label className="flex flex-col gap-2" htmlFor={`${id}-elites`}>
Elite forces included · {minimumElite}–{maximumElite}
<Input
id={`${id}-elites`}
className="min-h-11"
type="number"
min={minimumElite}
max={maximumElite}
step={1}
value={elite}
disabled={busy || !amount}
onChange={(e) =>
setEliteCount(
bounded(Number(e.target.value), minimumElite, maximumElite),
)
}
/>
</label>
)}
<label className="flex flex-col gap-2" htmlFor={`${id}-territory`}>
Destination territory
<select
id={`${id}-territory`}
className="min-h-11"
value={destination?.territory ?? ''}
disabled={busy || !shipment.destinations.length}
onChange={(e) => {
const options = shipment.destinations.filter(
(d) => d.territory === e.target.value,
);
const chosen = options.find((d) => !d.blocked) ?? options[0];
if (chosen)
setDestinationKey(location(chosen.territory, chosen.sector));
}}
>
{territories.map((t) => (
<option key={t} value={t}>
{territory(t).name}
</option>
))}
</select>
</label>
<label className="flex flex-col gap-2" htmlFor={`${id}-sector`}>
Destination sector
<select
id={`${id}-sector`}
className="min-h-11"
value={
destination
? location(destination.territory, destination.sector)
: ''
}
disabled={busy || !destination}
onChange={(e) => setDestinationKey(e.target.value)}
>
{shipment.destinations
.filter((d) => d.territory === destination?.territory)
.map((d) => (
<option key={d.sector} value={location(d.territory, d.sector)}>
{d.sector === 0 ? 'Sheltered interior' : d.sector}
{d.blocked ? ' · Unavailable' : ''}
</option>
))}
</select>
</label>
{destination?.advisors && (
<p className="m-0 text-sm">These forces join your advisors.</p>
)}
<p className="m-0 text-sm" aria-live="polite">
Selected: {amount} physical forces, including {elite} elites. Cost: 0
spice.
</p>
<p className="m-0 text-sm">
No-Field substitution for this effect remains unfinished.
</p>
{reason && (
<p id={`${id}-reason`} className="m-0 text-sm leading-6">
{reason}
</p>
)}
<Button
className="min-h-11 whitespace-normal"
disabled={busy || !!reason}
aria-describedby={reason ? `${id}-reason` : undefined}
onClick={() => {
if (!busy && !reason && destination)
act({
type: 'decision',
event: entry.event,
amount,
elite,
territory: destination.territory,
sector: destination.sector,
});
}}
>
Ship selected forces for free
</Button>
<Button
className="min-h-11 whitespace-normal"
variant="outline"
disabled={busy}
onClick={() => {
if (!busy) act({ type: 'decision', event: entry.event, amount: 0 });
}}
>
Send no forces
</Button>
</div>
);
}
+176
View File
@@ -0,0 +1,176 @@
'use client';
import {
useCallback,
useEffect,
useRef,
useState,
useSyncExternalStore,
} from 'react';
import { Bell, BellOff } from 'lucide-react';
import { FACTIONS, type FactionId } from '@/game/catalog';
import { Button } from './ui/button';
export type AutomaticActionEvent = {
id: string;
seq: number;
faction: FactionId;
name: string;
};
const PREFERENCE_KEY = 'dune.automatic-action-notices';
const PREFERENCE_EVENT = 'dune-automatic-action-notices-preference';
const MAX_QUEUED_NOTICES = 250;
let memoryPreference: boolean | undefined;
function readPreference() {
if (memoryPreference !== undefined) return memoryPreference;
try {
return localStorage.getItem(PREFERENCE_KEY) !== 'false';
} catch {
return true;
}
}
function subscribePreference(listener: () => void) {
window.addEventListener(PREFERENCE_EVENT, listener);
return () => window.removeEventListener(PREFERENCE_EVENT, listener);
}
const serverPreference = () => true;
/** Cosmetic feedback only. Supply public events with increasing, room-local sequences. */
export function AutomaticActionNotice({
events,
}: {
events: readonly AutomaticActionEvent[];
}) {
const enabled = useSyncExternalStore(
subscribePreference,
readPreference,
serverPreference,
);
// The first snapshot establishes a baseline. Refreshing never replays history.
const seenSeq = useRef(
events.reduce((last, event) => Math.max(last, event.seq), -1),
);
const queue = useRef<AutomaticActionEvent[]>([]);
const active = useRef<AutomaticActionEvent | null>(null);
const [notice, setNotice] = useState<
(AutomaticActionEvent & { fading: boolean }) | null
>(null);
const [completed, setCompleted] = useState(0);
const timers = useRef<ReturnType<typeof setTimeout>[]>([]);
const clearTimers = useCallback(() => {
for (const timer of timers.current) clearTimeout(timer);
timers.current = [];
}, []);
const showNext = useCallback(() => {
if (active.current) return;
if (!readPreference()) {
queue.current = [];
return;
}
const next = queue.current.shift();
if (!next) return;
active.current = next;
clearTimers();
timers.current = [
setTimeout(() => {
setNotice({ ...next, fading: false });
}, 0),
setTimeout(() => {
setNotice((current) =>
current?.id === next.id ? { ...current, fading: true } : current,
);
}, 1250),
setTimeout(() => {
active.current = null;
setNotice(null);
setCompleted((count) => count + 1);
}, 1500),
];
}, [clearTimers]);
useEffect(() => {
const fresh = events
.filter((event) => event.seq > seenSeq.current)
.toSorted((a, b) => a.seq - b.seq);
const additions: AutomaticActionEvent[] = [];
for (const event of fresh) {
if (event.seq <= seenSeq.current) continue;
seenSeq.current = event.seq;
additions.push(event);
}
// Disabled notices are consumed, so enabling cannot replay missed events.
if (!readPreference()) return;
const capacity = MAX_QUEUED_NOTICES - (active.current ? 1 : 0);
// Preserve the active notice and retain the newest pending events on overflow.
queue.current = [...queue.current, ...additions].slice(-capacity);
showNext();
}, [events, showNext, completed]);
useEffect(
() => () => {
clearTimers();
queue.current = [];
active.current = null;
},
[clearTimers],
);
const toggle = () => {
const next = !enabled;
memoryPreference = next;
clearTimers();
queue.current = [];
active.current = null;
setNotice(null);
try {
localStorage.setItem(PREFERENCE_KEY, String(next));
memoryPreference = undefined;
} catch {
// Do not turn a cosmetic preference into an error or blocking dialog.
}
window.dispatchEvent(new Event(PREFERENCE_EVENT));
};
const house = notice
? FACTIONS.find((candidate) => candidate.id === notice.faction)
: undefined;
return (
<>
<Button
variant="outline"
className="min-h-11 max-w-full whitespace-normal motion-reduce:transition-none"
aria-label="Automatic action notices"
aria-pressed={enabled}
onClick={toggle}
>
{enabled ? <Bell aria-hidden="true" /> : <BellOff aria-hidden="true" />}
Automatic action notices
<span aria-hidden="true">{enabled ? 'On' : 'Off'}</span>
</Button>
<output
aria-live="polite"
aria-atomic="true"
className="pointer-events-none fixed top-[max(1rem,env(safe-area-inset-top))] left-1/2 z-40 w-[calc(100%-2rem)] max-w-sm -translate-x-1/2"
>
{enabled && notice && (
<span
className={`block rounded-xl border border-[#807456] border-l-4 bg-[#141814]/95 px-4 py-3 text-[#f5ecda] shadow-xl transition-opacity duration-250 motion-reduce:opacity-100 motion-reduce:transition-none ${notice.fading ? 'opacity-0' : 'opacity-100'}`}
style={{ borderLeftColor: house?.color ?? '#d4b77b' }}
>
<span
className="block text-xs font-semibold tracking-wider uppercase"
style={{ color: house?.color ?? '#d4b77b' }}
>
{house?.name ?? 'Automatic action'}
</span>
<span className="mt-1 block break-words text-sm font-medium leading-5">
{notice.name}
</span>
</span>
)}
</output>
</>
);
}
@@ -0,0 +1,40 @@
'use client';
import { CardInspector } from './card-inspector';
import {
LeaderInspector,
type LeaderDisplayIdentity,
} from './leader-inspector';
import type { VisibleCard } from '@/game/card-presentation';
/** The parent supplies only faces already authorized by its private game view. */
export function BattleComponentInspection({
cards,
leader,
playerName,
}: {
cards: readonly VisibleCard[];
leader?: LeaderDisplayIdentity;
playerName: string;
}) {
if (!cards.length && !leader) return null;
return (
<details className="battle-component-inspection">
<summary>Inspect {playerName}’s plan components</summary>
<div className="visible-component-list">
{leader && (
<div>
<strong>{leader.name}</strong>
<LeaderInspector identity={leader} />
</div>
)}
{cards.map((card) => (
<div key={card.id}>
<strong>{card.name}</strong>
<CardInspector card={card} />
</div>
))}
</div>
</details>
);
}
+216
View File
@@ -0,0 +1,216 @@
'use client';
import { controlsLeader } from '@/game/leader-control';
import { leaderStrengthLabel } from '@/game/cards';
import {
battleCardLabel,
isWeaponCard,
isStoneBurner,
isDefenseCard,
VOICE_KINDS,
} from '@/game/battle-cards';
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import type { Action, GameView } from '@/game/engine';
export function BattlePreparation({
game,
send,
busy,
}: {
game: GameView;
send: (action: Action) => Promise<void>;
busy: boolean;
}) {
const b = game.battle!;
const preparation = b.preparation!;
const me = game.players.find((p) => p.id === game.me)!;
const [kind, setKind] = useState('poison');
const [must, setMust] = useState(false);
const [value, setValue] = useState('');
const act = (action: Action) => void send(action);
const beneficiary = game.players.find(
(p) => p.id === preparation.beneficiary,
)!;
if (preparation.owner !== game.me)
return (
<p className="notice">
Waiting for {game.players.find((p) => p.id === preparation.owner)?.name}
{preparation.kind === 'voice'
? ' to choose or decline the Voice.'
: preparation.kind === 'prescience'
? ' to choose or decline prescience.'
: ' to reveal the requested battle-plan element.'}
</p>
);
if (preparation.kind === 'voice')
return (
<>
<h3>The Voice</h3>
<p className="muted">
{beneficiary.id === me.id
? 'Command your opponent'
: `Assist ${beneficiary.name}`}{' '}
before Atreides chooses prescience and the combatants seal their
plans.
</p>
<label htmlFor="voice-kind">
Card type
<select
id="voice-kind"
value={kind}
onChange={(e) => setKind(e.target.value)}
>
{VOICE_KINDS.map((k) => (
<option key={k} value={k}>
{battleCardLabel(k)}
</option>
))}
</select>
</label>
<label className="decision-checkbox">
<input
type="checkbox"
checked={must}
onChange={(e) => setMust(e.target.checked)}
/>
Require this type (unchecked: forbid)
</label>
<Button
className="game-action"
disabled={busy}
onClick={() => act({ type: 'voice', kind, must })}
>
Use the Voice
</Button>
<Button
className="game-action"
disabled={busy}
onClick={() => act({ type: 'declineBattlePower' })}
>
Decline the Voice
</Button>
</>
);
const prescienceTarget =
beneficiary.id === b.attacker ? b.defender : b.attacker;
const noFieldTarget = b.noFieldPlayers.includes(prescienceTarget);
if (preparation.kind === 'prescience')
return (
<>
<h3>Battle prescience</h3>
<p className="muted">
Choose one element of{' '}
{beneficiary.id === me.id ? 'your' : `${beneficiary.name}’s`}{' '}
opponent’s plan. Only that element becomes fixed.
</p>
{noFieldTarget && (
<p className="fine">
A No-Field battle keeps the number dialed hidden from Atreides. You
can inspect the leader, weapon, or defense.
</p>
)}
{[
'leader',
'weapon',
'defense',
...(noFieldTarget ? [] : ['dial']),
].map((field) => (
<Button
className="game-action"
key={field}
disabled={busy}
onClick={() => act({ type: 'prescience', field })}
>
Reveal {field}
</Button>
))}
<Button
className="game-action"
disabled={busy}
onClick={() => act({ type: 'declineBattlePower' })}
>
Decline prescience
</Button>
</>
);
const field = b.prescience!.field;
const stoneReason =
field === 'weapon' && isStoneBurner(me.hand?.find((c) => c.id === value))
? b.stoneBurnerContext?.blocked
: null;
const choices =
field === 'leader'
? [
...me.leaders
.filter(
(l) =>
!l.dead &&
controlsLeader(me, l) &&
(!l.usedAt || l.usedAt === b.territory),
)
.map((l) => ({
id: l.id,
name: `${l.name} · ${leaderStrengthLabel(l)}`,
})),
...(me.hand?.filter((c) => c.kind === 'hero') ?? []),
]
: (me.hand?.filter((c) =>
field === 'weapon' ? isWeaponCard(c) : isDefenseCard(c),
) ?? []);
return (
<>
<h3>Reveal your {field}</h3>
<p className="muted">
{beneficiary.name} will see this element. You may still change the rest
of your plan before sealing it.
</p>
{b.voice && (
<p className="notice">
Voice: {b.voice.must ? 'must play' : 'cannot play'}{' '}
{battleCardLabel(b.voice.kind)}.
</p>
)}
<label htmlFor="prescience-answer">
{field === 'dial' ? 'Forces dialed' : field}
{field === 'dial' ? (
<Input
id="prescience-answer"
type="number"
min={0}
max={game.advanced || me.faction === 'ixians' ? 40 : 20}
step={game.advanced || me.faction === 'ixians' ? 0.5 : 1}
value={value}
onChange={(e) => setValue(e.target.value)}
/>
) : (
<select
id="prescience-answer"
value={value}
onChange={(e) => setValue(e.target.value)}
>
<option value="">None</option>
{choices.map((choice) => (
<option key={choice.id} value={choice.id}>
{choice.name}
</option>
))}
</select>
)}
</label>
{stoneReason && <p className="notice">{stoneReason}</p>}
<Button
className="game-action"
disabled={busy || !!stoneReason || (field === 'dial' && value === '')}
onClick={() =>
act({
type: 'prescienceAnswer',
value: field === 'dial' ? Number(value) : value || null,
})
}
>
Commit this element
</Button>
</>
);
}
+239
View File
@@ -0,0 +1,239 @@
'use client';
import { useId } from 'react';
import { HelpTip } from './help-tip';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { TRUTH_CARD_NAMES } from '@/game/truthtrance';
import { planClaimText, type PlanClaim } from '@/game/battle-promises';
import { CHEAP_HERO_TRAITOR } from '@/game/traitors';
import type { Action, GameView, Plan } from '@/game/engine';
export function BattleClaimFields({
game: g,
value: c,
onChange,
}: {
game: GameView;
value: PlanClaim;
onChange: (c: PlanClaim) => void;
}) {
const amountId = useId();
const initial = (kind: string): PlanClaim =>
kind === 'dial' || kind === 'support'
? { kind, compare: 'eq', value: 0 }
: kind === 'weapon' || kind === 'defense'
? { kind, name: null }
: kind === 'leader'
? { kind, leader: g.allLeaders[0].id }
: { kind: 'kwisatz', use: true };
return (
<>
<label>
Plan element
<select
value={c.kind}
onChange={(e) => onChange(initial(e.target.value))}
>
<option value="dial">Dial</option>
<option value="leader">Leader or hero</option>
<option value="weapon">Weapon slot</option>
<option value="defense">Defense slot</option>
<option value="support">Spice support</option>
<option value="kwisatz">Kwisatz Haderach</option>
</select>
</label>
{(c.kind === 'dial' || c.kind === 'support') && (
<>
<label>
Comparison
<select
value={c.compare}
onChange={(e) =>
onChange({
...c,
compare: e.target.value as 'eq' | 'gte' | 'lte',
})
}
>
<option value="eq">Exactly</option>
<option value="gte">At least</option>
<option value="lte">At most</option>
</select>
</label>
<label htmlFor={amountId}>
Amount
<Input
id={amountId}
type="number"
min={0}
max={40}
step={c.kind === 'dial' ? 0.5 : 1}
value={c.value}
onChange={(e) =>
onChange({ ...c, value: Number(e.target.value) })
}
/>
</label>
</>
)}
{(c.kind === 'weapon' || c.kind === 'defense') && (
<label>
Card name
<select
value={c.name ?? ''}
onChange={(e) => onChange({ ...c, name: e.target.value || null })}
>
<option value="">Empty slot</option>
{TRUTH_CARD_NAMES.map((name) => (
<option key={name}>{name}</option>
))}
</select>
</label>
)}
{c.kind === 'leader' && (
<label>
Leader
<select
value={c.leader ?? ''}
onChange={(e) => onChange({ ...c, leader: e.target.value || null })}
>
<option value="">No leader or hero</option>
{g.allLeaders.map((l) => (
<option key={l.id} value={l.id}>
{l.name}
</option>
))}
<option value={CHEAP_HERO_TRAITOR}>Cheap Hero / Heroine</option>
</select>
</label>
)}
{c.kind === 'kwisatz' && (
<label>
Kwisatz use
<select
value={c.use ? 'yes' : 'no'}
onChange={(e) => onChange({ ...c, use: e.target.value === 'yes' })}
>
<option value="yes">Included</option>
<option value="no">Not included</option>
</select>
</label>
)}
</>
);
}
export function BattlePromises({
game: g,
fill,
act,
busy,
}: {
game: GameView;
fill: (plan: Plan) => void;
act: (action: Action) => void;
busy: boolean;
}) {
const b = g.battle;
if (!b || (!b.truthPromises.length && !b.compliantPreparation)) return null;
const me = g.players.find((p) => p.id === g.me)!;
const name = (id: string | null) =>
id === null
? 'None'
: (me.hand?.find((c) => c.id === id)?.name ??
g.allLeaders.find((l) => l.id === id)?.name ??
id);
const plan = b.compliantPlan;
return (
<section
className="notice truthtrance-history"
aria-label={
b.truthPromises.length
? 'Battle Truthtrance commitments'
: 'Private battle preparation'
}
>
<h3>
{b.truthPromises.length
? 'Truthtrance commitments'
: 'Preparing your committed plan'}{' '}
<HelpTip topic="truthBattle" />
</h3>
<ul>
{b.truthPromises.map((p, i) => (
<li key={i}>
<p>
{g.players.find((other) => other.id === p.player)?.name}:{' '}
<strong>{p.answer ? 'Yes' : 'No'}</strong> to “
{planClaimText(p.claim, name)}”
{p.released ? ' · No longer possible; released' : ''}.
</p>
</li>
))}
</ul>
{b.compliantPreparation && (
<div>
<p className="eyebrow">Preparation · private</p>
{b.compliantPreparation.waitingForIncome && (
<p>Revival income must resolve before this plan can be funded.</p>
)}
{b.compliantPreparation.actions.map((action, index) => (
<p key={index}>
{index + 1}.{' '}
{action.mode === 'special'
? `Spend Karama and discard ${(action.cards as string[]).map(name).join(', ')} to gain ${(action.cards as string[]).length * 3} spice`
: typeof action.leader === 'string'
? `Play Ghola to revive ${action.leader === 'kwisatz' ? 'Kwisatz Haderach' : name(action.leader)}`
: `Play Ghola to revive ${Number(action.amount)} force${Number(action.amount) === 1 ? '' : 's'}${Number(action.elite) ? `, including ${Number(action.elite)} elite` : ''}`}
.
</p>
))}
<p className="fine">
{b.compliantPreparation.actions.length
? 'The next card is still in your hand. Complete the preparation and any response before reviewing and sealing the plan.'
: 'The revival is complete. Resolve its income response before reviewing and sealing the plan.'}{' '}
Other legal ways to keep your answer remain available.
</p>
{b.compliantPreparation.actions[0] && (
<Button
variant="outline"
disabled={
busy ||
!!g.truthtrance ||
!!g.response ||
!!g.decision ||
!!g.phaseOpening
}
onClick={() => act(b.compliantPreparation!.actions[0])}
>
Complete next preparation
</Button>
)}
</div>
)}
{plan && (
<details>
<summary>Review a compliant plan · private</summary>
<p>
Dial {plan.dial} · Support {plan.support}
{!!plan.allyPayment && ` · CHOAM pays ${plan.allyPayment}`}
<br />
Leader: {name(plan.leader)}
<br />
Weapon: {name(plan.weapon)}
<br />
Defense: {name(plan.defense)}
<br />
Kwisatz: {plan.kwisatz ? 'Included' : 'Not included'}
</p>
<p className="fine">
These choices satisfy your current commitments. Review and change
the other elements before sealing your plan.
</p>
<Button variant="outline" disabled={busy} onClick={() => fill(plan)}>
Fill these plan choices
</Button>
</details>
)}
</section>
);
}
+108
View File
@@ -0,0 +1,108 @@
.root {
display: grid;
width: 100%;
min-width: 0;
gap: 0.15rem;
color: #f2ddb1;
font-variant-numeric: tabular-nums;
}
.wheel {
display: block;
width: min(100%, 12rem);
height: auto;
justify-self: center;
overflow: visible;
pointer-events: none;
user-select: none;
}
.chosen {
fill: #f6e2b8;
font-family: Georgia, 'Times New Roman', serif;
font-size: 34px;
font-weight: 600;
letter-spacing: -1px;
}
.controls {
display: grid;
grid-template-columns: minmax(0, 1fr) 4.5rem;
align-items: start;
gap: 0.75rem;
min-width: 0;
}
.slider {
min-width: 0;
}
.root .range {
display: block;
width: 100%;
min-width: 0;
height: 44px;
margin: 0;
padding: 0;
border: 0;
background: transparent;
accent-color: #d8b66a;
cursor: pointer;
}
.bounds {
display: flex;
justify-content: space-between;
margin-top: -0.3rem;
color: #bbb39f;
font-size: 12px;
line-height: 1;
}
.root .number {
box-sizing: border-box;
width: 100%;
min-width: 0;
height: 44px;
margin: 0;
padding: 0.5rem;
border: 1px solid #8e794e;
border-radius: 0.35rem;
background: #151b13;
color: #f6e2b8;
font-family: inherit;
font-size: 16px;
font-variant-numeric: tabular-nums;
text-align: center;
color-scheme: dark;
}
.root input:focus-visible {
outline: 2px solid #f0d497;
outline-offset: 3px;
}
.root[data-disabled] .wheel {
opacity: 0.6;
}
.root input:disabled {
cursor: not-allowed;
opacity: 0.6;
}
@media (forced-colors: active) {
.root .number {
border-color: ButtonText;
color: CanvasText;
background: Canvas;
}
.root input:focus-visible {
outline-color: Highlight;
}
.bounds {
color: CanvasText;
}
}
+186
View File
@@ -0,0 +1,186 @@
'use client';
import { useId } from 'react';
import styles from './battle-wheel.module.css';
export type BattleWheelProps = {
value: number;
onChange: (value: number) => void;
max: number;
step: number;
disabled?: boolean;
id?: string;
};
const point = (angle: number, radius: number) => ({
x: 100 + Math.sin((angle * Math.PI) / 180) * radius,
y: 100 - Math.cos((angle * Math.PI) / 180) * radius,
});
/** The parent supplies the dial limits and labels the numeric input by its ID. */
export function BattleWheel({
value,
onChange,
max,
step,
disabled = false,
id = 'forces-dialed',
}: BattleWheelProps) {
const graphicId = useId();
const rimId = `${graphicId}-rim`;
const faceId = `${graphicId}-face`;
const fraction = max > 0 ? Math.min(1, Math.max(0, value / max)) : 0;
const angle = -135 + fraction * 270;
// This caps decorative detail only; the native controls keep the supplied step.
const divisions = Math.min(80, Math.max(1, Math.ceil(max / step)));
const labels = [
...new Set(
[0, 0.25, 0.5, 0.75, 1].map((position) =>
Math.min(max, Math.round((max * position) / step) * step),
),
),
];
return (
<div className={styles.root} data-disabled={disabled || undefined}>
<svg
className={styles.wheel}
viewBox="0 0 200 200"
aria-hidden="true"
focusable="false"
>
<defs>
<linearGradient id={rimId} x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stopColor="#e3c68a" />
<stop offset="0.35" stopColor="#756344" />
<stop offset="0.65" stopColor="#302d24" />
<stop offset="1" stopColor="#b29966" />
</linearGradient>
<radialGradient id={faceId} cx="40%" cy="28%" r="75%">
<stop offset="0" stopColor="#30362b" />
<stop offset="1" stopColor="#131810" />
</radialGradient>
</defs>
<circle cx="100" cy="102" r="96" fill="#080b07" opacity="0.6" />
<circle cx="100" cy="100" r="95" fill={`url(#${rimId})`} />
<circle cx="100" cy="100" r="91" fill="#181d15" />
<circle
cx="100"
cy="100"
r="87"
fill="none"
stroke="#736344"
strokeWidth="0.6"
/>
{Array.from({ length: divisions + 1 }, (_, index) => {
const position = index / divisions;
const tickAngle = -135 + position * 270;
const major = index % Math.max(1, Math.round(divisions / 4)) === 0;
const outer = point(tickAngle, 83);
const inner = point(tickAngle, major ? 73 : 78);
return (
<line
key={index}
x1={inner.x}
y1={inner.y}
x2={outer.x}
y2={outer.y}
stroke={position <= fraction ? '#e3c68a' : '#777762'}
strokeWidth={major ? 1.7 : 0.8}
/>
);
})}
{labels.map((mark) => {
const label = point(-135 + (max > 0 ? mark / max : 0) * 270, 62);
return (
<text
key={mark}
x={label.x}
y={label.y}
fill="#d1c4a4"
fontSize="12"
textAnchor="middle"
dominantBaseline="central"
>
{Number(mark.toFixed(2))}
</text>
);
})}
<circle
cx="100"
cy="100"
r="44"
fill={`url(#${faceId})`}
stroke="#89764d"
strokeWidth="1"
/>
<circle
cx="100"
cy="100"
r="40"
fill="none"
stroke="#c6ad76"
strokeOpacity="0.15"
/>
<path
d="M 96 29 L 100 18 L 104 29 Z"
fill="#f2d79b"
stroke="#171b12"
strokeWidth="0.8"
transform={`rotate(${angle} 100 100)`}
/>
<text
x="100"
y="101"
textAnchor="middle"
dominantBaseline="central"
className={styles.chosen}
>
{value}
</text>
<text
x="100"
y="124"
textAnchor="middle"
fill="#c0b290"
fontSize="9"
letterSpacing="1.6"
>
DIALED
</text>
<path d="M 85 173 H 115" stroke="#a28b5e" strokeWidth="1" />
<circle cx="100" cy="180" r="1.5" fill="#a28b5e" />
</svg>
<div className={styles.controls}>
<div className={styles.slider}>
<input
className={styles.range}
type="range"
min={0}
max={max}
step={step}
value={value}
disabled={disabled}
aria-label="Forces dialed slider"
onChange={(event) => onChange(Number(event.target.value))}
/>
<div className={styles.bounds} aria-hidden="true">
<span>0</span>
<span>{max}</span>
</div>
</div>
<input
className={styles.number}
id={id}
type="number"
min={0}
max={max}
step={step}
value={value}
disabled={disabled}
onChange={(event) => onChange(Number(event.target.value))}
/>
</div>
</div>
);
}
+159
View File
@@ -0,0 +1,159 @@
'use client';
import { Dialog as DialogPrimitive } from '@base-ui/react/dialog';
import { Eye, Shield, Sparkles, Swords, UserRound, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogClose,
DialogDescription,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import { cardPresentation, type VisibleCard } from '@/game/card-presentation';
/** Always pass a card from the requesting player's authorized view, never a deck. */
type CardProps = { card: VisibleCard };
export function CardRules({ card }: CardProps) {
return (
<p className="card-rules text-sm leading-relaxed text-[#e2d9c6]">
{cardPresentation(card).guidance}
</p>
);
}
const faceColors = {
weapon: 'from-[#512f28] via-[#302622] to-[#171b17]',
defense: 'from-[#284544] via-[#243330] to-[#171b17]',
leader: 'from-[#4b3c23] via-[#353123] to-[#171b17]',
utility: 'from-[#38344b] via-[#2d2c34] to-[#171b17]',
};
const roleIcons = {
weapon: Swords,
defense: Shield,
leader: UserRound,
utility: Sparkles,
};
/** Read-only inspection. It cannot play a card, read game state, or reveal another card. */
export function CardInspector({ card }: CardProps) {
const presentation = cardPresentation(card);
const Icon = roleIcons[presentation.role];
return (
<Dialog>
<DialogTrigger
render={
<Button
variant="outline"
className="min-h-11 w-full motion-reduce:transition-none"
/>
}
aria-label={`Inspect card: ${card.name}`}
>
<Eye aria-hidden="true" />
Inspect card
</DialogTrigger>
<DialogPortal>
<DialogOverlay className="bg-black/75 motion-reduce:animate-none motion-reduce:transition-none" />
<DialogPrimitive.Popup className="fixed top-1/2 left-1/2 z-50 flex max-h-[calc(100dvh-1rem)] w-[calc(100%-1rem)] max-w-3xl -translate-x-1/2 -translate-y-1/2 flex-col overflow-hidden rounded-2xl border border-[#8e7952] bg-[#141814] text-[#eeeae0] shadow-2xl outline-none duration-150 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 motion-reduce:animate-none motion-reduce:transition-none sm:max-h-[calc(100dvh-3rem)] sm:w-[calc(100%-3rem)]">
<div className="flex shrink-0 items-center justify-between gap-3 border-b border-[#414536] px-4 py-3">
<span className="text-xs font-semibold tracking-[0.16em] text-[#ddbc77] uppercase">
Card inspection
</span>
<DialogClose
render={
<Button
variant="outline"
className="min-h-11 min-w-11 motion-reduce:transition-none"
/>
}
>
<X aria-hidden="true" />
Close
</DialogClose>
</div>
<div className="min-h-0 overflow-y-auto overscroll-contain p-4 sm:p-6">
<div className="grid items-start gap-6 md:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)]">
<article
className={`min-w-0 rounded-xl border border-[#a58b5c] bg-gradient-to-br ${faceColors[presentation.role]} p-5 shadow-lg sm:p-6`}
>
<div className="mb-6 flex items-start justify-between gap-4 border-b border-[#b59a64]/40 pb-4">
<span className="text-xs font-semibold tracking-[0.12em] text-[#f1d79f] uppercase">
{presentation.category}
</span>
<Icon
className="size-6 shrink-0 text-[#edce90]"
aria-hidden="true"
/>
</div>
<div
aria-hidden="true"
className="mx-auto mb-6 flex size-24 rotate-45 items-center justify-center rounded-2xl border border-[#c5a86d]/40 bg-[#d7bd81]/5"
>
<Icon className="size-12 -rotate-45 text-[#ddbc77]" />
</div>
<DialogTitle className="break-words font-serif text-3xl leading-tight text-[#fff3d6] sm:text-4xl">
{card.name}
</DialogTitle>
<DialogDescription className="mt-5 text-base leading-7 text-[#eee5d2]">
{presentation.guidance}
</DialogDescription>
<div className="mt-6 border-t border-[#b59a64]/40 pt-3 text-xs tracking-wider text-[#cebea0] uppercase">
Treachery · gameplay guide
</div>
</article>
<section
className="flex min-w-0 flex-col gap-5"
aria-label={`How to use ${card.name}`}
>
<p className="m-0 text-sm leading-6 text-[#c1c5b8]">
{presentation.availability ??
'Inspecting a card does not play it. Close this view to use the table’s action controls.'}
</p>
{presentation.gameplay && (
<div className="flex flex-col gap-4 rounded-lg border border-[#454b3c] bg-[#1d221b] p-4 text-base leading-7 text-[#e0e4d8]">
<h3 className="m-0 text-base font-semibold text-[#efd9a8]">
Playing {card.name}
</h3>
{presentation.gameplay.map((paragraph) => (
<p className="m-0" key={paragraph}>
{paragraph}
</p>
))}
</div>
)}
{presentation.topics.map((topic, index) => (
<details
key={topic.id}
open={index === 0}
className="rounded-lg border border-[#454b3c] bg-[#1d221b]"
>
<summary className="cursor-pointer rounded-lg px-4 py-3 text-base font-semibold text-[#efd9a8] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#ddbc77]">
{topic.title}
</summary>
<div className="flex flex-col gap-3 border-t border-[#454b3c] p-4 text-base leading-7 text-[#e0e4d8]">
{topic.steps.map((step) => (
<p className="m-0" key={step}>
{step}
</p>
))}
{topic.example && (
<p className="m-0 rounded border-l-2 border-[#ddbc77] bg-[#ddbc77]/5 p-3">
<strong>Example: </strong>
{topic.example}
</p>
)}
</div>
</details>
))}
</section>
</div>
</div>
</DialogPrimitive.Popup>
</DialogPortal>
</Dialog>
);
}
+105
View File
@@ -0,0 +1,105 @@
'use client';
import type { Action, GameView } from '@/game/engine';
import { Button } from '@/components/ui/button';
import { CardInspector, CardRules } from './card-inspector';
export function AuditorDecision({
game,
act,
busy,
}: {
game: GameView;
act: (action: Action) => void;
busy: boolean;
}) {
const audit = game.auditor;
if (!audit) return null;
const payment = game.decision?.kind === 'choamAuditPayment';
const opponent = game.players.find((p) => p.id === audit.opponent);
return (
<section className="min-w-0 space-y-3" aria-label="Auditor decision">
<h3 className="font-serif text-xl">
{payment ? 'Keep your hand private' : 'Use the Auditor'}
</h3>
<p className="text-base leading-7">
{payment
? `Pay ${audit.count} spice to CHOAM to cancel the entire inspection, or allow it without payment.`
: `Your Auditor ${audit.survived ? 'survived' : 'died'}. You may inspect ${audit.count} random card${audit.count === 1 ? '' : 's'} from ${opponent?.name ?? 'your opponent'}’s hand. Cards used in this battle are excluded.`}
</p>
{!payment && (
<p className="text-base leading-7">
Karama can prevent the audit. Your opponent can also cancel by paying
one spice per card you would inspect.
</p>
)}
<div className="flex flex-wrap gap-2">
<Button
disabled={busy}
onClick={() =>
act(
payment
? {
type: 'decision',
event: audit.event,
pay: true,
count: audit.count,
}
: { type: 'decision', event: audit.event, audit: true },
)
}
>
{payment
? `Pay ${audit.count} spice · cancel audit`
: 'Audit opposing hand'}
</Button>
<Button
variant="outline"
disabled={busy}
onClick={() =>
act(
payment
? { type: 'decision', event: audit.event, pay: false }
: { type: 'decision', event: audit.event, audit: false },
)
}
>
{payment ? 'Allow inspection' : 'Decline audit'}
</Button>
</div>
</section>
);
}
export function AuditorInsight({ game }: { game: GameView }) {
const insight = game.auditorInsight;
if (!insight) return null;
const opponent = game.players.find((p) => p.id === insight.target);
return (
<section
className="notice min-w-0 space-y-3"
aria-label="Your private Auditor inspection"
>
<h3 className="font-serif text-xl">
Your Auditor inspection · {opponent?.name}
</h3>
<p className="text-base leading-7">
Only you receive this inspection. These cards were in the opposing hand
when the audit resolved; they may move afterward. The inspection stays
here until the next battle or turn.
</p>
<div className="grid min-w-0 gap-3 sm:grid-cols-2">
{insight.cards.map((card) => (
<article
key={card.id}
className="min-w-0 space-y-3 rounded-lg border border-[#485542] p-4"
>
<h4 className="font-serif text-xl">{card.name}</h4>
<CardRules card={card} />
<CardInspector card={card} />
</article>
))}
</div>
</section>
);
}
+112
View File
@@ -0,0 +1,112 @@
'use client';
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { HelpTip } from './help-tip';
import type { Action, GameView } from '@/game/engine';
import { gameTerritories } from '@/game/board';
export function ChoamBaliset({
game: g,
act,
busy,
}: {
game: GameView;
act: (a: Action) => void;
busy: boolean;
}) {
const [target, setTarget] = useState('');
const [territory, setTerritory] = useState('');
if (!g.choamWorthless || g.phase !== 5) return null;
const d = g.decision?.kind === 'choamMovement' ? g.decision : null;
const card = g.choamWorthless.cards.find((c) => c.name === 'Baliset');
if (!card && !d) return null;
const players = g.players.filter((p) => p.faction !== 'choam');
const selectedPlayer =
d?.mover ?? players.find((p) => p.id === target)?.id ?? players[0]?.id;
const territories = g.choamWorthless.baliset.filter(
(t) =>
!g.balisetRestrictions.some(
(b) => b.territory === t && b.player === selectedPlayer,
),
);
const selectedTerritory =
d?.territory ??
(territories.includes(territory) ? territory : territories[0]);
const name = (id?: string) =>
gameTerritories(g).find((t) => t.id === id)?.name;
return (
<section className="notice">
<h3>
Baliset <HelpTip topic="choamWorthless" />
</h3>
<p>
Prevent a player from moving into one territory you occupy this phase.
Shipment remains possible. Karama may cancel the effect.
</p>
{d ? (
<p>
{g.players.find((p) => p.id === d.mover)?.name} has declared a move of{' '}
{d.amount} forces into {name(d.territory)}, sector {d.sector}. No
forces have moved yet.
</p>
) : (
card && (
<>
<label>
Player{' '}
<select
value={selectedPlayer ?? ''}
onChange={(e) => setTarget(e.target.value)}
>
{players.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
</label>
<label>
Territory{' '}
<select
value={selectedTerritory ?? ''}
onChange={(e) => setTerritory(e.target.value)}
>
{territories.map((t) => (
<option key={t} value={t}>
{name(t)}
</option>
))}
</select>
</label>
</>
)
)}
{card && (
<Button
disabled={busy || !selectedPlayer || !selectedTerritory}
onClick={() =>
act({
type: 'card',
mode: 'choam',
card: card.id,
target: selectedPlayer,
territory: selectedTerritory,
})
}
>
Prevent movement
</Button>
)}
{!card && d && <p className="fine">No available Baliset play.</p>}
{d && (
<Button
variant="outline"
disabled={busy}
onClick={() => act({ type: 'decision', decline: true })}
>
Allow movement
</Button>
)}
</section>
);
}
+66
View File
@@ -0,0 +1,66 @@
'use client';
import { useState } from 'react';
import { HelpTip } from './help-tip';
import { Button } from '@/components/ui/button';
import type { Action, GameView } from '@/game/engine';
export function ChoamBattleFunding({
game: g,
act,
busy,
}: {
game: GameView;
act: (a: Action) => void;
busy: boolean;
}) {
const me = g.players.find((p) => p.id === g.me)!;
const [amount, setAmount] = useState(g.aid.pledged);
const deciding = g.decision?.kind === 'choamBattleFunding';
if (!g.advanced || g.phase !== 6 || !me.ally || me.faction !== 'choam')
return null;
return (
<section className="notice">
<h3>
Fund your ally’s battle forces <HelpTip topic="choamCombat" />
</h3>
<p>
Set aside spice for your ally’s combat support. Your ally chooses how
much to use with each plan. Sealed payments stay reserved; unspent funds
return at the end of Battle.
</p>
<p>Your current unspent pledge: {g.aid.pledged} spice.</p>
{g.battle?.choamAidBlocked && (
<p>
Karama prevents using your funding in this battle. Your pledge remains
available for later battles.
</p>
)}
<label>
New unspent pledge{' '}
<input
type="number"
min={0}
max={(me.spice ?? 0) + g.aid.pledged}
value={amount}
onChange={(e) => setAmount(Number(e.target.value))}
/>
</label>
<Button
disabled={busy}
onClick={() =>
act({ type: deciding ? 'decision' : 'pledgeAid', amount })
}
>
{deciding ? 'Confirm battle funding' : 'Update battle funding'}
</Button>
{deciding && (
<Button
variant="outline"
disabled={busy}
onClick={() => act({ type: 'decision', amount: g.aid.pledged })}
>
Keep current pledge
</Button>
)}
</section>
);
}
+66
View File
@@ -0,0 +1,66 @@
'use client';
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import type { Action, GameView } from '@/game/engine';
export function ChoamCashIn({
game: g,
act,
busy,
}: {
game: GameView;
act: (a: Action) => void;
busy: boolean;
}) {
const [activation, setActivation] = useState('');
const [selected, setSelected] = useState<string[]>([]);
const cash = g.choamCashIn;
if (!cash?.karamas.length) return null;
const card = cash.karamas.find((c) => c.id === activation) ?? cash.karamas[0];
const choices = cash.cards.filter((c) => c.id !== card.id);
const cards = selected.filter((id) => choices.some((c) => c.id === id));
return (
<section className="notice">
<h3>CHOAM special Karama</h3>
<p>
Once per game, spend Karama and discard other cards for three spice
each. Committed battle cards are excluded. The activating Karama earns
no spice.
</p>
<label>
Karama to spend{' '}
<select value={card.id} onChange={(e) => setActivation(e.target.value)}>
{cash.karamas.map((c, i) => (
<option key={c.id} value={c.id}>
Karama {i + 1}
</option>
))}
</select>
</label>
{choices.map((c) => (
<label key={c.id}>
<input
type="checkbox"
checked={cards.includes(c.id)}
onChange={(e) =>
setSelected(
e.target.checked
? [...selected, c.id]
: selected.filter((id) => id !== c.id),
)
}
/>
{c.name} · 3 spice
</label>
))}
<Button
className="game-action"
disabled={busy || !!g.truthtrance || cards.length === 0}
onClick={() =>
act({ type: 'card', mode: 'special', card: card.id, cards })
}
>
Spend Karama · gain {cards.length * 3} spice
</Button>
</section>
);
}
+98
View File
@@ -0,0 +1,98 @@
'use client';
import { useState } from 'react';
import { HelpTip } from './help-tip';
import { Button } from '@/components/ui/button';
import type { Action, GameView } from '@/game/engine';
import { gameTerritories, splitLocation } from '@/game/board';
export function ChoamGamont({
game: g,
act,
busy,
}: {
game: GameView;
act: (a: Action) => void;
busy: boolean;
}) {
const [selection, setSelection] = useState('');
if (!g.choamWorthless || g.phase !== 8) return null;
const card = g.choamWorthless.cards.find((c) => c.name === 'Trip to Gamont');
const options = g.choamWorthless.gamont;
const key = (o: (typeof options)[number]) =>
`${o.target}/${o.key}/${o.elite}`;
const chosen = options.find((o) => key(o) === selection) ?? options[0];
return (
<section className="notice">
<h3>
Trip to Gamont <HelpTip topic="choamWorthless" />
</h3>
<p>
Return one other player’s force to its reserves. Choose its sector and
force type. Karama may stop the return; victory is checked after this
opportunity. A No-Field in the selected sector must reveal first; the
card is used even if no force is there to return.
</p>
{card && options.length > 0 && (
<>
<label>
Force to return{' '}
<select
value={chosen ? key(chosen) : ''}
onChange={(e) => setSelection(e.target.value)}
>
{options.map((o) => {
const loc = splitLocation(o.key),
owner = g.players.find((p) => p.id === o.target)!;
const type = o.noField
? 'No-Field — reveal, then return one if present'
: o.elite
? owner.faction === 'ixians'
? 'cyborg'
: 'elite'
: owner.faction === 'ixians'
? 'suboid'
: 'ordinary';
return (
<option key={key(o)} value={key(o)}>
{owner.name} ·{' '}
{
gameTerritories(g).find((t) => t.id === loc.territory)
?.name
}{' '}
· sector {loc.sector} · {type}
</option>
);
})}
</select>
</label>
<Button
disabled={busy || !chosen}
onClick={() =>
act({
type: 'card',
mode: 'choam',
card: card.id,
target: chosen.target,
from: chosen.key,
elite: chosen.elite,
})
}
>
Return this force
</Button>
</>
)}
{(!card || !options.length) && (
<p className="fine">No available Trip to Gamont play.</p>
)}
{g.decision?.kind === 'choamMentat' && (
<Button
variant="outline"
disabled={busy}
onClick={() => act({ type: 'decision', done: true })}
>
Finish and check victory
</Button>
)}
</section>
);
}
+131
View File
@@ -0,0 +1,131 @@
'use client';
import type { Action, GameView } from '@/game/engine';
import { Button } from '@/components/ui/button';
export function ChoamMarket({
game: g,
act,
busy,
}: {
game: GameView;
act: (action: Action) => void;
busy: boolean;
}) {
const me = g.players.find((p) => p.id === g.me)!;
const market = g.choamMarket!;
if (g.decision?.kind === 'choamTradeReply')
return (
<>
<p>
CHOAM offers{' '}
<strong>
{market.offered?.name ?? 'a card no longer available'}
</strong>
. Choose one card to return. CHOAM will confirm before either hand
changes.
</p>
{me.hand?.map((c) => (
<Button
className="game-action"
key={c.id}
disabled={busy || !market.offered}
onClick={() => act({ type: 'decision', card: c.id })}
>
Return {c.name}
</Button>
))}
<Button
className="game-action"
variant="outline"
disabled={busy}
onClick={() => act({ type: 'decision', decline: true })}
>
Decline trade
</Button>
</>
);
if (g.decision?.kind === 'choamTradeConfirm')
return (
<>
<p>
Give <strong>{market.offered?.name ?? 'an unavailable card'}</strong>{' '}
and receive{' '}
<strong>{market.returned?.name ?? 'an unavailable card'}</strong>.
Both cards move together. This uses your one trade for the turn.
</p>
<Button
className="game-action"
disabled={busy || !market.offered || !market.returned}
onClick={() => act({ type: 'decision', accept: true })}
>
Confirm exchange
</Button>
<Button
className="game-action"
variant="outline"
disabled={busy}
onClick={() => act({ type: 'decision', decline: true })}
>
Decline trade
</Button>
</>
);
return (
<>
<p>
The phase is ending. Sell a Worthless card for two spice or a surplus
exact duplicate for three. A sale reveals the card and gives the table a
Karama response.
</p>
{market.sales?.map((sale) => (
<Button
className="game-action"
key={`${sale.card}-${sale.price}`}
disabled={busy}
onClick={() =>
act({
type: 'decision',
mode: 'sell',
card: sale.card,
witness: sale.witness,
})
}
>
Sell {sale.witness ? 'surplus ' : ''}
{me.hand?.find((c) => c.id === sale.card)?.name} · {sale.price} spice
</Button>
))}
{!market.sales?.length && (
<p className="fine">No eligible sales remain this phase.</p>
)}
{market.canTrade && (
<>
<h3>Offer your ally a card</h3>
<p className="fine">
Your ally chooses a return card; you confirm the exchange. Offers
stay private to the two of you.
</p>
{me.hand?.map((c) => (
<Button
className="game-action"
variant="outline"
key={c.id}
disabled={busy}
onClick={() =>
act({ type: 'decision', mode: 'trade', card: c.id })
}
>
Offer {c.name}
</Button>
))}
</>
)}
<Button
className="game-action"
disabled={busy}
onClick={() => act({ type: 'decision', done: true })}
>
Finish this phase
</Button>
</>
);
}
+83
View File
@@ -0,0 +1,83 @@
'use client';
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { HelpTip } from './help-tip';
import type { Action, GameView } from '@/game/engine';
import { territory } from '@/game/board';
export function ChoamStorm({
game: g,
act,
busy,
}: {
game: GameView;
act: (a: Action) => void;
busy: boolean;
}) {
const [selected, setSelected] = useState('');
const decision = g.decision?.kind === 'choamStorm' ? g.decision : null;
if (!decision || decision.player !== g.me) return null;
const card = g.choamWorthless?.cards.find((c) => c.name === 'Jubba Cloak');
const choice =
decision.territories.find((t) => t.territory === selected) ??
decision.territories[0];
return (
<section className="notice">
<h3>
Jubba Cloak <HelpTip topic="choamWorthless" />
</h3>
<p>
Protect your own forces in one territory from this storm movement. Spice
and other factions’ forces remain exposed. Karama may cancel this
effect.
</p>
{decision.protected.length > 0 && (
<p>
Protected:{' '}
{decision.protected.map((t) => territory(t).name).join(', ')}.
</p>
)}
<label>
Threatened territory{' '}
<select
value={choice?.territory ?? ''}
onChange={(e) => setSelected(e.target.value)}
>
{decision.territories.map((t) => (
<option key={t.territory} value={t.territory}>
{territory(t.territory).name} · {t.amount} forces · sectors{' '}
{t.sectors.join(', ')}
</option>
))}
</select>
</label>
{card ? (
<Button
disabled={busy || !choice}
onClick={() =>
act({
type: 'card',
mode: 'choam',
card: card.id,
territory: choice.territory,
})
}
>
Protect this territory
</Button>
) : (
<p className="fine">
No available Jubba Cloak play. Continue when ready to accept the
remaining losses.
</p>
)}
<Button
variant="outline"
disabled={busy}
onClick={() => act({ type: 'decision', decline: true })}
>
Continue storm
</Button>
</section>
);
}
+93
View File
@@ -0,0 +1,93 @@
'use client';
import { useState } from 'react';
import { HelpTip } from './help-tip';
import { Button } from '@/components/ui/button';
import type { Action, GameView } from '@/game/engine';
export function ChoamWorthless({
game: g,
act,
busy,
}: {
game: GameView;
act: (a: Action) => void;
busy: boolean;
}) {
const [target, setTarget] = useState('');
const me = g.players.find((p) => p.id === g.me)!;
const reactive = g.decision?.kind === 'choamFreeRevival';
if (!g.choamWorthless || ![4, 5].includes(g.phase)) return null;
const selected = g.choamWorthless.targets.includes(target)
? target
: g.choamWorthless.targets[0];
const recipient =
g.decision?.kind === 'choamFreeRevival' ? g.decision.recipient : selected;
return (
<section className="notice">
<h3>
CHOAM Worthless powers <HelpTip topic="choamWorthless" />
</h3>
<p>
{g.phase === 4
? 'La La La prevents a player’s free force revivals for the rest of Revival. If it stops a pending request, the player can choose a paid revival.'
: 'Kulon adds one territory to your movement range this turn, including movement with ornithopters.'}{' '}
A Karama response precedes discarding the card.
</p>
{g.phase === 4 && !reactive && (
<label>
Player to deny free revival{' '}
<select
value={recipient ?? ''}
onChange={(e) => setTarget(e.target.value)}
>
{g.choamWorthless.targets.map((id) => (
<option key={id} value={id}>
{g.players.find((p) => p.id === id)?.name}
</option>
))}
</select>
</label>
)}
{reactive && (
<p>
{g.players.find((p) => p.id === recipient)?.name} is requesting free
force revival.
</p>
)}
{g.choamWorthless.cards
.filter((c) =>
g.phase === 4 ? c.name === 'La La La' : c.name === 'Kulon',
)
.map((c) => (
<Button
key={c.id}
disabled={
busy ||
(g.phase === 5 &&
(g.active !== me.id ||
(me.moved ?? 0) >= (me.movesAllowed ?? 1))) ||
(g.phase === 4 && !recipient)
}
onClick={() =>
act({
type: 'card',
mode: 'choam',
card: c.id,
target: recipient,
})
}
>
Use {c.name}
</Button>
))}
{reactive && (
<Button
variant="outline"
disabled={busy}
onClick={() => act({ type: 'decision', decline: true })}
>
Allow this free revival
</Button>
)}
</section>
);
}
+145
View File
@@ -0,0 +1,145 @@
'use client';
import { useId, useState } from 'react';
import type { Action, GameView } from '@/game/engine';
import { Button } from './ui/button';
import { CardInspector, CardRules } from './card-inspector';
export function Distrans({
game,
act,
busy,
}: {
game: GameView;
act: (action: Action) => void;
busy: boolean;
}) {
const id = useId();
const [target, setTarget] = useState('');
const [selection, setSelection] = useState('');
const distrans = game.distrans;
if (!distrans) return null;
const choice =
distrans.choices.find((candidate) => candidate.recipient === target) ??
distrans.choices.find(
(candidate) => !candidate.blocked && candidate.cards.length > 0,
) ??
distrans.choices[0];
const recipient = game.players.find(
(player) => player.id === choice?.recipient,
);
const given =
choice?.cards.find((card) => card.id === selection) ?? choice?.cards[0];
const reason =
distrans.blocked ??
choice?.blocked ??
(!choice
? 'No recipient is available.'
: !given
? 'No other card is available to transfer to this player.'
: null);
return (
<details className="my-4 min-w-0">
<summary className="min-h-11 cursor-pointer py-3">
Distrans: transfer a card
</summary>
<div className="flex min-w-0 flex-col gap-4 py-3">
<p>
Give one other card from your hand to another player with room, then
discard Distrans. No spice is paid. The transfer happens immediately,
without a recipient confirmation or Karama response.
</p>
<CardInspector card={distrans.card} />
{choice && (
<>
<label htmlFor={`${id}-target`}>Recipient</label>
<select
id={`${id}-target`}
className="min-h-11 w-full min-w-0"
value={choice.recipient}
disabled={busy}
onChange={(event) => {
setTarget(event.target.value);
setSelection('');
}}
>
{distrans.choices.map((candidate) => (
<option key={candidate.recipient} value={candidate.recipient}>
{game.players.find(
(player) => player.id === candidate.recipient,
)?.name ?? 'Player'}
{candidate.blocked ? ' — unavailable' : ''}
</option>
))}
</select>
{choice.cards.length > 0 && (
<>
<label htmlFor={`${id}-given`}>Your card to transfer</label>
<select
id={`${id}-given`}
className="min-h-11 w-full min-w-0"
value={given?.id ?? ''}
disabled={busy}
onChange={(event) => setSelection(event.target.value)}
>
{choice.cards.map((card) => (
<option key={card.id} value={card.id}>
{card.name}
</option>
))}
</select>
{given && (
<div className="flex min-w-0 flex-col gap-3">
<CardRules card={given} />
<CardInspector card={given} />
</div>
)}
</>
)}
</>
)}
{reason && (
<p id={`${id}-reason`} className="notice">
{reason}
</p>
)}
<Button
className="min-h-11 whitespace-normal motion-reduce:transition-none"
disabled={busy || !!reason}
aria-describedby={reason ? `${id}-reason` : undefined}
onClick={() => {
if (!busy && !reason && choice && given)
act({
type: 'card',
card: distrans.card.id,
target: choice.recipient,
give: given.id,
});
}}
>
Give {given?.name ?? 'a card'} to {recipient?.name ?? 'the recipient'}{' '}
and discard Distrans
</Button>
{!!choice?.unavailable.length && (
<div
className="flex min-w-0 flex-col gap-3"
aria-labelledby={`${id}-unavailable`}
>
<h4 id={`${id}-unavailable`}>
Your cards unavailable for this transfer
</h4>
<ul className="flex list-none flex-col gap-4 p-0">
{choice.unavailable.map(({ card, reason: blocked }) => (
<li key={card.id} className="flex min-w-0 flex-col gap-2">
<strong>{card.name}</strong>
<p>{blocked}</p>
<CardInspector card={card} />
</li>
))}
</ul>
</div>
)}
</div>
</details>
);
}
+241
View File
@@ -0,0 +1,241 @@
'use client';
import { useId, useState } from 'react';
import Image from 'next/image';
import { Dialog as DialogPrimitive } from '@base-ui/react/dialog';
import { Eye, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogClose,
DialogDescription,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import type { GameView } from '@/game/engine';
import { DUKE_VIDAL_RULES } from '@/game/duke-vidal';
import { specialLeaderArt } from '@/game/special-leader-art';
/** Original Ecaz medallion; identity and strength have equivalent HTML text. */
function VidalDisc({ large = false }: { large?: boolean }) {
const gradient = useId();
return (
<svg
viewBox="0 0 160 160"
className={large ? 'mx-auto my-6 size-48 max-w-full' : 'size-20 shrink-0'}
aria-hidden="true"
focusable="false"
>
<defs>
<radialGradient id={gradient} cx="35%" cy="25%" r="80%">
<stop offset="0" stopColor="#795577" />
<stop offset="1" stopColor="#251b31" />
</radialGradient>
</defs>
<circle cx="80" cy="83" r="74" fill="#100f15" />
<circle
cx="80"
cy="79"
r="73"
fill={`url(#${gradient})`}
stroke="#d6bc80"
strokeWidth="2"
/>
<circle
cx="80"
cy="79"
r="65"
fill="none"
stroke="#c9b385"
strokeOpacity="0.65"
/>
<path
d="M 80 18 L 86 27 L 80 36 L 74 27 Z M 32 79 Q 32 39 63 30 M 128 79 Q 128 39 97 30 M 32 91 Q 41 125 64 133 M 128 91 Q 119 125 96 133"
fill="none"
stroke="#ddc892"
strokeWidth="2"
/>
<path
d="M 55 48 L 67 59 L 80 43 L 93 59 L 105 48 L 98 69 H 62 Z"
fill="none"
stroke="#f0dca7"
strokeWidth="2"
/>
<text
x="80"
y="121"
textAnchor="middle"
fill="#fff1c9"
fontFamily="Georgia, serif"
fontSize="56"
>
6
</text>
</svg>
);
}
/** Uses only the shared disc identity supplied by the public projection. */
function VidalPortrait({
id,
name,
large = false,
}: {
id?: string;
name: string;
large?: boolean;
}) {
const [failedSource, setFailedSource] = useState<string | null>(null);
const art = specialLeaderArt(id, name);
if (!art || failedSource === art.src) return <VidalDisc large={large} />;
return (
<span
className={`relative block shrink-0 ${large ? 'mx-auto my-6 size-48 max-w-full' : 'size-20'}`}
>
<span className="absolute inset-0 overflow-hidden rounded-full border-2 border-[#d6bc80] bg-[#251b31]">
<Image
src={art.src}
alt=""
width={1254}
height={1254}
sizes={large ? '192px' : '80px'}
loading="lazy"
decoding="async"
onError={() => setFailedSource(art.src)}
className="h-full w-full object-cover"
style={{ objectPosition: art.objectPosition }}
/>
<span
aria-hidden="true"
className="pointer-events-none absolute inset-1 rounded-full border border-[#e6ce99]/40"
/>
</span>
</span>
);
}
/** Read-only shared component. Custody comes exclusively from the public projection. */
export function DukeVidal({ game }: { game: GameView }) {
const duke = game.dukeVidal;
if (!duke) return null;
const controller = duke.controller
? game.players.find((player) => player.id === duke.controller)
: undefined;
const status = duke.leader.dead
? 'In the Tanks'
: duke.controller
? `Controlled by ${controller?.name ?? 'another player'}`
: 'Set aside';
return (
<section
aria-label="Duke Prad Vidal shared leader"
className="min-w-0 rounded-xl border border-[#75617a] bg-[#241f29] p-4 text-[#eee7de]"
>
<div className="flex flex-wrap items-center gap-4">
<VidalPortrait id={duke.leader.id} name={duke.leader.name} />
<div className="min-w-0 flex-1 basis-40">
<p className="m-0 text-xs font-semibold tracking-[0.16em] text-[#d5b888] uppercase">
Ecaz · shared leader · strength 6
</p>
<h3 className="mt-1 mb-2 break-words font-serif text-xl text-[#fff0d4]">
Duke Prad Vidal
</h3>
<p className="m-0 break-words text-sm">{status}</p>
<p className="mt-1 mb-0 text-sm text-[#cbbbcc]">No Traitor Card</p>
</div>
<Dialog>
<DialogTrigger
render={
<Button
variant="outline"
className="min-h-11 min-w-11 motion-reduce:transition-none"
/>
}
aria-label="Inspect Duke Prad Vidal"
>
<Eye aria-hidden="true" /> Inspect Duke
</DialogTrigger>
<DialogPortal>
<DialogOverlay className="bg-black/75 motion-reduce:animate-none motion-reduce:transition-none" />
<DialogPrimitive.Popup className="fixed top-1/2 left-1/2 z-50 flex max-h-[calc(100dvh-1rem)] w-[calc(100%-1rem)] max-w-3xl -translate-x-1/2 -translate-y-1/2 flex-col overflow-hidden rounded-2xl border border-[#9c815c] bg-[#19161d] text-[#eee7de] shadow-2xl outline-none motion-reduce:animate-none motion-reduce:transition-none">
<div className="flex shrink-0 items-center justify-between gap-3 border-b border-[#4c3b51] px-4 py-3">
<span className="text-xs font-semibold tracking-[0.16em] text-[#dec18b] uppercase">
Shared leader inspection
</span>
<DialogClose
render={
<Button
variant="outline"
className="min-h-11 min-w-11 motion-reduce:transition-none"
/>
}
>
<X aria-hidden="true" /> Close
</DialogClose>
</div>
<div className="min-h-0 overflow-y-auto overscroll-contain p-4 sm:p-6">
<div className="grid items-start gap-6 md:grid-cols-[minmax(0,0.8fr)_minmax(0,1.2fr)]">
<article className="min-w-0 rounded-xl border border-[#a08967] bg-gradient-to-br from-[#4a334e] to-[#221d28] p-5">
<p className="m-0 text-sm tracking-wider text-[#e5cea6] uppercase">
House Ecaz
</p>
<VidalPortrait
id={duke.leader.id}
name={duke.leader.name}
large
/>
<DialogTitle className="break-words font-serif text-3xl leading-tight text-[#fff1d5]">
{DUKE_VIDAL_RULES.name}
</DialogTitle>
<dl className="my-5 space-y-3 border-y border-[#a08967]/50 py-4">
<div>
<dt className="text-sm text-[#d6c2cc]">
Leader strength
</dt>
<dd className="m-0 font-serif text-4xl">6</dd>
</div>
<div>
<dt className="text-sm text-[#d6c2cc]">
Current state
</dt>
<dd className="m-0 break-words text-base">{status}</dd>
</div>
</dl>
<p className="mb-0 text-sm leading-6">
One shared disc. No Traitor Card.
</p>
</article>
<div className="min-w-0 space-y-5">
<DialogDescription className="m-0 text-base leading-7 text-[#ece0ce]">
{DUKE_VIDAL_RULES.summary}
</DialogDescription>
{DUKE_VIDAL_RULES.gameplay.map((paragraph) => (
<p
key={paragraph}
className="m-0 text-base leading-7 text-[#ddd4df]"
>
{paragraph}
</p>
))}
<p className="m-0 rounded-lg border border-[#675071] bg-[#2c2232] p-4 text-sm leading-6 text-[#e5d3df]">
Current support covers Moritani acquisition, direct Ecaz
Ambassador acquisition for Ecaz, battle use and death.
Unused Ecaz custody survives the turn; unused Moritani
custody expires. Ecaz alliance and loan alternatives,
reacquisition while already controlled, revival, and
captured or ghola custody remain unfinished. Full Ecaz and
Moritani games are unavailable. Inspection does not select
a battle leader.
</p>
</div>
</div>
</div>
</DialogPrimitive.Popup>
</DialogPortal>
</Dialog>
</div>
</section>
);
}
+340
View File
@@ -0,0 +1,340 @@
'use client';
import { useId, useState } from 'react';
import { Dialog as DialogPrimitive } from '@base-ui/react/dialog';
import { Eye, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogClose,
DialogDescription,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import type { Action, GameView } from '@/game/engine';
import type { AmbassadorToken } from '@/game/ecaz-ambassadors';
import {
AMBASSADOR_COMMON_GUIDANCE,
AMBASSADOR_REFERENCE,
} from '@/game/ambassador-reference';
import { TERRITORIES } from '@/game/board';
import { faction } from '@/game/catalog';
export const AMBASSADOR_COVERAGE =
'Development coverage: Ambassador placement, Ecaz’s direct Duke acquisition, Emperor, Atreides, Harkonnen, CHOAM, Ixian, Richese, Fremen and Guild entry effects are supported, plus Bene Gesserit copies of those eight ordinary effects. Ecaz alliance and loan choices, other effects and some competing arrival reactions remain unfinished; full Ecaz starts remain disabled.';
function tokenLocation(token: AmbassadorToken) {
if (token.zone === 'placed')
return `Placed in ${TERRITORIES.find((t) => t.id === token.location)?.name ?? 'a stronghold'}`;
return {
pool: 'Unused pool',
supply: 'Available supply',
used: 'Triggered · set aside',
removed: 'Permanently removed',
}[token.zone];
}
function AmbassadorArt({
token,
large = false,
}: {
token: AmbassadorToken;
large?: boolean;
}) {
const identity = faction(token.effect);
return (
<svg
viewBox="0 0 120 120"
className={large ? 'mx-auto my-5 size-40 max-w-full' : 'size-14 shrink-0'}
aria-hidden="true"
focusable="false"
>
<circle
cx="60"
cy="60"
r="55"
fill="#182725"
stroke={identity.color}
strokeWidth="4"
/>
<circle cx="60" cy="60" r="45" fill="none" stroke="#dac594" />
<path
d="M60 9l5 10-5 10-5-10zM60 91l5 10-5 10-5-10zM9 60l10-5 10 5-10 5zM91 60l10-5 10 5-10 5z"
fill="#dac594"
/>
<text
x="60"
y="75"
textAnchor="middle"
fill="#f2dfb9"
fontFamily="Georgia,serif"
fontSize="40"
>
{identity.sigil}
</text>
</svg>
);
}
/** Inspection accepts one already-public token; no private deck or game state is consulted. */
export function AmbassadorInspector({ token }: { token: AmbassadorToken }) {
const guide = AMBASSADOR_REFERENCE[token.effect];
const status = tokenLocation(token);
return (
<Dialog>
<DialogTrigger
render={
<Button
variant="outline"
className="h-auto min-h-11 w-full min-w-0 justify-start gap-3 whitespace-normal px-3 py-3 text-left motion-reduce:transition-none"
/>
}
aria-label={`Inspect ${guide.name} Ambassador: ${status}`}
>
<AmbassadorArt token={token} />
<span className="min-w-0 flex-1">
<span className="block font-serif text-base">{guide.name}</span>
<span className="block text-xs leading-5">{status}</span>
</span>
<Eye aria-hidden="true" className="size-4 shrink-0" />
</DialogTrigger>
<DialogPortal>
<DialogOverlay className="bg-black/75 motion-reduce:animate-none" />
<DialogPrimitive.Popup className="fixed left-1/2 top-1/2 z-50 max-h-[calc(100dvh-2rem)] w-[calc(100%-2rem)] max-w-xl -translate-x-1/2 -translate-y-1/2 overflow-y-auto rounded-2xl border border-[#8c815c] bg-[#17231f] p-5 text-[#e8e9dc] shadow-2xl outline-none sm:p-7">
<div className="flex items-start justify-between gap-3">
<div>
<DialogTitle className="m-0 font-serif text-2xl text-[#efd9a8]">
{guide.name} Ambassador
</DialogTitle>
<DialogDescription className="mt-2 text-sm leading-6">
Gameplay guide · {status}
</DialogDescription>
</div>
<DialogClose
render={
<Button
variant="ghost"
className="min-h-11 min-w-11 shrink-0 motion-reduce:transition-none"
/>
}
aria-label="Close Ambassador inspection"
>
<X aria-hidden="true" />
</DialogClose>
</div>
<AmbassadorArt token={token} large />
<p className="text-base leading-7">{guide.gameplay}</p>
<h3 className="mt-6 font-serif text-xl text-[#efd9a8]">
Using Ambassadors
</h3>
{AMBASSADOR_COMMON_GUIDANCE.map((paragraph) => (
<p key={paragraph} className="text-sm leading-6">
{paragraph}
</p>
))}
<p className="mt-5 border-t border-[#68705a] pt-4 text-sm leading-6">
{AMBASSADOR_COVERAGE}
</p>
<p className="text-xs leading-5">
Original illustration and gameplay explanation.
</p>
</DialogPrimitive.Popup>
</DialogPortal>
</Dialog>
);
}
export function AmbassadorSupply({ game }: { game: GameView }) {
if (!game.ambassadors) return null;
const supplyCount = game.ambassadors.tokens.filter(
(token) => token.zone === 'supply',
).length;
const placedCount = game.ambassadors.tokens.filter(
(token) => token.zone === 'placed',
).length;
const zones: AmbassadorToken['zone'][] = [
'supply',
'placed',
'used',
'removed',
'pool',
];
const names = {
supply: 'Available supply',
placed: 'On the board',
used: 'Triggered this group',
removed: 'Permanently removed',
pool: 'Unused pool',
};
return (
<details className="notice min-w-0">
<summary className="min-h-11 cursor-pointer py-2 leading-7 focus-visible:outline-2 focus-visible:outline-offset-2">
<span className="font-serif text-xl">Ecaz Ambassadors</span>{' '}
<span className="text-sm">
· {supplyCount} in supply · {placedCount} placed · Inspect tokens
</span>
</summary>
<p className="text-sm leading-6">
Inspect any token to read its effect and current location.
</p>
{zones.map((zone) => {
const tokens = game.ambassadors!.tokens.filter(
(token) => token.zone === zone,
);
if (!tokens.length) return null;
return (
<div key={zone} className="mt-4">
<h4 className="mb-2 text-sm font-semibold">
{names[zone]} · {tokens.length}
</h4>
<div className="grid min-w-0 gap-2 sm:grid-cols-2">
{tokens.map((token) => (
<AmbassadorInspector token={token} key={token.id} />
))}
</div>
</div>
);
})}
<p className="mt-4 text-sm leading-6">{AMBASSADOR_COVERAGE}</p>
</details>
);
}
export function EcazPlacement({
game,
act,
busy,
}: {
game: GameView;
act: (action: Action) => void;
busy: boolean;
}) {
const id = useId();
const [selectedToken, setSelectedToken] = useState('');
const [selectedTerritory, setSelectedTerritory] = useState('');
const me = game.players.find((player) => player.id === game.me);
const state = game.ambassadors;
if (
!state ||
me?.faction !== 'ecaz' ||
game.decision?.kind !== 'ecazPlacement' ||
game.decision.player !== me.id
)
return null;
const supply = state.tokens.filter((token) => token.zone === 'supply');
const destinations = TERRITORIES.filter(
(territory) =>
territory.type === 'stronghold' &&
!territory.sectors.includes(game.storm) &&
!state.tokens.some(
(token) => token.zone === 'placed' && token.location === territory.id,
),
);
const token = supply.find((candidate) => candidate.id === selectedToken);
const destination = destinations.find(
(candidate) => candidate.id === selectedTerritory,
);
const affordable = (me.spice ?? 0) >= state.nextCost;
const disabled =
busy || state.blocked || !token || !destination || !affordable;
return (
<section
className="notice flex min-w-0 flex-col gap-3"
aria-label="Place an Ecaz Ambassador"
>
<h3 className="m-0 font-serif text-xl">Place an Ambassador</h3>
<p className="m-0 text-sm leading-6">
Next placement: <strong>{state.nextCost} spice</strong> · Your available
spice: {me.spice ?? 0}. Each placement this turn increases the next cost
by one.
</p>
<label htmlFor={`${id}-token`}>
Ambassador
<select
id={`${id}-token`}
className="min-h-11 w-full"
value={token?.id ?? ''}
disabled={busy || state.blocked}
onChange={(event) => setSelectedToken(event.target.value)}
>
<option value="">Choose from your supply</option>
{supply.map((candidate) => (
<option key={candidate.id} value={candidate.id}>
{AMBASSADOR_REFERENCE[candidate.effect].name}
</option>
))}
</select>
</label>
{token && (
<>
<p className="m-0 text-sm leading-6">
{AMBASSADOR_REFERENCE[token.effect].gameplay}
</p>
<AmbassadorInspector token={token} />
</>
)}
<label htmlFor={`${id}-territory`}>
Stronghold
<select
id={`${id}-territory`}
className="min-h-11 w-full"
value={destination?.id ?? ''}
disabled={busy || state.blocked}
onChange={(event) => setSelectedTerritory(event.target.value)}
>
<option value="">Choose a stronghold outside the storm</option>
{destinations.map((candidate) => (
<option key={candidate.id} value={candidate.id}>
{candidate.name}
</option>
))}
</select>
</label>
{state.blocked && (
<p className="m-0 text-sm leading-6">
Ambassador placement is blocked for this turn.
</p>
)}
{!supply.length && (
<p className="m-0 text-sm leading-6">
No Ambassador is available in your supply.
</p>
)}
{!destinations.length && (
<p className="m-0 text-sm leading-6">
No ordinary stronghold is currently available for an Ambassador.
</p>
)}
{!affordable && (
<p className="m-0 text-sm leading-6">
You need {state.nextCost - (me.spice ?? 0)} more spice for this
placement.
</p>
)}
<div className="flex flex-wrap gap-2">
<Button
className="min-h-11 whitespace-normal motion-reduce:transition-none"
disabled={disabled}
onClick={() => {
if (!disabled && token && destination)
act({
type: 'decision',
token: token.id,
territory: destination.id,
});
}}
>
Place Ambassador · {state.nextCost} spice
</Button>
<Button
variant="outline"
className="min-h-11 whitespace-normal motion-reduce:transition-none"
disabled={busy}
onClick={() => act({ type: 'decision', decline: true })}
>
Finish Ambassador placement
</Button>
</div>
<p className="m-0 text-sm leading-6">{AMBASSADOR_COVERAGE}</p>
</section>
);
}
+367
View File
@@ -0,0 +1,367 @@
'use client';
import { useId, useState } from 'react';
import type { Action, GameView } from '@/game/engine';
import { AMBASSADOR_REFERENCE } from '@/game/ambassador-reference';
import { territory } from '@/game/board';
import { faction } from '@/game/catalog';
import { leaderStrengthLabel } from '@/game/cards';
import { CHEAP_HERO_TRAITOR } from '@/game/traitors';
import { Button } from './ui/button';
import { CardInspector, CardRules } from './card-inspector';
import { LeaderInspector } from './leader-inspector';
import { AmbassadorInspector, AMBASSADOR_COVERAGE } from './ecaz-ambassadors';
import { AmbassadorMovement } from './ambassador-movement';
import { AmbassadorShipment } from './ambassador-shipment';
const buttonClass = 'min-h-11 whitespace-normal motion-reduce:transition-none';
export function EcazEntry({
game,
act,
busy,
}: {
game: GameView;
act: (action: Action) => void;
busy: boolean;
}) {
const id = useId();
const [selected, setSelected] = useState<string[]>([]);
const entry = game.ambassadorEntry;
if (
!entry ||
game.decision?.kind !== 'ecazAmbassador' ||
game.decision.player !== game.me ||
!['offer', 'copy', 'cards', 'move', 'ship'].includes(entry.stage)
)
return null;
const me = game.players.find((p) => p.id === game.me)!;
const guide = AMBASSADOR_REFERENCE[entry.effect];
const token = game.ambassadors?.tokens.find((t) => t.effect === entry.effect);
const send = (fields: Omit<Action, 'type'>) => {
if (!busy) act({ type: 'decision', event: entry.event, ...fields });
};
const choices = entry.cards.flatMap((option) => {
const card = me.hand?.find((card) => card.id === option.card);
return card ? [{ card, blocked: option.blocked }] : [];
});
const chosen = choices.filter(
(choice) => !choice.blocked && selected.includes(choice.card.id),
);
const legalSelection = entry.effect === 'choam' || chosen.length === 1;
const dukeBlock =
entry.dukeAcquisition?.blocked ??
(entry.dukeAcquisition
? null
: 'Duke acquisition is unavailable for this opportunity.');
const duke = game.dukeVidal?.leader;
return (
<section
className="notice flex min-w-0 flex-col gap-4"
aria-label="Ecaz Ambassador entry decision"
>
<h3 className="m-0 font-serif text-xl">
{guide.name} Ambassador · {territory(entry.territory).name}
</h3>
<p className="m-0 text-sm leading-6">
{game.players.find((p) => p.id === entry.entrant)?.name} entered this
stronghold. {guide.gameplay}
</p>
{token && <AmbassadorInspector token={token} />}
{entry.stage === 'offer' ? (
<>
{entry.effect === 'ecaz' ? (
<>
<p className="m-0 text-sm leading-6">
Acquire Duke Vidal for Ecaz. The Ambassador returns to your
supply. Ecaz keeps Duke until battle use or until Moritani takes
him.
</p>
{duke && (
<LeaderInspector
identity={{
id: duke.id,
name: duke.name,
factionName: faction(duke.faction).name,
strength: leaderStrengthLabel(duke),
}}
/>
)}
<Button
className={buttonClass}
disabled={busy || !!dukeBlock}
aria-describedby={dukeBlock ? `${id}-duke-block` : undefined}
onClick={() => {
if (!dukeBlock)
send({
trigger: true,
beneficiary: entry.owner,
choice: 'duke',
});
}}
>
Acquire Duke Vidal for Ecaz
</Button>
{dukeBlock && (
<p id={`${id}-duke-block`} className="m-0 text-sm leading-6">
{dukeBlock}
</p>
)}
<p className="m-0 text-sm leading-6">
Alliance proposals, acquisition for an ally and Duke loans
through this Ambassador remain unfinished.
</p>
</>
) : (
<>
<p className="m-0 text-sm leading-6">
Trigger the Ambassador for yourself or an eligible ally, or
leave it in place. Triggering commits the token before the
beneficiary resolves its effect.
</p>
{entry.effect === 'richese' && (
<p className="m-0 text-sm leading-6">
Triggering automatically spends 3 spice from the selected
beneficiary and draws a card if the purchase can be completed.
Otherwise no spice is spent and no card is drawn. The token is
used either way; there is no later purchase choice.
</p>
)}
{entry.beneficiaries.map((option) => (
<div key={option.player} className="flex flex-col gap-2">
<Button
className={buttonClass}
disabled={busy || !!option.blocked}
onClick={() =>
send({ trigger: true, beneficiary: option.player })
}
>
Trigger for{' '}
{option.player === game.me
? 'yourself'
: game.players.find((p) => p.id === option.player)?.name}
</Button>
{option.blocked && (
<p className="m-0 text-sm leading-6">{option.blocked}</p>
)}
</div>
))}
</>
)}
<Button
variant="outline"
className={buttonClass}
disabled={busy}
onClick={() => send({ decline: true })}
>
Leave Ambassador in place
</Button>
</>
) : entry.stage === 'copy' ? (
<>
<p className="m-0 text-sm leading-6">
Choose an effect outside the original supply for this group. The
Bene Gesserit token has already been permanently removed.
</p>
{entry.copies.map((option) => {
const copied = AMBASSADOR_REFERENCE[option.effect];
const face = game.ambassadors?.tokens.find(
(t) => t.effect === option.effect,
);
return (
<article
key={option.effect}
className="flex min-w-0 flex-col gap-2 rounded-lg border border-[#485542] p-3"
>
<h4 className="m-0 font-serif text-lg">{copied.name}</h4>
<p className="m-0 text-sm leading-6">{copied.gameplay}</p>
{option.effect === 'richese' && (
<p className="m-0 text-sm leading-6">
This automatically spends 3 of your spice and draws a card
if the purchase can be completed. Otherwise no spice is
spent and no card is drawn. The Bene Gesserit token remains
removed.
</p>
)}
{face && <AmbassadorInspector token={face} />}
{option.blocked && (
<p className="m-0 text-sm leading-6">{option.blocked}</p>
)}
<Button
className={buttonClass}
disabled={busy || !!option.blocked}
onClick={() => send({ effect: option.effect })}
>
Use {copied.name} effect
</Button>
</article>
);
})}
</>
) : entry.stage === 'ship' ? (
<AmbassadorShipment
key={entry.event}
game={game}
act={act}
busy={busy}
/>
) : entry.stage === 'move' ? (
<AmbassadorMovement
key={entry.event}
game={game}
act={act}
busy={busy}
/>
) : (
<>
<p className="m-0 text-sm leading-6">
{entry.effect === 'choam'
? 'Choose any number of available cards, including none. Each discarded card earns three spice.'
: 'Choose exactly one available card to discard and draw its replacement.'}
</p>
<fieldset
className="flex min-w-0 flex-col gap-3 border-0 p-0"
disabled={busy}
>
<legend className="mb-2 text-sm font-semibold">
Your cards available for this effect
</legend>
{choices.map(({ card, blocked }) => (
<article
key={card.id}
className="flex min-w-0 flex-col gap-2 rounded-lg border border-[#485542] p-3"
>
<label
htmlFor={`${id}-${card.id}`}
className="flex min-h-11 cursor-pointer items-center gap-3 text-base"
>
<input
id={`${id}-${card.id}`}
type={entry.effect === 'ixians' ? 'radio' : 'checkbox'}
name={`${id}-discard`}
checked={!blocked && selected.includes(card.id)}
disabled={busy || !!blocked}
onChange={(event) =>
setSelected((current) =>
entry.effect === 'ixians'
? [card.id]
: event.target.checked
? [...current, card.id]
: current.filter((value) => value !== card.id),
)
}
/>
{card.name}
</label>
<CardRules card={card} />
<CardInspector card={card} />
{blocked && <p className="m-0 text-sm leading-6">{blocked}</p>}
</article>
))}
</fieldset>
<Button
className={buttonClass}
disabled={busy || !legalSelection}
onClick={() => {
if (legalSelection)
send({ cards: chosen.map((choice) => choice.card.id) });
}}
>
{entry.effect === 'choam'
? `Discard ${chosen.length} cards · gain ${chosen.length * 3} spice`
: 'Discard selected card and draw replacement'}
</Button>
</>
)}
<p className="m-0 text-xs leading-5">{AMBASSADOR_COVERAGE}</p>
</section>
);
}
/** Only recipient-projected snapshots are read. Local disclosures never send a game action. */
export function AmbassadorInsights({ game }: { game: GameView }) {
const snapshots = game.ambassadorInsights.filter(
(entry) => entry.viewer === game.me,
);
if (!snapshots.length) return null;
return (
<section
className="notice flex min-w-0 flex-col gap-3"
aria-label="Your private Ambassador inspection history"
>
<h3 className="m-0 font-serif text-xl">Private Ambassador inspections</h3>
<p className="m-0 text-sm leading-6">
These are snapshots of what you inspected at that moment, not a live
view of another hand. Only you receive them. Opening or closing an
inspection does not take a game action.
</p>
{snapshots.map((snapshot, index) => {
const target = game.players.find((p) => p.id === snapshot.target);
const leader = game.allLeaders.find((l) => l.id === snapshot.traitor);
const cheapHero = snapshot.traitor === CHEAP_HERO_TRAITOR;
return (
<details
key={snapshot.event}
open={index === snapshots.length - 1}
className="min-w-0 rounded-lg border border-[#485542] p-3"
>
<summary className="min-h-11 cursor-pointer py-2 text-base focus-visible:outline-2 focus-visible:outline-offset-2">
Turn {snapshot.turn} · {target?.name ?? 'Player'} ·{' '}
{snapshot.effect === 'atreides'
? 'Treachery hand'
: target?.faction === 'tleilaxu'
? 'Face Dancer identity'
: 'Traitor identity'}
</summary>
{snapshot.effect === 'atreides' ? (
<div className="grid min-w-0 gap-3 pt-3 sm:grid-cols-2">
{!snapshot.cards.length && (
<p className="text-sm leading-6">
The inspected hand was empty.
</p>
)}
{snapshot.cards.map((card) => (
<article
key={card.id}
className="flex min-w-0 flex-col gap-2 rounded-lg border border-[#485542] p-3"
>
<h4 className="m-0 font-serif text-lg">{card.name}</h4>
<CardRules card={card} />
<CardInspector card={card} />
</article>
))}
</div>
) : cheapHero || leader ? (
<div className="pt-3">
<LeaderInspector
kind={
target?.faction === 'tleilaxu' ? 'faceDancer' : 'traitor'
}
identity={
cheapHero
? {
name: 'Cheap Hero / Heroine',
factionName: 'Any faction',
cheapHero: true,
strength: 0,
}
: {
id: leader!.id,
name: leader!.name,
factionName: faction(leader!.faction).name,
strength: leaderStrengthLabel(leader!),
}
}
/>
</div>
) : (
<p className="text-sm leading-6">
Recorded identity: {snapshot.traitor ?? 'None'}
</p>
)}
</details>
);
})}
</section>
);
}
+149
View File
@@ -0,0 +1,149 @@
'use client';
import { useId, useState } from 'react';
import type { AllocationState } from '@/game/ecaz-spice-allocation';
import { territory } from '@/game/board';
import { Button } from './ui/button';
import { Input } from './ui/input';
const buttonClass = 'min-h-11 whitespace-normal motion-reduce:transition-none';
/** Parent key: event + allocation.index + allocation.player. A new lot or
* negotiating turn starts with a fresh draft rather than an old proposal. */
export function EcazSpice({
event,
allocation,
me,
players,
send,
busy,
}: {
event: string;
allocation: AllocationState;
me: string;
players: readonly { id: string; name: string }[];
send: (action: { type: string; [key: string]: unknown }) => void;
busy: boolean;
}) {
const id = useId();
const lot = allocation.lots[allocation.index];
const [draft, setDraft] = useState(() =>
String(Math.floor((lot?.amount ?? 0) / 2)),
);
if (!lot || allocation.player !== me) return null;
const name = (player: string) =>
players.find((p) => p.id === player)?.name ?? player;
const ecazName = name(lot.ecaz);
const allyName = name(lot.ally);
const ecazShare = Number(draft);
const valid =
draft.trim() !== '' &&
Number.isSafeInteger(ecazShare) &&
ecazShare >= 0 &&
ecazShare <= lot.amount;
const equalEcaz = Math.floor(lot.amount / 2);
const equalAlly = lot.amount - equalEcaz;
const offer = allocation.offer;
return (
<section
className="notice flex min-w-0 flex-col gap-4"
aria-labelledby={`${id}-title`}
>
<h3 id={`${id}-title`} className="m-0 font-serif text-xl">
Share spice from {territory(lot.territory).name}
</h3>
<p id={`${id}-pool`} className="m-0 text-sm leading-6">
Divide this shared pool of <strong>{lot.amount} spice</strong> between{' '}
{ecazName} (Ecaz{me === lot.ecaz ? ', you' : ''}) and {allyName} (ally
{me === lot.ally ? ', you' : ''}). This choice applies only to the
shared spice collected here.
</p>
{offer && (
<output className="m-0 text-sm leading-6">
{name(offer.by)} proposes {offer.ecazShare} spice for {ecazName} and{' '}
{lot.amount - offer.ecazShare} for {allyName}. Accept this split or
propose another.
</output>
)}
<form
className="flex min-w-0 flex-col gap-3"
onSubmit={(e) => {
e.preventDefault();
if (busy || !valid) return;
send({
type: 'decision',
event,
allocation: { kind: 'propose', ecazShare },
});
}}
>
<label className="flex flex-col gap-2" htmlFor={`${id}-share`}>
Ecaz share · {ecazName}
<Input
id={`${id}-share`}
className="min-h-11"
type="number"
min={0}
max={lot.amount}
step={1}
value={draft}
disabled={busy}
aria-invalid={!valid}
aria-describedby={`${id}-shares`}
onChange={(e) => setDraft(e.target.value)}
/>
</label>
<p
id={`${id}-shares`}
className="m-0 text-sm leading-6"
aria-live="polite"
>
{valid
? `Proposed split: ${ecazShare} for ${ecazName}, ${lot.amount - ecazShare} for ${allyName}.`
: `Enter a whole number from 0 to ${lot.amount}.`}
</p>
<div className="grid gap-2 sm:grid-cols-2">
<Button
type="submit"
className={buttonClass}
disabled={busy || !valid}
>
{offer ? 'Send counterproposal' : 'Propose split'}
</Button>
{offer && (
<Button
type="button"
className={buttonClass}
variant="outline"
disabled={busy || offer.by === me}
onClick={() =>
send({
type: 'decision',
event,
allocation: { kind: 'accept' },
})
}
>
Accept proposed split
</Button>
)}
</div>
</form>
<p className="m-0 text-sm leading-6">
Equal fallback: {equalEcaz} spice for {ecazName}, {equalAlly} for{' '}
{allyName}. The ally receives any odd remainder.
</p>
<Button
type="button"
className={buttonClass}
variant="outline"
disabled={busy}
onClick={() =>
send({ type: 'decision', event, allocation: { kind: 'equal' } })
}
>
Use equal split
</Button>
</section>
);
}
+29
View File
@@ -0,0 +1,29 @@
'use client';
import { useId } from 'react';
import { Input } from '@/components/ui/input';
export function EliteCount({
value,
onChange,
max,
label = 'Elite forces included',
}: {
value: number;
onChange: (value: number) => void;
max: number;
label?: string;
}) {
const id = useId();
return (
<label htmlFor={id}>
{label}
<Input
id={id}
type="number"
min={0}
max={max}
value={value}
onChange={(e) => onChange(Number(e.target.value))}
/>
</label>
);
}
+111
View File
@@ -0,0 +1,111 @@
'use client';
import { useState } from 'react';
import { territory, splitLocation } from '@/game/board';
import type { Action, GameView } from '@/game/engine';
import { Button } from './ui/button';
import { Input } from './ui/input';
export function FaceDanceDecision({
game: g,
act,
busy,
}: {
game: GameView;
act: (a: Action) => void;
busy: boolean;
}) {
const d = g.decision;
const me = g.players.find((p) => p.id === g.me)!;
const maximum =
d?.kind === 'faceDance'
? Object.entries(g.players.find((p) => p.id === d.winner)!.forces)
.filter(([key]) => splitLocation(key).territory === d.territory)
.reduce((sum, [, count]) => sum + count, 0)
: 0;
const [sources, setSources] = useState<Record<string, number>>({
reserves: Math.min(me.reserves, maximum),
});
const [sector, setSector] = useState(
d?.kind === 'faceDance' ? territory(d.territory).sectors[0] : 0,
);
if (d?.kind !== 'faceDance') return null;
const held = me.faceDancers?.some(
(c) => !c.revealed && c.leader === d.identity,
);
const total = Object.values(sources).reduce((sum, n) => sum + n, 0);
return (
<>
<h2>Face Dancer opportunity</h2>
<p>
{g.players.find((p) => p.id === d.winner)?.name} won in{' '}
{territory(d.territory).name}. Their battle rewards remain theirs.
</p>
{held ? (
<>
<p>
Return the winner’s {maximum} remaining forces to reserves. Replace
up to {maximum} with your forces. Their leader goes to the tanks
without a spice bounty.
</p>
{Object.entries({ reserves: me.reserves, ...me.forces }).map(
([key, available]) => (
<label key={key} htmlFor={`face-source-${key}`}>
{key === 'reserves'
? 'Reserves'
: `${territory(splitLocation(key).territory).name} · sector ${splitLocation(key).sector}`}{' '}
({available} available)
<Input
id={`face-source-${key}`}
type="number"
min={0}
max={Math.min(available, maximum)}
value={sources[key] ?? 0}
onChange={(e) =>
setSources({ ...sources, [key]: Number(e.target.value) })
}
/>
</label>
),
)}
<label htmlFor="face-sector">
Replacement sector
<select
id="face-sector"
value={sector}
onChange={(e) => setSector(Number(e.target.value))}
>
{territory(d.territory).sectors.map((s) => (
<option value={s} key={s}>
{s === 0 ? 'Polar Sink' : `Sector ${s}`}
</option>
))}
</select>
</label>
<p>
{total} / {maximum} replacement forces selected
</p>
<Button
disabled={
busy ||
total > maximum ||
Object.values(sources).some((n) => n < 0 || !Number.isInteger(n))
}
onClick={() =>
act({ type: 'decision', reveal: true, sources, sector })
}
>
Reveal Face Dancer
</Button>
</>
) : (
<p>You have no unrevealed Face Dancer matching the winning leader.</p>
)}
<Button
variant="outline"
disabled={busy}
onClick={() => act({ type: 'decision', reveal: false })}
>
Decline Face Dancer
</Button>
</>
);
}
File diff suppressed because it is too large. Load diff
+30
View File
@@ -0,0 +1,30 @@
'use client';
import { CircleHelp } from 'lucide-react';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import { HELP } from '@/game/reference';
export function HelpTip({ topic }: { topic: keyof typeof HELP }) {
return (
<Popover>
<PopoverTrigger
className="help-tip"
aria-label={`Help: ${topic}`}
openOnHover
delay={200}
>
<CircleHelp size={14} aria-hidden="true" />
</PopoverTrigger>
<PopoverContent
className="game-help-tooltip motion-reduce:animate-none"
aria-label={`Help: ${topic}`}
side="top"
>
{HELP[topic]}
</PopoverContent>
</Popover>
);
}
+84
View File
@@ -0,0 +1,84 @@
'use client';
import { useState } from 'react';
import type { Action, GameView } from '@/game/engine';
import { splitLocation } from '@/game/board';
import { Button } from './ui/button';
import { Input } from './ui/input';
export function IxSubstitution({
game: g,
act,
busy,
}: {
game: GameView;
act: (action: Action) => void;
busy: boolean;
}) {
const [sources, setSources] = useState<Record<string, number>>({});
const [recover, setRecover] = useState<Record<string, number>>({});
if (g.decision?.kind !== 'ixSubstitution') return null;
const d = g.decision;
const me = g.players.find((p) => p.id === g.me)!;
const available = Object.entries(me.forces)
.filter(([key]) => splitLocation(key).territory === d.territory)
.map(
([key, count]) => [key, count - (me.elites?.forces[key] ?? 0)] as const,
)
.filter(([, count]) => count > 0);
const total = (value: Record<string, number>) =>
Object.values(value).reduce((a, b) => a + b, 0);
return (
<>
<p>
Exchange surviving suboids, one for one, to retain cyborgs lost in this
battle. Retained cyborgs return to their casualty sectors. Opponents may
cancel this substitution with Karama.
</p>
<h3>Suboids to sacrifice</h3>
{available.map(([key, max]) => (
<label key={key}>
Sector {splitLocation(key).sector} · {max} suboids available
<Input
type="number"
min={0}
max={max}
value={sources[key] ?? 0}
onChange={(e) =>
setSources({ ...sources, [key]: Number(e.target.value) })
}
/>
</label>
))}
<h3>Cyborgs to retain</h3>
{Object.entries(d.losses).map(([key, max]) => (
<label key={key}>
Sector {splitLocation(key).sector} · {max} cyborgs lost
<Input
type="number"
min={0}
max={max}
value={recover[key] ?? 0}
onChange={(e) =>
setRecover({ ...recover, [key]: Number(e.target.value) })
}
/>
</label>
))}
<p>
{total(sources)} suboids for {total(recover)} cyborgs
</p>
<Button
disabled={busy || !total(sources) || total(sources) !== total(recover)}
onClick={() => act({ type: 'decision', sources, recover })}
>
Confirm substitution
</Button>
<Button
variant="outline"
disabled={busy}
onClick={() => act({ type: 'decision', decline: true })}
>
Keep these casualties
</Button>
</>
);
}
+119
View File
@@ -0,0 +1,119 @@
'use client';
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { HelpTip } from './help-tip';
import { CardInspector } from './card-inspector';
import type { Action, GameView } from '@/game/engine';
export function IxTechnology({
game: g,
act,
busy,
}: {
game: GameView;
act: (a: Action) => void;
busy: boolean;
}) {
const kind = g.decision?.kind;
const me = g.players.find((p) => p.id === g.me)!;
const [position, setPosition] = useState('bottom');
const cards =
kind === 'ixSetup'
? g.ixTechnology?.setup
: kind === 'ixAuction'
? g.ixTechnology?.pool
: me.hand;
if (kind === 'ixAllyCard')
return (
<>
<p>
You purchased{' '}
<strong>
{g.ixPurchased?.name ?? 'a card that is no longer in your hand'}
</strong>
. Your Ixian alliance lets you keep it or discard it for the top card
of the deck. The replacement stays private.{' '}
<HelpTip topic="ixTechnology" />
</p>
{g.ixPurchased && <CardInspector card={g.ixPurchased} />}
<Button
className="game-action"
disabled={busy}
onClick={() => act({ type: 'decision', accept: false })}
>
Keep purchase
</Button>
<Button
variant="outline"
disabled={busy || !g.ixPurchased}
onClick={() => act({ type: 'decision', accept: true })}
>
Request replacement
</Button>
</>
);
return (
<>
<p>
{kind === 'ixSetup'
? 'Choose your starting card. The remaining cards are shuffled and dealt privately, one to each other faction; Harkonnen then draws its extra card.'
: kind === 'ixAuction'
? 'Privately choose one card to return to the deck. The remaining pool will be shuffled for auction.'
: 'Exchange one card from your hand for the upcoming card, before Atreides looks. You commit your offered card before learning what you receive. This advantage can be attempted once this round.'}{' '}
<HelpTip topic="ixTechnology" />
</p>
{kind === 'ixAuction' && (
<label>
Return chosen card to
<select
value={position}
onChange={(e) => setPosition(e.target.value)}
>
<option value="bottom">Bottom of deck</option>
<option value="top">Top of deck</option>
</select>
</label>
)}
<div className="card-choices grid min-w-0 gap-3">
{cards?.map((c) => (
<article
key={c.id}
className="flex min-w-0 flex-col gap-3 rounded-lg border border-[#454b3c] bg-[#1d221b] p-3"
>
<h3 className="m-0 break-words text-base font-semibold text-[#efd9a8]">
{c.name}
</h3>
<CardInspector card={c} />
<Button
variant="outline"
className="game-action min-h-11"
aria-label={`${kind === 'ixAuction' ? 'Return card' : kind === 'ixSetup' ? 'Choose starting card' : 'Offer this card'}: ${c.name}`}
disabled={busy}
onClick={() =>
act({
type: 'decision',
card: c.id,
...(kind === 'ixAuction' ? { position } : {}),
})
}
>
{kind === 'ixAuction'
? 'Return card'
: kind === 'ixSetup'
? 'Choose starting card'
: 'Offer this card'}
</Button>
</article>
))}
</div>
{kind === 'ixTechnology' && (
<Button
variant="outline"
disabled={busy}
onClick={() => act({ type: 'decision', decline: true })}
>
Save technology for a later card
</Button>
)}
</>
);
}
+92
View File
@@ -0,0 +1,92 @@
'use client';
import { useId } from 'react';
import type { Action, GameView } from '@/game/engine';
import { richeseCardDefinition } from '@/game/richese-cards';
import { Button } from './ui/button';
import { CardInspector } from './card-inspector';
/** Reasons use public timing only; exact card reservation checks stay on the server. */
function unavailableReason(game: GameView) {
if (game.status !== 'playing')
return 'Order changes are available during play.';
if (game.truthtrance || game.response || game.decision || game.phaseOpening)
return 'Finish the current interaction before changing turn order.';
if (game.phase === 3) {
if (game.richeseAuction?.method === 'silent')
return 'Silent bids are simultaneous. These controls do not change their tie order.';
if (game.richeseAuction?.method !== 'onceAround')
return 'These bidding controls require a Once Around auction.';
return 'No order change is available for you in this lot. You must still be eligible and have not bid; first is available only before bidding begins. A card already committed elsewhere cannot be used.';
}
if (game.phase === 5)
return 'Movement order can change only before the current combined shipment and movement turn begins. First also requires that nobody has begun a turn this phase and that Advanced Guild timing is absent. Already-held positions and committed cards are unavailable.';
return 'These controls support Once Around bidding and movement order. Battle aggressor and other phase or auction modes remain unfinished.';
}
export function JuiceOfSapho({
game,
act,
busy,
}: {
game: GameView;
act: (action: Action) => void;
busy: boolean;
}) {
const id = useId();
const me = game.players.find((p) => p.id === game.me);
const card = me?.hand?.find(
(c) => richeseCardDefinition(c)?.card.effect === 'juiceOfSapho',
);
if (!card) return null;
const options = game.saphoOptions ?? [];
return (
<details className="my-4 min-w-0">
<summary className="min-h-11 cursor-pointer py-3">
Juice of Sapho: change turn order
</summary>
<div className="flex min-w-0 flex-col gap-4 py-3">
<p>
Choose one available order change, then discard Juice of Sapho. This
does not grant an extra bid, shipment or movement.
</p>
<CardInspector card={card} />
{options.length ? (
options.map((option) => {
const once = option.scope === 'onceAround';
return (
<div
key={`${option.scope}-${option.event}-${option.mode}`}
className="flex min-w-0 flex-col gap-2"
>
<p>
{once
? `Bid ${option.mode} in the current Once Around lot. Each eligible player still has only one bidding opportunity.`
: `Take the ${option.mode} remaining combined shipment and movement turn. Completed turns stay completed${option.mode === 'last' ? ', and the Guild cannot move behind you' : ''}.`}
</p>
<Button
className="min-h-11 whitespace-normal motion-reduce:transition-none"
disabled={busy}
onClick={() => {
if (!busy) act({ type: 'card', card: card.id, ...option });
}}
>
{once ? 'Bid' : 'Take your movement turn'} {option.mode} and
discard Juice of Sapho
</Button>
</div>
);
})
) : (
<p className="notice" id={`${id}-reason`}>
{unavailableReason(game)}
</p>
)}
<p className="text-sm">
Battle aggressor and other phase or auction modes are still
unfinished.
</p>
</div>
</details>
);
}
+317
View File
@@ -0,0 +1,317 @@
'use client';
import { useId } from 'react';
import { Dialog as DialogPrimitive } from '@base-ui/react/dialog';
import { Eye, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogClose,
DialogDescription,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import { HELP } from '@/game/reference';
import { LeaderPortrait } from './leader-portrait';
/** Supply only identity information this viewer is entitled to inspect. */
export type LeaderDisplayIdentity = Readonly<
{ id?: string; name: string; factionName: string } & (
| { cheapHero: true; strength: 0 }
| { cheapHero?: false; strength: number | string }
)
>;
export type LeaderInspectorProps = {
identity: LeaderDisplayIdentity;
kind?: 'leader' | 'traitor' | 'faceDancer';
};
/** An original geometric medallion, not a portrait or a printed component reproduction. */
function IdentityMedallion({ traitor }: { traitor: boolean }) {
const gradient = `${useId()}-seal`;
return (
<svg
viewBox="0 0 200 200"
className="mx-auto my-5 w-40 max-w-full sm:w-48"
aria-hidden="true"
focusable="false"
>
<defs>
<radialGradient id={gradient} cx="38%" cy="30%" r="75%">
<stop offset="0" stopColor={traitor ? '#563e38' : '#414c3b'} />
<stop offset="1" stopColor="#151a15" />
</radialGradient>
</defs>
<circle
cx="100"
cy="100"
r="92"
fill={`url(#${gradient})`}
stroke="#c3a365"
strokeWidth="2"
/>
<circle
cx="100"
cy="100"
r="84"
fill="none"
stroke="#bfa46e"
strokeOpacity="0.55"
/>
<circle
cx="100"
cy="100"
r="66"
fill="none"
stroke="#bfa46e"
strokeOpacity="0.25"
/>
{Array.from({ length: 16 }, (_, index) => (
<path
key={index}
d="M 100 12 V 20"
stroke="#ddc48c"
strokeWidth={index % 4 === 0 ? 3 : 1}
transform={`rotate(${index * 22.5} 100 100)`}
/>
))}
<path
d="M 100 40 L 139 100 L 100 160 L 61 100 Z"
fill="none"
stroke="#e3c787"
strokeWidth="2"
/>
<path
d={
traitor
? 'M 100 56 L 108 103 L 100 119 L 92 103 Z M 80 120 H 120 M 100 120 V 144'
: 'M 77 76 L 88 93 L 100 68 L 112 93 L 123 76 L 116 112 H 84 Z M 85 125 H 115'
}
fill="none"
stroke="#f1dba8"
strokeWidth="3"
strokeLinejoin="round"
/>
<circle cx="36" cy="100" r="3" fill="#c3a365" />
<circle cx="164" cy="100" r="3" fill="#c3a365" />
</svg>
);
}
/** Read-only: no game, catalog lookup, deck, session or action callback is accepted. */
export function LeaderInspector({
identity,
kind = 'leader',
}: LeaderInspectorProps) {
const traitor = kind === 'traitor';
const faceDancer = kind === 'faceDancer';
const label = faceDancer ? 'Face Dancer' : kind;
const cheapHero = identity.cheapHero === true;
const description = faceDancer
? cheapHero
? 'This Face Dancer identity matches either Cheap Hero or Cheap Heroine used as the winning battle leader, regardless of the hero card’s name or the player’s faction.'
: 'This Face Dancer identity matches the named winning leader. Tleilaxu can reveal an unrevealed match after that battle and its rewards are settled.'
: traitor
? cheapHero
? 'This traitor identity matches either Cheap Hero or Cheap Heroine used as a battle leader, regardless of the hero card’s name or the player’s faction.'
: 'This traitor card identifies the named leader. Holding it does not add that leader to your available battle leaders.'
: cheapHero
? 'A Cheap Hero or Heroine can stand in for a leader in a battle plan. Its leader strength is zero.'
: 'This identity shows the leader’s faction and strength. Battle powers can change the strength used in a battle.';
return (
<Dialog>
<DialogTrigger
render={
<Button
variant="outline"
className="min-h-11 min-w-11 motion-reduce:transition-none"
/>
}
aria-label={`Inspect ${label}: ${identity.name}`}
>
<Eye aria-hidden="true" />
Inspect {label}
</DialogTrigger>
<DialogPortal>
<DialogOverlay className="bg-black/75 motion-reduce:animate-none motion-reduce:transition-none" />
<DialogPrimitive.Popup className="fixed top-1/2 left-1/2 z-50 flex max-h-[calc(100dvh-1rem)] w-[calc(100%-1rem)] max-w-3xl -translate-x-1/2 -translate-y-1/2 flex-col overflow-hidden rounded-2xl border border-[#8e7952] bg-[#141814] text-[#eeeae0] shadow-2xl outline-none duration-150 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 motion-reduce:animate-none motion-reduce:transition-none sm:max-h-[calc(100dvh-3rem)] sm:w-[calc(100%-3rem)]">
<div className="flex shrink-0 items-center justify-between gap-3 border-b border-[#414536] px-4 py-3">
<span className="text-xs font-semibold tracking-[0.16em] text-[#ddbc77] uppercase">
{label} inspection
</span>
<DialogClose
render={
<Button
variant="outline"
className="min-h-11 min-w-11 motion-reduce:transition-none"
/>
}
>
<X aria-hidden="true" />
Close
</DialogClose>
</div>
<div className="min-h-0 overflow-y-auto overscroll-contain p-4 sm:p-6">
<div className="grid items-start gap-6 md:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)]">
<article
className={`min-w-0 rounded-xl border border-[#a58b5c] bg-gradient-to-br ${faceDancer ? 'from-[#43374b] via-[#2e2835]' : traitor ? 'from-[#482e2b] via-[#2e2721]' : 'from-[#344432] via-[#292f25]'} to-[#171b17] p-5 shadow-lg sm:p-6`}
>
<p className="m-0 break-words text-sm font-semibold tracking-wider text-[#f1d79f] uppercase">
{identity.factionName}
</p>
<LeaderPortrait
identityId={cheapHero ? undefined : identity.id}
name={identity.name}
className="mx-auto my-5 size-40 max-w-full sm:size-48"
sizes="(min-width: 640px) 192px, 160px"
fallback={<IdentityMedallion traitor={traitor} />}
/>
<DialogTitle className="break-words font-serif text-3xl leading-tight text-[#fff3d6] sm:text-4xl">
{identity.name}
</DialogTitle>
<dl className="mt-5 border-y border-[#b59a64]/40 py-4">
<dt className="text-sm font-medium text-[#d6c9ad]">
Leader strength
</dt>
<dd className="m-0 mt-1 break-words font-serif text-5xl leading-tight text-[#f6e4b9]">
{identity.strength}
</dd>
</dl>
<p className="mt-4 mb-0 text-xs tracking-wider text-[#cebea0] uppercase">
{faceDancer
? 'Face Dancer identity'
: traitor
? 'Traitor identity'
: 'Leader identity'}{' '}
· gameplay guide
</p>
</article>
<section
className="flex min-w-0 flex-col gap-5"
aria-label={`${faceDancer ? 'Face Dancer' : traitor ? 'Traitor' : 'Leader'} guidance`}
>
<DialogDescription className="m-0 text-base leading-7 text-[#eee5d2]">
{description}
</DialogDescription>
{identity.id === 'choam-auditor' && (
<div className="space-y-4 text-base leading-7 text-[#e0e4d8]">
<p>
Advanced CHOAM has this additional strength-2 leader and
its matching traitor identity. It remains separate from
the five ordinary leaders.
</p>
<p>
After using the Auditor in battle, CHOAM may inspect two
random opposing hand cards if it survived, or one if it
died. Exclude cards used in that battle. The opponent may
pay CHOAM one spice per card actually available to cancel
the entire inspection; partial cancellation is
unavailable. Karama can prevent the audit.
</p>
<p>
The Auditor may use CHOAM’s one normal leader revival
without waiting for all other leaders to enter the Tanks.
It cannot be captured by Harkonnen, acquired as a Tleilaxu
foreign ghola, or receive a leader skill. CHOAM may revive
it with its own Ghola card.
</p>
</div>
)}
{faceDancer ? (
<>
<p className="m-0 text-base leading-7 text-[#e0e4d8]">
Inspection does not reveal a Face Dancer or replace
forces. Use the table’s Face Dancer decision after another
faction’s winning battle, losses, card choices and rewards
have been resolved.
</p>
<p className="m-0 text-base leading-7 text-[#e0e4d8]">
Return the winner’s surviving forces to reserves. Tleilaxu
may replace up to that many with their own forces from
reserves or the board. Send the winning leader to the
tanks without another leader bounty; an already dead
leader does not die again.
</p>
<p className="m-0 text-base leading-7 text-[#e0e4d8]">
Kwisatz Haderach does not protect the accompanying leader
from Face Dancing. Karama cannot cancel this reveal or the
replacement forces.
</p>
<p className="m-0 text-base leading-7 text-[#e0e4d8]">
Keep a revealed Face Dancer face up until all three have
been used, then shuffle them back into the unused deck and
draw three new identities. During Mentat Pause, one
unrevealed Face Dancer may instead be exchanged once per
turn; that exchange has a Karama response.
</p>
{cheapHero && (
<p className="m-0 text-base leading-7 text-[#e0e4d8]">
Either hero can be the winning identity for Face
Dancing. Its leader strength is zero; the hero treachery
card and this Face Dancer identity remain separate
components.
</p>
)}
</>
) : traitor ? (
<>
<p className="m-0 text-base leading-7 text-[#e0e4d8]">
Inspection is not a traitor call and does not reveal this
identity to other players. After the battle plans are
revealed, use the table’s traitor decision to make a legal
call.
</p>
<p className="m-0 text-base leading-7 text-[#e0e4d8]">
Keep the identity after revealing it; it can match again
in later battles. A matching identity alone does not
bypass battle protections such as Kwisatz Haderach.
</p>
{!cheapHero && (
<p className="m-0 text-base leading-7 text-[#e0e4d8]">
Keeping your own leader as a traitor can prevent another
player from holding that traitor.
</p>
)}
{cheapHero && (
<p className="m-0 text-base leading-7 text-[#e0e4d8]">
This is the expansion traitor identity, separate from
the hero treachery cards. It can match any Cheap Hero or
Heroine used as a battle leader. The hero has zero
leader strength and grants no leader bounty.
</p>
)}
</>
) : (
<>
<p className="m-0 text-base leading-7 text-[#e0e4d8]">
{HELP.leader}
</p>
<p className="m-0 text-base leading-7 text-[#e0e4d8]">
Inspection does not choose or commit a leader. Close this
view to review the available leaders in your battle plan.
</p>
{cheapHero && (
<p className="m-0 text-base leading-7 text-[#e0e4d8]">
The hero treachery card is discarded after use. The
separate Cheap Hero traitor identity can match either
hero.
</p>
)}
</>
)}
{identity.strength === 'X' && (
<p className="m-0 rounded-lg border border-[#454b3c] bg-[#1d221b] p-4 text-base leading-7 text-[#e0e4d8]">
X marks variable strength. Use the faction’s battle rule to
determine its value against the opposing leader.
</p>
)}
</section>
</div>
</div>
</DialogPrimitive.Popup>
</DialogPortal>
</Dialog>
);
}
+54
View File
@@ -0,0 +1,54 @@
'use client';
import { useState, type ReactNode } from 'react';
import Image from 'next/image';
import { leaderArt } from '@/game/leader-art';
import { specialLeaderArt } from '@/game/special-leader-art';
/** Supply only an identity already authorized for this viewer. No game/deck lookup. */
export function LeaderPortrait({
identityId,
name,
fallback,
className = 'size-20',
sizes = '80px',
badge,
}: {
identityId?: string;
name: string;
fallback: ReactNode;
className?: string;
sizes?: string;
badge?: ReactNode;
}) {
const [failedSource, setFailedSource] = useState<string | null>(null);
const art = leaderArt(identityId, name) ?? specialLeaderArt(identityId, name);
if (!art || failedSource === art.src) return fallback;
return (
<span className={`relative block shrink-0 ${className}`}>
<span className="absolute inset-0 overflow-hidden rounded-full border-2 border-[#c5a66d] bg-[#1b201b] shadow-[inset_0_0_0_4px_#151a15,0_5px_15px_#0005]">
<Image
src={art.src}
alt=""
width={1024}
height={1024}
sizes={sizes}
loading="lazy"
decoding="async"
onError={() => setFailedSource(art.src)}
className="h-full w-full object-cover"
style={{ objectPosition: art.objectPosition }}
/>
<span
aria-hidden="true"
className="pointer-events-none absolute inset-1 rounded-full border border-[#e6ce99]/40"
/>
</span>
{badge !== undefined && (
<span className="absolute -right-1 -bottom-1 flex min-h-8 min-w-8 items-center justify-center rounded-full border border-[#dfc283] bg-[#171b17] px-1.5 font-serif text-lg leading-none text-[#fff0c5] shadow-md">
{badge}
</span>
)}
</span>
);
}
+157
View File
@@ -0,0 +1,157 @@
'use client';
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { HelpTip } from './help-tip';
import {
GRAPH,
TERRITORIES,
MOBILE_LOCATION,
mobileRouteDistance,
splitLocation,
territory,
} from '@/game/board';
import type { Action, GameView } from '@/game/engine';
const label = (key: string) => {
const loc = splitLocation(key);
return `${territory(loc.territory).name}${loc.sector ? ` · sector ${loc.sector}` : ''}`;
};
export function MobileStronghold({
game: g,
act,
busy,
card,
}: {
game: GameView;
act: (action: Action) => void;
busy: boolean;
card?: string;
}) {
const pointer = g.mobileStronghold?.location;
const placement = !pointer;
const [destination, setDestination] = useState('polar_sink:0');
const [route, setRoute] = useState<string[]>(pointer ? [pointer] : []);
const [collect, setCollect] = useState(true);
const max = card ? 2 : 3;
const current = route.at(-1) ?? '';
const choices = placement
? TERRITORIES.filter((t) => t.type !== 'stronghold').flatMap((t) =>
t.sectors.map((s) => `${t.id}:${s}`),
)
: (GRAPH[current] ?? []).filter(
(key) =>
mobileRouteDistance([...route, key]) <= max &&
splitLocation(key).sector !== g.storm &&
splitLocation(route[0]).sector !== g.storm,
);
const me = g.players.find((p) => p.id === g.me)!;
const preview = route.reduce(
(sum, key) =>
sum + Math.min(g.spice[key] ?? 0, (me.forces[MOBILE_LOCATION] ?? 0) * 2),
0,
);
const valid =
placement ||
(route.length > 1 &&
route.every((key) => splitLocation(key).sector !== g.storm) &&
territory(splitLocation(current).territory).type !== 'stronghold');
return (
<div className="mobile-route">
<p>
{placement
? 'Point your stronghold at a sector in any non-stronghold territory. Placement does not collect spice.'
: `Move the stronghold up to ${max} territories. Choose each sector along the route; the forces stay inside.`}{' '}
<HelpTip topic="mobileStronghold" />
</p>
{!placement && pointer && splitLocation(pointer).sector === g.storm && (
<p className="fine">
The pointing sector is in storm. Keep the current position until the
storm clears.
</p>
)}
{!placement && (
<>
<ol>
{route.map((key) => (
<li key={key}>
{label(key)}
{g.spice[key] ? ` · ${g.spice[key]} spice` : ''}
</li>
))}
</ol>
<p className="fine">
{mobileRouteDistance(route)} / {max} territories ·{' '}
{collect ? preview : 0} spice to collect
</p>
</>
)}
<label>
{placement ? 'Pointing sector' : 'Next sector'}
<select
aria-label={placement ? 'Pointing sector' : 'Next route sector'}
value={placement ? destination : ''}
onChange={(e) => {
if (placement) setDestination(e.target.value);
else if (e.target.value) setRoute([...route, e.target.value]);
}}
>
{!placement && <option value="">Choose next sector…</option>}
{choices.map((key) => (
<option key={key} value={key}>
{label(key)}
</option>
))}
</select>
</label>
{!placement && (
<>
<Button
variant="outline"
disabled={busy || route.length < 2}
onClick={() => setRoute(route.slice(0, -1))}
>
Undo last step
</Button>
<label className="decision-checkbox">
<input
type="checkbox"
checked={collect}
onChange={(e) => setCollect(e.target.checked)}
/>
Collect spice along the route
</label>
{!valid && route.length > 1 && (
<p className="fine">Finish in a non-stronghold territory.</p>
)}
</>
)}
<Button
className="game-action"
disabled={busy || !valid}
onClick={() =>
act(
placement
? { type: 'decision', location: destination }
: card
? { type: 'card', mode: 'special', card, route, collect }
: { type: 'decision', route, collect },
)
}
>
{placement
? 'Place stronghold'
: card
? 'Spend Karama and relocate'
: 'Declare stronghold route'}
</Button>
{!placement && !card && (
<Button
variant="outline"
disabled={busy}
onClick={() => act({ type: 'decision', decline: true })}
>
Keep current position
</Button>
)}
</div>
);
}
+105
View File
@@ -0,0 +1,105 @@
'use client';
import { useId, useState } from 'react';
import type { Action, GameView } from '@/game/engine';
import { territory } from '@/game/board';
import { battleCardLabel } from '@/game/battle-cards';
import { Button } from './ui/button';
import { CardInspector, CardRules } from './card-inspector';
export function MoritaniRetention({
game,
act,
busy,
}: {
game: GameView;
act: (action: Action) => void;
busy: boolean;
}) {
const groupId = useId();
const [selected, setSelected] = useState<string | null>(null);
const decision = game.decision;
if (decision?.kind !== 'moritaniRetention' || decision.player !== game.me)
return null;
const me = game.players.find((player) => player.id === game.me);
if (!me) return null;
const cards = (me.hand ?? []).filter((card) =>
decision.cards.includes(card.id),
);
const selectedCard = cards.find((card) => card.id === selected);
const owner = game.players.find((player) => player.id === decision.owner);
const disabled = busy || !!game.response || !!game.truthtrance;
return (
<section
className="min-w-0 space-y-4"
aria-label="Moritani allied card retention"
>
<p>
You lost the battle in {territory(decision.territory).name}. Your
Moritani ally{owner ? `, ${owner.name},` : ''} lets you choose one
eligible card played in that battle to keep. Using this ability is
optional.
</p>
<p className="fine">
Declare one card, then the table may respond with Karama before you
retain it. If the power is canceled, all these cards are discarded.
Declining also discards them.
</p>
<fieldset
className="m-0 min-w-0 space-y-3 border-0 p-0"
disabled={disabled}
>
<legend className="mb-3 font-semibold">
Choose one card to retain
</legend>
{cards.map((card) => (
<article
key={card.id}
className={`treachery-card card-${card.kind} min-w-0`}
>
<label
htmlFor={`${groupId}-${card.id}`}
className="flex min-h-11 cursor-pointer items-center gap-3 break-words"
>
<input
id={`${groupId}-${card.id}`}
type="radio"
name={groupId}
value={card.id}
checked={selectedCard?.id === card.id}
onChange={() => setSelected(card.id)}
className="size-5 shrink-0 accent-[#d7b673]"
/>
<span className="min-w-0 font-semibold">{card.name}</span>
</label>
<p className="eyebrow">{battleCardLabel(card.kind)}</p>
<CardRules card={card} />
<CardInspector card={card} />
</article>
))}
</fieldset>
{!cards.length && (
<p className="fine">
No eligible card remains in your hand. Decline to continue.
</p>
)}
<Button
className="min-h-11 w-full whitespace-normal"
disabled={disabled || !selectedCard}
onClick={() =>
selectedCard && act({ type: 'decision', keep: selectedCard.id })
}
>
Declare selected card
</Button>
<Button
variant="outline"
className="min-h-11 w-full whitespace-normal"
disabled={disabled}
onClick={() => act({ type: 'decision', keep: null })}
>
Decline retention
</Button>
</section>
);
}
+710
View File
@@ -0,0 +1,710 @@
'use client';
import { useId, useState } from 'react';
import { Dialog as DialogPrimitive } from '@base-ui/react/dialog';
import { Eye, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogClose,
DialogDescription,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import type { Action, GameView } from '@/game/engine';
import { territory } from '@/game/board';
import {
TERROR_COMMON_GAMEPLAY,
TERROR_DEFINITIONS,
TERROR_STRONGHOLDS,
type TerrorKind,
type TerrorToken,
} from '@/game/moritani-terror';
type ProjectedToken = NonNullable<GameView['moritaniTerror']>['tokens'][number];
/** Original vector marks. A hidden token always uses the same face and geometry. */
function TerrorArt({
kind,
large = false,
}: {
kind?: TerrorKind;
large?: boolean;
}) {
return (
<svg
viewBox="0 0 120 120"
className={large ? 'mx-auto my-6 size-40 max-w-full' : 'size-16 shrink-0'}
aria-hidden="true"
focusable="false"
>
<circle cx="60" cy="63" r="55" fill="#080e0f" />
<circle
cx="60"
cy="58"
r="54"
fill="#173a40"
stroke="#d2b77b"
strokeWidth="2"
/>
<circle cx="60" cy="58" r="46" fill="#10282d" stroke="#688987" />
<path
d="M 60 8 L 63 14 L 60 20 L 57 14 Z M 60 96 L 63 102 L 60 108 L 57 102 Z M 10 58 H 18 M 102 58 H 110"
fill="#ddc690"
stroke="#ddc690"
/>
<g
fill="none"
stroke="#f1dfb4"
strokeWidth="3"
strokeLinecap="round"
strokeLinejoin="round"
>
{kind === 'assassination' ? (
<>
<path d="M 68 26 L 75 33 L 53 66 L 42 75 L 45 61 Z M 40 65 L 54 76 M 47 72 L 37 88" />
<circle cx="77" cy="78" r="10" strokeWidth="1.5" />
</>
) : kind === 'atomics' ? (
<>
<circle cx="60" cy="58" r="9" fill="#b59a64" />
<path d="M 60 25 V 40 M 60 76 V 91 M 27 58 H 42 M 78 58 H 93 M 36 34 L 47 45 M 73 71 L 84 82 M 36 82 L 47 71 M 73 45 L 84 34" />
<circle cx="60" cy="58" r="25" strokeWidth="1" />
</>
) : kind === 'extortion' ? (
<>
<path d="M 38 38 H 82 V 82 H 38 Z M 47 38 V 30 H 73 V 38 M 38 50 H 82" />
<path d="M 60 58 L 66 65 L 60 74 L 54 65 Z" fill="#b59a64" />
</>
) : kind === 'robbery' ? (
<>
<path d="M 33 46 Q 60 29 87 46 L 80 70 Q 60 83 40 70 Z" />
<path d="M 43 50 L 54 54 L 44 59 M 77 50 L 66 54 L 76 59 M 55 70 H 65" />
</>
) : kind === 'sabotage' ? (
<>
<path d="M 53 28 H 67 L 70 39 L 81 41 L 87 54 L 79 63 L 81 76 L 67 84 L 58 76 L 45 79 L 36 65 L 43 55 L 40 41 L 52 38 Z" />
<path
d="M 68 39 L 53 57 L 67 59 L 52 79"
stroke="#d59a80"
strokeWidth="4"
/>
</>
) : kind === 'sneakAttack' ? (
<>
<path d="M 25 47 L 60 57 L 95 47 L 73 65 L 60 88 L 47 65 Z M 60 31 V 57 M 46 35 L 60 44 L 74 35" />
</>
) : (
<>
<path d="M 60 27 L 83 42 V 61 Q 81 78 60 89 Q 39 78 37 61 V 42 Z" />
<path d="M 49 59 V 52 A 11 11 0 0 1 71 52 V 59 M 47 59 H 73 V 75 H 47 Z" />
<path d="M 60 64 V 69" />
</>
)}
</g>
</svg>
);
}
function tokenContext(token: ProjectedToken) {
if (token.status === 'placed' && token.location)
return `Placed · ${territory(token.location).name}`;
if (token.status === 'extortion') return 'Revealed · awaiting Mentat';
if (token.status === 'removed') return 'Revealed · removed from play';
return 'Private supply';
}
function TokenInspector({
token,
owner,
}: {
token: ProjectedToken;
owner: boolean;
}) {
const revealed = token.status === 'removed' || token.status === 'extortion';
// Defense in depth: even an accidentally over-broad projection cannot render
// an opponent's hidden face. Never derive a secret kind from a physical ID.
const kind = (owner || revealed) && 'kind' in token ? token.kind : undefined;
const definition = kind ? TERROR_DEFINITIONS[kind] : undefined;
const title = definition?.name ?? 'Hidden Terror';
const context = tokenContext(token);
return (
<Dialog>
<DialogTrigger
render={
<Button
variant="outline"
className="h-auto min-h-11 min-w-0 flex-col gap-2 whitespace-normal border-[#66796a] bg-[#172624] px-2 py-3 text-[#f2dfb9] motion-reduce:transition-none"
/>
}
aria-label={`Inspect ${title}: ${context}`}
>
<TerrorArt kind={kind} />
<span className="break-words font-serif text-base leading-tight">
{title}
</span>
<span className="break-words text-xs leading-5 text-[#c4cabc]">
{context}
</span>
<span className="inline-flex items-center gap-1 text-xs text-[#dfc78e]">
<Eye aria-hidden="true" /> Inspect
</span>
</DialogTrigger>
<DialogPortal>
<DialogOverlay className="bg-black/75 motion-reduce:animate-none motion-reduce:transition-none" />
<DialogPrimitive.Popup className="fixed top-1/2 left-1/2 z-50 flex max-h-[calc(100dvh-1rem)] w-[calc(100%-1rem)] max-w-3xl -translate-x-1/2 -translate-y-1/2 flex-col overflow-hidden rounded-2xl border border-[#8e7952] bg-[#141814] text-[#eeeae0] shadow-2xl outline-none">
<div className="flex shrink-0 items-center justify-between gap-3 border-b border-[#414536] px-4 py-3">
<span className="text-xs font-semibold tracking-[0.16em] text-[#ddbc77] uppercase">
Moritani · Terror
</span>
<DialogClose
render={
<Button
variant="outline"
className="min-h-11 min-w-11 motion-reduce:transition-none"
/>
}
>
<X aria-hidden="true" /> Close
</DialogClose>
</div>
<div className="min-h-0 overflow-y-auto overscroll-contain p-4 sm:p-6">
<div className="grid items-start gap-6 md:grid-cols-[minmax(0,0.8fr)_minmax(0,1.2fr)]">
<article className="min-w-0 rounded-xl border border-[#a58b5c] bg-gradient-to-br from-[#294b4b] via-[#1c302d] to-[#171b17] p-5">
<p className="m-0 text-xs font-semibold tracking-widest text-[#e0cd9f] uppercase">
{context}
</p>
<TerrorArt kind={kind} large />
<DialogTitle className="break-words font-serif text-3xl leading-tight text-[#fff3d6] sm:text-4xl">
{title}
</DialogTitle>
<p className="mt-5 mb-0 text-sm leading-6 text-[#d3daca]">
{kind
? 'Original token illustration · gameplay guide'
: 'This placed token remains face down. Its identity is visible only to Moritani.'}
</p>
</article>
<section
className="min-w-0 space-y-4"
aria-label="Terror token guidance"
>
<DialogDescription className="m-0 text-base leading-7 text-[#f1dfb4]">
{definition?.summary ??
'A hidden Terror token marks this stronghold. Inspecting it does not reveal its identity or trigger an effect.'}
</DialogDescription>
{(definition?.gameplay ?? TERROR_COMMON_GAMEPLAY).map(
(paragraph) => (
<p
key={paragraph}
className="m-0 text-sm leading-7 text-[#e0e4d8]"
>
{paragraph}
</p>
),
)}
<p className="border-t border-[#414536] pt-4 text-sm leading-6 text-[#c9c2ae]">
Robbery, Sabotage, Sneak Attack and ordinary native-leader
Assassination have controls for supported entry reactions.
Enemy of My Enemy has partial alliance controls. Atomics and
Extortion remain under implementation, and complete Moritani
games remain unavailable. Inspection explains the rules; it
does not reveal a token or activate an effect.
</p>
</section>
</div>
</div>
</DialogPrimitive.Popup>
</DialogPortal>
</Dialog>
);
}
export function MoritaniTerrorSupply({ game }: { game: GameView }) {
const titleId = useId();
const view = game.moritaniTerror;
if (!view) return null;
const owner =
game.players.find((p) => p.id === game.me)?.faction === 'moritani';
const tokens = view.tokens.filter(
(token) => owner || token.status !== 'available',
);
return (
<section
className="min-w-0 rounded-xl border border-[#58624e] bg-[#161e19] p-3"
aria-labelledby={titleId}
>
<h3 id={titleId} className="m-0 font-serif text-xl text-[#f1dfb4]">
Moritani Terror
</h3>
<p className="mt-2 mb-3 text-sm leading-6 text-[#c4cabc]">
{owner
? 'Your supply and placed tokens. Inspect a face without revealing it to the table.'
: 'Placed tokens stay hidden. Revealed tokens can be inspected by everyone.'}
</p>
{tokens.length ? (
<div className="grid min-w-0 grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-6">
{tokens.map((token) => (
<TokenInspector key={token.id} token={token} owner={owner} />
))}
</div>
) : (
<p className="m-0 text-sm text-[#c4cabc]">No public Terror tokens.</p>
)}
</section>
);
}
export function MoritaniPlacement({
game,
act,
busy,
}: {
game: GameView;
act: (action: Action) => void;
busy: boolean;
}) {
const formId = useId();
const [selectedToken, setSelectedToken] = useState('');
const [selectedTerritory, setSelectedTerritory] = useState('');
const decision = game.decision;
if (
decision?.kind !== 'moritaniPlacement' ||
decision.player !== game.me ||
game.players.find((p) => p.id === game.me)?.faction !== 'moritani' ||
!game.moritaniTerror
)
return null;
const tokens = game.moritaniTerror.tokens.filter(
(token): token is TerrorToken =>
'kind' in token &&
(token.status === 'available' || token.status === 'placed'),
);
const token =
tokens.find((candidate) => candidate.id === selectedToken) ?? tokens[0];
const destinations = TERROR_STRONGHOLDS.filter(
(to) =>
to !== token?.location &&
!game.moritaniTerror!.tokens.some(
(candidate) =>
candidate.status === 'placed' && candidate.location === to,
),
);
const destination = destinations.includes(selectedTerritory)
? selectedTerritory
: destinations[0];
const waiting = Boolean(game.response);
const disabled = busy || waiting;
return (
<section className="notice min-w-0" aria-labelledby={`${formId}-title`}>
<h3 id={`${formId}-title`}>Place or relocate Terror</h3>
<p className="text-sm leading-6">
Choose one token and a stronghold without a Terror token for this Mentat
Pause. Storm does not prevent placement. Choose privately; the token’s
face stays hidden from opponents.
</p>
<div className="flex min-w-0 flex-col gap-3">
<label htmlFor={`${formId}-token`} className="text-sm font-medium">
Your Terror token
</label>
<select
id={`${formId}-token`}
className="min-h-11 w-full min-w-0 max-w-full"
disabled={disabled || !tokens.length}
value={token?.id ?? ''}
onChange={(event) => setSelectedToken(event.target.value)}
>
{!tokens.length && <option value="">No available token</option>}
{tokens.map((candidate) => (
<option key={candidate.id} value={candidate.id}>
{TERROR_DEFINITIONS[candidate.kind].name} ·{' '}
{candidate.location
? territory(candidate.location).name
: 'Supply'}
</option>
))}
</select>
<label htmlFor={`${formId}-territory`} className="text-sm font-medium">
Destination stronghold
</label>
<select
id={`${formId}-territory`}
className="min-h-11 w-full min-w-0 max-w-full"
disabled={disabled || !destinations.length || !token}
value={destination ?? ''}
onChange={(event) => setSelectedTerritory(event.target.value)}
>
{!destinations.length && (
<option value="">No empty stronghold</option>
)}
{destinations.map((to) => (
<option key={to} value={to}>
{territory(to).name}
</option>
))}
</select>
<output className="m-0 text-sm leading-6">
{waiting
? 'Waiting for the table’s Karama response. The token has not moved.'
: 'The table may use Karama before your placement or relocation takes effect.'}
</output>
<Button
className="h-auto min-h-11 whitespace-normal motion-reduce:transition-none"
disabled={disabled || !token || !destination}
onClick={() => {
if (token && destination)
act({
type: 'decision',
token: token.id,
territory: destination,
});
}}
>
{token?.status === 'placed'
? 'Declare relocation'
: 'Declare placement'}
</Button>
<Button
variant="outline"
className="h-auto min-h-11 whitespace-normal motion-reduce:transition-none"
disabled={disabled}
onClick={() => act({ type: 'decision', decline: true })}
>
Pass this Mentat opportunity
</Button>
</div>
</section>
);
}
export function MoritaniEntry({
game,
act,
busy,
}: {
game: GameView;
act: (action: Action) => void;
busy: boolean;
}) {
const formId = useId();
const [selectedCard, setSelectedCard] = useState('');
const [selectedAmount, setSelectedAmount] = useState(0);
const entry = game.terrorEntry;
const me = game.players.find((player) => player.id === game.me);
if (
game.decision?.kind !== 'moritaniTerror' ||
game.decision.player !== game.me ||
!entry ||
!me
)
return null;
const disabled = busy || Boolean(game.response) || Boolean(game.truthtrance);
const controlClass =
'h-auto min-h-11 whitespace-normal motion-reduce:transition-none';
// This entrant branch must precede every private kind, definition and token lookup.
if (entry.stage === 'allianceReply') {
if (entry.entrant !== game.me) return null;
const moritani = game.players.find(
(player) => player.faction === 'moritani',
);
if (!moritani) return null;
return (
<section className="notice min-w-0" aria-labelledby={`${formId}-title`}>
<h3 id={`${formId}-title`}>Enemy of My Enemy</h3>
<p className="text-sm leading-6">
{moritani.name} offers you an alliance after your arrival in{' '}
{territory(entry.territory).name}. The Terror token remains hidden.
</p>
<p className="text-sm leading-6">
Accepting forms your alliance with Moritani, ends any existing
alliances either of you has, and returns the token to Moritani’s
supply without revealing it. Refusing requires the token to be
revealed and its effect to resolve.
</p>
{[me, moritani]
.filter((player) => player.ally)
.map((player) => {
const formerPartner = game.players.find(
(partner) => partner.id === player.ally,
);
return (
<p key={player.id} className="text-sm leading-6">
{player.name}’s alliance with{' '}
{formerPartner?.name ?? 'their current ally'} will end if you
accept.
</p>
);
})}
<div className="flex min-w-0 flex-col gap-3">
<Button
className={controlClass}
disabled={disabled}
onClick={() => act({ type: 'decision', accept: true })}
>
Accept alliance
</Button>
<Button
variant="outline"
className={controlClass}
disabled={disabled}
onClick={() => act({ type: 'decision', accept: false })}
>
Refuse alliance
</Button>
</div>
</section>
);
}
if (me.faction !== 'moritani' || !('kind' in entry) || !entry.kind)
return null;
const definition = TERROR_DEFINITIONS[entry.kind];
const token = game.moritaniTerror?.tokens.find(
(candidate) => 'kind' in candidate && candidate.kind === entry.kind,
);
const entrant = game.players.find((player) => player.id === entry.entrant);
const hand = me.hand ?? [];
const selected = hand.find((card) => card.id === selectedCard);
const discardCard = selected ?? hand[0];
const giving = entry.stage === 'gift';
const sneak = entry.sneakAttack;
const maximum = sneak?.maximum ?? 0;
const amount = selectedAmount <= maximum ? selectedAmount : 0;
const arrival = {
shipment: 'by shipment',
movement: 'by movement',
guildTransport: 'by Guild transport',
advisor: 'as an accompanying advisor',
wormRide: 'by worm ride',
ambassador: 'through the Fremen Ambassador',
}[entry.cause];
return (
<section className="notice min-w-0" aria-labelledby={`${formId}-title`}>
<h3 id={`${formId}-title`} className="break-words">
{definition.name} · Terror reaction
</h3>
<p className="text-sm leading-6">
{entrant?.name ?? 'Another faction'} entered{' '}
{territory(entry.territory).name}, sector {entry.sector}, {arrival}.
</p>
{token && (
<div className="mb-4 grid grid-cols-1">
<TokenInspector token={token} owner />
</div>
)}
<div className="flex min-w-0 flex-col gap-3">
{entry.stage === 'offer' && (
<>
<p className="m-0 text-sm leading-6">
You may reveal this token against the entrant, or leave it hidden
and continue their action. Inspection keeps its face private.
</p>
{entry.kind === 'assassination' && entry.canReveal && (
<p className="m-0 text-sm leading-6">
Revealing Assassination immediately selects a random eligible
native leader. You do not choose the victim.
</p>
)}
{entry.kind === 'sneakAttack' && sneak && (
<div className="space-y-2 text-sm leading-6">
<p className="m-0">
Up to {maximum} of your reserve forces are available.
{sneak.blocked
? ` Positive entry is blocked: ${sneak.blocked}`
: ' They may enter this territory and sector for free.'}
</p>
<p className="m-0">
You may leave this token hidden, or reveal it and choose how
many forces to send. Revealing spends the token even if you
send no forces; zero remains available when entry is blocked.
</p>
</div>
)}
{entry.canOfferAlliance ? (
<div className="flex flex-col gap-3 border-y border-current/20 py-3">
<p className="m-0 text-sm leading-6">
Enemy of My Enemy lets you offer an alliance before revealing
this token. Acceptance ends both existing alliances and
returns the token hidden. Refusal requires its effect to
resolve. The table’s Karama response comes before the entrant
decides.
</p>
<Button
variant="outline"
className={controlClass}
disabled={disabled}
onClick={() => act({ type: 'decision', alliance: true })}
>
Offer alliance
</Button>
</div>
) : entry.allianceBlockedReason ? (
<p className="m-0 text-sm leading-6">
Enemy of My Enemy: {entry.allianceBlockedReason}
</p>
) : null}
{entry.canReveal ? (
<Button
className={controlClass}
disabled={disabled}
onClick={() => act({ type: 'decision', reveal: true })}
>
Reveal {definition.name}
</Button>
) : (
<p className="m-0 text-sm leading-6">
{entry.revealBlocked ??
`${definition.name} is still under implementation.`}{' '}
Leave the token hidden to continue this arrival.
</p>
)}
<Button
variant="outline"
className={controlClass}
disabled={disabled}
onClick={() => act({ type: 'decision', decline: true })}
>
Leave token hidden
</Button>
<p className="m-0 text-sm leading-6">
Robbery, Sabotage, Sneak Attack and ordinary native-leader
Assassination support eligible arrivals. Enemy of My Enemy has
partial alliance controls. Atomics and Extortion are not yet
available; complete Moritani games remain unavailable.
</p>
</>
)}
{entry.stage === 'robbery' && (
<>
<p className="m-0 text-sm leading-6">
Take half the entrant’s spice, rounded up, or draw the top
Treachery Card. A draw above your hand limit requires you to
choose a card to discard afterward.
</p>
<Button
className={controlClass}
disabled={disabled}
onClick={() => act({ type: 'decision', choice: 'spice' })}
>
Take half their spice
</Button>
<Button
variant="outline"
className={controlClass}
disabled={disabled}
onClick={() => act({ type: 'decision', choice: 'card' })}
>
Draw a Treachery Card
</Button>
</>
)}
{entry.stage === 'sneakAttack' && (
<>
<p className="m-0 text-sm leading-6">
Send forces from your reserves into{' '}
{territory(entry.territory).name}, sector {entry.sector}, for
free. This entry does not use your ordinary shipment or movement
turn. You may send no forces.
</p>
<label htmlFor={`${formId}-amount`} className="text-sm font-medium">
Forces from your reserves
</label>
<select
id={`${formId}-amount`}
className="min-h-11 w-full min-w-0 max-w-full"
disabled={disabled}
value={amount}
aria-describedby={`${formId}-entry-limit`}
onChange={(event) =>
setSelectedAmount(Number(event.target.value))
}
>
{Array.from({ length: maximum + 1 }, (_, count) => (
<option key={count} value={count}>
{count}
{count === 0 ? ' · no forces' : ''}
</option>
))}
</select>
<p id={`${formId}-entry-limit`} className="m-0 text-sm leading-6">
{sneak?.blocked ??
`Up to ${maximum} forces are available for this entry.`}
</p>
<Button
className={controlClass}
disabled={
disabled || !sneak || amount <= 0 || Boolean(sneak.blocked)
}
onClick={() => {
if (sneak && amount > 0 && !sneak.blocked)
act({ type: 'decision', amount });
}}
>
Send selected forces
</Button>
<Button
variant="outline"
className={controlClass}
disabled={disabled}
onClick={() => act({ type: 'decision', amount: 0 })}
>
Send no forces
</Button>
</>
)}
{(entry.stage === 'discard' || giving) && (
<>
<p className="m-0 text-sm leading-6">
{giving
? 'Sabotage’s random discard has resolved where possible. You may now give the entrant one of your own cards, or keep your hand.'
: 'Your Robbery draw exceeded your hand limit. Choose one of your own cards to discard, including the card just drawn.'}
</p>
<label htmlFor={`${formId}-card`} className="text-sm font-medium">
{giving ? 'Card to give (optional)' : 'Your card to discard'}
</label>
<select
id={`${formId}-card`}
className="min-h-11 w-full min-w-0 max-w-full"
disabled={disabled || !hand.length}
value={giving ? (selected?.id ?? '') : (discardCard?.id ?? '')}
onChange={(event) => setSelectedCard(event.target.value)}
>
{giving && <option value="">None · keep my hand</option>}
{!giving && !hand.length && (
<option value="">No available card</option>
)}
{hand.map((card) => (
<option key={card.id} value={card.id}>
{card.name}
</option>
))}
</select>
<Button
className={controlClass}
disabled={disabled || (!giving && !discardCard)}
onClick={() => {
if (giving && !selected)
act({ type: 'decision', decline: true });
else {
const card = giving ? selected : discardCard;
if (card) act({ type: 'decision', card: card.id });
}
}}
>
{giving
? selected
? 'Give selected card'
: 'Keep my hand'
: 'Discard selected card'}
</Button>
</>
)}
</div>
</section>
);
}
+110
View File
@@ -0,0 +1,110 @@
'use client';
import { useId } from 'react';
import type { Action, GameView } from '@/game/engine';
import { Button } from './ui/button';
import { CardInspector, CardRules } from './card-inspector';
type BoxProps = {
game: GameView;
act: (action: Action) => void;
busy: boolean;
};
/** Before payment this renders only the owned Box, never discard candidates. */
export function NullentropyBox({ game, act, busy }: BoxProps) {
const id = useId();
const box = game.nullentropy;
if (!box) return null;
if (box.search)
return (
<p className="notice">
Your Nullentropy Box search is paid. Complete the private card choice in
the decision panel above.
</p>
);
return (
<details className="my-4 min-w-0">
<summary className="min-h-11 cursor-pointer py-3">
Nullentropy Box: paid discard search
</summary>
<div className="flex min-w-0 flex-col gap-4 py-3">
<p>
Pay 2 spice to the bank to privately search the Treachery discard
pile. Choose one card other than any Nullentropy Box. The remaining
pile is shuffled, then your played Box is discarded on top.
</p>
<p className="fine">
Payment begins the search. There is no free cancellation after seeing
the cards. If only one card is eligible, the server takes it
automatically.
</p>
<CardRules card={box.card} />
<CardInspector card={box.card} />
{box.blocked && (
<p id={`${id}-reason`} className="notice">
{box.blocked}
</p>
)}
<Button
className="min-h-11 whitespace-normal motion-reduce:transition-none"
disabled={busy || !!box.blocked}
aria-describedby={box.blocked ? `${id}-reason` : undefined}
onClick={() => {
if (!busy && !box.blocked) act({ type: 'card', card: box.card.id });
}}
>
Pay 2 spice and search privately
</Button>
</div>
</details>
);
}
/** Only the paid, entitled projection supplies these temporary discard faces. */
export function NullentropySearch({ game, act, busy }: BoxProps) {
const search = game.nullentropy?.search;
if (
game.decision?.kind !== 'nullentropy' ||
game.decision.player !== game.me ||
!search
)
return null;
return (
<div className="flex min-w-0 flex-col gap-4">
<p>
You have paid 2 spice. Choose one card for your hand. This finishes the
search and discards your Box on top of the shuffled remainder.
</p>
<p className="fine">
These discard cards are visible only to you during this paid search.
Inspection does not select a card.
</p>
<div
className="grid min-w-0 gap-4"
style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(min(100%, 16rem), 1fr))' }}
>
{search.cards.map((card) => (
<article
key={card.id}
className="flex min-w-0 flex-col gap-3 rounded-lg border border-[#65644b] p-4"
>
<h3>{card.name}</h3>
<CardRules card={card} />
<CardInspector card={card} />
<Button
className="min-h-11 whitespace-normal motion-reduce:transition-none"
disabled={busy}
onClick={() => {
if (!busy)
act({ type: 'decision', event: search.event, card: card.id });
}}
>
Take {card.name} and finish search
</Button>
</article>
))}
</div>
</div>
);
}
+226
View File
@@ -0,0 +1,226 @@
'use client';
import { useId, useState } from 'react';
import type { Action, GameView } from '@/game/engine';
import {
validateCohortSelection,
type OrnithopterMode,
} from '@/game/ornithopter';
import { location, splitLocation, gameTerritories } from '@/game/board';
import { isAdvisor } from '@/game/advisors';
import { botGroundMoveAllowed } from '@/game/bot-mobility';
import { Button } from './ui/button';
import { CardInspector, CardRules } from './card-inspector';
/** The parent supplies its existing ordinary/elite/multi-sector/marker draft. */
export function OrnithopterMovement({
game,
act,
busy,
move,
unavailableReason,
}: {
game: GameView;
act: (action: Action) => void;
busy: boolean;
move: Action | null;
unavailableReason?: string;
}) {
const id = useId();
const [selected, setSelected] = useState<OrnithopterMode>('range3');
const card = game.ornithopter;
const me = game.players.find((p) => p.id === game.me);
if (!card || !me) return null;
const active = card.active;
const mode =
active?.mode ??
(
card.modes.find(
(option) => option.mode === selected && !option.blocked,
) ??
card.modes.find((option) => !option.blocked) ??
card.modes[0]
)?.mode;
let reason =
unavailableReason ??
card.blocked ??
(!active
? card.modes.find((option) => option.mode === mode)?.blocked
: null) ??
(!move || move.type !== 'move'
? 'Select a valid movement group and destination above.'
: null);
if (!reason && mode === 'twoGroups' && me.fremenMovementBlocked && move) {
const selectedForces = typeof move.from === 'string'
? { [move.from]: Number(move.amount) }
: (move.forces as Record<string, number> | undefined) ?? {};
if (Object.entries(selectedForces).some(([from, count]) => count > 0 &&
!botGroundMoveAllowed(game, me, from,
location(String(move.territory), Number(move.sector)), 0)))
reason = 'Karama removed this group’s two-territory advantage. Choose a reachable destination within the remaining normal range.';
}
const physicalSource =
typeof move?.from === 'string'
? move.from
: move?.forces && typeof move.forces === 'object'
? Object.keys(move.forces)[0]
: undefined;
if (
!reason &&
physicalSource &&
isAdvisor(me, splitLocation(physicalSource).territory)
)
reason = 'Advanced advisor use of the Ornithopter card awaits its ruling.';
if (!reason && active?.cohort && move) {
const forces =
(move.forces as Record<string, number> | undefined) ??
(typeof move.from === 'string'
? { [move.from]: Number(move.amount) }
: {});
const elites =
(move.eliteForces as Record<string, number> | undefined) ??
(typeof move.from === 'string'
? { [move.from]: Number(move.elite ?? 0) }
: {});
const marker =
game.richeseNoField?.owner === game.me
? game.richeseNoField.private?.deployed
: null;
try {
validateCohortSelection(
active.cohort,
me.forces,
me.elites?.forces ?? {},
forces,
elites,
typeof move.noField === 'string' && marker
? {
tokenId: move.noField,
event: typeof move.event === 'string' ? move.event : '',
from: location(marker.location.territory, marker.location.sector),
}
: undefined,
);
} catch (error) {
reason =
error instanceof Error && /cohort|original unmoved|marker/i.test(error.message)
? 'Choose forces that have not moved with this card. The first group cannot move again.'
: 'The selected force counts are no longer available. Choose an available group.';
}
}
return (
<section
className="my-4 flex min-w-0 flex-col gap-4"
aria-labelledby={`${id}-heading`}
>
<h3 id={`${id}-heading`}>
{active
? 'Ornithopter movement in progress'
: 'Use Ornithopter for this movement'}
</h3>
<CardRules card={card.card} />
<CardInspector card={card.card} />
{active ? (
<>
<p>
{active.completed} group moved · {active.remaining} remaining.{' '}
{active.mode === 'twoGroups'
? 'The next group must use different original forces.'
: 'This group has a maximum range of three territories.'}
</p>
{!!active.cohort && (
<div className="flex flex-col gap-2">
<p>Original unmoved forces available for selection:</p>
<ul>
{Object.entries(active.cohort.forces)
.filter(([, n]) => n > 0)
.map(([key, n]) => {
const source = splitLocation(key);
return (
<li key={key}>
{gameTerritories(game).find(
(t) => t.id === source.territory,
)?.name ?? source.territory}
, sector {source.sector}: {n} forces{me.elites ? `, including ${active.cohort!.elites[key] ?? 0} elite` : ''}.
</li>
);
})}
{active.cohort.noField && (
<li>
One original unmoved concealed No-Field; select its current
source above.
</li>
)}
</ul>
</div>
)}
</>
) : (
<>
<label htmlFor={`${id}-mode`}>Card movement mode</label>
<select
id={`${id}-mode`}
className="min-h-11 w-full min-w-0"
value={mode}
disabled={busy}
onChange={(e) => setSelected(e.target.value as OrnithopterMode)}
>
{card.modes.map((option) => (
<option
key={option.mode}
value={option.mode}
disabled={!!option.blocked}
>
{option.mode === 'range3'
? 'One group, up to three territories'
: 'Two different groups, normal movement'}
</option>
))}
</select>
{card.modes
.filter((option) => option.blocked)
.map((option) => (
<p className="fine" key={option.mode}>
{option.mode === 'range3'
? 'Three-territory mode'
: 'Two-group mode'}
: {option.blocked}
</p>
))}
<p className="fine">
Uses the forces, elite mix, No-Field and destination selected above.
The card is played with the first move and discarded when its use
ends. Storm and entry rules still apply.
</p>
</>
)}
{reason && (
<p className="notice" id={`${id}-reason`}>
{reason}
</p>
)}
<Button
className="min-h-11 whitespace-normal motion-reduce:transition-none"
disabled={busy || !!reason}
aria-describedby={reason ? `${id}-reason` : undefined}
onClick={() => {
if (busy || reason || !move || !mode) return;
const action = { ...move };
delete action.movementCard;
delete action.ornithopter;
delete action.ornithopterEvent;
act({
...action,
...(active
? { ornithopterEvent: active.event }
: { movementCard: card.card.id, ornithopter: mode }),
});
}}
>
{active
? 'Move next selected group'
: 'Play Ornithopter and move selected group'}
</Button>
</section>
);
}
+60
View File
@@ -0,0 +1,60 @@
'use client';
import { useId } from 'react';
import type { Action, GameView } from '@/game/engine';
import { Button } from './ui/button';
import { CardInspector, CardRules } from './card-inspector';
export function PortableSnooper({
game,
act,
busy,
}: {
game: GameView;
act: (action: Action) => void;
busy: boolean;
}) {
const id = useId();
const snooper = game.portableSnooper;
if (!snooper) return null;
const reason =
snooper.blocked ??
(!snooper.event ? 'A revealed battle is required for late defense.' : null);
return (
<details className="my-4 min-w-0">
<summary className="min-h-11 cursor-pointer py-3">
Portable Snooper: poison defense
</summary>
<div className="flex min-w-0 flex-col gap-4 py-3">
<p>
Choose this card as your normal battle defense, or play it after plans
are revealed when you have room for a late defense. Finish any current
interaction and play before submitting your traitor decision. It
protects against ordinary poison, but does not stop Poison Tooth.
</p>
<CardRules card={snooper.card} />
<CardInspector card={snooper.card} />
{reason && (
<p className="notice" id={`${id}-reason`}>
{reason}
</p>
)}
<Button
className="min-h-11 whitespace-normal motion-reduce:transition-none"
disabled={busy || !!reason}
aria-describedby={reason ? `${id}-reason` : undefined}
onClick={() => {
if (!busy && !reason && snooper.event)
act({
type: 'portableSnooper',
card: snooper.card.id,
event: snooper.event,
});
}}
>
Play Portable Snooper as a late defense
</Button>
</div>
</details>
);
}
+82
View File
@@ -0,0 +1,82 @@
'use client';
import type { GameView } from '@/game/engine';
import { HelpTip } from './help-tip';
import { faction } from '@/game/catalog';
import { leaderStrengthLabel } from '@/game/cards';
import { BattleComponentInspection } from './battle-component-inspection';
export function PrivateBattlePlan({ game }: { game: GameView }) {
const insight = game.battle?.fullPlanInsight;
if (
!insight ||
game.battle?.revealed ||
game.battle?.fullPlan?.owner !== game.me
)
return null;
const leader = game.allLeaders.find((l) => l.id === insight.plan.leader);
const name = (id: string | null) =>
id
? (insight.cards.find((c) => c.id === id)?.name ??
game.allLeaders.find((l) => l.id === id)?.name ??
id)
: 'None';
return (
<section
className="private-battle-plan"
aria-label="Private battle plan inspection"
>
<span className="eyebrow">Atreides · private inspection</span>
<h3>
{game.players.find((p) => p.id === insight.target)?.name}’s committed
plan <HelpTip topic="fullPlan" />
</h3>
<p className="muted">
Only you receive this inspection, including when you are helping an
ally. It stays available until both plans are publicly revealed. The
other combatant can prepare and seal their plan without an inspection
confirmation.
</p>
<dl>
<div>
<dt>Dial</dt>
<dd>{insight.plan.dial}</dd>
</div>
<div>
<dt>Spice support</dt>
<dd>{insight.plan.support}</dd>
</div>
<div>
<dt>Leader</dt>
<dd>{name(insight.plan.leader)}</dd>
</div>
<div>
<dt>Weapon</dt>
<dd>{name(insight.plan.weapon)}</dd>
</div>
<div>
<dt>Defense</dt>
<dd>{name(insight.plan.defense)}</dd>
</div>
<div>
<dt>Kwisatz Haderach</dt>
<dd>{insight.plan.kwisatz ? 'Included' : 'Not included'}</dd>
</div>
</dl>
<BattleComponentInspection
playerName={
game.players.find((p) => p.id === insight.target)?.name ?? 'Player'
}
cards={insight.cards}
leader={
leader
? {
name: leader.name,
factionName: faction(leader.faction).name,
strength: leaderStrengthLabel(leader),
}
: undefined
}
/>
</section>
);
}
+130
View File
@@ -0,0 +1,130 @@
'use client';
import { useId } from 'react';
import type { Action, GameView } from '@/game/engine';
import { Button } from './ui/button';
import { CardInspector, CardRules } from './card-inspector';
type PreparationProps = {
game: GameView;
act: (action: Action) => void;
busy: boolean;
};
export function ResidualPoison({ game, act, busy }: PreparationProps) {
const id = useId();
const poison = game.residualPoison;
if (!poison) return null;
const target = game.players.find((player) => player.id === poison.target);
const reason =
poison.blocked ??
(!target || !poison.event
? 'Use this card against your opponent before battle leaders are chosen.'
: null);
return (
<details className="my-4 min-w-0">
<summary className="min-h-11 cursor-pointer py-3">
Residual Poison: before choosing leaders
</summary>
<div className="flex min-w-0 flex-col gap-4 py-3">
<p>
Play against your actual battle opponent. One available opposing
leader is randomly sent to the Tanks, with no spice bounty, then
Residual Poison is discarded. You do not choose the leader.
</p>
<CardRules card={poison.card} />
<CardInspector card={poison.card} />
{reason && (
<p className="notice" id={`${id}-reason`}>
{reason}
</p>
)}
<Button
className="min-h-11 whitespace-normal motion-reduce:transition-none"
disabled={busy || !!reason}
aria-describedby={reason ? `${id}-reason` : undefined}
onClick={() => {
if (!busy && !reason && poison.target && poison.event)
act({
type: 'card',
card: poison.card.id,
target: poison.target,
event: poison.event,
});
}}
>
Play Residual Poison against {target?.name ?? 'your battle opponent'}
</Button>
</div>
</details>
);
}
/** Neutral public timing controls must never depend on either player's hand. */
export function BattleLeaderOpportunity({ game, act, busy }: PreparationProps) {
const id = useId(),
battle = game.battle,
stage = battle?.preLeader;
if (!battle || !stage || stage.closed) return null;
const combatants = [battle.attacker, battle.defender];
const mine = combatants.includes(game.me),
ready = stage.ready.includes(game.me);
const interrupted = !!(
game.truthtrance ||
game.response ||
game.decision ||
game.phaseOpening
);
return (
<section
className="my-4 flex min-w-0 flex-col gap-3"
aria-labelledby={`${id}-heading`}
>
<h3 id={`${id}-heading`}>Before choosing battle leaders</h3>
<p>
Both combatants have an opportunity to use available preparations before
either commits a leader. Continue when you are ready to choose leaders.
</p>
<ul>
{combatants.map((player) => (
<li key={player}>
{game.players.find((p) => p.id === player)?.name ?? 'Combatant'}:{' '}
{stage.ready.includes(player)
? 'Ready to choose leaders'
: 'Preparing'}
</li>
))}
</ul>
{mine &&
(!ready ? (
<>
{interrupted && (
<p className="notice" id={`${id}-reason`}>
Resolve the current interaction before continuing.
</p>
)}
<Button
className="min-h-11 whitespace-normal motion-reduce:transition-none"
disabled={busy || interrupted}
aria-describedby={interrupted ? `${id}-reason` : undefined}
onClick={() => {
if (!busy && !interrupted)
act({ type: 'battlePreparationReady', event: stage.event });
}}
>
Ready to choose leaders
</Button>
</>
) : (
<p className="fine">
You are ready. Waiting for the other combatant.
</p>
))}
{!mine && (
<p className="fine">
Waiting for the combatants to finish preparation.
</p>
)}
</section>
);
}
+115
View File
@@ -0,0 +1,115 @@
import type { GameView } from '@/game/engine';
import { faction } from '@/game/catalog';
import { leaderStrengthLabel } from '@/game/cards';
import { isStoneBurner } from '@/game/battle-cards';
import { stoneBurnerComparison } from '@/game/stone-burner';
import { BattleComponentInspection } from './battle-component-inspection';
export function RevealedBattle({ game }: { game: GameView }) {
const battle = game.battle;
if (!battle?.revealed) return null;
const attackerPlan = battle.plans[battle.attacker];
const defenderPlan = battle.plans[battle.defender];
const comparisons =
battle.cards.some(isStoneBurner) &&
battle.ownForces &&
attackerPlan &&
defenderPlan
? (battle.stoneBurnerContext?.opponentPools ?? []).map((opponent) =>
stoneBurnerComparison(
game.me === battle.attacker ? battle.ownForces! : opponent,
attackerPlan.dial,
attackerPlan.support,
game.me === battle.defender ? battle.ownForces! : opponent,
defenderPlan.dial,
defenderPlan.support,
),
)
: [];
const name = (id: string | null) =>
id
? (game.allLeaders.find((l) => l.id === id)?.name ??
battle.cards.find((c) => c.id === id)?.name ??
id)
: 'None';
return (
<div className="revealed-plans" aria-label="Revealed battle plans">
{Object.entries(battle.plans).map(([id, plan]) => {
const leader = game.allLeaders.find((l) => l.id === plan.leader);
const undialed = [
...new Set(
comparisons.flatMap(
(comparison) =>
comparison[id === battle.attacker ? 'attacker' : 'defender'],
),
),
].sort((a, b) => a - b);
return (
<div key={id}>
<b>{game.players.find((p) => p.id === id)?.name}</b>
<span>
Dial {plan.dial}
{game.advanced ? ` · ${plan.support} spice support` : ''}
{!!plan.allyPayment && ` (${plan.allyPayment} from CHOAM)`}
</span>
<span>Leader: {name(plan.leader)}</span>
{plan.kwisatz && (
<span>
Kwisatz Haderach · +2 if leader survives · protected from
traitors
</span>
)}
<span>Weapon: {name(plan.weapon)}</span>
{undialed.length > 0 && (
<span>
Stone Burner undialed tokens: {undialed.join(' or ')}
{undialed.length > 1
? ' (possible counts before casualty choice)'
: ''}
</span>
)}
{battle.stoneBurner[id] && (
<span>
Stone Burner:{' '}
{battle.stoneBurner[id] === 'kill'
? 'both leaders die'
: 'surviving leader strength ignored'}{' '}
· compare undialed force tokens
</span>
)}
{battle.poisonTooth[id] !== undefined && (
<span>
Poison Tooth: {battle.poisonTooth[id] ? 'activated' : 'unused'}
</span>
)}
<span>Defense in original plan: {name(plan.defense)}</span>
{battle.lateDefense[id] && (
<span>Added after reveal: {name(battle.lateDefense[id])}</span>
)}
<BattleComponentInspection
playerName={
game.players.find((p) => p.id === id)?.name ?? 'Player'
}
cards={battle.cards.filter((card) =>
[
plan.leader,
plan.weapon,
plan.defense,
battle.lateDefense[id],
].includes(card.id),
)}
leader={
leader
? {
name: leader.name,
factionName: faction(leader.faction).name,
strength: leaderStrengthLabel(leader),
}
: undefined
}
/>
</div>
);
})}
</div>
);
}
+243
View File
@@ -0,0 +1,243 @@
'use client';
import { controlsLeader, nativeAvailable } from '@/game/leader-control';
import { useState } from 'react';
import type { Action, GameView } from '@/game/engine';
import { Button } from './ui/button';
import { Input } from './ui/input';
export function RevivalCommerce({
game: g,
act,
busy,
}: {
game: GameView;
act: (action: Action) => void;
busy: boolean;
}) {
const me = g.players.find((p) => p.id === g.me)!;
const tleilaxu = g.players.find((p) => p.faction === 'tleilaxu');
const [selection, setSelection] = useState('');
const [prices, setPrices] = useState<Record<string, number>>({});
if (!tleilaxu) return null;
const name = (id: string) =>
id === 'kwisatz'
? 'Kwisatz Haderach'
: (g.allLeaders.find((l) => l.id === id)?.name ?? id);
if (me.id === tleilaxu.id)
return (
<div className="notice">
<h3>Revival commerce</h3>
<p>
Allow another faction to revive five forces. Your ally pays for their
own revivals; you may offer half price.
</p>
{g.advanced && (
<div>
<h4>Foreign gholas</h4>
<p>
Fill your active leader pool up to five. A ghola keeps its traitor
identity and can be sold back only after it dies.
</p>
{g.players
.filter((p) => p.id !== me.id)
.flatMap((p) => p.leaders)
.filter(
(l) =>
l.faction !== me.faction &&
l.dead &&
!l.capturedBy &&
l.name !== 'Auditor',
)
.map((l) => {
const cost = g.revival.discount
? Math.ceil(l.strength / 2)
: l.strength;
return (
<Button
key={l.id}
variant="outline"
disabled={
busy ||
cost > (me.spice ?? 0) ||
me.gholaBlocked?.includes(l.id) ||
me.leaders.filter(
(leader) => !leader.dead && controlsLeader(me, leader),
).length >= 5
}
onClick={() =>
act({ type: 'reviveForeignGhola', leader: l.id })
}
>
Revive {l.name} as a ghola · {cost} spice
</Button>
);
})}
</div>
)}
{g.players
.filter((p) => p.id !== me.id)
.map((p) => (
<div key={p.id}>
<span>
{p.name} · {p.tanks} forces in the tanks{' '}
</span>
<Button
variant="outline"
disabled={
busy ||
g.revivalRules.limitBlocked ||
g.revivalRules.expanded.includes(p.id)
}
onClick={() =>
act({ type: 'tleilaxuRevivalLimit', target: p.id })
}
>
Allow five revivals
</Button>
</div>
))}
{me.ally && (
<Button
disabled={
busy ||
g.revivalRules.discountBlocked ||
g.revivalRules.allyDiscount === me.ally
}
onClick={() => act({ type: 'tleilaxuAllyDiscount' })}
>
Offer ally half-price revival
</Button>
)}
{Object.entries(g.revivalRequests).map(([id, request]) => (
<div key={id}>
<p>
{g.players.find((p) => p.id === id)?.name} requests{' '}
{name(request.leader)}.
</p>
<label htmlFor={`revival-price-${id}`}>
Price in spice
<Input
id={`revival-price-${id}`}
type="number"
min={0}
max={1000000}
value={prices[id] ?? 0}
onChange={(e) =>
setPrices({ ...prices, [id]: Number(e.target.value) })
}
/>
</label>
<Button
disabled={busy}
onClick={() =>
act({
type: 'quoteLeaderRevival',
target: id,
amount: prices[id] ?? 0,
})
}
>
Quote this price
</Button>
<Button
variant="outline"
disabled={busy}
onClick={() => act({ type: 'declineLeaderRevival', target: id })}
>
Decline request
</Button>
<p className="fine">
{request.declined
? 'Declined'
: request.price === null
? 'No quote yet'
: `Current quote: ${request.price} spice`}
</p>
</div>
))}
</div>
);
const request = g.revivalRequests[me.id];
const deadNative = me.leaders.filter(
(l) => l.faction === me.faction && l.dead && !l.capturedBy,
);
const hasNative = me.leaders.some(
(l) => l.faction === me.faction && nativeAvailable(l),
);
const eligible = deadNative.filter((l) => hasNative || l.gholaBy);
const canRequest =
!g.revival.prevented &&
!me.leaderRevived &&
(hasNative || eligible.some((l) => l.gholaBy));
return (
<div className="notice">
<h3>Request a leader revival or ghola buyback</h3>
<p>
A request and its quoted price are visible only to you and Tleilaxu. No
spice is paid until you accept and the response resolves.
</p>
{request ? (
<>
<p>
{name(request.leader)} ·{' '}
{request.declined
? 'Request declined'
: request.price === null
? 'Awaiting a quote'
: `${request.price} spice`}
</p>
{request.price !== null && !request.declined && (
<Button
disabled={busy || request.price > (me.spice ?? 0)}
onClick={() => act({ type: 'acceptLeaderRevival' })}
>
Accept revival price
</Button>
)}
<Button
variant="outline"
disabled={busy}
onClick={() => act({ type: 'cancelLeaderRequest' })}
>
Withdraw request
</Button>
</>
) : canRequest && (eligible.length > 0 || me.kwisatz?.dead) ? (
<>
<label htmlFor="revival-request-leader">
Leader
<select
id="revival-request-leader"
value={selection}
onChange={(e) => setSelection(e.target.value)}
>
<option value="">Choose a dead leader</option>
{eligible.map((l) => (
<option key={l.id} value={l.id}>
{l.name}
{l.gholaBy ? ' · ghola buyback' : ''}
</option>
))}
{me.kwisatz?.dead && (
<option value="kwisatz">Kwisatz Haderach</option>
)}
</select>
</label>
<Button
disabled={busy || !selection}
onClick={() =>
act({ type: 'requestLeaderRevival', leader: selection })
}
>
Request a quote
</Button>
</>
) : (
<p className="fine">
Early revival needs an available native leader. A dead foreign ghola
requires a buyback agreement; other leaders use ordinary revival when
all are unavailable.
</p>
)}
</div>
);
}
+279
View File
@@ -0,0 +1,279 @@
'use client';
import { useId, useState } from 'react';
import type { Action, GameView } from '@/game/engine';
import { territory, MOBILE_STRONGHOLD } from '@/game/board';
import { Button } from './ui/button';
import { Input } from './ui/input';
export function RicheseAlliedNoFieldControls({
game,
act,
busy,
destination,
sector,
onSectorChange,
}: {
game: GameView;
act: (action: Action) => void;
busy: boolean;
destination: string;
sector: number;
onSectorChange: (sector: number) => void;
}) {
const id = useId();
const [token, setToken] = useState('');
const [payer, setPayer] = useState('');
const info = game.richeseNoField;
const me = game.players.find((player) => player.id === game.me);
const ally = game.players.find((player) => player.id === me?.ally);
if (!info || info.owner !== game.me || !info.private || !me || !ally)
return null;
const offer = info.allyOffer;
if (offer?.blocked)
return (
<p className="notice">
This allied shipment offer is no longer available: {offer.blocked} Your
ally may decline it without payment or token changes.
</p>
);
if (offer)
return (
<p className="notice">
Your private No-Field {offer.value} offer to {ally.name}: {offer.amount}{' '}
forces in {territory(offer.territory).name}, sector {offer.sector}.{' '}
{offer.payer === 'both'
? 'Each ally pays 1 spice'
: `${game.players.find((player) => player.id === offer.payer)?.name} pays ${offer.cost} spice`}{' '}
if the shipment completes. Declining costs nothing.
</p>
);
if (game.phase !== 5 || game.active !== ally.id || ally.shipped) return null;
const available = info.private.tokens.filter(
(candidate) => candidate.id !== info.private!.lastShipped,
);
const selected =
available.find((candidate) => candidate.id === token) ?? available[0];
const target = territory(destination);
const cost = target.type === 'stronghold' ? 1 : 2;
const selectedPayer =
payer === 'both' && cost === 2
? 'both'
: payer === ally.id
? ally.id
: me.id;
const reasons: string[] = [];
if (!info.canOfferAlly)
reasons.push(
info.allyOfferBlock ?? 'An allied No-Field offer is unavailable now.',
);
if (!selected)
reasons.push('Choose a token different from the last one used.');
if (destination === MOBILE_STRONGHOLD)
reasons.push('Direct shipment into the mobile stronghold is unavailable.');
if (!target.sectors.includes(sector))
reasons.push('Choose a sector in this territory.');
if (sector === game.storm)
reasons.push('You cannot ship into the storm sector.');
const ownCost =
selectedPayer === 'both' ? 1 : selectedPayer === me.id ? cost : 0;
if ((me.spice ?? 0) < ownCost)
reasons.push('You do not have enough spice to pay for this shipment.');
return (
<details className="my-4" open>
<summary className="min-h-11 cursor-pointer py-3 font-semibold">
Offer {ally.name} a No-Field shipment
</summary>
<div className="flex flex-col gap-3 py-3">
<p className="fine">
This replaces your ally’s normal shipment if accepted. The chosen
value and exact force count are shared privately with your ally. The
token reveals when the shipment resolves. No payment or existing
marker changes occur on decline.
</p>
{info.private.deployed && (
<p className="fine">
If accepted and allowed, your existing concealed No-Field must
reveal before your ally’s shipment.
</p>
)}
<label htmlFor={`${id}-token`}>Private token</label>
<select
id={`${id}-token`}
className="min-h-11"
value={selected?.id ?? ''}
disabled={busy}
onChange={(event) => setToken(event.target.value)}
>
{info.private.tokens.map((candidate) => (
<option
key={candidate.id}
value={candidate.id}
disabled={candidate.id === info.private!.lastShipped}
>
No-Field {candidate.value}
{candidate.id === info.private!.lastShipped
? ' · cannot repeat'
: ''}
</option>
))}
</select>
<p>Destination: {target.name}. Select another territory on the map.</p>
<label htmlFor={`${id}-sector`}>Sector</label>
<select
id={`${id}-sector`}
className="min-h-11"
value={sector}
disabled={busy}
onChange={(event) => onSectorChange(Number(event.target.value))}
>
{target.sectors.map((value) => (
<option key={value} value={value}>
{value}
{value === game.storm ? ' · storm' : ''}
</option>
))}
</select>
<label htmlFor={`${id}-payer`}>Pay the {cost} spice cost from</label>
<select
id={`${id}-payer`}
className="min-h-11"
value={selectedPayer}
disabled={busy}
onChange={(event) => setPayer(event.target.value)}
>
<option value={me.id}>{me.name} · you</option>
<option value={ally.id}>{ally.name} · your ally</option>
{cost === 2 && <option value="both">Each ally pays 1 spice</option>}
</select>
<p className="fine">
{cost === 2
? 'Either player can pay the full price, or each ally can pay 1 spice.'
: 'One player pays the full price.'}{' '}
Each payer’s funds are checked before acceptance and commitment; your
ally’s private balance stays hidden.
</p>
{reasons.length > 0 && (
<ul id={`${id}-reasons`} className="fine">
{reasons.map((reason) => (
<li key={reason}>{reason}</li>
))}
</ul>
)}
<Button
className="min-h-11 whitespace-normal"
disabled={busy || reasons.length > 0}
aria-describedby={reasons.length ? `${id}-reasons` : undefined}
onClick={() => {
if (!busy && !reasons.length && selected)
act({
type: 'offerRicheseNoField',
token: selected.id,
event: info.event,
territory: destination,
sector,
payer: selectedPayer,
});
}}
>
Offer No-Field {selected?.value ?? ''} shipment
</Button>
</div>
</details>
);
}
export function RicheseAlliedNoFieldDecision({
game,
act,
busy,
}: {
game: GameView;
act: (action: Action) => void;
busy: boolean;
}) {
const id = useId();
const [elite, setElite] = useState(0);
const offer = game.richeseNoField?.allyOffer;
if (
!offer ||
offer.recipient !== game.me ||
game.decision?.kind !== 'richeseAllyShipment'
)
return null;
const owner = game.players.find((player) => player.id === offer.owner)!;
const payer = game.players.find((player) => player.id === offer.payer);
const selectedElite = Math.max(
offer.eliteMin,
Math.min(
offer.eliteMax,
Number.isFinite(elite) ? Math.trunc(elite) : offer.eliteMin,
),
);
return (
<div className="flex flex-col gap-3">
{!offer.blocked && (
<p>
{owner.name} offers No-Field {offer.value} to ship {offer.amount} of
your forces to {territory(offer.territory).name}, sector{' '}
{offer.sector}.{' '}
{offer.payer === 'both'
? `You and ${owner.name} each pay 1 spice`
: `${payer?.id === game.me ? 'You pay' : `${payer?.name} pays`} ${offer.cost} spice`}{' '}
if the shipment completes.
</p>
)}
<p className="fine">
The chosen token and force count are private to this alliance until the
shipment reveals them. Accepting uses your normal shipment opportunity;
declining costs nothing. Any earlier concealed No-Field reveals only if
the accepted shipment proceeds.
</p>
{offer.blocked && (
<p className="notice" id={`${id}-blocked`}>
{offer.blocked} You can decline without cost.
</p>
)}
{!offer.blocked && offer.eliteMax > 0 && (
<label htmlFor={`${id}-elite`}>
Elite forces included ({offer.eliteMin}–{offer.eliteMax})
<Input
id={`${id}-elite`}
type="number"
min={offer.eliteMin}
max={offer.eliteMax}
value={selectedElite}
disabled={busy}
onChange={(event) => setElite(Number(event.target.value))}
/>
</label>
)}
<Button
className="min-h-11 whitespace-normal"
disabled={busy || !!offer.blocked}
aria-describedby={offer.blocked ? `${id}-blocked` : undefined}
onClick={() => {
if (!busy && !offer.blocked)
act({
type: 'decision',
event: offer.event,
accept: true,
elite: selectedElite,
});
}}
>
Accept allied No-Field shipment
</Button>
<Button
className="min-h-11"
disabled={busy}
onClick={() => {
if (!busy)
act({ type: 'decision', event: offer.event, decline: true });
}}
>
Decline offer · no cost
</Button>
</div>
);
}
+429
View File
@@ -0,0 +1,429 @@
'use client';
import { useId, useState } from 'react';
import type { Action, GameView } from '@/game/engine';
import type { RicheseAuctionMethod } from '@/game/richese-auction';
import { Button } from './ui/button';
import { Input } from './ui/input';
import { CardInspector, CardRules } from './card-inspector';
type Props = { game: GameView; act: (action: Action) => void; busy: boolean };
const buttonClass = 'min-h-11 whitespace-normal motion-reduce:transition-none';
const methodName = {
normal: 'Normal auction',
onceAround: 'Once Around',
silent: 'Silent auction',
};
/** Only supplied owner-projected hand/cache identities are selectable. */
export function RicheseAuctionDecision({ game, act, busy }: Props) {
const id = useId();
const [cardId, setCardId] = useState('');
const [method, setMethod] = useState<RicheseAuctionMethod>('onceAround');
const [direction, setDirection] = useState<'clockwise' | 'counterclockwise'>(
'clockwise',
);
const [claim, setClaim] = useState('');
const bidding = game.richeseBidding;
const decision = game.decision;
const me = game.players.find((player) => player.id === game.me);
if (
!bidding ||
!me ||
decision?.player !== game.me ||
bidding.owner !== game.me ||
![
'richeseBlackMarket',
'richeseDeclaration',
'richeseCache',
'richeseUnbid',
].includes(decision.kind)
)
return null;
const send = (fields: Omit<Action, 'type'>) => {
if (!busy) act({ type: 'decision', event: bidding.event, ...fields });
};
if (decision.kind === 'richeseDeclaration')
return (
<section
className="flex flex-col gap-4"
aria-label="Richese auction position"
>
<p>
Choose whether your face-up cache card is auctioned before or after
the ordinary cards. This declaration precedes Ixian auction
inspection.
</p>
<Button
className={buttonClass}
disabled={busy}
onClick={() => send({ position: 'first' })}
>
Auction cache card first
</Button>
<Button
className={buttonClass}
variant="outline"
disabled={busy}
onClick={() => send({ position: 'last' })}
>
Auction cache card last
</Button>
</section>
);
if (decision.kind === 'richeseUnbid') {
const room = (me.hand?.length ?? 0) < me.handLimit;
return (
<section
className="flex flex-col gap-4"
aria-label="Unsold Richese cache card"
>
<p>
No player offered spice. Keep the cache card for free if your hand has
room, or remove it from this game.
</p>
{game.richeseAuction?.card && (
<>
<CardRules card={game.richeseAuction.card} />
<CardInspector card={game.richeseAuction.card} />
</>
)}
{!room && (
<p className="fine">Your hand is full; the card cannot be added.</p>
)}
<Button
className={buttonClass}
disabled={busy || !room}
onClick={() => send({ keep: true })}
>
Keep card for free
</Button>
<Button
className={buttonClass}
variant="outline"
disabled={busy}
onClick={() => send({ keep: false })}
>
Remove card from game
</Button>
</section>
);
}
const blackMarket = decision.kind === 'richeseBlackMarket';
const cards = blackMarket ? (me.hand ?? []) : (bidding.cache ?? []);
const selected = cards.find((card) => card.id === cardId) ?? cards[0];
const chosenMethod =
!blackMarket && method === 'normal' ? 'onceAround' : method;
return (
<section
className="flex min-w-0 flex-col gap-4"
aria-label={blackMarket ? 'Black Market offer' : 'Richese cache offer'}
>
<p className="m-0 text-sm leading-6">
{blackMarket
? 'Offer a card from your hand without showing its face. Atreides may inspect it. An optional public claim may be truthful or a bluff. If no one pays spice, you keep the card.'
: 'Choose one card from your separate cache. Its face is revealed to everyone when offered. Cache cards do not count toward your hand limit.'}
</p>
{bidding.offerBlocked && (
<p id={`${id}-offer-blocked`} className="notice">
{bidding.offerBlocked}
</p>
)}
{cards.length ? (
<>
<label htmlFor={`${id}-card`}>Card to auction</label>
<select
id={`${id}-card`}
value={selected?.id ?? ''}
disabled={busy}
className="min-h-11 max-w-full"
onChange={(event) => setCardId(event.target.value)}
>
{cards.map((card) => (
<option key={card.id} value={card.id}>
{card.name}
</option>
))}
</select>
{selected && (
<>
<CardRules card={selected} />
<CardInspector card={selected} />
</>
)}
<label htmlFor={`${id}-method`}>Auction method</label>
<select
id={`${id}-method`}
value={chosenMethod}
disabled={busy}
className="min-h-11 max-w-full"
onChange={(event) =>
setMethod(event.target.value as RicheseAuctionMethod)
}
>
{blackMarket && <option value="normal">Normal auction</option>}
<option value="onceAround">Once Around</option>
<option value="silent">Silent auction</option>
</select>
{chosenMethod === 'onceAround' && (
<>
<label htmlFor={`${id}-direction`}>Bidding direction</label>
<select
id={`${id}-direction`}
value={direction}
disabled={busy}
className="min-h-11"
onChange={(event) =>
setDirection(event.target.value as typeof direction)
}
>
<option value="clockwise">Clockwise</option>
<option value="counterclockwise">Counterclockwise</option>
</select>
</>
)}
{blackMarket && (
<>
<label htmlFor={`${id}-claim`}>Public claim (optional)</label>
<Input
id={`${id}-claim`}
value={claim}
maxLength={300}
disabled={busy}
onChange={(event) => setClaim(event.target.value)}
placeholder="What you say you are selling"
/>
</>
)}
<p className="fine">
Karama cannot acquire this auction’s card.{' '}
{chosenMethod === 'silent'
? 'Every eligible player submits once; zero is allowed. Equal positive bids use storm order.'
: chosenMethod === 'onceAround'
? 'Each eligible player has one bidding opportunity, normally ending with Richese.'
: 'Bidding continues in order until the other bidders pass.'}
</p>
<Button
className={buttonClass}
aria-describedby={
bidding.offerBlocked ? `${id}-offer-blocked` : undefined
}
disabled={busy || !selected || !!bidding.offerBlocked}
onClick={() =>
send({
card: selected!.id,
method: chosenMethod,
direction,
...(blackMarket && claim.trim() ? { claim } : {}),
})
}
>
Offer {blackMarket ? 'Black Market' : 'cache'} card
</Button>
</>
) : (
<p>No card is available to offer.</p>
)}
{blackMarket && (
<Button
className={buttonClass}
variant="outline"
disabled={busy}
onClick={() => send({ decline: true })}
>
Skip Black Market
</Button>
)}
</section>
);
}
/** Only projected public bids and this viewer's own sealed submission are rendered. */
export function RicheseAuctionLot({ game, act, busy }: Props) {
const id = useId();
const auction = game.richeseAuction;
const [draft, setDraft] = useState({ event: '', amount: '', ally: '0' });
if (!auction) return null;
const me = game.me;
const silent = auction.method === 'silent';
const minimum = silent ? 0 : auction.bid + 1;
const amount =
draft.event === auction.event && draft.amount !== ''
? Number(draft.amount)
: minimum;
const allyPayment = draft.event === auction.event ? Number(draft.ally) : 0;
const update = (part: Partial<typeof draft>) =>
setDraft({
event: auction.event,
amount: String(amount),
ally: String(allyPayment),
...part,
});
const ownPayment = amount - allyPayment;
const sellerBlocked =
auction.source === 'blackMarket' && auction.owner === me;
const allowedTurn =
!auction.outcome &&
auction.eligible.includes(me) &&
(silent ? !auction.submitted.includes(me) : auction.active === me);
const canBid =
allowedTurn &&
Number.isSafeInteger(amount) &&
amount >= minimum &&
Number.isSafeInteger(allyPayment) &&
allyPayment >= 0 &&
allyPayment <= auction.allyAvailable &&
ownPayment >= 0 &&
ownPayment <= auction.ownAvailable &&
(!sellerBlocked || amount === 0);
const name = (player: string | null) =>
game.players.find((p) => p.id === player)?.name ?? 'Player';
const send = (bid: number | null, ally: number) => {
if (!busy)
act({
type: 'richeseBid',
event: auction.event,
amount: bid,
allyPayment: ally,
});
};
return (
<section
className="flex min-w-0 flex-col gap-4"
aria-label="Richese auction"
>
<h3 className="m-0 font-serif text-xl">
{auction.source === 'cache' ? 'Richese cache' : 'Black Market'} ·{' '}
{methodName[auction.method]}
</h3>
{auction.card ? (
<>
<p className="m-0 font-semibold">{auction.card.name}</p>
{auction.source === 'blackMarket' && (
<p className="fine">
This face is private to you through ownership or authorized
inspection.
</p>
)}
<CardRules card={auction.card} />
<CardInspector card={auction.card} />
</>
) : (
<p>The Black Market card remains face down.</p>
)}
<p className="fine">
{name(auction.owner)} is selling. Karama cannot acquire this card.
</p>
{auction.claim && (
<p className="notice">Seller’s public claim: {auction.claim}</p>
)}
{silent ? (
<>
<p className="m-0">
{auction.submitted.length} of {auction.eligible.length} bids
submitted.
</p>
{auction.ownBid !== null && (
<p className="notice">
Your sealed bid: {auction.ownBid} spice, including{' '}
{auction.ownAllyPayment} pledged by your ally.
</p>
)}
{auction.revealedBids && (
<ul>
{Object.entries(auction.revealedBids).map(([player, bid]) => (
<li key={player}>
{name(player)}: {bid} spice
</li>
))}
</ul>
)}
</>
) : (
<p className="m-0">
Highest bid: {auction.bid} spice
{auction.bidder ? ` from ${name(auction.bidder)}` : ''}.
</p>
)}
{allowedTurn ? (
<>
<p className="fine">
Available funding: {auction.ownAvailable} own spice and{' '}
{auction.allyAvailable} pledged spice.
</p>
{sellerBlocked && (
<p className="notice">
Positive Black Market bids by the seller are not supported.{' '}
{silent ? 'Submit zero to continue.' : 'Pass to continue.'}
</p>
)}
{!sellerBlocked && (
<>
<label htmlFor={`${id}-bid`}>
{silent ? 'Sealed bid' : 'Your bid'} (minimum {minimum})
</label>
<Input
id={`${id}-bid`}
type="number"
min={minimum}
step={1}
max={auction.ownAvailable + auction.allyAvailable}
value={amount}
disabled={busy}
onChange={(event) => update({ amount: event.target.value })}
/>
{auction.allyAvailable > 0 && (
<>
<label htmlFor={`${id}-ally`}>Pay from ally’s pledge</label>
<Input
id={`${id}-ally`}
type="number"
min={0}
step={1}
max={Math.min(amount, auction.allyAvailable)}
value={allyPayment}
disabled={busy}
onChange={(event) => update({ ally: event.target.value })}
/>
</>
)}
<p className="fine">
{canBid
? `Payment if you win: ${ownPayment} own spice + ${allyPayment} ally spice.`
: 'Choose a valid bid and funding split within the available spice.'}{' '}
{silent
? 'Your submitted amount stays private until all bids are in and cannot be changed.'
: 'Raise the current bid or pass.'}
</p>
<Button
className={buttonClass}
disabled={busy || !canBid}
onClick={() => send(amount, allyPayment)}
>
{silent ? 'Seal bid' : 'Raise bid'}
</Button>
</>
)}
<Button
className={buttonClass}
variant="outline"
disabled={busy}
onClick={() => send(silent ? 0 : null, 0)}
>
{silent ? 'Submit zero' : 'Pass this bid'}
</Button>
</>
) : (
<p className="muted">
{auction.outcome
? 'The auction result is being resolved.'
: silent && auction.submitted.includes(me)
? 'Your bid is sealed. Waiting for the other bidders.'
: !auction.eligible.includes(me)
? 'You are not eligible to bid on this card.'
: `Waiting for ${name(auction.active)} to bid.`}
</p>
)}
</section>
);
}
+143
View File
@@ -0,0 +1,143 @@
'use client';
import { useId, useState } from 'react';
import type { Action, GameView } from '@/game/engine';
import { Button } from './ui/button';
import { CardInspector, CardRules } from './card-inspector';
/** Gift cards come only from this viewer's explicit server projection. */
export function RicheseGift({
game,
act,
busy,
}: {
game: GameView;
act: (action: Action) => void;
busy: boolean;
}) {
const id = useId();
const [selection, setSelection] = useState('');
const gift = game.richeseGift;
if (!gift) return null;
const owner = game.players.find((player) => player.id === gift.owner);
const recipient = game.players.find(
(player) => player.id === (gift.pending?.recipient ?? gift.recipient),
);
const mine = gift.owner === game.me;
const pending = gift.pending;
if (pending) {
if (![pending.owner, pending.recipient].includes(game.me)) return null;
return (
<section
className="my-4 flex min-w-0 flex-col gap-3"
aria-labelledby={`${id}-pending`}
>
<h3 id={`${id}-pending`}>Richese gift awaiting resolution</h3>
<p>
{mine
? 'You are giving'
: `${owner?.name ?? 'Richese'} is giving you`}{' '}
{pending.card?.name ?? 'a Richese card'}
{mine ? ` to ${recipient?.name ?? 'your ally'}` : ''}. Resolve the
current response before the card transfers. Inspecting it does not
transfer the card.
</p>
{pending.card && (
<div className="flex min-w-0 flex-col gap-3">
<CardRules card={pending.card} />
<CardInspector card={pending.card} />
</div>
)}
</section>
);
}
if (!mine) return null;
const card =
gift.cards.find((candidate) => candidate.id === selection) ?? gift.cards[0];
const reason =
gift.blocked ??
(!recipient
? 'You need an ally to give a Richese card.'
: !card
? 'No Richese card in your hand is available to give.'
: null);
return (
<details className="my-4 min-w-0">
<summary className="min-h-11 cursor-pointer py-3">
Give a Richese card to your ally
</summary>
<div className="flex min-w-0 flex-col gap-4 py-3">
<p>
Give one Richese card from your hand to{' '}
{recipient?.name ?? 'your ally'}. Your ally must have room. Karama can
prevent this alliance power before the card transfers.
</p>
{gift.cards.length > 0 && (
<>
<label htmlFor={`${id}-card`}>Card to give</label>
<select
id={`${id}-card`}
className="min-h-11 w-full min-w-0"
value={card?.id ?? ''}
disabled={busy}
onChange={(event) => setSelection(event.target.value)}
>
{gift.cards.map((candidate) => (
<option key={candidate.id} value={candidate.id}>
{candidate.name}
</option>
))}
</select>
{card && (
<div className="flex min-w-0 flex-col gap-3">
<CardRules card={card} />
<CardInspector card={card} />
</div>
)}
</>
)}
{reason && (
<p id={`${id}-reason`} className="notice">
{reason}
</p>
)}
<Button
className="min-h-11 whitespace-normal motion-reduce:transition-none"
disabled={busy || !!reason}
aria-describedby={reason ? `${id}-reason` : undefined}
onClick={() => {
if (!busy && !reason && card)
act({ type: 'richeseGift', card: card.id });
}}
>
Give {card?.name ?? 'a Richese card'} to{' '}
{recipient?.name ?? 'your ally'}
</Button>
{gift.unavailable.length > 0 && (
<div
className="flex min-w-0 flex-col gap-3"
aria-labelledby={`${id}-unavailable`}
>
<h4 id={`${id}-unavailable`}>Cards unavailable to give</h4>
<ul className="flex list-none flex-col gap-4 p-0">
{gift.unavailable.map(
({ card: unavailable, reason: unavailableReason }) => (
<li
key={unavailable.id}
className="flex min-w-0 flex-col gap-2"
>
<strong>{unavailable.name}</strong>
<p>{unavailableReason}</p>
<CardInspector card={unavailable} />
</li>
),
)}
</ul>
</div>
)}
</div>
</details>
);
}
+266
View File
@@ -0,0 +1,266 @@
'use client';
import { useId, useState } from 'react';
import type { Action, GameView } from '@/game/engine';
import { faction } from '@/game/catalog';
import { territory, MOBILE_STRONGHOLD } from '@/game/board';
import { fighterCount } from '@/game/advisors';
import { reserveShipmentCost } from '@/game/shipment-price';
import type { RicheseNoFieldView } from '@/game/richese-no-field';
import { ShipmentQuote } from './shipment-quote';
import { Button } from './ui/button';
/** This renderer receives public player data only, never the private token model. */
export function NoFieldBoardMarkers({
players,
}: {
players: readonly {
id: string;
name: string;
faction: string;
noField?: RicheseNoFieldView | null;
}[];
}) {
return players.flatMap((player) => {
const marker = player.noField?.deployed;
if (!marker) return [];
const t = territory(marker.location.territory);
const label = `${player.name}: concealed No-Field in ${t.name}, sector ${marker.location.sector}. Counts as one force until revealed.`;
return [
<g
key={`no-field-${player.id}`}
aria-label={label}
transform={`translate(${t.center[0] - 45},${t.center[1] - 23})`}
pointerEvents="none"
>
<title>{label}</title>
<rect
x="-16"
y="-15"
width="32"
height="30"
rx="5"
fill="#181921"
stroke={faction(player.faction).color}
strokeWidth="3"
strokeDasharray="5 2"
/>
<text
y="5"
textAnchor="middle"
fill="#f5ebd1"
fontSize="12"
fontWeight="800"
>
NF
</text>
</g>,
];
});
}
export function RicheseNoFieldControls({
game,
act,
busy,
destination,
sector,
allyPayment,
}: {
game: GameView;
act: (action: Action) => void;
busy: boolean;
destination: string;
sector: number;
allyPayment: string;
}) {
const id = useId();
const [selection, setSelection] = useState('');
const info = game.richeseNoField;
const me = game.players.find((player) => player.id === game.me);
if (!info || info.owner !== game.me || !info.private || !me) return null;
const state = info.private;
const available = state.tokens.filter(
(token) => token.id !== state.lastShipped,
);
const selected =
available.find((token) => token.id === selection) ?? available[0];
const deployed = state.deployed;
const selectedTerritory = territory(destination);
const rate = {
faction: me.faction,
halfRate:
me.faction === 'guild' ||
game.players.some(
(player) => player.faction === 'guild' && player.id === me.ally,
) ||
game.karamaShipping?.player === me.id,
};
const cost = reserveShipmentCost(rate, selectedTerritory.type, 1);
const share =
!me.ally || allyPayment === ''
? Math.max(0, cost - (me.spice ?? 0))
: Number(allyPayment);
const validShare = Number.isSafeInteger(share) && share >= 0 && share <= cost;
const problems: string[] = [];
if (!info.canShip)
problems.push(info.shipBlock ?? 'No-Field shipment is not available now.');
if (!selected) problems.push('No different No-Field token is available.');
if (destination === MOBILE_STRONGHOLD)
problems.push('You cannot ship directly into the mobile stronghold.');
if (
selectedTerritory.type === 'stronghold' &&
game.players.filter(
(player) => player.id !== me.id && fighterCount(player, destination) > 0,
).length >= 2
)
problems.push('Two other factions already occupy this stronghold.');
if (!selectedTerritory.sectors.includes(sector))
problems.push('Select a sector in the destination territory.');
if (sector === game.storm)
problems.push('You cannot ship into the storm sector.');
if (!validShare) problems.push(`Choose an ally payment from 0 to ${cost}.`);
else {
if (share > game.aid.available)
problems.push(
`Only ${game.aid.available} pledged ally spice is available.`,
);
if (cost - share > (me.spice ?? 0))
problems.push('Your spice does not cover the chosen payment.');
}
const showShipment = game.phase === 5 && game.active === me.id && !me.shipped;
return (
<details className="my-4" open={showShipment || !!deployed}>
<summary className="min-h-11 cursor-pointer py-3 font-semibold">
Your No-Field tokens
</summary>
<div className="flex flex-col gap-3 py-3">
<p className="fine">
Your token values are private until revealed. A concealed marker
counts as one force, even when its value is zero. Physical forces
remain in reserves until revelation.
</p>
<ul
className="m-0 flex flex-wrap gap-3 p-0"
aria-label="Private No-Field inventory"
>
{state.tokens.map((token) => (
<li
key={token.id}
className="list-none rounded border border-[#8e8159] px-3 py-2"
>
<strong>No-Field {token.value}</strong>
<span className="block text-sm">
{deployed?.tokenId === token.id
? 'Concealed on the board'
: state.lastShipped === token.id
? 'Last used · cannot repeat'
: 'Available'}
</span>
</li>
))}
</ul>
{deployed && (
<>
<p>
Concealed at {territory(deployed.location.territory).name}, sector{' '}
{deployed.location.sector}. Use the movement controls to move the
marker alone or with physical forces from that territory.
</p>
<Button
className="min-h-11 whitespace-normal"
disabled={busy || !info.canReveal}
aria-describedby={
!info.canReveal ? `${id}-reveal-reason` : undefined
}
onClick={() => {
if (!busy && info.canReveal)
act({
type: 'revealNoField',
event: info.event,
token: deployed.tokenId,
});
}}
>
Reveal your No-Field
</Button>
{!info.canReveal && (
<p id={`${id}-reveal-reason`} className="fine">
{info.revealBlock ??
'Voluntary revelation is not available now.'}
</p>
)}
</>
)}
{showShipment && (
<>
<label htmlFor={`${id}-token`}>Private token to ship</label>
<select
id={`${id}-token`}
className="min-h-11"
value={selected?.id ?? ''}
disabled={busy || !!deployed}
onChange={(event) => setSelection(event.target.value)}
>
{state.tokens.map((token) => (
<option
key={token.id}
value={token.id}
disabled={token.id === state.lastShipped}
>
No-Field {token.value}
{token.id === state.lastShipped ? ' · cannot repeat' : ''}
</option>
))}
</select>
<p className="fine">
Destination: {selectedTerritory.name}, sector {sector}. Choose
another destination on the map or in the shipment controls. This
replaces your normal shipment at the price of one force.
</p>
<ShipmentQuote
id={`${id}-quote`}
quote={
validShare
? {
cost,
normalCost: reserveShipmentCost(
{ ...rate, halfRate: false },
selectedTerritory.type,
1,
),
ownPayment: cost - share,
pledgedPayment: share,
}
: null
}
funding={{
ownSpice: me.spice ?? 0,
pledgedSpice: game.aid.available,
}}
unavailableReasons={problems}
/>
<Button
className="min-h-11 whitespace-normal"
disabled={busy || problems.length > 0}
aria-describedby={`${id}-quote`}
onClick={() => {
if (!busy && problems.length === 0 && selected)
act({
type: 'ship',
noField: selected.id,
event: info.event,
territory: destination,
sector,
allyPayment: share,
});
}}
>
Ship concealed No-Field {selected?.value ?? ''} · {cost} spice
</Button>
</>
)}
</div>
</details>
);
}
+121
View File
@@ -0,0 +1,121 @@
'use client';
import { useId, useState } from 'react';
import type { Action, GameView } from '@/game/engine';
import { Button } from './ui/button';
import { CardInspector, CardRules } from './card-inspector';
export function RicheseSpecialKarama({
game,
act,
busy,
}: {
game: GameView;
act: (action: Action) => void;
busy: boolean;
}) {
const id = useId();
const [activation, setActivation] = useState('');
const [selection, setSelection] = useState('');
const purchase = game.richeseSpecialKarama;
if (!purchase) return null;
const karama =
purchase.karamas.find((card) => card.id === activation) ??
purchase.karamas[0];
const acquired =
purchase.cards.find((card) => card.id === selection) ?? purchase.cards[0];
const payee = purchase.payee
? (game.players.find((player) => player.id === purchase.payee)?.name ??
'Emperor')
: 'the Spice Bank';
const reason =
purchase.blocked ??
(!karama
? 'You need an available Karama card.'
: !acquired
? 'No cache card is available for this purchase.'
: null);
return (
<details className="my-4">
<summary className="min-h-11 cursor-pointer py-3">
Richese special Karama: buy from your cache
</summary>
<div className="flex flex-col gap-4 py-3">
<p>
Spend a Karama and 3 of your spice to secretly choose a cache card for
your hand. This uses your once-per-game special power. Payment goes to{' '}
{payee}.
</p>
<p className="fine">
The special purchase cannot be canceled by Karama. Emperor income has
its separate response. The remaining Richese card effects are still
being implemented; inspect a card before choosing it.
</p>
{purchase.karamas.length > 0 && (
<>
<label htmlFor={`${id}-activation`}>Karama to spend</label>
<select
id={`${id}-activation`}
className="min-h-11"
value={karama?.id ?? ''}
disabled={busy}
onChange={(event) => setActivation(event.target.value)}
>
{purchase.karamas.map((card) => (
<option value={card.id} key={card.id}>
{card.name}
</option>
))}
</select>
{karama && <CardInspector card={karama} />}
</>
)}
{purchase.cards.length > 0 && (
<>
<label htmlFor={`${id}-selection`}>Private cache choice</label>
<select
id={`${id}-selection`}
className="min-h-11"
value={acquired?.id ?? ''}
disabled={busy}
onChange={(event) => setSelection(event.target.value)}
>
{purchase.cards.map((card) => (
<option value={card.id} key={card.id}>
{card.name}
</option>
))}
</select>
{acquired && (
<div className="flex flex-col gap-3">
<CardRules card={acquired} />
<CardInspector card={acquired} />
</div>
)}
</>
)}
{reason && (
<p id={`${id}-reason`} className="notice">
{reason}
</p>
)}
<Button
className="min-h-11 whitespace-normal"
disabled={busy || !!reason}
aria-describedby={reason ? `${id}-reason` : undefined}
onClick={() => {
if (!busy && !reason && karama && acquired)
act({
type: 'card',
mode: 'special',
card: karama.id,
acquire: acquired.id,
});
}}
>
Spend Karama and 3 spice to buy {acquired?.name ?? 'a cache card'}
</Button>
</div>
</details>
);
}
+338
View File
@@ -0,0 +1,338 @@
'use client';
import Link from 'next/link';
import { StrongholdCardGallery } from './stronghold-cards';
import { createStrongholdCards } from '@/game/stronghold-cards';
import { useSearchParams } from 'next/navigation';
import { Suspense, useState, useEffect, useRef } from 'react';
import { Search, ArrowUpRight, BookOpen } from 'lucide-react';
import { Input } from '@/components/ui/input';
import { RULE_TOPICS } from '@/game/reference';
import { BattleWheel } from './battle-wheel';
import { RICHESE_CARD_DEFINITIONS } from '@/game/richese-cards';
import { CardInspector, CardRules } from './card-inspector';
export function RulesReference() {
return (
<Suspense
fallback={<p className="reference-page">Loading the rules reference…</p>}
>
<RulesReferenceContent />
</Suspense>
);
}
function RulesReferenceContent() {
const searchParams = useSearchParams();
const topicQuery = searchParams.get('topic');
const [query, setQuery] = useState('');
const [practiceDial, setPracticeDial] = useState(3);
const [category, setCategory] = useState('All topics');
const [opened, setOpened] = useState<Record<string, boolean>>({});
const pendingScroll = useRef<string | null>(null);
useEffect(() => {
const reveal = () => {
const id = topicQuery || window.location.hash.slice(1);
if (!RULE_TOPICS.some((t) => t.id === id)) return;
pendingScroll.current = id;
setQuery('');
setCategory('All topics');
setOpened((previous) => ({ ...previous, [id]: true }));
};
reveal();
window.addEventListener('hashchange', reveal);
return () => window.removeEventListener('hashchange', reveal);
}, [topicQuery]);
// Scroll only after React has rendered the destination with cleared filters.
useEffect(() => {
const id = pendingScroll.current;
if (!id) return;
const article = document.getElementById(id);
if (!article) return;
pendingScroll.current = null;
article.scrollIntoView({ block: 'start' });
});
const openTopic = (id: string) => {
pendingScroll.current = id;
setQuery('');
setCategory('All topics');
setOpened((previous) => ({ ...previous, [id]: true }));
};
const categories = [
'All topics',
...new Set(RULE_TOPICS.map((t) => t.category)),
];
const terms = query.toLowerCase().trim().split(/\s+/).filter(Boolean);
const topics = RULE_TOPICS.filter(
(t) =>
(category === 'All topics' || t.category === category) &&
terms.every((term) =>
[
t.title,
t.summary,
...t.steps,
t.example ?? '',
...(t.checklist ?? []).flatMap((item) => [
item.area,
item.status,
item.detail,
...(item.evidence ?? []),
]),
]
.join(' ')
.toLowerCase()
.includes(term),
),
);
return (
<main className="reference-page">
<header className="reference-header">
<Link href="/">← Return to Arrakis</Link>
<span className="eyebrow">
<BookOpen size={14} /> THE MENTAT’S REFERENCE
</span>
<h1>
Know the rules.
<br />
<em>Choose your moment.</em>
</h1>
<p>
Find a phase, card or faction. Follow the action guides, inspect
examples and move directly between related rules.
</p>
<p className="reference-status">
This reference is growing with the game. Advanced play and expansion
rules are unfinished.{' '}
<Link href="/rules/coverage">
View the implementation ledger <ArrowUpRight size={13} />
</Link>
{' · '}
<Link href="/rules?topic=implementation-checklist#implementation-checklist">
Open feature checklists <ArrowUpRight size={13} />
</Link>
</p>
</header>
<div className="reference-layout">
<aside className="reference-nav">
<label htmlFor="rules-search">
<Search size={16} /> Search the reference
</label>
<Input
id="rules-search"
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Storm, Karama, allies…"
/>
<nav aria-label="Rule categories">
{categories.map((c) => (
<button
key={c}
aria-pressed={category === c}
onClick={() => setCategory(c)}
>
{c}
<span>
{c === 'All topics'
? RULE_TOPICS.length
: RULE_TOPICS.filter((t) => t.category === c).length}
</span>
</button>
))}
</nav>
</aside>
<section className="reference-results" aria-label="Rule topics">
<output className="fine">
{topics.length} {topics.length === 1 ? 'topic' : 'topics'}
{query ? ` matching “${query}”` : ''}
</output>
{topics.length === 0 && (
<p>No matching topics. Try a card name or choose All topics.</p>
)}
{topics.map((topic) => (
<article id={topic.id} key={topic.id} className="rule-topic">
<details
open={terms.length > 0 || !!opened[topic.id]}
onToggle={(event) => {
if (terms.length) return;
const isOpen = event.currentTarget.open;
setOpened((previous) =>
previous[topic.id] === isOpen
? previous
: { ...previous, [topic.id]: isOpen },
);
}}
>
<summary>
<span className="eyebrow">{topic.category}</span>
<h2>{topic.title}</h2>
<span
className={`coverage-label coverage-${topic.coverage.toLowerCase()}`}
>
{topic.coverage}
</span>
<p>{topic.summary}</p>
</summary>
<div className="rule-topic-content">
<ol>
{topic.steps.map((step) => (
<li key={step}>{step}</li>
))}
</ol>
{topic.id === 'stronghold-cards' && (
<StrongholdCardGallery
state={createStrongholdCards()}
players={[]}
/>
)}
{topic.id === 'richese-cards' && (
<section
aria-label="Richese card collection"
className="grid gap-4 sm:grid-cols-2"
>
{RICHESE_CARD_DEFINITIONS.map(({ card, printedType }) => (
<article
key={card.id}
className="flex min-w-0 flex-col gap-3 rounded-lg border border-[#454b3c] bg-[#1d221b] p-4"
>
<h3 className="m-0 break-words font-serif text-xl text-[#efd9a8]">
{card.name}
</h3>
<p className="m-0 text-xs text-[#c1c5b8]">
{printedType} · 1 card
</p>
<CardRules card={card} />
<CardInspector card={card} />
</article>
))}
</section>
)}
{topic.checklist && (
<section
aria-label={`${topic.title} implementation checklist`}
>
<h3>Feature checklist</h3>
<div className="max-w-full overflow-x-auto">
<table className="w-full text-left text-sm leading-6">
<caption className="sr-only">
{topic.title}: implementation, controls, AI,
documentation and verification
</caption>
<thead>
<tr>
<th scope="col" className="p-2">
Area
</th>
<th scope="col" className="p-2">
Status
</th>
<th scope="col" className="p-2">
Scope and evidence
</th>
</tr>
</thead>
<tbody>
{topic.checklist.map((item) => (
<tr key={item.area}>
<th scope="row" className="p-2 align-top">
{item.area}
</th>
<td className="p-2 align-top">{item.status}</td>
<td className="p-2 align-top">
<p className="m-0">{item.detail}</p>
{item.evidence && (
<ul className="mt-2 list-disc pl-4">
{item.evidence.map((file) => (
<li key={file}>
<code className="break-all text-xs">
{file}
</code>
</li>
))}
</ul>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
)}
{topic.id === 'battle' && (
<aside className="rule-example">
<strong>Try a basic battle wheel</strong>
<p>
Practice with ten ordinary forces and a strength-five
leader. Use the slider, arrow keys or number field. In a
game, your choice stays private until the plans are
revealed.
</p>
<div style={{ maxWidth: 280 }}>
<label htmlFor="practice-battle-dial">
Practice dial
</label>
<BattleWheel
id="practice-battle-dial"
value={practiceDial}
onChange={(value) =>
setPracticeDial(
Math.min(10, Math.max(0, Math.round(value))),
)
}
max={10}
step={1}
/>
</div>
<p aria-live="polite">
If the leader survives, your total is {practiceDial} + 5
= {practiceDial + 5}. In a normal victory you lose the{' '}
{practiceDial} dialed forces; in a defeat you lose all
ten. Weapon, traitor and faction effects can change the
result.
</p>
<p className="fine">
This practice wheel does not change a live game.
</p>
</aside>
)}
{topic.example && (
<aside className="rule-example">
<strong>Example</strong>
<p>{topic.example}</p>
</aside>
)}
{topic.related && (
<nav
aria-label={`Related to ${topic.title}`}
className="related-rules"
>
{topic.related.map((id) => (
<a
href={`/rules?topic=${id}#${id}`}
key={id}
onClick={() => {
openTopic(id);
}}
>
{RULE_TOPICS.find((t) => t.id === id)?.title ?? id}{' '}
<ArrowUpRight size={13} />
</a>
))}
</nav>
)}
<a
className="rule-permalink"
href={`/rules?topic=${topic.id}#${topic.id}`}
onClick={() => openTopic(topic.id)}
>
Link to this topic
</a>
</div>
</details>
</article>
))}
</section>
</div>
</main>
);
}
+116
View File
@@ -0,0 +1,116 @@
'use client';
import { useId, useState } from 'react';
import { Bot, UserRound } from 'lucide-react';
import { Button } from '@/components/ui/button';
import type { Action, GameView } from '@/game/engine';
import {
BOT_DESCRIPTIONS,
DIFFICULTIES,
type Difficulty,
} from '@/game/bot-profiles';
export type SeatAutopilotProps = {
game: GameView;
act: (action: Action) => void;
busy: boolean;
};
/** Voluntary control of the current human seat; the action never names another player. */
export function SeatAutopilot({ game, act, busy }: SeatAutopilotProps) {
const id = useId();
const [difficulty, setDifficulty] = useState<Difficulty>('Medium');
const own = game.players.find((player) => player.id === game.me);
if (!own || own.bot || !['setup', 'playing'].includes(game.status))
return null;
if (own.autopilot)
return (
<section
className="notice flex min-w-0 flex-wrap items-center gap-4"
aria-label="Your seat autopilot"
>
<div className="flex min-w-0 flex-1 basis-60 flex-col gap-2">
<output className="flex items-center gap-2 text-base font-semibold text-[#efd9a8]">
<Bot aria-hidden="true" className="size-5 shrink-0" />
AI autopilot · {own.autopilot}
</output>
<p className="m-0 text-sm leading-6 text-[#e0e4d8]">
AI is making this seat’s decisions using only its available
information, with about 1.5 seconds between decisions. You keep the
seat and its recovery setup. Taking back control stops future AI
decisions; completed decisions remain in the game.
</p>
</div>
<Button
variant="outline"
className="min-h-11 w-full motion-reduce:transition-none sm:w-auto"
disabled={busy}
onClick={() => {
if (!busy) act({ type: 'setAutopilot', difficulty: null });
}}
>
<UserRound aria-hidden="true" />
Take back control
</Button>
</section>
);
return (
<details className="notice min-w-0">
<summary className="min-h-11 cursor-pointer py-3 text-base font-semibold text-[#efd9a8] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#ddbc77]">
Let AI play for me
</summary>
<div className="flex min-w-0 flex-col gap-4 pb-2">
<p id={`${id}-impact`} className="m-0 text-sm leading-6 text-[#e0e4d8]">
Autopilot makes setup and game decisions for your seat using only
information that seat can see, with about 1.5 seconds between
decisions. It may make irreversible decisions, including spending
spice, moving forces and committing battle plans. You retain your seat
and recovery setup and can take back control. Completed decisions
cannot be undone by turning autopilot off.
</p>
<div className="flex min-w-0 flex-col gap-2">
<label htmlFor={`${id}-difficulty`} className="text-sm font-semibold">
AI difficulty
</label>
<select
id={`${id}-difficulty`}
value={difficulty}
disabled={busy}
aria-describedby={`${id}-difficulty-help`}
className="min-h-11 w-full min-w-0 rounded-md border border-[#454b3c] bg-[#141814] px-3 text-base text-[#eeeae0] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#ddbc77] disabled:opacity-50"
onChange={(event) => {
const value = event.target.value;
if (DIFFICULTIES.some((choice) => choice === value))
setDifficulty(value as Difficulty);
}}
>
{DIFFICULTIES.map((choice) => (
<option key={choice} value={choice}>
{choice}
</option>
))}
</select>
<p
id={`${id}-difficulty-help`}
className="m-0 text-sm leading-6 text-[#c1c5b8]"
>
{BOT_DESCRIPTIONS[difficulty]}
</p>
</div>
<Button
className="min-h-11 w-full motion-reduce:transition-none sm:w-auto sm:self-start"
disabled={busy}
aria-describedby={`${id}-impact`}
onClick={() => {
if (!busy) act({ type: 'setAutopilot', difficulty });
}}
>
<Bot aria-hidden="true" />
Start autopilot
</Button>
</div>
</details>
);
}
+451
View File
@@ -0,0 +1,451 @@
'use client';
import { useId, useRef, useState } from 'react';
import { Copy, Eye, EyeOff, KeyRound } from 'lucide-react';
import type { GameView } from '@/game/engine';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { requestJson, requestMayHaveCompleted } from '@/lib/client-request';
import {
createRecoveryAttempt,
createRecoveryKit,
parseRecoveryKit,
serializeRecoveryKit,
type SeatRecoveryAttempt,
type SeatRecoveryKit,
} from '@/lib/seat-recovery';
type Restored = (view: GameView) => void;
type SetupResponse = { view: GameView; recoveryConfigured: true };
type ClaimResponse = { view: GameView; recovered: true; replayed: boolean };
/** Mount with key={`${game.code}:${game.me}`} to separate private owner contexts. */
export function SeatRecoverySetup({
game,
onRestored,
onPending,
disabled = false,
}: {
game: GameView;
onRestored: Restored;
onPending?: (pending: boolean) => void;
disabled?: boolean;
}) {
const id = useId();
const pending = useRef(false);
const [draft, setDraft] = useState<SeatRecoveryKit | null>(null);
const [saved, setSaved] = useState(false);
const [confirmed, setConfirmed] = useState(false);
const [busy, setBusy] = useState(false);
const [uncertain, setUncertain] = useState(false);
const [message, setMessage] = useState('');
const own = game.players.find((player) => player.id === game.me);
const kit =
draft?.roomCode === game.code && draft.playerId === game.me ? draft : null;
if (!own) return null;
async function copyKit() {
if (!kit) return;
try {
await navigator.clipboard.writeText(serializeRecoveryKit(kit));
setMessage(
'Private kit copied. Save it somewhere secure before continuing.',
);
} catch {
setMessage(
'Copy was unavailable. Select the complete kit below and copy it manually.',
);
}
}
async function saveKey() {
if (!kit || !saved || confirmed || disabled || pending.current) return;
let confirmedView: GameView | undefined;
pending.current = true;
setBusy(true);
onPending?.(true);
setMessage('Saving your recovery key…');
try {
const result = await requestJson<SetupResponse>(
`/api/rooms/${kit.roomCode}/control`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
type: 'setRecoveryKey',
version: game.version,
recoverySecret: kit.recoverySecret,
}),
},
);
if (
result.recoveryConfigured !== true ||
result.view.code !== kit.roomCode ||
result.view.me !== kit.playerId
)
throw new Error('The server did not confirm this seat’s recovery key.');
setConfirmed(true);
setUncertain(false);
setMessage(
'Recovery key saved. Keep your private kit; it is needed if this browser loses its seat.',
);
confirmedView = result.view;
} catch (error) {
const mayHaveSaved = requestMayHaveCompleted(error);
setUncertain(mayHaveSaved);
setMessage(
mayHaveSaved
? 'Saving was not confirmed and may have completed. Keep this same kit. Retry saving to confirm it; no retry has been sent automatically.'
: `${(error as Error).message} Your kit remains below. Review the current table and retry when ready.`,
);
} finally {
pending.current = false;
setBusy(false);
onPending?.(false);
}
if (confirmedView) onRestored(confirmedView);
}
return (
<details className="notice">
<summary className="cursor-pointer text-base font-semibold">
Protect your saved seat
</summary>
<p>
Create or replace a private recovery key for {own.name}. Anyone with the
kit can recover this seat, revealing its private information and
revoking its old browser sessions. Keep it separate from the public
invitation.
</p>
<p className="fine">
This restores your own seat after cookie loss. A replacement key
invalidates the previous kit. The kit stays in memory here; save it
before refreshing or closing this page.
</p>
{!kit ? (
<Button
variant="outline"
disabled={disabled || busy}
onClick={() => {
try {
setDraft(createRecoveryKit(game.code, game.me));
setSaved(false);
setConfirmed(false);
setUncertain(false);
setMessage('Save this kit before enabling its recovery key.');
} catch (error) {
setMessage((error as Error).message);
}
}}
>
<KeyRound aria-hidden="true" />
Generate private recovery kit
</Button>
) : (
<div className="space-y-3">
<label htmlFor={`${id}-kit`}>
Private recovery kit for room {kit.roomCode}
</label>
<Textarea
id={`${id}-kit`}
readOnly
value={serializeRecoveryKit(kit)}
rows={8}
spellCheck={false}
autoComplete="off"
className="font-mono text-sm"
aria-describedby={`${id}-privacy`}
/>
<p id={`${id}-privacy`} className="fine">
This contains a secret key. Save the entire kit privately; do not
put it in chat or the invitation.
</p>
<Button variant="outline" onClick={() => void copyKit()}>
<Copy aria-hidden="true" />
Copy private kit
</Button>
<label className="flex items-start gap-2" htmlFor={`${id}-saved`}>
<input
id={`${id}-saved`}
type="checkbox"
checked={saved}
disabled={disabled || busy}
onChange={(event) => setSaved(event.target.checked)}
/>
I have saved the complete private kit outside this page.
</label>
<div className="flex flex-wrap gap-2">
<Button
disabled={disabled || busy || !saved || confirmed}
onClick={() => void saveKey()}
>
{confirmed
? 'Recovery key confirmed'
: busy
? 'Saving…'
: uncertain
? 'Retry saving this key'
: 'Enable this recovery key'}
</Button>
{!uncertain && (
<Button
variant="outline"
disabled={disabled || busy}
onClick={() => {
setDraft(null);
setSaved(false);
setConfirmed(false);
setMessage(
confirmed
? 'Saved kit hidden. Keep your private copy for recovery.'
: 'Unsaved kit discarded.',
);
}}
>
{confirmed ? 'Done — hide saved kit' : 'Discard unsaved kit'}
</Button>
)}
</div>
</div>
)}
{message && (
<output className="block text-sm leading-relaxed" aria-live="polite">
{message}
</output>
)}
</details>
);
}
export function SeatRecoveryClaim({
initialCode,
onRestored,
onPending,
onUncertain,
disabled = false,
}: {
initialCode?: string;
onRestored: Restored;
onPending?: (pending: boolean) => void;
onUncertain?: (uncertain: boolean) => void;
disabled?: boolean;
}) {
const id = useId();
const pending = useRef(false);
const [kitText, setKitText] = useState('');
const [showKit, setShowKit] = useState(false);
const [prepared, setPrepared] = useState<{
room: string;
attempt: SeatRecoveryAttempt;
} | null>(null);
const [busy, setBusy] = useState(false);
const [uncertain, setUncertain] = useState(false);
const [attempted, setAttempted] = useState(false);
const [abandonAcknowledged, setAbandonAcknowledged] = useState(false);
const [message, setMessage] = useState('');
function inspectKit() {
if (disabled || pending.current) return;
try {
const kit = parseRecoveryKit(kitText);
setPrepared({ room: kit.roomCode, attempt: createRecoveryAttempt(kit) });
setAttempted(false);
setUncertain(false);
setShowKit(false);
setMessage('Kit read. Review the room and seat before recovering.');
} catch (error) {
setMessage((error as Error).message);
}
}
async function recover() {
if (!prepared || disabled || pending.current) return;
let confirmedView: GameView | undefined;
pending.current = true;
setBusy(true);
onPending?.(true);
setAttempted(true);
setAbandonAcknowledged(false);
setMessage('Recovering your seat…');
try {
const result = await requestJson<ClaimResponse>(
`/api/rooms/${prepared.room}/control`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
// The prepared UUID and session token are retained unchanged after a timeout.
body: JSON.stringify(prepared.attempt),
},
);
if (
result.recovered !== true ||
result.view.code !== prepared.room ||
result.view.me !== prepared.attempt.playerId
)
throw new Error('The server did not confirm the recovered seat.');
setKitText('');
setPrepared(null);
setShowKit(false);
setAttempted(false);
setUncertain(false);
onUncertain?.(false);
setMessage(
'Seat recovered in this browser. Previous browser sessions for this seat have been revoked.',
);
confirmedView = result.view;
} catch (error) {
// A later rejection cannot prove that an earlier ambiguous attempt failed.
// Retain that exact receipt until a response confirms this recovered seat.
const mayHaveRecovered = uncertain || requestMayHaveCompleted(error);
setUncertain(mayHaveRecovered);
onUncertain?.(mayHaveRecovered);
setMessage(
mayHaveRecovered
? 'Recovery was not confirmed and may have completed. Retry this same request below. Keep this page open to preserve its exact retry details; no retry is automatic.'
: `${(error as Error).message} You can retry the same request or inspect a different saved kit.`,
);
} finally {
pending.current = false;
setBusy(false);
onPending?.(false);
}
if (confirmedView) onRestored(confirmedView);
}
return (
<details className="notice">
<summary className="cursor-pointer text-base font-semibold">
Recover a saved seat
</summary>
<p>
Use the private kit saved before this browser lost its seat. Recovery
restores that seat’s private information and revokes its previous
browser sessions.
</p>
{initialCode && (
<p className="fine">
Invitation room: {initialCode}. The saved kit determines which room
and seat you recover.
</p>
)}
<label htmlFor={`${id}-claim`}>Paste your private recovery kit</label>
<div className="flex flex-wrap gap-2">
<Input
id={`${id}-claim`}
type={showKit ? 'text' : 'password'}
value={kitText}
readOnly={!!prepared}
disabled={disabled || busy}
onChange={(event) => setKitText(event.target.value)}
maxLength={4096}
autoComplete="off"
spellCheck={false}
aria-describedby={`${id}-claim-help`}
className="min-w-0 flex-1"
/>
<Button
variant="outline"
aria-pressed={showKit}
aria-controls={`${id}-claim`}
onClick={() => setShowKit(!showKit)}
>
{showKit ? <EyeOff aria-hidden="true" /> : <Eye aria-hidden="true" />}
{showKit ? 'Hide kit' : 'Show kit'}
</Button>
</div>
<p id={`${id}-claim-help`} className="fine">
Keep this kit private. Reading it checks its format; Recover this seat
submits it to restore your browser session.
</p>
{!prepared ? (
<Button
variant="outline"
disabled={disabled || !kitText.trim() || busy}
onClick={inspectKit}
>
Read recovery kit
</Button>
) : (
<div className="space-y-3">
<dl className="text-sm">
<dt>Room to recover</dt>
<dd className="font-mono">{prepared.room}</dd>
<dt>Saved seat identifier</dt>
<dd className="break-all font-mono">{prepared.attempt.playerId}</dd>
</dl>
<div className="flex flex-wrap gap-2">
<Button disabled={disabled || busy} onClick={() => void recover()}>
{busy
? 'Recovering…'
: attempted
? 'Retry same recovery request'
: 'Recover this seat'}
</Button>
{!uncertain && (
<Button
variant="outline"
disabled={disabled || busy}
onClick={() => {
setPrepared(null);
setAttempted(false);
setMessage('Paste or inspect your saved kit.');
}}
>
Use a different kit
</Button>
)}
</div>
{uncertain && (
<div className="space-y-3">
<p>
If this exact retry can no longer succeed, you can deliberately
abandon its retry details. This does not cancel a completed
recovery; previous browser sessions may already have been
revoked. You need a saved recovery kit to recover again.
</p>
<label className="flex items-start gap-2">
<input
type="checkbox"
checked={abandonAcknowledged}
disabled={disabled || busy}
onChange={(event) =>
setAbandonAcknowledged(event.target.checked)
}
/>
<span>
I understand that abandoning drops the exact retry details and
does not undo recovery.
</span>
</label>
<Button
variant="outline"
disabled={disabled || busy || !abandonAcknowledged}
onClick={() => {
if (disabled || pending.current || !abandonAcknowledged)
return;
setPrepared(null);
setAttempted(false);
setUncertain(false);
setAbandonAcknowledged(false);
setShowKit(false);
onUncertain?.(false);
setMessage(
'Recovery retry details abandoned. The private kit remains in the input; read it again or paste a newer saved kit before starting another recovery.',
);
}}
>
Abandon recovery attempt
</Button>
</div>
)}
</div>
)}
{message && (
<output className="block text-sm leading-relaxed" aria-live="polite">
{message}
</output>
)}
</details>
);
}
+101
View File
@@ -0,0 +1,101 @@
'use client';
import type { Action, GameView } from '@/game/engine';
import { liveShipmentPromises } from '@/game/shipment-promises';
import { territory } from '@/game/board';
import { reserveShipmentCost, guildShipmentCost } from '@/game/shipment-price';
import { Button } from './ui/button';
export function ShipmentPromises({
game: g,
act,
busy,
}: {
game: GameView;
act: (action: Action) => void;
busy: boolean;
}) {
const promises = liveShipmentPromises(g.shipmentPromises, g.me, g.turn);
if (!promises.length) return null;
const me = g.players.find((p) => p.id === g.me)!;
const next = g.shipmentCompletion?.actions[0];
const shipping = next && (next.type === 'ship' || next.type === 'guildShip');
const cost = shipping
? next.type === 'guildShip'
? guildShipmentCost(
territory(String(next.territory)).type,
Number(next.amount),
)
: reserveShipmentCost(
{
faction: me.faction,
halfRate:
me.faction === 'guild' ||
g.players.some(
(p) => p.faction === 'guild' && p.id === me.ally,
) ||
g.karamaShipping?.player === me.id,
},
territory(String(next.territory)).type,
Number(next.amount),
)
: null;
const share = shipping
? Number(next.allyPayment ?? Math.max(0, cost! - (me.spice ?? 0)))
: 0;
const description = next
? shipping
? `Ship ${Number(next.amount)} forces to ${territory(String(next.territory)).name}, sector ${Number(next.sector)}`
: next.type === 'pledgeAid'
? 'Reclaim your unused ally pledge'
: `Play ${me.hand?.find((c) => c.id === next.card)?.name ?? 'your preparation card'}${next.amount ? ` to revive ${Number(next.amount)} forces` : ' for your shipment discount'}`
: '';
return (
<section
className="notice"
aria-label="Your Truthtrance shipment promises"
id="shipment-promise-guidance"
>
<h3>Your shipment promises</h3>
<ul>
{promises.map((p, index) => (
<li key={index}>
{p.answer ? 'Ship' : 'Do not ship'} at least {p.minimum} physical
forces from reserves to {territory(p.territory).name} this turn.
</li>
))}
</ul>
{promises.some((p) => p.answer) && (
<p>
Complete a qualifying shipment before ground movement or finishing
your turn. You can choose any legal count, sector and funding that
honor your answers.
</p>
)}
{next && (
<details>
<summary>Suggested next step · private</summary>
<p>{description}.</p>
{cost !== null && (
<p>
Cost: {cost} spice · Your payment: {cost - share} · Ally pledge:{' '}
{share}.
</p>
)}
<Button
className="game-action"
disabled={
busy ||
!!g.truthtrance ||
!!g.response ||
!!g.decision ||
!!g.phaseOpening
}
onClick={() => act(next)}
>
{description}
</Button>
</details>
)}
</section>
);
}
+89
View File
@@ -0,0 +1,89 @@
export type ShipmentQuoteProps = {
id?: string;
label?: string;
physicalForces?: number;
quote: {
cost: number;
normalCost: number;
ownPayment: number;
pledgedPayment: number;
} | null;
funding: {
ownSpice: number;
pledgedSpice: number;
};
unavailableReasons?: readonly string[];
};
/** Displays only caller-supplied prices and private funding already authorized for
* this viewer. The caller validates the selection and controls action availability.
*/
export function ShipmentQuote({
id,
label = 'Reserve shipment',
physicalForces,
quote,
funding,
unavailableReasons = [],
}: ShipmentQuoteProps) {
return (
<section
id={id}
className="notice shipment-quote flex min-w-0 flex-col gap-3 text-sm leading-6"
aria-label={`${label} cost and funding`}
>
<div>
{label !== 'Reserve shipment' && (
<p className="m-0 font-semibold">{label}</p>
)}
<p className="m-0 text-base font-semibold text-[#efd9a8]">
{quote ? `Shipment cost: ${quote.cost} spice` : 'Shipment cost'}
</p>
{quote ? (
quote.normalCost !== quote.cost && (
<p className="m-0 text-sm leading-6">
Normal cost: {quote.normalCost} spice. The applicable rate is
included in your shipment cost.
</p>
)
) : (
<p className="m-0 text-sm leading-6">
Choose valid forces and funding to see the payment breakdown.
</p>
)}
</div>
<dl className="m-0 grid min-w-0 grid-cols-[minmax(0,1fr)_auto] gap-x-4 gap-y-1">
{physicalForces !== undefined && (
<>
<dt>Physical forces selected</dt>
<dd className="m-0 text-right tabular-nums">{physicalForces}</dd>
</>
)}
<dt>Your available spice</dt>
<dd className="m-0 text-right tabular-nums">{funding.ownSpice}</dd>
<dt>Available ally pledge</dt>
<dd className="m-0 text-right tabular-nums">{funding.pledgedSpice}</dd>
{quote && (
<>
<dt>Your payment</dt>
<dd className="m-0 text-right tabular-nums">{quote.ownPayment}</dd>
<dt>Ally pledge used</dt>
<dd className="m-0 text-right tabular-nums">
{quote.pledgedPayment}
</dd>
</>
)}
</dl>
{unavailableReasons.length > 0 && (
<div>
<p className="m-0 font-semibold">Shipment unavailable</p>
<ul className="m-0 list-disc space-y-1 pl-5">
{unavailableReasons.map((reason, index) => (
<li key={index}>{reason}</li>
))}
</ul>
</div>
)}
</section>
);
}
+267
View File
@@ -0,0 +1,267 @@
'use client';
import { useId } from 'react';
import { Dialog as DialogPrimitive } from '@base-ui/react/dialog';
import { Eye, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogClose,
DialogDescription,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import type { SpiceCard } from '@/game/cards';
import { territory } from '@/game/board';
import { PHASE_HELP } from '@/game/reference';
/** Only the supplied visible face is read. No deck, discard pile or game is accepted. */
export type SpiceCardInspectorProps = {
card: Readonly<SpiceCard>;
context?: string;
};
export function spiceCardTitle(card: Readonly<SpiceCard>): string {
if ('territory' in card)
return `${territory(card.territory).name} · ${card.amount} spice · sector ${card.sector}`;
if ('sandtrout' in card) return 'Sandtrout';
return `Shai-Hulud${card.thumper ? ' · Thumper encounter' : ''}${card.suppressed ? ' · suppressed' : ''}`;
}
/** Original vector illustration, with no claim to reproduce a printed card face. */
function SpiceIllustration({
kind,
}: {
kind: 'territory' | 'worm' | 'sandtrout';
}) {
const paint = `${useId()}-dune`;
return (
<svg
viewBox="0 0 260 190"
className="my-5 w-full"
aria-hidden="true"
focusable="false"
>
<defs>
<linearGradient id={paint} x1="0" y1="0" x2="0" y2="1">
<stop
offset="0"
stopColor={kind === 'sandtrout' ? '#344a42' : '#4f3d29'}
/>
<stop offset="1" stopColor="#171b17" />
</linearGradient>
</defs>
<rect
x="1"
y="1"
width="258"
height="188"
rx="90"
fill={`url(#${paint})`}
stroke="#ac8c54"
strokeOpacity="0.5"
/>
<circle cx="194" cy="47" r="16" fill="#dfbd7b" opacity="0.65" />
<path
d="M 12 121 Q 62 72 126 119 T 248 119 M 8 151 Q 76 95 144 147 T 252 143 M 30 174 Q 104 129 228 172"
fill="none"
stroke="#c5a76b"
strokeWidth="2"
strokeOpacity="0.65"
/>
{kind === 'territory' ? (
<g fill="#dfb976" stroke="#fff0c4" strokeWidth="1.5">
<path d="M 97 105 L 110 69 L 122 105 L 110 123 Z" />
<path d="M 126 112 L 144 58 L 161 112 L 144 135 Z" />
<path d="M 164 118 L 174 93 L 184 118 L 174 132 Z" />
</g>
) : kind === 'worm' ? (
<g fill="none" stroke="#dec18a">
<path
d="M 59 136 C 66 88 67 59 106 45 C 143 31 170 73 165 127"
strokeWidth="8"
/>
<ellipse
cx="116"
cy="77"
rx="31"
ry="37"
fill="#141814"
strokeWidth="3"
/>
<ellipse cx="116" cy="77" rx="20" ry="25" strokeWidth="1" />
<path
d="M 116 42 V 58 M 116 96 V 113 M 85 77 H 99 M 134 77 H 147 M 94 51 L 103 61 M 131 94 L 140 103 M 94 103 L 103 94 M 131 61 L 140 51"
strokeWidth="2"
/>
</g>
) : (
<g fill="#7c9579" fillOpacity="0.3" stroke="#c6d1aa" strokeWidth="2">
<path d="M 72 90 C 80 45 122 39 150 70 C 191 56 204 95 167 114 C 144 145 109 137 103 114 C 76 127 53 113 72 90 Z" />
<path
d="M 83 87 Q 120 63 159 87 M 96 107 Q 127 86 164 101"
fill="none"
/>
</g>
)}
</svg>
);
}
export function SpiceCardInspector({ card, context }: SpiceCardInspectorProps) {
const kind =
'territory' in card
? 'territory'
: 'sandtrout' in card
? 'sandtrout'
: 'worm';
const title = spiceCardTitle(card);
const faceName =
'territory' in card
? territory(card.territory).name
: kind === 'worm'
? 'Shai-Hulud'
: 'Sandtrout';
const description =
kind === 'territory'
? 'This spice blow names the territory, sector and base spice amount. The storm and card effects can change how much spice actually appears.'
: kind === 'worm'
? 'A Shai-Hulud encounter can consume forces and spice in the territory identified by the preceding card in its spice discard pile. Resolve protection, Nexus and riding decisions through the table.'
: 'Sandtrout breaks existing alliances and waits for the next active drawn Shai-Hulud. It suppresses that encounter, then the immediate replacement determines whether a spice blow is doubled.';
return (
<Dialog>
<DialogTrigger
render={
<Button
variant="outline"
className="min-h-11 min-w-11 motion-reduce:transition-none"
/>
}
aria-label={`Inspect spice card: ${title}${context ? ` · ${context}` : ''}`}
>
<Eye aria-hidden="true" />
Inspect spice card
</DialogTrigger>
<DialogPortal>
<DialogOverlay className="bg-black/75 motion-reduce:animate-none motion-reduce:transition-none" />
<DialogPrimitive.Popup className="fixed top-1/2 left-1/2 z-50 flex max-h-[calc(100dvh-1rem)] w-[calc(100%-1rem)] max-w-3xl -translate-x-1/2 -translate-y-1/2 flex-col overflow-hidden rounded-2xl border border-[#8e7952] bg-[#141814] text-[#eeeae0] shadow-2xl outline-none duration-150 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 motion-reduce:animate-none motion-reduce:transition-none sm:max-h-[calc(100dvh-3rem)] sm:w-[calc(100%-3rem)]">
<div className="flex shrink-0 items-center justify-between gap-3 border-b border-[#414536] px-4 py-3">
<span className="text-xs font-semibold tracking-[0.16em] text-[#ddbc77] uppercase">
Spice card inspection
</span>
<DialogClose
render={
<Button
variant="outline"
className="min-h-11 min-w-11 motion-reduce:transition-none"
/>
}
>
<X aria-hidden="true" />
Close
</DialogClose>
</div>
<div className="min-h-0 overflow-y-auto overscroll-contain p-4 sm:p-6">
<div className="grid items-start gap-6 md:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)]">
<article className="min-w-0 rounded-xl border border-[#a58b5c] bg-gradient-to-br from-[#493d29] via-[#2f3023] to-[#171b17] p-5 shadow-lg sm:p-6">
<p className="m-0 text-sm font-semibold tracking-wider text-[#f1d79f] uppercase">
{kind === 'territory'
? 'Spice blow'
: kind === 'worm'
? 'Worm encounter'
: 'Sandtrout'}
</p>
<SpiceIllustration kind={kind} />
<DialogTitle className="break-words font-serif text-3xl leading-tight text-[#fff3d6] sm:text-4xl">
{faceName}
</DialogTitle>
{'territory' in card && (
<dl className="mt-5 grid grid-cols-2 gap-4 border-y border-[#b59a64]/40 py-4">
<div>
<dt className="text-sm text-[#d6c9ad]">Card amount</dt>
<dd className="m-0 mt-1 font-serif text-4xl text-[#f6e4b9]">
{card.amount}
<span className="block font-sans text-sm">spice</span>
</dd>
</div>
<div>
<dt className="text-sm text-[#d6c9ad]">Sector</dt>
<dd className="m-0 mt-1 font-serif text-4xl text-[#f6e4b9]">
{card.sector}
</dd>
</div>
</dl>
)}
{'worm' in card && card.thumper && (
<p className="mt-4 text-base leading-7 text-[#eee5d2]">
Thumper-created encounter
</p>
)}
{'worm' in card && card.suppressed && (
<p className="mt-4 text-base leading-7 text-[#eee5d2]">
Suppressed by Sandtrout
</p>
)}
<p className="mt-5 mb-0 text-xs tracking-wider text-[#cebea0] uppercase">
Spice · gameplay guide
</p>
</article>
<section
className="flex min-w-0 flex-col gap-5"
aria-label="Spice card guidance"
>
{context && (
<p className="m-0 text-sm font-semibold text-[#ddbc77]">
{context}
</p>
)}
<DialogDescription className="m-0 text-base leading-7 text-[#eee5d2]">
{description}
</DialogDescription>
<p className="m-0 text-base leading-7 text-[#e0e4d8]">
Inspection does not draw a card or resolve an effect. Close
this view to use the table’s phase controls.
</p>
{kind === 'territory' ? (
<p className="m-0 text-base leading-7 text-[#e0e4d8]">
Spice cannot appear in the storm’s sector. A fresh blow
remains open for Harvester before acceptance. The amount on
this face is the card’s base value, not the total spice
currently on the board.
</p>
) : kind === 'worm' ? (
<p className="m-0 text-base leading-7 text-[#e0e4d8]">
First-turn drawn worms are ignored and later shuffled back.
A worm suppressed by Sandtrout does not devour the preceding
territory or create its own Nexus or Fremen ride. A normal
encounter follows the table’s worm and alliance decisions.
</p>
) : (
<>
<p className="m-0 text-base leading-7 text-[#e0e4d8]">
Territories drawn before the next active Shai-Hulud
receive their usual spice. Sandtrout remains pending until
that worm is suppressed, even across spice blows or turns.
</p>
<p className="m-0 text-base leading-7 text-[#e0e4d8]">
If the immediate replacement is a territory, double its
spice; Harvester may enhance that fresh blow again. If the
replacement is another Shai-Hulud, resolve it normally and
do not double a later territory. Spice still cannot appear
in the storm.
</p>
</>
)}
<p className="m-0 rounded-lg border border-[#454b3c] bg-[#1d221b] p-4 text-base leading-7 text-[#e0e4d8]">
{PHASE_HELP[1]}
</p>
</section>
</div>
</div>
</DialogPrimitive.Popup>
</DialogPortal>
</Dialog>
);
}
+219
View File
@@ -0,0 +1,219 @@
'use client';
import type { ReactNode } from 'react';
import Image from 'next/image';
import { Dialog as DialogPrimitive } from '@base-ui/react/dialog';
import { Button } from './ui/button';
import {
Dialog,
DialogClose,
DialogDescription,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
} from './ui/dialog';
import type { Action } from '@/game/engine';
import {
STRONGHOLD_CARDS,
type StrongholdId,
type StrongholdState,
} from '@/game/stronghold-cards';
type StrongholdDefinition = (typeof STRONGHOLD_CARDS)[number];
/** Original, bundled artwork; rules remain independent selectable text. */
function StrongholdIllustration({ id }: { id: StrongholdId }) {
return (
<Image
src={`/art/strongholds/${id}-v1.png`}
alt=""
unoptimized
width={1774}
height={887}
loading="lazy"
className="block aspect-[2/1] w-full object-cover"
/>
);
}
function StrongholdFace({
card,
ownership,
children,
enlarged = false,
}: {
enlarged?: boolean;
card: StrongholdDefinition;
ownership?: string;
children?: ReactNode;
}) {
return (
<article className="min-w-0 overflow-hidden rounded-xl border border-[#a88b60] bg-[#24271f] text-[#f2e8d3] shadow-lg">
<div className="m-2 overflow-hidden rounded-lg border border-[#ab8a58]/60">
<header className="flex min-h-20 items-center gap-3 bg-[#65362f] px-4 py-3">
<h3 className="m-0 min-w-0 flex-1 break-words font-serif text-xl leading-tight text-[#fff1d4]">
{card.name}
</h3>
<svg
viewBox="0 0 32 32"
className="h-8 w-8 shrink-0"
aria-hidden="true"
focusable="false"
>
<circle cx="16" cy="16" r="14" fill="none" stroke="#e2c18b" />
<path d="M8 24V13h5v11m1 0V7h5v17m1 0v-9h4v9" fill="#e2c18b" />
</svg>
</header>
<StrongholdIllustration id={card.id} />
<div className="space-y-4 p-4">
{ownership && (
<p className="m-0 break-words border-b border-[#a88b60]/40 pb-3 text-sm font-semibold leading-6 text-[#edcc8c]">
{ownership}
</p>
)}
{card.gameplay.map((paragraph) => (
<p
key={paragraph}
className={
enlarged ? 'm-0 text-xl leading-8' : 'm-0 text-base leading-7'
}
>
{paragraph}
</p>
))}
{!enlarged && (
<Dialog>
<DialogTrigger
render={
<Button
variant="outline"
className="min-h-11 w-full whitespace-normal"
/>
}
>
Inspect {card.name}
</DialogTrigger>
<DialogPortal>
<DialogOverlay className="bg-black/75 motion-reduce:animate-none" />
<DialogPrimitive.Popup className="fixed top-1/2 left-1/2 z-50 flex max-h-[calc(100dvh-1rem)] w-[calc(100%-1rem)] max-w-2xl -translate-x-1/2 -translate-y-1/2 flex-col overflow-hidden rounded-xl border border-[#a88b60] bg-[#171d18] text-[#f2e8d3] shadow-2xl outline-none motion-reduce:animate-none">
<div className="flex shrink-0 items-center justify-between gap-3 border-b border-[#a88b60]/40 px-4 py-3">
<DialogTitle className="text-base leading-6">
{card.name}
</DialogTitle>
<DialogClose
render={
<Button
variant="outline"
className="min-h-11 min-w-16"
/>
}
>
Close
</DialogClose>
</div>
<DialogDescription className="sr-only">
Full Stronghold Card with its public ownership and gameplay
text.
</DialogDescription>
<div className="min-h-0 overflow-y-auto overscroll-contain p-3 sm:p-5">
<StrongholdFace
card={card}
ownership={ownership}
enlarged
/>
</div>
</DialogPrimitive.Popup>
</DialogPortal>
</Dialog>
)}
{children}
<p className="m-0 border-t border-[#a88b60]/40 pt-3 text-xs leading-5 tracking-wide text-[#c9baa0]">
CHOAM &amp; Richese · Advanced Stronghold
</p>
</div>
</div>
</article>
);
}
export function StrongholdCardGallery({
state,
players,
}: {
state: StrongholdState;
players: { id: string; name: string }[];
}) {
return (
<section aria-label="Advanced Stronghold cards" className="space-y-4">
<div>
<h2 className="m-0 font-serif text-2xl text-[#f2e8d3]">
Advanced Stronghold cards
</h2>
<p className="mt-2 mb-0 text-sm leading-6 text-[#c9baa0]">
CHOAM &amp; Richese · Optional module
{state.claimedTurn > 0
? ` · Last claim: turn ${state.claimedTurn}`
: ' · Initial claims pending'}
</p>
</div>
<div className="grid grid-cols-1 items-start gap-4 sm:grid-cols-2 xl:grid-cols-3">
{STRONGHOLD_CARDS.map((card) => {
const owner = state.owners[card.id];
const name = players.find((player) => player.id === owner)?.name;
return (
<StrongholdFace
key={card.id}
card={card}
ownership={owner ? `Held by ${name ?? owner}` : 'Unclaimed'}
/>
);
})}
</div>
</section>
);
}
export function StrongholdCopyChoice({
event,
choices,
act,
busy,
}: {
event: string;
choices: StrongholdId[];
act: (action: Action) => void;
busy: boolean;
}) {
const available = STRONGHOLD_CARDS.filter((card) =>
choices.includes(card.id),
);
return (
<section
aria-label="Choose the mobile stronghold advantage"
className="space-y-4"
aria-busy={busy}
>
<p className="m-0 text-base leading-7">
Choose the controlled stronghold advantage to copy before making battle
plans.
</p>
<div className="grid grid-cols-1 items-start gap-4 sm:grid-cols-2">
{available.map((card) => (
<StrongholdFace key={card.id} card={card}>
<button
type="button"
disabled={busy}
className="min-h-12 w-full whitespace-normal rounded-lg border border-[#eed29a] bg-[#d4b171] px-4 py-3 text-base font-semibold leading-6 text-[#1c211b] enabled:cursor-pointer enabled:hover:bg-[#edcc8c] focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-[#edcc8c] disabled:cursor-wait disabled:opacity-50"
onClick={() =>
act({ type: 'decision', event, stronghold: card.id })
}
>
Copy {card.name}
</button>
</StrongholdFace>
))}
</div>
</section>
);
}
+78
View File
@@ -0,0 +1,78 @@
'use client';
import { useId } from 'react';
import { Input } from '@/components/ui/input';
import { TRUTH_CARD_NAMES } from '@/game/truthtrance';
import type { CardCountFact } from '@/game/truthtrance-card-count';
export function CardCountFields({
value,
onChange,
}: {
value: CardCountFact;
onChange: (fact: CardCountFact) => void;
}) {
const id = useId();
const invalid = !Number.isSafeInteger(value.value) || value.value < 0;
return (
<>
<label>
Card name
<select
value={value.name}
onChange={(event) => onChange({ ...value, name: event.target.value })}
>
{TRUTH_CARD_NAMES.map((name) => (
<option key={name}>{name}</option>
))}
</select>
</label>
<label>
Compare card count
<select
value={value.compare}
onChange={(event) =>
onChange({
...value,
compare: event.target.value as CardCountFact['compare'],
})
}
>
<option value="eq">Exactly</option>
<option value="gte">At least</option>
<option value="lte">At most</option>
</select>
</label>
<label htmlFor={`${id}-count`}>
Number of copies
<Input
id={`${id}-count`}
type="number"
min={0}
max={Number.MAX_SAFE_INTEGER}
step={1}
required
value={Number.isNaN(value.value) ? '' : value.value}
aria-invalid={invalid}
aria-describedby={`${id}-help${invalid ? ` ${id}-error` : ''}`}
onChange={(event) =>
onChange({ ...value, value: event.currentTarget.valueAsNumber })
}
/>
</label>
<p id={`${id}-help`} className="fine">
Counts copies of this exact named card in the player’s hand now. Cards
in the deck, discard pile, or a separate cache do not count. The answer
reveals only whether the comparison is true.
</p>
{invalid && (
<p id={`${id}-error`} className="notice" role="alert">
{Number.isInteger(value.value) &&
value.value > Number.MAX_SAFE_INTEGER
? 'That card count is too large.'
: 'Enter a whole card count of zero or more.'}
</p>
)}
</>
);
}
+701
View File
@@ -0,0 +1,701 @@
'use client';
import { TERRITORIES } from '@/game/board';
import { BattleClaimFields } from './battle-promises';
import { CardCountFields } from './truthtrance-card-count';
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';
import {
TRUTH_CARD_NAMES,
truthQuestionText,
type TruthFact,
type TruthQuestion,
} from '@/game/truthtrance';
import type { Action, GameView } from '@/game/engine';
export function Truthtrance({
game: g,
act,
busy,
}: {
game: GameView;
act: (a: Action) => void;
busy: boolean;
}) {
const w = g.truthtrance!;
const me = g.players.find((p) => p.id === g.me)!;
const current = w.queue[0];
const [target, setTarget] = useState(
g.players.find((p) => p.id !== me.id)!.id,
);
const [kind, setKind] = useState<
'fact' | 'freeform' | 'battlePlan' | 'shipment'
>('fact');
const [shipmentTerritory, setShipmentTerritory] = useState('carthag');
const [shipmentMinimum, setShipmentMinimum] = useState(6);
const invalidShipment =
!Number.isSafeInteger(shipmentMinimum) ||
shipmentMinimum < 1 ||
shipmentMinimum > 20;
const shipmentAvailable =
!g.advanced &&
!g.expansions.length &&
g.phase === 5 &&
g.active === target &&
!g.players.find((p) => p.id === target)?.shipped &&
!g.response &&
!g.decision &&
!g.phaseOpening;
const [planClauses, setPlanClauses] = useState<PlanClaim[]>([
{ kind: 'dial', compare: 'gte', value: 1 },
{ kind: 'weapon', name: null },
]);
const [scope, setScope] = useState<'fact' | 'currentTurn'>('fact');
const [text, setText] = useState('');
const [join, setJoin] = useState<'single' | 'and' | 'or'>('single');
const [clauses, setClauses] = useState<TruthFact[]>([
{ kind: 'hand', name: 'Shield' },
{ kind: 'hand', name: 'Snooper' },
]);
const factId = useId();
const invalidSpice = (fact: TruthFact) =>
(fact.kind === 'spice' || fact.kind === 'handCount') &&
(!Number.isSafeInteger(fact.value) || fact.value < 0);
const invalidFact = clauses
.slice(0, join === 'single' ? 1 : 2)
.some(invalidSpice);
const leaderName = (id: string) =>
id === CHEAP_HERO_TRAITOR
? 'Cheap Hero / Heroine'
: (g.allLeaders.find((l) => l.id === id)?.name ?? id);
const name = (id: string) => g.players.find((p) => p.id === id)!.name;
const question: TruthQuestion =
kind === 'fact'
? {
kind,
target,
fact: join === 'single' ? clauses[0] : { kind: join, terms: clauses },
}
: kind === 'battlePlan'
? {
kind,
target,
territory: g.battle?.territory ?? '',
claim:
join === 'single'
? planClauses[0]
: { kind: join, terms: planClauses },
}
: kind === 'shipment'
? {
kind,
target,
territory: shipmentTerritory,
minimum: shipmentMinimum,
}
: { kind, target, text, scope };
const button = (label: string, action: Action, disabled = false) => (
<Button
className="game-action"
disabled={busy || disabled}
onClick={() => act(action)}
>
{label}
</Button>
);
return (
<section className="truthtrance-panel" aria-label="Truthtrance question">
<h2>Truthtrance</h2>
<p className="fine">
<Link href="/rules#card-truthtrance">Question rules</Link> · The table
resumes its previous decision after these questions.
</p>
{w.stage === 'priority' ? (
<>
<p>
A Truthtrance has been declared. Declare yours now or pass;
questions then follow storm order.
</p>
<ul>
{g.players.map((p) => (
<li key={p.id}>
{p.name} ·{' '}
{w.queue.some((q) => q.player === p.id)
? 'Declared'
: w.passed.includes(p.id)
? 'Passed'
: 'Choosing'}
</li>
))}
</ul>
{!w.passed.includes(me.id) && (
<>
{me.hand
?.filter((c) => c.effect === 'truthtrance')
.slice(0, 1)
.map((c) => (
<div key={c.id}>
{button('Declare my Truthtrance', {
type: 'card',
card: c.id,
})}
</div>
))}
{(me.hand?.filter((c) => c.effect === 'truthtrance').length ??
0) > 1 &&
button('Declare both Truthtrances', {
type: 'card',
card: me.hand!.find((c) => c.effect === 'truthtrance')!.id,
cards: me
.hand!.filter((c) => c.effect === 'truthtrance')
.slice(1)
.map((c) => c.id),
})}
{button('Pass Truthtrance priority', { type: 'truthPass' })}
</>
)}
</>
) : (
<>
<p className="eyebrow">
{name(current.player)} holds the question · {w.queue.length} pending
</p>
{w.question && (
<blockquote>
<p>{truthQuestionText(w.question, leaderName)}</p>
<p className="fine">Asked of {name(w.question.target)}</p>
</blockquote>
)}
{w.stage === 'answer' ? (
w.question!.target === me.id ? (
<>
<p>
Your answer is public. Answer truthfully about the game; a
promise can only bind decisions in this turn.
</p>
{w.question!.kind === 'shipment' ? (
<>
<p className="notice">
A definite answer binds your shipment from reserves this
turn. Available preparation includes your Ghola, Karama
and recoverable allied funding. Yes leaves the exact
count, sector and payment yours to choose; No allows a
different shipment or none.
</p>
{(g.truthShipmentAnswers ?? []).map((answer) => (
<div key={answer}>
{button(
answer === 'unknown'
? 'I don’t know yet'
: `Answer ${answer === 'yes' ? 'Yes' : 'No'}`,
{ type: 'truthAnswer', answer },
)}
</div>
))}
</>
) : w.question!.kind === 'battlePlan' ? (
<>
<p className="notice">
Choose an answer you can honor in a legal plan, including
available Ghola preparation. Earlier Truthtrance answers,
Voice and revealed prescience elements apply. An answer
about a sealed plan is already fixed.
</p>
{(g.truthBattleAnswers ?? []).map((answer) => (
<div key={answer}>
{button(
answer === 'unknown'
? 'I don’t know yet'
: `Answer ${answer === 'yes' ? 'Yes' : 'No'}`,
{ type: 'truthAnswer', answer },
)}
</div>
))}
<p className="fine">
A definite answer constrains your plan. Other plan
elements remain yours to choose.
</p>
</>
) : w.question!.kind === 'fact' ? (
<>
<p className="notice">
Your current game information establishes:{' '}
<strong>
{g.truthAnswer === 'unknown'
? 'I don’t know yet'
: g.truthAnswer === 'yes'
? 'Yes'
: 'No'}
</strong>
. Only you see this until you answer.
</p>
{button('Publish my answer', {
type: 'truthAnswer',
answer: g.truthAnswer,
})}
</>
) : (
<>
{button('Answer Yes', {
type: 'truthAnswer',
answer: 'yes',
})}
{button('Answer No', { type: 'truthAnswer', answer: 'no' })}
{button('I don’t know', {
type: 'truthAnswer',
answer: 'unknown',
})}
<p className="fine">
Use “I don’t know” for something you cannot know or
decide. The holder can ask a different question or save
the card.
</p>
</>
)}
</>
) : (
<output>Waiting for {name(w.question!.target)} to answer.</output>
)
) : current.player !== me.id ? (
<output>
Waiting for {name(current.player)} to{' '}
{w.stage === 'unknown'
? 'ask again or save the card'
: 'ask a question'}
.
</output>
) : (
<>
{w.stage === 'unknown' && (
<>
<p>
The answer could not be known. Ask a different question or
keep Truthtrance for later.
</p>
{button('Save card for later', { type: 'truthSave' })}
</>
)}
<label>
Ask which player?
<select
value={target}
onChange={(e) => setTarget(e.target.value)}
>
{g.players
.filter((p) => p.id !== me.id)
.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
{p.bot ? ' · AI' : ''}
</option>
))}
</select>
</label>
<label>
Question type
<select
value={kind}
onChange={(e) => setKind(e.target.value as typeof kind)}
>
<option value="fact">
Verified cards, traitors or spice
</option>
<option value="shipment">
Bind a shipment from reserves
</option>
<option value="freeform">Write a game question</option>
{g.battle && (
<option value="battlePlan">
Bind a battle-plan choice
</option>
)}
</select>
</label>
{kind === 'shipment' ? (
<>
<label>
Shipment destination
<select
value={shipmentTerritory}
onChange={(e) => setShipmentTerritory(e.target.value)}
>
{TERRITORIES.map((t) => (
<option key={t.id} value={t.id}>
{t.name}
</option>
))}
</select>
</label>
<label htmlFor={`${factId}-shipment-minimum`}>
Minimum physical forces
<Input
id={`${factId}-shipment-minimum`}
type="number"
min={1}
max={20}
step={1}
required
value={
Number.isNaN(shipmentMinimum) ? '' : shipmentMinimum
}
aria-invalid={invalidShipment}
aria-describedby={`${factId}-shipment-help`}
onChange={(e) =>
setShipmentMinimum(e.currentTarget.valueAsNumber)
}
/>
</label>
<p className="fine" id={`${factId}-shipment-help`}>
Includes Fremen reinforcements and Guild transport from
southern reserves. Ground movement and transport of forces
already on the board do not count.
</p>
{!shipmentAvailable && (
<p className="notice">
Automatic enforcement currently supports the active
player’s unused shipment in the Basic game without
expansions. Select that player and finish pending
decisions first. Earlier questions and other rules
configurations remain unfinished.
</p>
)}
<p className="notice">
{invalidShipment
? 'Enter a whole minimum of one to twenty forces.'
: truthQuestionText(question, leaderName)}
</p>
</>
) : kind === 'battlePlan' ? (
<>
<label>
Combine conditions
<select
value={join}
onChange={(e) => setJoin(e.target.value as typeof join)}
>
<option value="single">One condition</option>
<option value="and">Both · AND</option>
<option value="or">Either · OR</option>
</select>
</label>
{planClauses
.slice(0, join === 'single' ? 1 : 2)
.map((c, index) => (
<fieldset key={index}>
<legend>Condition {index + 1}</legend>
<BattleClaimFields
game={g}
value={c}
onChange={(next) =>
setPlanClauses(
planClauses.map((old, n) =>
n === index ? next : old,
),
)
}
/>
</fieldset>
))}
<p className="notice">
{truthQuestionText(question, leaderName)}
</p>
{g.battle &&
![g.battle.attacker, g.battle.defender].includes(
target,
) && <p>Select one of the combatants for this question.</p>}
</>
) : kind === 'fact' ? (
<>
<label>
Combine facts
<select
value={join}
onChange={(e) => setJoin(e.target.value as typeof join)}
>
<option value="single">One fact</option>
<option value="and">Both facts · AND</option>
<option value="or">Either fact · OR</option>
</select>
</label>
{clauses
.slice(0, join === 'single' ? 1 : 2)
.map((c, index) => (
<fieldset key={index}>
<legend>Fact {index + 1}</legend>
<label>
Fact type
<select
value={c.kind}
onChange={(e) =>
setClauses(
clauses.map((old, n) =>
n === index
? e.target.value === 'hand'
? { kind: 'hand', name: 'Shield' }
: e.target.value === 'handCount'
? {
kind: 'handCount',
name: 'Shield',
compare: 'gte',
value: 2,
}
: e.target.value === 'spice'
? {
kind: 'spice',
compare: 'gte',
value: 6,
}
: {
kind: 'traitor',
leader: g.allLeaders[0].id,
}
: old,
),
)
}
>
<option value="hand">Holds a named card</option>
<option value="handCount">
Number of a named card
</option>
<option value="traitor">Selected a traitor</option>
<option value="spice">
Current personal spice
</option>
</select>
</label>
{c.kind === 'hand' ? (
<label>
Card name
<select
value={c.name}
onChange={(e) =>
setClauses(
clauses.map((old, n) =>
n === index
? { kind: 'hand', name: e.target.value }
: old,
),
)
}
>
{TRUTH_CARD_NAMES.map((name) => (
<option key={name}>{name}</option>
))}
</select>
</label>
) : c.kind === 'handCount' ? (
<CardCountFields
value={c}
onChange={(next) =>
setClauses(
clauses.map((old, n) =>
n === index ? next : old,
),
)
}
/>
) : c.kind === 'spice' ? (
<>
<label>
Compare personal spice
<select
value={c.compare}
onChange={(e) =>
setClauses(
clauses.map((old, n) =>
n === index
? {
...c,
compare: e.target
.value as typeof c.compare,
}
: old,
),
)
}
>
<option value="eq">Exactly</option>
<option value="gte">At least</option>
<option value="lte">At most</option>
</select>
</label>
<label htmlFor={`${factId}-${index}-spice-amount`}>
Spice amount
<Input
id={`${factId}-${index}-spice-amount`}
type="number"
min={0}
max={Number.MAX_SAFE_INTEGER}
step={1}
required
value={Number.isNaN(c.value) ? '' : c.value}
aria-invalid={invalidSpice(c)}
aria-describedby={`${factId}-${index}-spice-help${invalidSpice(c) ? ` ${factId}-${index}-spice-error` : ''}`}
onChange={(e) =>
setClauses(
clauses.map((old, n) =>
n === index
? {
...c,
value:
e.currentTarget.valueAsNumber,
}
: old,
),
)
}
/>
</label>
<p
className="fine"
id={`${factId}-${index}-spice-help`}
>
Counts spice the player personally holds now.
Excludes pledged aid and incoming payments. This
asks about the current balance; it does not
promise future holdings or spending.
</p>
{invalidSpice(c) && (
<p
className="notice"
role="alert"
id={`${factId}-${index}-spice-error`}
>
{Number.isInteger(c.value) &&
c.value > Number.MAX_SAFE_INTEGER
? 'That spice amount is too large.'
: 'Enter a whole spice amount of zero or more.'}
</p>
)}
</>
) : (
c.kind === 'traitor' && (
<label>
Leader
<select
value={c.leader}
onChange={(e) =>
setClauses(
clauses.map((old, n) =>
n === index
? {
kind: 'traitor',
leader: e.target.value,
}
: old,
),
)
}
>
{g.allLeaders.map((l) => (
<option key={l.id} value={l.id}>
{l.name}
</option>
))}
<option value={CHEAP_HERO_TRAITOR}>
Cheap Hero / Heroine
</option>
</select>
</label>
)
)}
</fieldset>
))}
<p className="notice">
{invalidFact
? 'Enter a valid whole amount before asking this question.'
: truthQuestionText(question, leaderName)}
</p>
</>
) : (
<>
<label>
Question concerns
<select
value={scope}
onChange={(e) => setScope(e.target.value as typeof scope)}
>
<option value="fact">A game fact</option>
<option value="currentTurn">
A promise during turn {g.turn}
</option>
</select>
</label>
<label>
One yes/no question
<textarea
rows={4}
maxLength={500}
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="Will you ship at least six forces to Carthag this turn?"
/>
</label>
<p className="notice">
Freeform answers rely on the players. Automatic checks for
arbitrary promises are unfinished. AI can answer structured
fact, supported shipment and battle-plan questions; it
cannot yet interpret freeform questions.
</p>
</>
)}
{button(
'Ask publicly',
{ type: 'truthAsk', question },
(kind === 'shipment' &&
(invalidShipment || !shipmentAvailable)) ||
(kind === 'freeform' && !text.trim()) ||
(kind === 'fact' && invalidFact) ||
(kind === 'battlePlan' &&
(!g.battle ||
![g.battle.attacker, g.battle.defender].includes(
target,
))),
)}
</>
)}
</>
)}
</section>
);
}
export function TruthHistory({ game: g }: { game: GameView }) {
const records = g.truthHistory.filter((r) => r.turn === g.turn);
if (!records.length) return null;
const name = (id: string) => g.players.find((p) => p.id === id)?.name ?? id;
return (
<details className="notice truthtrance-history">
<summary>Truthtrance answers · Turn {g.turn}</summary>
<p className="fine">
Structured battle-plan and supported reserve-shipment answers are
enforced. Current-turn freeform promises remain binding when possible,
but their automatic enforcement is unfinished.
</p>
<ol>
{records.map((r, i) => (
<li key={i}>
<p>
{name(r.asker)} → {name(r.question.target)}:{' '}
{truthQuestionText(r.question, (id) =>
id === CHEAP_HERO_TRAITOR
? 'Cheap Hero / Heroine'
: (g.allLeaders.find((l) => l.id === id)?.name ?? id),
)}
</p>
<strong>
{r.answer === 'unknown'
? 'I don’t know'
: r.answer === 'yes'
? 'Yes'
: 'No'}
</strong>
</li>
))}
</ol>
</details>
);
}
+78
View File
@@ -0,0 +1,78 @@
import { Accordion as AccordionPrimitive } from '@base-ui/react/accordion';
import { cn } from '@/lib/utils';
import { ChevronDownIcon, ChevronUpIcon } from 'lucide-react';
function Accordion({ className, ...props }: AccordionPrimitive.Root.Props) {
return (
<AccordionPrimitive.Root
data-slot="accordion"
className={cn('flex w-full flex-col', className)}
{...props}
/>
);
}
function AccordionItem({ className, ...props }: AccordionPrimitive.Item.Props) {
return (
<AccordionPrimitive.Item
data-slot="accordion-item"
className={cn('not-last:border-b', className)}
{...props}
/>
);
}
function AccordionTrigger({
className,
children,
...props
}: AccordionPrimitive.Trigger.Props) {
return (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
data-slot="accordion-trigger"
className={cn(
'focus-visible:ring-ring/50 focus-visible:border-ring focus-visible:after:border-ring **:data-[slot=accordion-trigger-icon]:text-muted-foreground rounded-lg py-2.5 text-left text-sm font-medium hover:underline focus-visible:ring-3 **:data-[slot=accordion-trigger-icon]:ml-auto **:data-[slot=accordion-trigger-icon]:size-4 group/accordion-trigger relative flex flex-1 items-start justify-between border border-transparent transition-all outline-none aria-disabled:pointer-events-none aria-disabled:opacity-50',
className,
)}
{...props}
>
{children}
<ChevronDownIcon
data-slot="accordion-trigger-icon"
className="pointer-events-none shrink-0 group-aria-expanded/accordion-trigger:hidden"
/>
<ChevronUpIcon
data-slot="accordion-trigger-icon"
className="pointer-events-none hidden shrink-0 group-aria-expanded/accordion-trigger:inline"
/>
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
);
}
function AccordionContent({
className,
children,
...props
}: AccordionPrimitive.Panel.Props) {
return (
<AccordionPrimitive.Panel
data-slot="accordion-content"
className="data-open:animate-accordion-down data-closed:animate-accordion-up text-sm overflow-hidden"
{...props}
>
<div
className={cn(
'pt-0 pb-2.5 h-(--accordion-panel-height) data-ending-style:h-0 data-starting-style:h-0 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4',
className,
)}
>
{children}
</div>
</AccordionPrimitive.Panel>
);
}
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };
+187
View File
@@ -0,0 +1,187 @@
'use client';
import * as React from 'react';
import { AlertDialog as AlertDialogPrimitive } from '@base-ui/react/alert-dialog';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
function AlertDialog({ ...props }: AlertDialogPrimitive.Root.Props) {
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />;
}
function AlertDialogTrigger({ ...props }: AlertDialogPrimitive.Trigger.Props) {
return (
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
);
}
function AlertDialogPortal({ ...props }: AlertDialogPrimitive.Portal.Props) {
return (
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
);
}
function AlertDialogOverlay({
className,
...props
}: AlertDialogPrimitive.Backdrop.Props) {
return (
<AlertDialogPrimitive.Backdrop
data-slot="alert-dialog-overlay"
className={cn(
'data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs fixed inset-0 isolate z-50',
className,
)}
{...props}
/>
);
}
function AlertDialogContent({
className,
size = 'default',
...props
}: AlertDialogPrimitive.Popup.Props & {
size?: 'default' | 'sm';
}) {
return (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Popup
data-slot="alert-dialog-content"
data-size={size}
className={cn(
'data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 bg-popover text-popover-foreground ring-foreground/10 gap-4 rounded-xl p-4 ring-1 duration-100 data-[size=default]:max-w-xs data-[size=sm]:max-w-xs data-[size=default]:sm:max-w-sm group/alert-dialog-content fixed top-1/2 left-1/2 z-50 grid w-full -translate-x-1/2 -translate-y-1/2 outline-none',
className,
)}
{...props}
/>
</AlertDialogPortal>
);
}
function AlertDialogHeader({
className,
...props
}: React.ComponentProps<'div'>) {
return (
<div
data-slot="alert-dialog-header"
className={cn(
'grid grid-rows-[auto_1fr] place-items-center gap-1.5 text-center has-data-[slot=alert-dialog-media]:grid-rows-[auto_auto_1fr] has-data-[slot=alert-dialog-media]:gap-x-4 sm:group-data-[size=default]/alert-dialog-content:place-items-start sm:group-data-[size=default]/alert-dialog-content:text-left sm:group-data-[size=default]/alert-dialog-content:has-data-[slot=alert-dialog-media]:grid-rows-[auto_1fr]',
className,
)}
{...props}
/>
);
}
function AlertDialogFooter({
className,
...props
}: React.ComponentProps<'div'>) {
return (
<div
data-slot="alert-dialog-footer"
className={cn(
'bg-muted/50 -mx-4 -mb-4 rounded-b-xl border-t p-4 flex flex-col-reverse gap-2 group-data-[size=sm]/alert-dialog-content:grid group-data-[size=sm]/alert-dialog-content:grid-cols-2 sm:flex-row sm:justify-end',
className,
)}
{...props}
/>
);
}
function AlertDialogMedia({
className,
...props
}: React.ComponentProps<'div'>) {
return (
<div
data-slot="alert-dialog-media"
className={cn(
"bg-muted mb-2 inline-flex size-10 items-center justify-center rounded-md sm:group-data-[size=default]/alert-dialog-content:row-span-2 *:[svg:not([class*='size-'])]:size-6",
className,
)}
{...props}
/>
);
}
function AlertDialogTitle({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
return (
<AlertDialogPrimitive.Title
data-slot="alert-dialog-title"
className={cn(
'text-base font-medium sm:group-data-[size=default]/alert-dialog-content:group-has-data-[slot=alert-dialog-media]/alert-dialog-content:col-start-2 cn-font-heading',
className,
)}
{...props}
/>
);
}
function AlertDialogDescription({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
return (
<AlertDialogPrimitive.Description
data-slot="alert-dialog-description"
className={cn(
'text-muted-foreground *:[a]:hover:text-foreground text-sm text-balance md:text-pretty *:[a]:underline *:[a]:underline-offset-3',
className,
)}
{...props}
/>
);
}
function AlertDialogAction({
className,
...props
}: React.ComponentProps<typeof Button>) {
return (
<Button
data-slot="alert-dialog-action"
className={cn(className)}
{...props}
/>
);
}
function AlertDialogCancel({
className,
variant = 'outline',
size = 'default',
...props
}: AlertDialogPrimitive.Close.Props &
Pick<React.ComponentProps<typeof Button>, 'variant' | 'size'>) {
return (
<AlertDialogPrimitive.Close
data-slot="alert-dialog-cancel"
className={cn(className)}
render={<Button variant={variant} size={size} />}
{...props}
/>
);
}
export {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogMedia,
AlertDialogOverlay,
AlertDialogPortal,
AlertDialogTitle,
AlertDialogTrigger,
};
+76
View File
@@ -0,0 +1,76 @@
import * as React from 'react';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const alertVariants = cva(
"group/alert relative grid w-full gap-0.5 rounded-lg border px-2.5 py-2 text-left text-sm has-data-[slot=alert-action]:relative has-data-[slot=alert-action]:pr-18 has-[>svg]:grid-cols-[auto_1fr] has-[>svg]:gap-x-2 *:[svg]:row-span-2 *:[svg]:translate-y-0.5 *:[svg]:text-current *:[svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: 'bg-card text-card-foreground',
destructive:
'bg-card text-destructive *:data-[slot=alert-description]:text-destructive/90 *:[svg]:text-current',
},
},
defaultVariants: {
variant: 'default',
},
},
);
function Alert({
className,
variant,
...props
}: React.ComponentProps<'div'> & VariantProps<typeof alertVariants>) {
return (
<div
data-slot="alert"
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
);
}
function AlertTitle({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="alert-title"
className={cn(
'font-medium group-has-[>svg]/alert:col-start-2 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground',
className,
)}
{...props}
/>
);
}
function AlertDescription({
className,
...props
}: React.ComponentProps<'div'>) {
return (
<div
data-slot="alert-description"
className={cn(
'text-sm text-balance text-muted-foreground md:text-pretty [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4',
className,
)}
{...props}
/>
);
}
function AlertAction({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="alert-action"
className={cn('absolute top-2 right-2', className)}
{...props}
/>
);
}
export { Alert, AlertTitle, AlertDescription, AlertAction };
+22
View File
@@ -0,0 +1,22 @@
import { cn } from '@/lib/utils';
function AspectRatio({
ratio,
className,
...props
}: React.ComponentProps<'div'> & { ratio: number }) {
return (
<div
data-slot="aspect-ratio"
style={
{
'--ratio': ratio,
} as React.CSSProperties
}
className={cn('relative aspect-(--ratio)', className)}
{...props}
/>
);
}
export { AspectRatio };
+207
View File
@@ -0,0 +1,207 @@
import * as React from 'react';
import { mergeProps } from '@base-ui/react/merge-props';
import { useRender } from '@base-ui/react/use-render';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
const attachmentVariants = cva(
'rounded-xl w-fit focus-within:ring-1 focus-within:ring-ring/50 group/attachment relative flex max-w-full min-w-0 shrink-0 flex-wrap border bg-card text-card-foreground transition-colors has-[>a,>button]:hover:bg-muted/50 data-[state=error]:border-destructive/30 data-[state=idle]:border-dashed',
{
variants: {
size: {
default:
'gap-2 has-data-[slot=attachment-content]:px-2.5 has-data-[slot=attachment-content]:py-2 has-data-[slot=attachment-media]:p-2 text-sm',
sm: 'gap-2.5 has-data-[slot=attachment-content]:px-2 has-data-[slot=attachment-content]:py-1.5 has-data-[slot=attachment-media]:p-1.5 text-xs',
xs: 'gap-1.5 has-data-[slot=attachment-content]:px-1.5 has-data-[slot=attachment-content]:py-1 has-data-[slot=attachment-media]:p-1 text-xs rounded-lg',
},
orientation: {
horizontal: 'min-w-40 items-center',
vertical: 'w-24 has-data-[slot=attachment-content]:w-30 flex-col',
},
},
},
);
function Attachment({
className,
state = 'done',
size = 'default',
orientation = 'horizontal',
...props
}: React.ComponentProps<'div'> &
VariantProps<typeof attachmentVariants> & {
state?: 'idle' | 'uploading' | 'processing' | 'error' | 'done';
}) {
return (
<div
data-slot="attachment"
data-state={state}
data-size={size}
data-orientation={orientation}
className={cn(attachmentVariants({ size, orientation }), className)}
{...props}
/>
);
}
const attachmentMediaVariants = cva(
"bg-muted text-foreground w-10 rounded-lg group-data-[size=sm]/attachment:w-8 group-data-[size=xs]/attachment:w-7 group-data-[size=xs]/attachment:rounded-md [&_svg:not([class*='size-'])]:size-4 group-data-[size=xs]/attachment:[&_svg:not([class*='size-'])]:size-3.5 group-data-[orientation=vertical]/attachment:w-full group-data-[orientation=vertical]/attachment:[&_svg:not([class*='size-'])]:size-6 group-data-[orientation=vertical]/attachment:*:data-[slot=spinner]:size-6! relative flex aspect-square shrink-0 items-center justify-center overflow-hidden group-data-[state=error]/attachment:bg-destructive/10 group-data-[state=error]/attachment:text-destructive [&_svg]:pointer-events-none",
{
variants: {
variant: {
icon: '',
image:
'opacity-60 group-data-[state=idle]/attachment:opacity-100 group-data-[state=done]/attachment:opacity-100 *:[img]:aspect-square *:[img]:w-full *:[img]:object-cover',
},
},
defaultVariants: {
variant: 'icon',
},
},
);
function AttachmentMedia({
className,
variant = 'icon',
...props
}: React.ComponentProps<'div'> & VariantProps<typeof attachmentMediaVariants>) {
return (
<div
data-slot="attachment-media"
data-variant={variant}
className={cn(attachmentMediaVariants({ variant }), className)}
{...props}
/>
);
}
function AttachmentContent({
className,
...props
}: React.ComponentProps<'div'>) {
return (
<div
data-slot="attachment-content"
className={cn(
'leading-tight group-data-[orientation=vertical]/attachment:px-1 max-w-full min-w-0 flex-1',
className,
)}
{...props}
/>
);
}
function AttachmentTitle({
className,
...props
}: React.ComponentProps<'span'>) {
return (
<span
data-slot="attachment-title"
className={cn(
'font-medium block max-w-full min-w-0 truncate group-data-[state=processing]/attachment:shimmer group-data-[state=uploading]/attachment:shimmer',
className,
)}
{...props}
/>
);
}
function AttachmentDescription({
className,
...props
}: React.ComponentProps<'span'>) {
return (
<span
data-slot="attachment-description"
className={cn(
'mt-0.5 text-xs block min-w-0 truncate text-muted-foreground group-data-[state=error]/attachment:text-destructive/80',
'max-w-full',
className,
)}
{...props}
/>
);
}
function AttachmentActions({
className,
...props
}: React.ComponentProps<'div'>) {
return (
<div
data-slot="attachment-actions"
className={cn(
'group-data-[orientation=vertical]/attachment:absolute group-data-[orientation=vertical]/attachment:top-3 group-data-[orientation=vertical]/attachment:right-3 relative z-20 group-data-[orientation=vertical]/attachment:gap-1 flex shrink-0 items-center',
className,
)}
{...props}
/>
);
}
function AttachmentAction({
className,
variant,
size = 'icon-xs',
...props
}: React.ComponentProps<typeof Button>) {
return (
<Button
data-slot="attachment-action"
variant={variant ?? 'ghost'}
size={size}
className={cn(className)}
{...props}
/>
);
}
function AttachmentTrigger({
className,
render,
type,
...props
}: useRender.ComponentProps<'button'>) {
return useRender({
defaultTagName: 'button',
props: mergeProps<'button'>(
{
type: render ? type : (type ?? 'button'),
className: cn('absolute inset-0 z-10 outline-none', className),
},
props,
),
render,
state: {
slot: 'attachment-trigger',
},
});
}
function AttachmentGroup({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="attachment-group"
className={cn(
'gap-3 scroll-px-1 py-1 flex min-w-0 scroll-fade-x snap-x snap-mandatory scrollbar-none overflow-x-auto overscroll-x-contain *:data-[slot=attachment]:flex-none *:data-[slot=attachment]:snap-start',
className,
)}
{...props}
/>
);
}
export {
Attachment,
AttachmentGroup,
AttachmentMedia,
AttachmentContent,
AttachmentTitle,
AttachmentDescription,
AttachmentActions,
AttachmentAction,
AttachmentTrigger,
};
+109
View File
@@ -0,0 +1,109 @@
'use client';
import * as React from 'react';
import { Avatar as AvatarPrimitive } from '@base-ui/react/avatar';
import { cn } from '@/lib/utils';
function Avatar({
className,
size = 'default',
...props
}: AvatarPrimitive.Root.Props & {
size?: 'default' | 'sm' | 'lg';
}) {
return (
<AvatarPrimitive.Root
data-slot="avatar"
data-size={size}
className={cn(
'size-8 rounded-full after:rounded-full data-[size=lg]:size-10 data-[size=sm]:size-6 group/avatar relative flex shrink-0 select-none after:absolute after:inset-0 after:border after:border-border after:mix-blend-darken dark:after:mix-blend-lighten',
className,
)}
{...props}
/>
);
}
function AvatarImage({ className, ...props }: AvatarPrimitive.Image.Props) {
return (
<AvatarPrimitive.Image
data-slot="avatar-image"
className={cn(
'rounded-full aspect-square size-full object-cover',
className,
)}
{...props}
/>
);
}
function AvatarFallback({
className,
...props
}: AvatarPrimitive.Fallback.Props) {
return (
<AvatarPrimitive.Fallback
data-slot="avatar-fallback"
className={cn(
'bg-muted text-muted-foreground rounded-full flex size-full items-center justify-center text-sm group-data-[size=sm]/avatar:text-xs',
className,
)}
{...props}
/>
);
}
function AvatarBadge({ className, ...props }: React.ComponentProps<'span'>) {
return (
<span
data-slot="avatar-badge"
className={cn(
'bg-primary text-primary-foreground ring-background absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-blend-color ring-2 select-none',
'group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden',
'group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2',
'group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2',
className,
)}
{...props}
/>
);
}
function AvatarGroup({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="avatar-group"
className={cn(
'group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2 *:data-[slot=avatar]:ring-background',
className,
)}
{...props}
/>
);
}
function AvatarGroupCount({
className,
...props
}: React.ComponentProps<'div'>) {
return (
<div
data-slot="avatar-group-count"
className={cn(
'bg-muted text-muted-foreground size-8 rounded-full text-sm group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3 relative flex shrink-0 items-center justify-center ring-2 ring-background',
className,
)}
{...props}
/>
);
}
export {
Avatar,
AvatarImage,
AvatarFallback,
AvatarGroup,
AvatarGroupCount,
AvatarBadge,
};
+52
View File
@@ -0,0 +1,52 @@
import { mergeProps } from '@base-ui/react/merge-props';
import { useRender } from '@base-ui/react/use-render';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const badgeVariants = cva(
'group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!',
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground [a]:hover:bg-primary/80',
secondary:
'bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80',
destructive:
'bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20',
outline:
'border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground',
ghost:
'hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50',
link: 'text-primary underline-offset-4 hover:underline',
},
},
defaultVariants: {
variant: 'default',
},
},
);
function Badge({
className,
variant = 'default',
render,
...props
}: useRender.ComponentProps<'span'> & VariantProps<typeof badgeVariants>) {
return useRender({
defaultTagName: 'span',
props: mergeProps<'span'>(
{
className: cn(badgeVariants({ variant }), className),
},
props,
),
render,
state: {
slot: 'badge',
variant,
},
});
}
export { Badge, badgeVariants };
+122
View File
@@ -0,0 +1,122 @@
import * as React from 'react';
import { mergeProps } from '@base-ui/react/merge-props';
import { useRender } from '@base-ui/react/use-render';
import { cn } from '@/lib/utils';
import { ChevronRightIcon, MoreHorizontalIcon } from 'lucide-react';
function Breadcrumb({ className, ...props }: React.ComponentProps<'nav'>) {
return (
<nav
aria-label="breadcrumb"
data-slot="breadcrumb"
className={cn(className)}
{...props}
/>
);
}
function BreadcrumbList({ className, ...props }: React.ComponentProps<'ol'>) {
return (
<ol
data-slot="breadcrumb-list"
className={cn(
'text-muted-foreground gap-1.5 text-sm flex flex-wrap items-center wrap-break-word',
className,
)}
{...props}
/>
);
}
function BreadcrumbItem({ className, ...props }: React.ComponentProps<'li'>) {
return (
<li
data-slot="breadcrumb-item"
className={cn('gap-1 inline-flex items-center', className)}
{...props}
/>
);
}
function BreadcrumbLink({
className,
render,
...props
}: useRender.ComponentProps<'a'>) {
return useRender({
defaultTagName: 'a',
props: mergeProps<'a'>(
{
className: cn('hover:text-foreground transition-colors', className),
},
props,
),
render,
state: {
slot: 'breadcrumb-link',
},
});
}
function BreadcrumbPage({ className, ...props }: React.ComponentProps<'span'>) {
return (
<span
data-slot="breadcrumb-page"
role="link"
aria-disabled="true"
aria-current="page"
className={cn('text-foreground font-normal', className)}
{...props}
/>
);
}
function BreadcrumbSeparator({
children,
className,
...props
}: React.ComponentProps<'li'>) {
return (
<li
data-slot="breadcrumb-separator"
role="presentation"
aria-hidden="true"
className={cn('[&>svg]:size-3.5', className)}
{...props}
>
{children ?? <ChevronRightIcon className="cn-rtl-flip" />}
</li>
);
}
function BreadcrumbEllipsis({
className,
...props
}: React.ComponentProps<'span'>) {
return (
<span
data-slot="breadcrumb-ellipsis"
role="presentation"
aria-hidden="true"
className={cn(
'size-5 [&>svg]:size-4 flex items-center justify-center',
className,
)}
{...props}
>
<MoreHorizontalIcon />
<span className="sr-only">More</span>
</span>
);
}
export {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis,
};
+128
View File
@@ -0,0 +1,128 @@
import * as React from 'react';
import { mergeProps } from '@base-ui/react/merge-props';
import { useRender } from '@base-ui/react/use-render';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
function BubbleGroup({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="bubble-group"
className={cn('gap-2 flex min-w-0 flex-col', className)}
{...props}
/>
);
}
const bubbleVariants = cva(
'gap-1 data-[align=end]:self-end max-w-[80%] data-[variant=ghost]:max-w-full group-data-[align=end]/message:self-end group/bubble relative flex w-fit min-w-0 flex-col',
{
variants: {
variant: {
default:
'*:data-[slot=bubble-content]:bg-primary *:data-[slot=bubble-content]:text-primary-foreground [&>[data-slot=bubble-content]:is(button,a):hover]:bg-primary/80',
secondary:
'*:data-[slot=bubble-content]:bg-secondary *:data-[slot=bubble-content]:text-secondary-foreground [&>[data-slot=bubble-content]:is(button,a):hover]:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)]',
muted:
'*:data-[slot=bubble-content]:bg-muted [&>[data-slot=bubble-content]:is(button,a):hover]:bg-[color-mix(in_oklch,var(--muted),var(--foreground)_5%)]',
tinted:
'*:data-[slot=bubble-content]:bg-[oklch(from_var(--primary)_0.93_calc(c*0.4)_h)] dark:*:data-[slot=bubble-content]:bg-[oklch(from_var(--primary)_0.3_calc(c*0.4)_h)] *:data-[slot=bubble-content]:text-foreground [&>[data-slot=bubble-content]:is(button,a):hover]:bg-[oklch(from_var(--primary)_0.88_calc(c*0.5)_h)] dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-[oklch(from_var(--primary)_0.35_calc(c*0.5)_h)]',
outline:
'*:data-[slot=bubble-content]:bg-background *:data-[slot=bubble-content]:border-border [&>[data-slot=bubble-content]:is(button,a):hover]:bg-muted [&>[data-slot=bubble-content]:is(button,a):hover]:text-foreground dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-input/30',
ghost:
'*:data-[slot=bubble-content]:rounded-none *:data-[slot=bubble-content]:bg-transparent *:data-[slot=bubble-content]:p-0 [&>[data-slot=bubble-content]:is(button,a):hover]:bg-muted [&>[data-slot=bubble-content]:is(button,a):hover]:text-foreground dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-muted/50 border-none',
destructive:
'*:data-[slot=bubble-content]:bg-destructive/10 dark:*:data-[slot=bubble-content]:bg-destructive/20 *:data-[slot=bubble-content]:text-destructive [&>[data-slot=bubble-content]:is(button,a):hover]:bg-destructive/20 dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-destructive/30',
},
},
defaultVariants: {
variant: 'default',
},
},
);
function Bubble({
variant = 'default',
align = 'start',
className,
...props
}: React.ComponentProps<'div'> &
VariantProps<typeof bubbleVariants> & {
align?: 'start' | 'end';
}) {
return (
<div
data-slot="bubble"
data-variant={variant}
data-align={align}
className={cn(bubbleVariants({ variant }), className)}
{...props}
/>
);
}
function BubbleContent({
className,
render,
...props
}: useRender.ComponentProps<'div'>) {
return useRender({
defaultTagName: 'div',
props: mergeProps<'div'>(
{
className: cn(
'rounded-xl border border-transparent px-3 py-2 text-sm leading-relaxed [button,a]:outline-none [button,a]:focus-visible:border-ring [button,a]:focus-visible:ring-3 [button,a]:focus-visible:ring-ring/50 group-data-[align=end]/bubble:self-end w-fit max-w-full min-w-0 overflow-hidden wrap-break-word [button]:text-left [button,a]:transition-colors',
className,
),
},
props,
),
render,
state: {
slot: 'bubble-content',
},
});
}
const bubbleReactionsVariants = cva(
'rounded-full ring-3 ring-card bg-muted shrink-0 gap-1 px-1.5 py-0.5 has-[button]:p-0 text-sm absolute z-10 flex w-fit items-center justify-center',
{
variants: {
side: {
top: 'top-0 -translate-y-3/4',
bottom: 'bottom-0 translate-y-3/4',
},
align: {
start: 'left-3',
end: 'right-3',
},
},
defaultVariants: {
side: 'bottom',
align: 'end',
},
},
);
function BubbleReactions({
side = 'bottom',
align = 'end',
className,
...props
}: React.ComponentProps<'div'> & {
align?: 'start' | 'end';
side?: 'top' | 'bottom';
}) {
return (
<div
data-slot="bubble-reactions"
data-align={align}
data-side={side}
className={cn(bubbleReactionsVariants({ side, align }), className)}
{...props}
/>
);
}
export { BubbleGroup, Bubble, BubbleContent, BubbleReactions };
+87
View File
@@ -0,0 +1,87 @@
import { mergeProps } from '@base-ui/react/merge-props';
import { useRender } from '@base-ui/react/use-render';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
import { Separator } from '@/components/ui/separator';
const buttonGroupVariants = cva(
"has-[>[data-slot=button-group]]:gap-2 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-lg flex w-fit items-stretch *:focus-visible:relative *:focus-visible:z-10 [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1",
{
variants: {
orientation: {
horizontal:
'[&>[data-slot]:not(:has(~[data-slot]))]:rounded-r-lg! *:data-slot:rounded-r-none [&>[data-slot]~[data-slot]]:rounded-l-none [&>[data-slot]~[data-slot]]:border-l-0',
vertical:
'[&>[data-slot]:not(:has(~[data-slot]))]:rounded-b-lg! flex-col *:data-slot:rounded-b-none [&>[data-slot]~[data-slot]]:rounded-t-none [&>[data-slot]~[data-slot]]:border-t-0',
},
},
defaultVariants: {
orientation: 'horizontal',
},
},
);
function ButtonGroup({
className,
orientation,
...props
}: React.ComponentProps<'div'> & VariantProps<typeof buttonGroupVariants>) {
return (
<div
role="group"
data-slot="button-group"
data-orientation={orientation}
className={cn(buttonGroupVariants({ orientation }), className)}
{...props}
/>
);
}
function ButtonGroupText({
className,
render,
...props
}: useRender.ComponentProps<'div'>) {
return useRender({
defaultTagName: 'div',
props: mergeProps<'div'>(
{
className: cn(
"bg-muted gap-2 rounded-lg border px-2.5 text-sm font-medium [&_svg:not([class*='size-'])]:size-4 flex items-center [&_svg]:pointer-events-none",
className,
),
},
props,
),
render,
state: {
slot: 'button-group-text',
},
});
}
function ButtonGroupSeparator({
className,
orientation = 'vertical',
...props
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="button-group-separator"
orientation={orientation}
className={cn(
'bg-input relative self-stretch data-horizontal:mx-px data-horizontal:w-auto data-vertical:my-px data-vertical:h-auto',
className,
)}
{...props}
/>
);
}
export {
ButtonGroup,
ButtonGroupSeparator,
ButtonGroupText,
buttonGroupVariants,
};
+58
View File
@@ -0,0 +1,58 @@
import { Button as ButtonPrimitive } from '@base-ui/react/button';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/80',
outline:
'border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50',
secondary:
'bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground',
ghost:
'hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50',
destructive:
'bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default:
'h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2',
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: 'h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2',
icon: 'size-8',
'icon-xs':
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
'icon-sm':
'size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg',
'icon-lg': 'size-9',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
},
);
function Button({
className,
variant = 'default',
size = 'default',
...props
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
return (
<ButtonPrimitive
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
);
}
export { Button, buttonVariants };
+231
View File
@@ -0,0 +1,231 @@
'use client';
import * as React from 'react';
import {
DayPicker,
getDefaultClassNames,
type DayButton,
type Locale,
} from 'react-day-picker';
import { cn } from '@/lib/utils';
import { Button, buttonVariants } from '@/components/ui/button';
import {
ChevronLeftIcon,
ChevronRightIcon,
ChevronDownIcon,
} from 'lucide-react';
function Calendar({
className,
classNames,
showOutsideDays = true,
captionLayout = 'label',
buttonVariant = 'ghost',
locale,
formatters,
components,
...props
}: React.ComponentProps<typeof DayPicker> & {
buttonVariant?: React.ComponentProps<typeof Button>['variant'];
}) {
const defaultClassNames = getDefaultClassNames();
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn(
'p-2 [--cell-radius:var(--radius-md)] [--cell-size:--spacing(7)] group/calendar bg-background in-data-[slot=card-content]:bg-transparent in-data-[slot=popover-content]:bg-transparent',
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
className,
)}
captionLayout={captionLayout}
locale={locale}
formatters={{
formatMonthDropdown: (date) =>
date.toLocaleString(locale?.code, { month: 'short' }),
...formatters,
}}
classNames={{
root: cn('w-fit', defaultClassNames.root),
months: cn(
'relative flex flex-col gap-4 md:flex-row',
defaultClassNames.months,
),
month: cn('flex w-full flex-col gap-4', defaultClassNames.month),
nav: cn(
'absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1',
defaultClassNames.nav,
),
button_previous: cn(
buttonVariants({ variant: buttonVariant }),
'size-(--cell-size) p-0 select-none aria-disabled:opacity-50',
defaultClassNames.button_previous,
),
button_next: cn(
buttonVariants({ variant: buttonVariant }),
'size-(--cell-size) p-0 select-none aria-disabled:opacity-50',
defaultClassNames.button_next,
),
month_caption: cn(
'flex h-(--cell-size) w-full items-center justify-center px-(--cell-size)',
defaultClassNames.month_caption,
),
dropdowns: cn(
'flex h-(--cell-size) w-full items-center justify-center gap-1.5 text-sm font-medium',
defaultClassNames.dropdowns,
),
dropdown_root: cn(
'cn-calendar-dropdown-root relative rounded-(--cell-radius)',
defaultClassNames.dropdown_root,
),
dropdown: cn(
'absolute inset-0 bg-popover opacity-0',
defaultClassNames.dropdown,
),
caption_label: cn(
'font-medium select-none',
captionLayout === 'label'
? 'cn-calendar-caption text-sm'
: 'cn-calendar-caption-label flex items-center gap-1 rounded-(--cell-radius) text-sm [&>svg]:size-3.5 [&>svg]:text-muted-foreground',
defaultClassNames.caption_label,
),
month_grid: cn('w-full border-collapse', defaultClassNames.month_grid),
weekdays: cn('flex', defaultClassNames.weekdays),
weekday: cn(
'flex-1 rounded-(--cell-radius) text-[0.8rem] font-normal text-muted-foreground select-none',
defaultClassNames.weekday,
),
week: cn('mt-2 flex w-full', defaultClassNames.week),
week_number_header: cn(
'w-(--cell-size) select-none',
defaultClassNames.week_number_header,
),
week_number: cn(
'text-[0.8rem] text-muted-foreground select-none',
defaultClassNames.week_number,
),
day: cn(
'group/day relative aspect-square h-full w-full rounded-(--cell-radius) p-0 text-center select-none [&:last-child[data-selected=true]_button]:rounded-r-(--cell-radius)',
props.showWeekNumber
? '[&:nth-child(2)[data-selected=true]_button]:rounded-l-(--cell-radius)'
: '[&:first-child[data-selected=true]_button]:rounded-l-(--cell-radius)',
defaultClassNames.day,
),
range_start: cn(
'relative isolate z-0 rounded-l-(--cell-radius) bg-muted after:absolute after:inset-y-0 after:right-0 after:w-4 after:bg-muted',
defaultClassNames.range_start,
),
range_middle: cn('rounded-none', defaultClassNames.range_middle),
range_end: cn(
'relative isolate z-0 rounded-r-(--cell-radius) bg-muted after:absolute after:inset-y-0 after:left-0 after:w-4 after:bg-muted',
defaultClassNames.range_end,
),
today: cn(
'rounded-(--cell-radius) bg-muted text-foreground data-[selected=true]:rounded-none',
defaultClassNames.today,
),
outside: cn(
'text-muted-foreground aria-selected:text-muted-foreground',
defaultClassNames.outside,
),
disabled: cn(
'text-muted-foreground opacity-50',
defaultClassNames.disabled,
),
hidden: cn('invisible', defaultClassNames.hidden),
...classNames,
}}
components={{
Root: ({ className, rootRef, ...props }) => {
return (
<div
data-slot="calendar"
ref={rootRef}
className={cn(className)}
{...props}
/>
);
},
Chevron: ({ className, orientation, ...props }) => {
if (orientation === 'left') {
return (
<ChevronLeftIcon
className={cn('cn-rtl-flip size-4', className)}
{...props}
/>
);
}
if (orientation === 'right') {
return (
<ChevronRightIcon
className={cn('cn-rtl-flip size-4', className)}
{...props}
/>
);
}
return (
<ChevronDownIcon className={cn('size-4', className)} {...props} />
);
},
DayButton: ({ ...props }) => (
<CalendarDayButton locale={locale} {...props} />
),
WeekNumber: ({ children, ...props }) => {
return (
<td {...props}>
<div className="flex size-(--cell-size) items-center justify-center text-center">
{children}
</div>
</td>
);
},
...components,
}}
{...props}
/>
);
}
function CalendarDayButton({
className,
day,
modifiers,
locale,
...props
}: React.ComponentProps<typeof DayButton> & { locale?: Partial<Locale> }) {
const defaultClassNames = getDefaultClassNames();
const ref = React.useRef<HTMLButtonElement>(null);
React.useEffect(() => {
if (modifiers.focused) ref.current?.focus();
}, [modifiers.focused]);
return (
<Button
variant="ghost"
size="icon"
data-day={day.date.toLocaleDateString(locale?.code)}
data-selected-single={
modifiers.selected &&
!modifiers.range_start &&
!modifiers.range_end &&
!modifiers.range_middle
}
data-range-start={modifiers.range_start}
data-range-end={modifiers.range_end}
data-range-middle={modifiers.range_middle}
className={cn(
'relative isolate z-10 flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 border-0 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-[3px] group-data-[focused=true]/day:ring-ring/50 data-[range-end=true]:rounded-(--cell-radius) data-[range-end=true]:rounded-r-(--cell-radius) data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground data-[range-middle=true]:rounded-none data-[range-middle=true]:bg-muted data-[range-middle=true]:text-foreground data-[range-start=true]:rounded-(--cell-radius) data-[range-start=true]:rounded-l-(--cell-radius) data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground dark:hover:text-foreground [&>span]:text-xs [&>span]:opacity-70',
defaultClassNames.day,
className,
)}
{...props}
/>
);
}
export { Calendar, CalendarDayButton };
+103
View File
@@ -0,0 +1,103 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
function Card({
className,
size = 'default',
...props
}: React.ComponentProps<'div'> & { size?: 'default' | 'sm' }) {
return (
<div
data-slot="card"
data-size={size}
className={cn(
'group/card flex flex-col gap-(--card-spacing) overflow-hidden rounded-xl bg-card py-(--card-spacing) text-sm text-card-foreground ring-1 ring-foreground/10 [--card-spacing:--spacing(4)] has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:[--card-spacing:--spacing(3)] data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl',
className,
)}
{...props}
/>
);
}
function CardHeader({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-header"
className={cn(
'group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-(--card-spacing) has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-(--card-spacing)',
className,
)}
{...props}
/>
);
}
function CardTitle({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-title"
className={cn(
'font-heading text-base leading-snug font-medium group-data-[size=sm]/card:text-sm',
className,
)}
{...props}
/>
);
}
function CardDescription({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-description"
className={cn('text-sm text-muted-foreground', className)}
{...props}
/>
);
}
function CardAction({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-action"
className={cn(
'col-start-2 row-span-2 row-start-1 self-start justify-self-end',
className,
)}
{...props}
/>
);
}
function CardContent({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-content"
className={cn('px-(--card-spacing)', className)}
{...props}
/>
);
}
function CardFooter({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-footer"
className={cn(
'flex items-center rounded-b-xl border-t bg-muted/50 p-(--card-spacing)',
className,
)}
{...props}
/>
);
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
};
+242
View File
@@ -0,0 +1,242 @@
'use client';
import * as React from 'react';
import useEmblaCarousel, {
type UseEmblaCarouselType,
} from 'embla-carousel-react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { ChevronLeftIcon, ChevronRightIcon } from 'lucide-react';
type CarouselApi = UseEmblaCarouselType[1];
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>;
type CarouselOptions = UseCarouselParameters[0];
type CarouselPlugin = UseCarouselParameters[1];
type CarouselProps = {
opts?: CarouselOptions;
plugins?: CarouselPlugin;
orientation?: 'horizontal' | 'vertical';
setApi?: (api: CarouselApi) => void;
};
type CarouselContextProps = {
carouselRef: ReturnType<typeof useEmblaCarousel>[0];
api: ReturnType<typeof useEmblaCarousel>[1];
scrollPrev: () => void;
scrollNext: () => void;
canScrollPrev: boolean;
canScrollNext: boolean;
} & CarouselProps;
const CarouselContext = React.createContext<CarouselContextProps | null>(null);
function useCarousel() {
const context = React.useContext(CarouselContext);
if (!context) {
throw new Error('useCarousel must be used within a <Carousel />');
}
return context;
}
function Carousel({
orientation = 'horizontal',
opts,
setApi,
plugins,
className,
children,
...props
}: React.ComponentProps<'div'> & CarouselProps) {
const [carouselRef, api] = useEmblaCarousel(
{
...opts,
axis: orientation === 'horizontal' ? 'x' : 'y',
},
plugins,
);
const [canScrollPrev, setCanScrollPrev] = React.useState(false);
const [canScrollNext, setCanScrollNext] = React.useState(false);
const onSelect = React.useCallback((api: CarouselApi) => {
if (!api) return;
setCanScrollPrev(api.canScrollPrev());
setCanScrollNext(api.canScrollNext());
}, []);
const scrollPrev = React.useCallback(() => {
api?.scrollPrev();
}, [api]);
const scrollNext = React.useCallback(() => {
api?.scrollNext();
}, [api]);
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === 'ArrowLeft') {
event.preventDefault();
scrollPrev();
} else if (event.key === 'ArrowRight') {
event.preventDefault();
scrollNext();
}
},
[scrollPrev, scrollNext],
);
React.useEffect(() => {
if (!api || !setApi) return;
setApi(api);
}, [api, setApi]);
React.useEffect(() => {
if (!api) return;
onSelect(api);
api.on('reInit', onSelect);
api.on('select', onSelect);
return () => {
api?.off('select', onSelect);
};
}, [api, onSelect]);
return (
<CarouselContext.Provider
value={{
carouselRef,
api: api,
opts,
orientation:
orientation || (opts?.axis === 'y' ? 'vertical' : 'horizontal'),
scrollPrev,
scrollNext,
canScrollPrev,
canScrollNext,
}}
>
<div
onKeyDownCapture={handleKeyDown}
className={cn('relative', className)}
role="region"
aria-roledescription="carousel"
data-slot="carousel"
{...props}
>
{children}
</div>
</CarouselContext.Provider>
);
}
function CarouselContent({ className, ...props }: React.ComponentProps<'div'>) {
const { carouselRef, orientation } = useCarousel();
return (
<div
ref={carouselRef}
className="overflow-hidden"
data-slot="carousel-content"
>
<div
className={cn(
'flex',
orientation === 'horizontal' ? '-ml-4' : '-mt-4 flex-col',
className,
)}
{...props}
/>
</div>
);
}
function CarouselItem({ className, ...props }: React.ComponentProps<'div'>) {
const { orientation } = useCarousel();
return (
<div
role="group"
aria-roledescription="slide"
data-slot="carousel-item"
className={cn(
'min-w-0 shrink-0 grow-0 basis-full',
orientation === 'horizontal' ? 'pl-4' : 'pt-4',
className,
)}
{...props}
/>
);
}
function CarouselPrevious({
className,
variant = 'outline',
size = 'icon-sm',
...props
}: React.ComponentProps<typeof Button>) {
const { orientation, scrollPrev, canScrollPrev } = useCarousel();
return (
<Button
data-slot="carousel-previous"
variant={variant}
size={size}
className={cn(
'rounded-full absolute touch-manipulation',
orientation === 'horizontal'
? 'inset-y-0 -left-12 my-auto'
: '-top-12 left-1/2 -translate-x-1/2 rotate-90',
className,
)}
disabled={!canScrollPrev}
onClick={scrollPrev}
{...props}
>
<ChevronLeftIcon className="cn-rtl-flip" />
<span className="sr-only">Previous slide</span>
</Button>
);
}
function CarouselNext({
className,
variant = 'outline',
size = 'icon-sm',
...props
}: React.ComponentProps<typeof Button>) {
const { orientation, scrollNext, canScrollNext } = useCarousel();
return (
<Button
data-slot="carousel-next"
variant={variant}
size={size}
className={cn(
'rounded-full absolute touch-manipulation',
orientation === 'horizontal'
? 'inset-y-0 -right-12 my-auto'
: '-bottom-12 left-1/2 -translate-x-1/2 rotate-90',
className,
)}
disabled={!canScrollNext}
onClick={scrollNext}
{...props}
>
<ChevronRightIcon className="cn-rtl-flip" />
<span className="sr-only">Next slide</span>
</Button>
);
}
export {
type CarouselApi,
Carousel,
CarouselContent,
CarouselItem,
CarouselPrevious,
CarouselNext,
useCarousel,
};
+373
View File
@@ -0,0 +1,373 @@
'use client';
import * as React from 'react';
import * as RechartsPrimitive from 'recharts';
import type { TooltipValueType } from 'recharts';
import { cn } from '@/lib/utils';
// Format: { THEME_NAME: CSS_SELECTOR }
const THEMES = { light: '', dark: '.dark' } as const;
const INITIAL_DIMENSION = { width: 320, height: 200 } as const;
type TooltipNameType = number | string;
export type ChartConfig = Record<
string,
{
label?: React.ReactNode;
icon?: React.ComponentType;
} & (
| { color?: string; theme?: never }
| { color?: never; theme: Record<keyof typeof THEMES, string> }
)
>;
type ChartContextProps = {
config: ChartConfig;
};
const ChartContext = React.createContext<ChartContextProps | null>(null);
function useChart() {
const context = React.useContext(ChartContext);
if (!context) {
throw new Error('useChart must be used within a <ChartContainer />');
}
return context;
}
function ChartContainer({
id,
className,
children,
config,
initialDimension = INITIAL_DIMENSION,
...props
}: React.ComponentProps<'div'> & {
config: ChartConfig;
children: React.ComponentProps<
typeof RechartsPrimitive.ResponsiveContainer
>['children'];
initialDimension?: {
width: number;
height: number;
};
}) {
const uniqueId = React.useId();
const chartId = `chart-${id ?? uniqueId.replace(/:/g, '')}`;
return (
<ChartContext.Provider value={{ config }}>
<div
data-slot="chart"
data-chart={chartId}
className={cn(
"flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
className,
)}
{...props}
>
<ChartStyle id={chartId} config={config} />
<RechartsPrimitive.ResponsiveContainer
initialDimension={initialDimension}
>
{children}
</RechartsPrimitive.ResponsiveContainer>
</div>
</ChartContext.Provider>
);
}
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
const colorConfig = Object.entries(config).filter(
([, config]) => config.theme ?? config.color,
);
if (!colorConfig.length) {
return null;
}
return (
<style
dangerouslySetInnerHTML={{
__html: Object.entries(THEMES)
.map(
([theme, prefix]) => `
${prefix} [data-chart=${id}] {
${colorConfig
.map(([key, itemConfig]) => {
const color =
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ??
itemConfig.color;
return color ? ` --color-${key}: ${color};` : null;
})
.join('\n')}
}
`,
)
.join('\n'),
}}
/>
);
};
const ChartTooltip = RechartsPrimitive.Tooltip;
function ChartTooltipContent({
active,
payload,
className,
indicator = 'dot',
hideLabel = false,
hideIndicator = false,
label,
labelFormatter,
labelClassName,
formatter,
color,
nameKey,
labelKey,
}: React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
React.ComponentProps<'div'> & {
hideLabel?: boolean;
hideIndicator?: boolean;
indicator?: 'line' | 'dot' | 'dashed';
nameKey?: string;
labelKey?: string;
} & Omit<
RechartsPrimitive.DefaultTooltipContentProps<
TooltipValueType,
TooltipNameType
>,
'accessibilityLayer'
>) {
const { config } = useChart();
const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) {
return null;
}
const [item] = payload;
const key = `${labelKey ?? item?.dataKey ?? item?.name ?? 'value'}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
const value =
!labelKey && typeof label === 'string'
? (config[label]?.label ?? label)
: itemConfig?.label;
if (labelFormatter) {
return (
<div className={cn('font-medium', labelClassName)}>
{labelFormatter(value, payload)}
</div>
);
}
if (!value) {
return null;
}
return <div className={cn('font-medium', labelClassName)}>{value}</div>;
}, [
label,
labelFormatter,
payload,
hideLabel,
labelClassName,
config,
labelKey,
]);
if (!active || !payload?.length) {
return null;
}
const nestLabel = payload.length === 1 && indicator !== 'dot';
return (
<div
className={cn(
'border-border/50 bg-background gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs shadow-xl grid min-w-32 items-start',
className,
)}
>
{!nestLabel ? tooltipLabel : null}
<div className="grid gap-1.5">
{payload
.filter((item) => item.type !== 'none')
.map((item, index) => {
const key = `${nameKey ?? item.name ?? item.dataKey ?? 'value'}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
const indicatorColor = color ?? item.payload?.fill ?? item.color;
return (
<div
key={index}
className={cn(
'flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground',
indicator === 'dot' && 'items-center',
)}
>
{formatter && item?.value !== undefined && item.name ? (
formatter(item.value, item.name, item, index, item.payload)
) : (
<>
{itemConfig?.icon ? (
<itemConfig.icon />
) : (
!hideIndicator && (
<div
className={cn(
'shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)',
{
'h-2.5 w-2.5': indicator === 'dot',
'w-1': indicator === 'line',
'w-0 border-[1.5px] border-dashed bg-transparent':
indicator === 'dashed',
'my-0.5': nestLabel && indicator === 'dashed',
},
)}
style={
{
'--color-bg': indicatorColor,
'--color-border': indicatorColor,
} as React.CSSProperties
}
/>
)
)}
<div
className={cn(
'flex flex-1 justify-between leading-none',
nestLabel ? 'items-end' : 'items-center',
)}
>
<div className="grid gap-1.5">
{nestLabel ? tooltipLabel : null}
<span className="text-muted-foreground">
{itemConfig?.label ?? item.name}
</span>
</div>
{item.value != null && (
<span className="font-mono font-medium text-foreground tabular-nums">
{typeof item.value === 'number'
? item.value.toLocaleString()
: String(item.value)}
</span>
)}
</div>
</>
)}
</div>
);
})}
</div>
</div>
);
}
const ChartLegend = RechartsPrimitive.Legend;
function ChartLegendContent({
className,
hideIcon = false,
payload,
verticalAlign = 'bottom',
nameKey,
}: React.ComponentProps<'div'> & {
hideIcon?: boolean;
nameKey?: string;
} & RechartsPrimitive.DefaultLegendContentProps) {
const { config } = useChart();
if (!payload?.length) {
return null;
}
return (
<div
className={cn(
'flex items-center justify-center gap-4',
verticalAlign === 'top' ? 'pb-3' : 'pt-3',
className,
)}
>
{payload
.filter((item) => item.type !== 'none')
.map((item, index) => {
const key = `${nameKey ?? item.dataKey ?? 'value'}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
return (
<div
key={index}
className={cn(
'flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground',
)}
>
{itemConfig?.icon && !hideIcon ? (
<itemConfig.icon />
) : (
<div
className="h-2 w-2 shrink-0 rounded-[2px]"
style={{
backgroundColor: item.color,
}}
/>
)}
{itemConfig?.label}
</div>
);
})}
</div>
);
}
function getPayloadConfigFromPayload(
config: ChartConfig,
payload: unknown,
key: string,
) {
if (typeof payload !== 'object' || payload === null) {
return undefined;
}
const payloadPayload =
'payload' in payload &&
typeof payload.payload === 'object' &&
payload.payload !== null
? payload.payload
: undefined;
let configLabelKey: string = key;
if (
key in payload &&
typeof payload[key as keyof typeof payload] === 'string'
) {
configLabelKey = payload[key as keyof typeof payload] as string;
} else if (
payloadPayload &&
key in payloadPayload &&
typeof payloadPayload[key as keyof typeof payloadPayload] === 'string'
) {
configLabelKey = payloadPayload[
key as keyof typeof payloadPayload
] as string;
}
return configLabelKey in config ? config[configLabelKey] : config[key];
}
export {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
ChartLegend,
ChartLegendContent,
ChartStyle,
};
+28
View File
@@ -0,0 +1,28 @@
'use client';
import { Checkbox as CheckboxPrimitive } from '@base-ui/react/checkbox';
import { cn } from '@/lib/utils';
import { CheckIcon } from 'lucide-react';
function Checkbox({ className, ...props }: CheckboxPrimitive.Root.Props) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
'peer relative flex size-4 shrink-0 items-center justify-center rounded-[4px] border border-input transition-colors outline-none group-has-disabled/field:opacity-50 group-has-[:focus-visible]/field-label:ring-0 group-has-[:focus-visible]/field-label:not-data-checked:border-input after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:aria-checked:border-primary dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground group-has-[:focus-visible]/field-label:data-checked:border-primary dark:data-checked:bg-primary',
className,
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="grid place-content-center text-current transition-none [&>svg]:size-3.5"
>
<CheckIcon />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
);
}
export { Checkbox };
+21
View File
@@ -0,0 +1,21 @@
'use client';
import { Collapsible as CollapsiblePrimitive } from '@base-ui/react/collapsible';
function Collapsible({ ...props }: CollapsiblePrimitive.Root.Props) {
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />;
}
function CollapsibleTrigger({ ...props }: CollapsiblePrimitive.Trigger.Props) {
return (
<CollapsiblePrimitive.Trigger data-slot="collapsible-trigger" {...props} />
);
}
function CollapsibleContent({ ...props }: CollapsiblePrimitive.Panel.Props) {
return (
<CollapsiblePrimitive.Panel data-slot="collapsible-content" {...props} />
);
}
export { Collapsible, CollapsibleTrigger, CollapsibleContent };
+300
View File
@@ -0,0 +1,300 @@
'use client';
import * as React from 'react';
import { Combobox as ComboboxPrimitive } from '@base-ui/react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput,
} from '@/components/ui/input-group';
import { ChevronDownIcon, XIcon, CheckIcon } from 'lucide-react';
const Combobox = ComboboxPrimitive.Root;
function ComboboxValue({ ...props }: ComboboxPrimitive.Value.Props) {
return <ComboboxPrimitive.Value data-slot="combobox-value" {...props} />;
}
function ComboboxTrigger({
className,
children,
...props
}: ComboboxPrimitive.Trigger.Props) {
return (
<ComboboxPrimitive.Trigger
data-slot="combobox-trigger"
className={cn("[&_svg:not([class*='size-'])]:size-4", className)}
{...props}
>
{children}
<ChevronDownIcon className="text-muted-foreground size-4 pointer-events-none" />
</ComboboxPrimitive.Trigger>
);
}
function ComboboxClear({ className, ...props }: ComboboxPrimitive.Clear.Props) {
return (
<ComboboxPrimitive.Clear
data-slot="combobox-clear"
render={<InputGroupButton variant="ghost" size="icon-xs" />}
className={cn(className)}
{...props}
>
<XIcon className="pointer-events-none" />
</ComboboxPrimitive.Clear>
);
}
function ComboboxInput({
className,
children,
disabled = false,
showTrigger = true,
showClear = false,
...props
}: ComboboxPrimitive.Input.Props & {
showTrigger?: boolean;
showClear?: boolean;
}) {
return (
<InputGroup className={cn('w-auto', className)}>
<ComboboxPrimitive.Input
render={<InputGroupInput disabled={disabled} />}
{...props}
/>
<InputGroupAddon align="inline-end">
{showTrigger && (
<InputGroupButton
size="icon-xs"
variant="ghost"
render={<ComboboxTrigger />}
data-slot="input-group-button"
className="group-has-data-[slot=combobox-clear]/input-group:hidden data-pressed:bg-transparent"
disabled={disabled}
/>
)}
{showClear && <ComboboxClear disabled={disabled} />}
</InputGroupAddon>
{children}
</InputGroup>
);
}
function ComboboxContent({
className,
side = 'bottom',
sideOffset = 6,
align = 'start',
alignOffset = 0,
anchor,
...props
}: ComboboxPrimitive.Popup.Props &
Pick<
ComboboxPrimitive.Positioner.Props,
'side' | 'align' | 'sideOffset' | 'alignOffset' | 'anchor'
>) {
return (
<ComboboxPrimitive.Portal>
<ComboboxPrimitive.Positioner
side={side}
sideOffset={sideOffset}
align={align}
alignOffset={alignOffset}
anchor={anchor}
className="isolate z-50"
>
<ComboboxPrimitive.Popup
data-slot="combobox-content"
data-chips={!!anchor}
className={cn(
'bg-popover text-popover-foreground data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 *:data-[slot=input-group]:bg-input/30 *:data-[slot=input-group]:border-input/30 overflow-hidden rounded-lg shadow-md ring-1 duration-100 *:data-[slot=input-group]:m-1 *:data-[slot=input-group]:mb-0 *:data-[slot=input-group]:h-8 *:data-[slot=input-group]:shadow-none data-[side=inline-start]:slide-in-from-right-2 data-[side=inline-end]:slide-in-from-left-2 cn-menu-target cn-menu-translucent group/combobox-content relative max-h-(--available-height) w-(--anchor-width) max-w-(--available-width) min-w-[calc(var(--anchor-width)+--spacing(7))] origin-(--transform-origin) data-[chips=true]:min-w-(--anchor-width)',
className,
)}
{...props}
/>
</ComboboxPrimitive.Positioner>
</ComboboxPrimitive.Portal>
);
}
function ComboboxList({ className, ...props }: ComboboxPrimitive.List.Props) {
return (
<ComboboxPrimitive.List
data-slot="combobox-list"
className={cn(
'no-scrollbar max-h-[min(calc(--spacing(72)---spacing(9)),calc(var(--available-height)---spacing(9)))] scroll-py-1 p-1 data-empty:p-0 overflow-y-auto overscroll-contain',
className,
)}
{...props}
/>
);
}
function ComboboxItem({
className,
children,
...props
}: ComboboxPrimitive.Item.Props) {
return (
<ComboboxPrimitive.Item
data-slot="combobox-item"
className={cn(
"data-highlighted:bg-accent data-highlighted:text-accent-foreground not-data-[variant=destructive]:data-highlighted:**:text-accent-foreground gap-2 rounded-md py-1 pr-8 pl-1.5 text-sm [&_svg:not([class*='size-'])]:size-4 relative flex w-full cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className,
)}
{...props}
>
{children}
<ComboboxPrimitive.ItemIndicator
render={
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center" />
}
>
<CheckIcon className="pointer-events-none" />
</ComboboxPrimitive.ItemIndicator>
</ComboboxPrimitive.Item>
);
}
function ComboboxGroup({ className, ...props }: ComboboxPrimitive.Group.Props) {
return (
<ComboboxPrimitive.Group
data-slot="combobox-group"
className={cn(className)}
{...props}
/>
);
}
function ComboboxLabel({
className,
...props
}: ComboboxPrimitive.GroupLabel.Props) {
return (
<ComboboxPrimitive.GroupLabel
data-slot="combobox-label"
className={cn('text-muted-foreground px-2 py-1.5 text-xs', className)}
{...props}
/>
);
}
function ComboboxCollection({ ...props }: ComboboxPrimitive.Collection.Props) {
return (
<ComboboxPrimitive.Collection data-slot="combobox-collection" {...props} />
);
}
function ComboboxEmpty({ className, ...props }: ComboboxPrimitive.Empty.Props) {
return (
<ComboboxPrimitive.Empty
data-slot="combobox-empty"
className={cn(
'text-muted-foreground hidden w-full justify-center py-2 text-center text-sm group-data-empty/combobox-content:flex',
className,
)}
{...props}
/>
);
}
function ComboboxSeparator({
className,
...props
}: ComboboxPrimitive.Separator.Props) {
return (
<ComboboxPrimitive.Separator
data-slot="combobox-separator"
className={cn('bg-border -mx-1 my-1 h-px', className)}
{...props}
/>
);
}
function ComboboxChips({
className,
...props
}: React.ComponentPropsWithRef<typeof ComboboxPrimitive.Chips> &
ComboboxPrimitive.Chips.Props) {
return (
<ComboboxPrimitive.Chips
data-slot="combobox-chips"
className={cn(
'dark:bg-input/30 border-input focus-within:border-ring focus-within:ring-ring/50 has-aria-invalid:ring-destructive/20 dark:has-aria-invalid:ring-destructive/40 has-aria-invalid:border-destructive dark:has-aria-invalid:border-destructive/50 flex min-h-8 flex-wrap items-center gap-1 rounded-lg border bg-transparent bg-clip-padding px-2.5 py-1 text-sm transition-colors focus-within:ring-3 has-aria-invalid:ring-3 has-data-[slot=combobox-chip]:px-1',
className,
)}
{...props}
/>
);
}
function ComboboxChip({
className,
children,
showRemove = true,
...props
}: ComboboxPrimitive.Chip.Props & {
showRemove?: boolean;
}) {
return (
<ComboboxPrimitive.Chip
data-slot="combobox-chip"
className={cn(
'bg-muted text-foreground flex h-[calc(--spacing(5.25))] w-fit items-center justify-center gap-1 rounded-sm px-1.5 text-xs font-medium whitespace-nowrap has-data-[slot=combobox-chip-remove]:pr-0 has-disabled:pointer-events-none has-disabled:cursor-not-allowed has-disabled:opacity-50',
className,
)}
{...props}
>
{children}
{showRemove && (
<ComboboxPrimitive.ChipRemove
render={<Button variant="ghost" size="icon-xs" />}
className="-ml-1 opacity-50 hover:opacity-100"
data-slot="combobox-chip-remove"
>
<XIcon className="pointer-events-none" />
</ComboboxPrimitive.ChipRemove>
)}
</ComboboxPrimitive.Chip>
);
}
function ComboboxChipsInput({
className,
...props
}: ComboboxPrimitive.Input.Props) {
return (
<ComboboxPrimitive.Input
data-slot="combobox-chip-input"
className={cn('min-w-16 flex-1 outline-none', className)}
{...props}
/>
);
}
function useComboboxAnchor() {
return React.useRef<HTMLDivElement | null>(null);
}
export {
Combobox,
ComboboxInput,
ComboboxContent,
ComboboxList,
ComboboxItem,
ComboboxGroup,
ComboboxLabel,
ComboboxCollection,
ComboboxEmpty,
ComboboxSeparator,
ComboboxChips,
ComboboxChip,
ComboboxChipsInput,
ComboboxTrigger,
ComboboxValue,
useComboboxAnchor,
};
+193
View File
@@ -0,0 +1,193 @@
'use client';
import * as React from 'react';
import { Command as CommandPrimitive } from 'cmdk';
import { cn } from '@/lib/utils';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { InputGroup, InputGroupAddon } from '@/components/ui/input-group';
import { SearchIcon, CheckIcon } from 'lucide-react';
function Command({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive>) {
return (
<CommandPrimitive
data-slot="command"
className={cn(
'bg-popover text-popover-foreground rounded-xl! p-1 flex size-full flex-col overflow-hidden',
className,
)}
{...props}
/>
);
}
function CommandDialog({
title = 'Command Palette',
description = 'Search for a command to run...',
children,
className,
showCloseButton = false,
...props
}: Omit<React.ComponentProps<typeof Dialog>, 'children'> & {
title?: string;
description?: string;
className?: string;
showCloseButton?: boolean;
children: React.ReactNode;
}) {
return (
<Dialog {...props}>
<DialogHeader className="sr-only">
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogContent
className={cn(
'rounded-xl! top-1/3 translate-y-0 overflow-hidden p-0',
className,
)}
showCloseButton={showCloseButton}
>
{children}
</DialogContent>
</Dialog>
);
}
function CommandInput({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
return (
<div data-slot="command-input-wrapper" className="p-1 pb-0">
<InputGroup className="bg-input/30 border-input/30 h-8! rounded-lg! shadow-none! *:data-[slot=input-group-addon]:pl-2!">
<CommandPrimitive.Input
data-slot="command-input"
className={cn(
'w-full text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
{...props}
/>
<InputGroupAddon>
<SearchIcon className="size-4 shrink-0 opacity-50" />
</InputGroupAddon>
</InputGroup>
</div>
);
}
function CommandList({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.List>) {
return (
<CommandPrimitive.List
data-slot="command-list"
className={cn(
'no-scrollbar max-h-72 scroll-py-1 outline-none overflow-x-hidden overflow-y-auto',
className,
)}
{...props}
/>
);
}
function CommandEmpty({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
return (
<CommandPrimitive.Empty
data-slot="command-empty"
className={cn('py-6 text-center text-sm', className)}
{...props}
/>
);
}
function CommandGroup({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
return (
<CommandPrimitive.Group
data-slot="command-group"
className={cn(
'text-foreground **:[[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 **:[[cmdk-group-heading]]:px-2 **:[[cmdk-group-heading]]:py-1.5 **:[[cmdk-group-heading]]:text-xs **:[[cmdk-group-heading]]:font-medium',
className,
)}
{...props}
/>
);
}
function CommandSeparator({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
return (
<CommandPrimitive.Separator
data-slot="command-separator"
className={cn('bg-border -mx-1 h-px', className)}
{...props}
/>
);
}
function CommandItem({
className,
children,
...props
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
return (
<CommandPrimitive.Item
data-slot="command-item"
className={cn(
"data-selected:bg-muted data-selected:text-foreground data-selected:*:[svg]:text-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none in-data-[slot=dialog-content]:rounded-lg! [&_svg:not([class*='size-'])]:size-4 group/command-item data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className,
)}
{...props}
>
{children}
<CheckIcon className="ml-auto opacity-0 group-has-data-[slot=command-shortcut]/command-item:hidden group-data-[checked=true]/command-item:opacity-100" />
</CommandPrimitive.Item>
);
}
function CommandShortcut({
className,
...props
}: React.ComponentProps<'span'>) {
return (
<span
data-slot="command-shortcut"
className={cn(
'text-muted-foreground group-data-selected/command-item:text-foreground ml-auto text-xs tracking-widest',
className,
)}
{...props}
/>
);
}
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
};
+272
View File
@@ -0,0 +1,272 @@
'use client';
import * as React from 'react';
import { ContextMenu as ContextMenuPrimitive } from '@base-ui/react/context-menu';
import { cn } from '@/lib/utils';
import { ChevronRightIcon, CheckIcon } from 'lucide-react';
function ContextMenu({ ...props }: ContextMenuPrimitive.Root.Props) {
return <ContextMenuPrimitive.Root data-slot="context-menu" {...props} />;
}
function ContextMenuPortal({ ...props }: ContextMenuPrimitive.Portal.Props) {
return (
<ContextMenuPrimitive.Portal data-slot="context-menu-portal" {...props} />
);
}
function ContextMenuTrigger({
className,
...props
}: ContextMenuPrimitive.Trigger.Props) {
return (
<ContextMenuPrimitive.Trigger
data-slot="context-menu-trigger"
className={cn('select-none', className)}
{...props}
/>
);
}
function ContextMenuContent({
className,
align = 'start',
alignOffset = 4,
side = 'right',
sideOffset = 0,
...props
}: ContextMenuPrimitive.Popup.Props &
Pick<
ContextMenuPrimitive.Positioner.Props,
'align' | 'alignOffset' | 'side' | 'sideOffset'
>) {
return (
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.Positioner
className="isolate z-50 outline-none"
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
>
<ContextMenuPrimitive.Popup
data-slot="context-menu-content"
className={cn(
'data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 bg-popover text-popover-foreground min-w-36 rounded-lg p-1 shadow-md ring-1 duration-100 data-[side=inline-start]:slide-in-from-right-2 data-[side=inline-end]:slide-in-from-left-2 cn-menu-target cn-menu-translucent z-50 max-h-(--available-height) origin-(--transform-origin) overflow-x-hidden overflow-y-auto outline-none',
className,
)}
{...props}
/>
</ContextMenuPrimitive.Positioner>
</ContextMenuPrimitive.Portal>
);
}
function ContextMenuGroup({ ...props }: ContextMenuPrimitive.Group.Props) {
return (
<ContextMenuPrimitive.Group data-slot="context-menu-group" {...props} />
);
}
function ContextMenuLabel({
className,
inset,
...props
}: ContextMenuPrimitive.GroupLabel.Props & {
inset?: boolean;
}) {
return (
<ContextMenuPrimitive.GroupLabel
data-slot="context-menu-label"
data-inset={inset}
className={cn(
'text-muted-foreground px-1.5 py-1 text-xs font-medium data-inset:pl-7',
className,
)}
{...props}
/>
);
}
function ContextMenuItem({
className,
inset,
variant = 'default',
...props
}: ContextMenuPrimitive.Item.Props & {
inset?: boolean;
variant?: 'default' | 'destructive';
}) {
return (
<ContextMenuPrimitive.Item
data-slot="context-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:text-destructive focus:*:[svg]:text-accent-foreground gap-1.5 rounded-md px-1.5 py-1 text-sm data-inset:pl-7 [&_svg:not([class*='size-'])]:size-4 group/context-menu-item relative flex cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className,
)}
{...props}
/>
);
}
function ContextMenuSub({ ...props }: ContextMenuPrimitive.SubmenuRoot.Props) {
return (
<ContextMenuPrimitive.SubmenuRoot data-slot="context-menu-sub" {...props} />
);
}
function ContextMenuSubTrigger({
className,
inset,
children,
...props
}: ContextMenuPrimitive.SubmenuTrigger.Props & {
inset?: boolean;
}) {
return (
<ContextMenuPrimitive.SubmenuTrigger
data-slot="context-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground gap-1.5 rounded-md px-1.5 py-1 text-sm data-inset:pl-7 [&_svg:not([class*='size-'])]:size-4 flex cursor-default items-center outline-hidden select-none [&_svg]:pointer-events-none [&_svg]:shrink-0",
className,
)}
{...props}
>
{children}
<ChevronRightIcon className="cn-rtl-flip ml-auto" />
</ContextMenuPrimitive.SubmenuTrigger>
);
}
function ContextMenuSubContent({
...props
}: React.ComponentProps<typeof ContextMenuContent>) {
return (
<ContextMenuContent
data-slot="context-menu-sub-content"
className="shadow-lg cn-menu-target cn-menu-translucent"
side="right"
{...props}
/>
);
}
function ContextMenuCheckboxItem({
className,
children,
checked,
inset,
...props
}: ContextMenuPrimitive.CheckboxItem.Props & {
inset?: boolean;
}) {
return (
<ContextMenuPrimitive.CheckboxItem
data-slot="context-menu-checkbox-item"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm data-inset:pl-7 [&_svg:not([class*='size-'])]:size-4 relative flex cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className,
)}
checked={checked}
{...props}
>
<span className="absolute right-2 pointer-events-none">
<ContextMenuPrimitive.CheckboxItemIndicator>
<CheckIcon />
</ContextMenuPrimitive.CheckboxItemIndicator>
</span>
{children}
</ContextMenuPrimitive.CheckboxItem>
);
}
function ContextMenuRadioGroup({
...props
}: ContextMenuPrimitive.RadioGroup.Props) {
return (
<ContextMenuPrimitive.RadioGroup
data-slot="context-menu-radio-group"
{...props}
/>
);
}
function ContextMenuRadioItem({
className,
children,
inset,
...props
}: ContextMenuPrimitive.RadioItem.Props & {
inset?: boolean;
}) {
return (
<ContextMenuPrimitive.RadioItem
data-slot="context-menu-radio-item"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm data-inset:pl-7 [&_svg:not([class*='size-'])]:size-4 relative flex cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className,
)}
{...props}
>
<span className="absolute right-2 pointer-events-none">
<ContextMenuPrimitive.RadioItemIndicator>
<CheckIcon />
</ContextMenuPrimitive.RadioItemIndicator>
</span>
{children}
</ContextMenuPrimitive.RadioItem>
);
}
function ContextMenuSeparator({
className,
...props
}: ContextMenuPrimitive.Separator.Props) {
return (
<ContextMenuPrimitive.Separator
data-slot="context-menu-separator"
className={cn('bg-border -mx-1 my-1 h-px', className)}
{...props}
/>
);
}
function ContextMenuShortcut({
className,
...props
}: React.ComponentProps<'span'>) {
return (
<span
data-slot="context-menu-shortcut"
className={cn(
'text-muted-foreground group-focus/context-menu-item:text-accent-foreground ml-auto text-xs tracking-widest',
className,
)}
{...props}
/>
);
}
export {
ContextMenu,
ContextMenuTrigger,
ContextMenuContent,
ContextMenuItem,
ContextMenuCheckboxItem,
ContextMenuRadioItem,
ContextMenuLabel,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuGroup,
ContextMenuPortal,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuRadioGroup,
};
+159
View File
@@ -0,0 +1,159 @@
'use client';
import * as React from 'react';
import { Dialog as DialogPrimitive } from '@base-ui/react/dialog';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { XIcon } from 'lucide-react';
function Dialog({ ...props }: DialogPrimitive.Root.Props) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
}
function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
}
function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
}
function DialogClose({ ...props }: DialogPrimitive.Close.Props) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
}
function DialogOverlay({
className,
...props
}: DialogPrimitive.Backdrop.Props) {
return (
<DialogPrimitive.Backdrop
data-slot="dialog-overlay"
className={cn(
'fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0',
className,
)}
{...props}
/>
);
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: DialogPrimitive.Popup.Props & {
showCloseButton?: boolean;
}) {
return (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Popup
data-slot="dialog-content"
className={cn(
'fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95',
className,
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
render={
<Button
variant="ghost"
className="absolute top-2 right-2"
size="icon-sm"
/>
}
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Popup>
</DialogPortal>
);
}
function DialogHeader({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="dialog-header"
className={cn('flex flex-col gap-2', className)}
{...props}
/>
);
}
function DialogFooter({
className,
showCloseButton = false,
children,
...props
}: React.ComponentProps<'div'> & {
showCloseButton?: boolean;
}) {
return (
<div
data-slot="dialog-footer"
className={cn(
'-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end',
className,
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close render={<Button variant="outline" />}>
Close
</DialogPrimitive.Close>
)}
</div>
);
}
function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn(
'font-heading text-base leading-none font-medium',
className,
)}
{...props}
/>
);
}
function DialogDescription({
className,
...props
}: DialogPrimitive.Description.Props) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn(
'text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground',
className,
)}
{...props}
/>
);
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
};
+6
View File
@@ -0,0 +1,6 @@
'use client';
export {
DirectionProvider,
useDirection,
} from '@base-ui/react/direction-provider';
+228
View File
@@ -0,0 +1,228 @@
'use client';
import * as React from 'react';
import { Drawer as DrawerPrimitive } from '@base-ui/react/drawer';
import { cn } from '@/lib/utils';
type DrawerContextProps = {
hasSnapPoints: boolean;
modal: DrawerPrimitive.Root.Props['modal'];
showSwipeHandle: boolean;
swipeDirection: NonNullable<DrawerPrimitive.Root.Props['swipeDirection']>;
};
const DrawerContext = React.createContext<DrawerContextProps | null>(null);
function useDrawer() {
const context = React.useContext(DrawerContext);
if (!context) {
throw new Error('useDrawer must be used within a Drawer.');
}
return context;
}
function Drawer({
modal = true,
showSwipeHandle = false,
snapPoints,
swipeDirection = 'down',
...props
}: DrawerPrimitive.Root.Props & {
showSwipeHandle?: boolean;
}) {
const hasSnapPoints = snapPoints != null && snapPoints.length > 0;
const contextValue = React.useMemo(
() => ({ hasSnapPoints, modal, showSwipeHandle, swipeDirection }),
[hasSnapPoints, modal, showSwipeHandle, swipeDirection],
);
return (
<DrawerContext.Provider value={contextValue}>
<DrawerPrimitive.Root
data-slot="drawer"
modal={modal}
snapPoints={snapPoints}
swipeDirection={swipeDirection}
{...props}
/>
</DrawerContext.Provider>
);
}
function DrawerTrigger({ ...props }: DrawerPrimitive.Trigger.Props) {
return <DrawerPrimitive.Trigger data-slot="drawer-trigger" {...props} />;
}
function DrawerPortal({ ...props }: DrawerPrimitive.Portal.Props) {
return <DrawerPrimitive.Portal data-slot="drawer-portal" {...props} />;
}
function DrawerClose({ ...props }: DrawerPrimitive.Close.Props) {
return <DrawerPrimitive.Close data-slot="drawer-close" {...props} />;
}
function DrawerOverlay({
className,
...props
}: DrawerPrimitive.Backdrop.Props) {
return (
<DrawerPrimitive.Backdrop
data-slot="drawer-overlay"
className={cn(
'bg-black/10 supports-backdrop-filter:backdrop-blur-xs fixed inset-0 z-50 min-h-dvh opacity-[max(var(--drawer-overlay-min-opacity,0),calc(1-var(--drawer-swipe-progress)))] transition-opacity duration-450 ease-[cubic-bezier(0.32,0.72,0,1)] select-none data-ending-style:pointer-events-none data-ending-style:opacity-0 data-ending-style:duration-[calc(var(--drawer-swipe-strength)*400ms)] data-snap-points:[--drawer-overlay-min-opacity:0.5] data-starting-style:opacity-0 data-swiping:duration-0 supports-[-webkit-touch-callout:none]:absolute',
className,
)}
{...props}
/>
);
}
function DrawerSwipeHandle({
className,
...props
}: React.ComponentProps<'div'>) {
return (
<div
data-slot="drawer-swipe-handle"
aria-hidden="true"
className={cn(
'after:block after:shrink-0 after:rounded-full after:bg-muted group-data-[swipe-axis=y]/drawer-popup:h-3 group-data-[swipe-axis=y]/drawer-popup:w-full group-data-[swipe-axis=y]/drawer-popup:justify-center group-data-[swipe-axis=y]/drawer-popup:after:h-1 group-data-[swipe-axis=y]/drawer-popup:after:w-24 group-data-[swipe-direction=down]/drawer-popup:items-end group-data-[swipe-direction=up]/drawer-popup:items-start group-data-[swipe-axis=x]/drawer-popup:h-full group-data-[swipe-axis=x]/drawer-popup:w-3 group-data-[swipe-axis=x]/drawer-popup:items-center group-data-[swipe-axis=x]/drawer-popup:after:h-24 group-data-[swipe-axis=x]/drawer-popup:after:w-1 group-data-[swipe-direction=left]/drawer-popup:justify-start group-data-[swipe-direction=right]/drawer-popup:justify-end relative z-10 flex shrink-0 cursor-grab transition-opacity duration-200 group-data-nested-drawer-open/drawer-popup:opacity-0 group-data-nested-drawer-swiping/drawer-popup:opacity-100 group-data-[swipe-direction=left]/drawer-popup:order-last group-data-[swipe-direction=up]/drawer-popup:order-last active:cursor-grabbing',
className,
)}
{...props}
/>
);
}
function DrawerContent({
className,
children,
...props
}: DrawerPrimitive.Popup.Props) {
const { hasSnapPoints, modal, showSwipeHandle, swipeDirection } = useDrawer();
const swipeAxis =
swipeDirection === 'down' || swipeDirection === 'up' ? 'y' : 'x';
return (
<DrawerPortal data-slot="drawer-portal">
{modal === true && (
<DrawerOverlay data-snap-points={hasSnapPoints ? '' : undefined} />
)}
<DrawerPrimitive.Viewport
data-slot="drawer-viewport"
data-modal={modal}
className="pointer-events-none fixed inset-0 z-50 select-none data-[modal=true]:pointer-events-auto"
>
<DrawerPrimitive.Popup
data-slot="drawer-popup"
data-swipe-axis={swipeAxis}
data-snap-points={hasSnapPoints ? '' : undefined}
className={cn(
// Base.
'bg-popover text-popover-foreground text-sm data-[swipe-direction=down]:rounded-t-xl data-[swipe-direction=down]:border-t data-[swipe-direction=left]:rounded-r-xl data-[swipe-direction=left]:border-r data-[swipe-direction=right]:rounded-l-xl data-[swipe-direction=right]:border-l data-[swipe-direction=up]:rounded-b-xl data-[swipe-direction=up]:border-b group/drawer-popup pointer-events-auto fixed z-50 m-(--drawer-inset,0px) flex h-(--drawer-content-height) max-h-(--drawer-content-max-height,none) min-h-0 w-(--drawer-content-width,auto) transform-[translate3d(var(--translate-x,0px),var(--translate-y,0px),0)_scale(var(--stack-scale))] flex-col transition-[transform,height,opacity,filter] duration-450 ease-[cubic-bezier(0.22,1,0.36,1)] will-change-transform outline-none select-none [interpolate-size:allow-keywords]',
// Nested.
'data-nested-drawer-open:overflow-hidden data-nested-drawer-open:brightness-95',
// Bleed.
'after:pointer-events-none after:absolute after:bg-(--drawer-bleed-background,var(--color-popover)) data-[swipe-axis=x]:after:inset-y-0 data-[swipe-axis=x]:after:w-(--bleed) data-[swipe-axis=y]:after:inset-x-0 data-[swipe-axis=y]:after:h-(--bleed) data-[swipe-direction=down]:after:top-full data-[swipe-direction=left]:after:right-full data-[swipe-direction=right]:after:left-full data-[swipe-direction=up]:after:bottom-full',
// Sizing.
'[--drawer-content-height:var(--drawer-height,auto)] data-[swipe-axis=x]:[--drawer-content-width:75%] data-[swipe-axis=y]:[--drawer-content-max-height:calc(100dvh-6rem)] data-[swipe-axis=y]:data-snap-points:[--drawer-content-height:100dvh] data-[swipe-axis=x]:sm:[--drawer-content-width:24rem]',
// Stack.
'[--bleed:3rem] [--peek:1rem] [--stack-height:var(--drawer-frontmost-height,var(--drawer-height,0px))] [--stack-peek-offset:max(0px,calc((var(--nested-drawers)-var(--stack-progress))*var(--peek)))] [--stack-progress:clamp(0,var(--drawer-swipe-progress),1)] [--stack-scale-base:max(0,calc(1-(var(--nested-drawers)*var(--stack-step))))] [--stack-scale:clamp(0,calc(var(--stack-scale-base)+(var(--stack-step)*var(--stack-progress))),1)] [--stack-shrink:calc(1-var(--stack-scale))] [--stack-step:0.05]',
// Transitions.
'data-ending-style:transform-(--closed-transform) data-ending-style:opacity-[0.9999] data-ending-style:duration-[calc(var(--drawer-swipe-strength)*400ms)] data-nested-drawer-swiping:duration-0 data-ending-style:data-nested-drawer-swiping:duration-[calc(var(--drawer-swipe-strength)*400ms)] data-starting-style:transform-(--closed-transform) data-swiping:duration-0 data-ending-style:data-swiping:duration-[calc(var(--drawer-swipe-strength)*400ms)]',
// Axis: y.
'data-[swipe-axis=y]:inset-x-0 data-[swipe-axis=y]:data-nested-drawer-open:h-(--stack-height)',
// Axis: x.
'data-[swipe-axis=x]:inset-y-0 data-[swipe-axis=x]:flex-row',
// Direction: down.
'data-[swipe-direction=down]:bottom-0 data-[swipe-direction=down]:origin-bottom data-[swipe-direction=down]:[--closed-transform:translate3d(0,calc(100%+var(--drawer-inset,0px)+2px),0)] data-[swipe-direction=down]:[--translate-y:calc(var(--drawer-snap-point-offset,0px)+var(--drawer-swipe-movement-y)-var(--stack-peek-offset)-(var(--stack-shrink)*var(--stack-height)))]',
// Direction: up.
'data-[swipe-direction=up]:top-0 data-[swipe-direction=up]:origin-top data-[swipe-direction=up]:[--closed-transform:translate3d(0,calc(-100%-var(--drawer-inset,0px)-2px),0)] data-[swipe-direction=up]:[--translate-y:calc(var(--drawer-snap-point-offset,0px)+var(--drawer-swipe-movement-y)+var(--stack-peek-offset)+(var(--stack-shrink)*var(--stack-height)))]',
// Direction: left.
'data-[swipe-direction=left]:left-0 data-[swipe-direction=left]:origin-left data-[swipe-direction=left]:[--closed-transform:translate3d(calc(-100%-var(--drawer-inset,0px)-2px),0,0)] data-[swipe-direction=left]:[--translate-x:calc(var(--drawer-swipe-movement-x)+var(--stack-peek-offset)+(var(--stack-shrink)*100%))]',
// Direction: right.
'data-[swipe-direction=right]:right-0 data-[swipe-direction=right]:origin-right data-[swipe-direction=right]:[--closed-transform:translate3d(calc(100%+var(--drawer-inset,0px)+2px),0,0)] data-[swipe-direction=right]:[--translate-x:calc(var(--drawer-swipe-movement-x)-var(--stack-peek-offset)-(var(--stack-shrink)*100%))]',
className,
)}
{...props}
>
{showSwipeHandle && <DrawerSwipeHandle />}
<DrawerPrimitive.Content
data-slot="drawer-content"
className={cn(
'flex min-h-0 flex-1 flex-col overflow-hidden overscroll-contain rounded-[inherit] transition-opacity duration-300 ease-[cubic-bezier(0.45,1.005,0,1.005)] select-text group-data-nested-drawer-open/drawer-popup:opacity-0 group-data-nested-drawer-swiping/drawer-popup:opacity-100 group-data-swiping/drawer-popup:select-none',
)}
>
{children}
</DrawerPrimitive.Content>
</DrawerPrimitive.Popup>
</DrawerPrimitive.Viewport>
</DrawerPortal>
);
}
function DrawerHeader({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="drawer-header"
className={cn(
'gap-0.5 p-4 pb-0 md:gap-0.5 md:text-left flex shrink-0 flex-col group-data-[swipe-axis=y]/drawer-popup:text-center',
className,
)}
{...props}
/>
);
}
function DrawerFooter({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="drawer-footer"
className={cn('gap-2 p-4 pt-0 mt-auto flex shrink-0 flex-col', className)}
{...props}
/>
);
}
function DrawerTitle({ className, ...props }: DrawerPrimitive.Title.Props) {
return (
<DrawerPrimitive.Title
data-slot="drawer-title"
className={cn(
'text-foreground text-base font-medium cn-font-heading',
className,
)}
{...props}
/>
);
}
function DrawerDescription({
className,
...props
}: DrawerPrimitive.Description.Props) {
return (
<DrawerPrimitive.Description
data-slot="drawer-description"
className={cn('text-muted-foreground text-sm text-balance', className)}
{...props}
/>
);
}
export {
Drawer,
DrawerPortal,
DrawerOverlay,
DrawerSwipeHandle,
DrawerTrigger,
DrawerClose,
DrawerContent,
DrawerHeader,
DrawerFooter,
DrawerTitle,
DrawerDescription,
};
+272
View File
@@ -0,0 +1,272 @@
'use client';
import * as React from 'react';
import { Menu as MenuPrimitive } from '@base-ui/react/menu';
import { cn } from '@/lib/utils';
import { ChevronRightIcon, CheckIcon } from 'lucide-react';
function DropdownMenu({ ...props }: MenuPrimitive.Root.Props) {
return <MenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
}
function DropdownMenuPortal({ ...props }: MenuPrimitive.Portal.Props) {
return <MenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />;
}
function DropdownMenuTrigger({ ...props }: MenuPrimitive.Trigger.Props) {
return <MenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />;
}
function DropdownMenuContent({
align = 'start',
alignOffset = 0,
side = 'bottom',
sideOffset = 4,
className,
...props
}: MenuPrimitive.Popup.Props &
Pick<
MenuPrimitive.Positioner.Props,
'align' | 'alignOffset' | 'side' | 'sideOffset'
>) {
return (
<MenuPrimitive.Portal>
<MenuPrimitive.Positioner
className="isolate z-50 outline-none"
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
>
<MenuPrimitive.Popup
data-slot="dropdown-menu-content"
className={cn(
'z-50 max-h-(--available-height) w-(--anchor-width) min-w-32 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:overflow-hidden data-closed:fade-out-0 data-closed:zoom-out-95',
className,
)}
{...props}
/>
</MenuPrimitive.Positioner>
</MenuPrimitive.Portal>
);
}
function DropdownMenuGroup({ ...props }: MenuPrimitive.Group.Props) {
return <MenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />;
}
function DropdownMenuLabel({
className,
inset,
...props
}: MenuPrimitive.GroupLabel.Props & {
inset?: boolean;
}) {
return (
<MenuPrimitive.GroupLabel
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
'px-1.5 py-1 text-xs font-medium text-muted-foreground data-inset:pl-7',
className,
)}
{...props}
/>
);
}
function DropdownMenuItem({
className,
inset,
variant = 'default',
...props
}: MenuPrimitive.Item.Props & {
inset?: boolean;
variant?: 'default' | 'destructive';
}) {
return (
<MenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"group/dropdown-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive",
className,
)}
{...props}
/>
);
}
function DropdownMenuSub({ ...props }: MenuPrimitive.SubmenuRoot.Props) {
return <MenuPrimitive.SubmenuRoot data-slot="dropdown-menu-sub" {...props} />;
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: MenuPrimitive.SubmenuTrigger.Props & {
inset?: boolean;
}) {
return (
<MenuPrimitive.SubmenuTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-popup-open:bg-accent data-popup-open:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto" />
</MenuPrimitive.SubmenuTrigger>
);
}
function DropdownMenuSubContent({
align = 'start',
alignOffset = -3,
side = 'right',
sideOffset = 0,
className,
...props
}: React.ComponentProps<typeof DropdownMenuContent>) {
return (
<DropdownMenuContent
data-slot="dropdown-menu-sub-content"
className={cn(
'w-auto min-w-[96px] rounded-lg bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95',
className,
)}
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
{...props}
/>
);
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
inset,
...props
}: MenuPrimitive.CheckboxItem.Props & {
inset?: boolean;
}) {
return (
<MenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
checked={checked}
{...props}
>
<span
className="pointer-events-none absolute right-2 flex items-center justify-center"
data-slot="dropdown-menu-checkbox-item-indicator"
>
<MenuPrimitive.CheckboxItemIndicator>
<CheckIcon />
</MenuPrimitive.CheckboxItemIndicator>
</span>
{children}
</MenuPrimitive.CheckboxItem>
);
}
function DropdownMenuRadioGroup({ ...props }: MenuPrimitive.RadioGroup.Props) {
return (
<MenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
);
}
function DropdownMenuRadioItem({
className,
children,
inset,
...props
}: MenuPrimitive.RadioItem.Props & {
inset?: boolean;
}) {
return (
<MenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
<span
className="pointer-events-none absolute right-2 flex items-center justify-center"
data-slot="dropdown-menu-radio-item-indicator"
>
<MenuPrimitive.RadioItemIndicator>
<CheckIcon />
</MenuPrimitive.RadioItemIndicator>
</span>
{children}
</MenuPrimitive.RadioItem>
);
}
function DropdownMenuSeparator({
className,
...props
}: MenuPrimitive.Separator.Props) {
return (
<MenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn('-mx-1 my-1 h-px bg-border', className)}
{...props}
/>
);
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<'span'>) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
'ml-auto text-xs tracking-widest text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground',
className,
)}
{...props}
/>
);
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
};
+104
View File
@@ -0,0 +1,104 @@
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
function Empty({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="empty"
className={cn(
'gap-4 rounded-xl border-dashed p-6 flex w-full min-w-0 flex-1 flex-col items-center justify-center text-center text-balance',
className,
)}
{...props}
/>
);
}
function EmptyHeader({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="empty-header"
className={cn('gap-2 flex max-w-sm flex-col items-center', className)}
{...props}
/>
);
}
const emptyMediaVariants = cva(
'mb-2 flex shrink-0 items-center justify-center [&_svg]:pointer-events-none [&_svg]:shrink-0',
{
variants: {
variant: {
default: 'bg-transparent',
icon: "bg-muted text-foreground flex size-8 shrink-0 items-center justify-center rounded-lg [&_svg:not([class*='size-'])]:size-4",
},
},
defaultVariants: {
variant: 'default',
},
},
);
function EmptyMedia({
className,
variant = 'default',
...props
}: React.ComponentProps<'div'> & VariantProps<typeof emptyMediaVariants>) {
return (
<div
data-slot="empty-icon"
data-variant={variant}
className={cn(emptyMediaVariants({ variant, className }))}
{...props}
/>
);
}
function EmptyTitle({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="empty-title"
className={cn(
'text-sm font-medium tracking-tight cn-font-heading',
className,
)}
{...props}
/>
);
}
function EmptyDescription({ className, ...props }: React.ComponentProps<'p'>) {
return (
<div
data-slot="empty-description"
className={cn(
'text-sm/relaxed text-muted-foreground [&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary',
className,
)}
{...props}
/>
);
}
function EmptyContent({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="empty-content"
className={cn(
'gap-2.5 text-sm flex w-full max-w-sm min-w-0 flex-col items-center text-balance',
className,
)}
{...props}
/>
);
}
export {
Empty,
EmptyHeader,
EmptyTitle,
EmptyDescription,
EmptyContent,
EmptyMedia,
};
+238
View File
@@ -0,0 +1,238 @@
'use client';
import { useMemo } from 'react';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
import { Label } from '@/components/ui/label';
import { Separator } from '@/components/ui/separator';
function FieldSet({ className, ...props }: React.ComponentProps<'fieldset'>) {
return (
<fieldset
data-slot="field-set"
className={cn(
'gap-4 has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3 flex flex-col',
className,
)}
{...props}
/>
);
}
function FieldLegend({
className,
variant = 'legend',
...props
}: React.ComponentProps<'legend'> & { variant?: 'legend' | 'label' }) {
return (
<legend
data-slot="field-legend"
data-variant={variant}
className={cn(
'mb-1.5 font-medium data-[variant=label]:text-sm data-[variant=legend]:text-base',
className,
)}
{...props}
/>
);
}
function FieldGroup({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="field-group"
className={cn(
'gap-5 data-[slot=checkbox-group]:gap-3 *:data-[slot=field-group]:gap-4 group/field-group @container/field-group flex w-full flex-col',
className,
)}
{...props}
/>
);
}
const fieldVariants = cva(
'data-[invalid=true]:text-destructive gap-2 group/field flex w-full',
{
variants: {
orientation: {
vertical: 'flex-col *:w-full [&>.sr-only]:w-auto',
horizontal:
'flex-row items-center has-[>[data-slot=field-content]]:items-start *:data-[slot=field-label]:flex-auto has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px',
responsive:
'flex-col *:w-full @md/field-group:flex-row @md/field-group:items-center @md/field-group:*:w-auto @md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:*:data-[slot=field-label]:flex-auto [&>.sr-only]:w-auto @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px',
},
},
defaultVariants: {
orientation: 'vertical',
},
},
);
function Field({
className,
orientation = 'vertical',
...props
}: React.ComponentProps<'div'> & VariantProps<typeof fieldVariants>) {
return (
<div
role="group"
data-slot="field"
data-orientation={orientation}
className={cn(fieldVariants({ orientation }), className)}
{...props}
/>
);
}
function FieldContent({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="field-content"
className={cn(
'gap-0.5 group/field-content flex flex-1 flex-col leading-snug',
className,
)}
{...props}
/>
);
}
function FieldLabel({
className,
...props
}: React.ComponentProps<typeof Label>) {
return (
<Label
data-slot="field-label"
className={cn(
'has-data-checked:bg-primary/5 has-data-checked:border-primary/30 dark:has-data-checked:border-primary/20 dark:has-data-checked:bg-primary/10 gap-2 leading-snug group-data-[disabled=true]/field:opacity-50 has-[>[data-slot=field]]:rounded-lg has-[>[data-slot=field]]:border has-[>[data-slot=field]]:not-has-[:disabled,[data-disabled]]:hover:bg-muted/50 has-[>[data-slot=field]]:has-[:focus-visible]:border-ring has-[>[data-slot=field]]:has-[:focus-visible]:ring-ring/50 has-[>[data-slot=field]]:has-[:focus-visible]:ring-3 *:data-[slot=field]:p-2.5 group/field-label peer/field-label flex w-fit',
'has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col',
className,
)}
{...props}
/>
);
}
function FieldTitle({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="field-label"
className={cn(
'gap-2 text-sm font-medium group-data-[disabled=true]/field:opacity-50 flex w-fit items-center',
className,
)}
{...props}
/>
);
}
function FieldDescription({ className, ...props }: React.ComponentProps<'p'>) {
return (
<p
data-slot="field-description"
className={cn(
'text-muted-foreground text-left text-sm [[data-variant=legend]+&]:-mt-1.5 leading-normal font-normal group-has-data-horizontal/field:text-balance',
'last:mt-0 nth-last-2:-mt-1',
'[&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary',
className,
)}
{...props}
/>
);
}
function FieldSeparator({
children,
className,
...props
}: React.ComponentProps<'div'> & {
children?: React.ReactNode;
}) {
return (
<div
data-slot="field-separator"
data-content={!!children}
className={cn(
'-my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2 relative',
className,
)}
{...props}
>
<Separator className="absolute inset-0 top-1/2" />
{children && (
<span
className="text-muted-foreground px-2 relative mx-auto block w-fit bg-background"
data-slot="field-separator-content"
>
{children}
</span>
)}
</div>
);
}
function FieldError({
className,
children,
errors,
...props
}: React.ComponentProps<'div'> & {
errors?: Array<{ message?: string } | undefined>;
}) {
const content = useMemo(() => {
if (children) {
return children;
}
if (!errors?.length) {
return null;
}
const uniqueErrors = [
...new Map(errors.map((error) => [error?.message, error])).values(),
];
if (uniqueErrors?.length == 1) {
return uniqueErrors[0]?.message;
}
return (
<ul className="ml-4 flex list-disc flex-col gap-1">
{uniqueErrors.map(
(error, index) =>
error?.message && <li key={index}>{error.message}</li>,
)}
</ul>
);
}, [children, errors]);
if (!content) {
return null;
}
return (
<div
role="alert"
data-slot="field-error"
className={cn('text-destructive text-sm font-normal', className)}
{...props}
>
{content}
</div>
);
}
export {
Field,
FieldLabel,
FieldDescription,
FieldError,
FieldGroup,
FieldLegend,
FieldSeparator,
FieldSet,
FieldContent,
FieldTitle,
};
+51
View File
@@ -0,0 +1,51 @@
'use client';
import { PreviewCard as PreviewCardPrimitive } from '@base-ui/react/preview-card';
import { cn } from '@/lib/utils';
function HoverCard({ ...props }: PreviewCardPrimitive.Root.Props) {
return <PreviewCardPrimitive.Root data-slot="hover-card" {...props} />;
}
function HoverCardTrigger({ ...props }: PreviewCardPrimitive.Trigger.Props) {
return (
<PreviewCardPrimitive.Trigger data-slot="hover-card-trigger" {...props} />
);
}
function HoverCardContent({
className,
side = 'bottom',
sideOffset = 4,
align = 'center',
alignOffset = 4,
...props
}: PreviewCardPrimitive.Popup.Props &
Pick<
PreviewCardPrimitive.Positioner.Props,
'align' | 'alignOffset' | 'side' | 'sideOffset'
>) {
return (
<PreviewCardPrimitive.Portal data-slot="hover-card-portal">
<PreviewCardPrimitive.Positioner
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
className="isolate z-50"
>
<PreviewCardPrimitive.Popup
data-slot="hover-card-content"
className={cn(
'data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 bg-popover text-popover-foreground w-64 rounded-lg p-2.5 text-sm shadow-md ring-1 duration-100 data-[side=inline-start]:slide-in-from-right-2 data-[side=inline-end]:slide-in-from-left-2 z-50 origin-(--transform-origin) outline-hidden',
className,
)}
{...props}
/>
</PreviewCardPrimitive.Positioner>
</PreviewCardPrimitive.Portal>
);
}
export { HoverCard, HoverCardTrigger, HoverCardContent };
+158
View File
@@ -0,0 +1,158 @@
'use client';
import * as React from 'react';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
function InputGroup({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="input-group"
role="group"
className={cn(
'border-input dark:bg-input/30 has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[[data-slot][aria-invalid=true]]:border-destructive dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40 has-disabled:bg-input/50 dark:has-disabled:bg-input/80 h-8 rounded-lg border transition-colors in-data-[slot=combobox-content]:focus-within:border-inherit in-data-[slot=combobox-content]:focus-within:ring-0 has-disabled:opacity-50 has-[[data-slot=input-group-control]:focus-visible]:ring-3 has-[[data-slot][aria-invalid=true]]:ring-3 has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-end]]:[&>input]:pr-1.5 has-[>[data-align=inline-start]]:[&>input]:pl-1.5 group/input-group relative flex w-full min-w-0 items-center outline-none has-[>textarea]:h-auto',
className,
)}
{...props}
/>
);
}
const inputGroupAddonVariants = cva(
"text-muted-foreground h-auto gap-2 py-1.5 text-sm font-medium group-data-[disabled=true]/input-group:opacity-50 [&>kbd]:rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-4 flex cursor-text items-center justify-center select-none",
{
variants: {
align: {
'inline-start':
'pl-2 has-[>button]:ml-[-0.3rem] has-[>kbd]:ml-[-0.15rem] order-first',
'inline-end':
'pr-2 has-[>button]:mr-[-0.3rem] has-[>kbd]:mr-[-0.15rem] order-last',
'block-start':
'px-2.5 pt-2 group-has-[>input]/input-group:pt-2 [.border-b]:pb-2 order-first w-full justify-start',
'block-end':
'px-2.5 pb-2 group-has-[>input]/input-group:pb-2 [.border-t]:pt-2 order-last w-full justify-start',
},
},
defaultVariants: {
align: 'inline-start',
},
},
);
function InputGroupAddon({
className,
align = 'inline-start',
...props
}: React.ComponentProps<'div'> & VariantProps<typeof inputGroupAddonVariants>) {
return (
<div
role="group"
data-slot="input-group-addon"
data-align={align}
className={cn(inputGroupAddonVariants({ align }), className)}
onClick={(e) => {
if ((e.target as HTMLElement).closest('button')) {
return;
}
e.currentTarget.parentElement?.querySelector('input')?.focus();
}}
{...props}
/>
);
}
const inputGroupButtonVariants = cva(
'gap-2 text-sm flex items-center shadow-none',
{
variants: {
size: {
xs: "h-6 gap-1 rounded-[calc(var(--radius)-3px)] px-1.5 [&>svg:not([class*='size-'])]:size-3.5",
sm: '',
'icon-xs':
'size-6 rounded-[calc(var(--radius)-3px)] p-0 has-[>svg]:p-0',
'icon-sm': 'size-8 p-0 has-[>svg]:p-0',
},
},
defaultVariants: {
size: 'xs',
},
},
);
function InputGroupButton({
className,
type = 'button',
variant = 'ghost',
size = 'xs',
...props
}: Omit<React.ComponentProps<typeof Button>, 'size' | 'type'> &
VariantProps<typeof inputGroupButtonVariants> & {
type?: 'button' | 'submit' | 'reset';
}) {
return (
<Button
type={type}
data-size={size}
variant={variant}
className={cn(inputGroupButtonVariants({ size }), className)}
{...props}
/>
);
}
function InputGroupText({ className, ...props }: React.ComponentProps<'span'>) {
return (
<span
className={cn(
"text-muted-foreground gap-2 text-sm [&_svg:not([class*='size-'])]:size-4 flex items-center [&_svg]:pointer-events-none",
className,
)}
{...props}
/>
);
}
function InputGroupInput({
className,
...props
}: React.ComponentProps<'input'>) {
return (
<Input
data-slot="input-group-control"
className={cn(
'rounded-none border-0 bg-transparent shadow-none ring-0 focus-visible:ring-0 disabled:bg-transparent aria-invalid:ring-0 dark:bg-transparent dark:disabled:bg-transparent flex-1',
className,
)}
{...props}
/>
);
}
function InputGroupTextarea({
className,
...props
}: React.ComponentProps<'textarea'>) {
return (
<Textarea
data-slot="input-group-control"
className={cn(
'rounded-none border-0 bg-transparent py-2 shadow-none ring-0 focus-visible:ring-0 disabled:bg-transparent aria-invalid:ring-0 dark:bg-transparent dark:disabled:bg-transparent flex-1 resize-none',
className,
)}
{...props}
/>
);
}
export {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupText,
InputGroupInput,
InputGroupTextarea,
};
+86
View File
@@ -0,0 +1,86 @@
'use client';
import * as React from 'react';
import { OTPInput, OTPInputContext } from 'input-otp';
import { cn } from '@/lib/utils';
import { MinusIcon } from 'lucide-react';
function InputOTP({
className,
containerClassName,
...props
}: React.ComponentProps<typeof OTPInput> & {
containerClassName?: string;
}) {
return (
<OTPInput
data-slot="input-otp"
containerClassName={cn(
'cn-input-otp flex items-center has-disabled:opacity-50',
containerClassName,
)}
spellCheck={false}
className={cn('disabled:cursor-not-allowed', className)}
{...props}
/>
);
}
function InputOTPGroup({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="input-otp-group"
className={cn(
'has-aria-invalid:ring-destructive/20 dark:has-aria-invalid:ring-destructive/40 has-aria-invalid:border-destructive rounded-lg has-aria-invalid:ring-3 flex items-center',
className,
)}
{...props}
/>
);
}
function InputOTPSlot({
index,
className,
...props
}: React.ComponentProps<'div'> & {
index: number;
}) {
const inputOTPContext = React.useContext(OTPInputContext);
const { char, hasFakeCaret, isActive } = inputOTPContext?.slots[index] ?? {};
return (
<div
data-slot="input-otp-slot"
data-active={isActive}
className={cn(
'dark:bg-input/30 border-input data-[active=true]:border-ring data-[active=true]:ring-ring/50 data-[active=true]:aria-invalid:ring-destructive/20 dark:data-[active=true]:aria-invalid:ring-destructive/40 aria-invalid:border-destructive data-[active=true]:aria-invalid:border-destructive size-8 border-y border-r text-sm transition-all outline-none first:rounded-l-lg first:border-l last:rounded-r-lg data-[active=true]:ring-3 relative flex items-center justify-center data-[active=true]:z-10',
className,
)}
{...props}
>
{char}
{hasFakeCaret && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<div className="animate-caret-blink bg-foreground h-4 w-px duration-1000" />
</div>
)}
</div>
);
}
function InputOTPSeparator({ ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="input-otp-separator"
className="[&_svg:not([class*='size-'])]:size-4 flex items-center"
role="separator"
{...props}
>
<MinusIcon />
</div>
);
}
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator };
+20
View File
@@ -0,0 +1,20 @@
import * as React from 'react';
import { Input as InputPrimitive } from '@base-ui/react/input';
import { cn } from '@/lib/utils';
function Input({ className, type, ...props }: React.ComponentProps<'input'>) {
return (
<InputPrimitive
type={type}
data-slot="input"
className={cn(
'h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40',
className,
)}
{...props}
/>
);
}
export { Input };
Loaded 100 of 771 files, more files were not shown because too many files have changed in this diff. Show more