feat(ui): rewrite Gallery/Stories/Logs on clean shadcn, delete their CSS (fast mode) (#822)

FAST-mode shadcn migration of three independent areas — Voice Gallery,
Stories editor, and the logs/status footer — onto shadcn primitives
(src/ui barrel over src/components/ui/*) + Tailwind token utilities. Palette
kept; behavior preserved; ~1000 lines of bespoke CSS removed.

Voice Gallery (VoiceGallery.jsx + gallery/{ArchetypeCard,ArchetypesZone,
CommunityZone,ImportsZone}.jsx):
- Zone toggle → <Segmented>; category chips → Button variant="chip"; facet
  dropdowns → <Select>; grid/list view toggle → <Segmented>; cards/chips/
  buttons/empty/loading → Tailwind token utilities.
- VoiceGallery.css 427 → 70 lines: kept only the now-playing equalizer
  @keyframes, the .arch-avatar/.accent-flag/.flag-globe classes rendered by
  the out-of-scope utils/archetypeIcons.jsx, and the app-wide .spin helper
  (it lived here, NOT in index.css — kept to avoid breaking ~30 consumers).

Stories editor (StoriesEditor.jsx):
- Track grid, chapter bar, cast/projects/split panels, tone/speed drawer,
  and native textarea/select/range chrome → Tailwind utilities; reusable
  class-string consts hoisted. Drag-reorder, preview chain, generate/stems,
  global speed, refs, i18n keys and aria-labels all unchanged.
- StoriesEditor.css 349 → 0 lines (file deleted; import removed). The dead
  .stories-track__voice-dot[data-char] palette (no data-char ever set) and
  cosmetic webkit scrollbars were dropped.
- Native <select>s get [color-scheme:dark] so the cast/voice pickers render
  on dark chrome across WebKit/WebView2/WebKitGTK (matches the old
  .facet-select intent; the original cast select was unstyled/light).

Logs/status footer (LogsFooter.jsx):
- Icon buttons, source pills + severity badges, version badge + pulse dot,
  discord/contact/donate, log lines and notification severity → Tailwind
  utilities. Spinner → motion-safe:animate-spin; reduced-motion via
  motion-reduce: variants.
- LogsFooter.css 376 → 78 lines: kept the position:fixed shell + the
  .app-container/.rail-right/≤600px ancestor-combinator insets (can't be
  element-local), the body ::-webkit-scrollbar, the collapsed/open heights,
  and the version-dot-pulse/heart-glow @keyframes.
- The shared --chrome-* token vars and index.css are untouched; Header is
  unaffected (no logs-footer__* class is referenced outside this component).

Verified: vite build, oxlint (0), oxfmt --check (clean), vitest (641
passed), bun run test:visual (48 passed), bun install --frozen-lockfile.
Eyeballed all three areas in a stubbed dev build — coherent and on-palette.

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Palash Debnath
2026-07-01 01:56:36 +05:30
committed by GitHub
co-authored by mergetest Claude Opus 4.8
parent 8a9c2f1ce4
commit b940eb460f
10 changed files with 459 additions and 1295 deletions
+27 -325
View File
@@ -1,8 +1,19 @@
/* VSCode-style bottom log panel. Fixed to the bottom of the viewport so
the main app can freely scroll underneath; we reserve bottom padding
via `body { padding-bottom: 28px }` in the global styles (added via
the component's mount — see App.jsx). */
/* VSCode-style bottom log panel. Fixed to the bottom of the viewport so the
main app can freely scroll underneath. Everything that can be expressed as
element-local styling (buttons, pills, badges, version, discord/donate,
log lines, notification items) now lives as Tailwind utilities in
LogsFooter.jsx. What stays here CANNOT be element-local Tailwind:
(a) the fixed footer shell + the ancestor-combinator inset rules
(`.app-container .logs-footer`, `.rail-right`, the ≤600px media query) —
they depend on the `logs-footer` class being matched by ancestor
selectors, which Tailwind utilities can't express;
(b) the collapsed/open height;
(c) the `::-webkit-scrollbar` pseudo-elements on the scrollable body;
(d) the @keyframes referenced via Tailwind arbitrary `[animation:…]` utilities
(version-dot-pulse on the update dot, heart-glow on the donate button). */
/* (a) Fixed footer shell + rail insets ------------------------------------ */
.logs-footer {
position: fixed;
left: 0;
@@ -11,8 +22,7 @@
z-index: 40;
display: flex;
flex-direction: column;
/* Chrome tokens defined at :root in index.css. The Header topbar
uses the same set, so changes here flow to both surfaces. */
/* Chrome tokens defined at :root in index.css; shared with the Header topbar. */
background: var(--chrome-bg);
border-top: 1px solid var(--chrome-border);
font-family: var(--font-sans);
@@ -21,12 +31,9 @@
user-select: none;
}
/* The footer sits *beside* the nav rail, never under or over it. The rail is
a full-window-height grid column (see .app-container in index.css), so the
footer is simply inset by the rail's 48px and joins it on the edge. Units
live in the app-container coordinate space, so the 48px stays aligned with
the rail's 48px grid column under UI-scale zoom. The splash/wizard footers
render outside .app-container and stay full-width. */
/* The footer sits *beside* the nav rail (a 48px grid column of .app-container),
never under or over it. Splash/wizard footers render outside .app-container
and stay full-width. */
.app-container .logs-footer {
left: 48px;
right: 0;
@@ -44,333 +51,28 @@
}
}
/* (b) Collapsed/open height ----------------------------------------------- */
.logs-footer--collapsed {
height: var(--chrome-bar-h);
}
.logs-footer--open {
/* height is set inline via React state */
min-height: 180px;
}
/* Drag handle, top bar, left/right clusters, title, badge base, log-line base,
and the notification panel layout now live as Tailwind utilities in
LogsFooter.jsx. What stays here is the chrome chrome: the fixed footer shell
(it's the anchor for the `.app-container .logs-footer` inset combinators + the
≤600px media query), every interactive button with its hover/disabled/animation
states, the severity color modifiers + their descendant overrides, scrollbar
pseudo-elements, and the @keyframes. */
/* Thin vertical divider between the UI-scale toggle (leftmost) and the
Logs title + source pills. Keeps the two distinct control groups from
visually running into each other. */
.logs-footer__divider {
width: 1px;
height: 16px;
background: rgba(255, 255, 255, 0.08);
margin: 0 4px;
}
.logs-footer__scale {
margin-right: 2px;
}
/* (c) Scrollbar pseudo-elements for the log body (Tailwind can't target these) */
.logs-footer__body::-webkit-scrollbar { width: 8px; height: 8px; }
.logs-footer__body::-webkit-scrollbar-thumb { background: rgba(255, 255, 255, 0.08); border-radius: 4px; }
.logs-footer__body::-webkit-scrollbar-thumb:hover { background: rgba(255, 255, 255, 0.15); }
/* Theme color dots */
.logs-footer__themes {
display: flex;
align-items: center;
gap: 4px;
}
.logs-footer__theme-dot {
width: 12px;
height: 12px;
border-radius: 50%;
border: 2px solid transparent;
background: var(--dot-color, #888);
cursor: pointer;
transition: transform 0.15s, border-color 0.15s, box-shadow 0.15s;
padding: 0;
}
.logs-footer__theme-dot:hover {
transform: scale(1.25);
box-shadow: 0 0 6px color-mix(in srgb, var(--dot-color, #888) 50%, transparent);
}
.logs-footer__theme-dot.is-active {
border-color: var(--dot-color, #888);
box-shadow: 0 0 8px color-mix(in srgb, var(--dot-color, #888) 40%, transparent);
transform: scale(1.15);
}
.logs-footer__toggle {
background: none;
border: none;
color: var(--chrome-fg-muted);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
width: var(--chrome-icon-btn);
height: var(--chrome-icon-btn);
border-radius: 3px;
}
.logs-footer__toggle:hover {
color: var(--chrome-fg);
background: var(--chrome-hover-bg);
}
/* Source pills with badges */
.logs-footer__pill {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 2px 8px;
height: var(--chrome-pill-h);
border-radius: 3px;
background: none;
border: 1px solid transparent;
color: var(--chrome-fg-muted);
font-size: 11px;
cursor: pointer;
transition: all 0.1s;
font-family: inherit;
}
.logs-footer__pill:hover {
background: var(--chrome-hover-bg);
color: var(--chrome-fg);
}
.logs-footer__pill--active {
background: var(--chrome-accent-bg);
border-color: var(--chrome-accent-border);
color: var(--chrome-accent);
}
.logs-footer__pill--error {
color: #fb4934;
}
.logs-footer__pill--warn {
color: #fabd2f;
}
.logs-footer__badge--error {
background: rgba(251, 73, 52, 0.2);
color: #fb4934;
}
.logs-footer__badge--warn {
background: rgba(250, 189, 47, 0.2);
color: #fabd2f;
}
/* Action buttons on the right */
/* Clickable app-version badge → Settings → Updates (sits by the network icon) */
.logs-footer__version {
background: none;
border: none;
cursor: pointer;
flex-shrink: 0;
padding: 0 6px;
height: var(--chrome-icon-btn);
border-radius: 4px;
font-size: 11px;
font-variant-numeric: tabular-nums;
letter-spacing: 0.02em;
color: var(--chrome-fg, #ebdbb2);
opacity: 0.55;
transition: opacity 0.15s, color 0.15s;
}
.logs-footer__version:hover {
opacity: 1;
color: var(--chrome-accent, #b8bb26);
text-decoration: underline;
}
/* Update-available: highlight the badge + a pulsing notification dot. */
.logs-footer__version--update {
position: relative;
opacity: 1;
color: var(--chrome-accent, #b8bb26);
font-weight: 600;
}
.logs-footer__version-dot {
display: inline-block;
width: 6px;
height: 6px;
margin-left: 5px;
border-radius: 50%;
background: var(--chrome-accent, #b8bb26);
box-shadow: 0 0 0 0 color-mix(in srgb, var(--chrome-accent, #b8bb26) 60%, transparent);
animation: version-dot-pulse 2s ease-in-out infinite;
vertical-align: middle;
}
/* (d) Keyframes referenced from LogsFooter.jsx via `[animation:…]` utilities.
The update dot keeps pulsing under reduced motion (matches prior behavior);
the donate button opts out via `motion-reduce:[animation:none]` in JSX. */
@keyframes version-dot-pulse {
0%, 100% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--chrome-accent, #b8bb26) 55%, transparent); }
50% { box-shadow: 0 0 0 4px color-mix(in srgb, var(--chrome-accent, #b8bb26) 0%, transparent); }
}
/* Discord button */
.logs-footer__discord {
background: none;
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
width: var(--chrome-icon-btn);
height: var(--chrome-icon-btn);
flex-shrink: 0;
border-radius: 4px;
color: #7289da;
opacity: 0.6;
transition: color 0.15s, opacity 0.15s, transform 0.15s;
}
.logs-footer__discord:hover {
opacity: 1;
color: #5865F2;
transform: scale(1.1);
}
/* Glowing donate heart */
.logs-footer__donate {
background: none;
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
width: var(--chrome-icon-btn);
height: var(--chrome-icon-btn);
flex-shrink: 0;
border-radius: 4px;
color: #d3869b;
margin-left: 4px;
transition: color 0.15s, transform 0.15s;
animation: heart-glow 2.5s ease-in-out infinite;
}
.logs-footer__donate:hover {
color: var(--chrome-accent);
transform: scale(1.15);
}
.logs-footer__donate svg {
fill: rgba(211, 134, 155, 0.25);
filter: drop-shadow(0 0 4px rgba(211, 134, 155, 0.35));
}
.logs-footer__donate:hover svg {
fill: rgba(211, 134, 155, 0.5);
filter: drop-shadow(0 0 8px rgba(211, 134, 155, 0.6));
}
@keyframes heart-glow {
0%, 100% { opacity: 0.7; transform: scale(1); }
50% { opacity: 1; transform: scale(1.08); }
}
.logs-footer__icon-btn {
background: none;
border: none;
color: var(--chrome-fg-muted);
cursor: pointer;
width: var(--chrome-icon-btn);
height: var(--chrome-icon-btn);
border-radius: 3px;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.1s;
}
.logs-footer__icon-btn:hover {
color: var(--chrome-fg);
background: var(--chrome-hover-bg);
}
.logs-footer__icon-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.logs-footer__icon-btn--report:hover {
color: #fabd2f;
}
.logs-footer__icon-btn .spinner {
animation: logs-spin 1s linear infinite;
}
@keyframes logs-spin {
to { transform: rotate(360deg); }
}
/* Body (scrollable log area) */
.logs-footer__body {
flex: 1;
min-height: 0;
overflow-y: auto;
overflow-x: auto;
padding: 4px 8px;
user-select: text;
}
.logs-footer__body::-webkit-scrollbar { width: 8px; height: 8px; }
.logs-footer__body::-webkit-scrollbar-thumb { background: rgba(255, 255, 255, 0.08); border-radius: 4px; }
.logs-footer__body::-webkit-scrollbar-thumb:hover { background: rgba(255, 255, 255, 0.15); }
.logs-footer__line--error {
background: rgba(251, 73, 52, 0.06);
}
.logs-footer__line--warn {
background: rgba(250, 189, 47, 0.04);
}
/* line-text base lives in JSX utilities; these severity overrides recolor it. */
.logs-footer__line--error .logs-footer__line-text { color: #fb4934; }
.logs-footer__line--warn .logs-footer__line-text { color: #fabd2f; }
/* ── Notification panel in footer ────────────────────────────────── */
/* notif-body / notif-item base+hover / notif-icon / notif-content layout /
notif-msg / notif-action are utilities in JSX. The severity border-left
modifiers, the clickable hover, and the `notif-content strong` rule stay. */
.logs-footer__notif-item--warn { border-left: 2px solid #fabd2f; }
.logs-footer__notif-item--error { border-left: 2px solid #fb4934; }
.logs-footer__notif-item--info { border-left: 2px solid #83a598; }
.logs-footer__notif-content strong {
font-size: 12px;
color: var(--color-fg);
}
.logs-footer__notif-link {
font-size: 11px;
color: var(--color-brand);
text-decoration: none;
margin-top: 2px;
}
.logs-footer__notif-link:hover { text-decoration: underline; }
.logs-footer__notif-hf {
display: flex;
gap: 4px;
margin-top: 4px;
}
.logs-footer__notif-hf-input {
flex: 1;
max-width: 280px;
background: var(--color-bg-elev-2);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
color: var(--color-fg);
font-size: 11px;
font-family: var(--font-mono);
padding: 3px 8px;
}
.logs-footer__notif-hf-input:focus {
border-color: var(--color-brand);
outline: none;
}
.logs-footer__notif-hf-btn {
background: var(--color-brand);
color: #1d2021;
border: none;
border-radius: var(--radius-md);
font-size: 11px;
font-weight: 600;
padding: 3px 10px;
cursor: pointer;
}
.logs-footer__notif-hf-btn:hover { opacity: 0.85; }
.logs-footer__notif-item--clickable { cursor: pointer; }
.logs-footer__notif-item--clickable:hover { background: rgba(255, 255, 255, 0.06); }
/* ── Reduced motion (10x P4, spec §3): the heart stops glowing; the
refresh spinner holds a static frame. ───────────────────────────────── */
@media (prefers-reduced-motion: reduce) {
.logs-footer__donate,
.logs-footer__icon-btn .spinner {
animation: none;
}
}
+154 -69
View File
@@ -92,35 +92,76 @@ function SeverityIcon({ level, size = 11 }) {
// fields (uiScale, theme, setUiScale, setTheme) are unchanged; only the
// rendering moved.
// Count badge base (layout/typography). The severity color variants
// (--error/--warn) remain in LogsFooter.css.
const BADGE =
// ── Tailwind utility-string presets (replacing the old `logs-footer__*` BEM
// classes). Chrome palette is referenced via `--chrome-*` arbitrary values so
// the footer keeps its fixed dark-chrome look (same tokens Header uses).
// Severity colors stay as literal arbitrary values (#fb4934 / #fabd2f /
// #83a598) — NOT text-danger/text-accent — so the footer chrome stays fixed
// across themes (the token utilities recolor per [data-theme]).
const ICON_BTN =
'flex items-center justify-center w-[var(--chrome-icon-btn)] h-[var(--chrome-icon-btn)] ' +
'rounded-[3px] bg-transparent border-0 cursor-pointer transition-all ' +
'[color:var(--chrome-fg-muted)] hover:[color:var(--chrome-fg)] hover:[background:var(--chrome-hover-bg)] ' +
'disabled:opacity-40 disabled:cursor-not-allowed';
// Report button: same shell but hovers warn-yellow instead of chrome-fg.
const ICON_BTN_REPORT =
'flex items-center justify-center w-[var(--chrome-icon-btn)] h-[var(--chrome-icon-btn)] ' +
'rounded-[3px] bg-transparent border-0 cursor-pointer transition-all ' +
'[color:var(--chrome-fg-muted)] hover:[color:#fabd2f] hover:[background:var(--chrome-hover-bg)]';
const PILL_BASE =
'inline-flex items-center gap-[6px] px-[8px] py-[2px] h-[var(--chrome-pill-h)] rounded-[3px] ' +
'bg-transparent border text-[11px] cursor-pointer transition-all [font-family:inherit] ' +
'hover:[background:var(--chrome-hover-bg)] hover:[color:var(--chrome-fg)]';
// Count-badge layout/typography (severity bg+color appended per case).
const BADGE_LAYOUT =
'inline-flex items-center justify-center min-w-[18px] px-[5px] h-[14px] rounded-[7px] ' +
'text-[10px] font-semibold [background:rgba(255,255,255,0.08)] [color:var(--chrome-fg)] ' +
'[font-family:var(--chrome-font-mono)]';
'text-[10px] font-semibold [font-family:var(--chrome-font-mono)]';
const BADGE_NEUTRAL = `${BADGE_LAYOUT} [background:rgba(255,255,255,0.08)] [color:var(--chrome-fg)]`;
const BADGE_ERROR = `${BADGE_LAYOUT} [background:rgba(251,73,52,0.2)] [color:#fb4934]`;
const BADGE_WARN = `${BADGE_LAYOUT} [background:rgba(250,189,47,0.2)] [color:#fabd2f]`;
const DISCORD_BTN =
'flex items-center justify-center w-[var(--chrome-icon-btn)] h-[var(--chrome-icon-btn)] shrink-0 ' +
'rounded-[4px] bg-transparent border-0 cursor-pointer [color:#7289da] opacity-60 ' +
'transition-[color,opacity,transform] duration-150 hover:opacity-100 hover:[color:#5865F2] hover:scale-110';
const DONATE_BTN =
'flex items-center justify-center w-[var(--chrome-icon-btn)] h-[var(--chrome-icon-btn)] shrink-0 ' +
'rounded-[4px] bg-transparent border-0 cursor-pointer [color:#d3869b] ml-[4px] ' +
'transition-[color,transform] duration-150 hover:[color:var(--chrome-accent)] hover:scale-[1.15] ' +
'[animation:heart-glow_2.5s_ease-in-out_infinite] motion-reduce:[animation:none]';
function SourcePill({ source, counts, active, onClick }) {
const hasErrors = counts.error > 0;
const hasWarns = counts.warn > 0;
// Severity color wins over active wins over muted (matches old cascade).
const colorClass = hasErrors
? '[color:#fb4934]'
: hasWarns
? '[color:#fabd2f]'
: active
? '[color:var(--chrome-accent)]'
: '[color:var(--chrome-fg-muted)]';
const activeClass = active
? '[background:var(--chrome-accent-bg)] [border-color:var(--chrome-accent-border)]'
: 'border-transparent';
return (
<button
type="button"
className={[
'logs-footer__pill',
active ? 'logs-footer__pill--active' : '',
hasErrors ? 'logs-footer__pill--error' : hasWarns ? 'logs-footer__pill--warn' : '',
]
.filter(Boolean)
.join(' ')}
className={`${PILL_BASE} ${activeClass} ${colorClass}`}
onClick={onClick}
aria-label={`${source.label} logs${hasErrors ? `, ${counts.error} errors` : hasWarns ? `, ${counts.warn} warnings` : ''}`}
>
<span className="font-medium">{source.label}</span>
{hasErrors && <span className={`${BADGE} logs-footer__badge--error`}>{counts.error}</span>}
{!hasErrors && hasWarns && (
<span className={`${BADGE} logs-footer__badge--warn`}>{counts.warn}</span>
{hasErrors && <span className={BADGE_ERROR}>{counts.error}</span>}
{!hasErrors && hasWarns && <span className={BADGE_WARN}>{counts.warn}</span>}
{!hasErrors && !hasWarns && counts.total > 0 && (
<span className={BADGE_NEUTRAL}>{counts.total}</span>
)}
{!hasErrors && !hasWarns && counts.total > 0 && <span className={BADGE}>{counts.total}</span>}
</button>
);
}
@@ -398,7 +439,7 @@ export default function LogsFooter() {
belong in always-visible chrome. */}
<button
type="button"
className="logs-footer__toggle"
className={ICON_BTN}
onClick={() => setCollapsed((c) => !c)}
title={collapsed ? t('logs.expand') : t('logs.collapse')}
aria-label={collapsed ? t('logs.expand_aria') : t('logs.collapse_aria')}
@@ -436,16 +477,16 @@ export default function LogsFooter() {
{!collapsed && (
<div className="flex items-center gap-[2px]">
<button
className="logs-footer__icon-btn"
className={ICON_BTN}
onClick={refreshAll}
disabled={loading}
title={t('logs.refresh')}
aria-label={t('logs.refresh_aria')}
>
<RefreshCw size={12} className={loading ? 'spinner' : ''} />
<RefreshCw size={12} className={loading ? 'motion-safe:animate-spin' : ''} />
</button>
<button
className="logs-footer__icon-btn"
className={ICON_BTN}
onClick={onCopy}
title={t('logs.copy_visible')}
aria-label={t('logs.copy_visible_aria')}
@@ -453,7 +494,7 @@ export default function LogsFooter() {
<Copy size={12} />
</button>
<button
className="logs-footer__icon-btn"
className={ICON_BTN}
onClick={onClear}
title={t('logs.clear')}
aria-label={t('logs.clear_aria')}
@@ -461,7 +502,7 @@ export default function LogsFooter() {
<Trash2 size={12} />
</button>
<button
className="logs-footer__icon-btn logs-footer__icon-btn--report"
className={ICON_BTN_REPORT}
onClick={onReportIssue}
title={t('logs.report_issue')}
aria-label={t('logs.report_issue_aria')}
@@ -469,7 +510,7 @@ export default function LogsFooter() {
<Bug size={12} />
</button>
<button
className="logs-footer__icon-btn"
className={ICON_BTN}
onClick={() => setCollapsed(true)}
title={t('logs.close')}
aria-label={t('logs.close_aria')}
@@ -480,7 +521,14 @@ export default function LogsFooter() {
)}
<button
type="button"
className={`logs-footer__version ${updateReady ? 'logs-footer__version--update' : ''}`}
className={
'shrink-0 px-[6px] h-[var(--chrome-icon-btn)] rounded-[4px] bg-transparent border-0 cursor-pointer ' +
'text-[11px] [font-variant-numeric:tabular-nums] tracking-[0.02em] transition-[opacity,color] duration-150 ' +
'hover:opacity-100 hover:[color:var(--chrome-accent)] hover:underline ' +
(updateReady
? 'relative opacity-100 font-semibold [color:var(--chrome-accent)]'
: 'opacity-55 [color:var(--chrome-fg)]')
}
onClick={() => useAppStore.getState().openSettingsTab?.('updates')}
title={
updateReady
@@ -503,12 +551,17 @@ export default function LogsFooter() {
}
>
v{APP_VERSION}
{updateReady && <span className="logs-footer__version-dot" aria-hidden="true" />}
{updateReady && (
<span
className="inline-block w-[6px] h-[6px] ml-[5px] rounded-full align-middle [background:var(--chrome-accent)] [animation:version-dot-pulse_2s_ease-in-out_infinite]"
aria-hidden="true"
/>
)}
</button>
<NetworkToggle />
<button
type="button"
className="logs-footer__discord"
className={DISCORD_BTN}
onClick={() => {
import('../api/external').then((m) =>
m.openExternal('https://discord.gg/bzQavDfVV9'),
@@ -523,7 +576,7 @@ export default function LogsFooter() {
</button>
<button
type="button"
className="logs-footer__discord"
className={DISCORD_BTN}
onClick={() => useAppStore.getState().setMode?.('contact')}
title={t('logs.contact', { defaultValue: 'Contact' })}
aria-label={t('logs.contact_aria', { defaultValue: 'Open the contact page' })}
@@ -532,7 +585,7 @@ export default function LogsFooter() {
</button>
<button
type="button"
className="logs-footer__donate"
className={DONATE_BTN}
onClick={() => useAppStore.getState().setMode?.('donate')}
title={t('logs.support_project')}
aria-label={t('logs.support_project_aria')}
@@ -543,7 +596,10 @@ export default function LogsFooter() {
</div>
{!collapsed && active !== 'notifications' && (
<div ref={scrollRef} className="logs-footer__body">
<div
ref={scrollRef}
className="logs-footer__body flex-1 min-h-0 overflow-y-auto overflow-x-auto py-[4px] px-[8px] select-text"
>
{current.length === 0 && (
<div className="p-[12px] [color:var(--chrome-fg-dim)] not-italic text-[11px] text-center">
{active === 'frontend' ? t('logs.empty_frontend_short') : t('logs.empty_lines')}
@@ -551,15 +607,29 @@ export default function LogsFooter() {
)}
{current.map((line, i) => {
const level = classifyLine(line);
const lineBg =
level === 'error'
? '[background:rgba(251,73,52,0.06)]'
: level === 'warn'
? '[background:rgba(250,189,47,0.04)]'
: '';
const lineColor =
level === 'error'
? '[color:#fb4934]'
: level === 'warn'
? '[color:#fabd2f]'
: '[color:#ebdbb2]';
return (
<div
key={i}
className={`flex items-start gap-[6px] px-[2px] py-[1px] rounded-[2px] logs-footer__line--${level}`}
className={`flex items-start gap-[6px] px-[2px] py-[1px] rounded-[2px] ${lineBg}`}
>
<span className="pt-[2px] shrink-0">
<SeverityIcon level={level} />
</span>
<pre className="logs-footer__line-text m-0 p-0 [font-family:inherit] text-[11.5px] leading-[1.5] whitespace-pre-wrap [word-break:break-word] [color:#ebdbb2] flex-1">
<pre
className={`m-0 p-0 [font-family:inherit] text-[11.5px] leading-[1.5] whitespace-pre-wrap [word-break:break-word] flex-1 ${lineColor}`}
>
{typeof line === 'string' ? line : JSON.stringify(line)}
</pre>
</div>
@@ -569,50 +639,65 @@ export default function LogsFooter() {
)}
{!collapsed && active === 'notifications' && (
<div className="logs-footer__body flex flex-col gap-[2px] px-[8px] py-[6px]">
<div className="logs-footer__body flex flex-col gap-[2px] px-[8px] py-[6px] flex-1 min-h-0 overflow-y-auto select-text">
{notifications.length === 0 ? (
<div className="p-[12px] [color:var(--chrome-fg-dim)] not-italic text-[11px] text-center">
{t('logs.all_clear')}
</div>
) : (
notifications.map((notif) => (
<div
key={notif.id}
className={`flex gap-[8px] items-start px-[10px] py-[8px] rounded-md [background:rgba(255,255,255,0.02)] [border:1px_solid_transparent] hover:[background:rgba(255,255,255,0.04)] logs-footer__notif-item--${notif.level} ${notif.action ? 'logs-footer__notif-item--clickable' : ''}`}
onClick={() => {
if (!notif.action) return;
// Acting on the crash notice acknowledges it — the backend
// stores the seen crash-log size so it doesn't re-fire
// every session until a NEW crash grows the log.
if (notif.id === 'crash-last-session') {
import('../api/client')
.then(({ apiFetch }) => apiFetch('/system/crash/ack', { method: 'POST' }))
.catch(() => {});
}
if (notif.action.type === 'navigate') {
useAppStore.getState().setMode?.(notif.action.target);
setCollapsed(true);
} else if (notif.action.type === 'link') {
import('../api/external').then((m) => m.openExternal(notif.action.target));
}
}}
role={notif.action ? 'button' : undefined}
tabIndex={notif.action ? 0 : undefined}
>
<span className="shrink-0 mt-[2px]">
<SeverityIcon level={notif.level} />
</span>
<div className="logs-footer__notif-content flex flex-col gap-[2px] flex-1 min-w-0">
<strong>{notif.title}</strong>
<span className="text-[11px] text-fg-muted leading-[1.5]">{notif.message}</span>
</div>
{notif.action && (
<span className="shrink-0 text-[11px] font-semibold text-brand whitespace-nowrap">
{notif.action.label}
notifications.map((notif) => {
// Severity border-left (literal colors, fixed across themes) +
// 1px transparent on the other sides for layout parity.
const sides =
'[border-top:1px_solid_transparent] [border-right:1px_solid_transparent] [border-bottom:1px_solid_transparent]';
const notifBorder =
notif.level === 'error'
? `[border-left:2px_solid_#fb4934] ${sides}`
: notif.level === 'warn'
? `[border-left:2px_solid_#fabd2f] ${sides}`
: `[border-left:2px_solid_#83a598] ${sides}`;
const notifHover = notif.action
? 'cursor-pointer hover:[background:rgba(255,255,255,0.06)]'
: 'hover:[background:rgba(255,255,255,0.04)]';
return (
<div
key={notif.id}
className={`flex gap-[8px] items-start px-[10px] py-[8px] rounded-md [background:rgba(255,255,255,0.02)] ${notifBorder} ${notifHover}`}
onClick={() => {
if (!notif.action) return;
// Acting on the crash notice acknowledges it — the backend
// stores the seen crash-log size so it doesn't re-fire
// every session until a NEW crash grows the log.
if (notif.id === 'crash-last-session') {
import('../api/client')
.then(({ apiFetch }) => apiFetch('/system/crash/ack', { method: 'POST' }))
.catch(() => {});
}
if (notif.action.type === 'navigate') {
useAppStore.getState().setMode?.(notif.action.target);
setCollapsed(true);
} else if (notif.action.type === 'link') {
import('../api/external').then((m) => m.openExternal(notif.action.target));
}
}}
role={notif.action ? 'button' : undefined}
tabIndex={notif.action ? 0 : undefined}
>
<span className="shrink-0 mt-[2px]">
<SeverityIcon level={notif.level} />
</span>
)}
</div>
))
<div className="flex flex-col gap-[2px] flex-1 min-w-0">
<strong className="text-[12px] [color:var(--color-fg)]">{notif.title}</strong>
<span className="text-[11px] text-fg-muted leading-[1.5]">{notif.message}</span>
</div>
{notif.action && (
<span className="shrink-0 text-[11px] font-semibold text-brand whitespace-nowrap">
{notif.action.label}
</span>
)}
</div>
);
})
)}
</div>
)}
-349
View File
@@ -1,349 +0,0 @@
/* ── Stories / Audiobook Editor ─────────────────────────────────────── */
/* Layout/spacing/typography for the editor shell, header, panels, stats and
the cast/split rows now live as Tailwind utilities in StoriesEditor.jsx.
What remains here is the hard-to-express stuff: the track grid + its
hover/active/drag combinators, native control chrome (textarea/select/range/
buttons + their focus/hover/disabled states), pseudo-element scrollbars, the
per-character color palette attribute selectors, and the chapter bar. */
.stories-editor__title {
font-family: var(--font-serif);
font-size: var(--text-xl);
font-weight: var(--weight-semibold);
color: var(--color-fg);
margin: 0;
display: flex;
align-items: center;
gap: 8px;
}
/* ── Track list ───────────────────────────────────────────────────── */
.stories-editor__tracks {
flex: 1;
display: flex;
flex-direction: column;
gap: 6px;
overflow-y: auto;
padding-right: 4px;
}
.stories-editor__tracks::-webkit-scrollbar { width: 6px; }
.stories-editor__tracks::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.08);
border-radius: 3px;
}
/* ── Single track row ─────────────────────────────────────────────── */
.stories-track {
display: grid;
grid-template-columns: 32px 1fr 160px 100px 44px;
gap: 8px;
align-items: center;
padding: 8px 10px;
background: var(--color-bg-elev-1);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
transition: border-color 0.15s, box-shadow 0.15s;
cursor: grab;
}
.stories-track:hover {
border-color: var(--color-border-strong);
box-shadow: var(--shadow-sm);
}
.stories-track--active {
border-color: var(--color-brand);
box-shadow: 0 0 0 1px var(--color-brand-glow);
}
.stories-track--narrator {
border-left: 3px solid var(--color-accent);
}
/* Drag handle */
.stories-track__grip {
display: flex;
align-items: center;
justify-content: center;
color: var(--color-fg-subtle);
cursor: grab;
}
.stories-track__grip:active {
cursor: grabbing;
}
/* Text area */
.stories-track__text {
width: 100%;
background: var(--color-bg-elev-2);
border: 1px solid transparent;
border-radius: var(--radius-md);
color: var(--color-fg);
font-family: var(--font-sans);
font-size: var(--text-sm);
padding: 6px 8px;
resize: none;
min-height: 36px;
line-height: 1.5;
transition: border-color 0.15s;
}
.stories-track__text:focus {
border-color: var(--color-brand);
outline: none;
}
/* Voice selector */
.stories-track__voice-select {
flex: 1;
background: var(--color-bg-elev-2);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
color: var(--color-fg);
font-size: var(--text-xs);
padding: 4px 6px;
font-family: var(--font-sans);
}
/* Character tag */
.stories-track__character {
font-size: var(--text-xs);
color: var(--color-fg-muted);
background: var(--color-bg-elev-2);
border: 1px solid var(--color-border);
border-radius: var(--radius-pill);
padding: 2px 8px;
text-align: center;
max-width: 100px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Track actions */
.stories-track__actions {
display: flex;
gap: 4px;
}
.stories-track__btn {
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
background: none;
border: none;
color: var(--color-fg-subtle);
cursor: pointer;
border-radius: var(--radius-sm);
transition: color 0.15s, background 0.15s;
padding: 0;
}
.stories-track__btn:hover {
color: var(--color-fg);
background: rgba(255, 255, 255, 0.06);
}
.stories-track__btn--delete:hover {
color: var(--color-danger);
}
/* ── Character color palette ──────────────────────────────────────── */
.stories-track__voice-dot[data-char="narrator"] { background: var(--color-accent); }
.stories-track__voice-dot[data-char="char-0"] { background: #d3869b; }
.stories-track__voice-dot[data-char="char-1"] { background: #83a598; }
.stories-track__voice-dot[data-char="char-2"] { background: #b8bb26; }
.stories-track__voice-dot[data-char="char-3"] { background: #fabd2f; }
.stories-track__voice-dot[data-char="char-4"] { background: #fe8019; }
.stories-track__voice-dot[data-char="char-5"] { background: #8ec07c; }
/* ── Paste & auto-split panel ────────────────────────────────────── */
.stories-editor__split-text {
width: 100%;
min-height: 96px;
padding: 8px 10px;
background: var(--color-bg-elev-2);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
color: var(--color-fg);
font-family: var(--font-sans);
font-size: var(--text-sm);
resize: vertical;
}
.stories-editor__split-num {
width: 64px;
padding: 4px 6px;
background: var(--color-bg-elev-2);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
color: var(--color-fg);
font-family: var(--font-mono);
font-size: var(--text-xs);
}
/* ── Cast panel ───────────────────────────────────────────────────── */
.stories-cast__add {
display: inline-flex;
align-items: center;
gap: 4px;
background: none;
border: 1px solid var(--color-border);
color: var(--color-fg);
font-family: inherit;
font-size: var(--text-xs);
padding: 3px 8px;
border-radius: var(--radius-sm);
cursor: pointer;
}
.stories-cast__add:hover { border-color: var(--color-accent); color: var(--color-accent); }
.stories-cast__name {
flex: 0 0 140px;
background: var(--color-bg, rgba(0, 0, 0, 0.2));
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
color: var(--color-fg);
font-family: inherit;
font-size: var(--text-xs);
padding: 4px 8px;
}
.stories-cast__select { flex: 1; }
.stories-cast__del {
background: none;
border: none;
color: var(--color-fg-subtle);
cursor: pointer;
width: 22px;
height: 22px;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-sm);
}
.stories-cast__del:hover:not(:disabled) { color: #fb4934; background: rgba(255, 255, 255, 0.06); }
.stories-cast__del:disabled { opacity: 0.35; cursor: not-allowed; }
/* ── Drag-to-reorder ──────────────────────────────────────────────── */
.stories-track { cursor: grab; flex-wrap: wrap; }
.stories-track--dragover { box-shadow: inset 0 2px 0 0 var(--color-accent, #b8bb26); }
/* ── Per-line tone/speed drawer ───────────────────────────────────── */
.stories-track__btn--on { color: var(--color-accent); background: rgba(255, 255, 255, 0.06); }
.stories-track__tone {
display: inline-flex;
align-items: center;
gap: 4px;
background: var(--color-bg-elev-2);
border: 1px solid var(--color-border);
border-radius: 999px;
color: var(--color-fg);
font-family: inherit;
font-size: var(--text-xs);
padding: 3px 9px;
cursor: pointer;
}
.stories-track__tone:hover { border-color: var(--color-accent); color: var(--color-accent); }
.stories-track__speed {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: var(--text-xs);
color: var(--color-fg-subtle);
}
.stories-track__speed input[type="range"] { width: 120px; }
.stories-track__speed-val { font-family: var(--font-mono); color: var(--color-fg); min-width: 44px; }
.stories-track__reset {
background: none;
border: 1px solid var(--color-border);
color: var(--color-fg-subtle);
font-family: inherit;
font-size: var(--text-xs);
padding: 2px 8px;
border-radius: var(--radius-sm);
cursor: pointer;
}
.stories-track__reset:hover { color: var(--color-fg); }
/* ── Projects panel ───────────────────────────────────────────────── */
.stories-proj__open {
flex: 1;
display: inline-flex;
align-items: center;
gap: 6px;
background: none;
border: none;
color: var(--color-fg);
font-family: inherit;
font-size: var(--text-xs);
text-align: left;
cursor: pointer;
padding: 4px 2px;
}
.stories-proj__open:hover { color: var(--color-accent); }
.stories-proj--current { background: rgba(184, 187, 38, 0.08); border-radius: var(--radius-sm); }
/* ── Export format selector ───────────────────────────────────────── */
/* Inherits the canonical chrome look from .input-base; only override the
toolbar-select sizing (input-base is full-width by default). */
.stories-editor__format {
width: auto;
font-size: var(--text-xs);
padding: 3px 6px;
}
/* ── Redesign: grouped toolbar, chapter bars, readable column ──────── */
/* 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-elev-2);
}
.stories-chapter--dragover { outline: 1px dashed var(--color-accent); outline-offset: 2px; }
.stories-chapter__grip { display: flex; color: var(--color-fg-subtle); 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-subtle); font-weight: 600; }
.stories-chapter__del {
flex: none;
display: flex;
padding: 4px;
border-radius: 6px;
background: none;
border: none;
color: var(--color-fg-subtle);
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; }
+77 -47
View File
@@ -61,7 +61,21 @@ import { storyToSpans } from '../utils/storyToSpans';
import { consumeLongformStream } from '../utils/longformStream';
import { reorder } from '../utils/storyReorder';
import { effectiveProfile, effectiveSpeed, castMember, nextCastColor } from '../utils/storyCast';
import './StoriesEditor.css';
// ── Shared class strings (replacing the old stories-* BEM chrome) ─────────
const ADD_BTN =
'inline-flex items-center gap-[4px] bg-transparent border border-border text-fg [font-size:var(--text-xs)] px-[8px] py-[3px] rounded-sm cursor-pointer hover:border-accent hover:text-accent';
const NAME_INPUT =
'bg-bg-elev-2 border border-border rounded-sm text-fg [font-size:var(--text-xs)] px-[8px] py-[4px]';
const SELECT_CHROME =
'bg-bg-elev-2 border border-border rounded-md text-fg [font-size:var(--text-xs)] px-[6px] py-[4px] [font-family:var(--font-sans)] [color-scheme:dark]';
const DEL_BTN =
'bg-transparent text-fg-subtle cursor-pointer w-[22px] h-[22px] flex items-center justify-center rounded-sm hover:enabled:text-danger hover:enabled:bg-white/[0.06] disabled:opacity-35 disabled:cursor-not-allowed';
const RESET_BTN =
'bg-transparent border border-border text-fg-subtle [font-size:var(--text-xs)] px-[8px] py-[2px] rounded-sm cursor-pointer hover:text-fg';
const SPEED_RANGE = 'w-[120px]';
const TRACK_BTN =
'w-[20px] h-[20px] flex items-center justify-center bg-transparent text-fg-subtle cursor-pointer rounded-sm [transition:color_0.15s,background_0.15s] p-0 hover:bg-white/[0.06]';
// Trigger a browser download for a Blob.
function download(blob, filename) {
@@ -611,7 +625,7 @@ export default function StoriesEditor({ profiles = [] }) {
{/* Header / toolbar */}
<div className="flex items-center justify-between gap-[12px]">
<div>
<h2 className="stories-editor__title">
<h2 className="font-serif [font-size:var(--text-xl)] [font-weight:var(--weight-semibold)] text-fg m-0 flex items-center gap-[8px]">
<BookOpen size={18} /> {t('stories.title')}
</h2>
<p className="text-fg-muted [font-size:var(--text-sm)]">{t('stories.subtitle')}</p>
@@ -690,7 +704,7 @@ export default function StoriesEditor({ profiles = [] }) {
per-line override. */}
<div className="inline-flex items-center gap-[4px]">
<label
className="stories-editor__speed"
className="inline-flex items-center gap-[8px] [font-size:var(--text-xs)] text-fg-subtle"
title={t('stories.global_speed_hint', {
defaultValue: 'Reading speed for all lines without their own speed override',
})}
@@ -704,14 +718,13 @@ export default function StoriesEditor({ profiles = [] }) {
value={globalSpeed}
onChange={(e) => setGlobalSpeed(parseFloat(e.target.value))}
aria-label={t('stories.global_speed', { defaultValue: 'Global reading speed' })}
className={SPEED_RANGE}
/>
<span className="stories-editor__speed-val">{globalSpeed.toFixed(2)}×</span>
<span className="[font-family:var(--font-mono)] text-fg min-w-[44px]">
{globalSpeed.toFixed(2)}×
</span>
{globalSpeed !== 1 && (
<button
type="button"
className="stories-track__reset"
onClick={() => setGlobalSpeed(1)}
>
<button type="button" className={RESET_BTN} onClick={() => setGlobalSpeed(1)}>
{t('stories.reset')}
</button>
)}
@@ -735,7 +748,7 @@ export default function StoriesEditor({ profiles = [] }) {
<Layers size={13} /> {t('stories.stems')}
</Button>
<select
className="input-base stories-editor__format"
className="input-base w-auto [font-size:var(--text-xs)] px-[6px] py-[3px]"
value={exportFormat}
onChange={(e) => setExportFormat(e.target.value)}
aria-label={t('stories.format')}
@@ -762,13 +775,13 @@ export default function StoriesEditor({ profiles = [] }) {
<span className="[font-size:var(--text-xs)] font-semibold uppercase tracking-[0.06em] text-accent">
{t('stories.projects')}
</span>
<button type="button" className="stories-cast__add" onClick={newStory}>
<button type="button" className={ADD_BTN} onClick={newStory}>
<Plus size={12} /> {t('stories.newStory')}
</button>
</div>
<div className="flex items-center gap-[8px]">
<input
className="stories-cast__name flex-1"
className={`${NAME_INPUT} flex-1`}
value={projectName}
onChange={(e) => setProjectName(e.target.value)}
placeholder={t('stories.untitled')}
@@ -781,18 +794,18 @@ export default function StoriesEditor({ profiles = [] }) {
{storyProjects.map((p) => (
<div
key={p.id}
className={`flex items-center gap-[8px] ${p.id === currentProjectId ? 'stories-proj--current' : ''}`}
className={`flex items-center gap-[8px] ${p.id === currentProjectId ? 'bg-[rgba(184,187,38,0.08)] rounded-sm' : ''}`}
>
<button
type="button"
className="stories-proj__open"
className="flex-1 inline-flex items-center gap-[6px] bg-transparent text-fg [font-size:var(--text-xs)] text-left cursor-pointer px-[2px] py-[4px] hover:text-accent"
onClick={() => openProject(p.id)}
>
<Folder size={12} /> {p.name}
</button>
<button
type="button"
className="stories-cast__del"
className={DEL_BTN}
onClick={() => deleteProject(p.id)}
aria-label={t('stories.deleteProject')}
>
@@ -819,7 +832,7 @@ export default function StoriesEditor({ profiles = [] }) {
<span className="[font-size:var(--text-xs)] font-semibold uppercase tracking-[0.06em] text-accent">
{t('stories.castTitle')}
</span>
<button type="button" className="stories-cast__add" onClick={addCharacter}>
<button type="button" className={ADD_BTN} onClick={addCharacter}>
<Plus size={12} /> {t('stories.addCharacter')}
</button>
</div>
@@ -830,13 +843,13 @@ export default function StoriesEditor({ profiles = [] }) {
style={{ background: c.color }}
/>
<input
className="stories-cast__name"
className={`${NAME_INPUT} flex-[0_0_140px]`}
value={c.name}
onChange={(e) => upsertCastMember({ ...c, name: e.target.value })}
aria-label={t('stories.characterName')}
/>
<select
className="stories-cast__select"
className={`${SELECT_CHROME} flex-1`}
value={c.profileId || ''}
onChange={(e) => setCharacterVoice(c.id, e.target.value || null)}
aria-label={`${c.name} ${t('stories.voice')}`}
@@ -850,7 +863,7 @@ export default function StoriesEditor({ profiles = [] }) {
</select>
<button
type="button"
className="stories-cast__del"
className={DEL_BTN}
onClick={() => deleteCharacter(c.id)}
disabled={c.id === 'narrator'}
title={
@@ -878,7 +891,7 @@ export default function StoriesEditor({ profiles = [] }) {
aria-label={t('stories.pasteSplit')}
>
<textarea
className="stories-editor__split-text"
className="w-full min-h-[96px] px-[10px] py-[8px] bg-bg-elev-2 border border-border rounded-sm text-fg [font-family:var(--font-sans)] [font-size:var(--text-sm)] resize-y"
placeholder={t('stories.splitPlaceholder')}
value={splitText}
onChange={(e) => setSplitText(e.target.value)}
@@ -895,7 +908,7 @@ export default function StoriesEditor({ profiles = [] }) {
step={10}
value={splitMax}
onChange={(e) => setSplitMax(parseInt(e.target.value, 10) || 180)}
className="stories-editor__split-num"
className="w-[64px] px-[6px] py-[4px] bg-bg-elev-2 border border-border rounded-sm text-fg [font-family:var(--font-mono)] [font-size:var(--text-xs)]"
/>
</label>
<span className="flex-1 [font-size:var(--text-xs)] text-fg-subtle">
@@ -943,7 +956,7 @@ export default function StoriesEditor({ profiles = [] }) {
</Button>
</div>
) : (
<div className="stories-editor__tracks" role="list">
<div className="flex-1 flex flex-col gap-[6px] overflow-y-auto pr-[4px]" role="list">
{tracks.map((track) => {
const dragProps = {
draggable: true,
@@ -974,19 +987,24 @@ export default function StoriesEditor({ profiles = [] }) {
key={track.id}
role="listitem"
className={[
'stories-chapter',
dragOver === track.id ? 'stories-chapter--dragover' : '',
'group flex items-center gap-[8px] mt-[16px] mb-[4px] px-[10px] py-[7px] rounded-md [border-left:3px_solid_var(--color-accent)] bg-bg-elev-2',
dragOver === track.id
? '[outline:1px_dashed_var(--color-accent)] outline-offset-[2px]'
: '',
]
.filter(Boolean)
.join(' ')}
{...dragProps}
>
<div className="stories-chapter__grip" aria-hidden="true">
<div
className="flex text-fg-subtle cursor-grab opacity-40 group-hover:opacity-100"
aria-hidden="true"
>
<GripVertical size={14} />
</div>
<Bookmark size={15} className="stories-chapter__icon" aria-hidden="true" />
<Bookmark size={15} className="flex-none text-accent" aria-hidden="true" />
<input
className="stories-chapter__title"
className="flex-1 min-w-0 bg-transparent border-none outline-none [font-family:inherit] [font-size:0.95rem] font-bold [letter-spacing:0.01em] text-fg px-0 py-[2px] placeholder:text-fg-subtle placeholder:font-semibold"
value={title}
onChange={(e) => updateTrack(track.id, 'text', `# ${e.target.value}`)}
placeholder={t('stories.addChapter')}
@@ -994,7 +1012,7 @@ export default function StoriesEditor({ profiles = [] }) {
/>
<button
type="button"
className="stories-chapter__del"
className="flex-none flex p-[4px] rounded-[6px] bg-transparent border-none text-fg-subtle cursor-pointer opacity-0 group-hover:opacity-70 hover:opacity-100 hover:text-danger"
onClick={(e) => {
e.stopPropagation();
removeTrack(track.id);
@@ -1016,22 +1034,29 @@ export default function StoriesEditor({ profiles = [] }) {
key={track.id}
role="listitem"
className={[
'stories-track',
activeTrack === track.id ? 'stories-track--active' : '',
track.character === 'narrator' ? 'stories-track--narrator' : '',
dragOver === track.id ? 'stories-track--dragover' : '',
'group grid [grid-template-columns:32px_1fr_160px_100px_44px] gap-[8px] items-center px-[10px] py-[8px] bg-bg-elev-1 border border-border rounded-lg [transition:border-color_0.15s,box-shadow_0.15s] cursor-grab flex-wrap hover:border-border-strong hover:[box-shadow:var(--shadow-sm)]',
activeTrack === track.id
? 'border-brand [box-shadow:0_0_0_1px_var(--color-brand-glow)]'
: '',
track.character === 'narrator'
? '[border-left:3px_solid_var(--color-accent)]'
: '',
dragOver === track.id ? '[box-shadow:inset_0_2px_0_0_var(--color-accent)]' : '',
]
.filter(Boolean)
.join(' ')}
onClick={() => setActiveTrack(track.id)}
{...dragProps}
>
<div className="stories-track__grip" aria-hidden="true">
<div
className="flex items-center justify-center text-fg-subtle cursor-grab active:cursor-grabbing"
aria-hidden="true"
>
<GripVertical size={14} />
</div>
<textarea
className="stories-track__text"
className="w-full bg-bg-elev-2 border border-transparent rounded-md text-fg [font-family:var(--font-sans)] [font-size:var(--text-sm)] px-[8px] py-[6px] resize-none min-h-[36px] leading-[1.5] [transition:border-color_0.15s] focus:border-brand focus:outline-none"
ref={(el) => {
if (el) trackTextRefs.current.set(track.id, el);
else trackTextRefs.current.delete(track.id);
@@ -1045,11 +1070,11 @@ export default function StoriesEditor({ profiles = [] }) {
<div className="flex items-center gap-[6px]">
<span
className="stories-track__voice-dot w-[10px] h-[10px] rounded-full shrink-0"
className="w-[10px] h-[10px] rounded-full shrink-0"
style={{ background: member ? member.color : '#a89984' }}
/>
<select
className="stories-track__voice-select"
className={`${SELECT_CHROME} flex-1`}
value={track.character}
onChange={(e) => updateTrack(track.id, 'character', e.target.value)}
aria-label={t('stories.character')}
@@ -1063,7 +1088,7 @@ export default function StoriesEditor({ profiles = [] }) {
</div>
<select
className="stories-track__character"
className="[font-size:var(--text-xs)] text-fg-muted bg-bg-elev-2 border border-border [border-radius:var(--radius-pill)] px-[8px] py-[2px] text-center max-w-[100px] overflow-hidden text-ellipsis whitespace-nowrap [color-scheme:dark]"
value={track.profileId || ''}
onChange={(e) => updateTrack(track.id, 'profileId', e.target.value || null)}
aria-label={t('stories.voice')}
@@ -1078,7 +1103,11 @@ export default function StoriesEditor({ profiles = [] }) {
))}
</select>
<div className="stories-track__actions">
<div
className={`flex gap-[4px] [transition:opacity_0.12s_ease] ${
activeTrack === track.id ? 'opacity-100' : 'opacity-50 group-hover:opacity-100'
}`}
>
<Menu
placement="bottom-end"
items={[
@@ -1098,7 +1127,7 @@ export default function StoriesEditor({ profiles = [] }) {
]}
>
<button
className="stories-track__btn"
className={`${TRACK_BTN} hover:text-fg`}
onClick={(e) => e.stopPropagation()}
title={t('stories.inlineVoiceHint')}
aria-label={t('stories.inlineVoice')}
@@ -1107,7 +1136,7 @@ export default function StoriesEditor({ profiles = [] }) {
</button>
</Menu>
<button
className={`stories-track__btn ${expandedLine === track.id ? 'stories-track__btn--on' : ''}`}
className={`${TRACK_BTN} hover:text-fg ${expandedLine === track.id ? 'text-accent bg-white/[0.06]' : ''}`}
onClick={(e) => {
e.stopPropagation();
setExpandedLine((id) => (id === track.id ? null : track.id));
@@ -1118,7 +1147,7 @@ export default function StoriesEditor({ profiles = [] }) {
<SlidersHorizontal size={12} />
</button>
<button
className="stories-track__btn"
className={`${TRACK_BTN} hover:text-fg`}
onClick={(e) => {
e.stopPropagation();
insertPauseInto(track.id);
@@ -1129,7 +1158,7 @@ export default function StoriesEditor({ profiles = [] }) {
<PauseIcon size={12} />
</button>
<button
className="stories-track__btn"
className={`${TRACK_BTN} hover:text-fg`}
onClick={(e) => {
e.stopPropagation();
previewTrack(track);
@@ -1141,7 +1170,7 @@ export default function StoriesEditor({ profiles = [] }) {
{track.generating ? <Mic size={12} className="spinner" /> : <Play size={12} />}
</button>
<button
className="stories-track__btn stories-track__btn--delete"
className={`${TRACK_BTN} hover:text-danger`}
onClick={(e) => {
e.stopPropagation();
removeTrack(track.id);
@@ -1163,7 +1192,7 @@ export default function StoriesEditor({ profiles = [] }) {
<button
key={tn.tag}
type="button"
className="stories-track__tone"
className="inline-flex items-center gap-[4px] bg-bg-elev-2 border border-border rounded-full text-fg [font-size:var(--text-xs)] px-[9px] py-[3px] cursor-pointer hover:border-accent hover:text-accent"
onClick={() => insertTokenInto(track.id, tn.tag)}
title={tn.tag}
>
@@ -1171,7 +1200,7 @@ export default function StoriesEditor({ profiles = [] }) {
</button>
))}
</div>
<label className="stories-track__speed">
<label className="inline-flex items-center gap-[8px] [font-size:var(--text-xs)] text-fg-subtle">
<span>{t('stories.speed')}</span>
<input
type="range"
@@ -1181,14 +1210,15 @@ export default function StoriesEditor({ profiles = [] }) {
value={track.speed || 1}
onChange={(e) => updateTrack(track.id, 'speed', parseFloat(e.target.value))}
aria-label={t('stories.speed')}
className={SPEED_RANGE}
/>
<span className="stories-track__speed-val">
<span className="[font-family:var(--font-mono)] text-fg min-w-[44px]">
{(track.speed || 1).toFixed(2)}×
</span>
{track.speed != null && (
<button
type="button"
className="stories-track__reset"
className={RESET_BTN}
onClick={() => updateTrack(track.id, 'speed', null)}
>
{t('stories.reset')}
@@ -12,7 +12,6 @@ import { facetLabel } from './constants';
export default function ArchetypeCard({
a,
t,
viewMode,
isFavorite,
isPlaying,
isLoadingPreview,
@@ -32,11 +31,18 @@ export default function ArchetypeCard({
? facetLabel(a.facets.accent)
: dialect || (a.language === 'Chinese' ? 'Chinese' : null);
const cardBase =
'group relative flex flex-col gap-[10px] p-[13px] rounded-[13px] ' +
'bg-[linear-gradient(180deg,rgba(255,255,255,0.038),rgba(255,255,255,0.012))] border ' +
'transition-[transform,border-color,box-shadow] duration-150 ' +
'hover:-translate-y-[2px] hover:shadow-[0_6px_22px_rgba(0,0,0,0.4)] ' +
'motion-reduce:transition-none motion-reduce:hover:translate-y-0';
const cardState = isPlaying
? 'border-[color:var(--card-accent)] shadow-[0_0_0_1px_var(--card-accent),0_6px_22px_rgba(0,0,0,0.4)]'
: 'border-white/[0.07] hover:border-white/[0.13]';
return (
<div
className={`archetype-card ${viewMode} ${isPlaying ? 'playing' : ''}`}
style={{ '--card-accent': color }}
>
<div className={`${cardBase} ${cardState}`} style={{ '--card-accent': color }}>
<div className="flex items-center gap-[10px]">
<ArchetypeAvatar item={a} />
<div className="flex-1 min-w-0">
@@ -50,7 +56,9 @@ export default function ArchetypeCard({
)}
</div>
<button
className={`fav-btn ${isFavorite ? 'on' : ''}`}
className={`flex-shrink-0 flex items-center justify-center w-[26px] h-[26px] rounded-[7px] cursor-pointer transition-colors hover:bg-white/[0.05] ${
isFavorite ? 'text-[#fabd2f]' : 'text-[var(--text-secondary)] hover:text-[#fabd2f]'
}`}
onClick={() => onToggleFavorite(a.id)}
title={t('gallery.favorite', { defaultValue: 'Favorite' })}
>
@@ -62,13 +70,13 @@ export default function ArchetypeCard({
same height and the action rows align across the grid. */}
<div className="flex flex-wrap items-center gap-[5px] min-h-[21px]">
{accentLabel && (
<span className="facet-chip with-flag">
<span className="inline-flex items-center gap-[5px] pl-[5px] pr-[8px] py-[2px] rounded-[7px] bg-white/[0.05] text-[var(--text-secondary)] text-[0.64rem] leading-[1.6]">
<AccentFlag accent={a.facets.accent} lang={a.language} size={14} />
{accentLabel}
</span>
)}
{a.facets.whisper && (
<span className="facet-chip">
<span className="inline-flex items-center gap-[5px] px-[8px] py-[2px] rounded-[7px] bg-white/[0.05] text-[var(--text-secondary)] text-[0.64rem] leading-[1.6]">
{t('archetypes.facet_whisper', { defaultValue: 'Whisper' })}
</span>
)}
@@ -76,7 +84,7 @@ export default function ArchetypeCard({
<div className="flex items-center gap-[6px] mt-auto">
<button
className="preview-btn"
className="inline-flex items-center gap-[6px] px-[11px] py-[6px] border border-white/[0.09] bg-white/[0.03] text-[var(--text-primary)] rounded-[8px] text-[0.7rem] cursor-pointer transition-colors hover:border-[color:var(--card-accent)] hover:text-[var(--card-accent)]"
onClick={() => onPreview(a)}
title={t('gallery.preview', { defaultValue: 'Preview' })}
>
@@ -89,11 +97,14 @@ export default function ArchetypeCard({
)}
<span>{t('gallery.preview', { defaultValue: 'Preview' })}</span>
</button>
<button className="use-btn" onClick={() => onUse(a)}>
<button
className="flex-1 inline-flex items-center justify-center gap-[6px] px-[10px] py-[6px] rounded-[8px] border border-[color:color-mix(in_srgb,var(--card-accent)_36%,transparent)] bg-[color-mix(in_srgb,var(--card-accent)_13%,transparent)] text-[var(--card-accent)] text-[0.72rem] font-semibold cursor-pointer transition-colors hover:bg-[var(--card-accent)] hover:border-[color:var(--card-accent)] hover:text-[#1d2021] focus-visible:bg-[var(--card-accent)] focus-visible:border-[color:var(--card-accent)] focus-visible:text-[#1d2021]"
onClick={() => onUse(a)}
>
<UserPlus size={14} /> {t('gallery.use_voice', { defaultValue: 'Use voice' })}
</button>
<button
className="designer-btn"
className="inline-flex items-center justify-center w-[30px] h-[30px] flex-shrink-0 border border-white/[0.09] bg-white/[0.03] text-[var(--text-secondary)] rounded-[8px] cursor-pointer opacity-50 transition-[opacity,border-color,color] duration-150 group-hover:opacity-100 focus-visible:opacity-100 hover:text-[var(--card-accent)] hover:border-[color:var(--card-accent)]"
onClick={() => onDesign(a)}
title={t('gallery.open_designer', { defaultValue: 'Open in Designer' })}
>
@@ -1,6 +1,6 @@
import React, { useState, useMemo, useEffect } from 'react';
import { Loader, Star, RotateCcw, Grid, List } from 'lucide-react';
import { Button } from '../../ui';
import { Button, Select, Segmented } from '../../ui';
import { useArchetypeCategories, useArchetypes } from '../../api/hooks';
import { ArchetypeIcon } from '../../utils/archetypeIcons';
import { titleCase, facetLabel } from './constants';
@@ -114,36 +114,49 @@ 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-[var(--chrome-border)] 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 =
viewMode === 'grid'
? 'grid grid-cols-[repeat(auto-fill,minmax(248px,1fr))] gap-[10px]'
: 'flex flex-col gap-[6px]';
return (
<div className="gallery-content gallery-scroll">
<div className="facet-bar">
<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-[var(--chrome-border)]">
{/* Three filter lanes (categories · facets · toggles), each its own
horizontally-scrollable portion; the view toggle is pinned right. */}
<div className="facet-group facet-group--cats use-case-chips">
<button
className={`category-chip ${!filters.use_case ? 'selected' : ''}`}
<div className={`${facetGroup} flex-[2.4_1_0]`}>
<Button
variant="chip"
active={!filters.use_case}
onClick={() => setFilter('use_case', null)}
>
{t('gallery.all', { defaultValue: 'All' })}
</button>
</Button>
{categories.map((c) => (
<button
<Button
key={c.id}
className={`category-chip ${filters.use_case === c.id ? 'selected' : ''}`}
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}
>
<ArchetypeIcon name={c.icon} size={13} />
{t(`archetypes.use_${c.id}`, { defaultValue: c.name })}
</button>
</Button>
))}
</div>
<div className="facet-group facet-group--facets facet-selects">
<div
className={`${facetGroup} flex-[1.6_1_0] pl-[10px] border-l border-[var(--chrome-border)]`}
>
{['gender', 'age', 'pitch', 'accent', 'lang'].map((dim) => (
<select
<Select
key={dim}
className="facet-select"
size="sm"
value={filters[dim] ?? ''}
onChange={(e) => setFilter(dim, e.target.value || null)}
>
@@ -155,12 +168,14 @@ export default function ArchetypesZone({
{facetLabel(opt)}
</option>
))}
</select>
</Select>
))}
</div>
<div className="facet-group facet-group--toggles">
<label className="facet-toggle">
<div
className={`${facetGroup} flex-[1_1_0] pl-[10px] border-l border-[var(--chrome-border)]`}
>
<label className={facetToggle}>
<input
type="checkbox"
checked={filters.whisper === true}
@@ -168,7 +183,7 @@ export default function ArchetypesZone({
/>
{t('archetypes.facet_whisper', { defaultValue: 'Whisper' })}
</label>
<label className="facet-toggle">
<label className={facetToggle}>
<input
type="checkbox"
checked={favOnly}
@@ -176,33 +191,28 @@ export default function ArchetypesZone({
/>
<Star size={12} /> {t('gallery.favorites', { defaultValue: 'Favorites' })}
</label>
<button
className="facet-reset"
<Button
variant="ghost"
size="sm"
leading={<RotateCcw size={12} />}
onClick={() => {
resetFilters();
setFavOnly(false);
}}
>
<RotateCcw size={12} /> {t('gallery.reset', { defaultValue: 'Reset' })}
</button>
{t('gallery.reset', { defaultValue: 'Reset' })}
</Button>
</div>
<div className="view-toggle">
<button
className={viewMode === 'grid' ? 'active' : ''}
onClick={() => setViewMode('grid')}
title="Grid"
>
<Grid size={14} />
</button>
<button
className={viewMode === 'list' ? 'active' : ''}
onClick={() => setViewMode('list')}
title="List"
>
<List size={14} />
</button>
</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' },
]}
/>
</div>
{showFeatured && (
@@ -212,7 +222,7 @@ export default function ArchetypesZone({
{t('archetypes.featured', { defaultValue: 'Featured' })}
</div>
</div>
<div className={`archetype-grid ${viewMode}`}>
<div className={gridClass}>
{applyFav(featured).map((a) => (
<ArchetypeCard key={a.id} {...cardProps(a)} />
))}
@@ -230,18 +240,18 @@ export default function ArchetypesZone({
</div>
</div>
{browseQ.isLoading ? (
<div className="loading">
<div className="flex items-center justify-center p-[24px] text-[var(--text-secondary)]">
<Loader className="spin" size={18} />
</div>
) : (
<>
<div className={`archetype-grid ${viewMode}`}>
<div className={gridClass}>
{applyFav(browse).map((a) => (
<ArchetypeCard key={a.id} {...cardProps(a)} />
))}
</div>
{applyFav(browse).length === 0 && (
<div className="empty">
<div className="flex flex-col items-center justify-center px-[16px] py-[32px] text-[var(--text-secondary)] text-center">
{t('gallery.no_matches', { defaultValue: 'No voices match these filters.' })}
</div>
)}
@@ -29,8 +29,11 @@ export default function CommunityZone({
}
};
const submitBtn =
'inline-flex items-center gap-[5px] px-[10px] py-[6px] border border-white/10 bg-white/[0.03] text-[var(--text-primary)] rounded-[8px] text-[0.7rem] cursor-pointer transition-colors hover:border-[color:var(--accent)] hover:text-[var(--accent)]';
return (
<div className="gallery-content gallery-scroll">
<div className="flex-1 min-h-0 flex flex-col overflow-y-auto">
<div className="shrink-0 px-[10px] py-[8px] mb-[8px] bg-bg-elev-2 rounded-[8px] text-[0.72rem] text-[var(--text-secondary)] leading-[1.4] flex items-center justify-between gap-[12px] flex-wrap">
<span>
{t('gallery.community_explainer', {
@@ -39,34 +42,33 @@ export default function CommunityZone({
})}
</span>
<div className="flex gap-[6px] shrink-0">
<button className="submit-btn" onClick={() => submit('preset')}>
<button className={submitBtn} onClick={() => submit('preset')}>
<Send size={13} /> {t('gallery.submit_preset', { defaultValue: 'Submit a preset' })}
</button>
<button className="submit-btn" onClick={() => submit('voice')}>
<button className={submitBtn} onClick={() => submit('voice')}>
<Send size={13} /> {t('gallery.submit_voice', { defaultValue: 'Submit a voice' })}
</button>
</div>
</div>
{itemsQ.isLoading ? (
<div className="loading">
<div className="flex items-center justify-center p-[24px] text-[var(--text-secondary)]">
<Loader className="spin" size={18} />
</div>
) : items.length === 0 ? (
<div className="empty">
<div className="flex flex-col items-center justify-center px-[16px] py-[32px] text-[var(--text-secondary)] text-center">
{t('gallery.community_empty', {
defaultValue:
'No community voices loaded yet — connect to the internet and reopen, or be the first to submit one.',
})}
</div>
) : (
<div className="archetype-grid grid">
<div className="grid grid-cols-[repeat(auto-fill,minmax(248px,1fr))] gap-[10px]">
{items.map((it) => (
<ArchetypeCard
key={it.id}
a={it}
t={t}
viewMode="grid"
isFavorite={favSet.has(it.id)}
isPlaying={playingId === it.id}
isLoadingPreview={loadingPreviewId === it.id}
+26 -11
View File
@@ -213,8 +213,13 @@ export default function ImportsZone({ t, playingId, loadingPreviewId, onPlayGall
}
};
const voicePlay =
'flex items-center justify-center w-[28px] h-[28px] rounded-full border border-[var(--chrome-border)] bg-bg-elev-1 text-[var(--text-primary)] cursor-pointer flex-shrink-0 hover:bg-[var(--accent)] hover:border-[color:var(--accent)] hover:text-white';
const actionBtn =
'flex items-center justify-center w-[24px] h-[24px] bg-transparent text-[var(--text-secondary)] rounded-[4px] cursor-pointer hover:bg-bg-elev-2 hover:text-[var(--text-primary)]';
return (
<div className="gallery-content">
<div className="flex-1 min-h-0 flex flex-col overflow-hidden">
<div className="shrink-0 px-[10px] py-[8px] mb-[8px] bg-bg-elev-2 rounded-[8px] text-[0.72rem] text-[var(--text-secondary)] leading-[1.4]">
{t('gallery.import_explainer', {
defaultValue:
@@ -223,8 +228,9 @@ export default function ImportsZone({ t, playingId, loadingPreviewId, onPlayGall
</div>
<div className="shrink-0 flex flex-col gap-[10px]">
<div className="search-row flex gap-[6px]">
<div className="flex gap-[6px]">
<Input
className="flex-1"
placeholder={t('gallery.import_placeholder', {
defaultValue: 'Paste a video/audio URL, or type to search…',
})}
@@ -288,13 +294,19 @@ export default function ImportsZone({ t, playingId, loadingPreviewId, onPlayGall
count: results.length,
})}
</span>
<button className="close-btn" onClick={() => setResults([])}>
<button
className="bg-none border-none text-[var(--text-secondary)] cursor-pointer p-[2px]"
onClick={() => setResults([])}
>
<X size={14} />
</button>
</div>
<div className="overflow-y-auto flex-1">
{results.map((r, i) => (
<div key={i} className="result-row">
<div
key={i}
className="flex justify-between items-center px-[10px] py-[8px] gap-[8px] border-b border-[var(--chrome-border)] last:border-0"
>
<div className="flex-1 min-w-0 flex flex-col gap-[2px]">
<span className="text-[0.75rem] truncate">{r.title}</span>
<span className="text-[0.65rem] text-[var(--text-secondary)]">
@@ -320,11 +332,11 @@ export default function ImportsZone({ t, playingId, loadingPreviewId, onPlayGall
</div>
{voicesQ.isLoading ? (
<div className="loading">
<div className="flex items-center justify-center p-[24px] text-[var(--text-secondary)]">
<Loader className="spin" size={18} />
</div>
) : voices.length === 0 ? (
<div className="empty">
<div className="flex flex-col items-center justify-center px-[16px] py-[32px] text-[var(--text-secondary)] text-center">
{t('gallery.no_imports', {
defaultValue: 'Nothing imported yet. Paste a URL above to get started.',
})}
@@ -332,8 +344,11 @@ export default function ImportsZone({ t, playingId, loadingPreviewId, onPlayGall
) : (
<div className="flex flex-col gap-[4px] overflow-y-auto flex-1 pr-[4px]">
{voices.map((v) => (
<div key={v.id} className="voice-card">
<button className="voice-play" onClick={() => onPlayGallery(v)}>
<div
key={v.id}
className="flex items-center gap-[8px] px-[10px] py-[8px] bg-bg-elev-2 rounded-[8px] transition-colors hover:bg-bg-elev-1"
>
<button className={voicePlay} onClick={() => onPlayGallery(v)}>
{loadingPreviewId === v.id ? (
<Loader className="spin" size={16} />
) : playingId === v.id ? (
@@ -350,21 +365,21 @@ export default function ImportsZone({ t, playingId, loadingPreviewId, onPlayGall
</div>
<div className="flex gap-[3px]">
<button
className="action-btn"
className={actionBtn}
onClick={() => handleTrimClick(v)}
title={t('gallery.trim', { defaultValue: 'Trim' })}
>
<Scissors size={14} />
</button>
<button
className="action-btn"
className={actionBtn}
onClick={() => handleSaveProfile(v)}
title={t('gallery.use_voice', { defaultValue: 'Use voice' })}
>
<UserPlus size={14} />
</button>
<button
className="action-btn danger"
className="flex items-center justify-center w-[24px] h-[24px] bg-transparent text-[var(--text-secondary)] rounded-[4px] cursor-pointer hover:bg-[#3d1f1f] hover:text-[#fb4934]"
onClick={() => handleDelete(v)}
title={t('gallery.delete', { defaultValue: 'Delete' })}
>
+54 -411
View File
@@ -1,427 +1,70 @@
/* .voice-gallery / .gallery-header / .header-top → Tailwind utilities in VoiceGallery.jsx. */
/* VoiceGallery.css — trimmed to the irreducible bits after the shadcn/Tailwind
migration. Everything layout/chrome moved to Tailwind utilities in the gallery
JSX. What remains is only what isn't cleanly expressible as utilities OR is
rendered by an out-of-scope file:
.header-text h2 {
font-size: 1.1rem;
font-weight: 600;
margin: 0;
color: var(--text-primary);
}
/* .gallery-search / .search-row base → Tailwind utilities in ImportsZone.jsx. */
.search-row .input-wrapper {
flex: 1;
}
.categories-row {
display: flex;
flex-wrap: wrap;
gap: 5px;
}
.category-chip {
display: inline-flex;
align-items: center;
gap: 5px;
height: 26px;
box-sizing: border-box;
padding: 0 10px;
border-radius: var(--chrome-radius-pill, 999px);
border: 1px solid var(--chrome-border);
background: transparent;
color: var(--chrome-fg-muted);
font-size: 0.68rem;
font-weight: 500;
white-space: nowrap;
cursor: pointer;
transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease;
}
.category-chip:hover {
border-color: var(--chrome-border-strong);
color: var(--chrome-fg);
}
.category-chip.selected {
background: var(--chrome-accent, var(--accent));
border-color: var(--chrome-accent, var(--accent));
color: #1b1b1b;
}
/* .search-results-panel / .panel-header → Tailwind utilities in ImportsZone.jsx. */
.close-btn {
background: none;
border: none;
color: var(--text-secondary);
cursor: pointer;
padding: 2px;
}
/* .results-list → Tailwind utilities in ImportsZone.jsx. */
.result-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 10px;
border-bottom: 1px solid var(--chrome-border);
gap: 8px;
}
.result-row:last-child {
border: none;
}
/* .result-info / .result-title / .result-meta → Tailwind utilities in ImportsZone.jsx. */
.gallery-content {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
/* .content-header / .content-title base → Tailwind utilities in ArchetypesZone.jsx + ImportsZone.jsx. */
.content-title .count {
font-weight: 400;
color: var(--text-secondary);
margin-left: 4px;
}
.view-toggle {
display: flex;
align-items: center;
gap: 2px;
height: 26px;
box-sizing: border-box;
background: var(--chrome-hover-bg);
border: 1px solid var(--chrome-border);
border-radius: 7px;
padding: 2px;
flex: 0 0 auto;
}
.view-toggle button {
display: flex;
align-items: center;
justify-content: center;
width: 26px;
height: 100%;
border: none;
background: transparent;
color: var(--chrome-fg-muted);
border-radius: 6px;
cursor: pointer;
transition: background var(--dur-fast), color var(--dur-fast);
}
.view-toggle button:hover { color: var(--chrome-fg); }
.view-toggle button.active {
background: var(--chrome-bg);
color: var(--chrome-fg);
}
.loading {
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
color: var(--text-secondary);
}
• .spin — app-wide spinner class. NOTE: this is NOT in index.css (only
`@keyframes spin` + `.spinner` are), and it's used beyond the gallery
(e.g. src/pages/AudiobookTab.jsx), so it must live here. Do not delete.
• .now-playing equalizer (+ @keyframes eq) and the .arch-avatar / flag
classes are all rendered by src/utils/archetypeIcons.jsx (out of scope),
which ships no CSS of its own — keep them here. */
.spin {
animation: spin 1s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 32px 16px;
color: var(--text-secondary);
text-align: center;
}
.empty svg {
opacity: 0.4;
margin-bottom: 8px;
}
.empty p {
margin: 2px 0;
font-size: 0.8rem;
}
/* .voice-list base → Tailwind utilities in ImportsZone.jsx (the .grid variant + its
descendant overrides below stay in CSS). */
.voice-list.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 8px;
}
.voice-card {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
background: var(--color-bg-elev-2);
border-radius: 8px;
transition: background 0.15s ease;
}
.voice-card:hover {
background: var(--color-bg-elev-1);
}
.voice-play {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 50%;
border: 1px solid var(--chrome-border);
background: var(--color-bg-elev-1);
color: var(--text-primary);
cursor: pointer;
flex-shrink: 0;
}
.voice-play:hover {
background: var(--accent);
border-color: var(--accent);
color: #fff;
}
/* .voice-info / .voice-name / .voice-meta / .voice-actions base → Tailwind utilities
in ImportsZone.jsx (the .voice-list.grid descendant overrides below stay in CSS). */
.action-btn {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border: none;
background: transparent;
color: var(--text-secondary);
border-radius: 4px;
cursor: pointer;
}
.action-btn:hover {
background: var(--color-bg-elev-2);
color: var(--text-primary);
}
.action-btn.danger:hover {
background: #3d1f1f;
color: #fb4934;
}
.voice-list.grid .voice-card {
flex-direction: column;
align-items: stretch;
padding: 10px;
text-align: center;
}
.voice-list.grid .voice-play {
width: 100%;
height: 32px;
border-radius: 6px;
margin-bottom: 6px;
}
.voice-list.grid .voice-info {
align-items: center;
}
.voice-list.grid .voice-actions {
justify-content: center;
margin-top: 6px;
}
/* ── Voice Gallery: zone toggle + header ──────────────────────────────── */
/* .gallery-sub → Tailwind utilities in VoiceGallery.jsx. */
.zone-toggle { display: inline-flex; gap: 2px; background: var(--chrome-hover-bg); border: 1px solid var(--chrome-border); border-radius: 8px; padding: 2px; }
.zone-tab { display: inline-flex; align-items: center; gap: 5px; padding: 6px 12px; border: none; background: transparent; color: var(--chrome-fg-muted); border-radius: 6px; font-size: 0.75rem; font-weight: 500; cursor: pointer; transition: background var(--dur-fast), color var(--dur-fast); }
.zone-tab:hover { color: var(--chrome-fg); }
.zone-tab.active { background: var(--chrome-bg); color: var(--chrome-fg); }
.gallery-notice { flex-shrink: 0; padding: 7px 10px; background: var(--color-bg-elev-2); border-left: 3px solid var(--accent); border-radius: 6px; font-size: 0.75rem; color: var(--text-primary); }
/* ── Facet filter bar ─────────────────────────────────────────────────── */
/* One compact line split into three filter lanes — categories · facets ·
toggles — each its own horizontally-scrollable portion so a long lane never
pushes the others off-screen. The view toggle is pinned at the far right. */
.facet-bar {
display: flex;
flex-direction: row;
align-items: center;
gap: 10px;
flex-wrap: nowrap;
flex-shrink: 0;
padding: 2px 0 10px;
margin-bottom: 8px;
border-bottom: 1px solid var(--chrome-border);
}
.facet-group {
display: flex;
align-items: center;
gap: 5px;
flex-wrap: nowrap;
min-width: 0;
overflow-x: auto;
overflow-y: hidden;
scrollbar-width: thin;
}
.facet-group::-webkit-scrollbar { height: 5px; }
.facet-group::-webkit-scrollbar-thumb { background: var(--chrome-border-strong); border-radius: 3px; }
.facet-group::-webkit-scrollbar-track { background: transparent; }
/* Width portions: categories get the most room, facets medium, toggles least. */
.facet-group--cats { flex: 2.4 1 0; }
.facet-group--facets { flex: 1.6 1 0; padding-left: 10px; border-left: 1px solid var(--chrome-border); }
.facet-group--toggles { flex: 1 1 0; padding-left: 10px; border-left: 1px solid var(--chrome-border); }
.view-toggle { flex: 0 0 auto; }
.chip-emoji { font-size: 0.8rem; line-height: 1; }
/* Uniform height + box model for every control on the single line. */
.facet-select,
.facet-toggle,
.facet-reset {
height: 26px;
box-sizing: border-box;
display: inline-flex;
align-items: center;
gap: 5px;
padding: 0 9px;
border-radius: 7px;
font-size: 0.68rem;
white-space: nowrap;
cursor: pointer;
}
/* Native <select> falls back to the OS-default (light) control surface when its
background resolves to an undefined var, so pin real dark-chrome tokens. */
.facet-select {
border: 1px solid var(--chrome-border);
background: var(--chrome-hover-bg);
color: var(--chrome-fg);
color-scheme: dark;
}
/* Belt-and-suspenders: explicit dark option list for WebViews that ignore
color-scheme on the popup (cross-platform parity: WebKit / WebView2 / WebKitGTK). */
.facet-select option { background: var(--chrome-bg); color: var(--chrome-fg); }
/* Whisper / Favorites read as pill toggles matching the dropdowns. */
.facet-toggle {
border: 1px solid var(--chrome-border);
background: var(--chrome-hover-bg);
color: var(--chrome-fg-muted);
}
.facet-toggle:hover { color: var(--chrome-fg); border-color: var(--chrome-border-strong); }
.facet-reset {
border: 1px solid var(--chrome-border);
background: transparent;
color: var(--chrome-fg-muted);
}
.facet-reset:hover { border-color: var(--chrome-border-strong); color: var(--chrome-fg); }
/* A small gap separates the view toggle from the filter controls on the line. */
.facet-selects .view-toggle { margin-left: 2px; }
/* ── Archetype sections + grid ────────────────────────────────────────── */
.gallery-content.gallery-scroll { overflow-y: auto; }
/* .archetype-section / .count-badge → Tailwind utilities in ArchetypesZone.jsx + ImportsZone.jsx. */
.archetype-grid.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(248px, 1fr)); gap: 10px; }
.archetype-grid.list { display: flex; flex-direction: column; gap: 6px; }
/* Card surface — a real, elevated tile so cards read as cards on the dark bg. */
.archetype-card {
/* Color-coded avatar tile + flag badge (archetypeIcons.jsx → ArchetypeAvatar). */
.arch-avatar {
position: relative;
flex-shrink: 0;
display: flex;
flex-direction: column;
gap: 10px;
padding: 13px;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.038), rgba(255, 255, 255, 0.012));
border: 1px solid rgba(255, 255, 255, 0.07);
border-radius: 13px;
transition: transform 0.16s ease, border-color 0.16s ease, box-shadow 0.16s ease;
align-items: center;
justify-content: center;
border: 1px solid transparent;
border-radius: 11px;
}
.archetype-card:hover { transform: translateY(-2px); border-color: rgba(255, 255, 255, 0.13); box-shadow: 0 6px 22px rgba(0, 0, 0, 0.4); }
.archetype-card.playing { border-color: var(--card-accent, var(--accent)); box-shadow: 0 0 0 1px var(--card-accent, var(--accent)), 0 6px 22px rgba(0, 0, 0, 0.4); }
/* Header: avatar tile + title + favorite */
/* .arch-head → Tailwind utilities in ArchetypeCard.jsx. */
.arch-avatar { position: relative; flex-shrink: 0; display: flex; align-items: center; justify-content: center; border: 1px solid transparent; border-radius: 11px; }
.arch-avatar-flag { position: absolute; right: -4px; bottom: -4px; display: flex; line-height: 0; border-radius: 3px; overflow: hidden; box-shadow: 0 0 0 2px var(--bg-tertiary, #1d2021); }
.accent-flag { display: block; border-radius: 2px; }
.flag-globe { color: var(--text-secondary); }
/* .arch-title / .archetype-name / .archetype-sub → Tailwind utilities in ArchetypeCard.jsx. */
.fav-btn { flex-shrink: 0; display: flex; align-items: center; justify-content: center; width: 26px; height: 26px; border: none; background: transparent; color: var(--text-secondary); border-radius: 7px; cursor: pointer; transition: color 0.15s ease, background 0.15s ease; }
.fav-btn:hover { color: #fabd2f; background: rgba(255, 255, 255, 0.05); }
.fav-btn.on { color: #fabd2f; }
/* Facet chips (accent chip carries its flag). Always rendered (even when a
voice has no accent/whisper chip) with a reserved min-height so every card
shares the same vertical rhythm and the action rows line up across the grid
instead of floating at ragged heights. */
/* .archetype-chips → Tailwind utilities in ArchetypeCard.jsx. */
.facet-chip { display: inline-flex; align-items: center; gap: 5px; padding: 2px 8px; border-radius: 7px; background: rgba(255, 255, 255, 0.05); color: var(--text-secondary); font-size: 0.64rem; line-height: 1.6; }
.facet-chip.with-flag { padding-left: 5px; }
/* Footer actions */
/* .arch-foot → Tailwind utilities in ArchetypeCard.jsx. */
.preview-btn { display: inline-flex; align-items: center; gap: 6px; padding: 6px 11px; border: 1px solid rgba(255, 255, 255, 0.09); background: rgba(255, 255, 255, 0.03); color: var(--text-primary); border-radius: 8px; font-size: 0.7rem; cursor: pointer; transition: border-color 0.15s ease, color 0.15s ease; }
.preview-btn:hover { border-color: var(--card-accent, var(--accent)); color: var(--card-accent, var(--accent)); }
/* Primary action — tonal by default (a low-alpha wash of the card accent with
accent-colored text), going solid only on hover/focus. This keeps the grid
calm at rest — the eye lands on the voice names, not a field of saturated
color blocks — while the CTA still "lights up" on the card you're pointing
at. The per-category hue is preserved as identity, just dialed down. */
.use-btn {
flex: 1; display: inline-flex; align-items: center; justify-content: center; gap: 6px;
padding: 6px 10px; border-radius: 8px;
border: 1px solid color-mix(in srgb, var(--card-accent, var(--accent)) 36%, transparent);
background: color-mix(in srgb, var(--card-accent, var(--accent)) 13%, transparent);
color: var(--card-accent, var(--accent));
font-size: 0.72rem; font-weight: 600; cursor: pointer;
transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease;
.arch-avatar-flag {
position: absolute;
right: -4px;
bottom: -4px;
display: flex;
line-height: 0;
border-radius: 3px;
overflow: hidden;
box-shadow: 0 0 0 2px var(--bg-tertiary, #1d2021);
}
.use-btn:hover, .use-btn:focus-visible {
background: var(--card-accent, var(--accent));
border-color: var(--card-accent, var(--accent));
color: #1d2021;
.accent-flag {
display: block;
border-radius: 2px;
}
.flag-globe {
color: var(--text-secondary);
}
/* Designer/open-in-designer is a tertiary action — keep it quiet at rest and
reveal it on card hover/focus so it isn't competing on every tile. */
.designer-btn { display: inline-flex; align-items: center; justify-content: center; width: 30px; height: 30px; flex-shrink: 0; border: 1px solid rgba(255, 255, 255, 0.09); background: rgba(255, 255, 255, 0.03); color: var(--text-secondary); border-radius: 8px; cursor: pointer; opacity: 0.5; transition: opacity 0.15s ease, border-color 0.15s ease, color 0.15s ease; }
.archetype-card:hover .designer-btn, .designer-btn:focus-visible { opacity: 1; }
.designer-btn:hover { color: var(--card-accent, var(--accent)); border-color: var(--card-accent, var(--accent)); }
/* Animated "now playing" equalizer */
.now-playing { display: inline-flex; align-items: flex-end; gap: 2px; width: 15px; height: 14px; }
.now-playing i { width: 2.5px; background: currentcolor; border-radius: 1px; transform-origin: bottom; animation: eq 0.9s ease-in-out infinite; }
/* Animated "now playing" equalizer (archetypeIcons.jsx → NowPlaying). */
.now-playing {
display: inline-flex;
align-items: flex-end;
gap: 2px;
width: 15px;
height: 14px;
}
.now-playing i {
width: 2.5px;
background: currentcolor;
border-radius: 1px;
transform-origin: bottom;
animation: eq 0.9s ease-in-out infinite;
}
.now-playing i:nth-child(1) { height: 45%; animation-delay: 0s; }
.now-playing i:nth-child(2) { height: 80%; animation-delay: 0.15s; }
.now-playing i:nth-child(3) { height: 60%; animation-delay: 0.3s; }
.now-playing i:nth-child(4) { height: 95%; animation-delay: 0.45s; }
@keyframes eq { 0%, 100% { transform: scaleY(0.4); } 50% { transform: scaleY(1); } }
@media (prefers-reduced-motion: reduce) { .now-playing i { animation: none; } .archetype-card { transition: none; } .archetype-card:hover { transform: none; } }
/* Category chips: lucide icon + label */
.use-case-chips .category-chip svg { flex-shrink: 0; }
/* .load-more / .import-explainer / .community-explainer / .submit-actions
→ Tailwind utilities in ArchetypesZone.jsx, CommunityZone.jsx + ImportsZone.jsx. */
.submit-btn { display: inline-flex; align-items: center; gap: 5px; padding: 6px 10px; border: 1px solid rgba(255, 255, 255, 0.1); background: rgba(255, 255, 255, 0.03); color: var(--text-primary); border-radius: 8px; font-size: 0.7rem; cursor: pointer; transition: border-color 0.15s ease, color 0.15s ease; }
.submit-btn:hover { border-color: var(--accent); color: var(--accent); }
@keyframes eq {
0%, 100% { transform: scaleY(0.4); }
50% { transform: scaleY(1); }
}
@media (prefers-reduced-motion: reduce) {
.now-playing i { animation: none; }
}
+38 -23
View File
@@ -7,6 +7,7 @@
import React, { useState, useRef, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Sparkles, Store, Upload } from 'lucide-react';
import { Segmented } from '../ui';
import { archetypePreviewUrl, useArchetypeAsProfile } from '../api/archetypes';
import { previewVoiceUrl } from '../api/gallery';
import { useAppStore } from '../store';
@@ -146,42 +147,56 @@ export default function VoiceGallery() {
}
};
const zoneItems = [
{
value: 'archetypes',
label: (
<span className="inline-flex items-center gap-[5px]">
<Sparkles size={14} /> {t('gallery.zone_archetypes', { defaultValue: 'Archetypes' })}
</span>
),
},
{
value: 'community',
label: (
<span className="inline-flex items-center gap-[5px]">
<Store size={14} /> {t('gallery.zone_community', { defaultValue: 'Community' })}
</span>
),
},
{
value: 'imports',
label: (
<span className="inline-flex items-center gap-[5px]">
<Upload size={14} /> {t('gallery.zone_imports', { defaultValue: 'My Imports' })}
</span>
),
},
];
return (
<div className="flex flex-col gap-[12px] p-[12px] h-full overflow-hidden">
<div className="shrink-0">
<div className="flex justify-between items-center">
<div className="header-text">
<h2>{t('gallery.title', { defaultValue: 'OmniVoice Gallery' })}</h2>
<div>
<h2 className="text-[1.1rem] font-semibold m-0 text-[var(--text-primary)]">
{t('gallery.title', { defaultValue: 'OmniVoice Gallery' })}
</h2>
<p className="mt-[2px] mr-0 mb-0 ml-0 text-[0.72rem] text-[var(--text-secondary)]">
{t('gallery.subtitle', {
defaultValue: 'Hundreds of ready-made designed voices — pick one and go.',
})}
</p>
</div>
<div className="zone-toggle">
<button
className={`zone-tab ${zone === 'archetypes' ? 'active' : ''}`}
onClick={() => setZone('archetypes')}
>
<Sparkles size={14} /> {t('gallery.zone_archetypes', { defaultValue: 'Archetypes' })}
</button>
<button
className={`zone-tab ${zone === 'community' ? 'active' : ''}`}
onClick={() => setZone('community')}
>
<Store size={14} /> {t('gallery.zone_community', { defaultValue: 'Community' })}
</button>
<button
className={`zone-tab ${zone === 'imports' ? 'active' : ''}`}
onClick={() => setZone('imports')}
>
<Upload size={14} /> {t('gallery.zone_imports', { defaultValue: 'My Imports' })}
</button>
</div>
<Segmented items={zoneItems} value={zone} onChange={setZone} />
</div>
</div>
{notice && <div className="gallery-notice">{notice}</div>}
{notice && (
<div className="shrink-0 px-[10px] py-[7px] bg-bg-elev-2 border-l-[3px] border-l-[color:var(--accent)] rounded-[6px] text-[0.75rem] text-[var(--text-primary)]">
{notice}
</div>
)}
{zone === 'archetypes' ? (
<ArchetypesZone