/ src / character-sheet.tsx
character-sheet.tsx
  1  import type React from "react";
  2  
  3  import { useRef, useState } from "react";
  4  
  5  import { cn } from "./cn";
  6  import { GrainOverlay } from "./grain-overlay";
  7  import { PortraitCanvas, type PortraitCanvasHandle } from "./portrait-canvas";
  8  
  9  const SHEETS_KEY = "dungeon-motion-sheets";
 10  type SheetData = Record<string, boolean | string>;
 11  
 12  function getSheets(): Record<string, SheetData> {
 13    try {
 14      return JSON.parse(localStorage.getItem(SHEETS_KEY) || "{}");
 15    } catch {
 16      return {};
 17    }
 18  }
 19  
 20  function serializeForm(): SheetData {
 21    const form = document.querySelector("form");
 22    if (!form) return {};
 23    const data: SheetData = {};
 24    for (const el of form.elements) {
 25      const field = el as
 26        | HTMLInputElement
 27        | HTMLSelectElement
 28        | HTMLTextAreaElement;
 29      if (!field.name) continue;
 30      data[field.name] =
 31        field instanceof HTMLInputElement && field.type === "checkbox"
 32          ? field.checked
 33          : field.value;
 34    }
 35    const portrait = localStorage.getItem("dungeon-motion-portrait");
 36    if (portrait) data["__portrait__"] = portrait;
 37    return data;
 38  }
 39  
 40  function applySheet(data: SheetData) {
 41    const form = document.querySelector("form");
 42    if (!form) return;
 43    for (const el of form.elements) {
 44      const field = el as
 45        | HTMLInputElement
 46        | HTMLSelectElement
 47        | HTMLTextAreaElement;
 48      if (!field.name || !(field.name in data)) continue;
 49      if (field instanceof HTMLInputElement && field.type === "checkbox") {
 50        field.checked = data[field.name] as boolean;
 51      } else {
 52        field.value = data[field.name] as string;
 53      }
 54    }
 55    if (typeof data["__portrait__"] === "string") {
 56      localStorage.setItem("dungeon-motion-portrait", data["__portrait__"]);
 57    }
 58    form.dispatchEvent(new Event("input", { bubbles: true }));
 59    form
 60      .querySelector('input[type="checkbox"]')
 61      ?.dispatchEvent(new Event("change", { bubbles: true }));
 62  }
 63  
 64  function SheetControls() {
 65    const [names, setNames] = useState<string[]>(() => Object.keys(getSheets()));
 66    const [saved, setSaved] = useState(false);
 67  
 68    return (
 69      <div className="fixed top-3 right-3 print:hidden flex items-center gap-1 z-50">
 70        <button
 71          className="text-[10px] tracking-widest uppercase text-stone-400/70 dark:text-stone-500/70 hover:text-stone-600 dark:hover:text-stone-300 focus-visible:text-stone-600 dark:focus-visible:text-stone-300 focus-visible:underline underline-offset-2 outline-none transition-colors cursor-pointer"
 72          onClick={(e) => {
 73            e.preventDefault();
 74            const data = serializeForm();
 75            const name = (data["char-name"] as string | undefined)?.trim();
 76            if (!name) return;
 77            const sheets = getSheets();
 78            sheets[name] = data;
 79            localStorage.setItem(SHEETS_KEY, JSON.stringify(sheets));
 80            setNames(Object.keys(sheets));
 81            setSaved(true);
 82            setTimeout(() => setSaved(false), 1500);
 83          }}
 84          type="button"
 85        >
 86          [ {saved ? "Saved" : "Save"} ]
 87        </button>
 88        <span className="text-stone-300/40 dark:text-stone-600/40 text-[10px]"></span>
 89        <select
 90          className="text-[10px] tracking-widest uppercase text-stone-400/70 dark:text-stone-500/70 hover:text-stone-600 dark:hover:text-stone-300 focus-visible:text-stone-600 dark:focus-visible:text-stone-300 focus-visible:underline underline-offset-2 outline-none transition-colors cursor-pointer bg-transparent appearance-none"
 91          onChange={(e) => {
 92            const name = e.currentTarget.value;
 93            if (!name) return;
 94            e.currentTarget.value = "";
 95            const sheets = getSheets();
 96            if (sheets[name]) applySheet(sheets[name]);
 97          }}
 98          onFocus={() => setNames(Object.keys(getSheets()))}
 99          value=""
100        >
101          <option value="">[ Load ]</option>
102          {names.map((n) => (
103            <option key={n} value={n}>
104              {n}
105            </option>
106          ))}
107        </select>
108      </div>
109    );
110  }
111  
112  export function CharacterSheet() {
113    return (
114      <form autoComplete="off" className="min-h-screen">
115        <GrainOverlay />
116        <SheetControls />
117  
118        <div className="relative max-w-6xl mx-auto px-6 print:max-w-none print:mx-0 print:px-0">
119          <div className="grid grid-cols-1 md:grid-cols-2 print:grid-cols-2 gap-8 md:gap-0 print:gap-0">
120            <LeftPage />
121            <RightPage />
122          </div>
123        </div>
124      </form>
125    );
126  }
127  
128  function LeftPage() {
129    return (
130      <div className="md:pr-8 print:pr-8 md:border-r print:border-r border-dashed border-stone-300 dark:border-stone-600 print:border-stone-400 space-y-6">
131        <div className="flex gap-6">
132          <div className="flex-1 min-w-0 space-y-2">
133            <InlineField label="Name" name="char-name" />
134            <InlineField label="Look" name="look" />
135            <InlineField label="Background" name="background" />
136            <div className="flex gap-4">
137              <InlineField label="Level" name="level" />
138              <InlineField label="XP" name="xp" />
139            </div>
140            <CombatShapes className="mt-4" />
141          </div>
142          <PortraitFrame />
143        </div>
144        <StatBlockWithDebilities />
145        <InlineField label="Instinct" name="instinct" />
146        <BondLines />
147      </div>
148    );
149  }
150  
151  function PortraitFrame() {
152    const canvasRef = useRef<PortraitCanvasHandle>(null);
153    const [hasStrokes, setHasStrokes] = useState(false);
154  
155    return (
156      <div className="shrink-0 w-36 self-stretch relative">
157        <div className="absolute inset-0 border-[3px] border-double border-stone-700 dark:border-stone-300 print:border-stone-800 rounded-xl [corner-shape:superellipse(-1.1)] overflow-hidden">
158          <PortraitCanvas onStrokesChange={setHasStrokes} ref={canvasRef} />
159        </div>
160        {hasStrokes && (
161          <button
162            className="absolute -top-2 -right-2 p-0.5 text-stone-400 hover:text-stone-700 dark:hover:text-stone-200 print:hidden"
163            onClick={() => canvasRef.current?.clear()}
164            title="Clear portrait"
165            type="button"
166          >
167            <svg
168              className="size-3.5"
169              fill="none"
170              stroke="currentColor"
171              strokeLinecap="round"
172              strokeLinejoin="round"
173              strokeWidth="2"
174              viewBox="0 0 24 24"
175            >
176              <path d="M18 6 6 18M6 6l12 12" />
177            </svg>
178          </button>
179        )}
180      </div>
181    );
182  }
183  
184  const STAT_PAIRS: [StatDef, StatDef, string][] = [
185    [
186      { abbr: "STR", name: "Strength" },
187      { abbr: "DEX", name: "Dexterity" },
188      "weakened",
189    ],
190    [
191      { abbr: "INT", name: "Intelligence" },
192      { abbr: "WIS", name: "Wisdom" },
193      "dazed",
194    ],
195    [
196      { abbr: "CON", name: "Constitution" },
197      { abbr: "CHA", name: "Charisma" },
198      "miserable",
199    ],
200  ];
201  
202  interface StatDef {
203    abbr: string;
204    name: string;
205  }
206  
207  function StatBlockWithDebilities() {
208    return (
209      <div>
210        <p className="font-hand text-stone-500 dark:text-stone-400 mb-3">
211          <span className="font-serif font-bold tracking-wider text-stone-700 dark:text-stone-300 text-sm">
212            Attributes
213          </span>{" "}
214          Assign +2, +1, +1, +0, +0, −1. Debility marked → roll with disadvantage.
215        </p>
216        <div className="flex gap-3 print:gap-2">
217          {STAT_PAIRS.map(([left, right, debility]) => (
218            <StatPairWithFork
219              debility={debility}
220              key={debility}
221              left={left}
222              right={right}
223            />
224          ))}
225        </div>
226      </div>
227    );
228  }
229  
230  function StatPairWithFork({
231    debility,
232    left,
233    right,
234  }: {
235    debility: string;
236    left: StatDef;
237    right: StatDef;
238  }) {
239    return (
240      <div className="flex-1">
241        <div className="flex gap-1.5 print:gap-1">
242          <StatBox stat={left} />
243          <StatBox stat={right} />
244        </div>
245        {/* Bracket fork with debility circle */}
246        <div className="relative flex items-start justify-center mt-0">
247          {/* Left arm of bracket */}
248          <div className="absolute left-1/4 top-0 w-[calc(25%)] h-3 border-l border-b border-stone-400 dark:border-stone-600 print:border-stone-500" />
249          {/* Right arm of bracket */}
250          <div className="absolute right-1/4 top-0 w-[calc(25%)] h-3 border-r border-b border-stone-400 dark:border-stone-600 print:border-stone-500" />
251          {/* Center stem */}
252          <div className="w-px h-4 bg-stone-400 dark:bg-stone-600 print:bg-stone-500 mt-3" />
253        </div>
254        {/* Debility circle checkbox */}
255        <div className="flex flex-col items-center -mt-0.5">
256          <input
257            className="size-4 rounded-full"
258            name={`debility-${debility}`}
259            type="checkbox"
260          />
261          <span className="text-[10px] text-stone-500 dark:text-stone-400 mt-0.5">
262            {debility}
263          </span>
264        </div>
265      </div>
266    );
267  }
268  
269  function StatBox({ stat }: { stat: StatDef }) {
270    return (
271      <div className="flex-1 relative border-2 border-stone-400 dark:border-stone-500 print:border-stone-500 rounded-lg [corner-shape:superellipse(-1.1)] pt-4 pb-4 px-1.5 print:px-1 text-center">
272        <div className="absolute top-0 left-1/2 -translate-x-1/2 -translate-y-1/2 bg-stone-50 dark:bg-stone-900 print:bg-white px-1 text-[10px] tracking-wider text-stone-700 dark:text-stone-300 leading-none whitespace-nowrap">
273          {stat.name}
274        </div>
275        <input
276          className="w-full text-center font-hand text-3xl print:text-2xl bg-transparent text-stone-800 dark:text-stone-200 focus:outline-none border-none"
277          name={`stat-${stat.abbr.toLowerCase()}`}
278          placeholder="+0"
279          type="text"
280        />
281        <div className="absolute bottom-0 left-1/2 -translate-x-1/2 translate-y-[calc(50%+2px)] bg-stone-50 dark:bg-stone-900 print:bg-white px-1 text-[9px] text-stone-500 dark:text-stone-400 leading-none whitespace-nowrap">
282          ({stat.abbr})
283        </div>
284      </div>
285    );
286  }
287  
288  function CombatShapes({ className }: { className?: string }) {
289    return (
290      <div
291        className={cn(
292          "flex justify-center items-end justify-self-start gap-1",
293          className,
294        )}
295      >
296        <ShapedStat
297          className="-translate-y-1.5 -translate-x-px"
298          label="HP"
299          name="hp"
300          viewBox="0 -20 250 270"
301        >
302          <path
303            d="m239.61 70.33c-1.91 10.33-3.26 19.63-9.43 30.62-6.77 12.06-16.44 22.58-26.07 36.01-6.17 5.51-11.21 13.35-19.84 19.85-5.65 4.95-11.3 10.26-16.34 12.96-11.03 8.74-21.43 18.43-37.13 36.18-4.49-4.49-7.82-10.71-12.81-15.25-12.48-11.03-21.41-22.94-35.18-32.34l0.48 0.48 0.58 0.92-2.29-1.4c-7.91-4.99-17.08-14.48-23.75-20.09-8.17-7.16-14.05-16.5-22.01-26.78 8.35 7.94 14.92 16.78 23.41 22.99l-0.82-1.72c-10.4-11.6-21.33-20.7-30.81-36.3-5.65-8.89-7.09-17.49-8.28-29.55-0.38-8.89 2.01-17.83 5.29-27.81 1.86-9.98 9.07-18.07 18.94-23.53 12.48-7.6 21.41-13.11 31.38-13.2 3.67-0.04 7.29 0.57 10.91 0.9 12.96 3.43 25.24 10.1 32.91 18.99 3.42 3.97 6.99 7.74 8.76 10.69-1.71 2.02-2.53 4.32-2.67 7.22l1.19-0.19c1.39-1.58 1.68-3.11 2.69-4.51 2.2 1.01 3.86-0.47 5.72-5.31 1.4-3.17 4.54-6.07 8.06-9.5 10.98-10.48 20.41-15.58 30.33-16.5 10.67-1.93 21.53-2.17 31.57 0.33 8.58 1.3 14.23 6.61 20.7 13.28 4.9 4.49 8.32 10 11.75 15.56 3.67 6.77 3.62 27.38 2.76 37z"
304            strokeWidth="7"
305          />
306        </ShapedStat>
307        <ShapedStat
308          className="-translate-y-1"
309          label="Armor"
310          name="armor"
311          viewBox="28 20 160 178"
312        >
313          <path
314            d="m164.9 49.28-1.15 0.21 0.11-4.2-1.05-0.92c-7.93-0.92-15.09-1.12-20.79-3.86l-3.94-3.16 0.63-2.26-0.73-0.92-0.94 0.31-0.42 1.64 1.05 3.79-0.42 0.41-4.25-2.06c-11.05-4.35-21.8-10.13-29.28-15.1l-1.45 1.12 0.21-1.53c-12.58 8.65-25.82 16.79-42.33 18.95l-7.48 1.95-7.93 0.21c-3.61 10.15-2.46 28.15 0.08 43.92 1.45 9.01 2.39 13.67 3.23 18.23l-3.23 6.18-0.52 2.74 1.65-0.31 1.75-0.61 3.5 2.55 7.93 22.14-0.32 10.46c6.32 0.62 9.93 3.88 15.73 11.78l-1.26 0.2 1.46 1.53c8.03 6.18 16.5 15.43 28.87 23.78 12.47-7.84 23.22-19.23 31.25-29.9 3.83-4.25 8.59-7.7 13.83-8.32-1.25-8.04-0.72-12.04 3.42-20.44 6.85-14.12 9.6-28.45 12.78-54.78 0.84-6.23 0.84-15.98-0.01-23.73z"
315            strokeWidth="5"
316          />
317        </ShapedStat>
318        <ShapedStat label="Damage" name="damage" viewBox="30 0 196 256">
319          <path
320            d="M 127.27,18.19 L 117.72,33.06 L 108.69,46.7 L 102.81,54.19 L 101.06,56.91 L 100.04,57.96 L 98.82,60.06 L 94.72,65.38 L 94.42,65.55 L 94.62,66.03 L 89.07,73.82 L 83.52,82.62 L 81.78,85.01 L 78.35,88.91 L 77.86,90.33 L 73.22,97.51 L 72.92,97.78 L 72.69,98.62 L 65.11,109.45 L 64.62,110.39 L 56.34,122.23 L 51.61,128.23 L 53.12,129.01 C 54.93,130.81 56.71,133.84 58.66,136.46 C 61.97,140.72 64.71,146.26 67.95,151.22 C 70.08,155.19 73.64,159.48 76.57,164.05 C 79.13,167.21 81.68,171.47 84.45,175.3 C 87.01,177.92 89.16,182.18 91.94,185.04 L 92.83,186.02 L 96.11,190.62 L 97.78,192.55 L 103.92,201.11 L 105.01,202.33 L 116.11,218.26 L 119.01,222.73 L 120.72,225.86 L 125.69,235.06 L 127.81,237.12 L 138.91,218.26 L 143.88,211.01 L 145.07,209.42 L 149.61,202.63 L 153.74,197.77 L 161.02,187.69 L 162.41,186.2 L 169.01,176.46 L 173.81,169.21 L 176.81,165.7 L 177.51,164.18 L 183.65,153.72 L 186.03,150.56 L 195.16,137.51 L 198.92,132.91 L 199.78,132.23 L 203.44,127.86 L 191.72,111.42 L 190.92,109.62 L 189.9,108.71 L 189.76,107.73 L 188.9,107.09 L 184.53,100.33 L 178.19,91.13 L 176.31,88.71 L 175.22,87.56 L 172.15,82.6 L 167.18,76.7 L 164.11,72.23 L 161.14,68.72 L 155.42,61.23 L 152.87,57.22 L 145.42,47.55 L 144.56,45.86 L 136.21,33.82 L 135.09,32.4 L 132.47,27.44 L 127.27,18.19 Z"
321            strokeWidth="6"
322          />
323        </ShapedStat>
324      </div>
325    );
326  }
327  
328  function ShapedStat({
329    children,
330    className,
331    label,
332    name,
333    viewBox = "0 0 48 48",
334  }: {
335    children: React.ReactNode;
336    className?: string;
337    label: string;
338    name: string;
339    viewBox?: string;
340  }) {
341    return (
342      <div className="flex flex-col items-center">
343        <span className="text-xs font-bold tracking-wider text-stone-500 dark:text-stone-400 mb-0.5">
344          {label}
345        </span>
346        <div className="relative size-28">
347          <svg
348            className="absolute inset-0 w-full h-full text-stone-400 dark:text-stone-500 print:text-stone-500"
349            fill="none"
350            stroke="currentColor"
351            strokeLinejoin="round"
352            viewBox={viewBox}
353          >
354            {children}
355          </svg>
356          <input
357            className={cn(
358              "absolute inset-0 w-full h-full text-center font-hand text-5xl bg-transparent text-stone-800 dark:text-stone-200 focus:outline-none -translate-x-1.5",
359              className,
360            )}
361            name={name}
362            type="text"
363          />
364        </div>
365      </div>
366    );
367  }
368  
369  function RightPage() {
370    return (
371      <div className="md:pl-8 print:pl-8 space-y-5">
372        <SuppliesTrack />
373  
374        <div>
375          <SectionLabel>Gear</SectionLabel>
376          <div className="space-y-1.5">
377            {Array.from({ length: 5 }).map((_, i) => (
378              <BlankLine key={i} name={`possession-${i}`} />
379            ))}
380          </div>
381        </div>
382  
383        <AcquiredMoves />
384      </div>
385    );
386  }
387  
388  function BondLines() {
389    return (
390      <div>
391        <SectionLabel>Bonds</SectionLabel>
392        <div className="space-y-1.5">
393          {Array.from({ length: 4 }).map((_, i) => (
394            <BlankLine key={i} name={`bond-${i}`} />
395          ))}
396        </div>
397      </div>
398    );
399  }
400  
401  function SuppliesTrack() {
402    const supplies = [
403      { count: 3, name: "Rations" },
404      { count: 3, name: "Bandages" },
405      { count: 3, name: "Ammo" },
406      { count: 5, name: "Adventuring Gear" },
407    ];
408  
409    return (
410      <div>
411        <SectionLabel>Supplies</SectionLabel>
412        <div className="space-y-1.5">
413          {supplies.map((s) => (
414            <div className="flex items-center gap-2" key={s.name}>
415              <span className="text-sm text-stone-700 dark:text-stone-300 shrink-0 whitespace-nowrap">
416                {s.name}
417              </span>
418              <div className="flex gap-1">
419                {Array.from({ length: s.count }).map((_, i) => (
420                  <input
421                    className="size-3.5"
422                    key={i}
423                    name={`supply-${s.name.toLowerCase().replaceAll(" ", "-")}-${i}`}
424                    type="checkbox"
425                  />
426                ))}
427              </div>
428            </div>
429          ))}
430        </div>
431      </div>
432    );
433  }
434  
435  function AcquiredMoves() {
436    return (
437      <div>
438        <SectionLabel>Moves</SectionLabel>
439        <div className="space-y-2">
440          {Array.from({ length: 8 }).map((_, i) => (
441            <div className="flex items-start gap-2" key={i}>
442              <input
443                className="size-4 shrink-0 mt-px"
444                name={`move-check-${i}`}
445                type="checkbox"
446              />
447              <textarea
448                className="flex-1 bg-transparent border-b border-stone-300 dark:border-stone-600 print:border-stone-400 font-hand text-base text-stone-800 dark:text-stone-200 focus:outline-none focus:border-stone-500 resize-none overflow-visible py-1 print:min-h-10 first-line:font-serif leading-none field-sizing-content"
449                name={`move-name-${i}`}
450                onChange={(event) => {
451                  const textarea = event.currentTarget;
452                  textarea.scrollTop = 0;
453                }}
454              />
455            </div>
456          ))}
457        </div>
458      </div>
459    );
460  }
461  
462  // -- Shared Utilities --
463  
464  function InlineField({
465    className,
466    label,
467    name,
468  }: {
469    className?: string;
470    label: string;
471    name: string;
472  }) {
473    return (
474      <label className={cn("flex items-baseline gap-2 flex-1", className)}>
475        <span className="font-bold tracking-wide text-stone-700 dark:text-stone-300 shrink-0 whitespace-nowrapt text-sm">
476          {label}
477        </span>
478        <input
479          className={cn(
480            "flex-1 w-0 bg-transparent border-b border-stone-300 dark:border-stone-600 print:border-stone-400 leading-none",
481            "font-hand text-stone-800 dark:text-stone-200",
482            "focus:outline-none focus:border-stone-500",
483            "text-2xl leading-none",
484          )}
485          name={name}
486          type="text"
487        />
488      </label>
489    );
490  }
491  
492  function BlankLine({ name }: { name: string }) {
493    return (
494      <input
495        className="w-full bg-transparent border-b border-stone-300 dark:border-stone-600 print:border-stone-400 font-hand text-xl text-stone-800 dark:text-stone-200 focus:outline-none focus:border-stone-500"
496        name={name}
497        type="text"
498      />
499    );
500  }
501  
502  function SectionLabel({ children }: { children: React.ReactNode }) {
503    return (
504      <h3 className="text-sm font-serif font-bold tracking-wide text-stone-700 dark:text-stone-300 mb-2">
505        {children}
506      </h3>
507    );
508  }