Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
502adca81b | ||
|
|
12e8bf6e69 | ||
|
|
472574ac31 | ||
|
|
f4a7b53393 | ||
|
|
8aec460ee4 | ||
|
|
6e10238fe0 | ||
|
|
b6e882add2 |
@@ -30,6 +30,7 @@ import { useBulkImport } from "./hooks/use-bulk-import";
|
|||||||
import { useEncounter } from "./hooks/use-encounter";
|
import { useEncounter } from "./hooks/use-encounter";
|
||||||
import { usePlayerCharacters } from "./hooks/use-player-characters";
|
import { usePlayerCharacters } from "./hooks/use-player-characters";
|
||||||
import { useSidePanelState } from "./hooks/use-side-panel-state";
|
import { useSidePanelState } from "./hooks/use-side-panel-state";
|
||||||
|
import { cn } from "./lib/utils";
|
||||||
|
|
||||||
function rollDice(): number {
|
function rollDice(): number {
|
||||||
return Math.floor(Math.random() * 20) + 1;
|
return Math.floor(Math.random() * 20) + 1;
|
||||||
@@ -200,7 +201,7 @@ export function App() {
|
|||||||
<div className="relative mx-auto flex min-h-0 w-full max-w-2xl flex-1 flex-col gap-3 px-4">
|
<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={cn("shrink-0 pt-8", actionBarAnim.topBarClass)}
|
||||||
onAnimationEnd={actionBarAnim.onTopBarExitEnd}
|
onAnimationEnd={actionBarAnim.onTopBarExitEnd}
|
||||||
>
|
>
|
||||||
<TurnNavigation
|
<TurnNavigation
|
||||||
@@ -216,7 +217,7 @@ export function App() {
|
|||||||
/* Empty state — ActionBar centered */
|
/* Empty state — ActionBar centered */
|
||||||
<div className="flex min-h-0 flex-1 items-center justify-center pt-8 pb-[15%]">
|
<div className="flex min-h-0 flex-1 items-center justify-center pt-8 pb-[15%]">
|
||||||
<div
|
<div
|
||||||
className={`w-full${actionBarAnim.risingClass}`}
|
className={cn("w-full", actionBarAnim.risingClass)}
|
||||||
onAnimationEnd={actionBarAnim.onRiseEnd}
|
onAnimationEnd={actionBarAnim.onRiseEnd}
|
||||||
>
|
>
|
||||||
<ActionBar
|
<ActionBar
|
||||||
@@ -275,7 +276,7 @@ export function App() {
|
|||||||
|
|
||||||
{/* Action Bar — fixed at bottom */}
|
{/* Action Bar — fixed at bottom */}
|
||||||
<div
|
<div
|
||||||
className={`shrink-0 pb-8${actionBarAnim.settlingClass}`}
|
className={cn("shrink-0 pb-8", actionBarAnim.settlingClass)}
|
||||||
onAnimationEnd={actionBarAnim.onSettleEnd}
|
onAnimationEnd={actionBarAnim.onSettleEnd}
|
||||||
>
|
>
|
||||||
<ActionBar
|
<ActionBar
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ describe("CombatantRow", () => {
|
|||||||
it("active combatant gets active border styling", () => {
|
it("active combatant gets active border styling", () => {
|
||||||
const { container } = renderRow({ isActive: true });
|
const { container } = renderRow({ isActive: true });
|
||||||
const row = container.firstElementChild;
|
const row = container.firstElementChild;
|
||||||
expect(row?.className).toContain("border-l-accent");
|
expect(row?.className).toContain("border-accent/40");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("unconscious combatant (currentHp === 0) gets dimmed styling", () => {
|
it("unconscious combatant (currentHp === 0) gets dimmed styling", () => {
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ function AddModeSuggestions({
|
|||||||
onAddFromPlayerCharacter?: (pc: PlayerCharacter) => void;
|
onAddFromPlayerCharacter?: (pc: PlayerCharacter) => void;
|
||||||
}>) {
|
}>) {
|
||||||
return (
|
return (
|
||||||
<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="card-glow absolute bottom-full z-50 mb-1 w-full max-w-xs rounded-lg border border-border bg-card">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
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"
|
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"
|
||||||
@@ -137,12 +137,14 @@ 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={cn(
|
||||||
if (isQueued) return "bg-accent/30 text-foreground";
|
"flex w-full items-center justify-between px-3 py-1.5 text-left text-foreground text-sm",
|
||||||
if (i === suggestionIndex)
|
isQueued && "bg-accent/30",
|
||||||
return "bg-accent/20 text-foreground";
|
!isQueued && i === suggestionIndex && "bg-accent/20",
|
||||||
return "text-foreground hover:bg-hover-neutral-bg";
|
!isQueued &&
|
||||||
})()}`}
|
i !== suggestionIndex &&
|
||||||
|
"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)}
|
||||||
@@ -455,7 +457,7 @@ export function ActionBar({
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-3 rounded-md border border-border bg-card px-4 py-3">
|
<div className="card-glow flex items-center gap-3 rounded-lg border border-border bg-card px-4 py-3">
|
||||||
<form
|
<form
|
||||||
onSubmit={handleAdd}
|
onSubmit={handleAdd}
|
||||||
className="relative flex flex-1 items-center gap-2"
|
className="relative flex flex-1 items-center gap-2"
|
||||||
@@ -496,17 +498,18 @@ export function ActionBar({
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{browseMode && deferredSuggestions.length > 0 && (
|
{browseMode && deferredSuggestions.length > 0 && (
|
||||||
<div className="absolute bottom-full z-50 mb-1 w-full rounded-md border border-border bg-card shadow-lg">
|
<div className="card-glow absolute bottom-full z-50 mb-1 w-full rounded-lg border border-border bg-card">
|
||||||
<ul className="max-h-48 overflow-y-auto py-1">
|
<ul className="max-h-48 overflow-y-auto py-1">
|
||||||
{deferredSuggestions.map((result, i) => (
|
{deferredSuggestions.map((result, i) => (
|
||||||
<li key={creatureKey(result)}>
|
<li key={creatureKey(result)}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`flex w-full items-center justify-between px-3 py-1.5 text-left text-sm ${
|
className={cn(
|
||||||
|
"flex w-full items-center justify-between px-3 py-1.5 text-left text-sm",
|
||||||
i === suggestionIndex
|
i === suggestionIndex
|
||||||
? "bg-accent/20 text-foreground"
|
? "bg-accent/20 text-foreground"
|
||||||
: "text-foreground hover:bg-hover-neutral-bg"
|
: "text-foreground hover:bg-hover-neutral-bg",
|
||||||
}`}
|
)}
|
||||||
onMouseDown={(e) => e.preventDefault()}
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
onClick={() => handleBrowseSelect(result)}
|
onClick={() => handleBrowseSelect(result)}
|
||||||
onMouseEnter={() => setSuggestionIndex(i)}
|
onMouseEnter={() => setSuggestionIndex(i)}
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ function EditableName({
|
|||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
type="text"
|
type="text"
|
||||||
value={draft}
|
value={draft}
|
||||||
className="h-7 text-sm"
|
className="h-7 max-w-48 text-sm"
|
||||||
onChange={(e) => setDraft(e.target.value)}
|
onChange={(e) => setDraft(e.target.value)}
|
||||||
onBlur={commit}
|
onBlur={commit}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
@@ -365,9 +365,13 @@ function rowBorderClass(
|
|||||||
isActive: boolean,
|
isActive: boolean,
|
||||||
isConcentrating: boolean | undefined,
|
isConcentrating: boolean | undefined,
|
||||||
): string {
|
): string {
|
||||||
if (isActive) return "border-l-2 border-l-accent bg-accent/10";
|
if (isActive && isConcentrating)
|
||||||
if (isConcentrating) return "border-l-2 border-l-purple-400";
|
return "border border-l-2 border-accent/40 border-l-purple-400 bg-accent/10 card-glow";
|
||||||
return "border-l-2 border-l-transparent";
|
if (isActive)
|
||||||
|
return "border border-l-2 border-accent/40 bg-accent/10 card-glow";
|
||||||
|
if (isConcentrating)
|
||||||
|
return "border border-l-2 border-transparent border-l-purple-400";
|
||||||
|
return "border border-l-2 border-transparent";
|
||||||
}
|
}
|
||||||
|
|
||||||
function concentrationIconClass(
|
function concentrationIconClass(
|
||||||
@@ -434,7 +438,7 @@ export function CombatantRow({
|
|||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"group rounded-md pr-3 transition-colors",
|
"group rounded-lg pr-3 transition-colors",
|
||||||
rowBorderClass(isActive, combatant.isConcentrating),
|
rowBorderClass(isActive, combatant.isConcentrating),
|
||||||
isPulsing && "animate-concentration-pulse",
|
isPulsing && "animate-concentration-pulse",
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ export function ConditionPicker({
|
|||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"absolute left-0 z-10 w-fit overflow-y-auto rounded-md border border-border bg-background p-1 shadow-lg",
|
"card-glow absolute left-0 z-10 w-fit overflow-y-auto rounded-lg border border-border bg-background p-1",
|
||||||
flipped ? "bottom-full mb-1" : "top-full mt-1",
|
flipped ? "bottom-full mb-1" : "top-full mt-1",
|
||||||
)}
|
)}
|
||||||
style={maxHeight ? { maxHeight } : undefined}
|
style={maxHeight ? { maxHeight } : undefined}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
Sparkles,
|
Sparkles,
|
||||||
ZapOff,
|
ZapOff,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import { cn } from "../lib/utils.js";
|
||||||
|
|
||||||
const ICON_MAP: Record<string, LucideIcon> = {
|
const ICON_MAP: Record<string, LucideIcon> = {
|
||||||
EyeOff,
|
EyeOff,
|
||||||
@@ -75,7 +76,10 @@ 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 transition-colors hover:bg-hover-neutral-bg ${colorClass}`}
|
className={cn(
|
||||||
|
"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);
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ export function CreatePlayerModal({
|
|||||||
return (
|
return (
|
||||||
<dialog
|
<dialog
|
||||||
ref={dialogRef}
|
ref={dialogRef}
|
||||||
className="m-auto w-full max-w-md rounded-lg border border-border bg-card p-6 text-foreground shadow-xl backdrop:bg-black/50"
|
className="card-glow m-auto w-full max-w-md rounded-lg border border-border bg-card p-6 backdrop:bg-black/50"
|
||||||
>
|
>
|
||||||
<div className="mb-4 flex items-center justify-between">
|
<div className="mb-4 flex items-center justify-between">
|
||||||
<h2 className="font-semibold text-foreground text-lg">
|
<h2 className="font-semibold text-foreground text-lg">
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ export function HpAdjustPopover({ onAdjust, onClose }: HpAdjustPopoverProps) {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className="fixed z-10 rounded-md border border-border bg-background p-2 shadow-lg"
|
className="card-glow fixed z-10 rounded-lg border border-border bg-background p-2"
|
||||||
style={
|
style={
|
||||||
pos
|
pos
|
||||||
? { top: pos.top, left: pos.left }
|
? { top: pos.top, left: pos.left }
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ export function PlayerManagement({
|
|||||||
return (
|
return (
|
||||||
<dialog
|
<dialog
|
||||||
ref={dialogRef}
|
ref={dialogRef}
|
||||||
className="m-auto w-full max-w-md rounded-lg border border-border bg-card p-6 text-foreground shadow-xl backdrop:bg-black/50"
|
className="card-glow m-auto w-full max-w-md rounded-lg border border-border bg-card p-6 backdrop:bg-black/50"
|
||||||
>
|
>
|
||||||
<div className="mb-4 flex items-center justify-between">
|
<div className="mb-4 flex items-center justify-between">
|
||||||
<h2 className="font-semibold text-foreground text-lg">
|
<h2 className="font-semibold text-foreground text-lg">
|
||||||
|
|||||||
@@ -1,8 +1,15 @@
|
|||||||
import { Database, Trash2 } from "lucide-react";
|
import { Database, Search, Trash2 } from "lucide-react";
|
||||||
import { useCallback, useEffect, useOptimistic, useState } from "react";
|
import {
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useOptimistic,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
import type { CachedSourceInfo } from "../adapters/bestiary-cache.js";
|
import type { CachedSourceInfo } from "../adapters/bestiary-cache.js";
|
||||||
import * as bestiaryCache from "../adapters/bestiary-cache.js";
|
import * as bestiaryCache from "../adapters/bestiary-cache.js";
|
||||||
import { Button } from "./ui/button.js";
|
import { Button } from "./ui/button.js";
|
||||||
|
import { Input } from "./ui/input.js";
|
||||||
|
|
||||||
interface SourceManagerProps {
|
interface SourceManagerProps {
|
||||||
onCacheCleared: () => void;
|
onCacheCleared: () => void;
|
||||||
@@ -12,6 +19,7 @@ export function SourceManager({
|
|||||||
onCacheCleared,
|
onCacheCleared,
|
||||||
}: Readonly<SourceManagerProps>) {
|
}: Readonly<SourceManagerProps>) {
|
||||||
const [sources, setSources] = useState<CachedSourceInfo[]>([]);
|
const [sources, setSources] = useState<CachedSourceInfo[]>([]);
|
||||||
|
const [filter, setFilter] = useState("");
|
||||||
const [optimisticSources, applyOptimistic] = useOptimistic(
|
const [optimisticSources, applyOptimistic] = useOptimistic(
|
||||||
sources,
|
sources,
|
||||||
(
|
(
|
||||||
@@ -46,6 +54,15 @@ export function SourceManager({
|
|||||||
onCacheCleared();
|
onCacheCleared();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const filteredSources = useMemo(() => {
|
||||||
|
const term = filter.toLowerCase();
|
||||||
|
return term
|
||||||
|
? optimisticSources.filter((s) =>
|
||||||
|
s.displayName.toLowerCase().includes(term),
|
||||||
|
)
|
||||||
|
: optimisticSources;
|
||||||
|
}, [optimisticSources, filter]);
|
||||||
|
|
||||||
if (optimisticSources.length === 0) {
|
if (optimisticSources.length === 0) {
|
||||||
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">
|
||||||
@@ -70,8 +87,17 @@ export function SourceManager({
|
|||||||
Clear All
|
Clear All
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="pointer-events-none absolute top-1/2 left-3 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder="Filter sources…"
|
||||||
|
value={filter}
|
||||||
|
onChange={(e) => setFilter(e.target.value)}
|
||||||
|
className="pl-8"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<ul className="flex flex-col gap-1">
|
<ul className="flex flex-col gap-1">
|
||||||
{optimisticSources.map((source) => (
|
{filteredSources.map((source) => (
|
||||||
<li
|
<li
|
||||||
key={source.sourceCode}
|
key={source.sourceCode}
|
||||||
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"
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useEffect, useState } from "react";
|
|||||||
import { getSourceDisplayName } from "../adapters/bestiary-index-adapter.js";
|
import { getSourceDisplayName } 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 { useSwipeToDismiss } from "../hooks/use-swipe-to-dismiss.js";
|
import { useSwipeToDismiss } from "../hooks/use-swipe-to-dismiss.js";
|
||||||
|
import { cn } from "../lib/utils.js";
|
||||||
import { BulkImportPrompt } from "./bulk-import-prompt.js";
|
import { BulkImportPrompt } from "./bulk-import-prompt.js";
|
||||||
import { SourceFetchPrompt } from "./source-fetch-prompt.js";
|
import { SourceFetchPrompt } from "./source-fetch-prompt.js";
|
||||||
import { SourceManager } from "./source-manager.js";
|
import { SourceManager } from "./source-manager.js";
|
||||||
@@ -55,9 +56,10 @@ function CollapsedTab({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onToggleCollapse}
|
onClick={onToggleCollapse}
|
||||||
className={`flex h-full w-[40px] cursor-pointer items-center justify-center text-muted-foreground hover:text-hover-neutral ${
|
className={cn(
|
||||||
side === "right" ? "self-start" : "self-end"
|
"flex h-full w-[40px] cursor-pointer items-center justify-center text-muted-foreground hover:text-hover-neutral",
|
||||||
}`}
|
side === "right" ? "self-start" : "self-end",
|
||||||
|
)}
|
||||||
aria-label="Expand stat block panel"
|
aria-label="Expand stat block panel"
|
||||||
>
|
>
|
||||||
<span className="writing-vertical-rl font-medium text-sm">
|
<span className="writing-vertical-rl font-medium text-sm">
|
||||||
@@ -152,7 +154,11 @@ function DesktopPanel({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`fixed top-0 bottom-0 flex w-[400px] flex-col border-border bg-card transition-slide-panel ${sideClasses} ${isCollapsed ? collapsedTranslate : "translate-x-0"}`}
|
className={cn(
|
||||||
|
"panel-glow fixed top-0 bottom-0 flex w-[400px] flex-col border-border bg-card transition-slide-panel",
|
||||||
|
sideClasses,
|
||||||
|
isCollapsed ? collapsedTranslate : "translate-x-0",
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
{isCollapsed ? (
|
{isCollapsed ? (
|
||||||
<CollapsedTab
|
<CollapsedTab
|
||||||
@@ -194,7 +200,10 @@ function MobileDrawer({
|
|||||||
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-border border-l bg-card shadow-xl ${isSwiping ? "" : "animate-slide-in-right"}`}
|
className={cn(
|
||||||
|
"panel-glow absolute top-0 right-0 bottom-0 w-[85%] max-w-md border-border border-l bg-card",
|
||||||
|
!isSwiping && "animate-slide-in-right",
|
||||||
|
)}
|
||||||
style={
|
style={
|
||||||
isSwiping ? { transform: `translateX(${offsetX}px)` } : undefined
|
isSwiping ? { transform: `translateX(${offsetX}px)` } : undefined
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,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="card-glow flex items-center gap-3 rounded-lg border border-border bg-card px-4 py-3">
|
||||||
<span className="text-foreground text-sm">{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">
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export function TurnNavigation({
|
|||||||
const activeCombatant = encounter.combatants[encounter.activeIndex];
|
const activeCombatant = encounter.combatants[encounter.activeIndex];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-3 rounded-md border border-border bg-card px-4 py-3">
|
<div className="card-glow flex items-center gap-3 rounded-lg border border-border bg-card px-4 py-3">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="icon"
|
size="icon"
|
||||||
|
|||||||
@@ -9,8 +9,9 @@ const buttonVariants = cva(
|
|||||||
variant: {
|
variant: {
|
||||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||||
outline:
|
outline:
|
||||||
"border border-border bg-transparent hover:bg-hover-neutral-bg hover:text-hover-neutral",
|
"border border-border bg-background/50 text-foreground hover:bg-hover-neutral-bg hover:text-hover-neutral",
|
||||||
ghost: "hover:bg-hover-neutral-bg hover:text-hover-neutral",
|
ghost:
|
||||||
|
"text-foreground hover:bg-hover-neutral-bg hover:text-hover-neutral",
|
||||||
},
|
},
|
||||||
size: {
|
size: {
|
||||||
default: "h-8 px-3 text-xs",
|
default: "h-8 px-3 text-xs",
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ export function OverflowMenu({ items }: OverflowMenuProps) {
|
|||||||
<EllipsisVertical className="h-5 w-5" />
|
<EllipsisVertical className="h-5 w-5" />
|
||||||
</Button>
|
</Button>
|
||||||
{!!open && (
|
{!!open && (
|
||||||
<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">
|
<div className="card-glow absolute right-0 bottom-full z-50 mb-1 min-w-48 rounded-lg border border-border bg-card py-1">
|
||||||
{items.map((item) => (
|
{items.map((item) => (
|
||||||
<button
|
<button
|
||||||
key={item.label}
|
key={item.label}
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
|
||||||
@theme {
|
@theme {
|
||||||
--color-background: #0f172a;
|
--color-background: #0e1a2e;
|
||||||
--color-foreground: #e2e8f0;
|
--color-foreground: #e2e8f0;
|
||||||
--color-muted: #64748b;
|
--color-muted: #7a8ba4;
|
||||||
--color-muted-foreground: #94a3b8;
|
--color-muted-foreground: #94a3b8;
|
||||||
--color-card: #1e293b;
|
--color-card: #1a2e4a;
|
||||||
--color-card-foreground: #e2e8f0;
|
--color-card-foreground: #e2e8f0;
|
||||||
--color-border: #334155;
|
--color-border: #2a5088;
|
||||||
--color-input: #334155;
|
--color-input: #2a5088;
|
||||||
--color-primary: #3b82f6;
|
--color-primary: #3b82f6;
|
||||||
--color-primary-foreground: #ffffff;
|
--color-primary-foreground: #ffffff;
|
||||||
--color-accent: #3b82f6;
|
--color-accent: #3b82f6;
|
||||||
@@ -20,8 +20,8 @@
|
|||||||
--color-hover-action-bg: var(--color-muted);
|
--color-hover-action-bg: var(--color-muted);
|
||||||
--color-hover-destructive-bg: transparent;
|
--color-hover-destructive-bg: transparent;
|
||||||
--radius-sm: 0.25rem;
|
--radius-sm: 0.25rem;
|
||||||
--radius-md: 0.375rem;
|
--radius-md: 0.5rem;
|
||||||
--radius-lg: 0.5rem;
|
--radius-lg: 0.75rem;
|
||||||
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
|
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,6 +169,28 @@
|
|||||||
concentration-glow 1200ms ease-out;
|
concentration-glow 1200ms ease-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@utility card-glow {
|
||||||
|
background-image: radial-gradient(
|
||||||
|
ellipse at 50% 50%,
|
||||||
|
oklch(0.35 0.05 250 / 0.5) 0%,
|
||||||
|
transparent 70%
|
||||||
|
);
|
||||||
|
box-shadow:
|
||||||
|
0 0 15px -2px oklch(0.623 0.214 259 / 0.2),
|
||||||
|
inset 0 1px 0 0 oklch(0.7 0.15 259 / 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility panel-glow {
|
||||||
|
background-image: linear-gradient(
|
||||||
|
to bottom,
|
||||||
|
oklch(0.35 0.05 250 / 0.4) 0%,
|
||||||
|
transparent 40%
|
||||||
|
);
|
||||||
|
box-shadow:
|
||||||
|
0 0 20px -2px oklch(0.623 0.214 259 / 0.15),
|
||||||
|
inset 0 1px 0 0 oklch(0.7 0.15 259 / 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
scrollbar-color: var(--color-border) transparent;
|
scrollbar-color: var(--color-border) transparent;
|
||||||
scrollbar-width: thin;
|
scrollbar-width: thin;
|
||||||
|
|||||||
@@ -30,6 +30,7 @@
|
|||||||
"jscpd": "jscpd",
|
"jscpd": "jscpd",
|
||||||
"oxlint": "oxlint --tsconfig apps/web/tsconfig.json --type-aware",
|
"oxlint": "oxlint --tsconfig apps/web/tsconfig.json --type-aware",
|
||||||
"check:ignores": "node scripts/check-lint-ignores.mjs",
|
"check:ignores": "node scripts/check-lint-ignores.mjs",
|
||||||
"check": "pnpm audit --audit-level=high && knip && biome check . && oxlint --tsconfig apps/web/tsconfig.json --type-aware && node scripts/check-lint-ignores.mjs && tsc --build && vitest run && jscpd"
|
"check:classnames": "node scripts/check-cn-classnames.mjs",
|
||||||
|
"check": "pnpm audit --audit-level=high && knip && biome check . && oxlint --tsconfig apps/web/tsconfig.json --type-aware && node scripts/check-lint-ignores.mjs && node scripts/check-cn-classnames.mjs && tsc --build && vitest run && jscpd"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
47
scripts/check-cn-classnames.mjs
Normal file
47
scripts/check-cn-classnames.mjs
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
/**
|
||||||
|
* Ban template-literal classNames in TSX files.
|
||||||
|
*
|
||||||
|
* Tailwind v4's production content extractor does static analysis on source
|
||||||
|
* files to discover utility classes. Template literals like
|
||||||
|
* className={`foo ${bar}`}
|
||||||
|
* can cause the extractor to miss classes adjacent to `${`, leading to
|
||||||
|
* styles that work in dev (JIT) but break in production.
|
||||||
|
*
|
||||||
|
* Rule: always use cn() for dynamic class composition instead.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { execSync } from "node:child_process";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
|
||||||
|
const PATTERN = /className\s*=\s*\{`/;
|
||||||
|
|
||||||
|
function findFiles() {
|
||||||
|
return execSync("git ls-files -- '*.tsx'", { encoding: "utf-8" })
|
||||||
|
.trim()
|
||||||
|
.split("\n")
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
let errors = 0;
|
||||||
|
|
||||||
|
for (const file of findFiles()) {
|
||||||
|
const lines = readFileSync(file, "utf-8").split("\n");
|
||||||
|
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
if (PATTERN.test(lines[i])) {
|
||||||
|
console.error(
|
||||||
|
`${file}:${i + 1}: className uses template literal — use cn() instead`,
|
||||||
|
);
|
||||||
|
errors++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errors > 0) {
|
||||||
|
console.error(
|
||||||
|
`\n${errors} template-literal className(s) found. Use cn() for dynamic classes.`,
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
} else {
|
||||||
|
console.log("No template-literal classNames found.");
|
||||||
|
}
|
||||||
@@ -148,7 +148,7 @@ While the bulk import is in progress, the user sees a text counter ("Loading sou
|
|||||||
If the user closes the side panel while a bulk import is still in progress, a persistent toast notification appears at the bottom-center of the screen showing the same progress text and progress bar.
|
If the user closes the side panel while a bulk import is still in progress, a persistent toast notification appears at the bottom-center of the screen showing the same progress text and progress bar.
|
||||||
|
|
||||||
**US-M6 — Manage Cached Sources (P4)**
|
**US-M6 — Manage Cached Sources (P4)**
|
||||||
A DM wants to see which sources are cached, clear a specific source's cache, or clear all cached data. A management UI provides this visibility and control.
|
A DM wants to see which sources are cached, find a specific source, clear a specific source's cache, or clear all cached data. A management UI provides this visibility and control, including a filter input to quickly locate sources by name when many are cached.
|
||||||
|
|
||||||
### Requirements
|
### Requirements
|
||||||
|
|
||||||
@@ -174,7 +174,7 @@ A DM wants to see which sources are cached, clear a specific source's cache, or
|
|||||||
- **FR-044**: The bulk import MUST run asynchronously and not block the rest of the app.
|
- **FR-044**: The bulk import MUST run asynchronously and not block the rest of the app.
|
||||||
- **FR-045**: The user MUST explicitly provide/confirm the URL before any fetches occur — the app never auto-fetches content.
|
- **FR-045**: The user MUST explicitly provide/confirm the URL before any fetches occur — the app never auto-fetches content.
|
||||||
- **FR-046**: The "Load All" button MUST be disabled when the URL field is empty or while a bulk import is already in progress.
|
- **FR-046**: The "Load All" button MUST be disabled when the URL field is empty or while a bulk import is already in progress.
|
||||||
- **FR-047**: The app MUST provide a management UI showing cached sources with options to clear individual sources or all cached data.
|
- **FR-047**: The app MUST provide a management UI showing cached sources with a filter input for searching by display name and options to clear individual sources or all cached data.
|
||||||
- **FR-048**: The normalization adapter and tag-stripping utility MUST remain the canonical pipeline for all fetched and uploaded data.
|
- **FR-048**: The normalization adapter and tag-stripping utility MUST remain the canonical pipeline for all fetched and uploaded data.
|
||||||
- **FR-049**: The distributed app bundle MUST contain zero copyrighted prose content — only mechanical facts and creature names in the search index.
|
- **FR-049**: The distributed app bundle MUST contain zero copyrighted prose content — only mechanical facts and creature names in the search index.
|
||||||
|
|
||||||
@@ -198,6 +198,7 @@ A DM wants to see which sources are cached, clear a specific source's cache, or
|
|||||||
16. **Given** two sources have been cached, **When** the DM opens the source management UI, **Then** both sources are listed with their display names.
|
16. **Given** two sources have been cached, **When** the DM opens the source management UI, **Then** both sources are listed with their display names.
|
||||||
17. **Given** the source management UI is open, **When** the DM clears a single source, **Then** that source's data is removed; stat blocks for its creatures require re-fetching, while other cached sources remain.
|
17. **Given** the source management UI is open, **When** the DM clears a single source, **Then** that source's data is removed; stat blocks for its creatures require re-fetching, while other cached sources remain.
|
||||||
18. **Given** the source management UI is open, **When** the DM clears all cached data, **Then** all source data is removed and all stat blocks require re-fetching.
|
18. **Given** the source management UI is open, **When** the DM clears all cached data, **Then** all source data is removed and all stat blocks require re-fetching.
|
||||||
|
19. **Given** many sources are cached, **When** the DM types a partial name in the filter input, **Then** only sources whose display name matches (case-insensitive) are shown.
|
||||||
|
|
||||||
### Edge Cases
|
### Edge Cases
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user