feat(stories): redesign — chapter section bars, grouped toolbar, readable column (#227)

Full design polish pass (behavior unchanged; JSX structure + CSS only):

- Chapters render as distinct section bars (heading title + grip + delete) with
  an accent left-border — no speaker/voice/tune/preview controls. Detection
  (isChapterText) is lenient so clearing the title doesn't flip the bar back to
  a voiced line mid-edit; unified with the chapter auto-numberer.
- Toolbar split into three labelled clusters with thin dividers — Project
  (Projects · Cast) · Content (Import · Paste&Split · +Line · +Chapter) ·
  Output (Stems · format · Generate) — and wraps instead of cramming one row.
- Editor centered at a 1040px reading column so lines no longer stretch
  edge-to-edge on wide windows.
- A line's secondary actions (inline-voice / tune / pause / preview / delete)
  are quieted to 0.5 opacity and revealed on row hover/active, cutting visual
  noise. Drag handlers factored into a shared dragProps (reused by both bar
  and line) so reordering still works across chapters + lines.

Verified: tsc clean, build OK, vitest 167/167.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Palash Debnath
2026-06-01 13:40:40 +05:30
committed by GitHub
co-authored by Claude Opus 4.8
parent 94f2363fa2
commit 94ed7e6347
2 changed files with 172 additions and 47 deletions
+65
View File
@@ -459,3 +459,68 @@
font-size: var(--text-xs);
padding: 3px 6px;
}
/* ── Redesign: grouped toolbar, chapter bars, readable column ──────── */
/* Center the editor at a comfortable reading width (header + script align). */
.stories-editor { max-width: 1040px; margin-inline: auto; }
/* Toolbar: logical clusters (project · content · output) with thin dividers. */
.stories-editor__actions { flex-wrap: wrap; }
.stories-editor__group { display: inline-flex; align-items: center; gap: 4px; }
.stories-editor__divider {
align-self: stretch;
width: 1px;
min-height: 18px;
margin: 0 4px;
background: var(--color-border);
opacity: 0.7;
}
/* Chapter = a section heading, not a voiced line. */
.stories-chapter {
display: flex;
align-items: center;
gap: 8px;
margin: 16px 0 4px;
padding: 7px 10px;
border-radius: var(--radius-md, 8px);
border-left: 3px solid var(--color-accent);
background: var(--color-bg-elevated, rgba(255, 255, 255, 0.03));
}
.stories-chapter--dragover { outline: 1px dashed var(--color-accent); outline-offset: 2px; }
.stories-chapter__grip { display: flex; color: var(--color-fg-dim, #a89884); cursor: grab; opacity: 0.4; }
.stories-chapter:hover .stories-chapter__grip { opacity: 1; }
.stories-chapter__icon { flex: none; color: var(--color-accent); }
.stories-chapter__title {
flex: 1;
min-width: 0;
background: none;
border: none;
outline: none;
font-family: inherit;
font-size: 0.95rem;
font-weight: 700;
letter-spacing: 0.01em;
color: var(--color-fg);
padding: 2px 0;
}
.stories-chapter__title::placeholder { color: var(--color-fg-dim, #a89884); font-weight: 600; }
.stories-chapter__del {
flex: none;
display: flex;
padding: 4px;
border-radius: 6px;
background: none;
border: none;
color: var(--color-fg-dim, #a89884);
cursor: pointer;
opacity: 0;
}
.stories-chapter:hover .stories-chapter__del { opacity: 0.7; }
.stories-chapter__del:hover { opacity: 1; color: var(--color-danger, #fb4934); }
/* Quiet a line's secondary actions until the row is hovered or active. */
.stories-track__actions { opacity: 0.5; transition: opacity 0.12s ease; }
.stories-track:hover .stories-track__actions,
.stories-track--active .stories-track__actions { opacity: 1; }
+107 -47
View File
@@ -37,6 +37,14 @@ function download(blob, filename) {
setTimeout(() => URL.revokeObjectURL(url), 10000);
}
// A chapter line is any track whose text is a markdown heading (`# …`). It
// renders as a section bar (no voice/tune/preview), and storyExport keys its
// chapter cues off the same prefix — keep the two in sync.
// Lenient on purpose: a heading with an empty title is still `# ` (or `#`), and
// it must stay a chapter while the user edits the title — otherwise clearing the
// text would flip the bar back into a voiced line card mid-edit.
const isChapterText = (s) => /^\s*#{1,6}(\s|$)/.test(s || '');
// Sentence-aware splitter for the "Paste & auto-split" panel. Walks the text
// and breaks at the closest sentence boundary that keeps each chunk under
// `maxChars`. Falls back to whitespace, then to the hard cap.
@@ -207,7 +215,7 @@ export default function StoriesEditor({ profiles = [] }) {
const openProject = useCallback((id) => { loadProject(id); setProjectsOpen(false); }, [loadProject]);
const addChapter = useCallback(() => {
const n = tracks.filter((tk) => /^#{1,6}\s+/.test((tk.text || '').trim())).length + 1;
const n = tracks.filter((tk) => isChapterText(tk.text)).length + 1;
setTracks((prev) => [...prev, makeTrack('narrator', `# ${t('stories.chapterN', { n })}`)]);
}, [tracks, setTracks, t]);
@@ -393,40 +401,56 @@ export default function StoriesEditor({ profiles = [] }) {
</div>
<div className="stories-editor__actions">
<input ref={fileInputRef} type="file" accept=".txt,.srt,text/plain" onChange={onImportFile} hidden />
<Button size="sm" variant="ghost" onClick={() => setProjectsOpen((v) => !v)} aria-label={t('stories.projects')}>
<Folder size={13} /> {currentProject ? currentProject.name : t('stories.projects')}
</Button>
<Button size="sm" variant="ghost" onClick={() => setCastOpen((v) => !v)} aria-label={t('stories.cast')}>
<Users size={13} /> {t('stories.cast')}
</Button>
<Button size="sm" variant="ghost" onClick={() => fileInputRef.current && fileInputRef.current.click()} aria-label={t('stories.import')}>
<Upload size={13} /> {t('stories.import')}
</Button>
<Button size="sm" variant="ghost" onClick={() => setSplitOpen((v) => !v)} aria-label={t('stories.pasteSplit')}>
<Scissors size={13} /> {t('stories.pasteSplit')}
</Button>
<Button size="sm" variant="ghost" onClick={addTrack} aria-label={t('stories.addLine')}>
<Plus size={13} /> {t('stories.addLine')}
</Button>
<Button size="sm" variant="ghost" onClick={addChapter} aria-label={t('stories.addChapter')}>
<Bookmark size={13} /> {t('stories.addChapter')}
</Button>
<Button size="sm" variant="ghost" onClick={exportStemsAll} disabled={tracks.length === 0 || exporting} aria-label={t('stories.stems')}>
<Layers size={13} /> {t('stories.stems')}
</Button>
<select
className="stories-editor__format"
value={exportFormat}
onChange={(e) => setExportFormat(e.target.value)}
aria-label={t('stories.format')}
title={t('stories.format')}
>
<option value="wav">WAV</option>
<option value="mp3">MP3</option>
</select>
<Button size="sm" onClick={generateAll} disabled={tracks.length === 0 || exporting}>
<Download size={13} /> {exporting ? `${exportPct}%` : t('stories.generateAll')}
</Button>
{/* Project */}
<div className="stories-editor__group">
<Button size="sm" variant="ghost" onClick={() => setProjectsOpen((v) => !v)} aria-label={t('stories.projects')}>
<Folder size={13} /> {currentProject ? currentProject.name : t('stories.projects')}
</Button>
<Button size="sm" variant="ghost" onClick={() => setCastOpen((v) => !v)} aria-label={t('stories.cast')}>
<Users size={13} /> {t('stories.cast')}
</Button>
</div>
<span className="stories-editor__divider" aria-hidden="true" />
{/* Content */}
<div className="stories-editor__group">
<Button size="sm" variant="ghost" onClick={() => fileInputRef.current && fileInputRef.current.click()} aria-label={t('stories.import')}>
<Upload size={13} /> {t('stories.import')}
</Button>
<Button size="sm" variant="ghost" onClick={() => setSplitOpen((v) => !v)} aria-label={t('stories.pasteSplit')}>
<Scissors size={13} /> {t('stories.pasteSplit')}
</Button>
<Button size="sm" variant="ghost" onClick={addTrack} aria-label={t('stories.addLine')}>
<Plus size={13} /> {t('stories.addLine')}
</Button>
<Button size="sm" variant="ghost" onClick={addChapter} aria-label={t('stories.addChapter')}>
<Bookmark size={13} /> {t('stories.addChapter')}
</Button>
</div>
<span className="stories-editor__divider" aria-hidden="true" />
{/* Output */}
<div className="stories-editor__group">
<Button size="sm" variant="ghost" onClick={exportStemsAll} disabled={tracks.length === 0 || exporting} aria-label={t('stories.stems')}>
<Layers size={13} /> {t('stories.stems')}
</Button>
<select
className="stories-editor__format"
value={exportFormat}
onChange={(e) => setExportFormat(e.target.value)}
aria-label={t('stories.format')}
title={t('stories.format')}
>
<option value="wav">WAV</option>
<option value="mp3">MP3</option>
</select>
<Button size="sm" onClick={generateAll} disabled={tracks.length === 0 || exporting}>
<Download size={13} /> {exporting ? `${exportPct}%` : t('stories.generateAll')}
</Button>
</div>
</div>
</div>
@@ -552,6 +576,53 @@ export default function StoriesEditor({ profiles = [] }) {
) : (
<div className="stories-editor__tracks" role="list">
{tracks.map((track) => {
const dragProps = {
draggable: true,
onDragStart: (e) => { dragId.current = track.id; e.dataTransfer.effectAllowed = 'move'; },
onDragOver: (e) => { e.preventDefault(); if (dragOver !== track.id) setDragOver(track.id); },
onDragLeave: () => setDragOver((d) => (d === track.id ? null : d)),
onDrop: (e) => {
e.preventDefault();
if (dragId.current != null && dragId.current !== track.id) {
setTracks((prev) => reorder(prev, dragId.current, track.id));
}
dragId.current = null;
setDragOver(null);
},
};
// Chapters render as a section bar — no voice / tune / preview.
if (isChapterText(track.text)) {
const title = track.text.replace(/^#{1,6}\s*/, '');
return (
<div
key={track.id}
role="listitem"
className={['stories-chapter', dragOver === track.id ? 'stories-chapter--dragover' : ''].filter(Boolean).join(' ')}
{...dragProps}
>
<div className="stories-chapter__grip" aria-hidden="true"><GripVertical size={14} /></div>
<Bookmark size={15} className="stories-chapter__icon" aria-hidden="true" />
<input
className="stories-chapter__title"
value={title}
onChange={(e) => updateTrack(track.id, 'text', `# ${e.target.value}`)}
placeholder={t('stories.addChapter')}
aria-label={t('stories.addChapter')}
/>
<button
type="button"
className="stories-chapter__del"
onClick={(e) => { e.stopPropagation(); removeTrack(track.id); }}
title={t('stories.removeLine')}
aria-label={t('stories.removeLine')}
>
<Trash2 size={13} />
</button>
</div>
);
}
const member = castMember(cast, track.character);
const inheritedId = member && member.profileId;
const inheritedName = inheritedId ? profileName(inheritedId) : null;
@@ -566,18 +637,7 @@ export default function StoriesEditor({ profiles = [] }) {
dragOver === track.id ? 'stories-track--dragover' : '',
].filter(Boolean).join(' ')}
onClick={() => setActiveTrack(track.id)}
draggable
onDragStart={(e) => { dragId.current = track.id; e.dataTransfer.effectAllowed = 'move'; }}
onDragOver={(e) => { e.preventDefault(); if (dragOver !== track.id) setDragOver(track.id); }}
onDragLeave={() => setDragOver((d) => (d === track.id ? null : d))}
onDrop={(e) => {
e.preventDefault();
if (dragId.current != null && dragId.current !== track.id) {
setTracks((prev) => reorder(prev, dragId.current, track.id));
}
dragId.current = null;
setDragOver(null);
}}
{...dragProps}
>
<div className="stories-track__grip" aria-hidden="true"><GripVertical size={14} /></div>