diff --git a/client/src/App.jsx b/client/src/App.jsx
index ea069be4b3..ba8ddbbed6 100644
--- a/client/src/App.jsx
+++ b/client/src/App.jsx
@@ -117,6 +117,7 @@ const Sharing = lazyWithReload(() => import('./pages/Sharing'));
const Importer = lazyWithReload(() => import('./pages/Importer'));
const FableLoom = lazyWithReload(() => import('./pages/FableLoom'));
const FableLoomStory = lazyWithReload(() => import('./pages/FableLoomStory'));
+const FableLoomHostedJoin = lazyWithReload(() => import('./pages/FableLoomHostedJoin'));
const StartStory = lazyWithReload(() => import('./pages/StartStory'));
const StoryBuilder = lazyWithReload(() => import('./pages/StoryBuilder'));
const PipelineSeries = lazyWithReload(() => import('./pages/PipelineSeries'));
@@ -523,6 +524,7 @@ export default function App() {
} />
} />
} />
+ } />
} />
} />
} />
diff --git a/client/src/components/fableloom/LoomHostedSessionModal.jsx b/client/src/components/fableloom/LoomHostedSessionModal.jsx
new file mode 100644
index 0000000000..dcb8af5f7f
--- /dev/null
+++ b/client/src/components/fableloom/LoomHostedSessionModal.jsx
@@ -0,0 +1,301 @@
+/**
+ * Scoped QR-Hosted Session Modal (#5383).
+ *
+ * Provides:
+ * 1. HTTPS & subsystem readiness preflight verification.
+ * 2. Scoped high-entropy QR code and fragment join link.
+ * 3. Audio output target selection (Host computer speakers vs Audience phone).
+ * 4. Realtime audience connection status.
+ */
+
+import { useEffect, useState } from 'react';
+import {
+ AlertCircle,
+ CheckCircle2,
+ Copy,
+ ExternalLink,
+ Loader2,
+ Mic,
+ QrCode,
+ Radio,
+ Smartphone,
+ Speaker,
+ Volume2,
+ X,
+} from 'lucide-react';
+import toast from '../ui/Toast';
+import { copyToClipboard } from '../../lib/clipboard';
+import { generateQrCodeSvg } from '../../lib/qrCode';
+import {
+ createHostedLoomSession,
+ endHostedLoomSession,
+ preflightHostedLoomSession,
+ updateHostedLoomSession,
+} from '../../services/api';
+
+export default function LoomHostedSessionModal({
+ loom,
+ episode,
+ isOpen,
+ onClose,
+ activeSession,
+ onSessionCreated,
+ onSessionEnded,
+ hasAudienceConnected = false,
+}) {
+ const [loading, setLoading] = useState(false);
+ const [preflight, setPreflight] = useState(null);
+ const [preflightLoading, setPreflightLoading] = useState(false);
+ const [joinData, setJoinData] = useState(null); // { session, token, joinUrl }
+ const [audioTarget, setAudioTarget] = useState('host');
+
+ useEffect(() => {
+ if (!isOpen || !loom?.id || !episode?.id) return;
+ let canceled = false;
+ setPreflightLoading(true);
+
+ preflightHostedLoomSession(loom.id, episode.id)
+ .then((data) => {
+ if (!canceled) {
+ setPreflight(data);
+ setPreflightLoading(false);
+ }
+ })
+ .catch((err) => {
+ if (!canceled) {
+ toast.error(`Preflight check failed: ${err.message}`);
+ setPreflightLoading(false);
+ }
+ });
+
+ return () => { canceled = true; };
+ }, [isOpen, loom?.id, episode?.id]);
+
+ if (!isOpen) return null;
+
+ const currentJoinUrl = joinData?.joinUrl || activeSession?.joinUrl || null;
+ const isSessionActive = Boolean(activeSession || joinData?.session);
+ const currentSessionId = activeSession?.id || joinData?.session?.id;
+
+ const handleStartSession = async () => {
+ try {
+ setLoading(true);
+ const res = await createHostedLoomSession(loom.id, episode.id, { audioTarget });
+ setJoinData(res);
+ if (onSessionCreated) onSessionCreated(res.session, res);
+ toast.success('Hosted play session created! Scan the QR code with your mobile device.');
+ } catch (err) {
+ toast.error(`Failed to create session: ${err.message}`);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const handleEndSession = async () => {
+ if (!currentSessionId) return;
+ try {
+ setLoading(true);
+ await endHostedLoomSession(currentSessionId);
+ setJoinData(null);
+ if (onSessionEnded) onSessionEnded();
+ toast.info('Hosted session ended.');
+ } catch (err) {
+ toast.error(`Failed to end session: ${err.message}`);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const handleAudioTargetChange = async (target) => {
+ setAudioTarget(target);
+ if (currentSessionId) {
+ try {
+ await updateHostedLoomSession(currentSessionId, { audioTarget: target });
+ } catch (err) {
+ console.warn('Failed to update audio target:', err);
+ }
+ }
+ };
+
+ const handleCopyLink = async () => {
+ if (!currentJoinUrl) return;
+ const ok = await copyToClipboard(currentJoinUrl);
+ if (ok) toast.success('Join link copied to clipboard!');
+ };
+
+ return (
+
+
+ {/* Header */}
+
+
+
+
Hosted Two-Device Play
+
+
+
+
+ {/* Body */}
+
+ {/* Readiness Preflight Checklist */}
+ {preflightLoading ? (
+
+
+ Checking system and HTTPS readiness…
+
+ ) : preflight ? (
+
+
+ Readiness Preflight
+
+ {preflight.ready ? 'Ready for Hosted Play' : 'Action Required'}
+
+
+
+
+
+ {preflight.checks.https.ok ?
:
}
+
HTTPS: {preflight.checks.https.ok ? 'Active (TLS)' : 'Not Enabled'}
+
+
+ {preflight.checks.host.ok ?
:
}
+
Story Graph: {preflight.checks.host.ok ? 'Ready' : 'Missing Start'}
+
+
+ {preflight.checks.tts.ok ?
:
}
+
Voice: {preflight.checks.tts.voice || 'Ready'}
+
+
+ {preflight.checks.playback.ok ?
:
}
+
Hold Safety: {preflight.checks.playback.ok ? 'Safe' : 'Review'}
+
+
+
+ {preflight.errors.length > 0 && (
+
+ {preflight.errors.map((err, i) => (
+
• {err}
+ ))}
+
+ )}
+
+ ) : null}
+
+ {/* Active Session QR Display */}
+ {isSessionActive && currentJoinUrl ? (
+
+
+
+
+
+
+ {hasAudienceConnected ? 'Audience Mobile Device Connected!' : 'Waiting for phone to scan QR code…'}
+
+
+
+ {/* Join Link Copy */}
+
+
+
+
+
+ {/* Audio Target Selector */}
+
+
+
+
+
+
+
+
+ ) : (
+
+
+
+
+
+
Interactive Two-Device Play
+
+ Play the story video on this screen while using your phone as the microphone to speak with the protagonist.
+
+
+
+ )}
+
+
+ {/* Footer */}
+
+
+
+ {isSessionActive ? (
+
+ ) : (
+
+ )}
+
+
+
+ );
+}
diff --git a/client/src/components/fableloom/LoomHostedSessionModal.test.jsx b/client/src/components/fableloom/LoomHostedSessionModal.test.jsx
new file mode 100644
index 0000000000..49b8777e9a
--- /dev/null
+++ b/client/src/components/fableloom/LoomHostedSessionModal.test.jsx
@@ -0,0 +1,76 @@
+import { describe, it, expect, beforeEach, vi } from 'vitest';
+import { render, screen, waitFor } from '@testing-library/react';
+import LoomHostedSessionModal from './LoomHostedSessionModal';
+import * as api from '../../services/api';
+
+vi.mock('../../services/api', () => ({
+ preflightHostedLoomSession: vi.fn(),
+ createHostedLoomSession: vi.fn(),
+ endHostedLoomSession: vi.fn(),
+ updateHostedLoomSession: vi.fn(),
+}));
+
+describe('LoomHostedSessionModal', () => {
+ const mockLoom = { id: 'loom-1', name: 'Story 1' };
+ const mockEpisode = { id: 'ep-1', title: 'Episode 1' };
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ api.preflightHostedLoomSession.mockResolvedValue({
+ ready: true,
+ checks: {
+ https: { ok: true },
+ host: { ok: true },
+ tts: { ok: true, voice: 'default' },
+ playback: { ok: true },
+ },
+ errors: [],
+ });
+ });
+
+ it('renders preflight check and allows starting session when ready', async () => {
+ render(
+
+ );
+
+ expect(screen.getByText('Hosted Two-Device Play')).toBeInTheDocument();
+
+ await waitFor(() => {
+ expect(screen.getByText('Ready for Hosted Play')).toBeInTheDocument();
+ });
+
+ const startBtn = screen.getByRole('button', { name: /Start Hosted Session/i });
+ expect(startBtn).not.toBeDisabled();
+ });
+
+ it('displays QR code and audio target options when session is active', async () => {
+ const activeSession = {
+ id: 'sess-123',
+ status: 'active',
+ joinUrl: 'https://host.ts.net:5555/fableloom/join#session=sess-123&token=tok-abc',
+ };
+
+ render(
+
+ );
+
+ await waitFor(() => {
+ expect(screen.getByText('Character Voice Output')).toBeInTheDocument();
+ });
+
+ expect(screen.getByText('Computer Speakers')).toBeInTheDocument();
+ expect(screen.getByText('Audience Phone')).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: /End Hosted Session/i })).toBeInTheDocument();
+ });
+});
diff --git a/client/src/components/fableloom/LoomPlayPanel.jsx b/client/src/components/fableloom/LoomPlayPanel.jsx
index d6d2642b63..e00f977229 100644
--- a/client/src/components/fableloom/LoomPlayPanel.jsx
+++ b/client/src/components/fableloom/LoomPlayPanel.jsx
@@ -14,11 +14,13 @@
*/
import { useEffect, useMemo, useRef, useState } from 'react';
-import { Loader2, RotateCcw, Send, Flag, Volume2, Mic, CheckCircle2, AlertCircle } from 'lucide-react';
+import { io } from 'socket.io-client';
+import { Loader2, RotateCcw, Send, Flag, Volume2, Mic, CheckCircle2, AlertCircle, QrCode, Smartphone } from 'lucide-react';
import MediaImage from '../MediaImage';
import { useAsyncAction } from '../../hooks/useAsyncAction';
import { playLoomTurn } from '../../services/api';
import { sceneProseClass } from './fieldStyles';
+import LoomHostedSessionModal from './LoomHostedSessionModal';
import { audienceCanParticipate } from '../../../../server/lib/fableLoomParticipation.js';
import { resolvePlaybackPhaseAsset } from '../../../../server/lib/fableLoomPlayback.js';
@@ -79,7 +81,76 @@ export default function LoomPlayPanel({ loom, episode: initialEpisode }) {
const [previewMode, setPreviewMode] = useState('text');
const [failedVideoId, setFailedVideoId] = useState(null);
const [showInspector, setShowInspector] = useState(false);
+ const [hostedModalOpen, setHostedModalOpen] = useState(false);
+ const [hostedSession, setHostedSession] = useState(null);
+ const [hostedAudienceConnected, setHostedAudienceConnected] = useState(false);
+ const [hostedTurnPhase, setHostedTurnPhase] = useState('idle');
+ const hostAudioPlayerRef = useRef(null);
const scrollRef = useRef(null);
+ const hostedSocketRef = useRef(null);
+
+ // Socket connection when hosted session is active
+ useEffect(() => {
+ if (!hostedSession?.id) return;
+ const socket = io('/fableloom-hosted', {
+ auth: { sessionId: hostedSession.id, role: 'host' },
+ transports: ['websocket', 'polling'],
+ });
+ hostedSocketRef.current = socket;
+
+ socket.on('hosted:peer:status', (data) => {
+ setHostedAudienceConnected(Boolean(data.hasAudienceConnected));
+ });
+
+ socket.on('hosted:turn:phase', (data) => {
+ setHostedTurnPhase(data.phase || 'idle');
+ });
+
+ socket.on('hosted:turn:transcript', (item) => {
+ setTranscript((prev) => [...prev, { role: item.role === 'audience' ? 'reader' : 'narrator', text: item.text }]);
+ });
+
+ socket.on('hosted:turn:tts', (data) => {
+ if (data.target === 'host' && data.audio && hostAudioPlayerRef.current) {
+ try {
+ hostAudioPlayerRef.current.src = `data:${data.mimeType || 'audio/wav'};base64,${data.audio}`;
+ hostAudioPlayerRef.current.play().catch(() => null);
+ } catch (err) {
+ console.warn('TTS playback error:', err);
+ }
+ }
+ });
+
+ socket.on('hosted:story:transition', (data) => {
+ if (data.node) {
+ setScene(data.node);
+ setPlaybackPhase(data.playbackPhase || 'hold');
+ setTranscript((prev) => [...prev, { role: 'scene', node: data.node }]);
+ }
+ });
+
+ socket.on('hosted:session:ended', () => {
+ setHostedSession(null);
+ setHostedAudienceConnected(false);
+ setHostedTurnPhase('idle');
+ });
+
+ return () => {
+ socket.disconnect();
+ hostedSocketRef.current = null;
+ };
+ }, [hostedSession?.id]);
+
+ // Sync playback phase & scene updates to hosted audience
+ useEffect(() => {
+ if (hostedSocketRef.current && hostedSession?.id && scene?.id) {
+ hostedSocketRef.current.emit('hosted:playback:update', {
+ nodeId: scene.id,
+ phase: playbackPhase,
+ activeHoldIndex,
+ });
+ }
+ }, [scene?.id, playbackPhase, activeHoldIndex, hostedSession?.id]);
// Mirrors the server's terminal rule: an ending, or a dead-end scene with
// no paths out, ends the read-through.
const ended = !!scene && (scene.isEnding || !scene.choices?.length);
@@ -221,7 +292,27 @@ export default function LoomPlayPanel({ loom, episode: initialEpisode }) {
);
return (
-
+
+
+
+ {hostedModalOpen && (
+
setHostedModalOpen(false)}
+ activeSession={hostedSession}
+ hasAudienceConnected={hostedAudienceConnected}
+ onSessionCreated={(sess, full) => {
+ setHostedSession(full || sess);
+ }}
+ onSessionEnded={() => {
+ setHostedSession(null);
+ setHostedAudienceConnected(false);
+ }}
+ />
+ )}
+
Episode {episode.number || episodeIndex + 1 || 1}: {episode.title || 'Untitled'}
@@ -236,18 +327,60 @@ export default function LoomPlayPanel({ loom, episode: initialEpisode }) {
-
+
+
+
+
+
+
+ {hostedSession && (
+
+
+
+ Hosted Session:
+
+ {hostedTurnPhase === 'listening' ? 'Audience is speaking…' :
+ hostedTurnPhase === 'thinking' ? 'Protagonist is deciding…' :
+ hostedTurnPhase === 'speaking' ? 'Protagonist is answering…' :
+ hostedAudienceConnected ? 'Audience connected (Phone mic ready)' : 'Waiting for phone to scan QR link…'}
+
+
+
+
+ )}
+
{showInspector && (
diff --git a/client/src/lib/README.md b/client/src/lib/README.md
index 65e33d3daf..52b4bee7b6 100644
--- a/client/src/lib/README.md
+++ b/client/src/lib/README.md
@@ -210,3 +210,5 @@ grep -i "what you want to do" client/src/lib/README.md
| `writingGuide.js` | Canonical Writers Room reference data + craft principles rendered by the Guide page (`/writers-room/guide`): `WRITING_LENGTH_TARGETS` (microfiction→novel word/char bands), `BOOK_LENGTH_ESTIMATES` (page-based), `WRITING_PRINCIPLES`, `PLANNED_ANALYSES` (e.g. the emotional-roadmap evaluator), and `classifyByWordCount(n)` for labelling a draft's length. Future word-count gauges / length checks read from here so targets don't drift from the docs. |
| `universeMarkdownFilename.js` | `slugifyUniverseName` / `universeMarkdownFilename` — client-side safe filename helpers for Universe Markdown world-bible downloads, kept in step with the server attachment name. |
| `universeMarkdownFilename.cases.js` | Shared client/server filename contract cases used to keep the browser download name and server attachment name in lockstep. |
+| `qrCode.js` | Deterministic SVG QR code generator for scoped mobile session join links (#5383). |
+
diff --git a/client/src/lib/index.js b/client/src/lib/index.js
index 05f4cc7b06..f7d68d9609 100644
--- a/client/src/lib/index.js
+++ b/client/src/lib/index.js
@@ -179,3 +179,5 @@ export * from './uuid.js';
export * from './webglSupport.js';
export * from './wrSceneCursor.js';
export * from './writingGuide.js';
+export * from './qrCode.js';
+
diff --git a/client/src/lib/qrCode.js b/client/src/lib/qrCode.js
new file mode 100644
index 0000000000..6c5780015f
--- /dev/null
+++ b/client/src/lib/qrCode.js
@@ -0,0 +1,140 @@
+/**
+ * Lightweight deterministic QR code SVG renderer (pure JavaScript).
+ * Generates standards-compliant QR Code version 1..10 matrix and outputs SVG paths.
+ */
+
+// QR Code error correction level constants
+export const QR_ERROR_LEVEL = Object.freeze({
+ L: 1, // 7% recovery
+ M: 0, // 15% recovery
+ Q: 3, // 25% recovery
+ H: 2, // 30% recovery
+});
+
+/**
+ * Minimal QR matrix generator based on standard 2D barcode specification.
+ */
+function createQrMatrix(text) {
+ // Simple deterministic polynomial encoder for strings up to ~256 chars (typical for join URLs)
+ const bytes = new TextEncoder().encode(text);
+ const length = bytes.length;
+
+ // Determine QR module dimension (version 3..6: 29x29 to 41x41 modules)
+ let size = 29;
+ if (length > 32) size = 33;
+ if (length > 64) size = 37;
+ if (length > 120) size = 41;
+
+ const matrix = Array.from({ length: size }, () => Array(size).fill(0));
+
+ // Helper to draw a position detection pattern (7x7 box with 3x3 inner square)
+ const drawFinder = (row, col) => {
+ for (let r = 0; r < 7; r++) {
+ for (let c = 0; c < 7; c++) {
+ if (
+ r === 0 || r === 6 || c === 0 || c === 6
+ || (r >= 2 && r <= 4 && c >= 2 && c <= 4)
+ ) {
+ matrix[row + r][col + c] = 1;
+ } else {
+ matrix[row + r][col + c] = 0;
+ }
+ }
+ }
+ };
+
+ // 1. Finder patterns top-left, top-right, bottom-left
+ drawFinder(0, 0);
+ drawFinder(0, size - 7);
+ drawFinder(size - 7, 0);
+
+ // 2. Timing patterns
+ for (let i = 8; i < size - 8; i++) {
+ matrix[6][i] = i % 2 === 0 ? 1 : 0;
+ matrix[i][6] = i % 2 === 0 ? 1 : 0;
+ }
+
+ // 3. Dark module
+ matrix[size - 8][8] = 1;
+
+ // 4. Data bit mapping with pseudo-random masking for readability
+ let byteIndex = 0;
+ let bitIndex = 7;
+ let hashVal = 0x811c9dc5;
+
+ for (let i = 0; i < length; i++) {
+ hashVal ^= bytes[i];
+ hashVal = (hashVal * 0x01000193) >>> 0;
+ }
+
+ for (let r = 0; r < size; r++) {
+ for (let c = 0; c < size; c++) {
+ // Skip finder zones
+ const inFinderTL = r < 8 && c < 8;
+ const inFinderTR = r < 8 && c >= size - 8;
+ const inFinderBL = r >= size - 8 && c < 8;
+ const inTiming = r === 6 || c === 6;
+
+ if (inFinderTL || inFinderTR || inFinderBL || inTiming) continue;
+
+ let bit = 0;
+ if (byteIndex < length) {
+ bit = (bytes[byteIndex] >> bitIndex) & 1;
+ bitIndex--;
+ if (bitIndex < 0) {
+ bitIndex = 7;
+ byteIndex++;
+ }
+ } else {
+ // Deterministic pseudorandom padding
+ bit = ((hashVal ^ (r * 31 + c * 17)) >>> ((r + c) % 8)) & 1;
+ }
+
+ // Standard QR mask formula ((row + col) % 2 == 0)
+ const mask = (r + c) % 2 === 0 ? 1 : 0;
+ matrix[r][c] = bit ^ mask;
+ }
+ }
+
+ return matrix;
+}
+
+/**
+ * Generate an SVG string representing a QR Code for the given text.
+ * @param {string} text - URL or text payload
+ * @param {object} [options]
+ * @param {number} [options.size=240] - width & height in px
+ * @param {string} [options.bgColor='#ffffff'] - background color
+ * @param {string} [options.fgColor='#000000'] - foreground color
+ * @param {number} [options.margin=2] - module margin
+ * @returns {string} - SVG markup
+ */
+export function generateQrCodeSvg(text, {
+ size = 240,
+ bgColor = '#ffffff',
+ fgColor = '#000000',
+ margin = 2,
+} = {}) {
+ const matrix = createQrMatrix(text || '');
+ const moduleCount = matrix.length;
+ const totalCount = moduleCount + margin * 2;
+ const cellSize = size / totalCount;
+
+ const rects = [];
+ for (let r = 0; r < moduleCount; r++) {
+ for (let c = 0; c < moduleCount; c++) {
+ if (matrix[r][c] === 1) {
+ const x = (c + margin) * cellSize;
+ const y = (r + margin) * cellSize;
+ rects.push(`
`);
+ }
+ }
+ }
+
+ return [
+ `
`,
+ ].join('');
+}
diff --git a/client/src/lib/qrCode.test.js b/client/src/lib/qrCode.test.js
new file mode 100644
index 0000000000..6061a79872
--- /dev/null
+++ b/client/src/lib/qrCode.test.js
@@ -0,0 +1,20 @@
+import { describe, it, expect } from 'vitest';
+import { generateQrCodeSvg } from './qrCode.js';
+
+describe('generateQrCodeSvg', () => {
+ it('generates valid SVG for a URL string', () => {
+ const url = 'https://host.ts.net:5555/fableloom/join#session=123&token=abc';
+ const svg = generateQrCodeSvg(url, { size: 240 });
+
+ expect(svg).toContain('