feat(ui): convert more DubTab CSS to Tailwind (wave 2, live-screenshot-verified) (#794)

Second-wave CSS->Tailwind conversion of DubTab, building on wave 1 (#788).
Removes 119 more lines from DubTab.css (919 -> 800) by moving the
stateless/standalone idle-skeleton rules into utilities in IdleSkeleton.jsx.

Every conversion was proven pixel-identical against the LIVE app (real Dub
screen on a dev server, not the isolated component harness). A throwaway
Playwright spec captured baselines of three reachable Dub states, the rules
were converted, and the same states were re-shot and pixel-diffed with
maxDiffPixels:0 (exact). States verified:
  - idle drop-zone (drop-zone leaves, URL ingest row, landing options)
  - idle + Advanced expanded (landing-adv field row)
  - file-loaded skeleton via setInputFiles, no backend upload (skel settings,
    skel table cells/headers/hint, cast strip, stepper)

Converted (base/standalone rules -> utilities): dub-idle-drop__lines/__title/
__sub, dub-ingest-row + __input, dub-idle-upload-label, dub-hidden-file,
dub-landing-opts + __label, dub-landing-opts__lang base, dub-landing-adv +
__field base, dub-cast base + __row + __kicker/__label base + --muted__chip,
dub-skel-settings, dub-skel-field/--sm, dub-skel-translate-btn,
dub-skel-transcript-toggle, dub-inline-icon, dub-skel-cell-*/header-* cells,
dub-skel-hint, dub-skel-gen-row.

Deliberately LEFT as CSS (would regress, per the diff oracle / wave-1 doctrine):
anything with @keyframes/animation (dub-skel-bar shimmer, dub-idle-drop pulse),
:hover/state interplay (dub-ingest-row__cta.is-ready, dub-landing-opts__adv,
dub-cast__pair), and cross-file unlayered overrides that a layered utility
would lose to (dub-skel-table on .segment-table, dub-skel-row on .segment-row,
dub-skel-gen-btn / dub-change-row__cta on .btn-primary,
dub-skel-transcript-toggle__inner on .override-toggle,
dub-skel-cell-acts__icon on .segment-del, dub-speakers-input on .input-base,
dub-ghost-footer on .studio-panel). Class hooks were kept on elements whose
.dub-cast--muted / --grow / select descendant rules still need them.

Gates: oxlint (0), oxfmt --check (clean), vite build, vitest (641 pass),
bun install --frozen-lockfile (no change), bun run test:visual (42 pass).

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-06-30 19:42:33 +05:30
committed by GitHub
co-authored by mergetest Claude Opus 4.8
parent ab87ef734d
commit 9d79bb8e36
2 changed files with 79 additions and 178 deletions
+59 -39
View File
@@ -131,7 +131,7 @@ export default function IdleSkeleton({
{handleDubImportSrt && (
<label
htmlFor="srt-import-banner-input"
className="dub-idle-upload-label"
className="flex items-center gap-[6px] px-[12px] py-[6px] bg-[rgba(255,255,255,0.05)] [border:1px_solid_rgba(255,255,255,0.1)] rounded-[6px] cursor-pointer text-[0.8rem] text-fg-muted"
title={t('dub.import_srt')}
style={{ cursor: 'pointer' }}
>
@@ -182,13 +182,16 @@ export default function IdleSkeleton({
}
/>
<div className="flex gap-[8px] mt-[8px] items-center">
<label htmlFor="video-upload" className="dub-idle-upload-label">
<label
htmlFor="video-upload"
className="flex items-center gap-[6px] px-[12px] py-[6px] bg-[rgba(255,255,255,0.05)] [border:1px_solid_rgba(255,255,255,0.1)] rounded-[6px] cursor-pointer text-[0.8rem] text-fg-muted"
>
<Film size={13} /> {t('dub.change_file')}
</label>
{dubJobId && handleDubImportSrt && (
<label
htmlFor="srt-import-input"
className="dub-idle-upload-label"
className="flex items-center gap-[6px] px-[12px] py-[6px] bg-[rgba(255,255,255,0.05)] [border:1px_solid_rgba(255,255,255,0.1)] rounded-[6px] cursor-pointer text-[0.8rem] text-fg-muted"
title={t('dub.import_srt')}
style={{ cursor: 'pointer' }}
>
@@ -289,11 +292,16 @@ export default function IdleSkeleton({
<div className="dub-idle-drop__puck">
<UploadCloud color="#d3869b" size={28} />
</div>
<div className="dub-idle-drop__lines">
<div className="dub-idle-drop__title">{t('dub.drop_here')}</div>
<div className="dub-idle-drop__sub">{t('dub.supported_formats')}</div>
<div className="text-center">
<div className="text-[0.9rem] text-fg font-medium mb-[4px]">
{t('dub.drop_here')}
</div>
<div className="text-[0.7rem] text-[#665c54]">{t('dub.supported_formats')}</div>
</div>
<div className="dub-ingest-row" onClick={(e) => e.preventDefault()}>
<div
className="flex gap-[6px] items-center px-[10px] py-[6px] mt-[10px] bg-[rgba(255,255,255,0.02)] [border:1px_solid_rgba(255,255,255,0.06)] rounded-[6px] w-[min(420px,80%)]"
onClick={(e) => e.preventDefault()}
>
<Link2 size={13} color="#a89984" />
<input
type="text"
@@ -311,7 +319,7 @@ export default function IdleSkeleton({
onIngestUrl();
}
}}
className="dub-ingest-row__input"
className="flex-1 bg-transparent border-none outline-none text-fg text-[0.75rem]"
/>
<button
type="button"
@@ -346,10 +354,10 @@ export default function IdleSkeleton({
{/* One decision up front: the target language. Everything else
(speakers, style) hides behind Advanced — ElevenLabs-style
flow, OmniVoice chrome. The pick pre-seeds the editor. */}
<div className="dub-landing-opts">
<label className="dub-landing-opts__lang">
<div className="flex items-center justify-between gap-[10px] mt-[10px] px-[10px] py-[8px] [border:1px_solid_var(--chrome-border)] rounded-[10px] bg-[var(--chrome-hover-bg)]">
<label className="dub-landing-opts__lang inline-flex items-center gap-[7px] min-w-0 text-[var(--chrome-fg-muted)]">
<Globe size={13} />
<span className="dub-landing-opts__label">
<span className="text-[0.72rem] font-medium whitespace-nowrap">
{t('dub.target_language', { defaultValue: 'Dub into' })}
</span>
<select
@@ -379,8 +387,11 @@ export default function IdleSkeleton({
</button>
</div>
{landingAdvOpen && (
<div className="dub-landing-adv">
<label className="dub-landing-adv__field" title={t('dub.num_speakers_help')}>
<div className="flex flex-wrap items-center gap-[12px] mt-[6px] px-[10px] py-[8px] [border:1px_solid_var(--chrome-border)] rounded-[10px]">
<label
className="dub-landing-adv__field inline-flex items-center gap-[6px] text-[0.7rem] text-[var(--chrome-fg-muted)]"
title={t('dub.num_speakers_help')}
>
<Users size={12} /> {t('dub.num_speakers_label')}
<input
type="number"
@@ -396,7 +407,7 @@ export default function IdleSkeleton({
}}
/>
</label>
<label className="dub-landing-adv__field dub-landing-adv__field--grow">
<label className="dub-landing-adv__field dub-landing-adv__field--grow inline-flex items-center gap-[6px] text-[0.7rem] text-[var(--chrome-fg-muted)]">
<UserSquare2 size={12} /> {t('dub.style')}
<input
type="text"
@@ -415,7 +426,7 @@ export default function IdleSkeleton({
type="file"
accept="video/*,audio/*,.mp3,.wav,.m4a,.aac,.flac,.ogg,.opus,.wma"
id="video-upload"
className="dub-hidden-file"
className="hidden"
onChange={(e) => {
const file = e.target.files[0];
if (!file) return;
@@ -436,11 +447,17 @@ export default function IdleSkeleton({
/>
{dubVideoFile && (
<div className="dub-cast dub-cast--muted">
<div className="dub-cast__row">
<span className="dub-cast__kicker">{t('dub.cast')}</span>
<span className="dub-cast__label">{t('dub.speaker', { n: 1 })}</span>
<span className="dub-cast--muted__chip">{t('dub.default')}</span>
<div className="dub-cast dub-cast--muted mt-[2px] px-[var(--space-3)] py-[3px] bg-[var(--chrome-bg)] rounded-[var(--chrome-radius-pill)] [border:1px_solid_var(--chrome-border)]">
<div className="flex gap-[var(--space-2)] items-center flex-wrap">
<span className="dub-cast__kicker font-[family-name:var(--chrome-font-mono)] text-[length:var(--chrome-label-size)] text-[var(--chrome-fg-muted)] tracking-[var(--chrome-label-track)] uppercase font-semibold">
{t('dub.cast')}
</span>
<span className="dub-cast__label font-[family-name:var(--chrome-font-mono)] text-[0.62rem] text-[var(--chrome-fg)]">
{t('dub.speaker', { n: 1 })}
</span>
<span className="font-[family-name:var(--chrome-font-mono)] text-[0.62rem] text-[var(--chrome-fg-dim)] px-[6px] py-[1px] bg-transparent [border:1px_solid_var(--chrome-border)] rounded-[var(--chrome-radius-pill)]">
{t('dub.default')}
</span>
</div>
</div>
)}
@@ -449,8 +466,8 @@ export default function IdleSkeleton({
{/* RIGHT: Ghost settings + segment table (only when video loaded) */}
{dubVideoFile ? (
<div className="studio-panel dub-panel-col">
<div className="dub-skel-settings">
<div className="dub-skel-field">
<div className="flex gap-[4px] mb-[4px] flex-wrap items-end opacity-40">
<div className="flex-1 min-w-[90px]">
<div className="label-row">
<Globe className="label-icon" size={9} /> {t('dub.language')}
</div>
@@ -458,13 +475,13 @@ export default function IdleSkeleton({
<option>{t('dub.auto')}</option>
</select>
</div>
<div className="dub-skel-field--sm">
<div className="flex-1 min-w-[80px]">
<div className="label-row">{t('dub.iso_code')}</div>
<select className="input-base input-base--xs" disabled>
<option>en {t('dub.original_audio')}</option>
</select>
</div>
<div className="dub-skel-field">
<div className="flex-1 min-w-[90px]">
<div className="label-row">
<UserSquare2 className="label-icon" size={9} /> {t('dub.style')}
</div>
@@ -474,25 +491,28 @@ export default function IdleSkeleton({
placeholder={t('dub.style_placeholder')}
/>
</div>
<button disabled className="dub-skel-translate-btn">
<button
disabled
className="px-[8px] py-[3px] bg-[rgba(131,165,152,0.08)] [border:1px_solid_rgba(131,165,152,0.12)] text-[#504945] rounded-[4px] text-[0.62rem] flex items-center gap-[3px] whitespace-nowrap"
>
<Languages size={10} /> {t('dub.translate_all')}
</button>
</div>
<div className="dub-skel-transcript-toggle">
<div className="mb-[4px]">
<div className="override-toggle dub-skel-transcript-toggle__inner">
<span>
<FileText size={10} className="dub-inline-icon" /> {t('dub.transcript')}
<FileText size={10} className="align-middle mr-[3px]" /> {t('dub.transcript')}
</span>
<ChevronDown size={10} />
</div>
</div>
<div className="segment-table dub-skel-table">
<div className="segment-header">
<span className="dub-skel-header-time">{t('dub.time_col')}</span>
<span className="dub-skel-header-spkr">{t('dub.spkr_col')}</span>
<span className="dub-skel-header-text">{t('dub.text_col')}</span>
<span className="dub-skel-header-voice">{t('dub.voice_col')}</span>
<span className="dub-skel-header-acts"></span>
<span className="w-[55px] flex-[0_0_55px]">{t('dub.time_col')}</span>
<span className="w-[44px] flex-[0_0_44px]">{t('dub.spkr_col')}</span>
<span className="flex-1">{t('dub.text_col')}</span>
<span className="w-[70px] flex-[0_0_70px]">{t('dub.voice_col')}</span>
<span className="w-[40px] flex-[0_0_40px]"></span>
</div>
{[1, 2, 3, 4, 5, 6].map((i) => (
<div
@@ -500,18 +520,18 @@ export default function IdleSkeleton({
className="segment-row dub-skel-row"
style={{ opacity: 0.5 + 0.07 * (6 - i) }}
>
<span className="dub-skel-cell-time dub-skel-bar" />
<span className="dub-skel-cell-spkr dub-skel-bar" />
<div className="dub-skel-cell-text dub-skel-bar" />
<span className="dub-skel-cell-voice dub-skel-bar" />
<div className="dub-skel-cell-acts">
<span className="dub-skel-bar w-[55px] flex-[0_0_55px]" />
<span className="dub-skel-bar w-[44px] flex-[0_0_44px]" />
<div className="dub-skel-bar flex-1 min-w-0" />
<span className="dub-skel-bar w-[70px] flex-[0_0_70px]" />
<div className="flex gap-[1px] w-[40px] flex-[0_0_40px]">
<span className="segment-del dub-skel-cell-acts__icon">
<Trash2 size={9} />
</span>
</div>
</div>
))}
<div className="dub-skel-hint">
<div className="px-[8px] pt-[10px] pb-[4px] text-[0.62rem] text-[var(--chrome-fg-dim)] text-center">
{t('dub.transcript_after_extract', {
defaultValue: 'Transcript appears after extraction.',
})}
@@ -525,7 +545,7 @@ export default function IdleSkeleton({
clean. Generate is the lone primary, exports demoted to one menu. */}
{dubVideoFile && (
<div className="studio-panel dub-ghost-footer">
<div className="dub-skel-gen-row">
<div className="flex gap-[4px]">
<button className="btn-primary dub-skel-gen-btn" disabled>
<Play size={11} /> {t('dub.generate_dub')}
</button>
+20 -139
View File
@@ -241,29 +241,9 @@
transform: scale(1.08);
box-shadow: 0 0 24px rgba(211, 134, 155, 0.15);
}
.dub-idle-drop__lines { text-align: center; }
.dub-idle-drop__title { font-size: 0.9rem; color: var(--color-fg); font-weight: 500; margin-bottom: 4px; }
.dub-idle-drop__sub { font-size: 0.7rem; color: #665c54; }
.dub-ingest-row {
display: flex;
gap: 6px;
align-items: center;
padding: 6px 10px;
margin-top: 10px;
background: rgba(255, 255, 255, 0.02);
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 6px;
width: min(420px, 80%);
}
.dub-ingest-row__input {
flex: 1;
background: transparent;
border: none;
outline: none;
color: var(--color-fg);
font-size: 0.75rem;
}
/* .dub-idle-drop__lines/__title/__sub + .dub-ingest-row/__input → utilities
(IdleSkeleton.jsx). .dub-idle-drop base/__puck stay (pulse keyframes + hover);
.dub-ingest-row__cta stays (.is-ready modifier interplay). */
.dub-ingest-row__cta {
padding: 3px 10px;
border-radius: 4px;
@@ -280,18 +260,7 @@
cursor: pointer;
}
.dub-idle-upload-label {
display: flex; align-items: center; gap: 6px;
padding: 6px 12px;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 6px;
cursor: pointer;
font-size: 0.8rem;
color: var(--color-fg-muted);
}
.dub-hidden-file { display: none; }
/* .dub-idle-upload-label + .dub-hidden-file → utilities (IdleSkeleton.jsx). */
/* .dub-change-row + .dub-speakers-hint → utilities (IdleSkeleton.jsx).
.dub-change-row__cta stays: it sits on .btn-primary (unlayered) and overrides
@@ -301,21 +270,10 @@
/* Compact disabled inputs in the idle skeleton */
.input-base--xs { font-size: 0.65rem; }
.dub-skel-translate-btn {
padding: 3px 8px;
background: rgba(131, 165, 152, 0.08);
border: 1px solid rgba(131, 165, 152, 0.12);
color: #504945;
border-radius: 4px;
font-size: 0.62rem;
display: flex;
align-items: center;
gap: 3px;
white-space: nowrap;
}
.dub-skel-transcript-toggle { margin-bottom: 4px; }
/* .dub-skel-translate-btn + .dub-skel-transcript-toggle + .dub-inline-icon →
utilities (IdleSkeleton.jsx). __inner stays: it sits on .override-toggle
(unlayered) and overrides its margin/padding, so a utility would lose. */
.dub-skel-transcript-toggle__inner { margin-top: 0; padding: 2px 6px; font-size: 0.65rem; opacity: 0.3; cursor: default; }
.dub-inline-icon { vertical-align: middle; margin-right: 3px; }
.dub-ghost-footer { padding: 4px 8px; flex-shrink: 0; }
@@ -581,29 +539,8 @@
/* .dub-lazy-fallback → utilities (DubRightColumn.jsx). */
/* ── Landing options — the one pre-upload decision + Advanced disclosure ── */
.dub-landing-opts {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-top: 10px;
padding: 8px 10px;
border: 1px solid var(--chrome-border);
border-radius: 10px;
background: var(--chrome-hover-bg, rgba(255, 255, 255, 0.03));
}
.dub-landing-opts__lang {
display: inline-flex;
align-items: center;
gap: 7px;
min-width: 0;
color: var(--chrome-fg-muted);
}
.dub-landing-opts__label {
font-size: 0.72rem;
font-weight: 500;
white-space: nowrap;
}
/* .dub-landing-opts + __lang/__label → utilities (IdleSkeleton.jsx). __lang
keeps its class for the descendant select rule below; __adv stays (:hover). */
.dub-landing-opts__lang select { max-width: 220px; }
.dub-landing-opts__adv {
display: inline-flex;
@@ -619,23 +556,8 @@
transition: color var(--dur-fast), border-color var(--dur-fast);
}
.dub-landing-opts__adv:hover { color: var(--chrome-fg); border-color: var(--chrome-border-strong); }
.dub-landing-adv {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px;
margin-top: 6px;
padding: 8px 10px;
border: 1px solid var(--chrome-border);
border-radius: 10px;
}
.dub-landing-adv__field {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 0.7rem;
color: var(--chrome-fg-muted);
}
/* .dub-landing-adv + __field base → utilities (IdleSkeleton.jsx). __field keeps
its class for the --grow modifier + descendant input rule below. */
.dub-landing-adv__field--grow { flex: 1; min-width: 200px; }
.dub-landing-adv__field--grow input { flex: 1; }
@@ -758,23 +680,10 @@
background-size: 400% 100%;
animation: dub-skel-shimmer 1.4s ease-in-out infinite;
}
.dub-skel-cell-time { width: 55px; flex: 0 0 55px; }
.dub-skel-cell-spkr { width: 44px; flex: 0 0 44px; }
.dub-skel-cell-text { flex: 1; min-width: 0; }
.dub-skel-cell-voice { width: 70px; flex: 0 0 70px; }
.dub-skel-cell-acts { display: flex; gap: 1px; width: 40px; flex: 0 0 40px; }
/* .dub-skel-cell-* and -header-* cells + .dub-skel-hint → utilities
(IdleSkeleton.jsx). __icon stays: it sits on .segment-del (unlayered) and
overrides its opacity, so a utility would lose. */
.dub-skel-cell-acts__icon { opacity: 0.3; }
.dub-skel-header-time { width: 55px; flex: 0 0 55px; }
.dub-skel-header-spkr { width: 44px; flex: 0 0 44px; }
.dub-skel-header-text { flex: 1; }
.dub-skel-header-voice{ width: 70px; flex: 0 0 70px; }
.dub-skel-header-acts { width: 40px; flex: 0 0 40px; }
.dub-skel-hint {
padding: 10px 8px 4px;
font-size: 0.62rem;
color: var(--chrome-fg-dim, #665c54);
text-align: center;
}
@keyframes dub-skel-shimmer {
0% { background-position: 100% 0; }
@@ -783,13 +692,8 @@
/* Disabled generation controls shown in idle skeleton */
.dub-skel-gen-btn { margin-top: 0; flex: 1; padding: 4px 8px; font-size: 0.7rem; opacity: 0.4; }
.dub-skel-gen-row { display: flex; gap: 4px; }
.dub-skel-settings {
display: flex; gap: 4px; margin-bottom: 4px;
flex-wrap: wrap; align-items: flex-end; opacity: 0.4;
}
.dub-skel-field { flex: 1; min-width: 90px; }
.dub-skel-field--sm { flex: 1; min-width: 80px; }
/* .dub-skel-gen-row + .dub-skel-settings + .dub-skel-field/--sm → utilities
(IdleSkeleton.jsx). .dub-skel-gen-btn stays (sits on .btn-primary, unlayered). */
/* ── Generating overlay (on top of waveform) ─────────────────── */
.dub-gen-overlay { display: flex; flex-direction: column; align-items: center; gap: 6px; width: 100%; padding: 10px; backdrop-filter: blur(2px); }
@@ -801,41 +705,18 @@
.dub-gen-overlay__stats { display: flex; gap: var(--space-4); font-size: 0.65rem; color: var(--color-fg-muted); font-variant-numeric: tabular-nums; }
/* ── Cast / speaker-voices strip below waveform ──────────────── */
.dub-cast {
margin-top: 2px;
padding: 3px var(--space-3);
background: var(--chrome-bg);
border-radius: var(--chrome-radius-pill);
border: 1px solid var(--chrome-border);
}
.dub-cast__row { display: flex; gap: var(--space-2); align-items: center; flex-wrap: wrap; }
.dub-cast__kicker {
font-family: var(--chrome-font-mono);
font-size: var(--chrome-label-size);
color: var(--chrome-fg-muted);
letter-spacing: var(--chrome-label-track);
text-transform: uppercase;
font-weight: 600;
}
/* .dub-cast base + __row + __kicker/__label base + --muted__chip → utilities
(IdleSkeleton.jsx). .dub-cast / __kicker / __label keep their classes so the
.dub-cast--muted (and its descendant) overrides below still apply; __pair
(:hover) and __select (real cast, not the skeleton) stay. */
.dub-cast__pair { display: flex; align-items: center; gap: 3px; padding: 1px 5px; border-radius: var(--radius-sm); transition: background 0.2s ease; }
.dub-cast__pair:hover { background: rgba(255, 255, 255, 0.03); }
.dub-cast__label { font-family: var(--chrome-font-mono); font-size: 0.62rem; color: var(--chrome-fg); }
.dub-cast__select { width: 95px; padding: 1px 3px; font-size: 0.60rem; }
/* Muted placeholder cast strip shown in the idle skeleton */
.dub-cast--muted { background: transparent; border-color: var(--chrome-border); }
.dub-cast--muted .dub-cast__label,
.dub-cast--muted .dub-cast__kicker { color: var(--chrome-fg-dim); }
.dub-cast--muted__chip {
font-family: var(--chrome-font-mono);
font-size: 0.62rem;
color: var(--chrome-fg-dim);
padding: 1px 6px;
background: transparent;
border: 1px solid var(--chrome-border);
border-radius: var(--chrome-radius-pill);
}
/* ── Output options row (footer) ─────────────────────────────── */
.dub-outputs-title { font-weight: 600; color: var(--color-fg); }
.dub-outputs-default { font-size: 0.6rem; padding: 2px 4px; width: 120px; }