diff --git a/client/src/components/fableloom/LoomEpisodeOutline.jsx b/client/src/components/fableloom/LoomEpisodeOutline.jsx index e14943cf4..3550afd66 100644 --- a/client/src/components/fableloom/LoomEpisodeOutline.jsx +++ b/client/src/components/fableloom/LoomEpisodeOutline.jsx @@ -2,13 +2,14 @@ * FableLoom episode outline — a text-first reading order for an episode graph. * * Reachable scenes are ordered breadth-first from the opening scene so the - * outline follows the same progression as the graph layout. Every scene keeps - * its authored prose and intent paths visible; selecting a path returns to the + * outline follows the same progression as the graph layout. Scene titles and + * metadata stay visible in the tree while authored prose and intent paths are + * available behind collapsed disclosures; selecting a path returns to the * visual editor with that scene selected. */ import { useMemo } from 'react'; -import { ArrowRight, Flag, Play, Waypoints } from 'lucide-react'; +import { ArrowRight, ChevronRight, Flag, Play, Waypoints } from 'lucide-react'; import { sceneProseClass } from './fieldStyles'; const asArray = (value) => (Array.isArray(value) ? value : []); @@ -52,67 +53,76 @@ function SceneBlock({ node, number, episode, format, byId, onSelectNode }) { {number} -
-
-
-

{node.title || 'Untitled scene'}

-
- {isStart && Opening} - {node.isEnding && ( - - {node.endingLabel || 'Ending'} - - )} - {!node.isEnding && ( - {node.playbackMode === 'cut' ? 'Automatic cut' : 'Decision loop'} - )} +
+ +
+
Scene {number} -
+ - {node.prose?.trim() ? ( -
- {node.prose} -
- ) : ( -

No scene text yet.

- )} +
+ {node.prose?.trim() ? ( +
+ {node.prose} +
+ ) : ( +

No scene text yet.

+ )} - {transitions.length > 0 && ( -
-

- {node.playbackMode === 'cut' ? 'Next cut' : 'Viewer paths'} -

-
    - {transitions.map((transition) => { - const target = byId.get(transition.targetNodeId); - return ( -
  • - - - {transition.intent || 'Unlabeled path'} - {transition.description && — {transition.description}} - {' '} - {target ? ( - - ) : ( - Missing scene - )} - -
  • - ); - })} -
-
- )} -
+ {transitions.length > 0 && ( +
+

+ {node.playbackMode === 'cut' ? 'Next cut' : 'Viewer paths'} +

+ +
+ )} + + ); } diff --git a/client/src/components/fableloom/LoomEpisodeOutline.test.jsx b/client/src/components/fableloom/LoomEpisodeOutline.test.jsx index 589c66911..2901f188c 100644 --- a/client/src/components/fableloom/LoomEpisodeOutline.test.jsx +++ b/client/src/components/fableloom/LoomEpisodeOutline.test.jsx @@ -22,16 +22,25 @@ const episode = { }; describe('LoomEpisodeOutline', () => { - it('shows scenes in story order with authored text, paths, and unreachable scenes', () => { + it('starts with a clear collapsed scene tree and expands a scene on demand', async () => { + const user = userEvent.setup(); render(); expect(screen.getByRole('heading', { name: 'The First Door' })).toBeInTheDocument(); - expect(screen.getByText('You stand before the first door.')).toBeInTheDocument(); - expect(screen.getByText('Open it')).toBeInTheDocument(); - expect(screen.getByRole('button', { name: /Scene 2: The Chamber/ })).toBeInTheDocument(); + expect(screen.getByText('Threshold')).toBeInTheDocument(); expect(screen.getByRole('heading', { name: 'Unreachable scenes' })).toBeInTheDocument(); expect(screen.getByText('Forgotten Hall')).toBeInTheDocument(); expect(screen.getByText('Awakened')).toBeInTheDocument(); + + const firstScene = screen.getByTestId('outline-scene-node-1'); + const firstDetails = firstScene.querySelector('details'); + expect(firstDetails).not.toHaveAttribute('open'); + expect(screen.getAllByTestId(/^outline-scene-/).every((scene) => !scene.querySelector('details').open)).toBe(true); + + await user.click(firstDetails.querySelector('summary')); + expect(firstDetails).toHaveAttribute('open'); + expect(screen.getByText('You stand before the first door.')).toBeVisible(); + expect(screen.getByText('Open it')).toBeVisible(); }); it('returns to the visual editor when a path destination is selected', async () => { @@ -39,6 +48,8 @@ describe('LoomEpisodeOutline', () => { const user = userEvent.setup(); render(); + const firstScene = screen.getByTestId('outline-scene-node-1'); + await user.click(firstScene.querySelector('summary')); await user.click(screen.getByRole('button', { name: /Scene 2: The Chamber/ })); expect(onSelectNode).toHaveBeenCalledWith('node-2'); });