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:
Palash Debnath
2026-07-01 03:49:28 +05:30
committed by GitHub
co-authored by mergetest Claude Opus 4.8
parent 8541eb75c5
commit 7264c321f8
5 changed files with 53 additions and 179 deletions
+7 -71
View File
@@ -1,63 +1,15 @@
.voice-preview { /* VoicePreview — all panel chrome converted to Tailwind utilities on
position: fixed; VoicePreview.jsx. Two things stay here as irreducible CSS:
/* Track the LogsFooter's live height so an expanded/resized footer never 1. the entrance @keyframes (referenced via animate-[…] on the popover); and
slides underneath the popover (LogsFooter.jsx writes the variable). */ 2. the select/text rules that layer on top of the *unlayered* shared
bottom: calc(var(--logs-footer-height, 28px) + 16px); .input-base — Tailwind utilities live in @layer utilities and would lose
right: 16px; the cascade to .input-base, so these overrides must stay unlayered. */
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;
}
@keyframes voice-preview-in { @keyframes voice-preview-in {
from { opacity: 0; transform: translateY(12px) scale(0.96); } from { opacity: 0; transform: translateY(12px) scale(0.96); }
to { opacity: 1; transform: translateY(0) scale(1); } 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 { .voice-preview__select {
font-size: 0.78rem; font-size: 0.78rem;
padding: 6px 8px; padding: 6px 8px;
@@ -69,19 +21,3 @@
line-height: 1.4; line-height: 1.4;
min-height: 48px; 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);
}
+10 -8
View File
@@ -98,14 +98,14 @@ export default function VoicePreview({
if (!open) return null; if (!open) return null;
return ( return (
<div className="voice-preview"> <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="voice-preview__head"> <div className="flex items-center justify-between py-[10px] px-[14px] border-b border-solid border-b-[var(--chrome-border)]">
<span className="voice-preview__title"> <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')} <Volume2 size={13} /> {t('voicePreview.title')}
</span> </span>
<button <button
type="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} onClick={onClose}
aria-label={t('voicePreview.close')} aria-label={t('voicePreview.close')}
> >
@@ -113,7 +113,7 @@ export default function VoicePreview({
</button> </button>
</div> </div>
<div className="voice-preview__body"> <div className="py-[12px] px-[14px] flex flex-col gap-[8px]">
<select <select
className="input-base voice-preview__select" className="input-base voice-preview__select"
value={voiceId} value={voiceId}
@@ -167,12 +167,12 @@ export default function VoicePreview({
src={audioUrl} src={audioUrl}
source="voice-preview" source="voice-preview"
autoPlay={autoPlayPreview} autoPlay={autoPlayPreview}
className="voice-preview__audio" className="w-full"
/> />
)} )}
</div> </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 ? ( {loading ? (
<Button variant="ghost" size="sm" onClick={handleStop} leading={<Square size={10} />}> <Button variant="ghost" size="sm" onClick={handleStop} leading={<Square size={10} />}>
{t('voicePreview.stop')} {t('voicePreview.stop')}
@@ -189,7 +189,9 @@ export default function VoicePreview({
{audioUrl ? t('voicePreview.regenerate') : t('voicePreview.preview')} {audioUrl ? t('voicePreview.regenerate') : t('voicePreview.preview')}
</Button> </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>
</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; }
}
+31 -8
View File
@@ -21,7 +21,6 @@ import { claimPlayback } from '../utils/playback';
import { isTauri, fileToMediaUrl } from '../utils/media'; import { isTauri, fileToMediaUrl } from '../utils/media';
import { unlockAudio } from '../utils/audioUnlock'; import { unlockAudio } from '../utils/audioUnlock';
import { useAppStore } from '../store'; import { useAppStore } from '../store';
import './WaveformPlayer.css';
const fmt = (s) => { const fmt = (s) => {
if (!isFinite(s) || s < 0) s = 0; if (!isFinite(s) || s < 0) s = 0;
@@ -277,9 +276,15 @@ export default function WaveformPlayer({
if (missing) { if (missing) {
return ( return (
<div <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> </div>
); );
} }
@@ -290,7 +295,7 @@ export default function WaveformPlayer({
return ( return (
<audio <audio
ref={nativeRef} ref={nativeRef}
className={`wf-player__native ${className}`} className={`w-full h-[34px] ${className}`}
controls controls
src={resolvedUrl} src={resolvedUrl}
autoPlay={autoPlay} autoPlay={autoPlay}
@@ -322,20 +327,38 @@ export default function WaveformPlayer({
} }
return ( 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`). */} {/* Hidden but DOM-attached playback element (see WaveSurfer `media`). */}
<audio ref={mediaRef} src={resolvedUrl} preload="metadata" style={{ display: 'none' }} /> <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 <button
type="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} onClick={togglePlay}
disabled={!resolvedUrl} disabled={!resolvedUrl}
aria-label={isPlaying ? 'Pause' : 'Play'} aria-label={isPlaying ? 'Pause' : 'Play'}
> >
{isPlaying ? <Pause size={compact ? 13 : 15} /> : <Play size={compact ? 13 : 15} />} {isPlaying ? <Pause size={compact ? 13 : 15} /> : <Play size={compact ? 13 : 15} />}
</button> </button>
<div className="wf-player__wave" ref={containerRef} style={{ height }} /> <div
<span className="wf-player__time"> 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)} {fmt(currentTime)} / {fmt(duration)}
</span> </span>
</div> </div>
+5 -5
View File
@@ -155,9 +155,9 @@ export default function WorkspaceHistory({
} }
return ( return (
<aside className="wh"> <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="wh__head"> <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="wh__title"> <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' })} <History size={13} /> {t('history.title', { defaultValue: 'History' })}
</span> </span>
<div className="flex flex-wrap gap-[4px]"> <div className="flex flex-wrap gap-[4px]">
@@ -178,9 +178,9 @@ export default function WorkspaceHistory({
</div> </div>
</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 ? ( {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', { {t('history.empty', {
defaultValue: 'Nothing here yet — your generations will appear on the right.', defaultValue: 'Nothing here yet — your generations will appear on the right.',
})} })}