Phase 6: character creation & level-up
- Ability generation: standard array, point buy (27-pt, validated), 4d6kh3 roll; AbilityGenModal with assign-from-pool + steppers (pure abilityGen lib + tests) - Level-up flow: increment level, HP gain (average or roll hit die + CON) - Print / save-to-PDF: print button + @media print hides app chrome and roll tray - Plan: added Phase 11 (data-driven Assistant) to the roadmap Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,31 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
await page.evaluate(async () => {
|
||||||
|
indexedDB.deleteDatabase('ttrpg-manager');
|
||||||
|
localStorage.clear();
|
||||||
|
});
|
||||||
|
await page.reload();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('generate ability scores and level up', async ({ page }) => {
|
||||||
|
await page.getByRole('button', { name: '+ New campaign' }).first().click();
|
||||||
|
await page.locator('input[data-autofocus]').fill('Build');
|
||||||
|
await page.getByRole('button', { name: 'Create' }).click();
|
||||||
|
await page.getByRole('link', { name: 'Characters' }).click();
|
||||||
|
await page.getByRole('button', { name: '+ New character' }).first().click();
|
||||||
|
await page.locator('input[data-autofocus]').fill('Builder');
|
||||||
|
await page.getByRole('button', { name: 'Create' }).click();
|
||||||
|
await page.getByRole('link', { name: /Builder/ }).click();
|
||||||
|
|
||||||
|
// Generate scores via standard array (default assignment 15,14,13,12,10,8)
|
||||||
|
await page.getByRole('button', { name: 'Generate' }).click();
|
||||||
|
await page.getByRole('button', { name: 'Apply' }).click();
|
||||||
|
await expect(page.getByLabel('STR score')).toHaveValue('15');
|
||||||
|
|
||||||
|
// Level up from 1 -> 2
|
||||||
|
await page.getByRole('button', { name: 'Level up' }).click();
|
||||||
|
await page.getByRole('button', { name: 'Level up' }).last().click();
|
||||||
|
await expect(page.getByRole('spinbutton', { name: 'Level', exact: true })).toHaveValue('2');
|
||||||
|
});
|
||||||
@@ -9,7 +9,7 @@ export function RollTray() {
|
|||||||
if (!last) return null;
|
if (!last) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="pointer-events-none fixed bottom-4 right-4 z-50 w-72">
|
<div data-roll-tray className="pointer-events-none fixed bottom-4 right-4 z-50 w-72 print:hidden">
|
||||||
<div
|
<div
|
||||||
key={last.seq}
|
key={last.seq}
|
||||||
className="animate-dice-settle pointer-events-auto rounded-lg border border-line bg-panel p-4 shadow-2xl"
|
className="animate-dice-settle pointer-events-auto rounded-lg border border-line bg-panel p-4 shadow-2xl"
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ import { AttacksSection } from './sheet/AttacksSection';
|
|||||||
import { ResourcesSection } from './sheet/ResourcesSection';
|
import { ResourcesSection } from './sheet/ResourcesSection';
|
||||||
import { SpellcastingSection } from './sheet/SpellcastingSection';
|
import { SpellcastingSection } from './sheet/SpellcastingSection';
|
||||||
import { DefensesSection } from './sheet/DefensesSection';
|
import { DefensesSection } from './sheet/DefensesSection';
|
||||||
|
import { AbilityGenModal } from './sheet/AbilityGenModal';
|
||||||
|
import { LevelUpModal } from './sheet/LevelUpModal';
|
||||||
|
|
||||||
const ABILITIES: AbilityKey[] = ['str', 'dex', 'con', 'int', 'wis', 'cha'];
|
const ABILITIES: AbilityKey[] = ['str', 'dex', 'con', 'int', 'wis', 'cha'];
|
||||||
|
|
||||||
@@ -33,6 +35,8 @@ const RANK_LABEL: Record<ProficiencyRank, string> = {
|
|||||||
export function CharacterSheet({ character }: { character: Character }) {
|
export function CharacterSheet({ character }: { character: Character }) {
|
||||||
// Local editable copy; write-through to the DB on change (debounced + flush on unmount).
|
// Local editable copy; write-through to the DB on change (debounced + flush on unmount).
|
||||||
const [c, setC] = useState<Character>(character);
|
const [c, setC] = useState<Character>(character);
|
||||||
|
const [levelUp, setLevelUp] = useState(false);
|
||||||
|
const [genScores, setGenScores] = useState(false);
|
||||||
const save = useDebouncedCallback((next: Character) => {
|
const save = useDebouncedCallback((next: Character) => {
|
||||||
// Persist everything except identity/timestamps (update() stamps updatedAt).
|
// Persist everything except identity/timestamps (update() stamps updatedAt).
|
||||||
const { id, campaignId: _campaignId, createdAt: _createdAt, updatedAt: _updatedAt, ...rest } = next;
|
const { id, campaignId: _campaignId, createdAt: _createdAt, updatedAt: _updatedAt, ...rest } = next;
|
||||||
@@ -84,7 +88,11 @@ export function CharacterSheet({ character }: { character: Character }) {
|
|||||||
<span className="rounded-full bg-elevated px-2 py-0.5 text-xs uppercase tracking-wide text-muted">
|
<span className="rounded-full bg-elevated px-2 py-0.5 text-xs uppercase tracking-wide text-muted">
|
||||||
{c.kind === 'pc' ? 'PC' : 'NPC'} · {sys.label}
|
{c.kind === 'pc' ? 'PC' : 'NPC'} · {sys.label}
|
||||||
</span>
|
</span>
|
||||||
<div className="ml-auto text-sm text-muted">{profLabel}</div>
|
<div className="ml-auto flex items-center gap-2">
|
||||||
|
<span className="text-sm text-muted">{profLabel}</span>
|
||||||
|
<Button size="sm" variant="secondary" onClick={() => setLevelUp(true)}>Level up</Button>
|
||||||
|
<Button size="sm" variant="ghost" onClick={() => window.print()} title="Print / save as PDF">Print</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mb-6 grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
<div className="mb-6 grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
@@ -118,7 +126,10 @@ export function CharacterSheet({ character }: { character: Character }) {
|
|||||||
|
|
||||||
{/* Abilities */}
|
{/* Abilities */}
|
||||||
<section className="mb-6">
|
<section className="mb-6">
|
||||||
<SectionTitle>Ability Scores</SectionTitle>
|
<div className="mb-2 flex items-center justify-between">
|
||||||
|
<SectionTitle>Ability Scores</SectionTitle>
|
||||||
|
<Button size="sm" variant="ghost" onClick={() => setGenScores(true)}>Generate</Button>
|
||||||
|
</div>
|
||||||
<div className="grid grid-cols-3 gap-3 sm:grid-cols-6">
|
<div className="grid grid-cols-3 gap-3 sm:grid-cols-6">
|
||||||
{ABILITIES.map((a) => {
|
{ABILITIES.map((a) => {
|
||||||
const mod = sys.abilityModifier(c.abilities[a]);
|
const mod = sys.abilityModifier(c.abilities[a]);
|
||||||
@@ -212,6 +223,13 @@ export function CharacterSheet({ character }: { character: Character }) {
|
|||||||
className="min-h-32 w-full resize-y rounded-md border border-line bg-surface px-3 py-2 text-sm text-ink focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/60"
|
className="min-h-32 w-full resize-y rounded-md border border-line bg-surface px-3 py-2 text-sm text-ink focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/60"
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{genScores && (
|
||||||
|
<AbilityGenModal onClose={() => setGenScores(false)} onApply={(abilities) => update({ abilities })} />
|
||||||
|
)}
|
||||||
|
{levelUp && (
|
||||||
|
<LevelUpModal character={c} onClose={() => setLevelUp(false)} onApply={(patch) => update(patch)} />
|
||||||
|
)}
|
||||||
</Page>
|
</Page>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import type { AbilityKey, AbilityScores } from '@/lib/rules';
|
||||||
|
import { ABILITY_ABBR, abilityModifier } from '@/lib/rules';
|
||||||
|
import { createRng } from '@/lib/rng';
|
||||||
|
import {
|
||||||
|
STANDARD_ARRAY,
|
||||||
|
rollAbilityScores,
|
||||||
|
pointBuyRemaining,
|
||||||
|
POINT_BUY_MIN,
|
||||||
|
POINT_BUY_MAX,
|
||||||
|
} from '@/lib/rules/abilityGen';
|
||||||
|
import { formatModifier } from '@/lib/format';
|
||||||
|
import { Modal } from '@/components/ui/Modal';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { Select } from '@/components/ui/Input';
|
||||||
|
import { NumberField } from '@/components/ui/NumberField';
|
||||||
|
import { cn } from '@/lib/cn';
|
||||||
|
|
||||||
|
const ABILITIES: AbilityKey[] = ['str', 'dex', 'con', 'int', 'wis', 'cha'];
|
||||||
|
type Method = 'standard' | 'roll' | 'pointbuy';
|
||||||
|
|
||||||
|
export function AbilityGenModal({ onApply, onClose }: { onApply: (scores: AbilityScores) => void; onClose: () => void }) {
|
||||||
|
const [method, setMethod] = useState<Method>('standard');
|
||||||
|
const [pool, setPool] = useState<number[]>([...STANDARD_ARRAY]);
|
||||||
|
// assignment[i] = pool index assigned to ABILITIES[i], or -1
|
||||||
|
const [assignment, setAssignment] = useState<number[]>(ABILITIES.map((_, i) => i));
|
||||||
|
const [pb, setPb] = useState<number[]>([8, 8, 8, 8, 8, 8]);
|
||||||
|
|
||||||
|
const chooseMethod = (m: Method) => {
|
||||||
|
setMethod(m);
|
||||||
|
if (m === 'standard') { setPool([...STANDARD_ARRAY]); setAssignment(ABILITIES.map((_, i) => i)); }
|
||||||
|
if (m === 'roll') { const p = rollAbilityScores(createRng()); setPool(p); setAssignment(ABILITIES.map((_, i) => i)); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const reroll = () => { setPool(rollAbilityScores(createRng())); setAssignment(ABILITIES.map((_, i) => i)); };
|
||||||
|
|
||||||
|
const usesPool = method === 'standard' || method === 'roll';
|
||||||
|
const remaining = pointBuyRemaining(pb);
|
||||||
|
|
||||||
|
const toScores = (): AbilityScores => {
|
||||||
|
const out = {} as AbilityScores;
|
||||||
|
ABILITIES.forEach((a, i) => {
|
||||||
|
out[a] = usesPool ? (pool[assignment[i]!] ?? 10) : pb[i]!;
|
||||||
|
});
|
||||||
|
return out;
|
||||||
|
};
|
||||||
|
|
||||||
|
const poolValid = !usesPool || new Set(assignment).size === ABILITIES.length;
|
||||||
|
const pbValid = method !== 'pointbuy' || remaining >= 0;
|
||||||
|
const valid = poolValid && pbValid;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
open
|
||||||
|
onClose={onClose}
|
||||||
|
title="Generate ability scores"
|
||||||
|
className="max-w-xl"
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="ghost" onClick={onClose}>Cancel</Button>
|
||||||
|
<Button variant="primary" disabled={!valid} onClick={() => { onApply(toScores()); onClose(); }}>Apply</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="mb-4 flex gap-1">
|
||||||
|
{([['standard', 'Standard array'], ['pointbuy', 'Point buy'], ['roll', '4d6 drop lowest']] as const).map(([m, label]) => (
|
||||||
|
<button
|
||||||
|
key={m}
|
||||||
|
onClick={() => chooseMethod(m)}
|
||||||
|
className={cn('rounded-md px-3 py-1.5 text-sm', method === m ? 'bg-accent text-accent-ink' : 'bg-elevated text-muted hover:text-ink')}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{method === 'roll' && (
|
||||||
|
<Button size="sm" variant="secondary" className="mb-3" onClick={reroll}>↻ Reroll</Button>
|
||||||
|
)}
|
||||||
|
{method === 'pointbuy' && (
|
||||||
|
<p className={cn('mb-3 text-sm', remaining < 0 ? 'text-danger' : 'text-muted')}>
|
||||||
|
Points remaining: <span className="font-semibold text-ink">{remaining}</span> / 27
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{usesPool && !poolValid && <p className="mb-3 text-sm text-warning">Assign each value to a different ability.</p>}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
|
||||||
|
{ABILITIES.map((a, i) => {
|
||||||
|
const value = usesPool ? (pool[assignment[i]!] ?? 0) : pb[i]!;
|
||||||
|
return (
|
||||||
|
<div key={a} className="rounded-lg border border-line bg-surface p-3 text-center">
|
||||||
|
<div className="text-xs font-semibold uppercase tracking-wide text-muted">{ABILITY_ABBR[a]}</div>
|
||||||
|
{usesPool ? (
|
||||||
|
<Select
|
||||||
|
className="mt-1"
|
||||||
|
aria-label={`${ABILITY_ABBR[a]} value`}
|
||||||
|
value={assignment[i]}
|
||||||
|
onChange={(e) => setAssignment((prev) => prev.map((v, j) => (j === i ? Number(e.target.value) : v)))}
|
||||||
|
>
|
||||||
|
{pool.map((p, idx) => (
|
||||||
|
<option key={idx} value={idx} disabled={assignment.includes(idx) && assignment[i] !== idx}>
|
||||||
|
{p}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
) : (
|
||||||
|
<NumberField
|
||||||
|
className="mt-1"
|
||||||
|
value={pb[i]!}
|
||||||
|
min={POINT_BUY_MIN}
|
||||||
|
max={POINT_BUY_MAX}
|
||||||
|
aria-label={`${ABILITY_ABBR[a]} score`}
|
||||||
|
onChange={(v) => setPb((prev) => prev.map((x, j) => (j === i ? Math.max(POINT_BUY_MIN, Math.min(POINT_BUY_MAX, v)) : x)))}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="mt-1 text-xs text-accent">{formatModifier(abilityModifier(value))}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import type { Character } from '@/lib/schemas';
|
||||||
|
import { abilityModifier } from '@/lib/rules';
|
||||||
|
import { rollDice } from '@/lib/dice/notation';
|
||||||
|
import { createRng } from '@/lib/rng';
|
||||||
|
import { Modal } from '@/components/ui/Modal';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { Select } from '@/components/ui/Input';
|
||||||
|
|
||||||
|
const HIT_DICE = [6, 8, 10, 12] as const;
|
||||||
|
|
||||||
|
export function LevelUpModal({ character, onApply, onClose }: {
|
||||||
|
character: Character;
|
||||||
|
onApply: (patch: Partial<Character>) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const [die, setDie] = useState<number>(8);
|
||||||
|
const [method, setMethod] = useState<'average' | 'roll'>('average');
|
||||||
|
const conMod = abilityModifier(character.abilities.con);
|
||||||
|
|
||||||
|
const average = Math.ceil(die / 2) + 1; // 5e fixed value per level
|
||||||
|
const apply = () => {
|
||||||
|
const base = method === 'average' ? average : rollDice(`1d${die}`, createRng()).total;
|
||||||
|
const gain = Math.max(1, base + conMod);
|
||||||
|
onApply({
|
||||||
|
level: Math.min(20, character.level + 1),
|
||||||
|
hp: { ...character.hp, max: character.hp.max + gain, current: character.hp.current + gain },
|
||||||
|
});
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const preview = (method === 'average' ? average : `1d${die}`) + (conMod !== 0 ? ` ${conMod >= 0 ? '+' : ''}${conMod}` : '');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
open
|
||||||
|
onClose={onClose}
|
||||||
|
title={`Level up to ${Math.min(20, character.level + 1)}`}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="ghost" onClick={onClose}>Cancel</Button>
|
||||||
|
<Button variant="primary" disabled={character.level >= 20} onClick={apply}>Level up</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<label className="block text-xs text-muted">
|
||||||
|
Hit die
|
||||||
|
<Select value={die} onChange={(e) => setDie(Number(e.target.value))}>
|
||||||
|
{HIT_DICE.map((d) => <option key={d} value={d}>d{d}</option>)}
|
||||||
|
</Select>
|
||||||
|
</label>
|
||||||
|
<label className="block text-xs text-muted">
|
||||||
|
HP method
|
||||||
|
<Select value={method} onChange={(e) => setMethod(e.target.value as 'average' | 'roll')}>
|
||||||
|
<option value="average">Average (+{average})</option>
|
||||||
|
<option value="roll">Roll the hit die</option>
|
||||||
|
</Select>
|
||||||
|
</label>
|
||||||
|
<p className="text-sm text-muted">
|
||||||
|
HP gained: <span className="font-medium text-ink">{preview}</span> (CON {conMod >= 0 ? '+' : ''}{conMod})
|
||||||
|
</p>
|
||||||
|
{character.level >= 20 && <p className="text-sm text-warning">Already at level 20.</p>}
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { rollAbilityScores, pointBuySpent, pointBuyRemaining, pointCost, STANDARD_ARRAY } from './abilityGen';
|
||||||
|
import { createRng } from '@/lib/rng';
|
||||||
|
|
||||||
|
describe('ability generation', () => {
|
||||||
|
it('standard array is the canonical spread', () => {
|
||||||
|
expect([...STANDARD_ARRAY]).toEqual([15, 14, 13, 12, 10, 8]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rolls six scores each in 3..18', () => {
|
||||||
|
const scores = rollAbilityScores(createRng('chargen'));
|
||||||
|
expect(scores).toHaveLength(6);
|
||||||
|
for (const s of scores) {
|
||||||
|
expect(s).toBeGreaterThanOrEqual(3);
|
||||||
|
expect(s).toBeLessThanOrEqual(18);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('point-buy costs match the standard table', () => {
|
||||||
|
expect(pointCost(8)).toBe(0);
|
||||||
|
expect(pointCost(13)).toBe(5);
|
||||||
|
expect(pointCost(15)).toBe(9);
|
||||||
|
expect(pointCost(16)).toBeNull();
|
||||||
|
expect(pointCost(7)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('27-point standard build is exactly spent', () => {
|
||||||
|
// all 13s costs 30 (>27), but 15,15,15,8,8,8 = 9+9+9 = 27
|
||||||
|
expect(pointBuySpent([15, 15, 15, 8, 8, 8])).toBe(27);
|
||||||
|
expect(pointBuyRemaining([15, 15, 15, 8, 8, 8])).toBe(0);
|
||||||
|
expect(pointBuyRemaining([8, 8, 8, 8, 8, 8])).toBe(27);
|
||||||
|
expect(pointBuySpent([16, 8, 8, 8, 8, 8])).toBe(Infinity);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import type { Rng } from '@/lib/rng';
|
||||||
|
import { createRng } from '@/lib/rng';
|
||||||
|
import { rollDice } from '@/lib/dice/notation';
|
||||||
|
|
||||||
|
/** The classic 5e/PF2e-ish standard array. */
|
||||||
|
export const STANDARD_ARRAY = [15, 14, 13, 12, 10, 8] as const;
|
||||||
|
|
||||||
|
/** Roll six ability scores as 4d6, drop the lowest. */
|
||||||
|
export function rollAbilityScores(rng: Rng = createRng()): number[] {
|
||||||
|
return Array.from({ length: 6 }, () => rollDice('4d6kh3', rng).total);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Point-buy (27-point standard). Scores 8–15; costs 0,1,2,3,4,5,7,9 for 8..15.
|
||||||
|
* Returns the cost of a single score, or null if out of the buyable range.
|
||||||
|
*/
|
||||||
|
const POINT_COST: Record<number, number> = { 8: 0, 9: 1, 10: 2, 11: 3, 12: 4, 13: 5, 14: 7, 15: 9 };
|
||||||
|
export const POINT_BUY_BUDGET = 27;
|
||||||
|
export const POINT_BUY_MIN = 8;
|
||||||
|
export const POINT_BUY_MAX = 15;
|
||||||
|
|
||||||
|
export function pointCost(score: number): number | null {
|
||||||
|
return score in POINT_COST ? POINT_COST[score]! : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Total points spent across six scores (Infinity if any score is out of range). */
|
||||||
|
export function pointBuySpent(scores: number[]): number {
|
||||||
|
let total = 0;
|
||||||
|
for (const s of scores) {
|
||||||
|
const c = pointCost(s);
|
||||||
|
if (c === null) return Infinity;
|
||||||
|
total += c;
|
||||||
|
}
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pointBuyRemaining(scores: number[]): number {
|
||||||
|
return POINT_BUY_BUDGET - pointBuySpent(scores);
|
||||||
|
}
|
||||||
@@ -85,6 +85,21 @@ body {
|
|||||||
scrollbar-color: var(--app-line) transparent;
|
scrollbar-color: var(--app-line) transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Print: hide app chrome so a character sheet prints (or saves to PDF) cleanly. */
|
||||||
|
@media print {
|
||||||
|
header,
|
||||||
|
[data-roll-tray] {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
main {
|
||||||
|
overflow: visible !important;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
background: #fff;
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* Dice roll animations */
|
/* Dice roll animations */
|
||||||
@keyframes dice-tumble {
|
@keyframes dice-tumble {
|
||||||
0% {
|
0% {
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
{"root":["./src/main.tsx","./src/router.tsx","./src/vite-env.d.ts","./src/app/RootLayout.tsx","./src/components/ui/Button.tsx","./src/components/ui/ErrorBoundary.tsx","./src/components/ui/Input.tsx","./src/components/ui/Modal.tsx","./src/components/ui/NumberField.tsx","./src/components/ui/Page.tsx","./src/components/ui/RollTray.tsx","./src/features/campaigns/CampaignsPage.tsx","./src/features/campaigns/hooks.ts","./src/features/characters/CharacterSheet.tsx","./src/features/characters/CharacterSheetPage.tsx","./src/features/characters/CharactersPage.tsx","./src/features/characters/hooks.ts","./src/features/characters/sheet/AttacksSection.tsx","./src/features/characters/sheet/DefensesSection.tsx","./src/features/characters/sheet/InventorySection.tsx","./src/features/characters/sheet/ResourcesSection.tsx","./src/features/characters/sheet/SpellcastingSection.tsx","./src/features/characters/sheet/common.tsx","./src/features/combat/CombatPage.tsx","./src/features/combat/EncounterTracker.tsx","./src/features/combat/hooks.ts","./src/features/combat/useConditionGlossary.ts","./src/features/compendium/CompendiumPage.tsx","./src/features/compendium/MonsterDetail.tsx","./src/features/compendium/details.tsx","./src/features/compendium/registry.test.ts","./src/features/compendium/registry.tsx","./src/features/dice/DicePage.tsx","./src/features/world/CalendarPage.tsx","./src/features/world/DashboardPage.tsx","./src/features/world/NotesPage.tsx","./src/features/world/NpcsPage.tsx","./src/features/world/QuestsPage.tsx","./src/features/world/hooks.ts","./src/lib/cn.ts","./src/lib/format.ts","./src/lib/ids.ts","./src/lib/rng.ts","./src/lib/useDebouncedCallback.ts","./src/lib/useRoll.ts","./src/lib/wikilinks.test.ts","./src/lib/wikilinks.ts","./src/lib/combat/budget.test.ts","./src/lib/combat/budget.ts","./src/lib/combat/engine.test.ts","./src/lib/combat/engine.ts","./src/lib/compendium/index.ts","./src/lib/compendium/types.ts","./src/lib/db/db.ts","./src/lib/db/repositories.test.ts","./src/lib/db/repositories.ts","./src/lib/dice/check.test.ts","./src/lib/dice/check.ts","./src/lib/dice/notation.test.ts","./src/lib/dice/notation.ts","./src/lib/io/character.test.ts","./src/lib/io/character.ts","./src/lib/io/file.ts","./src/lib/rules/abilities.ts","./src/lib/rules/conditions.ts","./src/lib/rules/depth.test.ts","./src/lib/rules/index.ts","./src/lib/rules/rest.ts","./src/lib/rules/rules.test.ts","./src/lib/rules/types.ts","./src/lib/rules/dnd5e/index.ts","./src/lib/rules/dnd5e/skills.ts","./src/lib/rules/pf2e/index.ts","./src/lib/rules/pf2e/skills.ts","./src/lib/schemas/campaign.ts","./src/lib/schemas/character.ts","./src/lib/schemas/common.ts","./src/lib/schemas/dice.ts","./src/lib/schemas/encounter.ts","./src/lib/schemas/index.ts","./src/lib/schemas/world.ts","./src/stores/macroStore.ts","./src/stores/rollStore.ts","./src/stores/uiStore.ts","./vitest.setup.ts"],"version":"5.9.3"}
|
{"root":["./src/main.tsx","./src/router.tsx","./src/vite-env.d.ts","./src/app/RootLayout.tsx","./src/components/ui/Button.tsx","./src/components/ui/ErrorBoundary.tsx","./src/components/ui/Input.tsx","./src/components/ui/Modal.tsx","./src/components/ui/NumberField.tsx","./src/components/ui/Page.tsx","./src/components/ui/RollTray.tsx","./src/features/campaigns/CampaignsPage.tsx","./src/features/campaigns/hooks.ts","./src/features/characters/CharacterSheet.tsx","./src/features/characters/CharacterSheetPage.tsx","./src/features/characters/CharactersPage.tsx","./src/features/characters/hooks.ts","./src/features/characters/sheet/AbilityGenModal.tsx","./src/features/characters/sheet/AttacksSection.tsx","./src/features/characters/sheet/DefensesSection.tsx","./src/features/characters/sheet/InventorySection.tsx","./src/features/characters/sheet/LevelUpModal.tsx","./src/features/characters/sheet/ResourcesSection.tsx","./src/features/characters/sheet/SpellcastingSection.tsx","./src/features/characters/sheet/common.tsx","./src/features/combat/CombatPage.tsx","./src/features/combat/EncounterTracker.tsx","./src/features/combat/hooks.ts","./src/features/combat/useConditionGlossary.ts","./src/features/compendium/CompendiumPage.tsx","./src/features/compendium/MonsterDetail.tsx","./src/features/compendium/details.tsx","./src/features/compendium/registry.test.ts","./src/features/compendium/registry.tsx","./src/features/dice/DicePage.tsx","./src/features/world/CalendarPage.tsx","./src/features/world/DashboardPage.tsx","./src/features/world/NotesPage.tsx","./src/features/world/NpcsPage.tsx","./src/features/world/QuestsPage.tsx","./src/features/world/hooks.ts","./src/lib/cn.ts","./src/lib/format.ts","./src/lib/ids.ts","./src/lib/rng.ts","./src/lib/useDebouncedCallback.ts","./src/lib/useRoll.ts","./src/lib/wikilinks.test.ts","./src/lib/wikilinks.ts","./src/lib/combat/budget.test.ts","./src/lib/combat/budget.ts","./src/lib/combat/engine.test.ts","./src/lib/combat/engine.ts","./src/lib/compendium/index.ts","./src/lib/compendium/types.ts","./src/lib/db/db.ts","./src/lib/db/repositories.test.ts","./src/lib/db/repositories.ts","./src/lib/dice/check.test.ts","./src/lib/dice/check.ts","./src/lib/dice/notation.test.ts","./src/lib/dice/notation.ts","./src/lib/io/character.test.ts","./src/lib/io/character.ts","./src/lib/io/file.ts","./src/lib/rules/abilities.ts","./src/lib/rules/abilityGen.test.ts","./src/lib/rules/abilityGen.ts","./src/lib/rules/conditions.ts","./src/lib/rules/depth.test.ts","./src/lib/rules/index.ts","./src/lib/rules/rest.ts","./src/lib/rules/rules.test.ts","./src/lib/rules/types.ts","./src/lib/rules/dnd5e/index.ts","./src/lib/rules/dnd5e/skills.ts","./src/lib/rules/pf2e/index.ts","./src/lib/rules/pf2e/skills.ts","./src/lib/schemas/campaign.ts","./src/lib/schemas/character.ts","./src/lib/schemas/common.ts","./src/lib/schemas/dice.ts","./src/lib/schemas/encounter.ts","./src/lib/schemas/index.ts","./src/lib/schemas/world.ts","./src/stores/macroStore.ts","./src/stores/rollStore.ts","./src/stores/uiStore.ts","./vitest.setup.ts"],"version":"5.9.3"}
|
||||||
Reference in New Issue
Block a user