(null);
+
+ useEffect(() => {
+ if (!open || !pending) {
+ setRuntime(null);
+ return;
+ }
+ let cancelled = false;
+ (async () => {
+ try {
+ const beatName = `lura-${pending}` as
+ | 'lura-arrival'
+ | 'lura-mid'
+ | 'lura-farewell';
+ const story = await loadInkStory(beatName);
+ if (cancelled) return;
+ bindGardenStateToInk(story, appStore.getState());
+ // The Ink files use `== arrival ==`, `== mid ==`, `== farewell ==`
+ // as their entry knots. ChoosePathString navigates to the named
+ // knot before the first Continue() call.
+ story.ChoosePathString(pending);
+ setRuntime(createInkRuntime(story));
+ } catch (err) {
+ // Fail-soft: log + close. The gate's pending flag stays set so
+ // the player can re-try (rather than losing the beat to a
+ // silent failure).
+ console.error('[LuraDialogue] failed to load beat', pending, err);
+ setDialogueOverlayOpen(false);
+ }
+ })();
+ return () => {
+ cancelled = true;
+ };
+ }, [open, pending, setDialogueOverlayOpen]);
+
+ if (!open) return null;
+
+ const onClose = (): void => {
+ setDialogueOverlayOpen(false);
+ // Resolve in the store so the visited flag updates and `pending`
+ // clears. Read fresh state (not the memoized hook value) so we
+ // don't carry a stale pending identifier.
+ setLuraBeatProgress(
+ resolvePendingLuraBeat(appStore.getState().luraBeatProgress),
+ );
+ };
+
+ return (
+ e.stopPropagation()}
+ style={{
+ position: 'fixed',
+ inset: 0,
+ zIndex: 85,
+ background: '#1a1a1aee',
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ color: '#e8e0d0',
+ fontFamily: 'serif',
+ }}
+ >
+
+ {runtime ? (
+
{}} />
+ ) : (
+ ...
+ )}
+
+ Close
+
+
+
+ );
+}
diff --git a/src/ui/dialogue/index.ts b/src/ui/dialogue/index.ts
new file mode 100644
index 0000000..24c688a
--- /dev/null
+++ b/src/ui/dialogue/index.ts
@@ -0,0 +1,15 @@
+/**
+ * Public barrel for src/ui/dialogue/.
+ *
+ * Per CORE-10: src/sim/** must NOT import from this module. The Ink
+ * runtime stays UI-tier (Architectural Responsibility Map line 40).
+ */
+export { LuraDialogue } from './LuraDialogue';
+export { InkRenderer } from './ink-renderer';
+export {
+ createInkRuntime,
+ DEFAULT_DELAY_MS,
+ PER_CHAR_MS,
+ MAX_DELAY_MS,
+} from './ink-runtime';
+export type { InkRuntime } from './ink-runtime';
diff --git a/src/ui/dialogue/ink-renderer.tsx b/src/ui/dialogue/ink-renderer.tsx
new file mode 100644
index 0000000..3a8337d
--- /dev/null
+++ b/src/ui/dialogue/ink-renderer.tsx
@@ -0,0 +1,110 @@
+import { useEffect, useRef, useState } from 'react';
+import type { InkRuntime } from './ink-runtime';
+
+/**
+ * InkRenderer — drives an InkRuntime and drips lines into the DOM with
+ * text-message cadence. Used by LuraDialogue (full-screen overlay).
+ *
+ * Phase 8's UX-05 reduced-motion toggle will short-circuit the cadence;
+ * the cleanest hook-point is to pass `skipDelay=true` from a parent
+ * component reading from the settings slice. For now Phase 2 ships the
+ * fixed cadence with a click-anywhere-to-skip affordance (calls
+ * runtime.skipDelay()).
+ *
+ * Selectable text — userSelect: 'text' on every + body — supports
+ * MEMR-05-style copy-paste from day one. Same posture as Plan 02-03's
+ * Memory Journal.
+ */
+export function InkRenderer({
+ runtime,
+ onComplete,
+}: {
+ runtime: InkRuntime;
+ onComplete?: () => void;
+}): React.ReactElement {
+ const [lines, setLines] = useState([]);
+ const [choices, setChoices] = useState<{ index: number; text: string }[]>([]);
+ const cancelled = useRef(false);
+ const runRef = useRef(0);
+
+ useEffect(() => {
+ cancelled.current = false;
+ runRef.current += 1;
+ const myRun = runRef.current;
+ setLines([]);
+ setChoices([]);
+ (async () => {
+ while (!cancelled.current && runRef.current === myRun) {
+ const line = await runtime.nextLine();
+ if (cancelled.current || runRef.current !== myRun) return;
+ if (line === null) break;
+ if (line.trim().length > 0) {
+ setLines((prev) => [...prev, line.trim()]);
+ }
+ }
+ const cs = runtime.currentChoices();
+ if (cs.length > 0) {
+ setChoices(cs);
+ return;
+ }
+ onComplete?.();
+ })();
+ return () => {
+ cancelled.current = true;
+ };
+ }, [runtime, onComplete]);
+
+ const onChoice = (index: number): void => {
+ runtime.chooseChoice(index);
+ setChoices([]);
+ // Re-run the loop to drain post-choice lines.
+ runRef.current += 1;
+ };
+
+ return (
+ runtime.skipDelay()}
+ style={{ cursor: 'pointer', userSelect: 'text' }}
+ >
+ {lines.map((line, i) => (
+
+ {line}
+
+ ))}
+ {choices.length > 0 && (
+
+ {choices.map((c) => (
+ {
+ e.stopPropagation();
+ onChoice(c.index);
+ }}
+ style={{
+ display: 'block',
+ margin: '0.4rem 0',
+ background: 'transparent',
+ color: '#e8e0d0',
+ border: '1px solid #4d4d52',
+ padding: '0.4rem 0.8rem',
+ cursor: 'pointer',
+ fontFamily: 'serif',
+ textAlign: 'left',
+ }}
+ >
+ {c.text}
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/src/ui/dialogue/ink-runtime.test.ts b/src/ui/dialogue/ink-runtime.test.ts
new file mode 100644
index 0000000..d4c72ab
--- /dev/null
+++ b/src/ui/dialogue/ink-runtime.test.ts
@@ -0,0 +1,174 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import type { Story } from 'inkjs';
+import {
+ createInkRuntime,
+ DEFAULT_DELAY_MS,
+ PER_CHAR_MS,
+ MAX_DELAY_MS,
+} from './ink-runtime';
+
+/**
+ * Ink runtime unit tests using a hand-rolled fake Story (no inkjs
+ * required). Vitest fake timers let us exercise the cadence delay
+ * without actually waiting wall-clock seconds.
+ */
+
+interface FakeStory {
+ lines: string[];
+ cursor: number;
+ choices: { index: number; text: string }[];
+ chosen: number | null;
+}
+
+interface FakeStoryHandle {
+ state: FakeStory;
+ canContinue: boolean;
+ currentChoices: { text: string; index: number }[];
+ Continue: () => string | null;
+ ChooseChoiceIndex: (i: number) => void;
+ chosen: number | null;
+}
+
+function makeStory(
+ lines: string[],
+ choices: { index: number; text: string }[] = [],
+): FakeStoryHandle {
+ const state: FakeStory = {
+ lines: [...lines],
+ cursor: 0,
+ choices,
+ chosen: null,
+ };
+ return {
+ state,
+ get canContinue() {
+ return state.cursor < state.lines.length;
+ },
+ get currentChoices() {
+ // inkjs's `currentChoices` is exposed only when story has paused
+ // on a choice; our fake exposes it once content is exhausted.
+ return state.cursor >= state.lines.length ? state.choices : [];
+ },
+ Continue() {
+ if (state.cursor >= state.lines.length) return null;
+ return state.lines[state.cursor++] ?? null;
+ },
+ ChooseChoiceIndex(i: number) {
+ state.chosen = i;
+ },
+ // chosen is read-through from the inner state mutation by
+ // ChooseChoiceIndex above. Tests assert against the live state.
+ get chosen() {
+ return state.chosen;
+ },
+ };
+}
+
+describe('createInkRuntime', () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ });
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it('returns each line in order, then null', async () => {
+ const story = makeStory(['First line.', 'Second line.']);
+ const runtime = createInkRuntime(story as unknown as Story);
+
+ const p1 = runtime.nextLine();
+ await vi.advanceTimersByTimeAsync(MAX_DELAY_MS + 100);
+ expect(await p1).toBe('First line.');
+
+ const p2 = runtime.nextLine();
+ await vi.advanceTimersByTimeAsync(MAX_DELAY_MS + 100);
+ expect(await p2).toBe('Second line.');
+
+ const p3 = runtime.nextLine();
+ expect(await p3).toBeNull();
+ });
+
+ it('canContinue reflects story state', async () => {
+ const story = makeStory(['only one']);
+ const runtime = createInkRuntime(story as unknown as Story);
+ expect(runtime.canContinue()).toBe(true);
+ const p = runtime.nextLine();
+ await vi.advanceTimersByTimeAsync(MAX_DELAY_MS + 100);
+ await p;
+ expect(runtime.canContinue()).toBe(false);
+ });
+
+ it('cadence delay scales with line length up to MAX_DELAY_MS', async () => {
+ const longLine = 'x'.repeat(500); // would be 1500 + 500*20 = 11500, capped at MAX
+ const story = makeStory([longLine]);
+ const runtime = createInkRuntime(story as unknown as Story);
+ const p = runtime.nextLine();
+
+ // Advance just-under MAX_DELAY_MS — promise must NOT resolve yet.
+ await vi.advanceTimersByTimeAsync(MAX_DELAY_MS - 1);
+ let resolved = false;
+ void p.then(() => {
+ resolved = true;
+ });
+ await Promise.resolve();
+ expect(resolved).toBe(false);
+
+ // Advance the rest — promise resolves.
+ await vi.advanceTimersByTimeAsync(2);
+ expect(await p).toBe(longLine);
+ });
+
+ it('skipDelay() makes the NEXT line resolve nearly immediately', async () => {
+ const story = makeStory(['short']);
+ const runtime = createInkRuntime(story as unknown as Story);
+ runtime.skipDelay();
+ const p = runtime.nextLine();
+ // No timer advance needed — skipDelay zeroes the delay.
+ await vi.advanceTimersByTimeAsync(0);
+ expect(await p).toBe('short');
+ });
+
+ it('skipDelay() only affects the next line, not subsequent lines', async () => {
+ const story = makeStory(['a', 'b']);
+ const runtime = createInkRuntime(story as unknown as Story);
+
+ runtime.skipDelay();
+ const p1 = runtime.nextLine();
+ await vi.advanceTimersByTimeAsync(0);
+ expect(await p1).toBe('a');
+
+ // Second line should respect the standard cadence.
+ const p2 = runtime.nextLine();
+ let resolved = false;
+ void p2.then(() => {
+ resolved = true;
+ });
+ await Promise.resolve();
+ expect(resolved).toBe(false);
+ await vi.advanceTimersByTimeAsync(MAX_DELAY_MS + 100);
+ expect(await p2).toBe('b');
+ });
+
+ it('chooseChoice forwards to the underlying story', async () => {
+ const story = makeStory(
+ [],
+ [
+ { index: 0, text: 'first' },
+ { index: 1, text: 'second' },
+ ],
+ );
+ const runtime = createInkRuntime(story as unknown as Story);
+ expect(runtime.currentChoices()).toEqual([
+ { index: 0, text: 'first' },
+ { index: 1, text: 'second' },
+ ]);
+ runtime.chooseChoice(1);
+ expect(story.chosen).toBe(1);
+ });
+
+ it('cadence constants are stable + sensible', () => {
+ expect(DEFAULT_DELAY_MS).toBeGreaterThanOrEqual(500);
+ expect(PER_CHAR_MS).toBeGreaterThan(0);
+ expect(MAX_DELAY_MS).toBeGreaterThanOrEqual(DEFAULT_DELAY_MS);
+ });
+});
diff --git a/src/ui/dialogue/ink-runtime.ts b/src/ui/dialogue/ink-runtime.ts
new file mode 100644
index 0000000..52b2337
--- /dev/null
+++ b/src/ui/dialogue/ink-runtime.ts
@@ -0,0 +1,70 @@
+import type { Story } from 'inkjs';
+
+/**
+ * InkRuntime — thin wrapper around an inkjs Story that yields lines one
+ * at a time with a tunable text-message-cadence delay.
+ *
+ * Used by InkRenderer (which drives the loop). Phase 2 ships fixed
+ * cadence values; Phase 8's UX-05 reduced-motion toggle short-circuits
+ * the delay (skipDelay-on-every-line equivalent). The cadence values
+ * are tuned in playtest — see Plan 02-04 SUMMARY.md "Cadence values"
+ * for the chosen baseline.
+ */
+export interface InkRuntime {
+ /** Pull the next available line; resolves after the cadence delay. Returns null at story end. */
+ nextLine(): Promise;
+ /** Are there more lines or choices available? */
+ canContinue(): boolean;
+ /** Current choices, if the story has paused on a choice point. */
+ currentChoices(): { index: number; text: string }[];
+ /** Pick a choice and resume. */
+ chooseChoice(index: number): void;
+ /** Skip the cadence delay on the next line (e.g., player tap-to-advance). */
+ skipDelay(): void;
+}
+
+/**
+ * Cadence baseline. Phase 2 values; tunable in playtest. The actual
+ * delay for a line is `min(MAX_DELAY_MS, DEFAULT_DELAY_MS + line.length * PER_CHAR_MS)`.
+ *
+ * For a typical 80-char line: 1500 + 80*20 = 3100ms (capped at 4000ms).
+ * For a short 10-char line: 1500 + 10*20 = 1700ms.
+ * For a one-word "Oh.": 1500 + 3*20 = 1560ms.
+ *
+ * Documented here so playtesters can adjust without spelunking through
+ * the renderer.
+ */
+export const DEFAULT_DELAY_MS = 1500;
+export const PER_CHAR_MS = 20;
+export const MAX_DELAY_MS = 4000;
+
+export function createInkRuntime(story: Story): InkRuntime {
+ let skipNext = false;
+ return {
+ async nextLine(): Promise {
+ if (!story.canContinue) return null;
+ const line = story.Continue();
+ if (line === null) return null;
+ const delay = skipNext
+ ? 0
+ : Math.min(MAX_DELAY_MS, DEFAULT_DELAY_MS + line.length * PER_CHAR_MS);
+ skipNext = false;
+ if (delay > 0) {
+ await new Promise((resolve) => setTimeout(resolve, delay));
+ }
+ return line;
+ },
+ canContinue(): boolean {
+ return story.canContinue;
+ },
+ currentChoices(): { index: number; text: string }[] {
+ return story.currentChoices.map((c, i) => ({ index: i, text: c.text }));
+ },
+ chooseChoice(index: number): void {
+ story.ChooseChoiceIndex(index);
+ },
+ skipDelay(): void {
+ skipNext = true;
+ },
+ };
+}
diff --git a/src/ui/index.ts b/src/ui/index.ts
index ccb50c2..e27c0b0 100644
--- a/src/ui/index.ts
+++ b/src/ui/index.ts
@@ -8,3 +8,4 @@
export * from './begin';
export * from './garden';
export * from './journal';
+export * from './dialogue';