feat(workspaces): refine voice story and audiobook flows

This commit is contained in:
debpalash
2026-08-11 19:28:43 +00:00
parent 680fa8fdef
commit f0382e0290
26 changed files with 2679 additions and 1107 deletions
+20 -18
View File
@@ -283,7 +283,7 @@ function App() {
mode === 'stories' ||
mode === 'audiobook' ||
// Voice (studio) and Dub workspaces moved their saved voices /
// projects + history into right-side panels; left sidebar dissolved.
// projects + history into workspace rails; global sidebar dissolved.
mode === 'studio' ||
mode === 'dub';
const availableSidebarTabs = [];
@@ -1134,7 +1134,7 @@ function App() {
};
const deleteHistory = async (id, type) => {
if (!(await askConfirm('Delete this history item?'))) return;
if (!(await askConfirm(i18n.t('history.delete_confirm')))) return;
try {
const endpoint = type === 'dub' ? `${API}/dub/history/${id}` : `${API}/history/${id}`;
await apiFetch(endpoint, { method: 'DELETE' });
@@ -1554,6 +1554,24 @@ function App() {
</div>
) : (
<div className="studio-with-history">
<div className="studio-voices">
<WorkspaceVoices
defineMethod={defineMethod}
profiles={profiles}
selectedProfile={selectedProfile}
setSelectedProfile={setSelectedProfile}
previewLoading={previewLoading}
handleSelectProfile={handleSelectProfile}
handleDeleteProfile={handleDeleteProfile}
handlePreviewVoice={handlePreviewVoice}
handleUnlockProfile={handleUnlockProfile}
openVoiceProfile={openVoiceProfile}
onOpenVoicePreview={(profileId) => {
setVoicePreviewProfileId(profileId || '');
setIsVoicePreviewOpen(true);
}}
/>
</div>
<div className="studio-with-history__main">
<ErrorBoundary name="clone-design">
<Suspense fallback={<LazyFallback />}>
@@ -1627,22 +1645,6 @@ function App() {
</ErrorBoundary>
</div>
<div className="studio-right">
<WorkspaceVoices
defineMethod={defineMethod}
profiles={profiles}
selectedProfile={selectedProfile}
setSelectedProfile={setSelectedProfile}
previewLoading={previewLoading}
handleSelectProfile={handleSelectProfile}
handleDeleteProfile={handleDeleteProfile}
handlePreviewVoice={handlePreviewVoice}
handleUnlockProfile={handleUnlockProfile}
openVoiceProfile={openVoiceProfile}
onOpenVoicePreview={(profileId) => {
setVoicePreviewProfileId(profileId || '');
setIsVoicePreviewOpen(true);
}}
/>
<WorkspaceHistory
history={history}
handleSaveHistoryAsProfile={handleSaveHistoryAsProfile}
File diff suppressed because it is too large Load Diff
@@ -48,6 +48,7 @@ import { useAppStore } from '../store';
* @param {()=>void} [onCreateVoice] render an inline "create voice" button
* @param {string} [recentsKey=''] persist recents under this key (real ids only)
* @param {string} [placeholder] trigger placeholder when nothing resolves
* @param {string} [ariaLabel] accessible label for the select trigger
* @param {boolean} [menuPortal=false] portal the dropdown to <body> (needed
* inside clipping ancestors: overflow:auto panels / react-window rows — #1220)
*/
@@ -84,6 +85,7 @@ export default function VoiceSelector({
onCreateVoice,
recentsKey = '',
placeholder,
ariaLabel,
disabled = false,
size = 'md',
buttonClassName,
@@ -299,6 +301,7 @@ export default function VoiceSelector({
disabled={disabled || materializing}
size={size}
buttonClassName={buttonClassName}
ariaLabel={ariaLabel}
menuPortal={menuPortal}
onOpenChange={setOpen}
onQueryChange={setRawQuery}
+42 -16
View File
@@ -179,7 +179,6 @@ export default function WorkspaceHistory({
<div
key={`dub-${item.id}`}
className="history-item history-item--dub"
onClick={() => restoreDubHistory(item)}
>
<div className="flex min-w-0 gap-[8px]">
<DubMediaPreview item={item} inputType={inputType} />
@@ -282,22 +281,30 @@ export default function WorkspaceHistory({
className="history-kind"
style={{ color: accent, background: `${accent}22` }}
>
<KindIcon size={9} /> {item.mode || 'synth'}
<KindIcon size={9} aria-hidden="true" />{' '}
{item.mode === 'clone' ? t('history.mode_clone') : t('history.mode_synth')}
</span>
<span className="history-meta">
{item.language && item.language !== 'Auto' ? `${item.language} · ` : ''}
{item.generation_time ? `${item.generation_time}s` : ''}
{item.generation_time
? t('history.generation_seconds', { duration: item.generation_time })
: ''}
</span>
</div>
<div
<button
type="button"
className={`history-title history-title--clamp ${expanded === item.id ? 'history-title--expanded' : ''}`}
title={item.text}
onClick={() => setExpanded((e) => (e === item.id ? null : item.id))}
aria-expanded={expanded === item.id}
aria-label={t(expanded === item.id ? 'history.collapse_text' : 'history.expand_text')}
>
{displayTitle(item.text)}
</div>
</button>
{item.seed != null && String(item.seed) !== '' ? (
<div className="history-subtitle history-subtitle--seed">seed {item.seed}</div>
<div className="history-subtitle history-subtitle--seed">
{t('history.seed', { seed: item.seed })}
</div>
) : null}
{item.audio_path ? (
<LazyWaveform
@@ -312,6 +319,7 @@ export default function WorkspaceHistory({
<div className="history-actions">
{toggleStarHistory ? (
<button
type="button"
className={`history-action-btn history-action-icon ${item.starred ? 'accent' : ''}`}
onClick={(e) => {
e.stopPropagation();
@@ -324,12 +332,18 @@ export default function WorkspaceHistory({
? t('history.unstar_take', { defaultValue: 'Unstar — allow cleanup' })
: t('history.star_take', { defaultValue: 'Star — keep this take' })
}
aria-label={
item.starred
? t('history.unstar_take', { defaultValue: 'Unstar — allow cleanup' })
: t('history.star_take', { defaultValue: 'Star — keep this take' })
}
>
<Star size={10} fill={item.starred ? 'currentColor' : 'none'} />
<Star size={10} fill={item.starred ? 'currentColor' : 'none'} aria-hidden="true" />
</button>
) : null}
{playTakeAsOutput ? (
<button
type="button"
className="history-action-btn accent history-action-icon"
onClick={(e) => {
e.stopPropagation();
@@ -339,59 +353,71 @@ export default function WorkspaceHistory({
title={t('history.play_take', {
defaultValue: 'Load as active output',
})}
aria-label={t('history.play_take', {
defaultValue: 'Load as active output',
})}
>
<Play size={10} />
<Play size={10} aria-hidden="true" />
</button>
) : null}
<button
type="button"
className="history-action-btn accent"
onClick={(e) => {
e.stopPropagation();
handleSaveHistoryAsProfile(item);
}}
>
<Save size={10} /> {t('sidebar.save_label')}
<Save size={10} aria-hidden="true" /> {t('sidebar.save_label')}
</button>
{item.profile_id ? (
<button
type="button"
className="history-action-btn accent history-action-icon"
onClick={(e) => {
e.stopPropagation();
handleLockProfile(item.profile_id, item.id, item.seed);
}}
title={t('sidebar.lock_identity')}
aria-label={t('sidebar.lock_identity')}
>
<Lock size={10} />
<Lock size={10} aria-hidden="true" />
</button>
) : null}
<button
type="button"
className="history-action-btn history-action-icon"
onClick={(e) =>
handleNativeExport(e, item.audio_path, item.audio_path, item.mode)
}
title="Export"
title={t('dub.export')}
aria-label={t('dub.export')}
>
<DownloadIcon size={10} />
<DownloadIcon size={10} aria-hidden="true" />
</button>
<button
type="button"
className="history-action-btn history-action-icon"
onClick={(e) => {
e.stopPropagation();
restoreHistory(item);
}}
title="Load config"
title={t('history.load_config')}
aria-label={t('history.load_config')}
>
<FolderOpen size={10} />
<FolderOpen size={10} aria-hidden="true" />
</button>
<button
type="button"
className="history-action-btn danger history-action-icon"
onClick={(e) => {
e.stopPropagation();
deleteHistory(item.id, 'synth');
}}
title="Delete"
title={t('common.delete')}
aria-label={t('common.delete')}
>
<Trash2 size={10} />
<Trash2 size={10} aria-hidden="true" />
</button>
</div>
) : null}
@@ -79,6 +79,18 @@ describe('WorkspaceHistory takes actions', () => {
expect(playTakeAsOutput.mock.calls[0][0].id).toBe('bb2');
});
it('exposes text expansion and icon actions to keyboard and assistive technology', () => {
renderRail();
const expand = screen.getAllByRole('button', { name: 'Expand text' })[0];
expect(expand).toHaveAttribute('aria-expanded', 'false');
fireEvent.click(expand);
expect(expand).toHaveAttribute('aria-expanded', 'true');
expect(screen.getAllByRole('button', { name: 'Export' })).toHaveLength(2);
expect(screen.getAllByRole('button', { name: 'Load settings' })).toHaveLength(2);
expect(screen.getAllByRole('button', { name: 'Delete' })).toHaveLength(2);
});
it('starred filter narrows the rail to starred takes only', () => {
renderRail();
@@ -1,9 +1,9 @@
/**
* WorkspaceProjects — the right-side "Dub projects" panel.
* WorkspaceProjects — the compact left-side "Dub projects" panel.
*
* Relocates the saved-dub-project list (and the Save-project button) out of the
* left Sidebar so the Dub workspace can dissolve its sidebar, mirroring the
* Voice workspace's WorkspaceVoices. Card markup + actions mirror the former
* left Sidebar so the Dub workspace can dissolve its global sidebar, mirroring
* the Voice workspace's WorkspaceVoices rail. Card markup + actions mirror the former
* Sidebar section 1:1 (open/load, delete).
*/
import React, { useMemo, useState } from 'react';
+2 -2
View File
@@ -1,8 +1,8 @@
/**
* WorkspaceVoices — the right-side "Saved voices" panel.
* WorkspaceVoices — the left-side "Saved voices" panel.
*
* Relocates the saved-profile list that used to live in the left Sidebar
* (the "Designed voices" / "Voice clones" section) to the right column, so
* (the "Designed voices" / "Voice clones" section) to the workspace rail, so
* the Voice workspace can dissolve the left sidebar entirely. Profiles are
* scoped by define-method: 'audio' shows reference-audio profiles
* (no instruct), 'design' shows designed profiles (have instruct).
@@ -0,0 +1,99 @@
import { BookMarked, BookOpen, FileUp, ListTree, Loader, Sparkles, Square } from 'lucide-react';
import { buttonVariants } from '@/components/ui/button.tsx';
import { Button } from '../../ui';
/** The inviting front door for the long-form workflow: context, path, and actions. */
export default function AudiobookHero({
t,
busy,
importing,
planLoading,
generating,
canRun,
onImport,
onLoadSample,
onPreview,
onCreate,
onStop,
}) {
return (
<section className="rounded-[12px] border border-transparent bg-[var(--color-bg-elev-2)] px-[12px] py-[9px]">
<div className="flex flex-wrap items-center justify-between gap-[10px]">
<div className="flex min-w-0 items-center gap-[9px]">
<div
className="relative flex h-[34px] w-[28px] shrink-0 items-center justify-center rounded-[5px_8px_8px_5px] bg-primary/[0.13] text-primary shadow-[inset_3px_0_0_color-mix(in_srgb,var(--color-brand)_25%,transparent)]"
aria-hidden="true"
>
<BookMarked size={15} strokeWidth={1.8} />
</div>
<h2
className="m-0 [font-family:var(--font-serif)] text-[var(--text-lg)] font-semibold text-fg"
>
{t('audiobook.title')}
</h2>
</div>
<div className="flex flex-wrap items-center justify-end gap-[4px]">
<label
title={t('audiobook.import')}
className={buttonVariants({
variant: 'subtle',
size: 'omniSm',
className: busy ? 'cursor-default opacity-50' : 'cursor-pointer',
})}
>
{importing ? <Loader className="animate-spin" /> : <FileUp />}
<span className="leading-none">{t('audiobook.import')}</span>
<input
type="file"
accept=".txt,.md,.epub,.pdf"
onChange={onImport}
disabled={busy}
aria-label={t('audiobook.import')}
className="hidden"
/>
</label>
<Button
variant="subtle"
size="sm"
onClick={onLoadSample}
disabled={busy}
title={t('audiobook.load_sample_hint')}
aria-label={t('audiobook.load_sample')}
leading={<BookOpen />}
>
{t('audiobook.load_sample')}
</Button>
<Button
variant="subtle"
size="sm"
onClick={onPreview}
disabled={!canRun}
loading={planLoading}
title={t('audiobook.preview_plan')}
aria-label={t('audiobook.preview_plan')}
leading={<ListTree />}
>
{t('audiobook.preview_plan')}
</Button>
{generating ? (
<Button variant="danger" size="sm" onClick={onStop} leading={<Square />}>
{t('audiobook.stop')}
</Button>
) : (
<Button
variant="primary"
size="sm"
onClick={onCreate}
disabled={!canRun}
leading={<Sparkles />}
>
{t('audiobook.create')}
</Button>
)}
</div>
</div>
</section>
);
}
@@ -0,0 +1,254 @@
import { useState } from 'react';
import {
BookText,
Code,
Languages,
Mic2,
SlidersHorizontal,
SpellCheck,
Users,
} from 'lucide-react';
import VoiceSelector from '../VoiceSelector';
import SearchableSelect from '../SearchableSelect';
import AudiobookOverrides from './AudiobookOverrides';
import BookDetails from './BookDetails';
import CastPanel from './CastPanel';
import LexiconEditor from './LexiconEditor';
import ALL_LANGUAGES from '../../languages.json';
import { POPULAR_LANGS } from '../../utils/constants';
const FIELD_LABEL =
'flex items-center gap-[5px] [font-family:var(--chrome-font-mono)] [font-size:var(--chrome-label-size)] font-semibold [letter-spacing:var(--chrome-label-track)] uppercase text-fg-muted';
const TOOL_BUTTON =
'relative flex h-[46px] min-w-0 flex-col items-center justify-center gap-[3px] rounded-[8px] border border-transparent bg-transparent px-[6px] text-[0.62rem] text-fg-muted cursor-pointer transition-[background,color,box-shadow] duration-[120ms] hover:bg-[var(--chrome-hover-bg)] hover:text-fg focus-visible:[outline:2px_solid_var(--chrome-accent)] focus-visible:[outline-offset:-2px] aria-pressed:bg-primary/[0.12] aria-pressed:text-primary aria-pressed:shadow-[inset_0_-2px_0_var(--color-brand)]';
function ToolButton({ active, badge = 0, icon, label, onClick }) {
return (
<button
type="button"
className={TOOL_BUTTON}
aria-pressed={active}
title={label}
onClick={onClick}
>
{icon}
<span className="w-full truncate text-center">{label}</span>
{badge > 0 && (
<span
className="absolute right-[5px] top-[4px] min-w-[15px] rounded-full bg-[var(--chrome-hover-bg)] px-[4px] text-center text-[0.55rem] leading-[15px] text-fg [font-variant-numeric:tabular-nums]"
aria-hidden="true"
>
{badge}
</span>
)}
</button>
);
}
/** Compact right-rail property inspector with one optional tool panel at a time. */
export default function AudiobookInspector({
t,
profiles,
defaultVoice,
setDefaultVoice,
language,
setLanguage,
format,
setFormat,
loudness,
setLoudness,
castNames,
voiceCast,
setVoiceCast,
overrides,
setOverrides,
emotionSupported,
coverPreview,
onCoverPick,
clearCover,
meta,
setMetaField,
lex,
setLexRow,
addLexRow,
removeLexRow,
}) {
// `undefined` means the user has not chosen a panel yet: Cast becomes the
// useful default as soon as the script contains [voice:…] tags. Once the user
// switches or closes a panel, their explicit choice wins.
const [activePanel, setActivePanel] = useState(undefined);
const panel =
activePanel === undefined
? castNames.length > 0
? 'cast'
: null
: activePanel === 'cast' && castNames.length === 0
? null
: activePanel;
const togglePanel = (next) => setActivePanel(panel === next ? null : next);
const detailCount =
Object.values(meta).filter((value) => value?.trim()).length + (coverPreview ? 1 : 0);
const lexiconCount = lex.filter((row) => row.word.trim() || row.say.trim()).length;
const outputCount =
(loudness !== 'off' ? 1 : 0) +
(Object.values(overrides).some((value) => value !== null && value !== false && value !== '')
? 1
: 0);
return (
<div className="flex flex-col gap-[9px] [container-type:inline-size] [container-name:audiobook-inspector]">
<div className="grid grid-cols-1 gap-[8px] rounded-[11px] bg-[var(--chrome-bg)] p-[10px] @min-[360px]/audiobook-inspector:grid-cols-2 @min-[560px]/audiobook-inspector:[grid-template-columns:minmax(170px,1.35fr)_minmax(120px,0.9fr)_minmax(100px,0.7fr)]">
<div className="flex min-w-0 flex-col gap-[4px] @min-[360px]/audiobook-inspector:col-span-2 @min-[560px]/audiobook-inspector:col-span-1">
<label className={FIELD_LABEL}>
<Mic2 size={11} aria-hidden="true" /> {t('audiobook.default_voice')}
</label>
<VoiceSelector
value={defaultVoice}
onChange={setDefaultVoice}
profiles={profiles}
defaultLabel={t('audiobook.engine_default')}
ariaLabel={t('audiobook.default_voice')}
/>
</div>
<div className="flex min-w-0 flex-col gap-[4px]">
<label className={FIELD_LABEL}>
<Languages size={11} aria-hidden="true" /> {t('audiobook.language')}
</label>
<SearchableSelect
value={language}
options={ALL_LANGUAGES}
popular={POPULAR_LANGS}
recentsKey="omnivoice.recents.audiobookLang"
onChange={setLanguage}
ariaLabel={t('audiobook.language')}
/>
</div>
<div className="flex min-w-0 flex-col gap-[4px]">
<label className={FIELD_LABEL}>{t('audiobook.format')}</label>
<select
className="input-base"
name="audiobook-format"
value={format}
onChange={(event) => setFormat(event.target.value)}
aria-label={t('audiobook.format')}
>
<option value="m4b">{t('audiobook.format_m4b')}</option>
<option value="mp3">{t('audiobook.format_mp3')}</option>
</select>
</div>
</div>
<div className="overflow-hidden rounded-[11px] bg-[var(--chrome-bg)] shadow-[inset_0_0_0_1px_var(--chrome-border)]">
<div
className="grid grid-cols-[repeat(auto-fit,minmax(68px,1fr))] gap-[2px] p-[4px]"
role="toolbar"
aria-label={t('audiobook.title')}
>
{castNames.length > 0 && (
<ToolButton
active={panel === 'cast'}
badge={castNames.length}
icon={<Users size={13} aria-hidden="true" />}
label={t('audiobook.cast')}
onClick={() => togglePanel('cast')}
/>
)}
<ToolButton
active={panel === 'output'}
badge={outputCount}
icon={<SlidersHorizontal size={13} aria-hidden="true" />}
label={t('audiobook.output')}
onClick={() => togglePanel('output')}
/>
<ToolButton
active={panel === 'details'}
badge={detailCount}
icon={<BookText size={13} aria-hidden="true" />}
label={t('audiobook.details')}
onClick={() => togglePanel('details')}
/>
<ToolButton
active={panel === 'lexicon'}
badge={lexiconCount}
icon={<SpellCheck size={13} aria-hidden="true" />}
label={t('audiobook.lexicon')}
onClick={() => togglePanel('lexicon')}
/>
<ToolButton
active={panel === 'markup'}
icon={<Code size={13} aria-hidden="true" />}
label={t('audiobook.markup_help')}
onClick={() => togglePanel('markup')}
/>
</div>
{panel && (
<div className="border-t border-transparent p-[11px]">
{panel === 'cast' && (
<CastPanel
t={t}
castNames={castNames}
voiceCast={voiceCast}
setVoiceCast={setVoiceCast}
profiles={profiles}
/>
)}
{panel === 'output' && (
<div className="flex flex-col gap-[8px]">
<div className="flex flex-col gap-[4px]">
<label className={FIELD_LABEL}>{t('audiobook.loudness')}</label>
<select
className="input-base"
name="audiobook-loudness"
value={loudness}
onChange={(event) => setLoudness(event.target.value)}
aria-label={t('audiobook.loudness')}
>
<option value="off">{t('audiobook.loudness_off')}</option>
<option value="acx">{t('audiobook.loudness_acx')}</option>
<option value="podcast">{t('audiobook.loudness_podcast')}</option>
</select>
</div>
<AudiobookOverrides
t={t}
overrides={overrides}
onChange={setOverrides}
emotionSupported={emotionSupported}
/>
</div>
)}
{panel === 'details' && (
<BookDetails
t={t}
coverPreview={coverPreview}
onCoverPick={onCoverPick}
clearCover={clearCover}
meta={meta}
setMetaField={setMetaField}
/>
)}
{panel === 'lexicon' && (
<div className="flex flex-col gap-[6px]">
<LexiconEditor
t={t}
lex={lex}
setLexRow={setLexRow}
addLexRow={addLexRow}
removeLexRow={removeLexRow}
/>
</div>
)}
{panel === 'markup' && (
<p className="m-0 text-[0.68rem] leading-[1.55] text-fg-muted">
{t('audiobook.markup_hint')}
</p>
)}
</div>
)}
</div>
</div>
);
}
@@ -20,14 +20,16 @@ export default function BookDetails({
setMetaField,
}) {
return (
<div className="flex gap-[12px] items-start">
<div style={{ position: 'relative', width: 96, height: 96, flexShrink: 0 }}>
<div className="flex items-start gap-[9px]">
<div className="relative size-[64px] shrink-0">
{coverPreview ? (
<>
<img
src={coverPreview}
alt={t('audiobook.cover')}
style={{ width: 96, height: 96, objectFit: 'cover', borderRadius: 6 }}
width="64"
height="64"
className="size-[64px] rounded-[7px] object-cover"
/>
<Button
variant="icon"
@@ -43,8 +45,8 @@ export default function BookDetails({
<label
className={buttonVariants({ variant: 'subtle', size: 'omniMd' })}
style={{
width: 96,
height: 96,
width: 64,
height: 64,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
@@ -53,8 +55,8 @@ export default function BookDetails({
cursor: 'pointer',
}}
>
<ImageIcon size={20} />
<span style={{ fontSize: '0.65rem' }}>{t('audiobook.cover_add')}</span>
<ImageIcon size={17} />
<span className="text-[0.58rem]">{t('audiobook.cover_add')}</span>
<input
type="file"
accept="image/png,image/jpeg"
@@ -64,11 +66,13 @@ export default function BookDetails({
</label>
)}
</div>
<div className="grid grid-cols-[1fr_1fr] gap-[8px] flex-1 min-w-0">
<div className="grid min-w-0 flex-1 grid-cols-2 gap-[6px]">
{META_FIELDS.map((k) => (
<input
key={k}
className="input-base"
name={`audiobook-${k}`}
autoComplete="off"
placeholder={t(`audiobook.meta_${k}`)}
value={meta[k]}
onChange={setMetaField(k)}
@@ -77,6 +81,8 @@ export default function BookDetails({
))}
<input
className="input-base"
name="audiobook-description"
autoComplete="off"
placeholder={t('audiobook.meta_description')}
value={meta.description}
onChange={setMetaField('description')}
@@ -1,4 +1,3 @@
import React from 'react';
import VoiceSelector from '../VoiceSelector';
/**
@@ -30,27 +29,23 @@ export default function CastPanel({
);
}
return (
<div className="flex flex-col gap-[10px]">
<p className="muted text-[0.72rem] leading-[1.5] m-0 text-fg-muted">
{t('audiobook.cast_hint')}
</p>
<div className="flex flex-col gap-[7px]">
{castNames.map((name) => {
const mapped = voiceCast[name] || '';
return (
<div key={name} className="flex flex-col gap-[4px]">
<div className="flex items-center justify-between gap-[8px]">
<code className="text-[0.72rem] text-fg break-all">[voice:{name}]</code>
{!mapped && (
<span className="muted text-[0.68rem] text-fg-muted whitespace-nowrap">
{t('audiobook.cast_uses_default')}
</span>
)}
</div>
<div
key={name}
className="grid grid-cols-[minmax(72px,0.7fr)_minmax(0,1.3fr)] items-center gap-[7px]"
>
<code className="truncate text-[0.68rem] text-fg" title={`[voice:${name}]`}>
{name}
</code>
<VoiceSelector
value={mapped}
onChange={(v) => setVoiceCast(name, v || null)}
profiles={profiles}
defaultLabel={t('audiobook.engine_default')}
ariaLabel={`${t('audiobook.cast')}: ${name}`}
/>
</div>
);
@@ -15,6 +15,8 @@ export default function LexiconEditor({ t, lex, setLexRow, addLexRow, removeLexR
<div key={i} className="flex gap-[6px]">
<input
className="input-base"
name={`lexicon-word-${i}`}
autoComplete="off"
placeholder={t('audiobook.lex_word')}
value={row.word}
onChange={setLexRow(i, 'word')}
@@ -23,6 +25,8 @@ export default function LexiconEditor({ t, lex, setLexRow, addLexRow, removeLexR
/>
<input
className="input-base"
name={`lexicon-pronunciation-${i}`}
autoComplete="off"
placeholder={t('audiobook.lex_say')}
value={row.say}
onChange={setLexRow(i, 'say')}
@@ -1,10 +1,19 @@
import React, { useState } from 'react';
import { ChevronDown, Smile } from 'lucide-react';
import {
AudioLines,
Bold,
ChevronDown,
ChevronsLeft,
ChevronsRight,
Pause,
Smile,
SpellCheck,
} from 'lucide-react';
import { TAGS } from '../../utils/constants';
// Compact chrome pill button — same visual language as the clone Insert menu.
const PILL =
'inline-flex items-center gap-[4px] border border-transparent bg-[var(--chrome-bg)] text-[var(--chrome-fg-muted)] px-[8px] py-[3px] rounded-[var(--chrome-radius-pill)] [font-family:var(--chrome-font-mono)] text-[0.66rem] whitespace-nowrap cursor-pointer transition-colors duration-[120ms] hover:bg-[var(--chrome-hover-bg)] hover:text-[var(--chrome-fg)] focus-visible:[outline:2px_solid_var(--chrome-accent)] focus-visible:[outline-offset:1px]';
'inline-flex h-[26px] items-center justify-center gap-[4px] border border-transparent bg-[var(--chrome-bg)] text-[var(--chrome-fg-muted)] px-[7px] py-0 rounded-[var(--chrome-radius-pill)] [font-family:var(--chrome-font-mono)] text-[0.62rem] whitespace-nowrap cursor-pointer transition-colors duration-[120ms] hover:bg-[var(--chrome-hover-bg)] hover:text-[var(--chrome-fg)] focus-visible:[outline:2px_solid_var(--chrome-accent)] focus-visible:[outline-offset:1px]';
const TAG_BTN =
'border border-transparent bg-transparent text-[var(--chrome-fg-muted)] px-[9px] py-[3px] rounded-[var(--chrome-radius-pill)] [font-family:var(--chrome-font-mono)] font-medium text-[0.66rem] whitespace-nowrap cursor-pointer transition-colors duration-[120ms] hover:bg-[var(--chrome-hover-bg)] hover:text-[var(--chrome-fg)]';
@@ -78,39 +87,82 @@ export default function MarkupToolbar({ t, textareaRef, text, setText }) {
return (
<div
className="flex flex-wrap items-center gap-[6px] relative"
className="relative flex flex-wrap items-center gap-[4px]"
role="toolbar"
aria-label={t('audiobook.markup_toolbar')}
>
<button type="button" className={PILL} onClick={() => insert('[pause 500ms]')}>
{t('audiobook.insert_pause')}
<button
type="button"
className={PILL}
onClick={() => insert('[pause 500ms]')}
aria-label={t('audiobook.insert_pause')}
title={t('audiobook.insert_pause')}
>
<Pause size={11} /> {t('audiobook.insert_pause')}
</button>
<button type="button" className={PILL} onClick={insertVoice}>
{t('audiobook.insert_voice')}
<button
type="button"
className={PILL}
onClick={insertVoice}
aria-label={t('audiobook.insert_voice')}
title={t('audiobook.insert_voice')}
>
<AudioLines size={11} /> {t('audiobook.insert_voice')}
</button>
<button type="button" className={PILL} onClick={() => wrap('[slow]', '[/slow]')}>
{t('audiobook.insert_slow')}
<button
type="button"
className={PILL}
onClick={() => wrap('[slow]', '[/slow]')}
aria-label={t('audiobook.insert_slow')}
title={t('audiobook.insert_slow')}
>
<ChevronsLeft size={11} /> {t('audiobook.insert_slow')}
</button>
<button type="button" className={PILL} onClick={() => wrap('[fast]', '[/fast]')}>
{t('audiobook.insert_fast')}
<button
type="button"
className={PILL}
onClick={() => wrap('[fast]', '[/fast]')}
aria-label={t('audiobook.insert_fast')}
title={t('audiobook.insert_fast')}
>
<ChevronsRight size={11} /> {t('audiobook.insert_fast')}
</button>
<button type="button" className={PILL} onClick={() => wrap('[emphasis]', '[/emphasis]')}>
{t('audiobook.insert_emphasis')}
<button
type="button"
className={PILL}
onClick={() => wrap('[emphasis]', '[/emphasis]')}
aria-label={t('audiobook.insert_emphasis')}
title={t('audiobook.insert_emphasis')}
>
<Bold size={11} /> {t('audiobook.insert_emphasis')}
</button>
<button type="button" className={PILL} onClick={() => wrap('[spell]', '[/spell]')}>
{t('audiobook.insert_spell')}
<button
type="button"
className={PILL}
onClick={() => wrap('[spell]', '[/spell]')}
aria-label={t('audiobook.insert_spell')}
title={t('audiobook.insert_spell')}
>
<SpellCheck size={11} /> {t('audiobook.insert_spell')}
</button>
<button
type="button"
className={PILL}
onClick={() => setReactionsOpen((o) => !o)}
aria-expanded={reactionsOpen}
aria-label={t('audiobook.insert_reactions')}
title={t('audiobook.insert_reactions')}
>
<Smile size={11} /> {t('audiobook.insert_reactions')} <ChevronDown size={10} />
<Smile size={11} /> {t('audiobook.insert_reactions')} <ChevronDown size={8} />
</button>
{reactionsOpen && (
<>
<div className="fixed inset-0 z-[19]" onClick={() => setReactionsOpen(false)} />
<button
type="button"
className="fixed inset-0 z-[19] cursor-default border-0 bg-transparent p-0"
onClick={() => setReactionsOpen(false)}
aria-label={t('common.close')}
/>
<div
className="absolute left-0 top-[calc(100%+6px)] z-20 flex flex-wrap gap-1 max-w-[min(360px,calc(100vw-16px))] max-h-[min(280px,calc(100vh-120px))] overflow-y-auto overscroll-contain p-2 bg-[var(--chrome-bg)] border border-transparent rounded-[10px] shadow-[0_8px_24px_rgba(0,0,0,0.45)]"
role="menu"
@@ -71,7 +71,7 @@ export default function ScriptPanel({
<div className="relative flex-1 flex flex-col min-h-0">
<textarea
ref={textAreaRef}
className="input-base flex-[0_1_auto] resize-y min-h-[160px] mb-[6px]"
className="input-base studio-script-input flex-[0_1_auto] resize-y mb-[6px]"
placeholder={
defineMethod === 'audio'
? t('clone.prompt_placeholder')
@@ -32,14 +32,12 @@ export default function ArchetypeCard({
: dialect || (a.language === 'Chinese' ? 'Chinese' : null);
const hasChips = Boolean(accentLabel || a.facets.whisper);
// Borderless by direction: the card keeps a transparent border only to reserve
// the box width; the playing state is conveyed by an accent ring (box-shadow)
// + lift, never a literal border.
const cardBase =
'group relative flex flex-col gap-[11px] p-[14px] rounded-[13px] border border-transparent ' +
'bg-[linear-gradient(180deg,rgba(255,255,255,0.038),rgba(255,255,255,0.012))] ' +
'transition-[transform,box-shadow] duration-150 ' +
'hover:-translate-y-[2px] hover:shadow-[0_6px_22px_rgba(0,0,0,0.4)] ' +
'group relative flex min-h-[168px] flex-col gap-[9px] p-[13px] rounded-[10px] ' +
'border border-[rgba(255,255,255,0.075)] bg-[rgba(255,255,255,0.026)] ' +
'transition-[transform,box-shadow,border-color,background-color] duration-150 ' +
'hover:-translate-y-px hover:border-[rgba(255,255,255,0.15)] ' +
'hover:bg-[rgba(255,255,255,0.042)] hover:shadow-[0_8px_24px_rgba(0,0,0,0.32)] ' +
'motion-reduce:transition-none motion-reduce:hover:translate-y-0';
const cardState = isPlaying
? 'shadow-[0_0_0_1px_var(--card-accent),0_6px_22px_rgba(0,0,0,0.4)]'
@@ -48,10 +46,10 @@ export default function ArchetypeCard({
return (
<div className={`${cardBase} ${cardState}`} style={{ '--card-accent': color }}>
{/* Header — the name is the focal point; metadata recedes (smaller, muted). */}
<div className="flex items-center gap-[11px]">
<ArchetypeAvatar item={a} />
<div className="flex items-start gap-[10px]">
<ArchetypeAvatar item={a} size={40} />
<div className="flex-1 min-w-0">
<div className="text-[0.86rem] font-semibold leading-tight text-[var(--color-fg)] truncate">
<div className="text-[0.82rem] font-semibold leading-tight text-[var(--color-fg)] truncate">
{a.name}
</div>
{sub && (
@@ -61,6 +59,7 @@ export default function ArchetypeCard({
)}
</div>
<button
type="button"
className={`flex-shrink-0 flex items-center justify-center w-[26px] h-[26px] rounded-[7px] cursor-pointer transition-[color,background-color,opacity] hover:bg-[var(--chrome-hover-bg)] ${
isFavorite
? 'text-[#fabd2f]'
@@ -68,8 +67,10 @@ export default function ArchetypeCard({
}`}
onClick={() => onToggleFavorite(a.id)}
title={t('gallery.favorite', { defaultValue: 'Favorite' })}
aria-label={t('gallery.favorite', { defaultValue: 'Favorite' })}
aria-pressed={isFavorite}
>
<Star size={15} fill={isFavorite ? 'currentColor' : 'none'} />
<Star size={15} fill={isFavorite ? 'currentColor' : 'none'} aria-hidden="true" />
</button>
</div>
@@ -94,33 +95,38 @@ export default function ArchetypeCard({
{/* Actions — quiet Preview (ghost, token hover), confident accent Use voice
(tinted → solid accent with inverse text), subtle magic-wand icon. */}
<div className="flex items-center gap-[6px] mt-auto">
<div className="flex items-center gap-[6px] mt-auto pt-[9px] border-t border-[rgba(255,255,255,0.055)]">
<button
className="inline-flex items-center gap-[6px] px-[11px] py-[6px] rounded-[8px] bg-transparent text-[var(--color-fg-muted)] text-[0.7rem] cursor-pointer transition-colors hover:bg-[var(--chrome-hover-bg)] hover:text-[var(--color-fg)]"
type="button"
className="inline-flex items-center gap-[6px] px-[9px] py-[6px] rounded-[6px] bg-transparent text-[var(--color-fg-muted)] text-[0.68rem] cursor-pointer transition-colors hover:bg-[var(--chrome-hover-bg)] hover:text-[var(--color-fg)]"
onClick={() => onPreview(a)}
title={t('gallery.preview', { defaultValue: 'Preview' })}
>
{isLoadingPreview ? (
<Loader className="spin" size={15} />
<Loader className="spin" size={15} aria-hidden="true" />
) : isPlaying ? (
<NowPlaying color={color} />
) : (
<Play size={15} />
<Play size={15} aria-hidden="true" />
)}
<span>{t('gallery.preview', { defaultValue: 'Preview' })}</span>
</button>
<button
className="flex-1 inline-flex items-center justify-center gap-[6px] px-[10px] py-[6px] rounded-[8px] bg-[color-mix(in_srgb,var(--card-accent)_15%,transparent)] text-[var(--card-accent)] text-[0.72rem] font-semibold cursor-pointer transition-colors hover:bg-[var(--card-accent)] hover:text-[var(--color-fg-inverse)] focus-visible:bg-[var(--card-accent)] focus-visible:text-[var(--color-fg-inverse)]"
type="button"
className="flex-1 inline-flex items-center justify-center gap-[6px] px-[10px] py-[6px] rounded-[6px] bg-[color-mix(in_srgb,var(--card-accent)_13%,transparent)] text-[var(--card-accent)] text-[0.7rem] font-semibold cursor-pointer transition-colors hover:bg-[var(--card-accent)] hover:text-[var(--color-fg-inverse)] focus-visible:bg-[var(--card-accent)] focus-visible:text-[var(--color-fg-inverse)]"
onClick={() => onUse(a)}
>
<UserPlus size={14} /> {t('gallery.use_voice', { defaultValue: 'Use voice' })}
<UserPlus size={14} aria-hidden="true" />{' '}
{t('gallery.use_voice', { defaultValue: 'Use voice' })}
</button>
<button
type="button"
className="inline-flex items-center justify-center w-[30px] h-[30px] flex-shrink-0 rounded-[8px] bg-transparent text-[var(--color-fg-muted)] cursor-pointer opacity-50 transition-[opacity,color,background-color] duration-150 group-hover:opacity-100 focus-visible:opacity-100 hover:bg-[var(--chrome-hover-bg)] hover:text-[var(--card-accent)]"
onClick={() => onDesign(a)}
title={t('gallery.open_designer', { defaultValue: 'Open in Designer' })}
aria-label={t('gallery.open_designer', { defaultValue: 'Open in Designer' })}
>
<Wand2 size={14} />
<Wand2 size={14} aria-hidden="true" />
</button>
</div>
</div>
@@ -1,8 +1,7 @@
import React, { useState, useMemo, useEffect } from 'react';
import { Loader, Star, RotateCcw, Grid, List } from 'lucide-react';
import { Loader, Star, RotateCcw, Grid, List, SlidersHorizontal } from 'lucide-react';
import { Button, Select, Segmented } from '../../ui';
import { useArchetypeCategories, useArchetypes } from '../../api/hooks';
import { ArchetypeIcon } from '../../utils/archetypeIcons';
import { titleCase, facetLabel } from './constants';
import ArchetypeCard from './ArchetypeCard';
@@ -62,6 +61,7 @@ export default function ArchetypesZone({
onDesign,
}) {
const [favOnly, setFavOnly] = useState(false);
const [filtersOpen, setFiltersOpen] = useState(false);
const [offset, setOffset] = useState(0);
useEffect(() => {
setOffset(0);
@@ -98,6 +98,9 @@ export default function ArchetypesZone({
const favSet = useMemo(() => new Set(favorites), [favorites]);
const applyFav = (list) => (favOnly ? list.filter((a) => favSet.has(a.id)) : list);
const advancedFilterCount = ['gender', 'age', 'pitch', 'accent', 'lang', 'whisper'].filter(
(key) => filters[key] !== null && filters[key] !== '',
).length;
// NOTE: no `key` here — React keys must be passed directly on the element,
// not spread in (spreading a `key` prop triggers a dev warning + is ignored).
@@ -114,8 +117,6 @@ export default function ArchetypesZone({
onToggleFavorite: toggleFavorite,
});
const facetGroup =
'flex items-center gap-[5px] flex-nowrap min-w-0 overflow-x-auto overflow-y-hidden [scrollbar-width:thin]';
const facetToggle =
'inline-flex items-center gap-[5px] h-[26px] box-border px-[9px] rounded-[7px] border border-transparent bg-[var(--chrome-hover-bg)] text-[var(--chrome-fg-muted)] text-[0.68rem] whitespace-nowrap cursor-pointer hover:text-[var(--chrome-fg)] hover:border-[color:var(--chrome-border-strong)]';
const gridClass =
@@ -125,60 +126,39 @@ export default function ArchetypesZone({
return (
<div className="flex-1 min-h-0 flex flex-col overflow-y-auto">
<div className="flex flex-row items-center gap-[10px] flex-nowrap shrink-0 pt-[2px] pb-[10px] mb-[8px] border-b border-transparent">
{/* Three filter lanes (categories · facets · toggles), each its own
horizontally-scrollable portion; the view toggle is pinned right. */}
<div className={`${facetGroup} flex-[2.4_1_0]`}>
<Button
variant="chip"
active={!filters.use_case}
onClick={() => setFilter('use_case', null)}
<div className="shrink-0 mb-[8px] pb-[8px] border-b border-transparent">
<div className="flex items-center gap-[6px] min-w-0">
<Select
size="sm"
className="w-auto min-w-[132px] max-w-[190px] shrink-0"
aria-label={t('gallery.zone_archetypes', { defaultValue: 'Archetypes' })}
value={filters.use_case ?? ''}
onChange={(e) => setFilter('use_case', e.target.value || null)}
>
{t('gallery.all', { defaultValue: 'All' })}
</Button>
{categories.map((c) => (
<Button
key={c.id}
variant="chip"
active={filters.use_case === c.id}
leading={<ArchetypeIcon name={c.icon} size={13} />}
onClick={() => setFilter('use_case', filters.use_case === c.id ? null : c.id)}
title={c.name}
>
{t(`archetypes.use_${c.id}`, { defaultValue: c.name })}
</Button>
))}
</div>
<div className={`${facetGroup} flex-[1.6_1_0] pl-[10px] border-l border-transparent`}>
{['gender', 'age', 'pitch', 'accent', 'lang'].map((dim) => (
<Select
key={dim}
size="sm"
value={filters[dim] ?? ''}
onChange={(e) => setFilter(dim, e.target.value || null)}
>
<option value="">
{t(`archetypes.facet_${dim}`, { defaultValue: titleCase(dim) })}
<option value="">{t('gallery.all', { defaultValue: 'All' })}</option>
{categories.map((c) => (
<option key={c.id} value={c.id}>
{t(`archetypes.use_${c.id}`, { defaultValue: c.name })}
</option>
{FACETS[dim].map((opt) => (
<option key={opt} value={opt}>
{facetLabel(opt)}
</option>
))}
</Select>
))}
</div>
<div className={`${facetGroup} flex-[1_1_0] pl-[10px] border-l border-transparent`}>
<label className={facetToggle}>
<input
type="checkbox"
checked={filters.whisper === true}
onChange={(e) => setFilter('whisper', e.target.checked ? true : null)}
/>
{t('archetypes.facet_whisper', { defaultValue: 'Whisper' })}
</label>
))}
</Select>
<Button
variant="ghost"
size="sm"
active={filtersOpen}
leading={<SlidersHorizontal size={13} />}
trailing={
advancedFilterCount > 0 ? (
<span className="min-w-[16px] rounded-full bg-[var(--accent)] px-[4px] py-px text-center text-[0.58rem] leading-[14px] text-white">
{advancedFilterCount}
</span>
) : null
}
aria-expanded={filtersOpen}
onClick={() => setFiltersOpen((open) => !open)}
>
{t('gallery.filters', { defaultValue: 'Filters' })}
</Button>
<label className={facetToggle}>
<input
type="checkbox"
@@ -187,28 +167,64 @@ export default function ArchetypesZone({
/>
<Star size={12} /> {t('gallery.favorites', { defaultValue: 'Favorites' })}
</label>
<Button
variant="ghost"
size="sm"
leading={<RotateCcw size={12} />}
onClick={() => {
resetFilters();
setFavOnly(false);
}}
>
{t('gallery.reset', { defaultValue: 'Reset' })}
</Button>
{hasActiveFilters(filters) || favOnly ? (
<Button
variant="icon"
iconSize="md"
onClick={() => {
resetFilters();
setFavOnly(false);
}}
title={t('gallery.reset', { defaultValue: 'Reset' })}
aria-label={t('gallery.reset', { defaultValue: 'Reset' })}
>
<RotateCcw size={13} />
</Button>
) : null}
<div className="ml-auto shrink-0">
<Segmented
size="xs"
value={viewMode}
onChange={setViewMode}
items={[
{ value: 'grid', label: <Grid size={14} />, title: 'Grid' },
{ value: 'list', label: <List size={14} />, title: 'List' },
]}
/>
</div>
</div>
<Segmented
size="xs"
value={viewMode}
onChange={setViewMode}
items={[
{ value: 'grid', label: <Grid size={14} />, title: 'Grid' },
{ value: 'list', label: <List size={14} />, title: 'List' },
]}
/>
{filtersOpen ? (
<div className="mt-[6px] flex items-center gap-[6px] overflow-x-auto pb-px [scrollbar-width:thin]">
{['gender', 'age', 'pitch', 'accent', 'lang'].map((dim) => (
<Select
key={dim}
size="sm"
className="w-auto min-w-[94px] max-w-[132px] shrink-0"
aria-label={t(`archetypes.facet_${dim}`, { defaultValue: titleCase(dim) })}
value={filters[dim] ?? ''}
onChange={(e) => setFilter(dim, e.target.value || null)}
>
<option value="">
{t(`archetypes.facet_${dim}`, { defaultValue: titleCase(dim) })}
</option>
{FACETS[dim].map((opt) => (
<option key={opt} value={opt}>
{facetLabel(opt)}
</option>
))}
</Select>
))}
<label className={`${facetToggle} shrink-0`}>
<input
type="checkbox"
checked={filters.whisper === true}
onChange={(e) => setFilter('whisper', e.target.checked ? true : null)}
/>
{t('archetypes.facet_whisper', { defaultValue: 'Whisper' })}
</label>
</div>
) : null}
</div>
{showFeatured && (
@@ -0,0 +1,94 @@
import React from 'react';
import { describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen } from '@testing-library/react';
import ArchetypesZone from './ArchetypesZone';
import ArchetypeCard from './ArchetypeCard';
vi.mock('../../api/hooks', () => ({
useArchetypeCategories: () => ({
data: [
{ id: 'narration', name: 'Narration & Story', icon: 'BookOpen' },
{ id: 'social', name: 'Social Media', icon: 'Radio' },
],
}),
useArchetypes: (filters) => ({
data: filters.featured ? { items: [] } : { items: [], total: 0 },
isLoading: false,
isFetching: false,
}),
}));
const t = (_key, options = {}) => options.defaultValue || _key;
const baseProps = {
t,
filters: {
use_case: null,
gender: null,
age: null,
pitch: null,
accent: null,
whisper: null,
lang: null,
},
setFilter: vi.fn(),
resetFilters: vi.fn(),
favorites: [],
toggleFavorite: vi.fn(),
viewMode: 'grid',
setViewMode: vi.fn(),
playingId: null,
loadingPreviewId: null,
onPreview: vi.fn(),
onUse: vi.fn(),
onDesign: vi.fn(),
};
describe('ArchetypesZone filter toolbar', () => {
it('keeps categories in one menu and reveals advanced filters on demand', () => {
const setFilter = vi.fn();
render(<ArchetypesZone {...baseProps} setFilter={setFilter} />);
const categoryMenu = screen.getByRole('combobox', { name: 'Archetypes' });
expect(screen.getAllByRole('combobox')).toHaveLength(1);
fireEvent.change(categoryMenu, { target: { value: 'social' } });
expect(setFilter).toHaveBeenCalledWith('use_case', 'social');
fireEvent.click(screen.getByRole('button', { name: 'Filters' }));
expect(screen.getAllByRole('combobox')).toHaveLength(6);
expect(screen.getByRole('checkbox', { name: 'Whisper' })).toBeInTheDocument();
});
});
describe('ArchetypeCard accessibility', () => {
it('names icon actions and exposes favorite state', () => {
render(
<ArchetypeCard
a={{
id: 'narrator',
name: 'Narrator',
language: 'English',
use_case: 'narration',
facets: { gender: 'female', age: 'adult', pitch: 'moderate pitch' },
attrs: {},
}}
t={t}
isFavorite
isPlaying={false}
isLoadingPreview={false}
onPreview={vi.fn()}
onUse={vi.fn()}
onDesign={vi.fn()}
onToggleFavorite={vi.fn()}
/>,
);
expect(screen.getByRole('button', { name: 'Favorite' })).toHaveAttribute(
'aria-pressed',
'true',
);
expect(screen.getByRole('button', { name: 'Open in Designer' })).toBeInTheDocument();
});
});
+50
View File
@@ -0,0 +1,50 @@
// Authored demo content for Stories — kept outside interface copy, like the
// Audiobook sample. Loading it creates a normal editable, saved project.
export const SAMPLE_STORY_NAME = "The Lighthouse at Wits' End";
export const SAMPLE_STORY_CAST = [
{ id: 'narrator', name: 'Narrator', color: '#fabd2f', profileId: null },
{ id: 'mara', name: 'Mara', color: '#83a598', profileId: null },
{ id: 'cole', name: 'Cole', color: '#d3869b', profileId: null },
];
export const SAMPLE_STORY_LINES = [
{ character: 'narrator', text: '# The Lamp' },
{
character: 'narrator',
text: 'The storm had been building for three days, and Mara knew that tonight the old lighthouse would be tested. [pause 0.6s] She climbed the spiral stair, counting each step.',
},
{
character: 'mara',
text: 'Not tonight. [pause 0.4s] You have held for forty years. You will hold for one more.',
},
{
character: 'narrator',
text: 'The lamp answered with a cough of light, then steadied. [laughter] Mara laughed—a short, surprised sound.',
},
{ character: 'narrator', text: '# The Radio' },
{
character: 'narrator',
text: 'Below, the radio crackled. A voice came through the static, fast and frightened.',
},
{
character: 'cole',
text: 'Lighthouse, this is the Kestrel. We have lost our bearing. [pause 0.3s] Can anyone hear me?',
},
{
character: 'mara',
text: 'Kestrel, this is Mara. [pause 0.5s] Steady now. Give me your call sign, letter by letter.',
},
{
character: 'cole',
text: 'Kestrel. [pause 0.4s] We cannot see the shore.',
},
{
character: 'mara',
text: 'You do not need to see the shore. [pause 0.6s] You need to see me. Look for the light.',
},
{
character: 'narrator',
text: 'Out beyond the black water, a small boat turned—slowly, then surely—toward a single, patient beam. [sigh] Some nights, that is the whole job: to be the thing that does not go out.',
},
];
+976 -74
View File
File diff suppressed because it is too large Load Diff
+69 -204
View File
@@ -1,18 +1,5 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
BookMarked,
BookOpen,
BookText,
Code,
Loader,
SlidersHorizontal,
SpellCheck,
Square,
Upload,
Users,
} from 'lucide-react';
import {
audiobookPlan,
audiobookGenerate,
@@ -24,26 +11,18 @@ import { audioUrl } from '../api/generate';
import { listEngines } from '../api/engines';
import { consumeLongformStream } from '../utils/longformStream';
import { useAppStore } from '../store';
import VoiceSelector from '../components/VoiceSelector';
import SearchableSelect from '../components/SearchableSelect';
import AudiobookOverrides, { overridesToRequest } from '../components/audiobook/AudiobookOverrides';
import Section from '../components/audiobook/Section';
import BookDetails from '../components/audiobook/BookDetails';
import LexiconEditor from '../components/audiobook/LexiconEditor';
import { overridesToRequest } from '../components/audiobook/AudiobookOverrides';
import GenerationProgress from '../components/audiobook/GenerationProgress';
import PlanList from '../components/audiobook/PlanList';
import AudiobookResult from '../components/audiobook/AudiobookResult';
import CastPanel from '../components/audiobook/CastPanel';
import MarkupToolbar from '../components/audiobook/MarkupToolbar';
import StatsBar from '../components/audiobook/StatsBar';
import ValidationWarnings from '../components/audiobook/ValidationWarnings';
import AudiobookHero from '../components/audiobook/AudiobookHero';
import AudiobookInspector from '../components/audiobook/AudiobookInspector';
import { useAudiobookLexicon } from '../hooks/useAudiobookLexicon';
import { parseCastNames, validateScript } from '../utils/audiobookScript';
import { SAMPLE_AUDIOBOOK_SCRIPT } from '../data/sampleAudiobook';
import ALL_LANGUAGES from '../languages.json';
import { POPULAR_LANGS } from '../utils/constants';
import { Button } from '../ui';
import { buttonVariants } from '@/components/ui/button.tsx';
// Chrome-mono uppercase form label (was the scoped `.audiobook-tab .field-label`
// rule; `.field-label` has no global styling, so it's reproduced as utilities).
@@ -408,196 +387,82 @@ export default function AudiobookTab({ profiles = [] }) {
);
return (
<div className="audiobook-tab flex flex-col h-full box-border px-[1.5rem] py-[1.25rem] gap-[12px]">
<div className="audiobook-tab__head flex flex-wrap items-start justify-between gap-[16px]">
<div>
<div
role="heading"
aria-level={2}
className="flex items-center gap-[8px] m-0 [font-family:var(--font-serif)] [font-size:var(--text-xl)] [font-weight:var(--weight-semibold)] text-fg"
>
<BookMarked size={20} /> {t('audiobook.title')}
</div>
<p className="muted audiobook-tab__sub mt-[2px] text-[var(--text-sm)] text-fg-muted">
{t('audiobook.subtitle')}
</p>
</div>
<div className="audiobook-tab__actions flex flex-wrap items-center gap-[8px]">
{/* All four use the one shadcn button layout (icon in the leading slot,
text in a leading-none span) so the row lines up. Import is a <label>
(it wraps the file input) styled the same way the old inline
flex/gap override is what made it ragged. */}
<label
className={buttonVariants({ variant: 'subtle', size: 'omniMd' })}
style={{ cursor: busy ? 'default' : 'pointer' }}
>
{importing ? <Loader size={14} className="spin" /> : <Upload size={14} />}
<span className="leading-none">{t('audiobook.import')}</span>
<input
type="file"
accept=".txt,.md,.epub,.pdf"
onChange={onImport}
disabled={busy}
style={{ display: 'none' }}
/>
</label>
<Button
variant="subtle"
onClick={loadSample}
disabled={busy}
title={t('audiobook.load_sample_hint')}
leading={<BookOpen size={14} />}
>
{t('audiobook.load_sample')}
</Button>
<Button variant="subtle" onClick={onPreview} disabled={!canRun} loading={planLoading}>
{t('audiobook.preview_plan')}
</Button>
{generating ? (
<Button variant="danger" onClick={onStop} leading={<Square size={14} />}>
{t('audiobook.stop')}
</Button>
) : (
<Button variant="primary" onClick={onCreate} disabled={!canRun}>
{t('audiobook.create')}
</Button>
)}
</div>
</div>
<div className="audiobook-tab flex h-full flex-col box-border px-[1.25rem] py-[1rem] gap-[10px] max-[1120px]:overflow-y-auto">
<AudiobookHero
t={t}
busy={busy}
importing={importing}
planLoading={planLoading}
generating={generating}
canRun={canRun}
onImport={onImport}
onLoadSample={loadSample}
onPreview={onPreview}
onCreate={onCreate}
onStop={onStop}
/>
<div className="audiobook-tab__body grid flex-auto grid-cols-[minmax(0,1fr)_minmax(300px,380px)] max-[900px]:grid-cols-1 gap-[16px] min-h-0">
<div className="audiobook-tab__body grid flex-auto grid-cols-[minmax(0,1fr)_minmax(440px,500px)] max-[1120px]:grid-cols-1 gap-[14px] min-h-0">
{/* Left: script editor fills the height */}
<div className="audiobook-tab__script flex flex-col min-h-0 gap-[6px]">
<label className={FIELD_LABEL}>{t('audiobook.script')}</label>
<MarkupToolbar t={t} textareaRef={textareaRef} text={text} setText={setText} />
<textarea
ref={textareaRef}
className="input-base"
value={text}
onChange={(e) => {
setText(e.target.value);
if (warningsDismissed) setWarningsDismissed(false);
}}
onKeyDown={onScriptKeyDown}
placeholder={t('audiobook.script_placeholder')}
aria-label={t('audiobook.script')}
/>
{text.trim() ? (
<StatsBar t={t} text={text} />
) : (
<p className="muted text-[var(--text-sm)] text-fg-muted m-0">
{t('audiobook.empty_hint')}
</p>
)}
<div className="audiobook-tab__script flex flex-col min-h-0 gap-[7px]">
<div className="flex min-h-[18px] items-center justify-between gap-[12px] px-[4px]">
<label className={FIELD_LABEL}>{t('audiobook.script')}</label>
{text.trim() ? <StatsBar t={t} text={text} /> : null}
</div>
<div className="audiobook-tab__manuscript flex min-h-0 flex-1 flex-col overflow-hidden rounded-[14px]">
<div className="border-b border-transparent px-[10px] py-[7px]">
<MarkupToolbar t={t} textareaRef={textareaRef} text={text} setText={setText} />
</div>
<textarea
ref={textareaRef}
className="input-base"
value={text}
onChange={(e) => {
setText(e.target.value);
if (warningsDismissed) setWarningsDismissed(false);
}}
onKeyDown={onScriptKeyDown}
placeholder={t('audiobook.script_placeholder')}
aria-label={t('audiobook.script')}
/>
{!text.trim() && (
<p className="m-0 border-t border-transparent px-[14px] py-[9px] text-[var(--text-sm)] text-fg-muted">
{t('audiobook.empty_hint')}
</p>
)}
</div>
</div>
{/* Right: settings + results, scrolls independently */}
<div className="audiobook-tab__side flex flex-col gap-[12px] min-h-0 overflow-y-auto max-[900px]:overflow-visible pr-[4px]">
<div className="audiobook-tab__field flex flex-col gap-[4px]">
<label className={FIELD_LABEL}>{t('audiobook.default_voice')}</label>
<VoiceSelector
value={defaultVoice}
onChange={setDefaultVoice}
profiles={profiles}
defaultLabel={t('audiobook.engine_default')}
/>
</div>
<div className="audiobook-tab__field flex flex-col gap-[4px]">
<label className={FIELD_LABEL}>{t('audiobook.language')}</label>
<SearchableSelect
value={language}
options={ALL_LANGUAGES}
popular={POPULAR_LANGS}
recentsKey="omnivoice.recents.audiobookLang"
onChange={setLanguage}
/>
</div>
{/* Cast one row per distinct [voice:NAME] in the script (#1217).
Open by default so the multi-voice mapping is discoverable the
moment a script uses [voice:]; hidden entirely when it doesn't. */}
{castNames.length > 0 && (
<Section title={t('audiobook.cast')} icon={<Users size={13} />} defaultOpen>
<CastPanel
t={t}
castNames={castNames}
voiceCast={voiceCast}
setVoiceCast={setVoiceCast}
profiles={profiles}
/>
</Section>
)}
<AudiobookOverrides
<div className="audiobook-tab__side flex flex-col gap-[9px] min-h-0 overflow-y-auto max-[1120px]:overflow-visible rounded-[12px] bg-[var(--color-bg-elev-2)] p-[10px]">
<AudiobookInspector
t={t}
profiles={profiles}
defaultVoice={defaultVoice}
setDefaultVoice={setDefaultVoice}
language={language}
setLanguage={setLanguage}
format={format}
setFormat={setFormat}
loudness={loudness}
setLoudness={setLoudness}
castNames={castNames}
voiceCast={voiceCast}
setVoiceCast={setVoiceCast}
overrides={overrides}
onChange={setLongformOverrides}
setOverrides={setLongformOverrides}
emotionSupported={emotionSupported}
coverPreview={coverPreview}
onCoverPick={onCoverPick}
clearCover={clearCover}
meta={meta}
setMetaField={setMetaField}
lex={lex}
setLexRow={setLexRow}
addLexRow={addLexRow}
removeLexRow={removeLexRow}
/>
{/* Output format + loudness. Open by default so a first-timer sees a
tangible setting next to the always-on voice/language above. */}
<Section title={t('audiobook.output')} icon={<SlidersHorizontal size={13} />} defaultOpen>
<div className="grid grid-cols-[1fr_1fr] gap-[8px]">
<div className="flex flex-col gap-[4px]">
<label className={FIELD_LABEL}>{t('audiobook.format')}</label>
<select
className="input-base"
value={format}
onChange={(e) => setFormat(e.target.value)}
aria-label={t('audiobook.format')}
>
<option value="m4b">{t('audiobook.format_m4b')}</option>
<option value="mp3">{t('audiobook.format_mp3')}</option>
</select>
</div>
<div className="flex flex-col gap-[4px]">
<label className={FIELD_LABEL}>{t('audiobook.loudness')}</label>
<select
className="input-base"
value={loudness}
onChange={(e) => setLoudness(e.target.value)}
aria-label={t('audiobook.loudness')}
>
<option value="off">{t('audiobook.loudness_off')}</option>
<option value="acx">{t('audiobook.loudness_acx')}</option>
<option value="podcast">{t('audiobook.loudness_podcast')}</option>
</select>
</div>
</div>
</Section>
{/* Book details — cover + embedded metadata. Collapsed by default. */}
<Section title={t('audiobook.details')} icon={<BookText size={13} />}>
<BookDetails
t={t}
coverPreview={coverPreview}
onCoverPick={onCoverPick}
clearCover={clearCover}
meta={meta}
setMetaField={setMetaField}
/>
</Section>
{/* Pronunciation lexicon — collapsed by default. */}
<Section title={t('audiobook.lexicon')} icon={<SpellCheck size={13} />}>
<LexiconEditor
t={t}
lex={lex}
setLexRow={setLexRow}
addLexRow={addLexRow}
removeLexRow={removeLexRow}
/>
</Section>
{/* Markup quick reference — collapsed by default. */}
<Section title={t('audiobook.markup_help')} icon={<Code size={13} />}>
<p className="muted" style={{ fontSize: '0.72rem', lineHeight: 1.6 }}>
{t('audiobook.markup_hint')}
</p>
</Section>
{!warningsDismissed && !generating && (
<ValidationWarnings
t={t}
+32 -27
View File
@@ -1,14 +1,10 @@
// Audiobook tab layout the prod-polish compaction (#1214).
//
// The right-hand settings column was flattened into consistent collapsible
// Sections (Output / Book details / Pronunciation / Markup) with the primary
// inputs (script, default voice, language) always visible. This guards that
// contract so a future refactor can't silently drop a control or un-collapse
// the column: every key control still renders, Output starts open, the long
// groups start collapsed, and a collapsed group opens on click.
// The right-hand settings column is a compact property inspector: essentials
// stay visible and one optional production tool opens at a time.
import React from 'react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, within } from '@testing-library/react';
import { render, screen, fireEvent } from '@testing-library/react';
import { I18nextProvider } from 'react-i18next';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import i18n from '../i18n';
@@ -40,9 +36,6 @@ const withI18n = (node) => (
<I18nextProvider i18n={i18n}>{node}</I18nextProvider>
</QueryClientProvider>
);
// The <details> element that holds a given section header title.
const sectionFor = (title) => screen.getByText(title).closest('details');
describe('AudiobookTab — compact grouped layout (#1214)', () => {
beforeEach(() => {
localStorage.clear();
@@ -50,17 +43,23 @@ describe('AudiobookTab — compact grouped layout (#1214)', () => {
});
it('keeps the primary inputs always visible', () => {
render(withI18n(<AudiobookTab profiles={[]} />));
const { container } = render(withI18n(<AudiobookTab profiles={[]} />));
// Script editor, default voice, language the three always-on controls.
expect(screen.getByLabelText(en.audiobook.script)).toBeTruthy();
expect(screen.getByText(en.audiobook.default_voice)).toBeTruthy();
expect(screen.getByText(en.audiobook.language)).toBeTruthy();
// The action bar keeps the new "Load sample" button + Create.
expect(screen.getByText(en.audiobook.load_sample)).toBeTruthy();
expect(screen.getByLabelText(en.audiobook.format)).toBeTruthy();
// Secondary actions stay discoverable through accessible icon labels.
expect(screen.getByLabelText(en.audiobook.load_sample)).toBeTruthy();
expect(screen.getByLabelText(en.audiobook.import)).toBeTruthy();
expect(screen.getByLabelText(en.audiobook.preview_plan)).toBeTruthy();
expect(screen.getByText(en.audiobook.create)).toBeTruthy();
expect(screen.getByRole('heading', { level: 2, name: en.audiobook.title })).toBeTruthy();
expect(container.querySelector('[class*="container-name:audiobook-inspector"]')).toBeTruthy();
expect(container.querySelector('[class*="@min-[360px]/audiobook-inspector:grid-cols-2"]')).toBeTruthy();
});
it('groups the secondary controls into collapsible sections', () => {
it('groups optional controls into an icon-led tool strip', () => {
render(withI18n(<AudiobookTab profiles={[]} />));
for (const title of [
en.audiobook.output,
@@ -68,26 +67,32 @@ describe('AudiobookTab — compact grouped layout (#1214)', () => {
en.audiobook.lexicon,
en.audiobook.markup_help,
]) {
expect(sectionFor(title).tagName).toBe('DETAILS');
expect(screen.getByRole('button', { name: title })).toBeTruthy();
}
});
it('opens Output by default and collapses the long groups', () => {
it('keeps optional panels closed by default', () => {
render(withI18n(<AudiobookTab profiles={[]} />));
expect(sectionFor(en.audiobook.output).open).toBe(true);
expect(sectionFor(en.audiobook.details).open).toBe(false);
expect(sectionFor(en.audiobook.lexicon).open).toBe(false);
// Output is open, so its format control is reachable right away.
expect(screen.getByLabelText(en.audiobook.format)).toBeTruthy();
expect(screen.queryByLabelText(en.audiobook.loudness)).toBeNull();
expect(screen.queryByLabelText(en.audiobook.meta_title)).toBeNull();
});
it('a collapsed section toggles open on its summary', () => {
it('opens Cast by default when the script contains cast tags', () => {
useAppStore.getState().setScript('# Chapter\n[voice:Mara] Hello');
render(withI18n(<AudiobookTab profiles={[]} />));
const details = sectionFor(en.audiobook.details);
expect(details.open).toBe(false);
fireEvent.click(within(details).getByText(en.audiobook.details));
expect(details.open).toBe(true);
// Once open, the metadata inputs are present.
expect(screen.getByRole('button', { name: en.audiobook.cast })).toHaveAttribute(
'aria-pressed',
'true',
);
expect(screen.getByLabelText(`${en.audiobook.cast}: Mara`)).toBeTruthy();
});
it('shows only the selected optional panel', () => {
render(withI18n(<AudiobookTab profiles={[]} />));
fireEvent.click(screen.getByRole('button', { name: en.audiobook.details }));
expect(screen.getByLabelText(en.audiobook.meta_title)).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: en.audiobook.output }));
expect(screen.queryByLabelText(en.audiobook.meta_title)).toBeNull();
expect(screen.getByLabelText(en.audiobook.loudness)).toBeTruthy();
});
});
@@ -1,6 +1,6 @@
import React from 'react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, within } from '@testing-library/react';
import { render, screen, fireEvent, within, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import '../i18n';
@@ -63,12 +63,36 @@ describe('StoriesEditor voice pickers (#1220)', () => {
it('cast picker renders VoiceSelector and stores the character voice', () => {
renderEditor();
// Open the Cast panel.
fireEvent.click(screen.getByRole('button', { name: /Cast/ }));
const castRegion = screen.getByRole('region', { name: /Cast/ });
const castRegion = screen.getByRole('complementary', { name: /Stories/ });
const trigger = within(castRegion).getByRole('button', { name: /Default/ });
fireEvent.click(trigger);
fireEvent.mouseDown(screen.getByText('Aria'));
expect(useAppStore.getState().cast[0].profileId).toBe('p_clone');
});
it('renders a calm writing hierarchy with the project stats and line canvas', () => {
renderEditor();
expect(screen.getByRole('heading', { level: 1, name: /Untitled story/ })).toBeInTheDocument();
expect(screen.getAllByText('1 lines').length).toBeGreaterThan(0);
expect(screen.getByRole('main')).toHaveClass('stories-manuscript');
expect(screen.getByRole('complementary')).toHaveClass('stories-sidebar');
expect(screen.getByRole('list')).toHaveClass('stories-track-list');
expect(screen.getByRole('listitem')).toHaveClass('stories-line');
});
it('loads a comprehensive working sample by default', async () => {
useAppStore.setState({ storyTracks: [], storyProjects: [], currentProjectId: null });
renderEditor();
await waitFor(() => expect(useAppStore.getState().storyTracks).toHaveLength(11));
const state = useAppStore.getState();
expect(state.cast.map((member) => member.name)).toEqual(['Narrator', 'Mara', 'Cole']);
expect(state.cast.every((member) => member.profileId === 'p_clone')).toBe(true);
expect(state.storyProjects.at(-1)?.name).toBe("The Lighthouse at Wits' End");
expect(
screen.getByRole('heading', { name: "The Lighthouse at Wits' End" }),
).toBeInTheDocument();
expect(state.storyTracks.filter((track) => track.text.startsWith('#'))).toHaveLength(2);
expect(state.storyTracks.some((track) => track.text.includes('[pause'))).toBe(true);
});
});
+13 -7
View File
@@ -33,10 +33,8 @@ describe('CastPanel', () => {
profiles={[]}
/>,
);
expect(screen.getByText('[voice:Mara]')).toBeInTheDocument();
expect(screen.getByText('[voice:Cole]')).toBeInTheDocument();
// Both unmapped both show the "uses Default voice" hint.
expect(screen.getAllByText('audiobook.cast_uses_default')).toHaveLength(2);
expect(screen.getByText('Mara')).toBeInTheDocument();
expect(screen.getByText('Cole')).toBeInTheDocument();
fireEvent.change(screen.getAllByTestId('voice-selector')[0], {
target: { value: 'pid-1' },
@@ -72,7 +70,7 @@ describe('MarkupToolbar', () => {
const ta = screen.getByLabelText('script');
ta.focus();
ta.setSelectionRange(5, 5); // caret right after "hello"
fireEvent.click(screen.getByText('audiobook.insert_pause'));
fireEvent.click(screen.getByRole('button', { name: 'audiobook.insert_pause' }));
expect(ta.value).toBe('hello[pause 500ms] world');
});
@@ -81,7 +79,7 @@ describe('MarkupToolbar', () => {
const ta = screen.getByLabelText('script');
ta.focus();
ta.setSelectionRange(0, 11); // select the whole thing
fireEvent.click(screen.getByText('audiobook.insert_slow'));
fireEvent.click(screen.getByRole('button', { name: 'audiobook.insert_slow' }));
expect(ta.value).toBe('[slow]hello world[/slow]');
});
@@ -90,9 +88,17 @@ describe('MarkupToolbar', () => {
const ta = screen.getByLabelText('script');
ta.focus();
ta.setSelectionRange(0, 0);
fireEvent.click(screen.getByText('audiobook.insert_voice'));
fireEvent.click(screen.getByRole('button', { name: 'audiobook.insert_voice' }));
expect(ta.value).toBe('[voice:NAME]');
});
it('exposes an accessible dismissal control for the reactions menu', () => {
render(<ToolbarHarness />);
fireEvent.click(screen.getByRole('button', { name: 'audiobook.insert_reactions' }));
const close = screen.getByRole('button', { name: 'common.close' });
fireEvent.click(close);
expect(screen.queryByRole('menu')).toBeNull();
});
});
describe('StatsBar', () => {
+18
View File
@@ -1,5 +1,6 @@
import { describe, it, expect } from 'vitest';
import { storyToSpans } from '../utils/storyToSpans';
import { SAMPLE_STORY_CAST, SAMPLE_STORY_LINES } from '../data/sampleStory';
const CAST = [
{ id: 'narrator', name: 'Narrator', profileId: 'p_narr' },
@@ -7,6 +8,23 @@ const CAST = [
];
describe('storyToSpans', () => {
it('compiles the bundled Stories demo through the real render plan', () => {
const cast = SAMPLE_STORY_CAST.map((member) => ({
...member,
profileId: `profile-${member.id}`,
}));
const chapters = storyToSpans(SAMPLE_STORY_LINES, cast);
const spans = chapters.flatMap((chapter) => chapter.spans);
expect(chapters.map((chapter) => chapter.title)).toEqual(['The Lamp', 'The Radio']);
expect(spans.length).toBeGreaterThan(9);
expect(spans.every((span) => span.text.trim())).toBe(true);
expect(new Set(spans.map((span) => span.voice_id))).toEqual(
new Set(['profile-narrator', 'profile-mara', 'profile-cole']),
);
expect(spans.some((span) => span.pause_ms_after > 0)).toBe(true);
});
it('resolves each line to its cast voice', () => {
const tracks = [
{ character: 'narrator', text: 'Once upon a time.' },
@@ -46,6 +46,13 @@ describe('workspace narrow-shell reflow (#476 CTA-clipping guard)', () => {
expect(css).toMatch(/\.studio-action-bar\s*\{[^}]*position:\s*sticky/s);
});
it('keeps saved voices in the left rail and generation history alone on the right', () => {
expect(app).toMatch(
/className="studio-voices">\s*<WorkspaceVoices[\s\S]*?<\/div>\s*<div className="studio-with-history__main">/,
);
expect(app).toMatch(/<div className="studio-right">\s*<WorkspaceHistory\s+history=\{history\}/);
});
it('gives Dub Projects its own narrower rail than Dub History', () => {
expect(app).toMatch(/className="studio-projects">\s*<WorkspaceProjects/);
expect(css).toMatch(/\.studio-projects\s*\{[^}]*flex:\s*0 0 240px/s);
@@ -57,4 +64,10 @@ describe('workspace narrow-shell reflow (#476 CTA-clipping guard)', () => {
/dubStep === 'idle'[\s\S]*?className="studio-projects"[\s\S]*?canSave=\{false\}/,
);
});
it('gives the Script editor more height without crowding narrow shells', () => {
expect(indexRaw).toMatch(/\.studio-script-input\s*\{[^}]*min-height:\s*240px/s);
expect(indexRaw).toMatch(/\.shell-narrow\s+\.studio-script-input[^}]*min-height:\s*200px/s);
expect(indexRaw).toMatch(/\.shell-mini\s+\.studio-script-input[^}]*min-height:\s*160px/s);
});
});
+2 -4
View File
@@ -133,7 +133,8 @@ export function NowPlaying({ color }) {
);
}
/** Color-coded icon tile with a small flag badge — the visual anchor of a card. */
/** Color-coded icon tile. Accent flags live in the metadata row, where they
* remain readable and do not duplicate into a clipped badge under the tile. */
export function ArchetypeAvatar({ item, size = 44 }) {
const color = USE_CASE_COLOR[item.use_case] || '#83a598';
return (
@@ -146,9 +147,6 @@ export function ArchetypeAvatar({ item, size = 44 }) {
}}
>
<ArchetypeIcon name={item.icon} size={Math.round(size * 0.46)} color={color} />
<span className="arch-avatar-flag">
<AccentFlag accent={item.facets?.accent} lang={item.language} size={15} />
</span>
</div>
);
}