From 0c20d765fdf38d775c5ab536573212939b73f1cc Mon Sep 17 00:00:00 2001 From: StreamDemon Date: Sun, 5 Jul 2026 01:34:38 +0800 Subject: [PATCH 1/3] =?UTF-8?q?feat(web):=20interactive=20character=20scre?= =?UTF-8?q?en=20=E2=80=94=20clickable=20rolls,=20telemetry=20rail,=20vital?= =?UTF-8?q?s=20bars?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR C of #10, closing the issue: the dossier becomes the DESIGN.md character screen and the sheet becomes playable. - lib/telemetry.ts: the field-telemetry log store (capped, toned entries) + machineName/d20Line formatters - components/telemetry-rail.tsx: the right-hand rail — role=log feed that follows new entries, blinking phosphor cursor, quick actions slot (Roll Vitals moves here per the design) - SheetView: optional SheetActions make saves (non-percentile), skills, spells, and strike/parry/dodge rows clickable with amber hover + CLICK TO ROLL affordances; vitals render as resource bars (H.P. blood, S.D.C. amber, P.P.E. ley gradient with the pulsating glow) filled by rolled value vs range max; review preview stays read-only (no actions wired) - gameplay rolls run CLIENT-SIDE through the isomorphic engine (rollSave/rollSkillCheck/rollD20 from #7) and print to the log — ephemeral by design; only rollVitals persists, and it logs its locked results - motion: scanline reveal on sheet load; dot-matrix strike flash when a vital lands (fine-grained update — the sheet Match is non-keyed now, so subscription pushes update text nodes instead of remounting, which also lets the flash observe value changes); all effects collapse under prefers-reduced-motion; implemented in CSS keyframes (solid-motionone deferred until something needs real choreography) Browser-verified: rolled a save, a skill check, a strike, cast a spell, and rolled vitals — all printed to the rail with correct verdict tones, the P.P.E. bar pulses, zero console errors. 158 tests, per-package checks and prod build clean. --- apps/web/src/components/sheet-view.tsx | 186 ++++++++++++++++++--- apps/web/src/components/telemetry-rail.tsx | 48 ++++++ apps/web/src/lib/telemetry.ts | 51 ++++++ apps/web/src/pages/character-sheet.tsx | 82 ++++++--- apps/web/src/styles.css | 34 +++- 5 files changed, 357 insertions(+), 44 deletions(-) create mode 100644 apps/web/src/components/telemetry-rail.tsx create mode 100644 apps/web/src/lib/telemetry.ts 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 ( -
+
@@ -155,26 +251,43 @@ export function SheetView(props: { sheet: CharacterSheet; vitalsExtra?: JSX.Elem VITALS
- - - + + - {(ppe) => } + {(ppe) => } + {(strength) => }
- {props.vitalsExtra}
COMBAT
- - - + props.actions!.rollCombat("strike", s().combat.strike)) + } + /> + props.actions!.rollCombat("parry", s().combat.parry)) + } + /> + props.actions!.rollCombat("dodge", s().combat.dodge)) + } + />
@@ -182,11 +295,26 @@ export function SheetView(props: { sheet: CharacterSheet; vitalsExtra?: JSX.Elem
- SAVING THROWS +
+ SAVING THROWS + + CLICK TO ROLL + +
{([name, save]) => ( - + props.actions!.rollSave(name, save) + : undefined + } + /> )}
@@ -194,7 +322,12 @@ export function SheetView(props: { sheet: CharacterSheet; vitalsExtra?: JSX.Elem
- SKILLS — FIELD RATED +
+ SKILLS — FIELD RATED + + CLICK TO ROLL + +
{(skill) => ( @@ -213,6 +346,7 @@ export function SheetView(props: { sheet: CharacterSheet; vitalsExtra?: JSX.Elem / {skill.value2}% } + onRoll={props.actions && (() => props.actions!.rollSkill(skill))} /> )} @@ -220,7 +354,12 @@ export function SheetView(props: { sheet: CharacterSheet; vitalsExtra?: JSX.Elem - SPELL KNOWLEDGE ({s().spells.count}) +
+ SPELL KNOWLEDGE ({s().spells.count}) + + CLICK TO CAST + +
{(spell) => ( @@ -235,6 +374,7 @@ export function SheetView(props: { sheet: CharacterSheet; vitalsExtra?: JSX.Elem {spell.ppe} PPE } + onRoll={props.actions && (() => props.actions!.castSpell(spell))} /> )} diff --git a/apps/web/src/components/telemetry-rail.tsx b/apps/web/src/components/telemetry-rail.tsx new file mode 100644 index 0000000..22f8a6e --- /dev/null +++ b/apps/web/src/components/telemetry-rail.tsx @@ -0,0 +1,48 @@ +import { createEffect, For, on, type JSX } from "solid-js"; +import type { TelemetryEntry, TelemetryTone } from "../lib/telemetry.ts"; + +const toneClass: Record = { + machine: "text-amber", + magic: "text-ley", + good: "text-ok", + bad: "text-blood-text", + dim: "text-dead", +}; + +/** + * The right-hand field-telemetry band (DESIGN.md): rolls print here like + * incoming field data, with quick actions below. + */ +export function TelemetryRail(props: { entries: TelemetryEntry[]; actions?: JSX.Element }) { + let logEl: HTMLDivElement | undefined; + + // Follow the feed: newest entry scrolls into view as it prints. + createEffect( + on( + () => props.entries.length, + () => logEl?.scrollTo({ top: logEl.scrollHeight }), + { defer: true }, + ), + ); + + return ( + + ); +} diff --git a/apps/web/src/lib/telemetry.ts b/apps/web/src/lib/telemetry.ts new file mode 100644 index 0000000..72d8d4b --- /dev/null +++ b/apps/web/src/lib/telemetry.ts @@ -0,0 +1,51 @@ +import { createSignal, type Accessor } from "solid-js"; + +export type TelemetryTone = "machine" | "magic" | "good" | "bad" | "dim"; + +export interface TelemetryEntry { + id: number; + text: string; + tone: TelemetryTone; +} + +const MAX_ENTRIES = 60; + +/** + * The dossier's field-telemetry log: every roll prints here, newest last. + * Ephemeral by design — gameplay rolls are moments at the table, not records + * (only `rollVitals` persists, via its mutation). + */ +export function createTelemetry(boot: string[] = []): { + entries: Accessor; + log: (text: string, tone?: TelemetryTone) => void; +} { + let nextId = 0; + const [entries, setEntries] = createSignal( + boot.map((text) => ({ id: nextId++, text, tone: "dim" })), + ); + const log = (text: string, tone: TelemetryTone = "machine") => + setEntries((current) => [...current, { id: nextId++, text, tone }].slice(-MAX_ENTRIES)); + return { entries, log }; +} + +/** "horrorFactor" -> "HORROR FACTOR" for the machine voice. */ +export function machineName(key: string): string { + return key.replace(/([A-Z])/g, " $1").toUpperCase(); +} + +/** `d20[14]+3 = 17 vs 15+ ✓` — the standard d20 telemetry fragment. */ +export function d20Line(roll: { + die: number; + bonus: number; + total: number; + target?: number; + success?: boolean; + naturalTwenty: boolean; + naturalOne: boolean; +}): string { + const bonus = roll.bonus >= 0 ? `+${roll.bonus}` : `${roll.bonus}`; + const verdict = + roll.target !== undefined ? ` vs ${roll.target}+ ${roll.success ? "✓" : "✗"}` : ""; + const nat = roll.naturalTwenty ? " — NAT 20" : roll.naturalOne ? " — NAT 1" : ""; + return `d20[${roll.die}]${bonus} = ${roll.total}${verdict}${nat}`; +} diff --git a/apps/web/src/pages/character-sheet.tsx b/apps/web/src/pages/character-sheet.tsx index 3d3ab57..76269ff 100644 --- a/apps/web/src/pages/character-sheet.tsx +++ b/apps/web/src/pages/character-sheet.tsx @@ -1,15 +1,23 @@ import { api } from "@riftforge/backend/api"; import type { Id } from "@riftforge/backend/dataModel"; -import type { CharacterSheet, Narrative } from "@riftforge/rules"; +import { + rollD20, + rollSave, + rollSkillCheck, + type CharacterSheet, + type Narrative, +} from "@riftforge/rules"; import { useParams } from "@solidjs/router"; import { createEffect, createSignal, Match, on, Show, Switch, type Accessor } from "solid-js"; import { createStore, reconcile } from "solid-js/store"; import { NarrativeFields } from "../components/narrative-fields.tsx"; -import { SheetView } from "../components/sheet-view.tsx"; +import { SheetView, type SheetActions } from "../components/sheet-view.tsx"; +import { TelemetryRail } from "../components/telemetry-rail.tsx"; import { Alert, Button, MonoLabel, Panel } from "../components/ui.tsx"; import { convex } from "../lib/client.ts"; import { createMutation, createQuery } from "../lib/convex.ts"; import { fromNarrative, toNarrative } from "../lib/narrative.ts"; +import { createTelemetry, d20Line, machineName } from "../lib/telemetry.ts"; /** Edit the player-authored file fields in place; saves via updateNarrative. */ function NarrativeEditor(props: { id: Id<"characters">; narrative: Narrative | undefined }) { @@ -81,7 +89,12 @@ function NarrativeEditor(props: { id: Id<"characters">; narrative: Narrative | u ); } -/** The live sheet: `SheetView` fed by the `characters.sheet` subscription. */ +/** + * The dossier: the live sheet plus the field-telemetry rail. Gameplay rolls + * (saves, skills, strikes, casts) run CLIENT-SIDE through the isomorphic + * engine and print to the log — moments at the table, not records. Only + * `rollVitals` persists, via its mutation. + */ export function CharacterSheetPage() { const params = useParams<{ id: string }>(); const id = () => params.id as Id<"characters">; @@ -90,19 +103,48 @@ export function CharacterSheetPage() { // so re-pin the rules-layer type here. const sheet = query.data as Accessor; const rollVitals = createMutation(convex, api.characters.rollVitals); + const telemetry = createTelemetry(["// ley-link established", "// awaiting command…"]); const [rollError, setRollError] = createSignal(); + const actions: SheetActions = { + rollSave: (name, save) => { + const roll = rollSave(save); + telemetry.log( + `> SAVE VS ${machineName(name)} :: ${d20Line(roll)}`, + roll.success === undefined ? "machine" : roll.success ? "good" : "bad", + ); + }, + rollSkill: (skill) => { + const check = rollSkillCheck(skill.value); + const label = skill.label ? ` (${skill.label.toUpperCase()})` : ""; + telemetry.log( + `> SKILL :: ${skill.name.toUpperCase()}${label} d%[${check.roll}] vs ${check.value}% ${check.success ? "✓" : "✗"}`, + check.success ? "good" : "bad", + ); + }, + castSpell: (spell) => { + telemetry.log(`> CAST :: ${spell.name.toUpperCase()} — ${spell.ppe} P.P.E.`, "magic"); + }, + rollCombat: (kind, bonus) => { + telemetry.log(`> ${kind.toUpperCase()} :: ${d20Line(rollD20(bonus))}`); + }, + }; + const roll = async () => { setRollError(undefined); try { - await rollVitals({ id: id() }); + const rolled = await rollVitals({ id: id() }); + telemetry.log( + `> ROLL VITALS :: H.P. ${rolled.hitPoints} · S.D.C. ${rolled.sdc}${rolled.ppe !== undefined ? ` · P.P.E. ${rolled.ppe}` : ""} — LOCKED`, + ); } catch (error) { setRollError(error instanceof Error ? error : new Error(String(error))); + telemetry.log("> ROLL VITALS :: WRITE FAILED", "bad"); } }; return ( -
+
// loading dossier…

}> {(err) => COULDN'T LOAD DOSSIER — {err().message}} @@ -110,13 +152,20 @@ export function CharacterSheetPage() { NO FILE ON RECORD. - - {(s) => ( - - @@ -125,16 +174,9 @@ export function CharacterSheetPage() {
} /> - )} +
- {/* Outside the keyed Match: live sheet updates (e.g. rolling vitals - mid-edit) must not remount the editor and wipe typed text. */} - -
- -
-
); } diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index 1b10c2f..67a0592 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -122,6 +122,36 @@ body::after { animation: ppe-pulse 2.5s ease-in-out infinite; } +/* Dot-matrix strike: a live stat just got struck onto the page. */ +@keyframes strike-flash { + 0% { + background: rgb(255 174 61 / 0.45); + box-shadow: 0 0 14px rgb(255 174 61 / 0.5); + } + 100% { + background: transparent; + box-shadow: none; + } +} +.strike-flash { + animation: strike-flash 0.6s ease-out; +} + +/* Scanline reveal: the dossier renders top-to-bottom on load. */ +@keyframes sheet-reveal { + from { + clip-path: inset(0 0 100% 0); + opacity: 0.4; + } + to { + clip-path: inset(0 0 0% 0); + opacity: 1; + } +} +.sheet-reveal { + animation: sheet-reveal 0.45s ease-out; +} + /* Telemetry cursor blink */ @keyframes cursor-blink { 0%, @@ -142,7 +172,9 @@ body::after { animation: none; box-shadow: 0 0 12px rgb(79 216 255 / 0.5); } - .cursor-blink { + .cursor-blink, + .strike-flash, + .sheet-reveal { animation: none; } } From e1f8bb2d018994f32e8f01c74364015a6c18e187 Mon Sep 17 00:00:00 2001 From: StreamDemon Date: Sun, 5 Jul 2026 01:41:10 +0800 Subject: [PATCH 2/3] fix(web): reset field telemetry when the dossier switches characters The log store was created once per mounted page, so navigating between /characters/:id routes carried one character''s roll history onto the next dossier. createTelemetry gains reset(); the page resets to the boot lines (and clears roll errors) on route-id change. --- apps/web/src/lib/telemetry.ts | 10 ++++++---- apps/web/src/pages/character-sheet.tsx | 13 +++++++++++++ 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/apps/web/src/lib/telemetry.ts b/apps/web/src/lib/telemetry.ts index 72d8d4b..c68e0a8 100644 --- a/apps/web/src/lib/telemetry.ts +++ b/apps/web/src/lib/telemetry.ts @@ -18,14 +18,16 @@ const MAX_ENTRIES = 60; export function createTelemetry(boot: string[] = []): { entries: Accessor; log: (text: string, tone?: TelemetryTone) => void; + /** Back to the boot lines — e.g. when the dossier switches characters. */ + reset: () => void; } { let nextId = 0; - const [entries, setEntries] = createSignal( - boot.map((text) => ({ id: nextId++, text, tone: "dim" })), - ); + const bootEntries = () => boot.map((text) => ({ id: nextId++, text, tone: "dim" as const })); + const [entries, setEntries] = createSignal(bootEntries()); const log = (text: string, tone: TelemetryTone = "machine") => setEntries((current) => [...current, { id: nextId++, text, tone }].slice(-MAX_ENTRIES)); - return { entries, log }; + const reset = () => setEntries(bootEntries()); + return { entries, log, reset }; } /** "horrorFactor" -> "HORROR FACTOR" for the machine voice. */ diff --git a/apps/web/src/pages/character-sheet.tsx b/apps/web/src/pages/character-sheet.tsx index 76269ff..9678b5e 100644 --- a/apps/web/src/pages/character-sheet.tsx +++ b/apps/web/src/pages/character-sheet.tsx @@ -106,6 +106,19 @@ export function CharacterSheetPage() { const telemetry = createTelemetry(["// ley-link established", "// awaiting command…"]); const [rollError, setRollError] = createSignal(); + // A new dossier starts with a fresh log: rolls belong to the character + // they were rolled for, not whoever the page shows next. + createEffect( + on( + id, + () => { + telemetry.reset(); + setRollError(undefined); + }, + { defer: true }, + ), + ); + const actions: SheetActions = { rollSave: (name, save) => { const roll = rollSave(save); From 2a8005bb15c60e194e2d81f59fcd4db6b9cfaa2b Mon Sep 17 00:00:00 2001 From: StreamDemon Date: Sun, 5 Jul 2026 01:44:26 +0800 Subject: [PATCH 3/3] fix(web): drop in-flight Roll Vitals results after dossier switch An async rollVitals resolving after navigation could log the previous character''s result (or error) onto the new dossier. The handler captures the id it rolled for and drops the outcome if the route moved. --- apps/web/src/pages/character-sheet.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/web/src/pages/character-sheet.tsx b/apps/web/src/pages/character-sheet.tsx index 9678b5e..1830bcf 100644 --- a/apps/web/src/pages/character-sheet.tsx +++ b/apps/web/src/pages/character-sheet.tsx @@ -144,13 +144,18 @@ export function CharacterSheetPage() { }; const roll = async () => { + // If the dossier switches while the mutation is in flight, the result + // belongs to the character it was rolled for — drop it silently. + const rolledFor = id(); setRollError(undefined); try { - const rolled = await rollVitals({ id: id() }); + const rolled = await rollVitals({ id: rolledFor }); + if (id() !== rolledFor) return; telemetry.log( `> ROLL VITALS :: H.P. ${rolled.hitPoints} · S.D.C. ${rolled.sdc}${rolled.ppe !== undefined ? ` · P.P.E. ${rolled.ppe}` : ""} — LOCKED`, ); } catch (error) { + if (id() !== rolledFor) return; setRollError(error instanceof Error ? error : new Error(String(error))); telemetry.log("> ROLL VITALS :: WRITE FAILED", "bad"); }