feat(ui): convert standalone widget CSS to Tailwind utilities (#781)
Move the clearly-mechanical CSS (flex/grid, spacing, sizing, typography, simple colors/borders/radii, and simple hover/disabled states) for seven standalone widgets onto their JSX as Tailwind v4 utilities. Animation (@keyframes), glass/backdrop-filter, pseudo-element/compound/sibling selectors, !important, media queries, and any class referenced from another file are left in CSS verbatim. Exact pixels/colors are preserved via @theme token utilities plus arbitrary var()/px values; transitions use arbitrary-property syntax so the timing function stays identical (Tailwind's transition utilities inject a different default ease). No preflight is loaded, so every converted border pairs an explicit border-solid/border-dashed + color. - NetworkToggle: fully converted; NetworkToggle.css deleted and its import removed (all classes were local). - FloatingPill: converted the static content/label/meta/timer/error/progress track + dismiss button; kept the pill base (animation+glass+fixed pos), dot, progress-fill (base + indeterminate !important/animation), keyframes, the prefers-reduced-motion block, and the --done/--error descendant overrides. - AudioTrimmer: converted all audio-trimmer__* parts + trim-field*; kept the .audio-trimmer base rule (also targeted by unlayered overrides in index.css). - ReadinessChecklist: converted title/list/item/status-layout/label/detail/ fix/all-pass; kept the glass base, the rc-spin keyframe, and the dynamic status--pass/warn/fail/loading color+animation modifiers. - MultiLangPicker: converted chips/add/summary/search/list/section/option; kept the .multi-lang__drop dropdown (animation + shadow) and mlp-in keyframe. - WorkspaceVoices: converted only the local wv__active*/wv__empty-cta active- voice card; kept wv/wv__head/wv__title/wv__search*/wv__scroll/wv__empty/ wv--collapsed/wv__rename-input (shared with WorkspaceProjects.jsx). - WorkspaceHistory: converted the local wh/wh__* panel chrome (active chip state expressed as a mutually-exclusive ternary since utilities are equal specificity); kept the studio-with-history/studio-right/shell-narrow/ shell-mini layout rules (referenced by App.jsx, index.css, and tests). Verified: oxlint exit 0, oxfmt --check clean, vite build OK, 641 vitest pass, bun install --frozen-lockfile clean. 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
5b6f49ee0c
commit
349a40a261
@@ -6,114 +6,3 @@
|
||||
gap: var(--space-5);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.audio-trimmer__meta {
|
||||
display: flex;
|
||||
gap: var(--space-6);
|
||||
align-items: center;
|
||||
font-size: var(--text-base);
|
||||
color: var(--color-fg-muted);
|
||||
}
|
||||
.audio-trimmer__hint {
|
||||
margin-left: auto;
|
||||
color: var(--color-fg-subtle);
|
||||
font-size: var(--text-2xs);
|
||||
}
|
||||
|
||||
.audio-trimmer__error {
|
||||
color: var(--color-danger);
|
||||
font-size: var(--text-md);
|
||||
}
|
||||
|
||||
.audio-trimmer__toolbar {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
align-items: center;
|
||||
}
|
||||
.audio-trimmer__view-info {
|
||||
margin-left: auto;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-fg-subtle);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.audio-trimmer__ruler {
|
||||
width: 100%;
|
||||
height: 18px;
|
||||
background: #141414;
|
||||
border-radius: var(--radius-md);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.audio-trimmer__wave {
|
||||
width: 100%;
|
||||
height: 160px;
|
||||
background: #0f1112;
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
cursor: crosshair;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.audio-trimmer__fields {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr auto;
|
||||
gap: var(--space-4);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.trim-field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
background: #0f1112;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 2px 6px;
|
||||
}
|
||||
.trim-field--readonly { cursor: default; }
|
||||
.trim-field__label {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--color-fg-subtle);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
font-weight: var(--weight-semibold);
|
||||
}
|
||||
.trim-field__input {
|
||||
background: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
color: var(--color-fg);
|
||||
font-size: var(--text-md);
|
||||
padding: 4px 6px;
|
||||
width: 100%;
|
||||
min-width: 60px;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
.trim-field__value {
|
||||
padding: 4px 6px;
|
||||
color: #b8bb26;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-md);
|
||||
flex: 1;
|
||||
}
|
||||
.trim-field__value.is-err { color: var(--color-danger); }
|
||||
.trim-field__unit { font-size: var(--text-xs); color: var(--color-fg-subtle); }
|
||||
|
||||
.audio-trimmer__actions {
|
||||
display: flex;
|
||||
gap: var(--space-4);
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.audio-trimmer__kbd-hint {
|
||||
font-size: var(--text-base);
|
||||
color: var(--color-fg-subtle);
|
||||
}
|
||||
.audio-trimmer__actions-right {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.audio-trimmer__audio { display: none; }
|
||||
|
||||
@@ -703,7 +703,7 @@ export default function AudioTrimmer({ file, maxSeconds = 15, onConfirm, onCance
|
||||
}
|
||||
>
|
||||
<div ref={containerRef} tabIndex={-1} className="audio-trimmer">
|
||||
<div className="audio-trimmer__meta">
|
||||
<div className="flex gap-[var(--space-6)] items-center [font-size:var(--text-base)] text-fg-muted">
|
||||
<span>
|
||||
{decoding
|
||||
? t('trimmer.decoding')
|
||||
@@ -711,13 +711,15 @@ export default function AudioTrimmer({ file, maxSeconds = 15, onConfirm, onCance
|
||||
? `${t('trimmer.meta_length', { duration: fmtHMS(audioMeta.duration), sampleRate: audioMeta.sampleRate })}${peakProgress > 0 && peakProgress < 1 ? ` · ${t('trimmer.meta_rendering', { percent: Math.round(peakProgress * 100) })}` : ''}`
|
||||
: '…'}
|
||||
</span>
|
||||
<span className="audio-trimmer__hint">{t('trimmer.keyboard_hint')}</span>
|
||||
<span className="ml-auto text-fg-subtle [font-size:var(--text-2xs)]">
|
||||
{t('trimmer.keyboard_hint')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{error && <div className="audio-trimmer__error">{error}</div>}
|
||||
{error && <div className="text-danger [font-size:var(--text-md)]">{error}</div>}
|
||||
|
||||
{/* Zoom controls */}
|
||||
<div className="audio-trimmer__toolbar">
|
||||
<div className="flex gap-[var(--space-2)] items-center">
|
||||
<Button
|
||||
variant="subtle"
|
||||
iconSize="md"
|
||||
@@ -754,7 +756,7 @@ export default function AudioTrimmer({ file, maxSeconds = 15, onConfirm, onCance
|
||||
>
|
||||
{t('trimmer.fit_sel_btn')}
|
||||
</Button>
|
||||
<div className="audio-trimmer__view-info">
|
||||
<div className="ml-auto [font-size:var(--text-sm)] text-fg-subtle tabular-nums">
|
||||
{t('trimmer.view_range', {
|
||||
start: fmtHMS(viewStart),
|
||||
end: fmtHMS(viewEnd),
|
||||
@@ -764,15 +766,24 @@ export default function AudioTrimmer({ file, maxSeconds = 15, onConfirm, onCance
|
||||
</div>
|
||||
|
||||
{/* Ruler */}
|
||||
<canvas ref={rulerRef} className="audio-trimmer__ruler" />
|
||||
<canvas
|
||||
ref={rulerRef}
|
||||
className="w-full h-[18px] bg-[#141414] rounded-md border-b border-solid border-b-[rgba(255,255,255,0.05)]"
|
||||
/>
|
||||
|
||||
{/* Waveform */}
|
||||
<canvas ref={waveRef} onMouseDown={onCanvasDown} className="audio-trimmer__wave" />
|
||||
<canvas
|
||||
ref={waveRef}
|
||||
onMouseDown={onCanvasDown}
|
||||
className="w-full h-[160px] bg-[#0f1112] rounded-lg border border-solid border-[rgba(255,255,255,0.05)] cursor-crosshair touch-none"
|
||||
/>
|
||||
|
||||
{/* Numeric fields */}
|
||||
<div className="audio-trimmer__fields">
|
||||
<label className="trim-field">
|
||||
<span className="trim-field__label">{t('trimmer.start_label')}</span>
|
||||
<div className="grid grid-cols-[1fr_1fr_1fr_auto] gap-[var(--space-4)] items-center">
|
||||
<label className="flex items-center gap-[var(--space-2)] bg-[#0f1112] border border-solid border-border rounded-md px-[6px] py-[2px]">
|
||||
<span className="[font-size:var(--text-xs)] text-fg-subtle uppercase [letter-spacing:0.05em] font-semibold">
|
||||
{t('trimmer.start_label')}
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
@@ -785,12 +796,16 @@ export default function AudioTrimmer({ file, maxSeconds = 15, onConfirm, onCance
|
||||
commitStartInput();
|
||||
}
|
||||
}}
|
||||
className="trim-field__input"
|
||||
className="bg-transparent border-0 outline-none text-fg [font-size:var(--text-md)] px-[6px] py-[4px] w-full min-w-[60px] font-mono"
|
||||
/>
|
||||
<span className="trim-field__unit">{t('trimmer.unit_seconds')}</span>
|
||||
<span className="[font-size:var(--text-xs)] text-fg-subtle">
|
||||
{t('trimmer.unit_seconds')}
|
||||
</span>
|
||||
</label>
|
||||
<label className="trim-field">
|
||||
<span className="trim-field__label">{t('trimmer.end_label')}</span>
|
||||
<label className="flex items-center gap-[var(--space-2)] bg-[#0f1112] border border-solid border-border rounded-md px-[6px] py-[2px]">
|
||||
<span className="[font-size:var(--text-xs)] text-fg-subtle uppercase [letter-spacing:0.05em] font-semibold">
|
||||
{t('trimmer.end_label')}
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
@@ -803,17 +818,23 @@ export default function AudioTrimmer({ file, maxSeconds = 15, onConfirm, onCance
|
||||
commitEndInput();
|
||||
}
|
||||
}}
|
||||
className="trim-field__input"
|
||||
className="bg-transparent border-0 outline-none text-fg [font-size:var(--text-md)] px-[6px] py-[4px] w-full min-w-[60px] font-mono"
|
||||
/>
|
||||
<span className="trim-field__unit">{t('trimmer.unit_seconds')}</span>
|
||||
<span className="[font-size:var(--text-xs)] text-fg-subtle">
|
||||
{t('trimmer.unit_seconds')}
|
||||
</span>
|
||||
</label>
|
||||
<div className="trim-field trim-field--readonly">
|
||||
<span className="trim-field__label">{t('trimmer.length_label')}</span>
|
||||
<span className={`trim-field__value ${tooLong ? 'is-err' : ''}`}>
|
||||
<div className="flex items-center gap-[var(--space-2)] bg-[#0f1112] border border-solid border-border rounded-md px-[6px] py-[2px] cursor-default">
|
||||
<span className="[font-size:var(--text-xs)] text-fg-subtle uppercase [letter-spacing:0.05em] font-semibold">
|
||||
{t('trimmer.length_label')}
|
||||
</span>
|
||||
<span
|
||||
className={`px-[6px] py-[4px] font-mono [font-size:var(--text-md)] flex-1 ${tooLong ? 'text-danger' : 'text-[#b8bb26]'}`}
|
||||
>
|
||||
{(duration_ms / 1000).toFixed(2)}
|
||||
{t('trimmer.unit_seconds')}
|
||||
</span>
|
||||
<span className="trim-field__unit">
|
||||
<span className="[font-size:var(--text-xs)] text-fg-subtle">
|
||||
{tooLong
|
||||
? t('trimmer.too_long', { max: maxSeconds })
|
||||
: tooShort
|
||||
@@ -833,7 +854,7 @@ export default function AudioTrimmer({ file, maxSeconds = 15, onConfirm, onCance
|
||||
</div>
|
||||
|
||||
{/* Play / Action row */}
|
||||
<div className="audio-trimmer__actions">
|
||||
<div className="flex gap-[var(--space-4)] items-center flex-wrap">
|
||||
<Button
|
||||
variant="subtle"
|
||||
onClick={togglePlay}
|
||||
@@ -843,9 +864,11 @@ export default function AudioTrimmer({ file, maxSeconds = 15, onConfirm, onCance
|
||||
>
|
||||
{playing ? t('trimmer.pause') : t('trimmer.preview_selection')}
|
||||
</Button>
|
||||
<span className="audio-trimmer__kbd-hint">{t('trimmer.play_hint')}</span>
|
||||
<span className="[font-size:var(--text-base)] text-fg-subtle">
|
||||
{t('trimmer.play_hint')}
|
||||
</span>
|
||||
|
||||
<div className="audio-trimmer__actions-right">
|
||||
<div className="ml-auto flex gap-[var(--space-4)]">
|
||||
<Button variant="ghost" onClick={onCancel}>
|
||||
{t('trimmer.cancel')}
|
||||
</Button>
|
||||
@@ -860,12 +883,7 @@ export default function AudioTrimmer({ file, maxSeconds = 15, onConfirm, onCance
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<audio
|
||||
ref={audioRef}
|
||||
preload="auto"
|
||||
onEnded={() => setPlaying(false)}
|
||||
className="audio-trimmer__audio"
|
||||
/>
|
||||
<audio ref={audioRef} preload="auto" onEnded={() => setPlaying(false)} className="hidden" />
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@@ -98,55 +98,14 @@
|
||||
}
|
||||
|
||||
/* ── Content area ───────────────────────────────────────────────────── */
|
||||
|
||||
.floating-pill__content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.floating-pill__label {
|
||||
font-weight: var(--weight-medium);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.floating-pill__meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--color-fg-muted);
|
||||
}
|
||||
|
||||
.floating-pill__timer {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-2xs);
|
||||
color: var(--color-fg-subtle);
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.floating-pill__error {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--color-danger);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
/* .floating-pill__content / __label / __meta / __timer / __error layout
|
||||
moved to Tailwind utilities on the JSX. The base __label rule lives there
|
||||
too; the unlayered `.floating-pill--done .floating-pill__label` override
|
||||
below still wins (utilities are low-priority @layer). */
|
||||
|
||||
/* ── Mini progress bar ──────────────────────────────────────────────── */
|
||||
|
||||
.floating-pill__progress {
|
||||
width: 100%;
|
||||
height: 3px;
|
||||
border-radius: 2px;
|
||||
background: var(--color-bg-elev-2);
|
||||
overflow: hidden;
|
||||
margin-top: 2px;
|
||||
}
|
||||
/* .floating-pill__progress (track) moved to Tailwind utilities on the JSX.
|
||||
The fill (base + indeterminate) stays here — it has !important + animation. */
|
||||
|
||||
.floating-pill__progress-fill {
|
||||
height: 100%;
|
||||
@@ -166,27 +125,7 @@
|
||||
}
|
||||
|
||||
/* ── Dismiss button ─────────────────────────────────────────────────── */
|
||||
|
||||
.floating-pill__dismiss {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--color-fg-subtle);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
transition: background var(--dur-fast) var(--ease-out),
|
||||
color var(--dur-fast) var(--ease-out);
|
||||
}
|
||||
|
||||
.floating-pill__dismiss:hover {
|
||||
background: rgba(255,255,255,0.08);
|
||||
color: var(--color-fg);
|
||||
}
|
||||
/* .floating-pill__dismiss + :hover moved to Tailwind utilities on the JSX. */
|
||||
|
||||
/* ── Done stage — green tint ────────────────────────────────────────── */
|
||||
|
||||
|
||||
@@ -101,19 +101,24 @@ export default function FloatingPill() {
|
||||
<span className={`floating-pill__dot floating-pill__dot--${stage}`} />
|
||||
|
||||
{/* Content */}
|
||||
<div className="floating-pill__content">
|
||||
<span className="floating-pill__label">
|
||||
<div className="floating-pill__content flex-1 min-w-0 flex flex-col gap-[2px]">
|
||||
<span className="floating-pill__label font-medium whitespace-nowrap overflow-hidden text-ellipsis">
|
||||
{stageEmoji} {label}
|
||||
</span>
|
||||
|
||||
{/* Meta row: timer + progress text */}
|
||||
<div className="floating-pill__meta">
|
||||
<div className="floating-pill__meta flex items-center gap-[var(--space-3)] [font-size:var(--text-xs)] text-fg-muted">
|
||||
{isActive && elapsed > 0 && (
|
||||
<span className="floating-pill__timer">{formatElapsed(elapsed)}</span>
|
||||
<span className="floating-pill__timer font-mono [font-size:var(--text-2xs)] text-fg-subtle [letter-spacing:0.03em]">
|
||||
{formatElapsed(elapsed)}
|
||||
</span>
|
||||
)}
|
||||
{progress !== null && isActive && <span>{Math.round(progress)}%</span>}
|
||||
{isError && error && (
|
||||
<span className="floating-pill__error" title={error}>
|
||||
<span
|
||||
className="floating-pill__error [font-size:var(--text-xs)] text-danger whitespace-nowrap overflow-hidden text-ellipsis"
|
||||
title={error}
|
||||
>
|
||||
{error}
|
||||
</span>
|
||||
)}
|
||||
@@ -121,7 +126,7 @@ export default function FloatingPill() {
|
||||
|
||||
{/* Mini progress bar */}
|
||||
{isActive && (
|
||||
<div className="floating-pill__progress">
|
||||
<div className="floating-pill__progress w-full h-[3px] rounded-[2px] bg-bg-elev-2 overflow-hidden mt-[2px]">
|
||||
<div
|
||||
className={[
|
||||
'floating-pill__progress-fill',
|
||||
@@ -137,7 +142,7 @@ export default function FloatingPill() {
|
||||
|
||||
{/* Dismiss / cancel button */}
|
||||
<button
|
||||
className="floating-pill__dismiss"
|
||||
className="floating-pill__dismiss flex items-center justify-center w-[20px] h-[20px] rounded-full border-0 bg-transparent text-fg-subtle cursor-pointer flex-shrink-0 [transition:background_var(--dur-fast)_var(--ease-out),color_var(--dur-fast)_var(--ease-out)] hover:bg-[rgba(255,255,255,0.08)] hover:text-fg"
|
||||
onClick={handleDismiss}
|
||||
title={cancellable ? t('common.cancel') : t('common.dismiss')}
|
||||
aria-label={cancellable ? t('common.cancelOp') : t('common.dismissStatus')}
|
||||
|
||||
@@ -1,66 +1,3 @@
|
||||
.multi-lang {
|
||||
position: relative;
|
||||
}
|
||||
.multi-lang__chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
min-height: 28px;
|
||||
}
|
||||
.multi-lang__chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 8px;
|
||||
background: var(--chrome-hover-bg);
|
||||
border: 1px solid var(--chrome-border);
|
||||
border-radius: 999px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.68rem;
|
||||
font-weight: 500;
|
||||
color: var(--chrome-fg);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.multi-lang__chip-x {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--chrome-fg-muted);
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-radius: 999px;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
.multi-lang__chip-x:hover {
|
||||
color: var(--color-danger);
|
||||
}
|
||||
.multi-lang__add {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 999px;
|
||||
border: 1px dashed var(--chrome-border);
|
||||
background: none;
|
||||
color: var(--chrome-fg-muted);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.multi-lang__add:hover {
|
||||
background: var(--chrome-hover-bg);
|
||||
color: var(--chrome-fg);
|
||||
border-style: solid;
|
||||
}
|
||||
.multi-lang__summary {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.62rem;
|
||||
color: var(--chrome-fg-dim);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* Dropdown */
|
||||
.multi-lang__drop {
|
||||
position: absolute;
|
||||
@@ -83,66 +20,3 @@
|
||||
from { opacity: 0; transform: translateY(-4px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
.multi-lang__search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 10px;
|
||||
border-bottom: 1px solid var(--chrome-border);
|
||||
color: var(--chrome-fg-muted);
|
||||
}
|
||||
.multi-lang__search input {
|
||||
flex: 1;
|
||||
background: none;
|
||||
border: none;
|
||||
outline: none;
|
||||
color: var(--chrome-fg);
|
||||
font-family: var(--font-sans);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
.multi-lang__list {
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
padding: 4px 0;
|
||||
}
|
||||
.multi-lang__section {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.62rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--chrome-fg-dim);
|
||||
padding: 6px 10px 2px;
|
||||
}
|
||||
.multi-lang__option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 5px 10px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--chrome-fg);
|
||||
font-family: var(--font-sans);
|
||||
font-size: 0.76rem;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
.multi-lang__option:hover {
|
||||
background: var(--chrome-hover-bg);
|
||||
}
|
||||
.multi-lang__option-code {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.68rem;
|
||||
color: var(--chrome-accent);
|
||||
min-width: 28px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.multi-lang__more,
|
||||
.multi-lang__empty {
|
||||
padding: 8px 10px;
|
||||
font-size: 0.7rem;
|
||||
color: var(--chrome-fg-dim);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@@ -72,16 +72,19 @@ export default function MultiLangPicker({
|
||||
}, [query, selectedCodes]);
|
||||
|
||||
return (
|
||||
<div className="multi-lang" ref={dropRef}>
|
||||
<div className="multi-lang__chips">
|
||||
<div className="relative" ref={dropRef}>
|
||||
<div className="flex flex-wrap gap-[4px] items-center min-h-[28px]">
|
||||
{selected.map((s) => (
|
||||
<span key={s.code} className="multi-lang__chip">
|
||||
<span
|
||||
key={s.code}
|
||||
className="inline-flex items-center gap-[4px] px-[8px] py-[2px] bg-[var(--chrome-hover-bg)] border border-solid border-[var(--chrome-border)] rounded-full [font-family:var(--font-mono)] text-[0.68rem] font-medium text-[color:var(--chrome-fg)] uppercase"
|
||||
>
|
||||
<Globe size={9} />
|
||||
<span>{s.code}</span>
|
||||
{!disabled && (
|
||||
<button
|
||||
type="button"
|
||||
className="multi-lang__chip-x"
|
||||
className="bg-transparent border-0 text-[color:var(--chrome-fg-muted)] cursor-pointer p-0 flex items-center rounded-full [transition:color_0.15s] hover:text-danger"
|
||||
onClick={() => removeLang(s.code)}
|
||||
aria-label={`Remove ${s.lang}`}
|
||||
>
|
||||
@@ -93,7 +96,7 @@ export default function MultiLangPicker({
|
||||
{!disabled && (
|
||||
<button
|
||||
type="button"
|
||||
className="multi-lang__add"
|
||||
className="flex items-center justify-center w-[24px] h-[24px] rounded-full border border-dashed border-[var(--chrome-border)] bg-transparent text-[color:var(--chrome-fg-muted)] cursor-pointer [transition:all_0.15s] hover:bg-[var(--chrome-hover-bg)] hover:text-[color:var(--chrome-fg)] hover:border-solid"
|
||||
onClick={() => setDropOpen(!dropOpen)}
|
||||
title={t('dub.add_language')}
|
||||
>
|
||||
@@ -103,14 +106,14 @@ export default function MultiLangPicker({
|
||||
</div>
|
||||
|
||||
{selected.length > 0 && (
|
||||
<div className="multi-lang__summary">
|
||||
<div className="[font-family:var(--font-mono)] text-[0.62rem] text-[color:var(--chrome-fg-dim)] mt-[4px]">
|
||||
{t('dub.languages_selected', { count: selected.length })}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{dropOpen && (
|
||||
<div className="multi-lang__drop">
|
||||
<div className="multi-lang__search">
|
||||
<div className="flex items-center gap-[6px] px-[10px] py-[8px] border-b border-solid border-b-[var(--chrome-border)] text-[color:var(--chrome-fg-muted)]">
|
||||
<Search size={10} />
|
||||
<input
|
||||
ref={inputRef}
|
||||
@@ -118,44 +121,55 @@ export default function MultiLangPicker({
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder={t('dub.search_languages')}
|
||||
spellCheck={false}
|
||||
className="flex-1 bg-transparent border-0 outline-none text-[color:var(--chrome-fg)] [font-family:var(--font-sans)] text-[0.78rem]"
|
||||
/>
|
||||
</div>
|
||||
<div className="multi-lang__list">
|
||||
<div className="overflow-y-auto flex-1 py-[4px]">
|
||||
{popularFiltered.length > 0 && (
|
||||
<>
|
||||
<div className="multi-lang__section">{t('dub.popular')}</div>
|
||||
<div className="[font-family:var(--font-mono)] text-[0.62rem] font-semibold uppercase [letter-spacing:0.04em] text-[color:var(--chrome-fg-dim)] pt-[6px] px-[10px] pb-[2px]">
|
||||
{t('dub.popular')}
|
||||
</div>
|
||||
{popularFiltered.map((item) => (
|
||||
<button
|
||||
key={item.code}
|
||||
type="button"
|
||||
className="multi-lang__option"
|
||||
className="flex items-center gap-[8px] w-full px-[10px] py-[5px] bg-transparent border-0 text-[color:var(--chrome-fg)] [font-family:var(--font-sans)] text-[0.76rem] cursor-pointer text-left [transition:background_0.1s] hover:bg-[var(--chrome-hover-bg)]"
|
||||
onClick={() => addLang(item.lang, item.code)}
|
||||
>
|
||||
<span className="multi-lang__option-code">{item.code}</span>
|
||||
<span className="[font-family:var(--font-mono)] text-[0.68rem] text-[color:var(--chrome-accent)] min-w-[28px] font-semibold">
|
||||
{item.code}
|
||||
</span>
|
||||
<span>{item.lang}</span>
|
||||
</button>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
<div className="multi-lang__section">{t('dub.all_languages')}</div>
|
||||
<div className="[font-family:var(--font-mono)] text-[0.62rem] font-semibold uppercase [letter-spacing:0.04em] text-[color:var(--chrome-fg-dim)] pt-[6px] px-[10px] pb-[2px]">
|
||||
{t('dub.all_languages')}
|
||||
</div>
|
||||
{filteredLangs.slice(0, 50).map((lc) => (
|
||||
<button
|
||||
key={lc.code}
|
||||
type="button"
|
||||
className="multi-lang__option"
|
||||
className="flex items-center gap-[8px] w-full px-[10px] py-[5px] bg-transparent border-0 text-[color:var(--chrome-fg)] [font-family:var(--font-sans)] text-[0.76rem] cursor-pointer text-left [transition:background_0.1s] hover:bg-[var(--chrome-hover-bg)]"
|
||||
onClick={() => addLang(lc.label, lc.code)}
|
||||
>
|
||||
<span className="multi-lang__option-code">{lc.code}</span>
|
||||
<span className="[font-family:var(--font-mono)] text-[0.68rem] text-[color:var(--chrome-accent)] min-w-[28px] font-semibold">
|
||||
{lc.code}
|
||||
</span>
|
||||
<span>{lc.label}</span>
|
||||
</button>
|
||||
))}
|
||||
{filteredLangs.length > 50 && (
|
||||
<div className="multi-lang__more">
|
||||
<div className="px-[10px] py-[8px] text-[0.7rem] text-[color:var(--chrome-fg-dim)] text-center">
|
||||
{t('dub.more_to_narrow', { count: filteredLangs.length - 50 })}
|
||||
</div>
|
||||
)}
|
||||
{filteredLangs.length === 0 && popularFiltered.length === 0 && (
|
||||
<div className="multi-lang__empty">{t('dub.no_matches')}</div>
|
||||
<div className="px-[10px] py-[8px] text-[0.7rem] text-[color:var(--chrome-fg-dim)] text-center">
|
||||
{t('dub.no_matches')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,197 +0,0 @@
|
||||
/* Footer Local/Network share toggle. Matches the logs-footer pill chrome
|
||||
(gruvbox palette, 20px pill height); the "Network" active state uses the
|
||||
accent green #b8bb26. The panel is an upward-opening dropdown card. */
|
||||
|
||||
.net-toggle {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ── Pill ─────────────────────────────────────────────────────────────── */
|
||||
.net-toggle__pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 2px 8px;
|
||||
height: 20px;
|
||||
border-radius: 3px;
|
||||
background: none;
|
||||
border: 1px solid transparent;
|
||||
color: #a89984;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
transition: all 0.1s;
|
||||
}
|
||||
.net-toggle__pill:hover {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: #ebdbb2;
|
||||
}
|
||||
.net-toggle__pill:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.net-toggle__pill--on {
|
||||
background: rgba(184, 187, 38, 0.12);
|
||||
border-color: rgba(184, 187, 38, 0.4);
|
||||
color: #b8bb26;
|
||||
}
|
||||
.net-toggle__pill--on:hover {
|
||||
background: rgba(184, 187, 38, 0.18);
|
||||
color: #b8bb26;
|
||||
}
|
||||
|
||||
/* ── Dropdown panel (opens upward, since the footer is pinned to bottom) ── */
|
||||
.net-toggle__panel {
|
||||
position: absolute;
|
||||
bottom: calc(100% + 8px);
|
||||
right: 0;
|
||||
z-index: 60;
|
||||
width: 248px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
background: var(--chrome-bg, #1d2021);
|
||||
border: 1px solid rgba(184, 187, 38, 0.35);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45);
|
||||
color: #ebdbb2;
|
||||
}
|
||||
|
||||
.net-toggle__panel-title {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: #b8bb26;
|
||||
}
|
||||
|
||||
.net-toggle__hint {
|
||||
margin: 0;
|
||||
font-size: 11px;
|
||||
line-height: 1.5;
|
||||
color: #a89984;
|
||||
}
|
||||
|
||||
/* ── Address row ──────────────────────────────────────────────────────── */
|
||||
.net-toggle__row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px;
|
||||
border-radius: 6px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
.net-toggle__row-main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
.net-toggle__addr {
|
||||
font-family: var(--chrome-font-mono, var(--font-mono, monospace));
|
||||
font-size: 11.5px;
|
||||
color: #ebdbb2;
|
||||
word-break: break-all;
|
||||
}
|
||||
.net-toggle__row-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.net-toggle__iconbtn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #a89984;
|
||||
cursor: pointer;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 3px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.1s;
|
||||
}
|
||||
.net-toggle__iconbtn:hover {
|
||||
color: #b8bb26;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.net-toggle__qr {
|
||||
display: block;
|
||||
width: 104px;
|
||||
height: 104px;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
/* ── PIN + stop ───────────────────────────────────────────────────────── */
|
||||
.net-toggle__pin {
|
||||
font-size: 12px;
|
||||
color: #a89984;
|
||||
text-align: center;
|
||||
}
|
||||
.net-toggle__pin strong {
|
||||
font-family: var(--chrome-font-mono, var(--font-mono, monospace));
|
||||
font-size: 14px;
|
||||
letter-spacing: 0.12em;
|
||||
color: #b8bb26;
|
||||
}
|
||||
|
||||
.net-toggle__off {
|
||||
background: rgba(251, 73, 52, 0.12);
|
||||
border: 1px solid rgba(251, 73, 52, 0.35);
|
||||
color: #fb4934;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
font-family: inherit;
|
||||
padding: 5px 10px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: all 0.1s;
|
||||
}
|
||||
.net-toggle__off:hover {
|
||||
background: rgba(251, 73, 52, 0.2);
|
||||
}
|
||||
.net-toggle__off:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.net-toggle__confirm-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.net-toggle__enable {
|
||||
background: rgba(184, 187, 38, 0.15);
|
||||
border: 1px solid rgba(184, 187, 38, 0.4);
|
||||
color: #b8bb26;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
font-family: inherit;
|
||||
padding: 5px 12px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.net-toggle__enable:hover { background: rgba(184, 187, 38, 0.25); }
|
||||
.net-toggle__cancel {
|
||||
background: transparent;
|
||||
border: 1px solid var(--border, #504945);
|
||||
color: inherit;
|
||||
font-size: 11px;
|
||||
font-family: inherit;
|
||||
padding: 5px 12px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.net-toggle__enable:disabled,
|
||||
.net-toggle__cancel:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
@@ -7,7 +7,6 @@ import { Wifi, WifiOff, Copy, ExternalLink } from 'lucide-react';
|
||||
import toast from 'react-hot-toast';
|
||||
import { apiJson, apiPost } from '../api/client';
|
||||
import { openExternal } from '../api/external';
|
||||
import './NetworkToggle.css';
|
||||
|
||||
export default function NetworkToggle() {
|
||||
const { t } = useTranslation();
|
||||
@@ -79,9 +78,9 @@ export default function NetworkToggle() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="net-toggle">
|
||||
<div className="relative inline-flex items-center flex-shrink-0">
|
||||
<button
|
||||
className={`net-toggle__pill ${st.enabled ? 'net-toggle__pill--on' : ''}`}
|
||||
className={`inline-flex items-center gap-[5px] py-[2px] px-[8px] h-[20px] rounded-sm font-medium text-[11px] [font-family:inherit] cursor-pointer [transition:all_0.1s] border border-solid disabled:opacity-50 disabled:cursor-not-allowed ${st.enabled ? 'bg-[rgba(184,187,38,0.12)] border-[rgba(184,187,38,0.4)] text-[#b8bb26] hover:bg-[rgba(184,187,38,0.18)]' : 'bg-transparent border-transparent text-[#a89984] hover:bg-[rgba(255,255,255,0.04)] hover:text-fg'}`}
|
||||
onClick={st.enabled ? () => setOpen((o) => !o) : () => setConfirming((c) => !c)}
|
||||
disabled={busy}
|
||||
title={st.enabled ? t('network.sharing_on_title') : t('network.share_on_network')}
|
||||
@@ -93,19 +92,28 @@ export default function NetworkToggle() {
|
||||
</button>
|
||||
|
||||
{!st.enabled && confirming && (
|
||||
<div className="net-toggle__panel net-toggle__panel--confirm">
|
||||
<div className="net-toggle__panel-title">{t('network.share_confirm_title')}</div>
|
||||
<p className="net-toggle__hint">{t('network.share_confirm_hint')}</p>
|
||||
<div className="net-toggle__confirm-actions">
|
||||
<div className="absolute bottom-[calc(100%+8px)] right-0 z-[60] w-[248px] flex flex-col gap-[8px] p-[12px] bg-[var(--chrome-bg,#1d2021)] border border-solid border-[rgba(184,187,38,0.35)] rounded-[8px] shadow-[0_8px_24px_rgba(0,0,0,0.45)] text-fg">
|
||||
<div className="text-[11px] font-semibold uppercase [letter-spacing:0.06em] text-[#b8bb26]">
|
||||
{t('network.share_confirm_title')}
|
||||
</div>
|
||||
<p className="m-0 text-[11px] [line-height:1.5] text-[#a89984]">
|
||||
{t('network.share_confirm_hint')}
|
||||
</p>
|
||||
<div className="flex gap-[6px] mt-[8px]">
|
||||
<button
|
||||
type="button"
|
||||
className="net-toggle__cancel"
|
||||
className="bg-transparent border border-solid border-[var(--border,#504945)] [color:inherit] text-[11px] [font-family:inherit] py-[5px] px-[12px] rounded-md cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
onClick={() => setConfirming(false)}
|
||||
disabled={busy}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button type="button" className="net-toggle__enable" onClick={enable} disabled={busy}>
|
||||
<button
|
||||
type="button"
|
||||
className="bg-[rgba(184,187,38,0.15)] border border-solid border-[rgba(184,187,38,0.4)] text-[#b8bb26] text-[11px] font-semibold [font-family:inherit] py-[5px] px-[12px] rounded-md cursor-pointer hover:bg-[rgba(184,187,38,0.25)] disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
onClick={enable}
|
||||
disabled={busy}
|
||||
>
|
||||
{busy ? t('network.enabling') : t('network.enable')}
|
||||
</button>
|
||||
</div>
|
||||
@@ -113,23 +121,30 @@ export default function NetworkToggle() {
|
||||
)}
|
||||
|
||||
{st.enabled && open && (
|
||||
<div className="net-toggle__panel">
|
||||
<div className="net-toggle__panel-title">{t('network.shared_title')}</div>
|
||||
<div className="absolute bottom-[calc(100%+8px)] right-0 z-[60] w-[248px] flex flex-col gap-[8px] p-[12px] bg-[var(--chrome-bg,#1d2021)] border border-solid border-[rgba(184,187,38,0.35)] rounded-[8px] shadow-[0_8px_24px_rgba(0,0,0,0.45)] text-fg">
|
||||
<div className="text-[11px] font-semibold uppercase [letter-spacing:0.06em] text-[#b8bb26]">
|
||||
{t('network.shared_title')}
|
||||
</div>
|
||||
{(st.lan_addresses || []).length === 0 && (
|
||||
<p className="net-toggle__hint">{t('network.no_interface')}</p>
|
||||
<p className="m-0 text-[11px] [line-height:1.5] text-[#a89984]">
|
||||
{t('network.no_interface')}
|
||||
</p>
|
||||
)}
|
||||
{(st.lan_addresses || []).map((ip) => {
|
||||
const url = `http://${ip}:${st.share_port}/?pin=${st.pin}`;
|
||||
return (
|
||||
<div key={ip} className="net-toggle__row">
|
||||
<div className="net-toggle__row-main">
|
||||
<code className="net-toggle__addr">
|
||||
<div
|
||||
key={ip}
|
||||
className="flex flex-col items-center gap-[8px] p-[8px] rounded-lg bg-[rgba(255,255,255,0.03)] border border-solid border-[rgba(255,255,255,0.05)]"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-[8px] w-full">
|
||||
<code className="[font-family:var(--chrome-font-mono,var(--font-mono,monospace))] text-[11.5px] text-fg break-all">
|
||||
{ip}:{st.share_port}
|
||||
</code>
|
||||
<div className="net-toggle__row-actions">
|
||||
<div className="flex items-center gap-[2px] flex-shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
className="net-toggle__iconbtn"
|
||||
className="bg-transparent border-0 text-[#a89984] cursor-pointer w-[22px] h-[22px] rounded-sm flex items-center justify-center [transition:all_0.1s] hover:text-[#b8bb26] hover:bg-[rgba(255,255,255,0.06)]"
|
||||
onClick={() => copy(url)}
|
||||
aria-label={`Copy ${ip}`}
|
||||
title={t('network.copy_link')}
|
||||
@@ -138,7 +153,7 @@ export default function NetworkToggle() {
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="net-toggle__iconbtn"
|
||||
className="bg-transparent border-0 text-[#a89984] cursor-pointer w-[22px] h-[22px] rounded-sm flex items-center justify-center [transition:all_0.1s] hover:text-[#b8bb26] hover:bg-[rgba(255,255,255,0.06)]"
|
||||
onClick={() => openExternal(url)}
|
||||
aria-label={`Open ${ip}`}
|
||||
title={t('network.open_in_browser')}
|
||||
@@ -149,7 +164,7 @@ export default function NetworkToggle() {
|
||||
</div>
|
||||
{qrs[ip] && (
|
||||
<img
|
||||
className="net-toggle__qr"
|
||||
className="block w-[104px] h-[104px] rounded-md bg-[#fff] p-[4px]"
|
||||
src={qrs[ip]}
|
||||
alt={t('network.qr_alt', { ip })}
|
||||
width={104}
|
||||
@@ -159,10 +174,18 @@ export default function NetworkToggle() {
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className="net-toggle__pin">
|
||||
{t('network.pin')} <strong>{st.pin}</strong>
|
||||
<div className="text-[12px] text-[#a89984] text-center">
|
||||
{t('network.pin')}{' '}
|
||||
<strong className="[font-family:var(--chrome-font-mono,var(--font-mono,monospace))] text-[14px] [letter-spacing:0.12em] text-[#b8bb26]">
|
||||
{st.pin}
|
||||
</strong>
|
||||
</div>
|
||||
<button type="button" className="net-toggle__off" onClick={disable} disabled={busy}>
|
||||
<button
|
||||
type="button"
|
||||
className="bg-[rgba(251,73,52,0.12)] border border-solid border-[rgba(251,73,52,0.35)] text-danger text-[11px] font-semibold [font-family:inherit] py-[5px] px-[10px] rounded-md cursor-pointer [transition:all_0.1s] hover:bg-[rgba(251,73,52,0.2)] disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
onClick={disable}
|
||||
disabled={busy}
|
||||
>
|
||||
{t('network.stop_sharing')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -17,54 +17,8 @@
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.readiness-checklist__title {
|
||||
font-weight: var(--weight-semibold);
|
||||
font-size: var(--text-md);
|
||||
color: var(--color-fg);
|
||||
margin: 0 0 var(--space-2) 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.readiness-checklist__title-icon {
|
||||
font-size: var(--text-lg);
|
||||
}
|
||||
|
||||
.readiness-checklist__list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.readiness-checklist__item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border-radius: var(--radius-sm);
|
||||
transition: background var(--dur-fast) var(--ease-out);
|
||||
}
|
||||
|
||||
.readiness-checklist__item:hover {
|
||||
background: var(--color-bg-elev-3);
|
||||
}
|
||||
|
||||
/* ── Status icons ───────────────────────────────────────────────────── */
|
||||
|
||||
.readiness-checklist__status {
|
||||
flex-shrink: 0;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
.readiness-checklist__status--pass { color: var(--color-success); }
|
||||
.readiness-checklist__status--warn { color: var(--color-accent); }
|
||||
.readiness-checklist__status--fail { color: var(--color-danger); }
|
||||
@@ -76,37 +30,3 @@
|
||||
@keyframes rc-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* ── Content ────────────────────────────────────────────────────────── */
|
||||
|
||||
.readiness-checklist__label {
|
||||
font-weight: var(--weight-medium);
|
||||
color: var(--color-fg);
|
||||
}
|
||||
|
||||
.readiness-checklist__detail {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--color-fg-muted);
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
.readiness-checklist__fix {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--color-accent);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* ── Compact summary when all pass ──────────────────────────────────── */
|
||||
|
||||
.readiness-checklist__all-pass {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
background: rgba(142, 192, 124, 0.08);
|
||||
border: 1px solid rgba(142, 192, 124, 0.15);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--color-success);
|
||||
font-weight: var(--weight-medium);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
@@ -111,8 +111,8 @@ export default function ReadinessChecklist({ compact = false, showWhenAllPass =
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="readiness-checklist">
|
||||
<div className="readiness-checklist__title">
|
||||
<span className="readiness-checklist__title-icon">
|
||||
<div className="flex items-center gap-[var(--space-3)] font-semibold [font-size:var(--text-md)] text-fg m-0 mb-[var(--space-2)]">
|
||||
<span className="[font-size:var(--text-lg)]">
|
||||
<Search size={14} />
|
||||
</span>
|
||||
{t('readiness.checking_system')}
|
||||
@@ -126,7 +126,7 @@ export default function ReadinessChecklist({ compact = false, showWhenAllPass =
|
||||
const issues = checks.filter((c) => c.status !== 'pass');
|
||||
if (issues.length === 0) {
|
||||
return (
|
||||
<div className="readiness-checklist__all-pass">
|
||||
<div className="flex items-center gap-[var(--space-3)] py-[var(--space-3)] px-[var(--space-4)] bg-[rgba(142,192,124,0.08)] border border-solid border-[rgba(142,192,124,0.15)] rounded-md text-success font-medium [font-size:var(--text-sm)]">
|
||||
<CheckCircle size={14} />
|
||||
{t('readiness.all_ready')}
|
||||
</div>
|
||||
@@ -134,17 +134,22 @@ export default function ReadinessChecklist({ compact = false, showWhenAllPass =
|
||||
}
|
||||
return (
|
||||
<div className="readiness-checklist">
|
||||
<ul className="readiness-checklist__list">
|
||||
<ul className="list-none m-0 p-0 flex flex-col gap-[var(--space-2)]">
|
||||
{issues.map((check) => (
|
||||
<li key={check.id} className="readiness-checklist__item">
|
||||
<li
|
||||
key={check.id}
|
||||
className="flex items-start gap-[var(--space-3)] py-[var(--space-2)] px-[var(--space-3)] rounded-sm [transition:background_var(--dur-fast)_var(--ease-out)] hover:bg-bg-elev-3"
|
||||
>
|
||||
<span
|
||||
className={`readiness-checklist__status readiness-checklist__status--${check.status}`}
|
||||
className={`shrink-0 w-[16px] h-[16px] flex items-center justify-center mt-[1px] readiness-checklist__status readiness-checklist__status--${check.status}`}
|
||||
>
|
||||
<StatusIcon status={check.status} />
|
||||
</span>
|
||||
<div>
|
||||
<div className="readiness-checklist__label">{check.label}</div>
|
||||
{check.fix && <div className="readiness-checklist__fix">{check.fix}</div>}
|
||||
<div className="font-medium text-fg">{check.label}</div>
|
||||
{check.fix && (
|
||||
<div className="[font-size:var(--text-xs)] text-accent mt-[2px]">{check.fix}</div>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
@@ -155,25 +160,30 @@ export default function ReadinessChecklist({ compact = false, showWhenAllPass =
|
||||
|
||||
return (
|
||||
<div className="readiness-checklist">
|
||||
<div className="readiness-checklist__title">
|
||||
<span className="readiness-checklist__title-icon">
|
||||
<div className="flex items-center gap-[var(--space-3)] font-semibold [font-size:var(--text-md)] text-fg m-0 mb-[var(--space-2)]">
|
||||
<span className="[font-size:var(--text-lg)]">
|
||||
{anyFail ? <AlertTriangle size={14} /> : <CheckCircle size={14} />}
|
||||
</span>
|
||||
{t('readiness.system_readiness')}
|
||||
</div>
|
||||
<ul className="readiness-checklist__list">
|
||||
<ul className="list-none m-0 p-0 flex flex-col gap-[var(--space-2)]">
|
||||
{checks.map((check) => (
|
||||
<li key={check.id} className="readiness-checklist__item">
|
||||
<li
|
||||
key={check.id}
|
||||
className="flex items-start gap-[var(--space-3)] py-[var(--space-2)] px-[var(--space-3)] rounded-sm [transition:background_var(--dur-fast)_var(--ease-out)] hover:bg-bg-elev-3"
|
||||
>
|
||||
<span
|
||||
className={`readiness-checklist__status readiness-checklist__status--${check.status}`}
|
||||
className={`shrink-0 w-[16px] h-[16px] flex items-center justify-center mt-[1px] readiness-checklist__status readiness-checklist__status--${check.status}`}
|
||||
>
|
||||
<StatusIcon status={check.status} />
|
||||
</span>
|
||||
<div>
|
||||
<div className="readiness-checklist__label">{check.label}</div>
|
||||
<div className="readiness-checklist__detail">{check.detail}</div>
|
||||
<div className="font-medium text-fg">{check.label}</div>
|
||||
<div className="[font-size:var(--text-xs)] text-fg-muted mt-[1px]">
|
||||
{check.detail}
|
||||
</div>
|
||||
{check.fix && (
|
||||
<div className="readiness-checklist__fix">
|
||||
<div className="[font-size:var(--text-xs)] text-accent mt-[2px]">
|
||||
<Lightbulb size={12} /> {check.fix}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -60,83 +60,7 @@
|
||||
background: var(--chrome-bg);
|
||||
}
|
||||
|
||||
.wh {
|
||||
flex: 1 1 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
border-top: 1px solid var(--chrome-border-strong, var(--chrome-border));
|
||||
}
|
||||
|
||||
.wh__head {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--chrome-border);
|
||||
}
|
||||
|
||||
.wh__title {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-family: var(--chrome-font-mono, var(--font-mono));
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: var(--chrome-fg-muted);
|
||||
}
|
||||
|
||||
.wh__filters {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.wh__chip {
|
||||
flex: 0 0 auto;
|
||||
padding: 2px 10px;
|
||||
font-size: 0.68rem;
|
||||
font-weight: 500;
|
||||
color: var(--chrome-fg-muted);
|
||||
background: transparent;
|
||||
border: 1px solid var(--chrome-border-strong);
|
||||
border-radius: var(--chrome-radius-pill, 999px);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.wh__chip:hover {
|
||||
background: var(--chrome-hover-bg);
|
||||
color: var(--chrome-fg);
|
||||
}
|
||||
|
||||
.wh__chip.is-active {
|
||||
color: var(--color-brand, #d3869b);
|
||||
background: color-mix(in srgb, var(--color-brand, #d3869b) 12%, transparent);
|
||||
border-color: color-mix(in srgb, var(--color-brand, #d3869b) 35%, transparent);
|
||||
}
|
||||
|
||||
.wh__scroll {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.wh__empty {
|
||||
color: var(--chrome-fg-dim);
|
||||
font-size: 0.72rem;
|
||||
line-height: 1.5;
|
||||
text-align: center;
|
||||
padding: 32px 16px;
|
||||
}
|
||||
/* Panel chrome (.wh / .wh__*) moved to Tailwind utilities on WorkspaceHistory.jsx. */
|
||||
|
||||
/* On a narrow shell the right column drops below the definition column. Same
|
||||
shell-class trigger as above (not a viewport media query) so it reflows in
|
||||
|
||||
@@ -93,15 +93,15 @@ export default function WorkspaceHistory({
|
||||
// ── Dub variant: a flat list of dub jobs, no clone/design filter. ──
|
||||
if (variant === 'dub') {
|
||||
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.dub_title', { defaultValue: 'Dub history' })}
|
||||
</span>
|
||||
</div>
|
||||
<div className="wh__scroll">
|
||||
<div className="flex-[1_1_auto] min-h-0 overflow-y-auto flex flex-col gap-[8px] p-[8px]">
|
||||
{dubHistory.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_dub', { defaultValue: 'Your dubs will appear here.' })}
|
||||
</div>
|
||||
) : (
|
||||
@@ -160,12 +160,16 @@ export default function WorkspaceHistory({
|
||||
<span className="wh__title">
|
||||
<History size={13} /> {t('history.title', { defaultValue: 'History' })}
|
||||
</span>
|
||||
<div className="wh__filters">
|
||||
<div className="flex flex-wrap gap-[4px]">
|
||||
{FILTERS.map((f) => (
|
||||
<button
|
||||
key={f.id}
|
||||
type="button"
|
||||
className={`wh__chip ${filter === f.id ? 'is-active' : ''}`}
|
||||
className={`flex-[0_0_auto] py-[2px] px-[10px] text-[0.68rem] font-medium border border-solid rounded-[var(--chrome-radius-pill,999px)] cursor-pointer [transition:background_0.15s_ease,color_0.15s_ease,border-color_0.15s_ease] ${
|
||||
filter === f.id
|
||||
? 'text-[color:var(--color-brand,#d3869b)] bg-[color-mix(in_srgb,var(--color-brand,#d3869b)_12%,transparent)] border-[color-mix(in_srgb,var(--color-brand,#d3869b)_35%,transparent)]'
|
||||
: 'bg-transparent text-[color:var(--chrome-fg-muted)] border-[var(--chrome-border-strong)] hover:bg-[var(--chrome-hover-bg)] hover:text-[color:var(--chrome-fg)]'
|
||||
}`}
|
||||
onClick={() => setFilter(f.id)}
|
||||
>
|
||||
{t(`history.filter_${f.id}`, { defaultValue: f.label })}
|
||||
|
||||
@@ -16,60 +16,9 @@
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
/* ── Active voice card (10x §2) — identity always visible ─────── */
|
||||
.wv__active {
|
||||
flex: 0 0 auto;
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--chrome-border);
|
||||
}
|
||||
.wv__active-kicker {
|
||||
font-family: var(--chrome-font-mono, var(--font-mono));
|
||||
font-size: 0.62rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
/* 10x P4 contrast: readable kicker — dim (3.91:1 default theme) → muted. */
|
||||
color: var(--chrome-fg-muted, #a89984);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.wv__active-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--chrome-accent-border, rgba(211, 134, 155, 0.35));
|
||||
background: var(--chrome-accent-bg, rgba(211, 134, 155, 0.08));
|
||||
border-radius: 10px;
|
||||
}
|
||||
.wv__active-row { display: flex; align-items: center; gap: 8px; justify-content: space-between; }
|
||||
.wv__active-name { font-size: 0.8rem; font-weight: 600; color: var(--chrome-fg); min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.wv__active-recipe {
|
||||
font-size: 0.68rem;
|
||||
color: var(--chrome-fg-muted);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.wv__active-actions { display: flex; gap: 6px; }
|
||||
.wv__active-empty {
|
||||
font-size: 0.7rem;
|
||||
line-height: 1.5;
|
||||
color: var(--chrome-fg-muted);
|
||||
padding: 8px 10px;
|
||||
border: 1px dashed var(--chrome-border);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.wv__empty-cta {
|
||||
display: block;
|
||||
margin: 8px auto 0;
|
||||
padding: 4px 10px;
|
||||
font-size: 0.66rem;
|
||||
color: var(--chrome-fg-muted);
|
||||
background: transparent;
|
||||
border: 1px dashed var(--chrome-border);
|
||||
border-radius: var(--chrome-radius-pill, 999px);
|
||||
cursor: default;
|
||||
}
|
||||
/* Active-voice card + empty-state CTA (.wv__active*, .wv__empty-cta) converted
|
||||
to Tailwind utilities on WorkspaceVoices.jsx — these classes were local to
|
||||
this component, so their rules were removed here. */
|
||||
|
||||
.wv__head {
|
||||
flex: 0 0 auto;
|
||||
|
||||
@@ -70,14 +70,16 @@ export default function WorkspaceVoices({
|
||||
return (
|
||||
<section className={`wv ${items.length === 0 ? 'wv--collapsed' : ''}`}>
|
||||
{/* ── ACTIVE VOICE ─────────────────────────────────────────────── */}
|
||||
<div className="wv__active">
|
||||
<div className="wv__active-kicker">
|
||||
<div className="flex-[0_0_auto] py-[10px] px-[12px] border-b border-solid border-b-[var(--chrome-border)]">
|
||||
<div className="[font-family:var(--chrome-font-mono,var(--font-mono))] text-[0.62rem] uppercase [letter-spacing:0.06em] text-[color:var(--chrome-fg-muted,#a89984)] mb-[6px]">
|
||||
{t('voices.active', { defaultValue: 'Active voice' })}
|
||||
</div>
|
||||
{active ? (
|
||||
<div className="wv__active-card">
|
||||
<div className="wv__active-row">
|
||||
<span className="wv__active-name">{active.name}</span>
|
||||
<div className="flex flex-col gap-[6px] py-[8px] px-[10px] border border-solid border-[var(--chrome-accent-border,rgba(211,134,155,0.35))] bg-[var(--chrome-accent-bg,rgba(211,134,155,0.08))] rounded-[10px]">
|
||||
<div className="flex items-center gap-[8px] justify-between">
|
||||
<span className="text-[0.8rem] font-semibold text-[color:var(--chrome-fg)] min-w-0 overflow-hidden text-ellipsis whitespace-nowrap">
|
||||
{active.name}
|
||||
</span>
|
||||
<span
|
||||
className="history-kind"
|
||||
style={{
|
||||
@@ -88,7 +90,7 @@ export default function WorkspaceVoices({
|
||||
{active.instruct ? t('sidebar.design_label') : t('sidebar.clone_label')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="wv__active-recipe">
|
||||
<div className="text-[0.68rem] text-[color:var(--chrome-fg-muted)] whitespace-nowrap overflow-hidden text-ellipsis">
|
||||
{active.instruct ||
|
||||
t('voices.active_clone_recipe', {
|
||||
defaultValue: 'Cloned from your reference clip',
|
||||
@@ -102,7 +104,7 @@ export default function WorkspaceVoices({
|
||||
compact
|
||||
/>
|
||||
) : null}
|
||||
<div className="wv__active-actions">
|
||||
<div className="flex gap-[6px]">
|
||||
<button
|
||||
type="button"
|
||||
className="history-action-btn"
|
||||
@@ -113,7 +115,7 @@ export default function WorkspaceVoices({
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="wv__active-empty">
|
||||
<div className="text-[0.7rem] [line-height:1.5] text-[color:var(--chrome-fg-muted)] py-[8px] px-[10px] border border-dashed border-[var(--chrome-border)] rounded-[10px]">
|
||||
{t('voices.none_selected', {
|
||||
defaultValue: 'No voice selected — describe one, drop audio, or pick below.',
|
||||
})}
|
||||
@@ -143,7 +145,7 @@ export default function WorkspaceVoices({
|
||||
{/* Empty states carry verbs (10x §2). */}
|
||||
<button
|
||||
type="button"
|
||||
className="wv__empty-cta"
|
||||
className="block mt-[8px] mx-auto py-[4px] px-[10px] text-[0.66rem] text-[color:var(--chrome-fg-muted)] bg-transparent border border-dashed border-[var(--chrome-border)] rounded-[var(--chrome-radius-pill,999px)] cursor-default"
|
||||
onClick={() => setDefineMethod(defineMethod === 'audio' ? 'audio' : 'design')}
|
||||
>
|
||||
{defineMethod === 'audio'
|
||||
|
||||
Reference in New Issue
Block a user