feat(ui): rewrite workspace/voice tail components on clean shadcn, trim their CSS (fast mode) (#832)
FAST-mode shadcn/Tailwind migration of the workspace + voice "tail" components: the cleanly JSX-controlled chrome moves onto the JSX as Tailwind v4 utilities (token utilities + arbitrary var()/px to preserve exact pixels/colors), with irreducible CSS kept co-located. - WaveformPlayer: all three render branches (player, native fallback, missing notice) converted to Tailwind; WaveformPlayer.css deleted (-87). The `wf-player__btn` class is retained as the focus-visible hook for the shared a11y ring in index.css; the dead `wf-player__spin` rule + `wf-spin` keyframe + its reduced-motion block were removed. - VoicePreview: popover container/header/title/close/body/foot/hint converted to Tailwind; VoicePreview.css trimmed 87->23 lines. Kept the `voice-preview-in` entrance @keyframes (referenced via animate-[…]) and the `.voice-preview__select`/`__text` rules — they layer on top of the *unlayered* shared `.input-base`, and Tailwind utilities (in @layer utilities) would lose that cascade, so they stay unlayered. - WorkspaceHistory: finished the voice variant, which #781 left on the now-deleted `.wh`/`.wh__head`/`.wh__title`/`.wh__scroll`/`.wh__empty` classes (rendering unstyled). Converted them to the same Tailwind utilities the dub variant already uses. Kept the studio-with-history/ studio-right/shell-narrow layout + the `.studio-action-bar` sticky override (#476, guarded by workspaceHistoryReflow.test.js). - WorkspaceVoices: already fully converted by #781; its `.wv*` chrome is shared with the out-of-scope WorkspaceProjects.jsx, so the CSS stays. Verified: vite build OK, oxlint exit 0, oxfmt --check clean, 641 vitest pass (incl. workspaceHistoryReflow + waveform), 48 visual pass, bun install --frozen-lockfile clean. Eyeballed the Voice workspace (history rows + waveform players) and the VoicePreview popover in a live dev run. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
mergetest
Claude Opus 4.8
parent
8541eb75c5
commit
7264c321f8
@@ -1,63 +1,15 @@
|
||||
.voice-preview {
|
||||
position: fixed;
|
||||
/* Track the LogsFooter's live height so an expanded/resized footer never
|
||||
slides underneath the popover (LogsFooter.jsx writes the variable). */
|
||||
bottom: calc(var(--logs-footer-height, 28px) + 16px);
|
||||
right: 16px;
|
||||
z-index: 900;
|
||||
width: 320px;
|
||||
background: var(--chrome-bg);
|
||||
border: 1px solid var(--chrome-border-strong);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,0.4);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
animation: voice-preview-in 0.2s ease-out;
|
||||
}
|
||||
/* VoicePreview — all panel chrome converted to Tailwind utilities on
|
||||
VoicePreview.jsx. Two things stay here as irreducible CSS:
|
||||
1. the entrance @keyframes (referenced via animate-[…] on the popover); and
|
||||
2. the select/text rules that layer on top of the *unlayered* shared
|
||||
.input-base — Tailwind utilities live in @layer utilities and would lose
|
||||
the cascade to .input-base, so these overrides must stay unlayered. */
|
||||
|
||||
@keyframes voice-preview-in {
|
||||
from { opacity: 0; transform: translateY(12px) scale(0.96); }
|
||||
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||
}
|
||||
|
||||
.voice-preview__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 14px;
|
||||
border-bottom: 1px solid var(--chrome-border);
|
||||
}
|
||||
.voice-preview__title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--chrome-fg);
|
||||
}
|
||||
.voice-preview__close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--chrome-fg-muted);
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
border-radius: 6px;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.voice-preview__close:hover {
|
||||
background: var(--chrome-hover-bg);
|
||||
color: var(--chrome-fg);
|
||||
}
|
||||
|
||||
.voice-preview__body {
|
||||
padding: 12px 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.voice-preview__select {
|
||||
font-size: 0.78rem;
|
||||
padding: 6px 8px;
|
||||
@@ -69,19 +21,3 @@
|
||||
line-height: 1.4;
|
||||
min-height: 48px;
|
||||
}
|
||||
.voice-preview__audio {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.voice-preview__foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 14px 10px;
|
||||
border-top: 1px solid var(--chrome-border);
|
||||
}
|
||||
.voice-preview__hint {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.65rem;
|
||||
color: var(--chrome-fg-dim);
|
||||
}
|
||||
|
||||
@@ -98,14 +98,14 @@ export default function VoicePreview({
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div className="voice-preview">
|
||||
<div className="voice-preview__head">
|
||||
<span className="voice-preview__title">
|
||||
<div className="fixed bottom-[calc(var(--logs-footer-height,28px)+16px)] right-[16px] z-[900] w-[320px] bg-[var(--chrome-bg)] border border-solid border-[var(--chrome-border-strong)] rounded-[12px] [box-shadow:0_8px_32px_rgba(0,0,0,0.4)] flex flex-col overflow-hidden animate-[voice-preview-in_0.2s_ease-out]">
|
||||
<div className="flex items-center justify-between py-[10px] px-[14px] border-b border-solid border-b-[var(--chrome-border)]">
|
||||
<span className="flex items-center gap-[6px] [font-family:var(--font-mono)] text-[0.72rem] font-semibold uppercase [letter-spacing:0.04em] text-[color:var(--chrome-fg)]">
|
||||
<Volume2 size={13} /> {t('voicePreview.title')}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="voice-preview__close"
|
||||
className="bg-transparent border-none text-[color:var(--chrome-fg-muted)] cursor-pointer p-[4px] rounded-[6px] [transition:background_0.15s] hover:bg-[var(--chrome-hover-bg)] hover:text-[color:var(--chrome-fg)]"
|
||||
onClick={onClose}
|
||||
aria-label={t('voicePreview.close')}
|
||||
>
|
||||
@@ -113,7 +113,7 @@ export default function VoicePreview({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="voice-preview__body">
|
||||
<div className="py-[12px] px-[14px] flex flex-col gap-[8px]">
|
||||
<select
|
||||
className="input-base voice-preview__select"
|
||||
value={voiceId}
|
||||
@@ -167,12 +167,12 @@ export default function VoicePreview({
|
||||
src={audioUrl}
|
||||
source="voice-preview"
|
||||
autoPlay={autoPlayPreview}
|
||||
className="voice-preview__audio"
|
||||
className="w-full"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="voice-preview__foot">
|
||||
<div className="flex items-center justify-between pt-[8px] px-[14px] pb-[10px] border-t border-solid border-t-[var(--chrome-border)]">
|
||||
{loading ? (
|
||||
<Button variant="ghost" size="sm" onClick={handleStop} leading={<Square size={10} />}>
|
||||
{t('voicePreview.stop')}
|
||||
@@ -189,7 +189,9 @@ export default function VoicePreview({
|
||||
{audioUrl ? t('voicePreview.regenerate') : t('voicePreview.preview')}
|
||||
</Button>
|
||||
)}
|
||||
<span className="voice-preview__hint">{t('voicePreview.hint')}</span>
|
||||
<span className="[font-family:var(--font-mono)] text-[0.65rem] text-[color:var(--chrome-fg-dim)]">
|
||||
{t('voicePreview.hint')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
/* WaveformPlayer — the one shared audio player. Accent surfaces use the
|
||||
--color-brand tokens (re-themed per theme in ui/themes.css) so it reads the
|
||||
same in the sidebar, popovers, modals, and full-width panels. */
|
||||
|
||||
.wf-player {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
padding: 6px 10px;
|
||||
border-radius: 10px;
|
||||
background: rgba(168, 153, 132, 0.08);
|
||||
border: 1px solid rgba(168, 153, 132, 0.18);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.wf-player--compact {
|
||||
gap: 8px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.wf-player__btn {
|
||||
flex: 0 0 auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
color: var(--color-fg-inverse);
|
||||
background: var(--color-brand);
|
||||
transition: background 0.15s ease, transform 0.1s ease;
|
||||
}
|
||||
|
||||
.wf-player--compact .wf-player__btn {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
}
|
||||
|
||||
.wf-player__btn:hover:not(:disabled) { background: var(--color-brand-hover); }
|
||||
.wf-player__btn:active:not(:disabled) { transform: scale(0.94); }
|
||||
.wf-player__btn:disabled { opacity: 0.6; cursor: default; }
|
||||
|
||||
.wf-player__wave {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.wf-player__time {
|
||||
flex: 0 0 auto;
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-size: 11px;
|
||||
color: rgba(168, 153, 132, 0.85);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.wf-player--compact .wf-player__time { font-size: 10px; }
|
||||
|
||||
.wf-player__spin { animation: wf-spin 1s linear infinite; }
|
||||
|
||||
/* Stale history rows whose audio file is gone — inert notice, no controls. */
|
||||
.wf-player--missing { justify-content: center; opacity: 0.55; }
|
||||
.wf-player__missing-msg {
|
||||
font-size: 10.5px;
|
||||
font-style: italic;
|
||||
color: var(--chrome-fg-dim, #665c54);
|
||||
}
|
||||
|
||||
@keyframes wf-spin { to { transform: rotate(360deg); } }
|
||||
|
||||
/* Native fallback element (WebKit decode failure) — keep it full-width so it
|
||||
doesn't look out of place where the waveform would have been. */
|
||||
.wf-player__native {
|
||||
width: 100%;
|
||||
height: 34px;
|
||||
}
|
||||
|
||||
/* ── Reduced motion (10x P4, spec §3): loading spinner holds a static
|
||||
frame instead of rotating. ──────────────────────────────────────────── */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.wf-player__spin { animation: none; }
|
||||
}
|
||||
@@ -21,7 +21,6 @@ import { claimPlayback } from '../utils/playback';
|
||||
import { isTauri, fileToMediaUrl } from '../utils/media';
|
||||
import { unlockAudio } from '../utils/audioUnlock';
|
||||
import { useAppStore } from '../store';
|
||||
import './WaveformPlayer.css';
|
||||
|
||||
const fmt = (s) => {
|
||||
if (!isFinite(s) || s < 0) s = 0;
|
||||
@@ -277,9 +276,15 @@ export default function WaveformPlayer({
|
||||
if (missing) {
|
||||
return (
|
||||
<div
|
||||
className={`wf-player wf-player--missing ${compact ? 'wf-player--compact' : ''} ${className}`}
|
||||
className={`flex items-center justify-center w-full min-w-0 box-border opacity-55 border border-solid border-[rgba(168,153,132,0.18)] bg-[rgba(168,153,132,0.08)] ${
|
||||
compact
|
||||
? 'gap-[8px] py-[4px] px-[8px] rounded-[8px]'
|
||||
: 'gap-[10px] py-[6px] px-[10px] rounded-[10px]'
|
||||
} ${className}`}
|
||||
>
|
||||
<span className="wf-player__missing-msg">audio file missing</span>
|
||||
<span className="text-[10.5px] italic text-[color:var(--chrome-fg-dim,#665c54)]">
|
||||
audio file missing
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -290,7 +295,7 @@ export default function WaveformPlayer({
|
||||
return (
|
||||
<audio
|
||||
ref={nativeRef}
|
||||
className={`wf-player__native ${className}`}
|
||||
className={`w-full h-[34px] ${className}`}
|
||||
controls
|
||||
src={resolvedUrl}
|
||||
autoPlay={autoPlay}
|
||||
@@ -322,20 +327,38 @@ export default function WaveformPlayer({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`wf-player ${compact ? 'wf-player--compact' : ''} ${className}`}>
|
||||
<div
|
||||
className={`flex items-center w-full min-w-0 box-border border border-solid border-[rgba(168,153,132,0.18)] bg-[rgba(168,153,132,0.08)] ${
|
||||
compact
|
||||
? 'gap-[8px] py-[4px] px-[8px] rounded-[8px]'
|
||||
: 'gap-[10px] py-[6px] px-[10px] rounded-[10px]'
|
||||
} ${className}`}
|
||||
>
|
||||
{/* Hidden but DOM-attached playback element (see WaveSurfer `media`). */}
|
||||
<audio ref={mediaRef} src={resolvedUrl} preload="metadata" style={{ display: 'none' }} />
|
||||
{/* `wf-player__btn` class kept as the focus-visible hook (shared a11y ring
|
||||
in index.css); all other button visuals are Tailwind utilities. */}
|
||||
<button
|
||||
type="button"
|
||||
className="wf-player__btn"
|
||||
className={`wf-player__btn flex-[0_0_auto] inline-flex items-center justify-center border-none rounded-full cursor-pointer text-[color:var(--color-fg-inverse)] bg-[var(--color-brand)] [transition:background_0.15s_ease,transform_0.1s_ease] enabled:hover:bg-[var(--color-brand-hover)] enabled:active:scale-[0.94] disabled:opacity-60 disabled:cursor-default ${
|
||||
compact ? 'w-[26px] h-[26px]' : 'w-[30px] h-[30px]'
|
||||
}`}
|
||||
onClick={togglePlay}
|
||||
disabled={!resolvedUrl}
|
||||
aria-label={isPlaying ? 'Pause' : 'Play'}
|
||||
>
|
||||
{isPlaying ? <Pause size={compact ? 13 : 15} /> : <Play size={compact ? 13 : 15} />}
|
||||
</button>
|
||||
<div className="wf-player__wave" ref={containerRef} style={{ height }} />
|
||||
<span className="wf-player__time">
|
||||
<div
|
||||
className="flex-[1_1_auto] min-w-0 cursor-pointer"
|
||||
ref={containerRef}
|
||||
style={{ height }}
|
||||
/>
|
||||
<span
|
||||
className={`flex-[0_0_auto] [font-variant-numeric:tabular-nums] text-[color:rgba(168,153,132,0.85)] whitespace-nowrap ${
|
||||
compact ? 'text-[10px]' : 'text-[11px]'
|
||||
}`}
|
||||
>
|
||||
{fmt(currentTime)} / {fmt(duration)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -155,9 +155,9 @@ export default function WorkspaceHistory({
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className="wh">
|
||||
<div className="wh__head">
|
||||
<span className="wh__title">
|
||||
<aside className="flex-[1_1_0] flex flex-col min-h-0 overflow-hidden border-t border-solid border-t-[var(--chrome-border-strong,var(--chrome-border))]">
|
||||
<div className="flex-[0_0_auto] flex flex-col gap-[8px] py-[10px] px-[12px] border-b border-solid border-b-[var(--chrome-border)]">
|
||||
<span className="inline-flex items-center gap-[6px] [font-family:var(--chrome-font-mono,var(--font-mono))] text-[0.72rem] font-semibold [letter-spacing:0.04em] uppercase text-[color:var(--chrome-fg-muted)]">
|
||||
<History size={13} /> {t('history.title', { defaultValue: 'History' })}
|
||||
</span>
|
||||
<div className="flex flex-wrap gap-[4px]">
|
||||
@@ -178,9 +178,9 @@ export default function WorkspaceHistory({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="wh__scroll">
|
||||
<div className="flex-[1_1_auto] min-h-0 overflow-y-auto flex flex-col gap-[8px] p-[8px]">
|
||||
{items.length === 0 ? (
|
||||
<div className="wh__empty">
|
||||
<div className="text-[color:var(--chrome-fg-dim)] text-[0.72rem] [line-height:1.5] text-center py-[32px] px-[16px]">
|
||||
{t('history.empty', {
|
||||
defaultValue: 'Nothing here yet — your generations will appear on the right.',
|
||||
})}
|
||||
|
||||
Reference in New Issue
Block a user