feat: story library, player and builder UI
All checks were successful
/ checks (push) Successful in 41s
/ deploy (push) Successful in 4s

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
nholtware 2026-11-05 12:45:06 -06:00
parent 52778622a4
commit 364a911794
14 changed files with 1149 additions and 41 deletions

View file

@ -26,26 +26,44 @@ afterEach(() => {
}); });
describe("app", () => { describe("app", () => {
it("home shows the app name from runtime config and the build sha", async () => { it("library lists published stories and links to play", async () => {
vi.stubGlobal(
"fetch",
vi.fn(
async () =>
new Response(
JSON.stringify({
stories: [
{
id: "s1",
title: "Cave",
description: "Dark",
nodeCount: 3,
published: true,
},
],
}),
),
),
);
renderAt("/"); renderAt("/");
expect( expect(
await screen.findByRole("heading", { name: "test-app" }), await screen.findByRole("heading", { name: "Cave" }),
).toBeInTheDocument(); ).toBeInTheDocument();
expect(screen.getByTestId("build-sha")).toHaveTextContent("dev"); expect(
screen.getAllByRole("link", { name: "Play" }).at(-1)!,
).toHaveAttribute("href", "/play/s1");
}); });
it("home hides the banner while hello-banner is off", async () => { it("library shows a builder prompt when empty", async () => {
renderAt("/"); vi.stubGlobal(
await screen.findByRole("heading", { name: "test-app" }); "fetch",
expect(screen.queryByTestId("banner")).not.toBeInTheDocument(); vi.fn(async () => new Response(JSON.stringify({ stories: [] }))),
});
it("home shows the banner while hello-banner is on", async () => {
flagsOn.add("hello-banner");
renderAt("/");
expect(await screen.findByTestId("banner")).toHaveTextContent(
"hello-banner flag is on",
); );
renderAt("/");
expect(
await screen.findByText(/No published stories yet/),
).toBeInTheDocument();
}); });
it("status fetches / and /readyz under API_BASE_URL and renders the JSON", async () => { it("status fetches / and /readyz under API_BASE_URL and renders the JSON", async () => {

35
src/lib/play.ts Normal file
View file

@ -0,0 +1,35 @@
import type { StoryDoc } from "./stories";
export interface PlayState {
path: string[]; // node keys visited, last is current
}
export const initial = (doc: StoryDoc): PlayState => ({ path: [doc.start] });
export function choose(doc: StoryDoc, s: PlayState, index: number): PlayState {
const cur = s.path[s.path.length - 1]!;
const choice = doc.nodes[cur]?.choices[index];
if (!choice || !doc.nodes[choice.to] || doc.nodes[cur]?.ending) return s;
return { path: [...s.path, choice.to] };
}
export const back = (s: PlayState): PlayState =>
s.path.length > 1 ? { path: s.path.slice(0, -1) } : s;
/** Drop saved paths that no longer fit the (possibly edited) story. */
export function restore(doc: StoryDoc, raw: string | null): PlayState {
try {
const path = (JSON.parse(raw ?? "null") as { path?: unknown } | null)?.path;
if (
Array.isArray(path) &&
path.length > 0 &&
path[0] === doc.start &&
path.every((k) => typeof k === "string" && doc.nodes[k])
) {
return { path: path as string[] };
}
} catch {
// fall through to a fresh game
}
return initial(doc);
}

87
src/lib/stories.test.ts Normal file
View file

@ -0,0 +1,87 @@
import { describe, expect, it } from "vitest";
import { back, choose, initial, restore } from "./play";
import {
analyze,
emptyDoc,
nextKey,
removeNode,
renameNode,
type StoryDoc,
} from "./stories";
const doc: StoryDoc = {
start: "a",
nodes: {
a: {
title: "A",
text: "",
choices: [
{ label: "to b", to: "b" },
{ label: "to c", to: "c" },
],
},
b: { title: "B", text: "", ending: "win", choices: [] },
c: { title: "C", text: "", choices: [{ label: "back", to: "a" }] },
},
};
describe("play", () => {
it("walks, undoes, and refuses to move past an ending", () => {
let s = initial(doc);
s = choose(doc, s, 1);
expect(s.path).toEqual(["a", "c"]);
s = back(s);
expect(s.path).toEqual(["a"]);
s = choose(doc, s, 0);
expect(choose(doc, s, 0)).toBe(s);
expect(back(initial(doc)).path).toEqual(["a"]);
});
it("restores a valid saved path and discards a stale one", () => {
expect(restore(doc, JSON.stringify({ path: ["a", "c"] })).path).toEqual([
"a",
"c",
]);
expect(restore(doc, JSON.stringify({ path: ["a", "gone"] })).path).toEqual([
"a",
]);
expect(restore(doc, JSON.stringify({ path: ["c"] })).path).toEqual(["a"]);
expect(restore(doc, "not json").path).toEqual(["a"]);
expect(restore(doc, null).path).toEqual(["a"]);
});
});
describe("editor helpers", () => {
it("a fresh doc has the start dead end flagged", () => {
const msgs = analyze(emptyDoc()).map((i) => i.message);
expect(msgs).toContain("dead end: no choices and not marked as an ending");
expect(msgs).toContain("no ending is reachable from the start node");
});
it("a valid doc has no errors", () => {
expect(analyze(doc).filter((i) => i.level === "error")).toEqual([]);
});
it("renameNode rewrites start and choice targets; ignores collisions", () => {
const r = renameNode(doc, "a", "intro");
expect(r.start).toBe("intro");
expect(r.nodes.c!.choices[0]!.to).toBe("intro");
expect(r.nodes.a).toBeUndefined();
expect(renameNode(doc, "a", "b")).toBe(doc);
});
it("removeNode drops inbound choices and repoints start", () => {
const r = removeNode(doc, "b");
expect(r.nodes.a!.choices.map((c) => c.to)).toEqual(["c"]);
expect(removeNode(doc, "a").start).toBe("b");
});
it("nextKey avoids collisions", () => {
expect(
nextKey({
start: "a",
nodes: { "node-2": doc.nodes.a!, a: doc.nodes.a! },
}),
).toBe("node-3");
});
});

215
src/lib/stories.ts Normal file
View file

@ -0,0 +1,215 @@
import { getConfig } from "./config";
export type Ending = "win" | "lose" | "neutral";
export interface Choice {
label: string;
to: string;
}
export interface StoryNode {
title: string;
text: string;
ending?: Ending;
choices: Choice[];
}
export interface StoryDoc {
start: string;
nodes: Record<string, StoryNode>;
}
export interface Story {
id: string;
title: string;
description: string;
doc: StoryDoc;
published: boolean;
updatedAt: string;
}
export interface StorySummary extends Omit<Story, "doc"> {
nodeCount: number;
}
export interface Issue {
level: "error" | "warning";
node?: string;
message: string;
}
export interface StoryInput {
title: string;
description: string;
doc: StoryDoc;
}
export class ApiError extends Error {
constructor(
message: string,
readonly status: number,
readonly issues: Issue[] = [],
) {
super(message);
}
}
async function call<T>(
method: string,
path: string,
body?: unknown,
): Promise<T> {
const base = getConfig().API_BASE_URL.replace(/\/$/, "");
const res = await fetch(`${base}${path}`, {
method,
headers: {
Accept: "application/json",
...(body === undefined ? {} : { "Content-Type": "application/json" }),
},
body: body === undefined ? undefined : JSON.stringify(body),
});
if (res.status === 204) return undefined as T;
const text = await res.text();
let data: Record<string, unknown> = {};
try {
data = JSON.parse(text);
} catch {
// non-JSON error body
}
if (!res.ok) {
throw new ApiError(
(data.error as string | undefined) ?? `request failed (${res.status})`,
res.status,
(data.issues as Issue[] | undefined) ?? [],
);
}
return data as T;
}
export const listStories = (mine: boolean) =>
call<{ stories: StorySummary[] }>(
"GET",
`/stories${mine ? "?mine=1" : ""}`,
).then((r) => r.stories);
export const getStory = (id: string) =>
call<{ story: Story; issues: Issue[] }>("GET", `/stories/${id}`);
export const createStory = (input: StoryInput) =>
call<{ story: Story; issues: Issue[] }>("POST", "/stories", input);
export const saveStory = (id: string, input: StoryInput) =>
call<{ story: Story; issues: Issue[] }>("PUT", `/stories/${id}`, input);
export const publishStory = (id: string) =>
call<{ story: Story; issues: Issue[] }>("POST", `/stories/${id}/publish`);
export const unpublishStory = (id: string) =>
call<{ story: Story; issues: Issue[] }>("POST", `/stories/${id}/unpublish`);
export const deleteStory = (id: string) =>
call<void>("DELETE", `/stories/${id}`);
export const KEY_RE = /^[a-z0-9][a-z0-9_-]{0,39}$/;
/** Mirror of the backend's analyze(), for live feedback while editing. */
export function analyze(doc: StoryDoc): Issue[] {
const issues: Issue[] = [];
const nodes = doc.nodes;
if (!nodes[doc.start]) {
issues.push({
level: "error",
message: `start node "${doc.start}" does not exist`,
});
}
for (const [k, n] of Object.entries(nodes)) {
if (!KEY_RE.test(k)) {
issues.push({
level: "error",
node: k,
message: "invalid node key (use a-z, 0-9, - _)",
});
}
for (const c of n.choices) {
if (!nodes[c.to]) {
issues.push({
level: "error",
node: k,
message: `choice "${c.label}" points to missing node "${c.to}"`,
});
}
}
if (!n.ending && n.choices.length === 0) {
issues.push({
level: "error",
node: k,
message: "dead end: no choices and not marked as an ending",
});
}
if (n.ending && n.choices.length > 0) {
issues.push({
level: "warning",
node: k,
message: "ending node has choices that will never be shown",
});
}
}
if (nodes[doc.start]) {
const seen = new Set<string>();
const stack = [doc.start];
while (stack.length) {
const k = stack.pop()!;
const n = nodes[k];
if (seen.has(k) || !n) continue;
seen.add(k);
if (!n.ending) for (const c of n.choices) stack.push(c.to);
}
for (const k of Object.keys(nodes)) {
if (!seen.has(k)) {
issues.push({
level: "warning",
node: k,
message: "unreachable from the start node",
});
}
}
if (![...seen].some((k) => nodes[k]?.ending)) {
issues.push({
level: "error",
message: "no ending is reachable from the start node",
});
}
}
return issues;
}
export function emptyDoc(): StoryDoc {
return {
start: "start",
nodes: {
start: {
title: "The beginning",
text: "Your story starts here.",
choices: [],
},
},
};
}
/** Rename a node key and rewrite every reference to it. */
export function renameNode(doc: StoryDoc, from: string, to: string): StoryDoc {
if (from === to || doc.nodes[to]) return doc;
const nodes: Record<string, StoryNode> = {};
for (const [k, n] of Object.entries(doc.nodes)) {
nodes[k === from ? to : k] = {
...n,
choices: n.choices.map((c) => (c.to === from ? { ...c, to } : c)),
};
}
return { start: doc.start === from ? to : doc.start, nodes };
}
export function removeNode(doc: StoryDoc, key: string): StoryDoc {
const nodes: Record<string, StoryNode> = {};
for (const [k, n] of Object.entries(doc.nodes)) {
if (k !== key)
nodes[k] = { ...n, choices: n.choices.filter((c) => c.to !== key) };
}
return {
start: doc.start === key ? (Object.keys(nodes)[0] ?? "") : doc.start,
nodes,
};
}
export function nextKey(doc: StoryDoc): string {
for (let i = Object.keys(doc.nodes).length + 1; ; i++) {
if (!doc.nodes[`node-${i}`]) return `node-${i}`;
}
}

View file

@ -3,6 +3,7 @@ import { createRoot } from "react-dom/client";
import { createBrowserRouter, RouterProvider } from "react-router"; import { createBrowserRouter, RouterProvider } from "react-router";
import { FlagsProvider } from "./lib/flags"; import { FlagsProvider } from "./lib/flags";
import { routes } from "./routes"; import { routes } from "./routes";
import "./styles.css";
const router = createBrowserRouter(routes); const router = createBrowserRouter(routes);

View file

@ -1,7 +1,10 @@
import type { RouteObject } from "react-router"; import type { RouteObject } from "react-router";
import { Build, buildLoader } from "./routes/Build";
import { BuildList, buildListLoader } from "./routes/BuildList";
import { ErrorPage } from "./routes/ErrorPage"; import { ErrorPage } from "./routes/ErrorPage";
import { Home } from "./routes/Home";
import { Layout } from "./routes/Layout"; import { Layout } from "./routes/Layout";
import { Library, libraryLoader } from "./routes/Library";
import { Play, playLoader } from "./routes/Play";
import { Status, statusLoader } from "./routes/Status"; import { Status, statusLoader } from "./routes/Status";
// Data-mode route table. Add pages here; loaders live next to their component. // Data-mode route table. Add pages here; loaders live next to their component.
@ -11,7 +14,10 @@ export const routes: RouteObject[] = [
element: <Layout />, element: <Layout />,
errorElement: <ErrorPage />, errorElement: <ErrorPage />,
children: [ children: [
{ index: true, element: <Home /> }, { index: true, element: <Library />, loader: libraryLoader },
{ path: "play/:id", element: <Play />, loader: playLoader },
{ path: "build", element: <BuildList />, loader: buildListLoader },
{ path: "build/:id", element: <Build />, loader: buildLoader },
{ path: "status", element: <Status />, loader: statusLoader }, { path: "status", element: <Status />, loader: statusLoader },
], ],
}, },

313
src/routes/Build.tsx Normal file
View file

@ -0,0 +1,313 @@
import { useMemo, useState } from "react";
import { Link, useLoaderData, type LoaderFunctionArgs } from "react-router";
import {
analyze,
ApiError,
getStory,
KEY_RE,
nextKey,
publishStory,
removeNode,
renameNode,
saveStory,
unpublishStory,
type Ending,
type Issue,
type Story,
type StoryDoc,
type StoryNode,
} from "../lib/stories";
export async function buildLoader({ params }: LoaderFunctionArgs) {
return (await getStory(params.id!)).story;
}
export function Build() {
return <Editor story={useLoaderData() as Story} />;
}
export function Editor({ story }: { story: Story }) {
const [title, setTitle] = useState(story.title);
const [description, setDescription] = useState(story.description);
const [doc, setDoc] = useState<StoryDoc>(story.doc);
const [published, setPublished] = useState(story.published);
const [dirty, setDirty] = useState(false);
const [message, setMessage] = useState<string | null>(null);
const [serverIssues, setServerIssues] = useState<Issue[] | null>(null);
const issues = useMemo(() => analyze(doc), [doc]);
const keys = Object.keys(doc.nodes);
const edit = (fn: (d: StoryDoc) => StoryDoc) => {
setDoc(fn);
setDirty(true);
};
const patchNode = (k: string, patch: Partial<StoryNode>) =>
edit((d) => ({
...d,
nodes: { ...d.nodes, [k]: { ...d.nodes[k]!, ...patch } },
}));
async function save(): Promise<boolean> {
try {
await saveStory(story.id, { title, description, doc });
setDirty(false);
setMessage("Saved.");
setServerIssues(null);
return true;
} catch (e) {
setMessage(
e instanceof Error ? `Save failed: ${e.message}` : "Save failed",
);
return false;
}
}
async function togglePublish() {
if (dirty && !(await save())) return;
try {
if (published) {
await unpublishStory(story.id);
setPublished(false);
setMessage("Unpublished.");
} else {
await publishStory(story.id);
setPublished(true);
setMessage("Published!");
}
} catch (e) {
if (e instanceof ApiError) setServerIssues(e.issues);
setMessage(e instanceof Error ? e.message : "Publish failed");
}
}
return (
<section className="editor">
<p>
<Link to="/build">← Your stories</Link>
</p>
<h1>Edit story</h1>
<label>
Title
<input
value={title}
maxLength={120}
onChange={(e) => {
setTitle(e.target.value);
setDirty(true);
}}
/>
</label>
<label>
Description
<textarea
rows={2}
value={description}
onChange={(e) => {
setDescription(e.target.value);
setDirty(true);
}}
/>
</label>
<label>
Start scene
<select
value={doc.start}
onChange={(e) => edit((d) => ({ ...d, start: e.target.value }))}
>
{keys.map((k) => (
<option key={k} value={k}>
{doc.nodes[k]!.title} ({k})
</option>
))}
</select>
</label>
<div className="toolbar">
<button onClick={() => void save()} disabled={!dirty}>
Save
</button>
<button onClick={() => void togglePublish()}>
{published ? "Unpublish" : "Publish"}
</button>
{published && <Link to={`/play/${story.id}`}>Play →</Link>}
<span role="status">{message ?? (dirty ? "Unsaved changes" : "")}</span>
</div>
<div aria-label="Problems" className="issues">
{(serverIssues ?? issues).length === 0 ? (
<p className="ok">No problems found.</p>
) : (
<ul>
{(serverIssues ?? issues).map((i, n) => (
<li key={n} className={i.level}>
<strong>{i.level}</strong>
{i.node ? ` [${i.node}]` : ""}: {i.message}
</li>
))}
</ul>
)}
</div>
{keys.map((k) => (
<NodeEditor
key={k}
nodeKey={k}
node={doc.nodes[k]!}
allKeys={keys}
doc={doc}
onPatch={(p) => patchNode(k, p)}
onRename={(to) => edit((d) => renameNode(d, k, to))}
onRemove={() => edit((d) => removeNode(d, k))}
/>
))}
<button
onClick={() =>
edit((d) => {
const k = nextKey(d);
return {
...d,
nodes: {
...d.nodes,
[k]: { title: "New scene", text: "", choices: [] },
},
};
})
}
>
+ Add scene
</button>
</section>
);
}
function NodeEditor(props: {
nodeKey: string;
node: StoryNode;
allKeys: string[];
doc: StoryDoc;
onPatch: (p: Partial<StoryNode>) => void;
onRename: (to: string) => void;
onRemove: () => void;
}) {
const { nodeKey, node, allKeys, onPatch } = props;
const [keyDraft, setKeyDraft] = useState(nodeKey);
const keyTaken = keyDraft !== nodeKey && allKeys.includes(keyDraft);
const keyOk = KEY_RE.test(keyDraft) && !keyTaken;
return (
<fieldset className="node">
<legend>
{node.title} <code>{nodeKey}</code>
</legend>
<label>
Key
<input
value={keyDraft}
aria-invalid={!keyOk}
onChange={(e) => setKeyDraft(e.target.value)}
onBlur={() =>
keyOk ? props.onRename(keyDraft) : setKeyDraft(nodeKey)
}
/>
</label>
<label>
Scene title
<input
value={node.title}
onChange={(e) => onPatch({ title: e.target.value })}
/>
</label>
<label>
Text
<textarea
rows={4}
value={node.text}
onChange={(e) => onPatch({ text: e.target.value })}
/>
</label>
<label>
Ending
<select
value={node.ending ?? ""}
onChange={(e) =>
onPatch({
ending: (e.target.value || undefined) as Ending | undefined,
})
}
>
<option value="">Not an ending</option>
<option value="win">Win</option>
<option value="lose">Lose</option>
<option value="neutral">Neutral</option>
</select>
</label>
<div>
<strong>Choices</strong>
{node.choices.map((c, i) => (
<div key={i} className="choice-row">
<input
aria-label={`Choice ${i + 1} label`}
value={c.label}
onChange={(e) =>
onPatch({
choices: node.choices.map((x, j) =>
j === i ? { ...x, label: e.target.value } : x,
),
})
}
/>
<select
aria-label={`Choice ${i + 1} target`}
value={c.to}
onChange={(e) =>
onPatch({
choices: node.choices.map((x, j) =>
j === i ? { ...x, to: e.target.value } : x,
),
})
}
>
{allKeys.map((k) => (
<option key={k} value={k}>
{props.doc.nodes[k]!.title} ({k})
</option>
))}
</select>
<button
className="link"
aria-label={`Remove choice ${i + 1}`}
onClick={() =>
onPatch({ choices: node.choices.filter((_, j) => j !== i) })
}
>
✕
</button>
</div>
))}
<button
className="link"
disabled={node.choices.length >= 10}
onClick={() =>
onPatch({
choices: [
...node.choices,
{ label: "Do something", to: allKeys[0]! },
],
})
}
>
+ Add choice
</button>
</div>
<button
className="link danger"
onClick={props.onRemove}
disabled={allKeys.length < 2}
>
Delete scene
</button>
</fieldset>
);
}

59
src/routes/BuildList.tsx Normal file
View file

@ -0,0 +1,59 @@
import { Link, useLoaderData, useNavigate, useRevalidator } from "react-router";
import {
createStory,
deleteStory,
emptyDoc,
listStories,
type StorySummary,
} from "../lib/stories";
export const buildListLoader = () => listStories(true);
export function BuildList() {
const stories = useLoaderData() as StorySummary[];
const navigate = useNavigate();
const { revalidate } = useRevalidator();
async function create() {
const { story } = await createStory({
title: "Untitled adventure",
description: "",
doc: emptyDoc(),
});
navigate(`/build/${story.id}`);
}
async function remove(s: StorySummary) {
if (!confirm(`Delete "${s.title}"? This cannot be undone.`)) return;
await deleteStory(s.id);
await revalidate();
}
return (
<section>
<h1>Your stories</h1>
<p>
<button onClick={create}>New story</button>
</p>
<ul className="cards">
{stories.map((s) => (
<li key={s.id}>
<h2>
{s.title}{" "}
<span className="badge">
{s.published ? "published" : "draft"}
</span>
</h2>
<small>{s.nodeCount} scenes</small>{" "}
<Link className="button" to={`/build/${s.id}`}>
Edit
</Link>{" "}
<button className="link" onClick={() => remove(s)}>
Delete
</button>
</li>
))}
</ul>
</section>
);
}

View file

@ -1,24 +0,0 @@
import { buildSha, getConfig } from "../lib/config";
import { useFlag } from "../lib/flags";
export function Home() {
// Demo flag (Plan 07): toggle "hello-banner" in Unleash, then reload.
const banner = useFlag("hello-banner");
return (
<section>
{banner && (
<p
role="status"
data-testid="banner"
style={{ background: "#fff3c4", padding: "0.5rem 1rem" }}
>
The hello-banner flag is on.
</p>
)}
<h1>{getConfig().APP_NAME}</h1>
<p>
Build: <code data-testid="build-sha">{buildSha}</code>
</p>
</section>
);
}

View file

@ -7,7 +7,10 @@ export function Layout() {
<header> <header>
<strong>{getConfig().APP_NAME}</strong> <strong>{getConfig().APP_NAME}</strong>
<nav> <nav>
<NavLink to="/">Home</NavLink> |{" "} <NavLink to="/" end>
Play
</NavLink>
<NavLink to="/build">Build</NavLink>
<NavLink to="/status">Status</NavLink> <NavLink to="/status">Status</NavLink>
</nav> </nav>
</header> </header>

31
src/routes/Library.tsx Normal file
View file

@ -0,0 +1,31 @@
import { Link, useLoaderData } from "react-router";
import { listStories, type StorySummary } from "../lib/stories";
export const libraryLoader = () => listStories(false);
export function Library() {
const stories = useLoaderData() as StorySummary[];
return (
<section>
<h1>Choose your adventure</h1>
{stories.length === 0 ? (
<p>
No published stories yet. <Link to="/build">Build one!</Link>
</p>
) : (
<ul className="cards">
{stories.map((s) => (
<li key={s.id}>
<h2>{s.title}</h2>
<p>{s.description}</p>
<small>{s.nodeCount} scenes</small>{" "}
<Link className="button" to={`/play/${s.id}`}>
Play
</Link>
</li>
))}
</ul>
)}
</section>
);
}

79
src/routes/Play.tsx Normal file
View file

@ -0,0 +1,79 @@
import { useEffect, useState } from "react";
import { Link, useLoaderData, type LoaderFunctionArgs } from "react-router";
import { back, choose, initial, restore, type PlayState } from "../lib/play";
import { getStory, type Story } from "../lib/stories";
export async function playLoader({ params }: LoaderFunctionArgs) {
return (await getStory(params.id!)).story;
}
export function Play() {
const story = useLoaderData() as Story;
return <Player story={story} />;
}
export function Player({ story }: { story: Story }) {
const storageKey = `adventure:${story.id}`;
const [state, setState] = useState<PlayState>(() =>
restore(story.doc, localStorage.getItem(storageKey)),
);
useEffect(() => {
localStorage.setItem(storageKey, JSON.stringify(state));
}, [state, storageKey]);
const key = state.path[state.path.length - 1]!;
const node = story.doc.nodes[key]!;
return (
<article className="player">
<p>
<Link to="/">← Library</Link>
</p>
<h1>{story.title}</h1>
<h2>{node.title}</h2>
{node.text.split(/\n{2,}/).map((p, i) => (
<p key={i}>{p}</p>
))}
{node.ending ? (
<div className={`ending ${node.ending}`} role="status">
<strong>
{node.ending === "win"
? "You win!"
: node.ending === "lose"
? "The end… for you."
: "The end."}
</strong>
<p>
You made {state.path.length - 1} choice
{state.path.length === 2 ? "" : "s"}.
</p>
<button onClick={() => setState(initial(story.doc))}>
Play again
</button>
</div>
) : (
<ol className="choices">
{node.choices.map((c, i) => (
<li key={i}>
<button onClick={() => setState((s) => choose(story.doc, s, i))}>
{c.label}
</button>
</li>
))}
</ol>
)}
<p>
<button
className="link"
disabled={state.path.length < 2}
onClick={() => setState(back)}
>
↩ Undo last choice
</button>{" "}
<button className="link" onClick={() => setState(initial(story.doc))}>
Restart
</button>
</p>
</article>
);
}

119
src/routes/ui.test.tsx Normal file
View file

@ -0,0 +1,119 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter } from "react-router";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { Editor } from "./Build";
import { Player } from "./Play";
import type { Story } from "../lib/stories";
vi.mock("@unleash/proxy-client-react", () => ({
FlagProvider: ({ children }: { children: React.ReactNode }) => children,
useFlag: () => false,
useVariant: () => ({ name: "disabled", enabled: false }),
}));
const story: Story = {
id: "s1",
title: "Cave",
description: "",
published: false,
updatedAt: "",
doc: {
start: "a",
nodes: {
a: {
title: "Entrance",
text: "It is dark.",
choices: [{ label: "Go in", to: "b" }],
},
b: { title: "Treasure", text: "Gold!", ending: "win", choices: [] },
},
},
};
beforeEach(() => {
localStorage.clear();
window.__APP_CONFIG__ = { API_BASE_URL: "/api", APP_NAME: "t" };
});
describe("Player", () => {
it("plays to an ending, shows the result, and restarts", async () => {
const user = userEvent.setup();
render(
<MemoryRouter>
<Player story={story} />
</MemoryRouter>,
);
expect(screen.getByText("It is dark.")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Go in" }));
expect(screen.getByRole("status")).toHaveTextContent("You win!");
expect(screen.getByRole("status")).toHaveTextContent("1 choice.");
await user.click(screen.getByRole("button", { name: "Play again" }));
expect(screen.getByText("It is dark.")).toBeInTheDocument();
});
it("resumes from saved progress", async () => {
localStorage.setItem("adventure:s1", JSON.stringify({ path: ["a", "b"] }));
render(
<MemoryRouter>
<Player story={story} />
</MemoryRouter>,
);
expect(screen.getByText("Gold!")).toBeInTheDocument();
});
});
describe("Editor", () => {
it("shows live problems and saves via PUT", async () => {
const user = userEvent.setup();
const fetchMock = vi.fn(
async () => new Response(JSON.stringify({ story, issues: [] })),
);
vi.stubGlobal("fetch", fetchMock);
render(
<MemoryRouter>
<Editor story={story} />
</MemoryRouter>,
);
expect(screen.getByText("No problems found.")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Remove choice 1" }));
expect(screen.getByText(/dead end/)).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Save" }));
expect(await screen.findByText("Saved.")).toBeInTheDocument();
const [url, init] = fetchMock.mock.calls[0] as unknown as [
string,
RequestInit,
];
expect(url).toBe("/api/stories/s1");
expect(init.method).toBe("PUT");
});
it("surfaces server publish errors", async () => {
const user = userEvent.setup();
vi.stubGlobal(
"fetch",
vi.fn(
async () =>
new Response(
JSON.stringify({
error: "story has errors; fix them before publishing",
issues: [{ level: "error", message: "boom" }],
}),
{ status: 422 },
),
),
);
render(
<MemoryRouter>
<Editor story={story} />
</MemoryRouter>,
);
await user.click(screen.getByRole("button", { name: "Publish" }));
expect(
await screen.findByText(/fix them before publishing/),
).toBeInTheDocument();
expect(screen.getByText(/boom/)).toBeInTheDocument();
});
});

166
src/styles.css Normal file
View file

@ -0,0 +1,166 @@
:root {
font-family: system-ui, sans-serif;
line-height: 1.5;
color: #1d1d1f;
}
body {
margin: 0;
background: #faf8f3;
}
header {
display: flex;
gap: 2rem;
align-items: center;
padding: 0.75rem 1.5rem;
background: #2b2118;
color: #f5e9d3;
}
header nav {
display: flex;
gap: 1rem;
}
header a {
color: #f5e9d3;
text-decoration: none;
opacity: 0.75;
}
header a.active {
opacity: 1;
text-decoration: underline;
}
main {
max-width: 46rem;
margin: 0 auto;
padding: 1.5rem;
}
button,
.button {
font: inherit;
padding: 0.4rem 0.9rem;
border: 1px solid #6b4f2d;
background: #6b4f2d;
color: #fff;
border-radius: 6px;
cursor: pointer;
text-decoration: none;
}
button:disabled {
opacity: 0.4;
cursor: default;
}
button.link {
background: none;
border: none;
color: #6b4f2d;
text-decoration: underline;
padding: 0.2rem;
}
button.danger {
color: #a31d1d;
}
.cards {
list-style: none;
padding: 0;
display: grid;
gap: 1rem;
}
.cards li {
background: #fff;
border: 1px solid #e3dccd;
border-radius: 8px;
padding: 1rem;
}
.cards h2 {
margin: 0 0 0.25rem;
font-size: 1.2rem;
}
.badge {
font-size: 0.7rem;
background: #e3dccd;
padding: 0.1rem 0.5rem;
border-radius: 99px;
vertical-align: middle;
}
.player {
font-size: 1.1rem;
}
.choices {
list-style: none;
padding: 0;
display: grid;
gap: 0.6rem;
}
.choices button {
width: 100%;
text-align: left;
padding: 0.7rem 1rem;
}
.ending {
padding: 1rem;
border-radius: 8px;
border: 2px solid;
}
.ending.win {
border-color: #2e7d32;
background: #e8f5e9;
}
.ending.lose {
border-color: #a31d1d;
background: #fdecea;
}
.ending.neutral {
border-color: #6b4f2d;
background: #f3ece0;
}
.editor label {
display: block;
margin-bottom: 0.75rem;
font-weight: 600;
}
.editor input,
.editor textarea,
.editor select {
display: block;
width: 100%;
box-sizing: border-box;
font: inherit;
padding: 0.35rem;
margin-top: 0.2rem;
}
.editor input[aria-invalid="true"] {
outline: 2px solid #a31d1d;
}
.toolbar {
display: flex;
gap: 0.75rem;
align-items: center;
margin: 1rem 0;
}
.issues ul {
padding-left: 1.2rem;
}
.issues .error strong {
color: #a31d1d;
}
.issues .warning strong {
color: #b26a00;
}
.issues .ok {
color: #2e7d32;
}
.node {
border: 1px solid #d7cdb8;
border-radius: 8px;
margin: 1rem 0;
background: #fff;
}
.choice-row {
display: grid;
grid-template-columns: 1fr 1fr auto;
gap: 0.5rem;
margin: 0.4rem 0;
}
.choice-row input,
.choice-row select {
margin-top: 0 !important;
}