Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 7 additions & 7 deletions client/src/components/fableloom/LoomValidationPanel.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,13 +67,13 @@ export default function LoomValidationPanel({ loom, episode, onSelectNode }) {
);

return (
<div className="p-4 space-y-4">
<div className="p-3 sm:p-4 space-y-3 sm:space-y-4">
{/* Panel Tab Navigation */}
<div className="flex border-b border-port-border mb-3 text-xs">
<div className="flex border-b border-port-border mb-3 text-xs overflow-x-auto scrollbar-hide touch-pan-x">
<button
type="button"
onClick={() => setActiveTab('structure')}
className={`px-3 py-1.5 font-medium border-b-2 flex items-center gap-1.5 ${
className={`shrink-0 whitespace-nowrap px-2.5 sm:px-3 py-2 font-medium border-b-2 flex items-center gap-1.5 ${
activeTab === 'structure'
? 'border-port-accent text-port-accent'
: 'border-transparent text-port-text-muted hover:text-port-text'
Expand All @@ -85,7 +85,7 @@ export default function LoomValidationPanel({ loom, episode, onSelectNode }) {
<button
type="button"
onClick={() => setActiveTab('production')}
className={`px-3 py-1.5 font-medium border-b-2 flex items-center gap-1.5 ${
className={`shrink-0 whitespace-nowrap px-2.5 sm:px-3 py-2 font-medium border-b-2 flex items-center gap-1.5 ${
activeTab === 'production'
? 'border-port-accent text-port-accent'
: 'border-transparent text-port-text-muted hover:text-port-text'
Expand All @@ -101,7 +101,7 @@ export default function LoomValidationPanel({ loom, episode, onSelectNode }) {
) : (
<>
<div>
<div className="flex items-center justify-between mb-2">
<div className="flex flex-wrap items-baseline justify-between gap-x-2 gap-y-0.5 mb-2">
<h3 className="text-sm font-semibold">Structure</h3>
{structural?.stats && (
<span className="text-xs text-port-text-muted">
Expand All @@ -124,7 +124,7 @@ export default function LoomValidationPanel({ loom, episode, onSelectNode }) {

{structural?.productionReadiness && (
<div>
<div className="flex items-center justify-between mb-2">
<div className="flex flex-wrap items-baseline justify-between gap-x-2 gap-y-0.5 mb-2">
<h3 className="text-sm font-semibold">Production readiness</h3>
<span className="text-xs text-port-text-muted">
{structural.productionReadiness.ready ? 'Ready for live voice' : `${structural.productionReadiness.totalErrors} blocking error(s)`}
Expand All @@ -141,7 +141,7 @@ export default function LoomValidationPanel({ loom, episode, onSelectNode }) {
)}

<div>
<div className="flex items-center justify-between mb-2">
<div className="flex flex-wrap items-baseline justify-between gap-x-2 gap-y-0.5 mb-2">
<h3 className="text-sm font-semibold">Story review</h3>
<button
type="button"
Expand Down
11 changes: 8 additions & 3 deletions client/src/components/ui/TabPills.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,12 @@
// app's other toggle filters use — while sharing this component's styling.
// Knobs cover the call-site quirks: `runningKind` swaps a per-tab icon for a
// spinner; `stretch` makes each tab `flex-1` (StoryboardPanel); `mobileDropdown`
// collapses to a `<select>` below `sm` (UniverseBuilder); `controlsIdPrefix`
// wires `aria-controls` (and `id="tab-<id>"`) to matching tabpanels — pass
// collapses to a `<select>` below `sm` (UniverseBuilder), whose wrapper is
// `sm:hidden` unless `mobileSelectClassName` replaces it wholesale — a caller
// passes that to make the select a flex sibling sharing one row with other
// controls (`sm:hidden min-w-0 flex-1` in FableLoomStory's episode row), and the
// replacement must carry its own `sm:hidden` or the select shows on desktop;
// `controlsIdPrefix` wires `aria-controls` (and `id="tab-<id>"`) to matching tabpanels — pass
// `'tabpanel'` to mirror ChiefOfStaff's wiring. `t.trailing` is an optional
// ReactNode rendered after the count (e.g. PipelineIssue's per-stage status dot).
import { Loader2 } from 'lucide-react';
Expand All @@ -30,6 +34,7 @@ export default function TabPills({
runningKind = null,
mobileDropdown = false,
mobileSelectId,
mobileSelectClassName = '',
ariaLabel,
controlsIdPrefix,
hideLabelOnMobile = false,
Expand All @@ -43,7 +48,7 @@ export default function TabPills({
// Mobile `<select>` collapse, shared by both variants so `mobileDropdown` works
// regardless of `variant` (the underline tab bar just overflow-scrolls without it).
const mobileSelect = mobileDropdown ? (
<div className="sm:hidden">
<div className={mobileSelectClassName || 'sm:hidden'}>
{mobileSelectId && <label htmlFor={mobileSelectId} className="sr-only">{ariaLabel || 'Section'}</label>}
<select
id={mobileSelectId}
Expand Down
116 changes: 65 additions & 51 deletions client/src/pages/FableLoomStory.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,11 @@
* breakpoint). Right rail: the selected scene's editor, or the
* structure/review panel when nothing is selected. On small screens a
* selected scene slides up over the graph as a dismissible details sheet.
* The page never scrolls (the app `<main>` is `overflow-hidden` for this
* route), so the stacked graph/rail split is sized in percentages of the pane
* left under the header, and the header demotes its actions into an
* `OverflowMenu` on phones — `vh` sizing here silently pushes the rail's
* content off-screen.
*/

import { useCallback, useEffect, useMemo, useState } from 'react';
Expand All @@ -19,6 +24,7 @@ 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 OverflowMenu from '../components/ui/OverflowMenu';
import PageSkeleton from '../components/ui/PageSkeleton';
import TabPills from '../components/ui/TabPills';
import { useAsyncAction } from '../hooks/useAsyncAction';
Expand Down Expand Up @@ -52,6 +58,14 @@ import {
weaveLoomEpisode,
} from '../services/api';

// Phone-first sizing: a 44px touch target next to the overflow trigger, then
// the denser desktop row from `sm` up. Base and variants never both set the
// same utility — with no tailwind-merge, two border colors resolve by
// stylesheet order, not by class-string order.
const headerActionBase = 'flex min-h-[44px] items-center gap-1 rounded border px-2.5 py-2 text-xs sm:min-h-[36px] sm:py-1.5';
const headerActionClass = `${headerActionBase} border-port-border hover:border-port-accent`;
const headerPlayClass = `${headerActionBase} border-port-accent bg-port-accent text-white`;

export default function FableLoomStory({ view = 'graph' }) {
const { loomId, episodeId, nodeId } = useParams();
const navigate = useNavigate();
Expand Down Expand Up @@ -411,6 +425,18 @@ export default function FableLoomStory({ view = 'graph' }) {
}
};

// One list drives both header shapes: labelled buttons from `sm` up, and the
// same actions inside the overflow menu on phones, where five buttons plus
// the loom title ran off the right edge of the screen.
const headerActions = [
{ id: 'settings', label: 'Story settings', short: 'Settings', icon: Settings, onSelect: () => setSettingsOpen(true) },
...(episode ? [
{ id: 'add-scene', label: 'Add scene', short: 'Scene', icon: Plus, onSelect: handleAddNode },
{ id: 'edit-episode', label: 'Edit episode', hint: 'Edit episode title and synopsis', icon: PencilLine, onSelect: () => setSetupOpen(true) },
{ id: 'weave', label: 'Weave', hint: 'Weave this episode with AI', icon: Sparkles, onSelect: () => setSetupOpen(true) },
] : []),
];

const handleMoveNode = (movedNodeId, pos) => {
// Optimistic: fold the new position into local state, persist silently.
// The echo is NOT folded back in — pos is already exact client-side, and
Expand Down Expand Up @@ -457,77 +483,61 @@ export default function FableLoomStory({ view = 'graph' }) {
onUpdate={handleMediaJobUpdate}
onTerminal={handleMediaJobTerminal}
/>
<header className="border-b border-port-border px-4 py-2.5 space-y-2">
<div className="flex items-center gap-3 flex-wrap">
<Link to="/fableloom" className="text-port-text-muted hover:text-port-text" aria-label="Back to FableLoom">
<header className="border-b border-port-border px-3 py-2 sm:px-4 sm:py-2.5 space-y-1.5 sm:space-y-2">
<div className="flex items-center gap-2 sm:gap-3">
<Link to="/fableloom" className="shrink-0 text-port-text-muted hover:text-port-text" aria-label="Back to FableLoom">
<ArrowLeft size={18} />
</Link>
<h1 className="font-semibold flex items-center gap-2 min-w-0">
<h1 className="font-semibold flex items-center gap-2 min-w-0 flex-1">
<Waypoints size={16} className="text-port-accent shrink-0" />
<span className="truncate">{loom.name}</span>
</h1>
{linkedSeries ? (
<Link
to={`/pipeline/series/${encodeURIComponent(linkedSeries.id)}`}
className="flex items-center gap-1 px-2 py-1 rounded border border-port-border text-xs text-port-text-muted hover:text-port-text hover:border-port-accent min-w-0"
className="flex min-w-0 shrink items-center gap-1 rounded border border-port-border px-2 py-1 text-xs text-port-text-muted hover:border-port-accent hover:text-port-text"
title="Open the series this branching narrative is linked to"
>
<WorkflowIcon size={12} className="shrink-0" />
<span className="truncate max-w-[12rem]">{linkedSeries.name || 'Untitled series'}</span>
<span className="truncate max-w-[5rem] sm:max-w-[12rem]">{linkedSeries.name || 'Untitled series'}</span>
</Link>
) : null}
<div className="flex items-center gap-2 ml-auto">
<button
type="button"
onClick={() => setSettingsOpen(true)}
aria-label="Story settings"
className="flex items-center gap-1 px-2.5 py-1.5 rounded border border-port-border text-xs hover:border-port-accent"
>
<Settings size={13} /> Settings
</button>
<div className="flex shrink-0 items-center gap-2">
<div className="hidden items-center gap-2 sm:flex">
{headerActions.map((action) => (
<button
key={action.id}
type="button"
onClick={action.onSelect}
aria-label={action.label}
title={action.hint || action.label}
className={headerActionClass}
>
<action.icon size={13} /> {action.short || action.label}
</button>
))}
</div>
<OverflowMenu label="Story actions" items={headerActions} className="sm:hidden" />
{episode && (
<>
<button
type="button"
onClick={handleAddNode}
aria-label="Add scene"
className="flex items-center gap-1 px-2.5 py-1.5 rounded border border-port-border text-xs hover:border-port-accent"
>
<Plus size={13} /> Scene
</button>
<button
type="button"
onClick={() => setSetupOpen(true)}
aria-label="Edit episode"
title="Edit episode title and synopsis"
className="flex items-center gap-1 px-2.5 py-1.5 rounded border border-port-border text-xs hover:border-port-accent"
>
<PencilLine size={13} /> Edit episode
</button>
<button
type="button"
onClick={() => setSetupOpen(true)}
className="flex items-center gap-1 px-2.5 py-1.5 rounded border border-port-border text-xs hover:border-port-accent"
>
<Sparkles size={13} /> Weave
</button>
<button
type="button"
onClick={() => setPlayOpen(true)}
className="flex items-center gap-1 px-2.5 py-1.5 rounded bg-port-accent text-white text-xs"
aria-label="Play"
title="Play this episode"
className={headerPlayClass}
>
<BookOpenText size={13} /> Play
</button>
</>
)}
</div>
</div>
<div className="flex items-center gap-2 flex-wrap">
<div className="flex items-center gap-1.5 sm:gap-2 flex-wrap">
<TabPills
variant="pills"
size="sm"
ariaLabel="Series and episodes"
mobileDropdown
mobileSelectClassName="sm:hidden min-w-0 flex-1"
tabs={[
{ id: 'plan', label: 'Series plan' },
...loom.episodes.map((e) => ({ id: e.id, label: `${e.number}. ${e.title || 'Untitled'}` })),
Expand All @@ -538,9 +548,11 @@ export default function FableLoomStory({ view = 'graph' }) {
<button
type="button"
onClick={handleAddEpisode}
className="px-2.5 py-1 rounded-full text-xs border border-dashed border-port-border text-port-text-muted hover:border-port-accent hover:text-port-accent"
aria-label="Add episode"
title="Add episode"
className="flex min-h-[36px] shrink-0 items-center rounded-full border border-dashed border-port-border px-3 text-xs text-port-text-muted hover:border-port-accent hover:text-port-accent"
>
+ Episode
+ <span className="hidden sm:inline">Episode</span>
</button>
{episode && (
<TabPills
Expand Down Expand Up @@ -583,8 +595,13 @@ export default function FableLoomStory({ view = 'graph' }) {
onSelectNode={(id) => navigate(episodePath(episode.id, id))}
/>
) : (
// Stacked (phone) split: the graph takes whatever the validation rail
// doesn't. The rail is capped as a PERCENTAGE OF THIS PANE, never in
// `vh` — the page is `overflow-hidden` under the app chrome, so viewport
// units ignored the header and pushed the rail's content off-screen
// (the graph claimed 55vh while only ~70vh was left to split).
<div className="relative flex-1 min-h-0 flex flex-col lg:flex-row">
<section className="flex-1 min-h-[55vh] lg:min-h-0 min-w-0 relative">
<section className="relative min-h-0 min-w-0 flex-1">
{episode.nodes.length ? (
<LoomCanvas
episode={episode}
Expand Down Expand Up @@ -637,11 +654,8 @@ export default function FableLoomStory({ view = 'graph' }) {
data-testid={node ? 'scene-details-sheet' : 'loom-validation-rail'}
aria-label={node ? `${node.title || 'Scene'} details` : 'Episode validation'}
className={node
? 'absolute inset-x-0 bottom-0 z-20 flex h-[calc(100%_-_0.75rem)] max-h-dvh-cap flex-col overflow-hidden rounded-t-2xl border border-b-0 border-port-border bg-port-card shadow-2xl motion-safe:animate-in motion-safe:slide-in-from-bottom-4 motion-safe:duration-200 lg:static lg:h-auto lg:max-h-none lg:w-[380px] lg:shrink-0 lg:rounded-none lg:border-y-0 lg:border-r-0 lg:border-l'
: 'flex max-h-dvh-cap flex-col overflow-hidden border-t border-port-border lg:max-h-none lg:w-[380px] lg:shrink-0 lg:border-t-0 lg:border-l'}
style={node
? { '--dvh-cap': '78vh', '--dvh-cap-dynamic': '78dvh' }
: { '--dvh-cap': '45vh', '--dvh-cap-dynamic': '45dvh' }}
? 'absolute inset-x-0 bottom-0 z-20 flex h-[calc(100%_-_0.75rem)] flex-col overflow-hidden rounded-t-2xl border border-b-0 border-port-border bg-port-card shadow-2xl motion-safe:animate-in motion-safe:slide-in-from-bottom-4 motion-safe:duration-200 lg:static lg:h-auto lg:w-[380px] lg:shrink-0 lg:rounded-none lg:border-y-0 lg:border-r-0 lg:border-l'
: 'flex max-h-[45%] flex-col overflow-hidden border-t border-port-border lg:max-h-none lg:w-[380px] lg:shrink-0 lg:border-t-0 lg:border-l'}
>
{node && (
<div className="relative flex min-h-[4.5rem] shrink-0 items-center justify-center border-b border-port-border px-4 py-3 lg:hidden">
Expand Down
34 changes: 33 additions & 1 deletion client/src/pages/FableLoomStory.test.jsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import { render, screen, waitFor, within } from '@testing-library/react';
import { MemoryRouter, Route, Routes } from 'react-router';
import userEvent from '@testing-library/user-event';

Expand Down Expand Up @@ -269,6 +269,38 @@ describe('FableLoomStory episode rail layout', () => {
expect(rail).toHaveClass('flex', 'flex-col', 'overflow-hidden');
expect(rail.firstElementChild).toHaveClass('min-h-0', 'flex-1', 'overflow-y-auto');
});

it('sizes the stacked graph/rail split against the pane, never the viewport', async () => {
api.getLoom.mockResolvedValue(loom({ episodes: [episode()] }));
renderEditor('/fableloom/loom-1/ep-1');

const rail = await screen.findByTestId('loom-validation-rail');
expect(rail).toHaveClass('max-h-[45%]', 'lg:max-h-none');
// A `vh` cap ignores the page header the pane sits under, so the rail's
// content was clipped below the fold of an `overflow-hidden` page.
expect(rail.getAttribute('style') || '').not.toMatch(/vh/);
// The graph takes what's left rather than claiming a `vh` floor of its own.
expect(rail.previousElementSibling).toHaveClass('flex-1', 'min-h-0');
});
});

describe('FableLoomStory mobile header', () => {
it('offers every header action from the phone overflow menu', async () => {
const user = userEvent.setup();
api.getLoom.mockResolvedValue(loom({ episodes: [episode()] }));
renderEditor('/fableloom/loom-1/ep-1');

// The labelled row is desktop-only; on a phone the same actions live behind
// the overflow trigger, because five buttons ran off the right edge.
await user.click(await screen.findByRole('button', { name: 'Story actions' }));
const menu = screen.getByRole('menu', { name: 'Story actions' });
for (const name of ['Story settings', 'Add scene', 'Edit episode', 'Weave']) {
expect(within(menu).getByRole('menuitem', { name })).toBeInTheDocument();
}

await user.click(within(menu).getByRole('menuitem', { name: 'Edit episode' }));
expect(screen.getByRole('heading', { name: 'Episode setup' })).toBeInTheDocument();
});
});

describe('FableLoomStory mobile scene details', () => {
Expand Down