Upgrade Biome to 2.4.7 and enable 54 additional lint rules
Add rules covering bug prevention (noLeakedRender, noFloatingPromises, noImportCycles, noReactForwardRef), security (noScriptUrl, noAlert), performance (noAwaitInLoops, useTopLevelRegex), and code style (noNestedTernary, useGlobalThis, useNullishCoalescing, useSortedClasses, plus ~40 more). Fix all violations: extract top-level regex constants, guard React && renders with boolean coercion, refactor nested ternaries, replace window with globalThis, sort Tailwind classes, and introduce expectDomainError test helper to eliminate conditional expects. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -55,11 +55,10 @@ function useActionBarAnimation(combatantCount: number) {
|
|||||||
const empty = combatantCount === 0;
|
const empty = combatantCount === 0;
|
||||||
const risingClass = rising ? " animate-rise-to-center" : "";
|
const risingClass = rising ? " animate-rise-to-center" : "";
|
||||||
const settlingClass = settling ? " animate-settle-to-bottom" : "";
|
const settlingClass = settling ? " animate-settle-to-bottom" : "";
|
||||||
const topBarClass = settling
|
const exitingClass = topBarExiting
|
||||||
? " animate-slide-down-in"
|
? " absolute inset-x-0 top-0 z-10 px-4 animate-slide-up-out"
|
||||||
: topBarExiting
|
: "";
|
||||||
? " absolute inset-x-0 top-0 z-10 px-4 animate-slide-up-out"
|
const topBarClass = settling ? " animate-slide-down-in" : exitingClass;
|
||||||
: "";
|
|
||||||
const showTopBar = !empty || topBarExiting;
|
const showTopBar = !empty || topBarExiting;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -194,7 +193,7 @@ export function App() {
|
|||||||
block: "nearest",
|
block: "nearest",
|
||||||
behavior: "smooth",
|
behavior: "smooth",
|
||||||
});
|
});
|
||||||
}, [encounter.activeIndex]);
|
}, []);
|
||||||
|
|
||||||
// Auto-show stat block for the active combatant when turn changes,
|
// Auto-show stat block for the active combatant when turn changes,
|
||||||
// but only when the viewport is wide enough to show it alongside the tracker.
|
// but only when the viewport is wide enough to show it alongside the tracker.
|
||||||
@@ -203,7 +202,7 @@ export function App() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (prevActiveIndexRef.current === encounter.activeIndex) return;
|
if (prevActiveIndexRef.current === encounter.activeIndex) return;
|
||||||
prevActiveIndexRef.current = encounter.activeIndex;
|
prevActiveIndexRef.current = encounter.activeIndex;
|
||||||
if (!window.matchMedia("(min-width: 1024px)").matches) return;
|
if (!globalThis.matchMedia("(min-width: 1024px)").matches) return;
|
||||||
const active = encounter.combatants[encounter.activeIndex];
|
const active = encounter.combatants[encounter.activeIndex];
|
||||||
if (!active?.creatureId || !isLoaded) return;
|
if (!active?.creatureId || !isLoaded) return;
|
||||||
sidePanel.showCreature(active.creatureId as CreatureId);
|
sidePanel.showCreature(active.creatureId as CreatureId);
|
||||||
@@ -216,8 +215,8 @@ export function App() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen flex-col">
|
<div className="flex h-screen flex-col">
|
||||||
<div className="relative mx-auto flex w-full max-w-2xl flex-1 flex-col gap-3 px-4 min-h-0">
|
<div className="relative mx-auto flex min-h-0 w-full max-w-2xl flex-1 flex-col gap-3 px-4">
|
||||||
{actionBarAnim.showTopBar && (
|
{!!actionBarAnim.showTopBar && (
|
||||||
<div
|
<div
|
||||||
className={`shrink-0 pt-8${actionBarAnim.topBarClass}`}
|
className={`shrink-0 pt-8${actionBarAnim.topBarClass}`}
|
||||||
onAnimationEnd={actionBarAnim.onTopBarExitEnd}
|
onAnimationEnd={actionBarAnim.onTopBarExitEnd}
|
||||||
@@ -233,7 +232,7 @@ export function App() {
|
|||||||
|
|
||||||
{isEmpty ? (
|
{isEmpty ? (
|
||||||
/* Empty state — ActionBar centered */
|
/* Empty state — ActionBar centered */
|
||||||
<div className="flex flex-1 items-center justify-center min-h-0 pb-[15%] pt-8">
|
<div className="flex min-h-0 flex-1 items-center justify-center pt-8 pb-[15%]">
|
||||||
<div
|
<div
|
||||||
className={`w-full${actionBarAnim.risingClass}`}
|
className={`w-full${actionBarAnim.risingClass}`}
|
||||||
onAnimationEnd={actionBarAnim.onRiseEnd}
|
onAnimationEnd={actionBarAnim.onRiseEnd}
|
||||||
@@ -263,7 +262,7 @@ export function App() {
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{/* Scrollable area — combatant list */}
|
{/* Scrollable area — combatant list */}
|
||||||
<div className="flex-1 overflow-y-auto min-h-0">
|
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||||
<div className="flex flex-col px-2 py-2">
|
<div className="flex flex-col px-2 py-2">
|
||||||
{encounter.combatants.map((c, i) => (
|
{encounter.combatants.map((c, i) => (
|
||||||
<CombatantRow
|
<CombatantRow
|
||||||
@@ -322,7 +321,7 @@ export function App() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Pinned Stat Block Panel (left) */}
|
{/* Pinned Stat Block Panel (left) */}
|
||||||
{sidePanel.pinnedCreatureId && sidePanel.isWideDesktop && (
|
{!!sidePanel.pinnedCreatureId && sidePanel.isWideDesktop && (
|
||||||
<StatBlockPanel
|
<StatBlockPanel
|
||||||
creatureId={sidePanel.pinnedCreatureId}
|
creatureId={sidePanel.pinnedCreatureId}
|
||||||
creature={pinnedCreature}
|
creature={pinnedCreature}
|
||||||
|
|||||||
@@ -200,6 +200,7 @@ describe("ConfirmButton", () => {
|
|||||||
const parentHandler = vi.fn();
|
const parentHandler = vi.fn();
|
||||||
render(
|
render(
|
||||||
// biome-ignore lint/a11y/noStaticElementInteractions: test wrapper
|
// biome-ignore lint/a11y/noStaticElementInteractions: test wrapper
|
||||||
|
// biome-ignore lint/a11y/noNoninteractiveElementInteractions: test wrapper
|
||||||
<div onKeyDown={parentHandler}>
|
<div onKeyDown={parentHandler}>
|
||||||
<ConfirmButton
|
<ConfirmButton
|
||||||
icon={<XIcon />}
|
icon={<XIcon />}
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { StatBlockPanel } from "../components/stat-block-panel";
|
import { StatBlockPanel } from "../components/stat-block-panel";
|
||||||
|
|
||||||
|
const CLOSE_REGEX = /close/i;
|
||||||
|
const COLLAPSE_REGEX = /collapse/i;
|
||||||
|
|
||||||
const CREATURE_ID = "srd:goblin" as CreatureId;
|
const CREATURE_ID = "srd:goblin" as CreatureId;
|
||||||
const CREATURE: Creature = {
|
const CREATURE: Creature = {
|
||||||
id: CREATURE_ID,
|
id: CREATURE_ID,
|
||||||
@@ -26,7 +29,7 @@ const CREATURE: Creature = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function mockMatchMedia(matches: boolean) {
|
function mockMatchMedia(matches: boolean) {
|
||||||
Object.defineProperty(window, "matchMedia", {
|
Object.defineProperty(globalThis, "matchMedia", {
|
||||||
writable: true,
|
writable: true,
|
||||||
value: vi.fn().mockImplementation((query: string) => ({
|
value: vi.fn().mockImplementation((query: string) => ({
|
||||||
matches,
|
matches,
|
||||||
@@ -92,7 +95,7 @@ describe("Stat Block Panel Collapse/Expand and Pin", () => {
|
|||||||
screen.getByRole("button", { name: "Collapse stat block panel" }),
|
screen.getByRole("button", { name: "Collapse stat block panel" }),
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
expect(
|
expect(
|
||||||
screen.queryByRole("button", { name: /close/i }),
|
screen.queryByRole("button", { name: CLOSE_REGEX }),
|
||||||
).not.toBeInTheDocument();
|
).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -247,7 +250,7 @@ describe("Stat Block Panel Collapse/Expand and Pin", () => {
|
|||||||
it("pinned panel has no collapse button", () => {
|
it("pinned panel has no collapse button", () => {
|
||||||
renderPanel({ panelRole: "pinned", side: "left" });
|
renderPanel({ panelRole: "pinned", side: "left" });
|
||||||
expect(
|
expect(
|
||||||
screen.queryByRole("button", { name: /collapse/i }),
|
screen.queryByRole("button", { name: COLLAPSE_REGEX }),
|
||||||
).not.toBeInTheDocument();
|
).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import type {
|
|||||||
import { creatureId, proficiencyBonus } from "@initiative/domain";
|
import { creatureId, proficiencyBonus } from "@initiative/domain";
|
||||||
import { stripTags } from "./strip-tags.js";
|
import { stripTags } from "./strip-tags.js";
|
||||||
|
|
||||||
|
const LEADING_DIGITS_REGEX = /^(\d+)/;
|
||||||
|
|
||||||
// --- Raw 5etools types (minimal, for parsing) ---
|
// --- Raw 5etools types (minimal, for parsing) ---
|
||||||
|
|
||||||
interface RawMonster {
|
interface RawMonster {
|
||||||
@@ -168,7 +170,7 @@ function extractAc(ac: RawMonster["ac"]): {
|
|||||||
}
|
}
|
||||||
if ("special" in first) {
|
if ("special" in first) {
|
||||||
// Variable AC (e.g. spell summons) — parse leading number if possible
|
// Variable AC (e.g. spell summons) — parse leading number if possible
|
||||||
const match = first.special.match(/^(\d+)/);
|
const match = first.special.match(LEADING_DIGITS_REGEX);
|
||||||
return {
|
return {
|
||||||
value: match ? Number(match[1]) : 0,
|
value: match ? Number(match[1]) : 0,
|
||||||
source: first.special,
|
source: first.special,
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ export function stripTags(text: string): string {
|
|||||||
|
|
||||||
// {@atkr type} → mapped attack roll text
|
// {@atkr type} → mapped attack roll text
|
||||||
result = result.replace(/\{@atkr\s+([^}]+)\}/g, (_, type: string) => {
|
result = result.replace(/\{@atkr\s+([^}]+)\}/g, (_, type: string) => {
|
||||||
return ATKR_MAP[type.trim()] ?? `Attack Roll:`;
|
return ATKR_MAP[type.trim()] ?? "Attack Roll:";
|
||||||
});
|
});
|
||||||
|
|
||||||
// {@actSave ability} → "Ability saving throw"
|
// {@actSave ability} → "Ability saving throw"
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export function AcShield({ value, onClick, className }: AcShieldProps) {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative inline-flex items-center justify-center text-sm tabular-nums text-muted-foreground transition-colors hover:text-hover-neutral",
|
"relative inline-flex items-center justify-center text-muted-foreground text-sm tabular-nums transition-colors hover:text-hover-neutral",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
style={{ width: 28, height: 32 }}
|
style={{ width: 28, height: 32 }}
|
||||||
@@ -29,8 +29,8 @@ export function AcShield({ value, onClick, className }: AcShieldProps) {
|
|||||||
>
|
>
|
||||||
<path d="M14 1.5 L2.5 6.5 L2.5 15 Q2.5 25 14 30.5 Q25.5 25 25.5 15 L25.5 6.5 Z" />
|
<path d="M14 1.5 L2.5 6.5 L2.5 15 Q2.5 25 14 30.5 Q25.5 25 25.5 15 L25.5 6.5 Z" />
|
||||||
</svg>
|
</svg>
|
||||||
<span className="relative text-xs font-medium leading-none">
|
<span className="relative font-medium text-xs leading-none">
|
||||||
{value !== undefined ? value : "\u2014"}
|
{value == null ? "\u2014" : String(value)}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -85,20 +85,20 @@ function AddModeSuggestions({
|
|||||||
<div className="absolute bottom-full z-50 mb-1 w-full max-w-xs rounded-md border border-border bg-card shadow-lg">
|
<div className="absolute bottom-full z-50 mb-1 w-full max-w-xs rounded-md border border-border bg-card shadow-lg">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="flex w-full items-center gap-1.5 border-b border-border px-3 py-2 text-left text-sm text-accent hover:bg-accent/20"
|
className="flex w-full items-center gap-1.5 border-border border-b px-3 py-2 text-left text-accent text-sm hover:bg-accent/20"
|
||||||
onMouseDown={(e) => e.preventDefault()}
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
onClick={onDismiss}
|
onClick={onDismiss}
|
||||||
>
|
>
|
||||||
<Plus className="h-3.5 w-3.5" />
|
<Plus className="h-3.5 w-3.5" />
|
||||||
<span className="flex-1">Add "{nameInput}" as custom</span>
|
<span className="flex-1">Add "{nameInput}" as custom</span>
|
||||||
<kbd className="rounded border border-border px-1.5 py-0.5 text-xs text-muted-foreground">
|
<kbd className="rounded border border-border px-1.5 py-0.5 text-muted-foreground text-xs">
|
||||||
Esc
|
Esc
|
||||||
</kbd>
|
</kbd>
|
||||||
</button>
|
</button>
|
||||||
<div className="max-h-48 overflow-y-auto py-1">
|
<div className="max-h-48 overflow-y-auto py-1">
|
||||||
{pcMatches.length > 0 && (
|
{pcMatches.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<div className="px-3 py-1 text-xs font-medium text-muted-foreground">
|
<div className="px-3 py-1 font-medium text-muted-foreground text-xs">
|
||||||
Players
|
Players
|
||||||
</div>
|
</div>
|
||||||
<ul>
|
<ul>
|
||||||
@@ -113,18 +113,18 @@ function AddModeSuggestions({
|
|||||||
<li key={pc.id}>
|
<li key={pc.id}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm text-foreground hover:bg-hover-neutral-bg"
|
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-foreground text-sm hover:bg-hover-neutral-bg"
|
||||||
onMouseDown={(e) => e.preventDefault()}
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onAddFromPlayerCharacter?.(pc);
|
onAddFromPlayerCharacter?.(pc);
|
||||||
onClear();
|
onClear();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{PcIcon && (
|
{!!PcIcon && (
|
||||||
<PcIcon size={14} style={{ color: pcColor }} />
|
<PcIcon size={14} style={{ color: pcColor }} />
|
||||||
)}
|
)}
|
||||||
<span className="flex-1 truncate">{pc.name}</span>
|
<span className="flex-1 truncate">{pc.name}</span>
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-muted-foreground text-xs">
|
||||||
Player
|
Player
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -144,19 +144,18 @@ function AddModeSuggestions({
|
|||||||
<li key={key}>
|
<li key={key}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`flex w-full items-center justify-between px-3 py-1.5 text-left text-sm ${
|
className={`flex w-full items-center justify-between px-3 py-1.5 text-left text-sm ${(() => {
|
||||||
isQueued
|
if (isQueued) return "bg-accent/30 text-foreground";
|
||||||
? "bg-accent/30 text-foreground"
|
if (i === suggestionIndex)
|
||||||
: i === suggestionIndex
|
return "bg-accent/20 text-foreground";
|
||||||
? "bg-accent/20 text-foreground"
|
return "text-foreground hover:bg-hover-neutral-bg";
|
||||||
: "text-foreground hover:bg-hover-neutral-bg"
|
})()}`}
|
||||||
}`}
|
|
||||||
onMouseDown={(e) => e.preventDefault()}
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
onClick={() => onClickSuggestion(result)}
|
onClick={() => onClickSuggestion(result)}
|
||||||
onMouseEnter={() => onSetSuggestionIndex(i)}
|
onMouseEnter={() => onSetSuggestionIndex(i)}
|
||||||
>
|
>
|
||||||
<span>{result.name}</span>
|
<span>{result.name}</span>
|
||||||
<span className="flex items-center gap-1 text-xs text-muted-foreground">
|
<span className="flex items-center gap-1 text-muted-foreground text-xs">
|
||||||
{isQueued ? (
|
{isQueued ? (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
@@ -482,12 +481,12 @@ export function ActionBar({
|
|||||||
className="pr-8"
|
className="pr-8"
|
||||||
autoFocus={autoFocus}
|
autoFocus={autoFocus}
|
||||||
/>
|
/>
|
||||||
{bestiaryLoaded && onViewStatBlock && (
|
{bestiaryLoaded && !!onViewStatBlock && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
className={cn(
|
className={cn(
|
||||||
"absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-hover-neutral",
|
"absolute top-1/2 right-2 -translate-y-1/2 text-muted-foreground hover:text-hover-neutral",
|
||||||
browseMode && "text-accent",
|
browseMode && "text-accent",
|
||||||
)}
|
)}
|
||||||
onClick={toggleBrowseMode}
|
onClick={toggleBrowseMode}
|
||||||
@@ -520,7 +519,7 @@ export function ActionBar({
|
|||||||
onMouseEnter={() => setSuggestionIndex(i)}
|
onMouseEnter={() => setSuggestionIndex(i)}
|
||||||
>
|
>
|
||||||
<span>{result.name}</span>
|
<span>{result.name}</span>
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-muted-foreground text-xs">
|
||||||
{result.sourceDisplayName}
|
{result.sourceDisplayName}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -578,7 +577,7 @@ export function ActionBar({
|
|||||||
{!browseMode && nameInput.length >= 2 && !hasSuggestions && (
|
{!browseMode && nameInput.length >= 2 && !hasSuggestions && (
|
||||||
<Button type="submit">Add</Button>
|
<Button type="submit">Add</Button>
|
||||||
)}
|
)}
|
||||||
{showRollAllInitiative && onRollAllInitiative && (
|
{showRollAllInitiative && !!onRollAllInitiative && (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
size="icon"
|
size="icon"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Loader2 } from "lucide-react";
|
import { Loader2 } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useId, useState } from "react";
|
||||||
import { getAllSourceCodes } from "../adapters/bestiary-index-adapter.js";
|
import { getAllSourceCodes } from "../adapters/bestiary-index-adapter.js";
|
||||||
import type { BulkImportState } from "../hooks/use-bulk-import.js";
|
import type { BulkImportState } from "../hooks/use-bulk-import.js";
|
||||||
import { Button } from "./ui/button.js";
|
import { Button } from "./ui/button.js";
|
||||||
@@ -20,12 +20,13 @@ export function BulkImportPrompt({
|
|||||||
onDone,
|
onDone,
|
||||||
}: BulkImportPromptProps) {
|
}: BulkImportPromptProps) {
|
||||||
const [baseUrl, setBaseUrl] = useState(DEFAULT_BASE_URL);
|
const [baseUrl, setBaseUrl] = useState(DEFAULT_BASE_URL);
|
||||||
|
const baseUrlId = useId();
|
||||||
const totalSources = getAllSourceCodes().length;
|
const totalSources = getAllSourceCodes().length;
|
||||||
|
|
||||||
if (importState.status === "complete") {
|
if (importState.status === "complete") {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<div className="rounded-md border border-green-500/50 bg-green-500/10 px-3 py-2 text-sm text-green-400">
|
<div className="rounded-md border border-green-500/50 bg-green-500/10 px-3 py-2 text-green-400 text-sm">
|
||||||
All sources loaded
|
All sources loaded
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={onDone}>Done</Button>
|
<Button onClick={onDone}>Done</Button>
|
||||||
@@ -54,7 +55,7 @@ export function BulkImportPrompt({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
<div className="flex items-center gap-2 text-muted-foreground text-sm">
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
Loading sources... {processed}/{importState.total}
|
Loading sources... {processed}/{importState.total}
|
||||||
</div>
|
</div>
|
||||||
@@ -74,23 +75,20 @@ export function BulkImportPrompt({
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-sm font-semibold text-foreground">
|
<h3 className="font-semibold text-foreground text-sm">
|
||||||
Import All Sources
|
Import All Sources
|
||||||
</h3>
|
</h3>
|
||||||
<p className="mt-1 text-xs text-muted-foreground">
|
<p className="mt-1 text-muted-foreground text-xs">
|
||||||
Load stat block data for all {totalSources} sources at once.
|
Load stat block data for all {totalSources} sources at once.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<label
|
<label htmlFor={baseUrlId} className="text-muted-foreground text-xs">
|
||||||
htmlFor="bulk-base-url"
|
|
||||||
className="text-xs text-muted-foreground"
|
|
||||||
>
|
|
||||||
Base URL
|
Base URL
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Input
|
||||||
id="bulk-base-url"
|
id={baseUrlId}
|
||||||
type="url"
|
type="url"
|
||||||
value={baseUrl}
|
value={baseUrl}
|
||||||
onChange={(e) => setBaseUrl(e.target.value)}
|
onChange={(e) => setBaseUrl(e.target.value)}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export function ColorPalette({ value, onChange }: ColorPaletteProps) {
|
|||||||
className={cn(
|
className={cn(
|
||||||
"h-8 w-8 rounded-full transition-all",
|
"h-8 w-8 rounded-full transition-all",
|
||||||
value === color
|
value === color
|
||||||
? "ring-2 ring-foreground ring-offset-2 ring-offset-background scale-110"
|
? "scale-110 ring-2 ring-foreground ring-offset-2 ring-offset-background"
|
||||||
: "hover:scale-110",
|
: "hover:scale-110",
|
||||||
)}
|
)}
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@@ -136,20 +136,18 @@ function EditableName({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<button
|
||||||
<button
|
type="button"
|
||||||
type="button"
|
onClick={handleClick}
|
||||||
onClick={handleClick}
|
onTouchStart={handleTouchStart}
|
||||||
onTouchStart={handleTouchStart}
|
onTouchEnd={cancelLongPress}
|
||||||
onTouchEnd={cancelLongPress}
|
onTouchCancel={cancelLongPress}
|
||||||
onTouchCancel={cancelLongPress}
|
onTouchMove={cancelLongPress}
|
||||||
onTouchMove={cancelLongPress}
|
className="cursor-text truncate text-left text-foreground text-sm transition-colors hover:text-hover-neutral"
|
||||||
className="truncate text-left text-sm text-foreground cursor-text hover:text-hover-neutral transition-colors"
|
style={color ? { color } : undefined}
|
||||||
style={color ? { color } : undefined}
|
>
|
||||||
>
|
{name}
|
||||||
{name}
|
</button>
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -205,7 +203,7 @@ function MaxHpDisplay({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={startEditing}
|
onClick={startEditing}
|
||||||
className="inline-block h-7 min-w-[3ch] text-center text-sm leading-7 tabular-nums text-muted-foreground transition-colors hover:text-hover-neutral"
|
className="inline-block h-7 min-w-[3ch] text-center text-muted-foreground text-sm tabular-nums leading-7 transition-colors hover:text-hover-neutral"
|
||||||
>
|
>
|
||||||
{maxHp ?? "Max"}
|
{maxHp ?? "Max"}
|
||||||
</button>
|
</button>
|
||||||
@@ -230,7 +228,7 @@ function ClickableHp({
|
|||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"inline-block h-7 w-[4ch] text-center text-sm leading-7 tabular-nums text-muted-foreground",
|
"inline-block h-7 w-[4ch] text-center text-muted-foreground text-sm tabular-nums leading-7",
|
||||||
dimmed && "opacity-50",
|
dimmed && "opacity-50",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -245,7 +243,7 @@ function ClickableHp({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => setPopoverOpen(true)}
|
onClick={() => setPopoverOpen(true)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"inline-block h-7 min-w-[3ch] text-center text-sm font-medium leading-7 tabular-nums transition-colors hover:text-hover-neutral",
|
"inline-block h-7 min-w-[3ch] text-center font-medium text-sm tabular-nums leading-7 transition-colors hover:text-hover-neutral",
|
||||||
status === "bloodied" && "text-amber-400",
|
status === "bloodied" && "text-amber-400",
|
||||||
status === "unconscious" && "text-red-400",
|
status === "unconscious" && "text-red-400",
|
||||||
status === "healthy" && "text-foreground",
|
status === "healthy" && "text-foreground",
|
||||||
@@ -254,7 +252,7 @@ function ClickableHp({
|
|||||||
>
|
>
|
||||||
{currentHp}
|
{currentHp}
|
||||||
</button>
|
</button>
|
||||||
{popoverOpen && (
|
{!!popoverOpen && (
|
||||||
<HpAdjustPopover
|
<HpAdjustPopover
|
||||||
onAdjust={onAdjust}
|
onAdjust={onAdjust}
|
||||||
onClose={() => setPopoverOpen(false)}
|
onClose={() => setPopoverOpen(false)}
|
||||||
@@ -397,10 +395,10 @@ function InitiativeDisplay({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={startEditing}
|
onClick={startEditing}
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-7 w-full text-center text-sm leading-7 tabular-nums transition-colors",
|
"h-7 w-full text-center text-sm tabular-nums leading-7 transition-colors",
|
||||||
initiative !== undefined
|
initiative === undefined
|
||||||
? "font-medium text-foreground hover:text-hover-neutral"
|
? "text-muted-foreground hover:text-hover-neutral"
|
||||||
: "text-muted-foreground hover:text-hover-neutral",
|
: "font-medium text-foreground hover:text-hover-neutral",
|
||||||
dimmed && "opacity-50",
|
dimmed && "opacity-50",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -491,6 +489,7 @@ export function CombatantRow({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
/* biome-ignore lint/a11y/noStaticElementInteractions: role="button" is set conditionally when onShowStatBlock exists */
|
/* biome-ignore lint/a11y/noStaticElementInteractions: role="button" is set conditionally when onShowStatBlock exists */
|
||||||
|
/* biome-ignore lint/a11y/noNoninteractiveElementInteractions: role="button" is set conditionally when onShowStatBlock exists */
|
||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
role={onShowStatBlock ? "button" : undefined}
|
role={onShowStatBlock ? "button" : undefined}
|
||||||
@@ -517,7 +516,7 @@ export function CombatantRow({
|
|||||||
title="Concentrating"
|
title="Concentrating"
|
||||||
aria-label="Toggle concentration"
|
aria-label="Toggle concentration"
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex w-full items-center justify-center self-stretch -my-2 -ml-[2px] pl-[14px] transition-opacity hover:text-hover-neutral hover:opacity-100",
|
"-my-2 -ml-[2px] flex w-full items-center justify-center self-stretch pl-[14px] transition-opacity hover:text-hover-neutral hover:opacity-100",
|
||||||
concentrationIconClass(combatant.isConcentrating, dimmed),
|
concentrationIconClass(combatant.isConcentrating, dimmed),
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -526,6 +525,7 @@ export function CombatantRow({
|
|||||||
|
|
||||||
{/* Initiative */}
|
{/* Initiative */}
|
||||||
{/* biome-ignore lint/a11y/noStaticElementInteractions: stopPropagation wrapper for interactive children */}
|
{/* biome-ignore lint/a11y/noStaticElementInteractions: stopPropagation wrapper for interactive children */}
|
||||||
|
{/* biome-ignore lint/a11y/noNoninteractiveElementInteractions: stopPropagation wrapper for interactive children */}
|
||||||
<div
|
<div
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
onKeyDown={(e) => e.stopPropagation()}
|
onKeyDown={(e) => e.stopPropagation()}
|
||||||
@@ -542,22 +542,22 @@ export function CombatantRow({
|
|||||||
{/* Name + Conditions */}
|
{/* Name + Conditions */}
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative flex flex-wrap items-center gap-1 min-w-0",
|
"relative flex min-w-0 flex-wrap items-center gap-1",
|
||||||
dimmed && "opacity-50",
|
dimmed && "opacity-50",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{combatant.icon &&
|
{!!combatant.icon &&
|
||||||
combatant.color &&
|
!!combatant.color &&
|
||||||
(() => {
|
(() => {
|
||||||
const PcIcon = PLAYER_ICON_MAP[combatant.icon as PlayerIcon];
|
const PcIcon = PLAYER_ICON_MAP[combatant.icon as PlayerIcon];
|
||||||
const pcColor =
|
const iconColor =
|
||||||
PLAYER_COLOR_HEX[
|
PLAYER_COLOR_HEX[
|
||||||
combatant.color as keyof typeof PLAYER_COLOR_HEX
|
combatant.color as keyof typeof PLAYER_COLOR_HEX
|
||||||
];
|
];
|
||||||
return PcIcon ? (
|
return PcIcon ? (
|
||||||
<PcIcon
|
<PcIcon
|
||||||
size={14}
|
size={14}
|
||||||
style={{ color: pcColor }}
|
style={{ color: iconColor }}
|
||||||
className="shrink-0"
|
className="shrink-0"
|
||||||
/>
|
/>
|
||||||
) : null;
|
) : null;
|
||||||
@@ -574,7 +574,7 @@ export function CombatantRow({
|
|||||||
onRemove={(conditionId) => onToggleCondition(id, conditionId)}
|
onRemove={(conditionId) => onToggleCondition(id, conditionId)}
|
||||||
onOpenPicker={() => setPickerOpen((prev) => !prev)}
|
onOpenPicker={() => setPickerOpen((prev) => !prev)}
|
||||||
/>
|
/>
|
||||||
{pickerOpen && (
|
{!!pickerOpen && (
|
||||||
<ConditionPicker
|
<ConditionPicker
|
||||||
activeConditions={combatant.conditions}
|
activeConditions={combatant.conditions}
|
||||||
onToggle={(conditionId) => onToggleCondition(id, conditionId)}
|
onToggle={(conditionId) => onToggleCondition(id, conditionId)}
|
||||||
@@ -585,6 +585,7 @@ export function CombatantRow({
|
|||||||
|
|
||||||
{/* AC */}
|
{/* AC */}
|
||||||
{/* biome-ignore lint/a11y/noStaticElementInteractions: stopPropagation wrapper for interactive children */}
|
{/* biome-ignore lint/a11y/noStaticElementInteractions: stopPropagation wrapper for interactive children */}
|
||||||
|
{/* biome-ignore lint/a11y/noNoninteractiveElementInteractions: stopPropagation wrapper for interactive children */}
|
||||||
<div
|
<div
|
||||||
className={cn(dimmed && "opacity-50")}
|
className={cn(dimmed && "opacity-50")}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
@@ -595,6 +596,7 @@ export function CombatantRow({
|
|||||||
|
|
||||||
{/* HP */}
|
{/* HP */}
|
||||||
{/* biome-ignore lint/a11y/noStaticElementInteractions: stopPropagation wrapper for interactive children */}
|
{/* biome-ignore lint/a11y/noStaticElementInteractions: stopPropagation wrapper for interactive children */}
|
||||||
|
{/* biome-ignore lint/a11y/noNoninteractiveElementInteractions: stopPropagation wrapper for interactive children */}
|
||||||
<div
|
<div
|
||||||
className="flex items-center gap-1"
|
className="flex items-center gap-1"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
@@ -609,7 +611,7 @@ export function CombatantRow({
|
|||||||
{maxHp !== undefined && (
|
{maxHp !== undefined && (
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-sm tabular-nums text-muted-foreground",
|
"text-muted-foreground text-sm tabular-nums",
|
||||||
dimmed && "opacity-50",
|
dimmed && "opacity-50",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -626,7 +628,7 @@ export function CombatantRow({
|
|||||||
icon={<X size={16} />}
|
icon={<X size={16} />}
|
||||||
label="Remove combatant"
|
label="Remove combatant"
|
||||||
onConfirm={() => onRemove(id)}
|
onConfirm={() => onRemove(id)}
|
||||||
className="h-7 w-7 text-muted-foreground opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto focus:opacity-100 focus:pointer-events-auto pointer-coarse:opacity-100 pointer-coarse:pointer-events-auto transition-opacity"
|
className="pointer-events-none pointer-coarse:pointer-events-auto h-7 w-7 text-muted-foreground opacity-0 pointer-coarse:opacity-100 transition-opacity focus:pointer-events-auto focus:opacity-100 group-hover:pointer-events-auto group-hover:opacity-100"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ export function ConditionTags({
|
|||||||
type="button"
|
type="button"
|
||||||
title={def.label}
|
title={def.label}
|
||||||
aria-label={`Remove ${def.label}`}
|
aria-label={`Remove ${def.label}`}
|
||||||
className={`inline-flex items-center rounded p-0.5 hover:bg-hover-neutral-bg transition-colors ${colorClass}`}
|
className={`inline-flex items-center rounded p-0.5 transition-colors hover:bg-hover-neutral-bg ${colorClass}`}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onRemove(condId);
|
onRemove(condId);
|
||||||
@@ -89,7 +89,7 @@ export function ConditionTags({
|
|||||||
type="button"
|
type="button"
|
||||||
title="Add condition"
|
title="Add condition"
|
||||||
aria-label="Add condition"
|
aria-label="Add condition"
|
||||||
className="inline-flex items-center rounded p-0.5 text-muted-foreground hover:text-hover-neutral hover:bg-hover-neutral-bg transition-colors opacity-0 group-hover:opacity-100 focus:opacity-100 pointer-coarse:opacity-100 transition-opacity"
|
className="inline-flex items-center rounded p-0.5 text-muted-foreground opacity-0 pointer-coarse:opacity-100 transition-colors transition-opacity hover:bg-hover-neutral-bg hover:text-hover-neutral focus:opacity-100 group-hover:opacity-100"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onOpenPicker();
|
onOpenPicker();
|
||||||
|
|||||||
@@ -87,17 +87,19 @@ export function CreatePlayerModal({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
// biome-ignore lint/a11y/noStaticElementInteractions: backdrop click to close
|
// biome-ignore lint/a11y/noStaticElementInteractions: backdrop click to close
|
||||||
|
// biome-ignore lint/a11y/noNoninteractiveElementInteractions: backdrop click to close
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
|
||||||
onMouseDown={onClose}
|
onMouseDown={onClose}
|
||||||
>
|
>
|
||||||
{/* biome-ignore lint/a11y/noStaticElementInteractions: prevent close when clicking modal content */}
|
{/* biome-ignore lint/a11y/noStaticElementInteractions: prevent close when clicking modal content */}
|
||||||
|
{/* biome-ignore lint/a11y/noNoninteractiveElementInteractions: prevent close when clicking modal content */}
|
||||||
<div
|
<div
|
||||||
className="w-full max-w-md rounded-lg border border-border bg-card p-6 shadow-xl"
|
className="w-full max-w-md rounded-lg border border-border bg-card p-6 shadow-xl"
|
||||||
onMouseDown={(e) => e.stopPropagation()}
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<div className="mb-4 flex items-center justify-between">
|
<div className="mb-4 flex items-center justify-between">
|
||||||
<h2 className="text-lg font-semibold text-foreground">
|
<h2 className="font-semibold text-foreground text-lg">
|
||||||
{isEdit ? "Edit Player" : "Create Player"}
|
{isEdit ? "Edit Player" : "Create Player"}
|
||||||
</h2>
|
</h2>
|
||||||
<Button
|
<Button
|
||||||
@@ -112,7 +114,7 @@ export function CreatePlayerModal({
|
|||||||
|
|
||||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
||||||
<div>
|
<div>
|
||||||
<span className="mb-1 block text-sm text-muted-foreground">
|
<span className="mb-1 block text-muted-foreground text-sm">
|
||||||
Name
|
Name
|
||||||
</span>
|
</span>
|
||||||
<Input
|
<Input
|
||||||
@@ -126,12 +128,14 @@ export function CreatePlayerModal({
|
|||||||
aria-label="Name"
|
aria-label="Name"
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
{error && <p className="mt-1 text-sm text-destructive">{error}</p>}
|
{!!error && (
|
||||||
|
<p className="mt-1 text-destructive text-sm">{error}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<span className="mb-1 block text-sm text-muted-foreground">
|
<span className="mb-1 block text-muted-foreground text-sm">
|
||||||
AC
|
AC
|
||||||
</span>
|
</span>
|
||||||
<Input
|
<Input
|
||||||
@@ -145,7 +149,7 @@ export function CreatePlayerModal({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<span className="mb-1 block text-sm text-muted-foreground">
|
<span className="mb-1 block text-muted-foreground text-sm">
|
||||||
Max HP
|
Max HP
|
||||||
</span>
|
</span>
|
||||||
<Input
|
<Input
|
||||||
@@ -161,14 +165,14 @@ export function CreatePlayerModal({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<span className="mb-2 block text-sm text-muted-foreground">
|
<span className="mb-2 block text-muted-foreground text-sm">
|
||||||
Color
|
Color
|
||||||
</span>
|
</span>
|
||||||
<ColorPalette value={color} onChange={setColor} />
|
<ColorPalette value={color} onChange={setColor} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<span className="mb-2 block text-sm text-muted-foreground">
|
<span className="mb-2 block text-muted-foreground text-sm">
|
||||||
Icon
|
Icon
|
||||||
</span>
|
</span>
|
||||||
<IconGrid value={icon} onChange={setIcon} />
|
<IconGrid value={icon} onChange={setIcon} />
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import {
|
|||||||
} from "react";
|
} from "react";
|
||||||
import { Input } from "./ui/input";
|
import { Input } from "./ui/input";
|
||||||
|
|
||||||
|
const DIGITS_ONLY_REGEX = /^\d+$/;
|
||||||
|
|
||||||
interface HpAdjustPopoverProps {
|
interface HpAdjustPopoverProps {
|
||||||
readonly onAdjust: (delta: number) => void;
|
readonly onAdjust: (delta: number) => void;
|
||||||
readonly onClose: () => void;
|
readonly onClose: () => void;
|
||||||
@@ -102,7 +104,7 @@ export function HpAdjustPopover({ onAdjust, onClose }: HpAdjustPopoverProps) {
|
|||||||
className="h-7 w-[7ch] text-center text-sm tabular-nums"
|
className="h-7 w-[7ch] text-center text-sm tabular-nums"
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const v = e.target.value;
|
const v = e.target.value;
|
||||||
if (v === "" || /^\d+$/.test(v)) {
|
if (v === "" || DIGITS_ONLY_REGEX.test(v)) {
|
||||||
setInputValue(v);
|
setInputValue(v);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export function IconGrid({ value, onChange }: IconGridProps) {
|
|||||||
className={cn(
|
className={cn(
|
||||||
"flex h-9 w-9 items-center justify-center rounded-md transition-all",
|
"flex h-9 w-9 items-center justify-center rounded-md transition-all",
|
||||||
value === iconId
|
value === iconId
|
||||||
? "bg-primary/20 ring-2 ring-primary text-foreground"
|
? "bg-primary/20 text-foreground ring-2 ring-primary"
|
||||||
: "text-muted-foreground hover:bg-card hover:text-foreground",
|
: "text-muted-foreground hover:bg-card hover:text-foreground",
|
||||||
)}
|
)}
|
||||||
aria-label={iconId}
|
aria-label={iconId}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { PlayerCharacter, PlayerCharacterId } from "@initiative/domain";
|
import type { PlayerCharacter, PlayerCharacterId } from "@initiative/domain";
|
||||||
import { forwardRef, useImperativeHandle, useState } from "react";
|
import { type RefObject, useImperativeHandle, useState } from "react";
|
||||||
import { CreatePlayerModal } from "./create-player-modal.js";
|
import { CreatePlayerModal } from "./create-player-modal.js";
|
||||||
import { PlayerManagement } from "./player-management.js";
|
import { PlayerManagement } from "./player-management.js";
|
||||||
|
|
||||||
@@ -29,13 +29,15 @@ interface PlayerCharacterSectionProps {
|
|||||||
onDeleteCharacter: (id: PlayerCharacterId) => void;
|
onDeleteCharacter: (id: PlayerCharacterId) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const PlayerCharacterSection = forwardRef<
|
export const PlayerCharacterSection = function PlayerCharacterSectionInner({
|
||||||
PlayerCharacterSectionHandle,
|
characters,
|
||||||
PlayerCharacterSectionProps
|
onCreateCharacter,
|
||||||
>(function PlayerCharacterSection(
|
onEditCharacter,
|
||||||
{ characters, onCreateCharacter, onEditCharacter, onDeleteCharacter },
|
onDeleteCharacter,
|
||||||
ref,
|
ref,
|
||||||
) {
|
}: PlayerCharacterSectionProps & {
|
||||||
|
ref?: RefObject<PlayerCharacterSectionHandle | null>;
|
||||||
|
}) {
|
||||||
const [managementOpen, setManagementOpen] = useState(false);
|
const [managementOpen, setManagementOpen] = useState(false);
|
||||||
const [createOpen, setCreateOpen] = useState(false);
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
const [editingPlayer, setEditingPlayer] = useState<
|
const [editingPlayer, setEditingPlayer] = useState<
|
||||||
@@ -88,4 +90,4 @@ export const PlayerCharacterSection = forwardRef<
|
|||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
});
|
};
|
||||||
|
|||||||
@@ -35,17 +35,19 @@ export function PlayerManagement({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
// biome-ignore lint/a11y/noStaticElementInteractions: backdrop click to close
|
// biome-ignore lint/a11y/noStaticElementInteractions: backdrop click to close
|
||||||
|
// biome-ignore lint/a11y/noNoninteractiveElementInteractions: backdrop click to close
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
|
||||||
onMouseDown={onClose}
|
onMouseDown={onClose}
|
||||||
>
|
>
|
||||||
{/* biome-ignore lint/a11y/noStaticElementInteractions: prevent close when clicking modal content */}
|
{/* biome-ignore lint/a11y/noStaticElementInteractions: prevent close when clicking modal content */}
|
||||||
|
{/* biome-ignore lint/a11y/noNoninteractiveElementInteractions: prevent close when clicking modal content */}
|
||||||
<div
|
<div
|
||||||
className="w-full max-w-md rounded-lg border border-border bg-card p-6 shadow-xl"
|
className="w-full max-w-md rounded-lg border border-border bg-card p-6 shadow-xl"
|
||||||
onMouseDown={(e) => e.stopPropagation()}
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<div className="mb-4 flex items-center justify-between">
|
<div className="mb-4 flex items-center justify-between">
|
||||||
<h2 className="text-lg font-semibold text-foreground">
|
<h2 className="font-semibold text-foreground text-lg">
|
||||||
Player Characters
|
Player Characters
|
||||||
</h2>
|
</h2>
|
||||||
<Button
|
<Button
|
||||||
@@ -76,16 +78,16 @@ export function PlayerManagement({
|
|||||||
key={pc.id}
|
key={pc.id}
|
||||||
className="group flex items-center gap-3 rounded-md px-3 py-2 hover:bg-hover-neutral-bg"
|
className="group flex items-center gap-3 rounded-md px-3 py-2 hover:bg-hover-neutral-bg"
|
||||||
>
|
>
|
||||||
{Icon && (
|
{!!Icon && (
|
||||||
<Icon size={18} style={{ color }} className="shrink-0" />
|
<Icon size={18} style={{ color }} className="shrink-0" />
|
||||||
)}
|
)}
|
||||||
<span className="flex-1 truncate text-sm text-foreground">
|
<span className="flex-1 truncate text-foreground text-sm">
|
||||||
{pc.name}
|
{pc.name}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs tabular-nums text-muted-foreground">
|
<span className="text-muted-foreground text-xs tabular-nums">
|
||||||
AC {pc.ac}
|
AC {pc.ac}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs tabular-nums text-muted-foreground">
|
<span className="text-muted-foreground text-xs tabular-nums">
|
||||||
HP {pc.maxHp}
|
HP {pc.maxHp}
|
||||||
</span>
|
</span>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Download, Loader2, Upload } from "lucide-react";
|
import { Download, Loader2, Upload } from "lucide-react";
|
||||||
import { useRef, useState } from "react";
|
import { useId, useRef, useState } from "react";
|
||||||
import { getDefaultFetchUrl } from "../adapters/bestiary-index-adapter.js";
|
import { getDefaultFetchUrl } from "../adapters/bestiary-index-adapter.js";
|
||||||
import { Button } from "./ui/button.js";
|
import { Button } from "./ui/button.js";
|
||||||
import { Input } from "./ui/input.js";
|
import { Input } from "./ui/input.js";
|
||||||
@@ -23,6 +23,7 @@ export function SourceFetchPrompt({
|
|||||||
const [status, setStatus] = useState<"idle" | "fetching" | "error">("idle");
|
const [status, setStatus] = useState<"idle" | "fetching" | "error">("idle");
|
||||||
const [error, setError] = useState<string>("");
|
const [error, setError] = useState<string>("");
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const sourceUrlId = useId();
|
||||||
|
|
||||||
const handleFetch = async () => {
|
const handleFetch = async () => {
|
||||||
setStatus("fetching");
|
setStatus("fetching");
|
||||||
@@ -64,21 +65,21 @@ export function SourceFetchPrompt({
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-sm font-semibold text-foreground">
|
<h3 className="font-semibold text-foreground text-sm">
|
||||||
Load {sourceDisplayName}
|
Load {sourceDisplayName}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="mt-1 text-xs text-muted-foreground">
|
<p className="mt-1 text-muted-foreground text-xs">
|
||||||
Stat block data for this source needs to be loaded. Enter a URL or
|
Stat block data for this source needs to be loaded. Enter a URL or
|
||||||
upload a JSON file.
|
upload a JSON file.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<label htmlFor="source-url" className="text-xs text-muted-foreground">
|
<label htmlFor={sourceUrlId} className="text-muted-foreground text-xs">
|
||||||
Source URL
|
Source URL
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Input
|
||||||
id="source-url"
|
id={sourceUrlId}
|
||||||
type="url"
|
type="url"
|
||||||
value={url}
|
value={url}
|
||||||
onChange={(e) => setUrl(e.target.value)}
|
onChange={(e) => setUrl(e.target.value)}
|
||||||
@@ -97,7 +98,7 @@ export function SourceFetchPrompt({
|
|||||||
{status === "fetching" ? "Loading..." : "Load"}
|
{status === "fetching" ? "Loading..." : "Load"}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<span className="text-xs text-muted-foreground">or</span>
|
<span className="text-muted-foreground text-xs">or</span>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -117,7 +118,7 @@ export function SourceFetchPrompt({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{status === "error" && (
|
{status === "error" && (
|
||||||
<div className="rounded-md border border-destructive/50 bg-destructive/10 px-3 py-2 text-xs text-destructive">
|
<div className="rounded-md border border-destructive/50 bg-destructive/10 px-3 py-2 text-destructive text-xs">
|
||||||
{error}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export function SourceManager({ onCacheCleared }: SourceManagerProps) {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadSources();
|
void loadSources();
|
||||||
}, [loadSources]);
|
}, [loadSources]);
|
||||||
|
|
||||||
const handleClearSource = async (sourceCode: string) => {
|
const handleClearSource = async (sourceCode: string) => {
|
||||||
@@ -48,7 +48,7 @@ export function SourceManager({ onCacheCleared }: SourceManagerProps) {
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center gap-2 py-8 text-center">
|
<div className="flex flex-col items-center gap-2 py-8 text-center">
|
||||||
<Database className="h-8 w-8 text-muted-foreground" />
|
<Database className="h-8 w-8 text-muted-foreground" />
|
||||||
<p className="text-sm text-muted-foreground">No cached sources</p>
|
<p className="text-muted-foreground text-sm">No cached sources</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -56,12 +56,12 @@ export function SourceManager({ onCacheCleared }: SourceManagerProps) {
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-sm font-semibold text-foreground">
|
<span className="font-semibold text-foreground text-sm">
|
||||||
Cached Sources
|
Cached Sources
|
||||||
</span>
|
</span>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="hover:text-hover-destructive hover:border-hover-destructive"
|
className="hover:border-hover-destructive hover:text-hover-destructive"
|
||||||
onClick={handleClearAll}
|
onClick={handleClearAll}
|
||||||
>
|
>
|
||||||
<Trash2 className="mr-1 h-3 w-3" />
|
<Trash2 className="mr-1 h-3 w-3" />
|
||||||
@@ -75,10 +75,10 @@ export function SourceManager({ onCacheCleared }: SourceManagerProps) {
|
|||||||
className="flex items-center justify-between rounded-md border border-border px-3 py-2"
|
className="flex items-center justify-between rounded-md border border-border px-3 py-2"
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<span className="text-sm text-foreground">
|
<span className="text-foreground text-sm">
|
||||||
{source.displayName}
|
{source.displayName}
|
||||||
</span>
|
</span>
|
||||||
<span className="ml-2 text-xs text-muted-foreground">
|
<span className="ml-2 text-muted-foreground text-xs">
|
||||||
{source.creatureCount} creatures
|
{source.creatureCount} creatures
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ function CollapsedTab({
|
|||||||
}`}
|
}`}
|
||||||
aria-label="Expand stat block panel"
|
aria-label="Expand stat block panel"
|
||||||
>
|
>
|
||||||
<span className="writing-vertical-rl text-sm font-medium">
|
<span className="writing-vertical-rl font-medium text-sm">
|
||||||
{creatureName}
|
{creatureName}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -81,7 +81,7 @@ function PanelHeader({
|
|||||||
onUnpin: () => void;
|
onUnpin: () => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between border-b border-border px-4 py-2">
|
<div className="flex items-center justify-between border-border border-b px-4 py-2">
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
{panelRole === "browse" && (
|
{panelRole === "browse" && (
|
||||||
<Button
|
<Button
|
||||||
@@ -189,18 +189,18 @@ function MobileDrawer({
|
|||||||
<div className="fixed inset-0 z-50">
|
<div className="fixed inset-0 z-50">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="absolute inset-0 bg-black/50 animate-in fade-in"
|
className="fade-in absolute inset-0 animate-in bg-black/50"
|
||||||
onClick={onDismiss}
|
onClick={onDismiss}
|
||||||
aria-label="Close stat block"
|
aria-label="Close stat block"
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
className={`absolute top-0 right-0 bottom-0 w-[85%] max-w-md border-l border-border bg-card shadow-xl ${isSwiping ? "" : "animate-slide-in-right"}`}
|
className={`absolute top-0 right-0 bottom-0 w-[85%] max-w-md border-border border-l bg-card shadow-xl ${isSwiping ? "" : "animate-slide-in-right"}`}
|
||||||
style={
|
style={
|
||||||
isSwiping ? { transform: `translateX(${offsetX}px)` } : undefined
|
isSwiping ? { transform: `translateX(${offsetX}px)` } : undefined
|
||||||
}
|
}
|
||||||
{...handlers}
|
{...handlers}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between border-b border-border px-4 py-2">
|
<div className="flex items-center justify-between border-border border-b px-4 py-2">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon-sm"
|
size="icon-sm"
|
||||||
@@ -241,13 +241,13 @@ export function StatBlockPanel({
|
|||||||
sourceManagerMode,
|
sourceManagerMode,
|
||||||
}: StatBlockPanelProps) {
|
}: StatBlockPanelProps) {
|
||||||
const [isDesktop, setIsDesktop] = useState(
|
const [isDesktop, setIsDesktop] = useState(
|
||||||
() => window.matchMedia("(min-width: 1024px)").matches,
|
() => globalThis.matchMedia("(min-width: 1024px)").matches,
|
||||||
);
|
);
|
||||||
const [needsFetch, setNeedsFetch] = useState(false);
|
const [needsFetch, setNeedsFetch] = useState(false);
|
||||||
const [checkingCache, setCheckingCache] = useState(false);
|
const [checkingCache, setCheckingCache] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const mq = window.matchMedia("(min-width: 1024px)");
|
const mq = globalThis.matchMedia("(min-width: 1024px)");
|
||||||
const handler = (e: MediaQueryListEvent) => setIsDesktop(e.matches);
|
const handler = (e: MediaQueryListEvent) => setIsDesktop(e.matches);
|
||||||
mq.addEventListener("change", handler);
|
mq.addEventListener("change", handler);
|
||||||
return () => mq.removeEventListener("change", handler);
|
return () => mq.removeEventListener("change", handler);
|
||||||
@@ -266,7 +266,7 @@ export function StatBlockPanel({
|
|||||||
}
|
}
|
||||||
|
|
||||||
setCheckingCache(true);
|
setCheckingCache(true);
|
||||||
isSourceCached(sourceCode).then((cached) => {
|
void isSourceCached(sourceCode).then((cached) => {
|
||||||
setNeedsFetch(!cached);
|
setNeedsFetch(!cached);
|
||||||
setCheckingCache(false);
|
setCheckingCache(false);
|
||||||
});
|
});
|
||||||
@@ -303,7 +303,7 @@ export function StatBlockPanel({
|
|||||||
|
|
||||||
if (checkingCache) {
|
if (checkingCache) {
|
||||||
return (
|
return (
|
||||||
<div className="p-4 text-sm text-muted-foreground">Loading...</div>
|
<div className="p-4 text-muted-foreground text-sm">Loading...</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -324,19 +324,16 @@ export function StatBlockPanel({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-4 text-sm text-muted-foreground">
|
<div className="p-4 text-muted-foreground text-sm">
|
||||||
No stat block available
|
No stat block available
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const creatureName =
|
let fallbackName = "Creature";
|
||||||
creature?.name ??
|
if (sourceManagerMode) fallbackName = "Sources";
|
||||||
(sourceManagerMode
|
else if (bulkImportMode) fallbackName = "Import All Sources";
|
||||||
? "Sources"
|
const creatureName = creature?.name ?? fallbackName;
|
||||||
: bulkImportMode
|
|
||||||
? "Import All Sources"
|
|
||||||
: "Creature");
|
|
||||||
|
|
||||||
if (isDesktop) {
|
if (isDesktop) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -54,11 +54,11 @@ export function StatBlock({ creature }: StatBlockProps) {
|
|||||||
<div className="space-y-1 text-foreground">
|
<div className="space-y-1 text-foreground">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-xl font-bold text-amber-400">{creature.name}</h2>
|
<h2 className="font-bold text-amber-400 text-xl">{creature.name}</h2>
|
||||||
<p className="text-sm italic text-muted-foreground">
|
<p className="text-muted-foreground text-sm italic">
|
||||||
{creature.size} {creature.type}, {creature.alignment}
|
{creature.size} {creature.type}, {creature.alignment}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-muted-foreground text-xs">
|
||||||
{creature.sourceDisplayName}
|
{creature.sourceDisplayName}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -69,7 +69,7 @@ export function StatBlock({ creature }: StatBlockProps) {
|
|||||||
<div className="space-y-0.5 text-sm">
|
<div className="space-y-0.5 text-sm">
|
||||||
<div>
|
<div>
|
||||||
<span className="font-semibold">Armor Class</span> {creature.ac}
|
<span className="font-semibold">Armor Class</span> {creature.ac}
|
||||||
{creature.acSource && (
|
{!!creature.acSource && (
|
||||||
<span className="text-muted-foreground">
|
<span className="text-muted-foreground">
|
||||||
{" "}
|
{" "}
|
||||||
({creature.acSource})
|
({creature.acSource})
|
||||||
@@ -194,7 +194,7 @@ export function StatBlock({ creature }: StatBlockProps) {
|
|||||||
{creature.actions && creature.actions.length > 0 && (
|
{creature.actions && creature.actions.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<SectionDivider />
|
<SectionDivider />
|
||||||
<h3 className="text-base font-bold text-amber-400">Actions</h3>
|
<h3 className="font-bold text-amber-400 text-base">Actions</h3>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{creature.actions.map((a) => (
|
{creature.actions.map((a) => (
|
||||||
<div key={a.name} className="text-sm">
|
<div key={a.name} className="text-sm">
|
||||||
@@ -209,7 +209,7 @@ export function StatBlock({ creature }: StatBlockProps) {
|
|||||||
{creature.bonusActions && creature.bonusActions.length > 0 && (
|
{creature.bonusActions && creature.bonusActions.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<SectionDivider />
|
<SectionDivider />
|
||||||
<h3 className="text-base font-bold text-amber-400">Bonus Actions</h3>
|
<h3 className="font-bold text-amber-400 text-base">Bonus Actions</h3>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{creature.bonusActions.map((a) => (
|
{creature.bonusActions.map((a) => (
|
||||||
<div key={a.name} className="text-sm">
|
<div key={a.name} className="text-sm">
|
||||||
@@ -224,7 +224,7 @@ export function StatBlock({ creature }: StatBlockProps) {
|
|||||||
{creature.reactions && creature.reactions.length > 0 && (
|
{creature.reactions && creature.reactions.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<SectionDivider />
|
<SectionDivider />
|
||||||
<h3 className="text-base font-bold text-amber-400">Reactions</h3>
|
<h3 className="font-bold text-amber-400 text-base">Reactions</h3>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{creature.reactions.map((a) => (
|
{creature.reactions.map((a) => (
|
||||||
<div key={a.name} className="text-sm">
|
<div key={a.name} className="text-sm">
|
||||||
@@ -236,13 +236,13 @@ export function StatBlock({ creature }: StatBlockProps) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Legendary Actions */}
|
{/* Legendary Actions */}
|
||||||
{creature.legendaryActions && (
|
{!!creature.legendaryActions && (
|
||||||
<>
|
<>
|
||||||
<SectionDivider />
|
<SectionDivider />
|
||||||
<h3 className="text-base font-bold text-amber-400">
|
<h3 className="font-bold text-amber-400 text-base">
|
||||||
Legendary Actions
|
Legendary Actions
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-sm italic text-muted-foreground">
|
<p className="text-muted-foreground text-sm italic">
|
||||||
{creature.legendaryActions.preamble}
|
{creature.legendaryActions.preamble}
|
||||||
</p>
|
</p>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export function Toast({
|
|||||||
return createPortal(
|
return createPortal(
|
||||||
<div className="fixed bottom-4 left-4 z-50">
|
<div className="fixed bottom-4 left-4 z-50">
|
||||||
<div className="flex items-center gap-3 rounded-lg border border-border bg-card px-4 py-3 shadow-lg">
|
<div className="flex items-center gap-3 rounded-lg border border-border bg-card px-4 py-3 shadow-lg">
|
||||||
<span className="text-sm text-foreground">{message}</span>
|
<span className="text-foreground text-sm">{message}</span>
|
||||||
{progress !== undefined && (
|
{progress !== undefined && (
|
||||||
<div className="h-2 w-24 overflow-hidden rounded-full bg-muted">
|
<div className="h-2 w-24 overflow-hidden rounded-full bg-muted">
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -33,8 +33,8 @@ export function TurnNavigation({
|
|||||||
<StepBack className="h-5 w-5" />
|
<StepBack className="h-5 w-5" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<div className="min-w-0 flex-1 flex items-center justify-center gap-2 text-sm">
|
<div className="flex min-w-0 flex-1 items-center justify-center gap-2 text-sm">
|
||||||
<span className="rounded-full bg-muted text-foreground text-sm px-2 py-0.5 font-semibold shrink-0">
|
<span className="shrink-0 rounded-full bg-muted px-2 py-0.5 font-semibold text-foreground text-sm">
|
||||||
R{encounter.roundNumber}
|
R{encounter.roundNumber}
|
||||||
</span>
|
</span>
|
||||||
{activeCombatant ? (
|
{activeCombatant ? (
|
||||||
|
|||||||
@@ -55,17 +55,17 @@ export function ConfirmButton({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleKeyDown(e: KeyboardEvent) {
|
function handleEscapeKey(e: KeyboardEvent) {
|
||||||
if (e.key === "Escape") {
|
if (e.key === "Escape") {
|
||||||
revert();
|
revert();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener("mousedown", handleMouseDown);
|
document.addEventListener("mousedown", handleMouseDown);
|
||||||
document.addEventListener("keydown", handleKeyDown);
|
document.addEventListener("keydown", handleEscapeKey);
|
||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener("mousedown", handleMouseDown);
|
document.removeEventListener("mousedown", handleMouseDown);
|
||||||
document.removeEventListener("keydown", handleKeyDown);
|
document.removeEventListener("keydown", handleEscapeKey);
|
||||||
};
|
};
|
||||||
}, [isConfirming, revert]);
|
}, [isConfirming, revert]);
|
||||||
|
|
||||||
@@ -100,7 +100,7 @@ export function ConfirmButton({
|
|||||||
className={cn(
|
className={cn(
|
||||||
className,
|
className,
|
||||||
isConfirming
|
isConfirming
|
||||||
? "bg-destructive text-primary-foreground rounded-md animate-confirm-pulse hover:bg-destructive hover:text-primary-foreground"
|
? "animate-confirm-pulse rounded-md bg-destructive text-primary-foreground hover:bg-destructive hover:text-primary-foreground"
|
||||||
: "hover:text-hover-destructive",
|
: "hover:text-hover-destructive",
|
||||||
)}
|
)}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
@@ -110,7 +110,8 @@ export function ConfirmButton({
|
|||||||
aria-label={isConfirming ? `Confirm ${label.toLowerCase()}` : label}
|
aria-label={isConfirming ? `Confirm ${label.toLowerCase()}` : label}
|
||||||
title={isConfirming ? `Confirm ${label.toLowerCase()}` : label}
|
title={isConfirming ? `Confirm ${label.toLowerCase()}` : label}
|
||||||
>
|
>
|
||||||
{isConfirming ? <Check size={16} /> : icon}
|
{isConfirming ? <Check size={16} /> : null}
|
||||||
|
{!isConfirming && icon}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,19 +1,21 @@
|
|||||||
import { forwardRef, type InputHTMLAttributes } from "react";
|
import type { InputHTMLAttributes, RefObject } from "react";
|
||||||
import { cn } from "../../lib/utils";
|
import { cn } from "../../lib/utils";
|
||||||
|
|
||||||
type InputProps = InputHTMLAttributes<HTMLInputElement>;
|
type InputProps = InputHTMLAttributes<HTMLInputElement>;
|
||||||
|
|
||||||
export const Input = forwardRef<HTMLInputElement, InputProps>(
|
export const Input = ({
|
||||||
({ className, ...props }, ref) => {
|
className,
|
||||||
return (
|
ref,
|
||||||
<input
|
...props
|
||||||
ref={ref}
|
}: InputProps & { ref?: RefObject<HTMLInputElement | null> }) => {
|
||||||
className={cn(
|
return (
|
||||||
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm text-foreground shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary disabled:cursor-not-allowed disabled:opacity-50",
|
<input
|
||||||
className,
|
ref={ref}
|
||||||
)}
|
className={cn(
|
||||||
{...props}
|
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-foreground text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
/>
|
className,
|
||||||
);
|
)}
|
||||||
},
|
{...props}
|
||||||
);
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
@@ -48,13 +48,13 @@ export function OverflowMenu({ items }: OverflowMenuProps) {
|
|||||||
>
|
>
|
||||||
<EllipsisVertical className="h-5 w-5" />
|
<EllipsisVertical className="h-5 w-5" />
|
||||||
</Button>
|
</Button>
|
||||||
{open && (
|
{!!open && (
|
||||||
<div className="absolute bottom-full right-0 z-50 mb-1 min-w-48 rounded-md border border-border bg-card py-1 shadow-lg">
|
<div className="absolute right-0 bottom-full z-50 mb-1 min-w-48 rounded-md border border-border bg-card py-1 shadow-lg">
|
||||||
{items.map((item) => (
|
{items.map((item) => (
|
||||||
<button
|
<button
|
||||||
key={item.label}
|
key={item.label}
|
||||||
type="button"
|
type="button"
|
||||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm text-foreground hover:bg-hover-neutral-bg disabled:pointer-events-none disabled:opacity-50"
|
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-foreground text-sm hover:bg-hover-neutral-bg disabled:pointer-events-none disabled:opacity-50"
|
||||||
disabled={item.disabled}
|
disabled={item.disabled}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
item.onClick();
|
item.onClick();
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ export function useBestiary(): BestiaryHook {
|
|||||||
setIsLoaded(true);
|
setIsLoaded(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
bestiaryCache.loadAllCachedCreatures().then((map) => {
|
void bestiaryCache.loadAllCachedCreatures().then((map) => {
|
||||||
setCreatureMap(map);
|
setCreatureMap(map);
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ export function useBulkImport(): BulkImportHook {
|
|||||||
countersRef.current = { completed: 0, failed: 0 };
|
countersRef.current = { completed: 0, failed: 0 };
|
||||||
setState({ status: "loading", total, completed: 0, failed: 0 });
|
setState({ status: "loading", total, completed: 0, failed: 0 });
|
||||||
|
|
||||||
(async () => {
|
void (async () => {
|
||||||
const cacheChecks = await Promise.all(
|
const cacheChecks = await Promise.all(
|
||||||
allCodes.map(async (code) => ({
|
allCodes.map(async (code) => ({
|
||||||
code,
|
code,
|
||||||
@@ -75,6 +75,7 @@ export function useBulkImport(): BulkImportHook {
|
|||||||
|
|
||||||
for (let i = 0; i < uncached.length; i += BATCH_SIZE) {
|
for (let i = 0; i < uncached.length; i += BATCH_SIZE) {
|
||||||
const batch = uncached.slice(i, i + BATCH_SIZE);
|
const batch = uncached.slice(i, i + BATCH_SIZE);
|
||||||
|
// biome-ignore lint/performance/noAwaitInLoops: sequential batching is intentional to avoid overwhelming the server with too many concurrent requests
|
||||||
await Promise.allSettled(
|
await Promise.allSettled(
|
||||||
batch.map(async ({ code }) => {
|
batch.map(async ({ code }) => {
|
||||||
const url = getDefaultFetchUrl(code, baseUrl);
|
const url = getDefaultFetchUrl(code, baseUrl);
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ import {
|
|||||||
saveEncounter,
|
saveEncounter,
|
||||||
} from "../persistence/encounter-storage.js";
|
} from "../persistence/encounter-storage.js";
|
||||||
|
|
||||||
|
const COMBATANT_ID_REGEX = /^c-(\d+)$/;
|
||||||
|
|
||||||
const EMPTY_ENCOUNTER: Encounter = {
|
const EMPTY_ENCOUNTER: Encounter = {
|
||||||
combatants: [],
|
combatants: [],
|
||||||
activeIndex: 0,
|
activeIndex: 0,
|
||||||
@@ -48,7 +50,7 @@ function initializeEncounter(): Encounter {
|
|||||||
function deriveNextId(encounter: Encounter): number {
|
function deriveNextId(encounter: Encounter): number {
|
||||||
let max = 0;
|
let max = 0;
|
||||||
for (const c of encounter.combatants) {
|
for (const c of encounter.combatants) {
|
||||||
const match = /^c-(\d+)$/.exec(c.id);
|
const match = COMBATANT_ID_REGEX.exec(c.id);
|
||||||
if (match) {
|
if (match) {
|
||||||
const n = Number.parseInt(match[1], 10);
|
const n = Number.parseInt(match[1], 10);
|
||||||
if (n > max) max = n;
|
if (n > max) max = n;
|
||||||
@@ -316,7 +318,7 @@ export function useEncounter() {
|
|||||||
|
|
||||||
setEvents((prev) => [...prev, ...addResult]);
|
setEvents((prev) => [...prev, ...addResult]);
|
||||||
},
|
},
|
||||||
[makeStore, editCombatant],
|
[makeStore],
|
||||||
);
|
);
|
||||||
|
|
||||||
const addFromPlayerCharacter = useCallback(
|
const addFromPlayerCharacter = useCallback(
|
||||||
@@ -368,7 +370,7 @@ export function useEncounter() {
|
|||||||
|
|
||||||
setEvents((prev) => [...prev, ...addResult]);
|
setEvents((prev) => [...prev, ...addResult]);
|
||||||
},
|
},
|
||||||
[makeStore, editCombatant],
|
[makeStore],
|
||||||
);
|
);
|
||||||
|
|
||||||
const isEmpty = encounter.combatants.length === 0;
|
const isEmpty = encounter.combatants.length === 0;
|
||||||
|
|||||||
@@ -34,11 +34,11 @@ export function useSidePanelState(): SidePanelState & SidePanelActions {
|
|||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
const [isWideDesktop, setIsWideDesktop] = useState(
|
const [isWideDesktop, setIsWideDesktop] = useState(
|
||||||
() => window.matchMedia("(min-width: 1280px)").matches,
|
() => globalThis.matchMedia("(min-width: 1280px)").matches,
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const mq = window.matchMedia("(min-width: 1280px)");
|
const mq = globalThis.matchMedia("(min-width: 1280px)");
|
||||||
const handler = (e: MediaQueryListEvent) => setIsWideDesktop(e.matches);
|
const handler = (e: MediaQueryListEvent) => setIsWideDesktop(e.matches);
|
||||||
mq.addEventListener("change", handler);
|
mq.addEventListener("change", handler);
|
||||||
return () => mq.removeEventListener("change", handler);
|
return () => mq.removeEventListener("change", handler);
|
||||||
|
|||||||
102
biome.json
102
biome.json
@@ -1,14 +1,14 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://biomejs.dev/schemas/2.0.0/schema.json",
|
"$schema": "https://biomejs.dev/schemas/2.4.7/schema.json",
|
||||||
"files": {
|
"files": {
|
||||||
"includes": [
|
"includes": [
|
||||||
"**",
|
"**",
|
||||||
"!**/dist/**",
|
"!**/dist",
|
||||||
"!.claude/**",
|
"!.claude",
|
||||||
"!.specify/**",
|
"!.specify",
|
||||||
"!specs/**",
|
"!specs",
|
||||||
"!coverage/**",
|
"!coverage",
|
||||||
"!.pnpm-store/**"
|
"!.pnpm-store"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"assist": {
|
"assist": {
|
||||||
@@ -21,6 +21,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"css": {
|
||||||
|
"parser": {
|
||||||
|
"cssModules": false,
|
||||||
|
"tailwindDirectives": true
|
||||||
|
}
|
||||||
|
},
|
||||||
"formatter": {
|
"formatter": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"indentStyle": "tab",
|
"indentStyle": "tab",
|
||||||
@@ -30,13 +36,93 @@
|
|||||||
"enabled": true,
|
"enabled": true,
|
||||||
"rules": {
|
"rules": {
|
||||||
"recommended": true,
|
"recommended": true,
|
||||||
|
"a11y": {
|
||||||
|
"noNoninteractiveElementInteractions": "error"
|
||||||
|
},
|
||||||
"complexity": {
|
"complexity": {
|
||||||
"noExcessiveCognitiveComplexity": {
|
"noExcessiveCognitiveComplexity": {
|
||||||
"level": "error",
|
"level": "error",
|
||||||
"options": {
|
"options": {
|
||||||
"maxAllowedComplexity": 15
|
"maxAllowedComplexity": 15
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
"noUselessStringConcat": "error"
|
||||||
|
},
|
||||||
|
"correctness": {
|
||||||
|
"noNestedComponentDefinitions": "error",
|
||||||
|
"noReactPropAssignments": "error"
|
||||||
|
},
|
||||||
|
"nursery": {
|
||||||
|
"noConditionalExpect": "error",
|
||||||
|
"noDuplicatedSpreadProps": "error",
|
||||||
|
"noFloatingPromises": "error",
|
||||||
|
"noLeakedRender": "error",
|
||||||
|
"noMisusedPromises": "error",
|
||||||
|
"noNestedPromises": "error",
|
||||||
|
"noReturnAssign": "error",
|
||||||
|
"noScriptUrl": "error",
|
||||||
|
"noShadow": "error",
|
||||||
|
"noUnnecessaryConditions": "error",
|
||||||
|
"noUselessReturn": "error",
|
||||||
|
"useArraySome": "error",
|
||||||
|
"useArraySortCompare": "error",
|
||||||
|
"useAwaitThenable": "error",
|
||||||
|
"useErrorCause": "error",
|
||||||
|
"useExhaustiveSwitchCases": "error",
|
||||||
|
"useFind": "error",
|
||||||
|
"useGlobalThis": "error",
|
||||||
|
"useNullishCoalescing": "error",
|
||||||
|
"useRegexpExec": "error",
|
||||||
|
"useSortedClasses": "error",
|
||||||
|
"useSpread": "error"
|
||||||
|
},
|
||||||
|
"performance": {
|
||||||
|
"noAwaitInLoops": "error",
|
||||||
|
"useTopLevelRegex": "error"
|
||||||
|
},
|
||||||
|
"style": {
|
||||||
|
"noCommonJs": "error",
|
||||||
|
"noDoneCallback": "error",
|
||||||
|
"noExportedImports": "error",
|
||||||
|
"noInferrableTypes": "error",
|
||||||
|
"noNamespace": "error",
|
||||||
|
"noNegationElse": "error",
|
||||||
|
"noNestedTernary": "error",
|
||||||
|
"noParameterAssign": "error",
|
||||||
|
"noSubstr": "error",
|
||||||
|
"noUnusedTemplateLiteral": "error",
|
||||||
|
"noUselessElse": "error",
|
||||||
|
"noYodaExpression": "error",
|
||||||
|
"useAsConstAssertion": "error",
|
||||||
|
"useAtIndex": "error",
|
||||||
|
"useCollapsedElseIf": "error",
|
||||||
|
"useCollapsedIf": "error",
|
||||||
|
"useConsistentBuiltinInstantiation": "error",
|
||||||
|
"useDefaultParameterLast": "error",
|
||||||
|
"useExplicitLengthCheck": "error",
|
||||||
|
"useForOf": "error",
|
||||||
|
"useFragmentSyntax": "error",
|
||||||
|
"useNumberNamespace": "error",
|
||||||
|
"useSelfClosingElements": "error",
|
||||||
|
"useShorthandAssign": "error",
|
||||||
|
"useThrowNewError": "error",
|
||||||
|
"useThrowOnlyError": "error",
|
||||||
|
"useTrimStartEnd": "error"
|
||||||
|
},
|
||||||
|
"suspicious": {
|
||||||
|
"noAlert": "error",
|
||||||
|
"noConstantBinaryExpressions": "error",
|
||||||
|
"noDeprecatedImports": "error",
|
||||||
|
"noEvolvingTypes": "error",
|
||||||
|
"noImportCycles": "error",
|
||||||
|
"noReactForwardRef": "error",
|
||||||
|
"noSkippedTests": "error",
|
||||||
|
"noTemplateCurlyInString": "error",
|
||||||
|
"noTsIgnore": "error",
|
||||||
|
"noUnusedExpressions": "error",
|
||||||
|
"noVar": "error",
|
||||||
|
"useAwait": "error",
|
||||||
|
"useErrorMessage": "error"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "2.0.0",
|
"@biomejs/biome": "2.4.7",
|
||||||
"@vitest/coverage-v8": "^3.2.4",
|
"@vitest/coverage-v8": "^3.2.4",
|
||||||
"jscpd": "^4.0.8",
|
"jscpd": "^4.0.8",
|
||||||
"knip": "^5.85.0",
|
"knip": "^5.85.0",
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
|
|||||||
import { addCombatant } from "../add-combatant.js";
|
import { addCombatant } from "../add-combatant.js";
|
||||||
import type { Combatant, Encounter } from "../types.js";
|
import type { Combatant, Encounter } from "../types.js";
|
||||||
import { combatantId, isDomainError } from "../types.js";
|
import { combatantId, isDomainError } from "../types.js";
|
||||||
|
import { expectDomainError } from "./test-helpers.js";
|
||||||
|
|
||||||
// --- Helpers ---
|
// --- Helpers ---
|
||||||
|
|
||||||
@@ -112,20 +113,14 @@ describe("addCombatant", () => {
|
|||||||
const e = enc([A, B]);
|
const e = enc([A, B]);
|
||||||
const result = addCombatant(e, combatantId("x"), "");
|
const result = addCombatant(e, combatantId("x"), "");
|
||||||
|
|
||||||
expect(isDomainError(result)).toBe(true);
|
expectDomainError(result, "invalid-name");
|
||||||
if (isDomainError(result)) {
|
|
||||||
expect(result.code).toBe("invalid-name");
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("scenario 6: whitespace-only name returns error", () => {
|
it("scenario 6: whitespace-only name returns error", () => {
|
||||||
const e = enc([A, B]);
|
const e = enc([A, B]);
|
||||||
const result = addCombatant(e, combatantId("x"), " ");
|
const result = addCombatant(e, combatantId("x"), " ");
|
||||||
|
|
||||||
expect(isDomainError(result)).toBe(true);
|
expectDomainError(result, "invalid-name");
|
||||||
if (isDomainError(result)) {
|
|
||||||
expect(result.code).toBe("invalid-name");
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -146,12 +141,10 @@ describe("addCombatant", () => {
|
|||||||
for (const e of scenarios) {
|
for (const e of scenarios) {
|
||||||
const result = successResult(e, "new", "New");
|
const result = successResult(e, "new", "New");
|
||||||
const { combatants, activeIndex } = result.encounter;
|
const { combatants, activeIndex } = result.encounter;
|
||||||
if (combatants.length > 0) {
|
// After adding a combatant, list is always non-empty
|
||||||
expect(activeIndex).toBeGreaterThanOrEqual(0);
|
expect(combatants.length).toBeGreaterThan(0);
|
||||||
expect(activeIndex).toBeLessThan(combatants.length);
|
expect(activeIndex).toBeGreaterThanOrEqual(0);
|
||||||
} else {
|
expect(activeIndex).toBeLessThan(combatants.length);
|
||||||
expect(activeIndex).toBe(0);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -188,7 +181,7 @@ describe("addCombatant", () => {
|
|||||||
it("INV-7: new combatant is always appended at the end", () => {
|
it("INV-7: new combatant is always appended at the end", () => {
|
||||||
const e = enc([A, B]);
|
const e = enc([A, B]);
|
||||||
const { encounter } = successResult(e, "C", "C");
|
const { encounter } = successResult(e, "C", "C");
|
||||||
expect(encounter.combatants[encounter.combatants.length - 1]).toEqual({
|
expect(encounter.combatants.at(-1)).toEqual({
|
||||||
id: combatantId("C"),
|
id: combatantId("C"),
|
||||||
name: "C",
|
name: "C",
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
|
|||||||
import { adjustHp } from "../adjust-hp.js";
|
import { adjustHp } from "../adjust-hp.js";
|
||||||
import type { Combatant, Encounter } from "../types.js";
|
import type { Combatant, Encounter } from "../types.js";
|
||||||
import { combatantId, isDomainError } from "../types.js";
|
import { combatantId, isDomainError } from "../types.js";
|
||||||
|
import { expectDomainError } from "./test-helpers.js";
|
||||||
|
|
||||||
function makeCombatant(
|
function makeCombatant(
|
||||||
name: string,
|
name: string,
|
||||||
@@ -101,37 +102,25 @@ describe("adjustHp", () => {
|
|||||||
it("returns error for nonexistent combatant", () => {
|
it("returns error for nonexistent combatant", () => {
|
||||||
const e = enc([makeCombatant("A", { maxHp: 20, currentHp: 10 })]);
|
const e = enc([makeCombatant("A", { maxHp: 20, currentHp: 10 })]);
|
||||||
const result = adjustHp(e, combatantId("Z"), -1);
|
const result = adjustHp(e, combatantId("Z"), -1);
|
||||||
expect(isDomainError(result)).toBe(true);
|
expectDomainError(result, "combatant-not-found");
|
||||||
if (isDomainError(result)) {
|
|
||||||
expect(result.code).toBe("combatant-not-found");
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns error when combatant has no HP tracking", () => {
|
it("returns error when combatant has no HP tracking", () => {
|
||||||
const e = enc([makeCombatant("A")]);
|
const e = enc([makeCombatant("A")]);
|
||||||
const result = adjustHp(e, combatantId("A"), -1);
|
const result = adjustHp(e, combatantId("A"), -1);
|
||||||
expect(isDomainError(result)).toBe(true);
|
expectDomainError(result, "no-hp-tracking");
|
||||||
if (isDomainError(result)) {
|
|
||||||
expect(result.code).toBe("no-hp-tracking");
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns error for zero delta", () => {
|
it("returns error for zero delta", () => {
|
||||||
const e = enc([makeCombatant("A", { maxHp: 20, currentHp: 10 })]);
|
const e = enc([makeCombatant("A", { maxHp: 20, currentHp: 10 })]);
|
||||||
const result = adjustHp(e, combatantId("A"), 0);
|
const result = adjustHp(e, combatantId("A"), 0);
|
||||||
expect(isDomainError(result)).toBe(true);
|
expectDomainError(result, "zero-delta");
|
||||||
if (isDomainError(result)) {
|
|
||||||
expect(result.code).toBe("zero-delta");
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns error for non-integer delta", () => {
|
it("returns error for non-integer delta", () => {
|
||||||
const e = enc([makeCombatant("A", { maxHp: 20, currentHp: 10 })]);
|
const e = enc([makeCombatant("A", { maxHp: 20, currentHp: 10 })]);
|
||||||
const result = adjustHp(e, combatantId("A"), 1.5);
|
const result = adjustHp(e, combatantId("A"), 1.5);
|
||||||
expect(isDomainError(result)).toBe(true);
|
expectDomainError(result, "invalid-delta");
|
||||||
if (isDomainError(result)) {
|
|
||||||
expect(result.code).toBe("invalid-delta");
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
createEncounter,
|
createEncounter,
|
||||||
type Encounter,
|
type Encounter,
|
||||||
} from "../types.js";
|
} from "../types.js";
|
||||||
|
import { expectDomainError } from "./test-helpers.js";
|
||||||
|
|
||||||
// --- Helpers ---
|
// --- Helpers ---
|
||||||
|
|
||||||
@@ -150,10 +151,7 @@ describe("advanceTurn", () => {
|
|||||||
};
|
};
|
||||||
const result = advanceTurn(enc);
|
const result = advanceTurn(enc);
|
||||||
|
|
||||||
expect(isDomainError(result)).toBe(true);
|
expectDomainError(result, "invalid-encounter");
|
||||||
if (isDomainError(result)) {
|
|
||||||
expect(result.code).toBe("invalid-encounter");
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("scenario 8: three advances on [A,B,C] completes a full round cycle", () => {
|
it("scenario 8: three advances on [A,B,C] completes a full round cycle", () => {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { createPlayerCharacter } from "../create-player-character.js";
|
|||||||
import type { PlayerCharacter } from "../player-character-types.js";
|
import type { PlayerCharacter } from "../player-character-types.js";
|
||||||
import { playerCharacterId } from "../player-character-types.js";
|
import { playerCharacterId } from "../player-character-types.js";
|
||||||
import { isDomainError } from "../types.js";
|
import { isDomainError } from "../types.js";
|
||||||
|
import { expectDomainError } from "./test-helpers.js";
|
||||||
|
|
||||||
const id = playerCharacterId("pc-1");
|
const id = playerCharacterId("pc-1");
|
||||||
|
|
||||||
@@ -80,10 +81,7 @@ describe("createPlayerCharacter", () => {
|
|||||||
|
|
||||||
it("rejects empty name", () => {
|
it("rejects empty name", () => {
|
||||||
const result = createPlayerCharacter([], id, "", 10, 50, "blue", "sword");
|
const result = createPlayerCharacter([], id, "", 10, 50, "blue", "sword");
|
||||||
expect(isDomainError(result)).toBe(true);
|
expectDomainError(result, "invalid-name");
|
||||||
if (isDomainError(result)) {
|
|
||||||
expect(result.code).toBe("invalid-name");
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects whitespace-only name", () => {
|
it("rejects whitespace-only name", () => {
|
||||||
@@ -96,10 +94,7 @@ describe("createPlayerCharacter", () => {
|
|||||||
"blue",
|
"blue",
|
||||||
"sword",
|
"sword",
|
||||||
);
|
);
|
||||||
expect(isDomainError(result)).toBe(true);
|
expectDomainError(result, "invalid-name");
|
||||||
if (isDomainError(result)) {
|
|
||||||
expect(result.code).toBe("invalid-name");
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects negative AC", () => {
|
it("rejects negative AC", () => {
|
||||||
@@ -112,10 +107,7 @@ describe("createPlayerCharacter", () => {
|
|||||||
"blue",
|
"blue",
|
||||||
"sword",
|
"sword",
|
||||||
);
|
);
|
||||||
expect(isDomainError(result)).toBe(true);
|
expectDomainError(result, "invalid-ac");
|
||||||
if (isDomainError(result)) {
|
|
||||||
expect(result.code).toBe("invalid-ac");
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects non-integer AC", () => {
|
it("rejects non-integer AC", () => {
|
||||||
@@ -128,10 +120,7 @@ describe("createPlayerCharacter", () => {
|
|||||||
"blue",
|
"blue",
|
||||||
"sword",
|
"sword",
|
||||||
);
|
);
|
||||||
expect(isDomainError(result)).toBe(true);
|
expectDomainError(result, "invalid-ac");
|
||||||
if (isDomainError(result)) {
|
|
||||||
expect(result.code).toBe("invalid-ac");
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("allows AC of 0", () => {
|
it("allows AC of 0", () => {
|
||||||
@@ -149,10 +138,7 @@ describe("createPlayerCharacter", () => {
|
|||||||
"blue",
|
"blue",
|
||||||
"sword",
|
"sword",
|
||||||
);
|
);
|
||||||
expect(isDomainError(result)).toBe(true);
|
expectDomainError(result, "invalid-max-hp");
|
||||||
if (isDomainError(result)) {
|
|
||||||
expect(result.code).toBe("invalid-max-hp");
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects negative maxHp", () => {
|
it("rejects negative maxHp", () => {
|
||||||
@@ -165,10 +151,7 @@ describe("createPlayerCharacter", () => {
|
|||||||
"blue",
|
"blue",
|
||||||
"sword",
|
"sword",
|
||||||
);
|
);
|
||||||
expect(isDomainError(result)).toBe(true);
|
expectDomainError(result, "invalid-max-hp");
|
||||||
if (isDomainError(result)) {
|
|
||||||
expect(result.code).toBe("invalid-max-hp");
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects non-integer maxHp", () => {
|
it("rejects non-integer maxHp", () => {
|
||||||
@@ -181,10 +164,7 @@ describe("createPlayerCharacter", () => {
|
|||||||
"blue",
|
"blue",
|
||||||
"sword",
|
"sword",
|
||||||
);
|
);
|
||||||
expect(isDomainError(result)).toBe(true);
|
expectDomainError(result, "invalid-max-hp");
|
||||||
if (isDomainError(result)) {
|
|
||||||
expect(result.code).toBe("invalid-max-hp");
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects invalid color", () => {
|
it("rejects invalid color", () => {
|
||||||
@@ -197,10 +177,7 @@ describe("createPlayerCharacter", () => {
|
|||||||
"neon",
|
"neon",
|
||||||
"sword",
|
"sword",
|
||||||
);
|
);
|
||||||
expect(isDomainError(result)).toBe(true);
|
expectDomainError(result, "invalid-color");
|
||||||
if (isDomainError(result)) {
|
|
||||||
expect(result.code).toBe("invalid-color");
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects invalid icon", () => {
|
it("rejects invalid icon", () => {
|
||||||
@@ -213,10 +190,7 @@ describe("createPlayerCharacter", () => {
|
|||||||
"blue",
|
"blue",
|
||||||
"banana",
|
"banana",
|
||||||
);
|
);
|
||||||
expect(isDomainError(result)).toBe(true);
|
expectDomainError(result, "invalid-icon");
|
||||||
if (isDomainError(result)) {
|
|
||||||
expect(result.code).toBe("invalid-icon");
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("allows undefined color", () => {
|
it("allows undefined color", () => {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { deletePlayerCharacter } from "../delete-player-character.js";
|
|||||||
import type { PlayerCharacter } from "../player-character-types.js";
|
import type { PlayerCharacter } from "../player-character-types.js";
|
||||||
import { playerCharacterId } from "../player-character-types.js";
|
import { playerCharacterId } from "../player-character-types.js";
|
||||||
import { isDomainError } from "../types.js";
|
import { isDomainError } from "../types.js";
|
||||||
|
import { expectDomainError } from "./test-helpers.js";
|
||||||
|
|
||||||
const id1 = playerCharacterId("pc-1");
|
const id1 = playerCharacterId("pc-1");
|
||||||
const id2 = playerCharacterId("pc-2");
|
const id2 = playerCharacterId("pc-2");
|
||||||
@@ -28,10 +29,7 @@ describe("deletePlayerCharacter", () => {
|
|||||||
|
|
||||||
it("returns error for not-found id", () => {
|
it("returns error for not-found id", () => {
|
||||||
const result = deletePlayerCharacter([makePC()], id2);
|
const result = deletePlayerCharacter([makePC()], id2);
|
||||||
expect(isDomainError(result)).toBe(true);
|
expectDomainError(result, "player-character-not-found");
|
||||||
if (isDomainError(result)) {
|
|
||||||
expect(result.code).toBe("player-character-not-found");
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("emits PlayerCharacterDeleted event", () => {
|
it("emits PlayerCharacterDeleted event", () => {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
|
|||||||
import { editCombatant } from "../edit-combatant.js";
|
import { editCombatant } from "../edit-combatant.js";
|
||||||
import type { Combatant, Encounter } from "../types.js";
|
import type { Combatant, Encounter } from "../types.js";
|
||||||
import { combatantId, isDomainError } from "../types.js";
|
import { combatantId, isDomainError } from "../types.js";
|
||||||
|
import { expectDomainError } from "./test-helpers.js";
|
||||||
|
|
||||||
// --- Helpers ---
|
// --- Helpers ---
|
||||||
|
|
||||||
@@ -124,40 +125,28 @@ describe("editCombatant", () => {
|
|||||||
const e = enc([Alice, Bob]);
|
const e = enc([Alice, Bob]);
|
||||||
const result = editCombatant(e, combatantId("nonexistent"), "NewName");
|
const result = editCombatant(e, combatantId("nonexistent"), "NewName");
|
||||||
|
|
||||||
expect(isDomainError(result)).toBe(true);
|
expectDomainError(result, "combatant-not-found");
|
||||||
if (isDomainError(result)) {
|
|
||||||
expect(result.code).toBe("combatant-not-found");
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("empty name returns invalid-name error", () => {
|
it("empty name returns invalid-name error", () => {
|
||||||
const e = enc([Alice, Bob]);
|
const e = enc([Alice, Bob]);
|
||||||
const result = editCombatant(e, combatantId("Alice"), "");
|
const result = editCombatant(e, combatantId("Alice"), "");
|
||||||
|
|
||||||
expect(isDomainError(result)).toBe(true);
|
expectDomainError(result, "invalid-name");
|
||||||
if (isDomainError(result)) {
|
|
||||||
expect(result.code).toBe("invalid-name");
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("whitespace-only name returns invalid-name error", () => {
|
it("whitespace-only name returns invalid-name error", () => {
|
||||||
const e = enc([Alice, Bob]);
|
const e = enc([Alice, Bob]);
|
||||||
const result = editCombatant(e, combatantId("Alice"), " ");
|
const result = editCombatant(e, combatantId("Alice"), " ");
|
||||||
|
|
||||||
expect(isDomainError(result)).toBe(true);
|
expectDomainError(result, "invalid-name");
|
||||||
if (isDomainError(result)) {
|
|
||||||
expect(result.code).toBe("invalid-name");
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("empty encounter returns combatant-not-found for any id", () => {
|
it("empty encounter returns combatant-not-found for any id", () => {
|
||||||
const e = enc([]);
|
const e = enc([]);
|
||||||
const result = editCombatant(e, combatantId("any"), "Name");
|
const result = editCombatant(e, combatantId("any"), "Name");
|
||||||
|
|
||||||
expect(isDomainError(result)).toBe(true);
|
expectDomainError(result, "combatant-not-found");
|
||||||
if (isDomainError(result)) {
|
|
||||||
expect(result.code).toBe("combatant-not-found");
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { editPlayerCharacter } from "../edit-player-character.js";
|
|||||||
import type { PlayerCharacter } from "../player-character-types.js";
|
import type { PlayerCharacter } from "../player-character-types.js";
|
||||||
import { playerCharacterId } from "../player-character-types.js";
|
import { playerCharacterId } from "../player-character-types.js";
|
||||||
import { isDomainError } from "../types.js";
|
import { isDomainError } from "../types.js";
|
||||||
|
import { expectDomainError } from "./test-helpers.js";
|
||||||
|
|
||||||
const id = playerCharacterId("pc-1");
|
const id = playerCharacterId("pc-1");
|
||||||
|
|
||||||
@@ -42,50 +43,32 @@ describe("editPlayerCharacter", () => {
|
|||||||
playerCharacterId("pc-999"),
|
playerCharacterId("pc-999"),
|
||||||
{ name: "Nope" },
|
{ name: "Nope" },
|
||||||
);
|
);
|
||||||
expect(isDomainError(result)).toBe(true);
|
expectDomainError(result, "player-character-not-found");
|
||||||
if (isDomainError(result)) {
|
|
||||||
expect(result.code).toBe("player-character-not-found");
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects empty name", () => {
|
it("rejects empty name", () => {
|
||||||
const result = editPlayerCharacter([makePC()], id, { name: "" });
|
const result = editPlayerCharacter([makePC()], id, { name: "" });
|
||||||
expect(isDomainError(result)).toBe(true);
|
expectDomainError(result, "invalid-name");
|
||||||
if (isDomainError(result)) {
|
|
||||||
expect(result.code).toBe("invalid-name");
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects invalid AC", () => {
|
it("rejects invalid AC", () => {
|
||||||
const result = editPlayerCharacter([makePC()], id, { ac: -1 });
|
const result = editPlayerCharacter([makePC()], id, { ac: -1 });
|
||||||
expect(isDomainError(result)).toBe(true);
|
expectDomainError(result, "invalid-ac");
|
||||||
if (isDomainError(result)) {
|
|
||||||
expect(result.code).toBe("invalid-ac");
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects invalid maxHp", () => {
|
it("rejects invalid maxHp", () => {
|
||||||
const result = editPlayerCharacter([makePC()], id, { maxHp: 0 });
|
const result = editPlayerCharacter([makePC()], id, { maxHp: 0 });
|
||||||
expect(isDomainError(result)).toBe(true);
|
expectDomainError(result, "invalid-max-hp");
|
||||||
if (isDomainError(result)) {
|
|
||||||
expect(result.code).toBe("invalid-max-hp");
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects invalid color", () => {
|
it("rejects invalid color", () => {
|
||||||
const result = editPlayerCharacter([makePC()], id, { color: "neon" });
|
const result = editPlayerCharacter([makePC()], id, { color: "neon" });
|
||||||
expect(isDomainError(result)).toBe(true);
|
expectDomainError(result, "invalid-color");
|
||||||
if (isDomainError(result)) {
|
|
||||||
expect(result.code).toBe("invalid-color");
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects invalid icon", () => {
|
it("rejects invalid icon", () => {
|
||||||
const result = editPlayerCharacter([makePC()], id, { icon: "banana" });
|
const result = editPlayerCharacter([makePC()], id, { icon: "banana" });
|
||||||
expect(isDomainError(result)).toBe(true);
|
expectDomainError(result, "invalid-icon");
|
||||||
if (isDomainError(result)) {
|
|
||||||
expect(result.code).toBe("invalid-icon");
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns error when no fields changed", () => {
|
it("returns error when no fields changed", () => {
|
||||||
@@ -94,10 +77,7 @@ describe("editPlayerCharacter", () => {
|
|||||||
name: pc.name,
|
name: pc.name,
|
||||||
ac: pc.ac,
|
ac: pc.ac,
|
||||||
});
|
});
|
||||||
expect(isDomainError(result)).toBe(true);
|
expectDomainError(result, "no-changes");
|
||||||
if (isDomainError(result)) {
|
|
||||||
expect(result.code).toBe("no-changes");
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("emits exactly one event on success", () => {
|
it("emits exactly one event on success", () => {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
|
|||||||
import { removeCombatant } from "../remove-combatant.js";
|
import { removeCombatant } from "../remove-combatant.js";
|
||||||
import type { Combatant, Encounter } from "../types.js";
|
import type { Combatant, Encounter } from "../types.js";
|
||||||
import { combatantId, isDomainError } from "../types.js";
|
import { combatantId, isDomainError } from "../types.js";
|
||||||
|
import { expectDomainError } from "./test-helpers.js";
|
||||||
|
|
||||||
// --- Helpers ---
|
// --- Helpers ---
|
||||||
|
|
||||||
@@ -92,10 +93,7 @@ describe("removeCombatant", () => {
|
|||||||
const e = enc([A, B], 0, 1);
|
const e = enc([A, B], 0, 1);
|
||||||
const result = removeCombatant(e, combatantId("nonexistent"));
|
const result = removeCombatant(e, combatantId("nonexistent"));
|
||||||
|
|
||||||
expect(isDomainError(result)).toBe(true);
|
expectDomainError(result, "combatant-not-found");
|
||||||
if (isDomainError(result)) {
|
|
||||||
expect(result.code).toBe("combatant-not-found");
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
type Encounter,
|
type Encounter,
|
||||||
isDomainError,
|
isDomainError,
|
||||||
} from "../types.js";
|
} from "../types.js";
|
||||||
|
import { expectDomainError } from "./test-helpers.js";
|
||||||
|
|
||||||
// --- Helpers ---
|
// --- Helpers ---
|
||||||
|
|
||||||
@@ -83,10 +84,7 @@ describe("retreatTurn", () => {
|
|||||||
const enc = encounter([A, B, C], 0, 1);
|
const enc = encounter([A, B, C], 0, 1);
|
||||||
const result = retreatTurn(enc);
|
const result = retreatTurn(enc);
|
||||||
|
|
||||||
expect(isDomainError(result)).toBe(true);
|
expectDomainError(result, "no-previous-turn");
|
||||||
if (isDomainError(result)) {
|
|
||||||
expect(result.code).toBe("no-previous-turn");
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("scenario 4: single-combatant retreat — wraps to same combatant, decrements round", () => {
|
it("scenario 4: single-combatant retreat — wraps to same combatant, decrements round", () => {
|
||||||
@@ -117,10 +115,7 @@ describe("retreatTurn", () => {
|
|||||||
};
|
};
|
||||||
const result = retreatTurn(enc);
|
const result = retreatTurn(enc);
|
||||||
|
|
||||||
expect(isDomainError(result)).toBe(true);
|
expectDomainError(result, "invalid-encounter");
|
||||||
if (isDomainError(result)) {
|
|
||||||
expect(result.code).toBe("invalid-encounter");
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { rollInitiative } from "../roll-initiative.js";
|
import { rollInitiative } from "../roll-initiative.js";
|
||||||
import { isDomainError } from "../types.js";
|
import { isDomainError } from "../types.js";
|
||||||
|
import { expectDomainError } from "./test-helpers.js";
|
||||||
|
|
||||||
describe("rollInitiative", () => {
|
describe("rollInitiative", () => {
|
||||||
describe("valid rolls", () => {
|
describe("valid rolls", () => {
|
||||||
@@ -32,18 +33,12 @@ describe("rollInitiative", () => {
|
|||||||
describe("invalid dice rolls", () => {
|
describe("invalid dice rolls", () => {
|
||||||
it("rejects 0", () => {
|
it("rejects 0", () => {
|
||||||
const result = rollInitiative(0, 5);
|
const result = rollInitiative(0, 5);
|
||||||
expect(isDomainError(result)).toBe(true);
|
expectDomainError(result, "invalid-dice-roll");
|
||||||
if (isDomainError(result)) {
|
|
||||||
expect(result.code).toBe("invalid-dice-roll");
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects 21", () => {
|
it("rejects 21", () => {
|
||||||
const result = rollInitiative(21, 5);
|
const result = rollInitiative(21, 5);
|
||||||
expect(isDomainError(result)).toBe(true);
|
expectDomainError(result, "invalid-dice-roll");
|
||||||
if (isDomainError(result)) {
|
|
||||||
expect(result.code).toBe("invalid-dice-roll");
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects non-integer (3.5)", () => {
|
it("rejects non-integer (3.5)", () => {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
|
|||||||
import { setAc } from "../set-ac.js";
|
import { setAc } from "../set-ac.js";
|
||||||
import type { Combatant, Encounter } from "../types.js";
|
import type { Combatant, Encounter } from "../types.js";
|
||||||
import { combatantId, isDomainError } from "../types.js";
|
import { combatantId, isDomainError } from "../types.js";
|
||||||
|
import { expectDomainError } from "./test-helpers.js";
|
||||||
|
|
||||||
function makeCombatant(name: string, ac?: number): Combatant {
|
function makeCombatant(name: string, ac?: number): Combatant {
|
||||||
return ac === undefined
|
return ac === undefined
|
||||||
@@ -67,30 +68,21 @@ describe("setAc", () => {
|
|||||||
const e = enc([makeCombatant("A")]);
|
const e = enc([makeCombatant("A")]);
|
||||||
const result = setAc(e, combatantId("nonexistent"), 10);
|
const result = setAc(e, combatantId("nonexistent"), 10);
|
||||||
|
|
||||||
expect(isDomainError(result)).toBe(true);
|
expectDomainError(result, "combatant-not-found");
|
||||||
if (isDomainError(result)) {
|
|
||||||
expect(result.code).toBe("combatant-not-found");
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns error for negative AC", () => {
|
it("returns error for negative AC", () => {
|
||||||
const e = enc([makeCombatant("A")]);
|
const e = enc([makeCombatant("A")]);
|
||||||
const result = setAc(e, combatantId("A"), -1);
|
const result = setAc(e, combatantId("A"), -1);
|
||||||
|
|
||||||
expect(isDomainError(result)).toBe(true);
|
expectDomainError(result, "invalid-ac");
|
||||||
if (isDomainError(result)) {
|
|
||||||
expect(result.code).toBe("invalid-ac");
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns error for non-integer AC", () => {
|
it("returns error for non-integer AC", () => {
|
||||||
const e = enc([makeCombatant("A")]);
|
const e = enc([makeCombatant("A")]);
|
||||||
const result = setAc(e, combatantId("A"), 3.5);
|
const result = setAc(e, combatantId("A"), 3.5);
|
||||||
|
|
||||||
expect(isDomainError(result)).toBe(true);
|
expectDomainError(result, "invalid-ac");
|
||||||
if (isDomainError(result)) {
|
|
||||||
expect(result.code).toBe("invalid-ac");
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns error for NaN", () => {
|
it("returns error for NaN", () => {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
|
|||||||
import { setHp } from "../set-hp.js";
|
import { setHp } from "../set-hp.js";
|
||||||
import type { Combatant, Encounter } from "../types.js";
|
import type { Combatant, Encounter } from "../types.js";
|
||||||
import { combatantId, isDomainError } from "../types.js";
|
import { combatantId, isDomainError } from "../types.js";
|
||||||
|
import { expectDomainError } from "./test-helpers.js";
|
||||||
|
|
||||||
function makeCombatant(
|
function makeCombatant(
|
||||||
name: string,
|
name: string,
|
||||||
@@ -10,9 +11,9 @@ function makeCombatant(
|
|||||||
return {
|
return {
|
||||||
id: combatantId(name),
|
id: combatantId(name),
|
||||||
name,
|
name,
|
||||||
...(opts?.maxHp !== undefined
|
...(opts?.maxHp === undefined
|
||||||
? { maxHp: opts.maxHp, currentHp: opts.currentHp ?? opts.maxHp }
|
? {}
|
||||||
: {}),
|
: { maxHp: opts.maxHp, currentHp: opts.currentHp ?? opts.maxHp }),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,37 +117,25 @@ describe("setHp", () => {
|
|||||||
it("returns error for nonexistent combatant", () => {
|
it("returns error for nonexistent combatant", () => {
|
||||||
const e = enc([makeCombatant("A")]);
|
const e = enc([makeCombatant("A")]);
|
||||||
const result = setHp(e, combatantId("Z"), 10);
|
const result = setHp(e, combatantId("Z"), 10);
|
||||||
expect(isDomainError(result)).toBe(true);
|
expectDomainError(result, "combatant-not-found");
|
||||||
if (isDomainError(result)) {
|
|
||||||
expect(result.code).toBe("combatant-not-found");
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects maxHp of 0", () => {
|
it("rejects maxHp of 0", () => {
|
||||||
const e = enc([makeCombatant("A")]);
|
const e = enc([makeCombatant("A")]);
|
||||||
const result = setHp(e, combatantId("A"), 0);
|
const result = setHp(e, combatantId("A"), 0);
|
||||||
expect(isDomainError(result)).toBe(true);
|
expectDomainError(result, "invalid-max-hp");
|
||||||
if (isDomainError(result)) {
|
|
||||||
expect(result.code).toBe("invalid-max-hp");
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects negative maxHp", () => {
|
it("rejects negative maxHp", () => {
|
||||||
const e = enc([makeCombatant("A")]);
|
const e = enc([makeCombatant("A")]);
|
||||||
const result = setHp(e, combatantId("A"), -5);
|
const result = setHp(e, combatantId("A"), -5);
|
||||||
expect(isDomainError(result)).toBe(true);
|
expectDomainError(result, "invalid-max-hp");
|
||||||
if (isDomainError(result)) {
|
|
||||||
expect(result.code).toBe("invalid-max-hp");
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects non-integer maxHp", () => {
|
it("rejects non-integer maxHp", () => {
|
||||||
const e = enc([makeCombatant("A")]);
|
const e = enc([makeCombatant("A")]);
|
||||||
const result = setHp(e, combatantId("A"), 3.5);
|
const result = setHp(e, combatantId("A"), 3.5);
|
||||||
expect(isDomainError(result)).toBe(true);
|
expectDomainError(result, "invalid-max-hp");
|
||||||
if (isDomainError(result)) {
|
|
||||||
expect(result.code).toBe("invalid-max-hp");
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
|
|||||||
import { setInitiative } from "../set-initiative.js";
|
import { setInitiative } from "../set-initiative.js";
|
||||||
import type { Combatant, Encounter } from "../types.js";
|
import type { Combatant, Encounter } from "../types.js";
|
||||||
import { combatantId, isDomainError } from "../types.js";
|
import { combatantId, isDomainError } from "../types.js";
|
||||||
|
import { expectDomainError } from "./test-helpers.js";
|
||||||
|
|
||||||
// --- Helpers ---
|
// --- Helpers ---
|
||||||
|
|
||||||
@@ -73,10 +74,7 @@ describe("setInitiative", () => {
|
|||||||
const e = enc([A, B], 0);
|
const e = enc([A, B], 0);
|
||||||
const result = setInitiative(e, combatantId("A"), 3.5);
|
const result = setInitiative(e, combatantId("A"), 3.5);
|
||||||
|
|
||||||
expect(isDomainError(result)).toBe(true);
|
expectDomainError(result, "invalid-initiative");
|
||||||
if (isDomainError(result)) {
|
|
||||||
expect(result.code).toBe("invalid-initiative");
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("AS-3b: reject NaN", () => {
|
it("AS-3b: reject NaN", () => {
|
||||||
@@ -109,10 +107,7 @@ describe("setInitiative", () => {
|
|||||||
const e = enc([A, B], 0);
|
const e = enc([A, B], 0);
|
||||||
const result = setInitiative(e, combatantId("nonexistent"), 10);
|
const result = setInitiative(e, combatantId("nonexistent"), 10);
|
||||||
|
|
||||||
expect(isDomainError(result)).toBe(true);
|
expectDomainError(result, "combatant-not-found");
|
||||||
if (isDomainError(result)) {
|
|
||||||
expect(result.code).toBe("combatant-not-found");
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
9
packages/domain/src/__tests__/test-helpers.ts
Normal file
9
packages/domain/src/__tests__/test-helpers.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { expect } from "vitest";
|
||||||
|
import { type DomainError, isDomainError } from "../types.js";
|
||||||
|
|
||||||
|
export function expectDomainError(result: unknown, code: string): DomainError {
|
||||||
|
expect(isDomainError(result)).toBe(true);
|
||||||
|
if (!isDomainError(result)) throw new Error("unreachable");
|
||||||
|
expect(result.code).toBe(code);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
|
|||||||
import { toggleConcentration } from "../toggle-concentration.js";
|
import { toggleConcentration } from "../toggle-concentration.js";
|
||||||
import type { Combatant, Encounter } from "../types.js";
|
import type { Combatant, Encounter } from "../types.js";
|
||||||
import { combatantId, isDomainError } from "../types.js";
|
import { combatantId, isDomainError } from "../types.js";
|
||||||
|
import { expectDomainError } from "./test-helpers.js";
|
||||||
|
|
||||||
function makeCombatant(name: string, isConcentrating?: boolean): Combatant {
|
function makeCombatant(name: string, isConcentrating?: boolean): Combatant {
|
||||||
return isConcentrating
|
return isConcentrating
|
||||||
@@ -46,10 +47,7 @@ describe("toggleConcentration", () => {
|
|||||||
const e = enc([makeCombatant("A")]);
|
const e = enc([makeCombatant("A")]);
|
||||||
const result = toggleConcentration(e, combatantId("missing"));
|
const result = toggleConcentration(e, combatantId("missing"));
|
||||||
|
|
||||||
expect(isDomainError(result)).toBe(true);
|
expectDomainError(result, "combatant-not-found");
|
||||||
if (isDomainError(result)) {
|
|
||||||
expect(result.code).toBe("combatant-not-found");
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not mutate input encounter", () => {
|
it("does not mutate input encounter", () => {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { CONDITION_DEFINITIONS } from "../conditions.js";
|
|||||||
import { toggleCondition } from "../toggle-condition.js";
|
import { toggleCondition } from "../toggle-condition.js";
|
||||||
import type { Combatant, Encounter } from "../types.js";
|
import type { Combatant, Encounter } from "../types.js";
|
||||||
import { combatantId, isDomainError } from "../types.js";
|
import { combatantId, isDomainError } from "../types.js";
|
||||||
|
import { expectDomainError } from "./test-helpers.js";
|
||||||
|
|
||||||
function makeCombatant(
|
function makeCombatant(
|
||||||
name: string,
|
name: string,
|
||||||
@@ -77,20 +78,14 @@ describe("toggleCondition", () => {
|
|||||||
"flying" as ConditionId,
|
"flying" as ConditionId,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(isDomainError(result)).toBe(true);
|
expectDomainError(result, "unknown-condition");
|
||||||
if (isDomainError(result)) {
|
|
||||||
expect(result.code).toBe("unknown-condition");
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns error for nonexistent combatant", () => {
|
it("returns error for nonexistent combatant", () => {
|
||||||
const e = enc([makeCombatant("A")]);
|
const e = enc([makeCombatant("A")]);
|
||||||
const result = toggleCondition(e, combatantId("missing"), "blinded");
|
const result = toggleCondition(e, combatantId("missing"), "blinded");
|
||||||
|
|
||||||
expect(isDomainError(result)).toBe(true);
|
expectDomainError(result, "combatant-not-found");
|
||||||
if (isDomainError(result)) {
|
|
||||||
expect(result.code).toBe("combatant-not-found");
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not mutate input encounter", () => {
|
it("does not mutate input encounter", () => {
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import type { DomainEvent } from "./events.js";
|
import type { DomainEvent } from "./events.js";
|
||||||
import type { DomainError, Encounter } from "./types.js";
|
import type { DomainError, Encounter } from "./types.js";
|
||||||
import { isDomainError } from "./types.js";
|
|
||||||
|
|
||||||
interface AdvanceTurnSuccess {
|
interface AdvanceTurnSuccess {
|
||||||
readonly encounter: Encounter;
|
readonly encounter: Encounter;
|
||||||
@@ -62,4 +61,4 @@ export function advanceTurn(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export { isDomainError };
|
export { isDomainError } from "./types.js";
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ export function resolveCreatureName(
|
|||||||
exactMatches.push(i);
|
exactMatches.push(i);
|
||||||
} else {
|
} else {
|
||||||
const match = new RegExp(`^${escapeRegExp(baseName)} (\\d+)$`).exec(name);
|
const match = new RegExp(`^${escapeRegExp(baseName)} (\\d+)$`).exec(name);
|
||||||
|
// biome-ignore lint/nursery/noUnnecessaryConditions: RegExp.exec() returns null on no match — false positive
|
||||||
if (match) {
|
if (match) {
|
||||||
const num = Number.parseInt(match[1], 10);
|
const num = Number.parseInt(match[1], 10);
|
||||||
if (num > maxNumber) maxNumber = num;
|
if (num > maxNumber) maxNumber = num;
|
||||||
|
|||||||
@@ -81,17 +81,17 @@ function applyFields(
|
|||||||
): PlayerCharacter {
|
): PlayerCharacter {
|
||||||
return {
|
return {
|
||||||
id: existing.id,
|
id: existing.id,
|
||||||
name: fields.name !== undefined ? fields.name.trim() : existing.name,
|
name: fields.name === undefined ? existing.name : fields.name.trim(),
|
||||||
ac: fields.ac !== undefined ? fields.ac : existing.ac,
|
ac: fields.ac === undefined ? existing.ac : fields.ac,
|
||||||
maxHp: fields.maxHp !== undefined ? fields.maxHp : existing.maxHp,
|
maxHp: fields.maxHp === undefined ? existing.maxHp : fields.maxHp,
|
||||||
color:
|
color:
|
||||||
fields.color !== undefined
|
fields.color === undefined
|
||||||
? ((fields.color as PlayerCharacter["color"]) ?? undefined)
|
? existing.color
|
||||||
: existing.color,
|
: ((fields.color as PlayerCharacter["color"]) ?? undefined),
|
||||||
icon:
|
icon:
|
||||||
fields.icon !== undefined
|
fields.icon === undefined
|
||||||
? ((fields.icon as PlayerCharacter["icon"]) ?? undefined)
|
? existing.icon
|
||||||
: existing.icon,
|
: ((fields.icon as PlayerCharacter["icon"]) ?? undefined),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,14 +21,12 @@ export function setAc(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (value !== undefined) {
|
if (value !== undefined && (!Number.isInteger(value) || value < 0)) {
|
||||||
if (!Number.isInteger(value) || value < 0) {
|
return {
|
||||||
return {
|
kind: "domain-error",
|
||||||
kind: "domain-error",
|
code: "invalid-ac",
|
||||||
code: "invalid-ac",
|
message: `AC must be a non-negative integer, got ${value}`,
|
||||||
message: `AC must be a non-negative integer, got ${value}`,
|
};
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const target = encounter.combatants[targetIdx];
|
const target = encounter.combatants[targetIdx];
|
||||||
|
|||||||
@@ -28,14 +28,12 @@ export function setHp(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (maxHp !== undefined) {
|
if (maxHp !== undefined && (!Number.isInteger(maxHp) || maxHp < 1)) {
|
||||||
if (!Number.isInteger(maxHp) || maxHp < 1) {
|
return {
|
||||||
return {
|
kind: "domain-error",
|
||||||
kind: "domain-error",
|
code: "invalid-max-hp",
|
||||||
code: "invalid-max-hp",
|
message: `Max HP must be a positive integer, got ${maxHp}`,
|
||||||
message: `Max HP must be a positive integer, got ${maxHp}`,
|
};
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const target = encounter.combatants[targetIdx];
|
const target = encounter.combatants[targetIdx];
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ export function setInitiative(
|
|||||||
const aInit = a.c.initiative as number;
|
const aInit = a.c.initiative as number;
|
||||||
const bInit = b.c.initiative as number;
|
const bInit = b.c.initiative as number;
|
||||||
const diff = bInit - aInit;
|
const diff = bInit - aInit;
|
||||||
return diff !== 0 ? diff : a.i - b.i;
|
return diff === 0 ? a.i - b.i : diff;
|
||||||
}
|
}
|
||||||
if (aHas && !bHas) return -1;
|
if (aHas && !bHas) return -1;
|
||||||
if (!aHas && bHas) return 1;
|
if (!aHas && bHas) return 1;
|
||||||
|
|||||||
74
pnpm-lock.yaml
generated
74
pnpm-lock.yaml
generated
@@ -12,8 +12,8 @@ importers:
|
|||||||
.:
|
.:
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@biomejs/biome':
|
'@biomejs/biome':
|
||||||
specifier: 2.0.0
|
specifier: 2.4.7
|
||||||
version: 2.0.0
|
version: 2.4.7
|
||||||
'@vitest/coverage-v8':
|
'@vitest/coverage-v8':
|
||||||
specifier: ^3.2.4
|
specifier: ^3.2.4
|
||||||
version: 3.2.4(vitest@3.2.4(@types/node@25.3.3)(jiti@2.6.1)(jsdom@28.1.0)(lightningcss@1.31.1))
|
version: 3.2.4(vitest@3.2.4(@types/node@25.3.3)(jiti@2.6.1)(jsdom@28.1.0)(lightningcss@1.31.1))
|
||||||
@@ -212,55 +212,55 @@ packages:
|
|||||||
resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==}
|
resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
'@biomejs/biome@2.0.0':
|
'@biomejs/biome@2.4.7':
|
||||||
resolution: {integrity: sha512-BlUoXEOI/UQTDEj/pVfnkMo8SrZw3oOWBDrXYFT43V7HTkIUDkBRY53IC5Jx1QkZbaB+0ai1wJIfYwp9+qaJTQ==}
|
resolution: {integrity: sha512-vXrgcmNGZ4lpdwZSpMf1hWw1aWS6B+SyeSYKTLrNsiUsAdSRN0J4d/7mF3ogJFbIwFFSOL3wT92Zzxia/d5/ng==}
|
||||||
engines: {node: '>=14.21.3'}
|
engines: {node: '>=14.21.3'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
'@biomejs/cli-darwin-arm64@2.0.0':
|
'@biomejs/cli-darwin-arm64@2.4.7':
|
||||||
resolution: {integrity: sha512-QvqWYtFFhhxdf8jMAdJzXW+Frc7X8XsnHQLY+TBM1fnT1TfeV/v9vsFI5L2J7GH6qN1+QEEJ19jHibCY2Ypplw==}
|
resolution: {integrity: sha512-Oo0cF5mHzmvDmTXw8XSjhCia8K6YrZnk7aCS54+/HxyMdZMruMO3nfpDsrlar/EQWe41r1qrwKiCa2QDYHDzWA==}
|
||||||
engines: {node: '>=14.21.3'}
|
engines: {node: '>=14.21.3'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [darwin]
|
os: [darwin]
|
||||||
|
|
||||||
'@biomejs/cli-darwin-x64@2.0.0':
|
'@biomejs/cli-darwin-x64@2.4.7':
|
||||||
resolution: {integrity: sha512-5JFhls1EfmuIH4QGFPlNpxJQFC6ic3X1ltcoLN+eSRRIPr6H/lUS1ttuD0Fj7rPgPhZqopK/jfH8UVj/1hIsQw==}
|
resolution: {integrity: sha512-I+cOG3sd/7HdFtvDSnF9QQPrWguUH7zrkIMMykM3PtfWU9soTcS2yRb9Myq6MHmzbeCT08D1UmY+BaiMl5CcoQ==}
|
||||||
engines: {node: '>=14.21.3'}
|
engines: {node: '>=14.21.3'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [darwin]
|
os: [darwin]
|
||||||
|
|
||||||
'@biomejs/cli-linux-arm64-musl@2.0.0':
|
'@biomejs/cli-linux-arm64-musl@2.4.7':
|
||||||
resolution: {integrity: sha512-Bxsz8ki8+b3PytMnS5SgrGV+mbAWwIxI3ydChb/d1rURlJTMdxTTq5LTebUnlsUWAX6OvJuFeiVq9Gjn1YbCyA==}
|
resolution: {integrity: sha512-I2NvM9KPb09jWml93O2/5WMfNR7Lee5Latag1JThDRMURVhPX74p9UDnyTw3Ae6cE1DgXfw7sqQgX7rkvpc0vw==}
|
||||||
engines: {node: '>=14.21.3'}
|
engines: {node: '>=14.21.3'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
|
||||||
'@biomejs/cli-linux-arm64@2.0.0':
|
'@biomejs/cli-linux-arm64@2.4.7':
|
||||||
resolution: {integrity: sha512-BAH4QVi06TzAbVchXdJPsL0Z/P87jOfes15rI+p3EX9/EGTfIjaQ9lBVlHunxcmoptaA5y1Hdb9UYojIhmnjIw==}
|
resolution: {integrity: sha512-om6FugwmibzfP/6ALj5WRDVSND4H2G9X0nkI1HZpp2ySf9lW2j0X68oQSaHEnls6666oy4KDsc5RFjT4m0kV0w==}
|
||||||
engines: {node: '>=14.21.3'}
|
engines: {node: '>=14.21.3'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
|
||||||
'@biomejs/cli-linux-x64-musl@2.0.0':
|
'@biomejs/cli-linux-x64-musl@2.4.7':
|
||||||
resolution: {integrity: sha512-tiQ0ABxMJb9I6GlfNp0ulrTiQSFacJRJO8245FFwE3ty3bfsfxlU/miblzDIi+qNrgGsLq5wIZcVYGp4c+HXZA==}
|
resolution: {integrity: sha512-00kx4YrBMU8374zd2wHuRV5wseh0rom5HqRND+vDldJPrWwQw+mzd/d8byI9hPx926CG+vWzq6AeiT7Yi5y59g==}
|
||||||
engines: {node: '>=14.21.3'}
|
engines: {node: '>=14.21.3'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
|
||||||
'@biomejs/cli-linux-x64@2.0.0':
|
'@biomejs/cli-linux-x64@2.4.7':
|
||||||
resolution: {integrity: sha512-09PcOGYTtkopWRm6mZ/B6Mr6UHdkniUgIG/jLBv+2J8Z61ezRE+xQmpi3yNgUrFIAU4lPA9atg7mhvE/5Bo7Wg==}
|
resolution: {integrity: sha512-bV8/uo2Tj+gumnk4sUdkerWyCPRabaZdv88IpbmDWARQQoA/Q0YaqPz1a+LSEDIL7OfrnPi9Hq1Llz4ZIGyIQQ==}
|
||||||
engines: {node: '>=14.21.3'}
|
engines: {node: '>=14.21.3'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
|
||||||
'@biomejs/cli-win32-arm64@2.0.0':
|
'@biomejs/cli-win32-arm64@2.4.7':
|
||||||
resolution: {integrity: sha512-vrTtuGu91xNTEQ5ZcMJBZuDlqr32DWU1r14UfePIGndF//s2WUAmer4FmgoPgruo76rprk37e8S2A2c0psXdxw==}
|
resolution: {integrity: sha512-hOUHBMlFCvDhu3WCq6vaBoG0dp0LkWxSEnEEsxxXvOa9TfT6ZBnbh72A/xBM7CBYB7WgwqboetzFEVDnMxelyw==}
|
||||||
engines: {node: '>=14.21.3'}
|
engines: {node: '>=14.21.3'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [win32]
|
os: [win32]
|
||||||
|
|
||||||
'@biomejs/cli-win32-x64@2.0.0':
|
'@biomejs/cli-win32-x64@2.4.7':
|
||||||
resolution: {integrity: sha512-2USVQ0hklNsph/KIR72ZdeptyXNnQ3JdzPn3NbjI4Sna34CnxeiYAaZcZzXPDl5PYNFBivV4xmvT3Z3rTmyDBg==}
|
resolution: {integrity: sha512-qEpGjSkPC3qX4ycbMUthXvi9CkRq7kZpkqMY1OyhmYlYLnANnooDQ7hDerM8+0NJ+DZKVnsIc07h30XOpt7LtQ==}
|
||||||
engines: {node: '>=14.21.3'}
|
engines: {node: '>=14.21.3'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [win32]
|
os: [win32]
|
||||||
@@ -2308,39 +2308,39 @@ snapshots:
|
|||||||
|
|
||||||
'@bcoe/v8-coverage@1.0.2': {}
|
'@bcoe/v8-coverage@1.0.2': {}
|
||||||
|
|
||||||
'@biomejs/biome@2.0.0':
|
'@biomejs/biome@2.4.7':
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@biomejs/cli-darwin-arm64': 2.0.0
|
'@biomejs/cli-darwin-arm64': 2.4.7
|
||||||
'@biomejs/cli-darwin-x64': 2.0.0
|
'@biomejs/cli-darwin-x64': 2.4.7
|
||||||
'@biomejs/cli-linux-arm64': 2.0.0
|
'@biomejs/cli-linux-arm64': 2.4.7
|
||||||
'@biomejs/cli-linux-arm64-musl': 2.0.0
|
'@biomejs/cli-linux-arm64-musl': 2.4.7
|
||||||
'@biomejs/cli-linux-x64': 2.0.0
|
'@biomejs/cli-linux-x64': 2.4.7
|
||||||
'@biomejs/cli-linux-x64-musl': 2.0.0
|
'@biomejs/cli-linux-x64-musl': 2.4.7
|
||||||
'@biomejs/cli-win32-arm64': 2.0.0
|
'@biomejs/cli-win32-arm64': 2.4.7
|
||||||
'@biomejs/cli-win32-x64': 2.0.0
|
'@biomejs/cli-win32-x64': 2.4.7
|
||||||
|
|
||||||
'@biomejs/cli-darwin-arm64@2.0.0':
|
'@biomejs/cli-darwin-arm64@2.4.7':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@biomejs/cli-darwin-x64@2.0.0':
|
'@biomejs/cli-darwin-x64@2.4.7':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@biomejs/cli-linux-arm64-musl@2.0.0':
|
'@biomejs/cli-linux-arm64-musl@2.4.7':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@biomejs/cli-linux-arm64@2.0.0':
|
'@biomejs/cli-linux-arm64@2.4.7':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@biomejs/cli-linux-x64-musl@2.0.0':
|
'@biomejs/cli-linux-x64-musl@2.4.7':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@biomejs/cli-linux-x64@2.0.0':
|
'@biomejs/cli-linux-x64@2.4.7':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@biomejs/cli-win32-arm64@2.0.0':
|
'@biomejs/cli-win32-arm64@2.4.7':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@biomejs/cli-win32-x64@2.0.0':
|
'@biomejs/cli-win32-x64@2.4.7':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@bramus/specificity@2.4.2':
|
'@bramus/specificity@2.4.2':
|
||||||
|
|||||||
Reference in New Issue
Block a user