Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 38 additions & 2 deletions apps/web/src/builder/steps/identity.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
import { MonoLabel, Panel, TextInput } from "../../components/ui.tsx";
import { createSignal, Show } from "solid-js";
import { NarrativeFields } from "../../components/narrative-fields.tsx";
import { Button, MonoLabel, Panel, TextInput } from "../../components/ui.tsx";
import type { BuilderStore } from "../store.ts";

export function IdentityStep(props: { store: BuilderStore }) {
const [showFile, setShowFile] = createSignal(false);

return (
<Panel class="space-y-3 p-5">
<Panel class="space-y-4 p-5">
<h2 class="font-display text-2xl tracking-[0.03em]">DECLARE IDENTITY</h2>
<label class="block max-w-sm space-y-1">
<MonoLabel>CHARACTER NAME</MonoLabel>
Expand All @@ -14,6 +18,38 @@ export function IdentityStep(props: { store: BuilderStore }) {
onInput={(e) => props.store.setDraft("name", e.currentTarget.value)}
/>
</label>

<div class="border-t border-line pt-3">
<div class="flex items-baseline gap-3">
<h3 class="font-display text-[15px] tracking-[0.1em] text-muted">// PERSONNEL FILE</h3>
<MonoLabel class="text-dead">OPTIONAL — AUTHOR: PLAYER</MonoLabel>
<Button
variant="ghost"
class="ml-auto"
aria-expanded={showFile()}
aria-controls="personnel-file-fields"
onClick={() => setShowFile((v) => !v)}
>
{showFile() ? "collapse" : "expand"}
</Button>
</div>
<Show
when={showFile()}
fallback={
<p class="mt-1 font-mono text-[12px] text-dead">
// epithet, appearance, traits, backstory — make the file yours (editable later on the
dossier)
</p>
}
>
<div class="mt-3" id="personnel-file-fields">
<NarrativeFields
form={props.store.draft.narrative}
onChange={(field, value) => props.store.setDraft("narrative", field, value)}
/>
</div>
</Show>
</div>
</Panel>
);
}
6 changes: 6 additions & 0 deletions apps/web/src/builder/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,13 @@ import {
} from "@riftforge/rules";
import { createMemo, type Accessor } from "solid-js";
import { createStore, type SetStoreFunction } from "solid-js/store";
import { emptyNarrativeForm, toNarrative, type NarrativeForm } from "../lib/narrative.ts";

/** Everything the player has chosen so far. */
export interface Draft {
name: string;
/** Optional player-authored identity (raw form strings). */
narrative: NarrativeForm;
attributes?: Record<AttributeCode, AttributeRoll>;
occId?: string;
alignmentId?: string;
Expand Down Expand Up @@ -52,6 +55,7 @@ export interface BuilderStore {
export function createBuilderStore(): BuilderStore {
const [draft, setDraft] = createStore<Draft>({
name: "",
narrative: { ...emptyNarrativeForm },
psychicClass: "ordinary",
occChoices: {},
related: [],
Expand Down Expand Up @@ -94,6 +98,7 @@ export function createBuilderStore(): BuilderStore {
const attributes = attributeTotals();
const skills = assembled();
if (!draft.occId || !attributes || !skills) return undefined;
const narrative = toNarrative(draft.narrative);
return {
name: draft.name.trim(),
occId: draft.occId,
Expand All @@ -104,6 +109,7 @@ export function createBuilderStore(): BuilderStore {
psychicClass: draft.psychicClass,
skills: skills.skills,
spellIds: draft.spellIds,
...(narrative !== undefined ? { narrative } : {}),
};
});

Expand Down
77 changes: 77 additions & 0 deletions apps/web/src/components/narrative-fields.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { For } from "solid-js";
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
import type { NarrativeForm } from "../lib/narrative.ts";
import { MonoLabel, TextInput } from "./ui.tsx";

// maxLength mirrors the rules-layer schema caps so writes can't be rejected
// for length (appearanceSchema / narrativeSchema in @riftforge/rules).
const APPEARANCE_FIELDS = [
["height", "HEIGHT", 40],
["weight", "WEIGHT", 40],
["age", "AGE", 40],
["eyes", "EYES", 80],
["origin", "ORIGIN", 120],
["disposition", "DISPOSITION", 120],
] as const satisfies readonly (readonly [keyof NarrativeForm, string, number])[];

/** 12 traits × 60 chars + separators — the schema's worst legal case. */
const TRAITS_INPUT_MAX = 12 * 60 + 11 * 2;

/**
* The player-authored identity fields — shared by the wizard's identity step
* and the dossier's edit panel. All optional: story, not mechanics.
*/
export function NarrativeFields(props: {
form: NarrativeForm;
onChange: <K extends keyof NarrativeForm>(field: K, value: string) => void;
}) {
return (
<div class="space-y-3">
<label class="block space-y-1">
<MonoLabel>EPITHET — one line under the name</MonoLabel>
<TextInput
class="w-full"
maxLength={200}
placeholder='"The ley lines whisper, and she whispers back."'
value={props.form.epithet}
onInput={(e) => props.onChange("epithet", e.currentTarget.value)}
/>
</label>
<div class="grid grid-cols-2 gap-2 md:grid-cols-3">
<For each={APPEARANCE_FIELDS}>
{([field, label, max]) => (
<label class="block space-y-1">
<MonoLabel>{label}</MonoLabel>
<TextInput
class="w-full"
maxLength={max}
value={props.form[field]}
onInput={(e) => props.onChange(field, e.currentTarget.value)}
/>
</label>
)}
</For>
</div>
<label class="block space-y-1">
<MonoLabel>TRAITS — comma-separated, up to 12 (60 chars each)</MonoLabel>
<TextInput
class="w-full"
maxLength={TRAITS_INPUT_MAX}
placeholder="Magic Zone survivor, Coalition watchlist, D-Bee sympathizer"
value={props.form.traits}
onInput={(e) => props.onChange("traits", e.currentTarget.value)}
/>
</label>
<label class="block space-y-1">
<MonoLabel>BACKSTORY — your words, the machine won't touch them</MonoLabel>
<textarea
rows={6}
maxLength={20_000}
class="notch-8 w-full border border-line bg-noir px-3 py-2 font-narrative text-[14px] text-fg placeholder:text-dead focus:border-amber"
placeholder="She walked out of the Magic Zone on her fourteenth birthday…"
value={props.form.backstory}
onInput={(e) => props.onChange("backstory", e.currentTarget.value)}
/>
</label>
</div>
);
}
100 changes: 93 additions & 7 deletions apps/web/src/components/sheet-view.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { CharacterSheet, SheetSave, StatValue } from "@riftforge/rules";
import type { Appearance, CharacterSheet, SheetSave, StatValue } from "@riftforge/rules";
import { For, Show, type JSX } from "solid-js";
import { DataValue, MonoLabel, Panel, SectionTitle } from "./ui.tsx";
import { Chip, DataValue, MonoLabel, Panel, SectionTitle } from "./ui.tsx";

/** "rolled 17" once vitals are pinned, otherwise the possible range. */
function statValue(stat: StatValue): string {
Expand All @@ -20,6 +20,47 @@ function saveBonus(save: SheetSave): string {
return `${sign}${save.bonus}${save.percent ? "%" : ""}`;
}

const APPEARANCE_ROWS = [
["height", "HEIGHT"],
["weight", "WEIGHT"],
["age", "AGE"],
["eyes", "EYES"],
["origin", "ORIGIN"],
["disposition", "DISPOSITION"],
] as const satisfies readonly (readonly [keyof Appearance, string])[];

/** Schematic silhouette — the portrait frame ships before upload does. */
function PortraitFrame() {
return (
<div class="relative flex h-44 w-36 shrink-0 items-center justify-center border border-line bg-inset [clip-path:polygon(14px_0,100%_0,100%_calc(100%-14px),calc(100%-14px)_100%,0_100%,0_14px)]">
<div class="absolute inset-0 bg-[repeating-linear-gradient(0deg,rgb(79_216_255/0.05)_0_1px,transparent_1px_4px)]" />
<svg
width="84"
height="108"
viewBox="0 0 120 150"
fill="none"
class="opacity-85 [filter:drop-shadow(0_0_12px_rgb(79_216_255/0.4))]"
aria-hidden="true"
>
<path
d="M60 12 C40 12 30 30 30 48 C30 66 40 80 60 80 C80 80 90 66 90 48 C90 30 80 12 60 12 Z"
stroke="#4FD8FF"
stroke-width="1.5"
/>
<path
d="M18 148 C18 112 36 94 60 94 C84 94 102 112 102 148"
stroke="#4FD8FF"
stroke-width="1.5"
/>
<path d="M30 40 L14 26 M90 40 L106 26" stroke="#FFAE3D" stroke-width="1" opacity="0.7" />
</svg>
<span class="absolute bottom-1.5 left-2 font-mono text-[9px] tracking-[0.14em] text-dead">
NO IMAGE ON FILE
</span>
</div>
);
}

function StatRow(props: { label: JSX.Element; value: JSX.Element; live?: boolean }) {
return (
<div class="flex items-baseline justify-between border-b border-dotted border-line py-1 text-[13.5px] last:border-b-0">
Expand All @@ -41,19 +82,50 @@ export function SheetView(props: { sheet: CharacterSheet; vitalsExtra?: JSX.Elem
const s = () => props.sheet;
return (
<article class="space-y-4">
<header>
<MonoLabel>
LEVEL {s().level} // {s().occ.name.toUpperCase()} // {s().occ.category.toUpperCase()}
</MonoLabel>
<div class="flex items-start justify-between gap-4">
<header class="flex gap-5">
<PortraitFrame />
<div class="min-w-0 flex-1">
<MonoLabel>
LEVEL {s().level} // {s().occ.name.toUpperCase()} // {s().occ.category.toUpperCase()}
</MonoLabel>
<h1 class="font-display text-5xl leading-none tracking-[0.02em]">{s().name}</h1>
<Show when={s().narrative?.epithet}>
{(epithet) => (
<p class="mt-1 font-narrative text-[15px] italic text-[#B9C4CA]">
&ldquo;{epithet()}&rdquo;
</p>
)}
</Show>
<Show when={(s().narrative?.traits?.length ?? 0) > 0}>
<div class="mt-2.5 flex flex-wrap gap-1.5">
<For each={s().narrative!.traits}>{(trait) => <Chip>{trait}</Chip>}</For>
</div>
</Show>
</div>
<div class="flex shrink-0 flex-col items-end gap-3">
<Show when={s().alignment}>
{(alignment) => (
<span class="mt-1 inline-block rotate-2 border-2 border-blood px-3 py-0.5 font-display text-[15px] tracking-[0.12em] text-blood opacity-85">
{alignment().name.toUpperCase()}
</span>
)}
</Show>
<Show
// An `appearance: {}` written via the API is valid but has nothing
// to show — hide the block unless at least one row has a value.
when={APPEARANCE_ROWS.some(([field]) => s().narrative?.appearance?.[field])}
>
<dl class="text-right font-mono text-[11.5px] leading-[1.9] text-muted">
<For each={APPEARANCE_ROWS.filter(([field]) => s().narrative?.appearance?.[field])}>
{([field, label]) => (
<div>
<dt class="inline">{label} </dt>
<dd class="inline text-fg">{s().narrative!.appearance![field]}</dd>
</div>
)}
</For>
</dl>
</Show>
</div>
</header>

Expand Down Expand Up @@ -169,6 +241,20 @@ export function SheetView(props: { sheet: CharacterSheet; vitalsExtra?: JSX.Elem
</div>
</Panel>
</div>

<Show when={s().narrative?.backstory}>
{(backstory) => (
<Panel class="p-4">
<div class="flex items-baseline justify-between">
<SectionTitle>PERSONNEL FILE — NARRATIVE</SectionTitle>
<MonoLabel class="text-dead">AUTHOR: PLAYER</MonoLabel>
</div>
<p class="mt-2 max-w-[68ch] whitespace-pre-wrap font-narrative text-[14.5px] leading-relaxed text-[#C9CFC7]">
{backstory()}
</p>
</Panel>
)}
</Show>
</article>
);
}
71 changes: 71 additions & 0 deletions apps/web/src/lib/narrative.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import type { Narrative } from "@riftforge/rules";

/** Raw form strings for the narrative fields (wizard + dossier editor). */
export interface NarrativeForm {
epithet: string;
height: string;
weight: string;
age: string;
eyes: string;
origin: string;
disposition: string;
/** Comma-separated in the form; split into chips on save. */
traits: string;
backstory: string;
}

export const emptyNarrativeForm: NarrativeForm = {
epithet: "",
height: "",
weight: "",
age: "",
eyes: "",
origin: "",
disposition: "",
traits: "",
backstory: "",
};

const opt = (value: string): string | undefined => {
const trimmed = value.trim();
return trimmed === "" ? undefined : trimmed;
};

/** Form strings -> a Narrative, or undefined when every field is blank. */
export function toNarrative(form: NarrativeForm): Narrative | undefined {
const appearance = {
height: opt(form.height),
weight: opt(form.weight),
age: opt(form.age),
eyes: opt(form.eyes),
origin: opt(form.origin),
disposition: opt(form.disposition),
};
const hasAppearance = Object.values(appearance).some((v) => v !== undefined);
const traits = form.traits
.split(",")
Comment thread
StreamDemon marked this conversation as resolved.
.map((t) => t.trim())
.filter((t) => t.length > 0);
const narrative: Narrative = {
...(opt(form.epithet) !== undefined ? { epithet: opt(form.epithet) } : {}),
...(hasAppearance ? { appearance } : {}),
...(traits.length > 0 ? { traits } : {}),
...(opt(form.backstory) !== undefined ? { backstory: opt(form.backstory) } : {}),
};
return Object.keys(narrative).length > 0 ? narrative : undefined;
}

/** A stored Narrative -> form strings (for the dossier editor). */
export function fromNarrative(narrative: Narrative | undefined): NarrativeForm {
return {
epithet: narrative?.epithet ?? "",
height: narrative?.appearance?.height ?? "",
weight: narrative?.appearance?.weight ?? "",
age: narrative?.appearance?.age ?? "",
eyes: narrative?.appearance?.eyes ?? "",
origin: narrative?.appearance?.origin ?? "",
disposition: narrative?.appearance?.disposition ?? "",
traits: narrative?.traits?.join(", ") ?? "",
backstory: narrative?.backstory ?? "",
};
}
Loading
Loading