diff --git a/apps/web/src/components/sheet-view.tsx b/apps/web/src/components/sheet-view.tsx
index ef0997f..64117ab 100644
--- a/apps/web/src/components/sheet-view.tsx
+++ b/apps/web/src/components/sheet-view.tsx
@@ -1,5 +1,12 @@
-import type { Appearance, CharacterSheet, SheetSave, StatValue } from "@riftforge/rules";
-import { For, Show, type JSX } from "solid-js";
+import type {
+ Appearance,
+ CharacterSheet,
+ ResolvedSkill,
+ SheetSave,
+ Spell,
+ StatValue,
+} from "@riftforge/rules";
+import { createEffect, createSignal, For, on, onCleanup, Show, type JSX } from "solid-js";
import { Chip, DataValue, MonoLabel, Panel, SectionTitle } from "./ui.tsx";
/** "rolled 17" once vitals are pinned, otherwise the possible range. */
@@ -29,6 +36,15 @@ const APPEARANCE_ROWS = [
["disposition", "DISPOSITION"],
] as const satisfies readonly (readonly [keyof Appearance, string])[];
+/** Roll handlers the dossier page wires in; absent (e.g. in the wizard's
+ * review preview) the sheet renders read-only. */
+export interface SheetActions {
+ rollSave: (name: string, save: SheetSave) => void;
+ rollSkill: (skill: ResolvedSkill) => void;
+ castSpell: (spell: Spell) => void;
+ rollCombat: (kind: "strike" | "parry" | "dodge", bonus: number) => void;
+}
+
/** Schematic silhouette — the portrait frame ships before upload does. */
function PortraitFrame() {
return (
@@ -61,15 +77,94 @@ function PortraitFrame() {
);
}
-function StatRow(props: { label: JSX.Element; value: JSX.Element; live?: boolean }) {
+/** A stat line; clickable (and amber on hover) when the page wires a roll. */
+function StatRow(props: {
+ label: JSX.Element;
+ value: JSX.Element;
+ live?: boolean;
+ onRoll?: () => void;
+}) {
+ const value = () => (
+
+ {props.value}
+
+ );
return (
-
- {props.label}
-
+ {props.label}
+ {value()}
+
+ }
+ >
+
+
+ );
+}
+
+const barFill = {
+ blood: "bg-gradient-to-r from-[#7a2018] to-blood shadow-[0_0_10px_rgb(226_59_46/0.4)]",
+ amber: "bg-gradient-to-r from-[#7a5a17] to-amber shadow-[0_0_10px_rgb(255_174_61/0.35)]",
+ ley: "bg-gradient-to-r from-[#14606e] to-ley ppe-pulse",
+} as const;
+
+/**
+ * A vital as a resource bar: fill = rolled value against the range maximum,
+ * empty until rolled. The value flashes like a dot-matrix strike when a new
+ * roll lands (fine-grained update — the sheet is NOT remounted).
+ */
+function VitalBar(props: { label: string; stat: StatValue; tone: keyof typeof barFill }) {
+ const [flash, setFlash] = createSignal(false);
+ let timer: ReturnType | undefined;
+ createEffect(
+ on(
+ () => props.stat.rolled,
+ (rolled, previous) => {
+ if (rolled === undefined || rolled === previous) return;
+ setFlash(false);
+ requestAnimationFrame(() => setFlash(true));
+ clearTimeout(timer);
+ timer = setTimeout(() => setFlash(false), 700);
+ },
+ { defer: true },
+ ),
+ );
+ onCleanup(() => clearTimeout(timer));
+
+ const pct = () =>
+ props.stat.rolled === undefined || props.stat.max <= 0
+ ? 0
+ : Math.min(100, Math.round((props.stat.rolled / props.stat.max) * 100));
+
+ return (
+
+
+ {props.label}
+
+ {statValue(props.stat)}
+
+
+
+
+
);
}
@@ -77,11 +172,12 @@ function StatRow(props: { label: JSX.Element; value: JSX.Element; live?: boolean
/**
* Every `deriveSheet` section in Ley Terminal chrome (DESIGN.md). Shared by
* the live sheet page and the builder's review preview so they never drift.
+ * With `actions`, saves/skills/spells/combat rows roll on click.
*/
-export function SheetView(props: { sheet: CharacterSheet; vitalsExtra?: JSX.Element }) {
+export function SheetView(props: { sheet: CharacterSheet; actions?: SheetActions }) {
const s = () => props.sheet;
return (
-
+