Phase:
)}
- {liveVoiceActive && (
-
-
- Off-screen voice window open
-
-
- Ambience ducked {scene?.interactionWindow?.ambientDuckDb ?? -8} dB
-
-
- )}
-
{transcript.map((turn, i) => {
if (turn.role === 'scene') {
- if (previewMode === 'video' || i === latestSceneTurnIndex) return null;
+ if (i === latestSceneTurnIndex) return null;
const historicalCut = !turn.node.isEnding
&& turn.node.choices?.length > 0
&& (!audienceCanParticipate(loom, turn.node)
|| (turn.node.playbackMode === 'cut' && turn.node.choices.length === 1));
return (
-
);
}
@@ -483,18 +544,11 @@ export default function LoomPlayPanel({ loom, episode: initialEpisode }) {
);
})}
- setFailedVideoId(currentAsset.videoHistoryId)}
/>
{ended && (
@@ -519,7 +573,7 @@ export default function LoomPlayPanel({ loom, episode: initialEpisode }) {
/>
)}
-
+
{!ended && audienceConnected && !automaticCut && scene?.choices?.length > 0 && (
{scene.choices.filter((c) => c.intent).map((c) => (
@@ -596,6 +650,8 @@ export default function LoomPlayPanel({ loom, episode: initialEpisode }) {
)}
}
+
+
);
}
@@ -612,10 +668,9 @@ function SeriesDeliveryCard({ label, title, transcript, emptyMessage }) {
);
}
-function SceneCard({
- node, isOpening = false, format, previewMode, onCutEnded, automaticCut,
- playbackPhase = 'hold', activeAsset = null,
- helperMode = false, videoFailed = false, onVideoError,
+function SceneMedia({
+ node, previewMode, onCutEnded, automaticCut,
+ playbackPhase = 'hold', activeAsset = null, videoFailed = false, onVideoError,
}) {
if (!node) return null;
const videoId = activeAsset?.videoHistoryId || node.videoHistoryId || null;
@@ -626,8 +681,26 @@ function SceneCard({
const holdLoopCount = node.playbackAssets?.holdLoopVideoHistoryIds?.length || 0;
const shouldLoopNatively = !automaticCut && !node.isEnding && playbackPhase === 'hold' && holdLoopCount <= 1;
+ if (!showVideo && !showImage) {
+ const message = previewMode === 'image'
+ ? 'No storyboard image rendered for this cut yet.'
+ : previewMode === 'video'
+ ? videoFailed
+ ? 'The rendered video is unavailable; advance manually or retry after rendering.'
+ : 'No video rendered for this cut yet.'
+ : 'Text rehearsal';
+ return (
+
+
+
{previewMode === 'text' ? 'FableLoom' : 'Media pending'}
+
{message}
+
+
+ );
+ }
+
return (
-
+ <>
{showVideo && (
)}
{showImage && (
-
+
)}
-
-
- {isOpening ? 'Opening' : node.isEnding ? (node.endingLabel || 'Ending') : node.title || 'Scene'}
-
- {previewMode === 'text' &&
{node.prose}
}
- {previewMode === 'image' && (
-
-
- Scene description & dialogue
-
-
- {node.prose || 'No scene description has been authored yet.'}
-
+ >
+ );
+}
+
+function SceneScriptCard({
+ node, format, automaticCut, helperMode = false, historical = false,
+}) {
+ if (!node) return null;
+
+ return (
+
+
+
+
+ {historical ? 'Earlier scene' : 'Scene description & dialogue'}
- )}
- {previewMode === 'image' && !node.image &&
No storyboard image rendered for this cut yet.
}
- {previewMode === 'video' && (!videoId || videoFailed) && (
-
- {videoFailed ? 'The rendered video is unavailable; advance manually or retry after rendering.' : 'No video rendered for this cut yet.'}
-
- )}
+
{node.title || 'Untitled scene'}
+
+ {!historical &&
{format === 'teleplay' ? 'Teleplay' : 'Story'}}
+
+
+ {node.prose || 'No scene description has been authored yet.'}
+
{!node.isEnding && (
{automaticCut
@@ -683,7 +758,6 @@ function SceneCard({
Protagonist off-screen — keep this decision loop running while the audience conversation happens on the side device.
)}
-
-
+
);
}
diff --git a/client/src/components/fableloom/LoomPlayPanel.test.jsx b/client/src/components/fableloom/LoomPlayPanel.test.jsx
index 4ae9739e5..1e25da4a4 100644
--- a/client/src/components/fableloom/LoomPlayPanel.test.jsx
+++ b/client/src/components/fableloom/LoomPlayPanel.test.jsx
@@ -3,7 +3,7 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
vi.mock('../../services/api', () => ({ playLoomTurn: vi.fn() }));
-vi.mock('../MediaImage', () => ({ default: () => null }));
+vi.mock('../MediaImage', () => ({ default: (props) =>
![]()
}));
import { playLoomTurn } from '../../services/api';
import LoomPlayPanel from './LoomPlayPanel';
@@ -31,6 +31,28 @@ const sendMessage = async (user, text) => {
beforeEach(() => vi.clearAllMocks());
describe('LoomPlayPanel', () => {
+ it('presents produced media as the main stage with the teleplay beside it', () => {
+ const onClose = vi.fn();
+ const producedEpisode = {
+ ...episode,
+ nodes: [{ ...episode.nodes[0], image: 'opening-still.png' }, episode.nodes[1]],
+ };
+
+ render(
);
+
+ expect(screen.getByRole('region', { name: 'Scene media' })).toBeInTheDocument();
+ expect(screen.getByRole('complementary', { name: 'Teleplay' })).toHaveTextContent('You stand before it.');
+ expect(screen.getByRole('img', { name: 'The Gate' })).toHaveClass('h-full', 'w-full', 'object-contain');
+ expect(screen.getByLabelText('Preview stage')).toHaveValue('image');
+
+ fireEvent.click(screen.getByRole('button', { name: 'Close player' }));
+ expect(onClose).toHaveBeenCalledTimes(1);
+ });
+
it('renders the opening scene with intent hint chips', () => {
render(
);
expect(screen.getByText('You stand before it.')).toBeInTheDocument();
@@ -198,7 +220,9 @@ describe('LoomPlayPanel', () => {
await waitFor(() => expect(playLoomTurn).toHaveBeenCalledWith(
'loom-1', 'ep-cut', expect.objectContaining({ nodeId: 'cut-1', transitionId: 'continue-1' }), { silent: true },
));
- await waitFor(() => expect(screen.getByText('Wait')).toBeInTheDocument());
+ await waitFor(() => expect(screen.getAllByText('Wait')).not.toHaveLength(0));
+ expect(screen.getByText('A door opens.')).toBeInTheDocument();
+ expect(screen.getByText('A guard paces.')).toBeInTheDocument();
});
it('loops rendered decision video while waiting for input', async () => {
@@ -417,7 +441,7 @@ describe('LoomPlayPanel', () => {
await waitFor(() => expect(playLoomTurn).toHaveBeenCalledWith(
'loom-1', 'ep-prod', expect.objectContaining({ transitionId: 'tr-gate' }), { silent: true },
));
- await waitFor(() => expect(screen.getByText('Ending')).toBeInTheDocument());
+ await waitFor(() => expect(screen.getAllByText('Ending')).not.toHaveLength(0));
});
it('shows rehearsal details with inspector drawer', async () => {
diff --git a/client/src/pages/FableLoomStory.jsx b/client/src/pages/FableLoomStory.jsx
index 7cd2575c1..d234d50bb 100644
--- a/client/src/pages/FableLoomStory.jsx
+++ b/client/src/pages/FableLoomStory.jsx
@@ -4,7 +4,7 @@
* URL is the source of truth: /fableloom/:loomId/plan is the series workspace;
* /fableloom/:loomId/:episodeId selects an episode, /outline switches that
* episode to its text outline, and /:nodeId selects a scene in the graph. The
- * play drawer rides ?play=1.
+ * full-screen player rides ?play=1.
* Left: the scene-graph canvas (stacks top-to-bottom under the `lg` rail
* breakpoint). Right rail: the selected scene's editor, or the
* structure/review panel when nothing is selected. On small screens a
@@ -18,12 +18,14 @@ import toast from '../components/ui/Toast';
import Drawer from '../components/Drawer';
import ConfirmButtonPair from '../components/ui/ConfirmButtonPair';
import { FormField } from '../components/ui/FormField.jsx';
+import Modal from '../components/ui/Modal';
import PageSkeleton from '../components/ui/PageSkeleton';
import TabPills from '../components/ui/TabPills';
import { useAsyncAction } from '../hooks/useAsyncAction';
import { useConfirmDelete } from '../hooks/useConfirmDelete';
import useFableLoomAiRun from '../hooks/useFableLoomAiRun';
import useContainerWidth from '../hooks/useContainerWidth';
+import { useScrollLock } from '../hooks/useScrollLock';
import LoomCanvas from '../components/fableloom/LoomCanvas';
import LoomEpisodeOutline from '../components/fableloom/LoomEpisodeOutline';
import LoomEpisodeOutlinePlanner from '../components/fableloom/LoomEpisodeOutlinePlanner';
@@ -68,6 +70,7 @@ export default function FableLoomStory({ view = 'graph' }) {
const [setupOpen, setSetupOpen] = useState(false);
const [settingsOpen, setSettingsOpen] = useState(false);
const playOpen = searchParams.get('play') === '1';
+ useScrollLock(playOpen);
const seriesPlanOpen = episodeId === 'plan';
const outlineOpen = view === 'outline';
// Orientation keys off the PAGE, not the canvas. The canvas is the leftover
@@ -684,9 +687,19 @@ export default function FableLoomStory({ view = 'graph' }) {
/>
{episode && (
-
setPlayOpen(false)} title="Play" subtitle={loom.name} size="md" bodyClassName="p-0">
-
-
+
setPlayOpen(false)}
+ size="none"
+ align="none"
+ usePortal
+ zIndexClassName="z-[100]"
+ backdropClassName="bg-black"
+ panelClassName="h-[100dvh] w-full overflow-hidden bg-black"
+ ariaLabel={`${loom.name} player`}
+ >
+ setPlayOpen(false)} />
+
)}
);
diff --git a/client/src/pages/FableLoomStory.test.jsx b/client/src/pages/FableLoomStory.test.jsx
index fff1880de..67c08ace9 100644
--- a/client/src/pages/FableLoomStory.test.jsx
+++ b/client/src/pages/FableLoomStory.test.jsx
@@ -123,6 +123,22 @@ beforeEach(() => {
});
describe('FableLoomStory navigation and series backlink', () => {
+ it('opens Play as a viewport-filling movie player', async () => {
+ const user = userEvent.setup();
+ api.getLoom.mockResolvedValue(loom({ episodes: [episode()] }));
+ renderEditor('/fableloom/loom-1/ep-1');
+
+ await user.click(await screen.findByRole('button', { name: 'Play' }));
+
+ const player = screen.getByRole('dialog', { name: 'Example Loom player' });
+ expect(player).toHaveClass('h-[100dvh]', 'w-full', 'overflow-hidden', 'bg-black');
+ expect(document.body.style.overflow).toBe('hidden');
+
+ await user.keyboard('{Escape}');
+ await waitFor(() => expect(screen.queryByRole('dialog', { name: 'Example Loom player' })).not.toBeInTheDocument());
+ expect(document.body.style.overflow).toBe('');
+ });
+
it('opens an empty loom in the series plan before asking for episodes', async () => {
renderEditor();