Compare commits
19
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dc984ac195 | ||
|
|
15e5efed5f | ||
|
|
62035435e8 | ||
|
|
4eed552153 | ||
|
|
ae516cae63 | ||
|
|
85fd9ca799 | ||
|
|
86c701bff9 | ||
|
|
f4e318f9f2 | ||
|
|
287a6cb3a2 | ||
|
|
c29c276dc1 | ||
|
|
3d0705fdb7 | ||
|
|
8f7b242610 | ||
|
|
d0aa2fbd52 | ||
|
|
df845f45a0 | ||
|
|
d55976eff0 | ||
|
|
b2e578b21d | ||
|
|
f7b7e2c13a | ||
|
|
1550ce2976 | ||
|
|
7489bef085 |
@@ -770,13 +770,18 @@ jobs:
|
||||
print(f"preview manifest OK: {v} platforms={sorted(pk)}")
|
||||
PY
|
||||
|
||||
# ── Post-release version bump (versioning hard rule, owner-set 2026-06-11) ──
|
||||
# main is always last-release + 1 patch. The moment a stable v* tag is
|
||||
# released, bump the three version sources on main to the next patch so every
|
||||
# PR and preview build identifies as the next version. Pushes directly to
|
||||
# main with the workflow token (a metadata-only commit; CI runs on PRs).
|
||||
# ── Post-release version bump (OWNER-GATED as of 2026-07-01) ──────────────
|
||||
# Previously auto-ran after every stable v* tag to keep main = release + 1.
|
||||
# The owner now controls bumps manually ("keep 0.3.8; I say when to bump"), so
|
||||
# this job is OPT-IN: it runs ONLY when the repo variable AUTO_VERSION_BUMP is
|
||||
# set to 'true' (Settings → Secrets and variables → Actions → Variables).
|
||||
# Unset/anything-else → main stays at whatever it is after release. Re-enable
|
||||
# by setting the variable; disable again by unsetting it.
|
||||
version-bump:
|
||||
if: github.event_name == 'push' && github.ref_type == 'tag' && !contains(github.ref, '-')
|
||||
if: >-
|
||||
github.event_name == 'push' && github.ref_type == 'tag'
|
||||
&& !contains(github.ref, '-')
|
||||
&& vars.AUTO_VERSION_BUMP == 'true'
|
||||
runs-on: ubuntu-22.04
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
@@ -0,0 +1,570 @@
|
||||
# Test-install pipeline — CI-only verification that the app INSTALLS and
|
||||
# FIRST-RUNS (including the required default TTS model) on every supported
|
||||
# platform, producing throwaway installer artifacts.
|
||||
#
|
||||
# This workflow NEVER releases anything:
|
||||
# - no tag, no GitHub Release, no updater manifest, no publishing
|
||||
# - unsigned builds (updater artifacts disabled via a --config overlay, so
|
||||
# no TAURI_SIGNING_PRIVATE_KEY / APPLE_* secrets are needed or read)
|
||||
# - no version stamping/bumping — bundles carry whatever version is in git
|
||||
# - installers land as short-lived workflow ARTIFACTS (retention: 7 days)
|
||||
#
|
||||
# Two independent matrices per platform:
|
||||
# build — mirrors release.yml's bundle steps (uv + ffmpeg sidecars,
|
||||
# same `tauri build --target --bundles` invocation) minus
|
||||
# every tag/sign/publish part, then re-runs release.yml's
|
||||
# installer structural smoke (DMG mount / MSI quiet install /
|
||||
# AppImage extract) and uploads the installers.
|
||||
# first-run-smoke— sets up the backend venv exactly like the app's own first
|
||||
# launch (`uv sync --frozen --no-dev`, the command
|
||||
# lib.rs::ensure_venv_ready runs), boots the backend
|
||||
# headless, waits for the REQUIRED default model
|
||||
# (k2-fsa/OmniVoice, ~2.4 GB) to download + load, then runs
|
||||
# one real POST /generate synthesis and validates the WAV.
|
||||
#
|
||||
# Triggers: manual dispatch, or a push to the ci/test-install working branch
|
||||
# (so the run starts straight from the branch without merging to main).
|
||||
|
||||
name: Test Install (no release)
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches: ["ci/test-install"]
|
||||
|
||||
# Read-only token — this workflow must be structurally incapable of creating
|
||||
# tags/releases or pushing version bumps.
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: test-install-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
# Run all JavaScript actions on Node 24 (mirrors ci.yml / release.yml).
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
|
||||
jobs:
|
||||
# ── Installer builds (unsigned, artifacts only) ──────────────────────────
|
||||
build:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
# Same platform set as release.yml. Note: the Intel-mac leg uses
|
||||
# macos-15-intel — macos-13 retired in Dec 2025; macos-15-intel is
|
||||
# GitHub's designated x86_64 migration target (see release.yml).
|
||||
- os: macos-14
|
||||
slug: macos-arm64
|
||||
label: "macOS Apple Silicon"
|
||||
rust_target: aarch64-apple-darwin
|
||||
bundles: "app,dmg"
|
||||
- os: macos-15-intel
|
||||
slug: macos-x64
|
||||
label: "macOS Intel"
|
||||
rust_target: x86_64-apple-darwin
|
||||
bundles: "app,dmg"
|
||||
# Windows: MSI only — NSIS fails at makensis near its ~2 GB stub
|
||||
# limit (see release.yml).
|
||||
- os: windows-2022
|
||||
slug: windows-x64
|
||||
label: "Windows x64"
|
||||
rust_target: x86_64-pc-windows-msvc
|
||||
bundles: "msi"
|
||||
# Linux: AppImage only — tauri-bundler's .deb target currently fails
|
||||
# with "Failed to create control scripts" (see release.yml).
|
||||
- os: ubuntu-22.04
|
||||
slug: linux-x64
|
||||
label: "Linux x64"
|
||||
rust_target: x86_64-unknown-linux-gnu
|
||||
bundles: "appimage"
|
||||
|
||||
runs-on: ${{ matrix.os }}
|
||||
name: Build (${{ matrix.label }})
|
||||
timeout-minutes: 90
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
# ── Language runtimes (mirrors release.yml) ────────────────────────
|
||||
- name: Setup Rust (stable)
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: ${{ matrix.rust_target }}
|
||||
|
||||
- name: Rust cache
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: frontend/src-tauri -> target
|
||||
key: ${{ matrix.rust_target }}-testinstall
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v1
|
||||
|
||||
# ── Platform deps (Tauri host requirements only — no Python here) ─
|
||||
- name: macOS system deps
|
||||
if: runner.os == 'macOS'
|
||||
run: |
|
||||
brew install ffmpeg || true
|
||||
|
||||
- name: Linux system deps
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y \
|
||||
libwebkit2gtk-4.1-dev \
|
||||
build-essential curl wget file libxdo-dev libssl-dev \
|
||||
libayatana-appindicator3-dev librsvg2-dev \
|
||||
libasound2-dev ffmpeg
|
||||
|
||||
# ── Frontend build ─────────────────────────────────────────────────
|
||||
- name: Cache bun deps
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.bun/install/cache
|
||||
key: ${{ runner.os }}-bun-${{ hashFiles('frontend/bun.lock', 'bun.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-bun-
|
||||
|
||||
- name: Install frontend deps
|
||||
working-directory: frontend
|
||||
run: bun install
|
||||
|
||||
# ── Sidecars (verbatim from release.yml) ───────────────────────────
|
||||
# Pinned uv version mirrors the `UV_VERSION` constant in lib.rs; bump
|
||||
# both together when refreshing.
|
||||
- name: Bundle uv (${{ matrix.rust_target }})
|
||||
shell: bash
|
||||
env:
|
||||
UV_VERSION: "0.11.7"
|
||||
TRIPLE: ${{ matrix.rust_target }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p frontend/src-tauri/binaries
|
||||
case "$TRIPLE" in
|
||||
aarch64-apple-darwin|x86_64-apple-darwin|x86_64-unknown-linux-gnu)
|
||||
ARCHIVE="tar.gz"
|
||||
;;
|
||||
x86_64-pc-windows-msvc)
|
||||
ARCHIVE="zip"
|
||||
;;
|
||||
*)
|
||||
echo "Unsupported target for uv bundling: $TRIPLE"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
URL="https://github.com/astral-sh/uv/releases/download/${UV_VERSION}/uv-${TRIPLE}.${ARCHIVE}"
|
||||
echo "Fetching $URL"
|
||||
WORK=$(mktemp -d)
|
||||
if [ "$ARCHIVE" = "zip" ]; then
|
||||
curl -fsSL "$URL" -o "$WORK/uv.zip"
|
||||
unzip -j -o "$WORK/uv.zip" -d "$WORK"
|
||||
mv "$WORK/uv.exe" "frontend/src-tauri/binaries/uv-${TRIPLE}.exe"
|
||||
else
|
||||
curl -fsSL "$URL" | tar -xz -C "$WORK"
|
||||
mv "$WORK/uv-${TRIPLE}/uv" "frontend/src-tauri/binaries/uv-${TRIPLE}"
|
||||
chmod +x "frontend/src-tauri/binaries/uv-${TRIPLE}"
|
||||
fi
|
||||
ls -la "frontend/src-tauri/binaries/"
|
||||
|
||||
# Same constant lives in frontend/src-tauri/src/tools.rs:
|
||||
# FFMPEG_BTBN_VERSION — bump together.
|
||||
- name: Bundle ffmpeg + ffprobe (${{ matrix.rust_target }})
|
||||
shell: bash
|
||||
env:
|
||||
TRIPLE: ${{ matrix.rust_target }}
|
||||
FFMPEG_BTBN_VERSION: "latest"
|
||||
run: |
|
||||
set -euo pipefail
|
||||
BINDIR="frontend/src-tauri/binaries"
|
||||
mkdir -p "$BINDIR"
|
||||
WORK=$(mktemp -d)
|
||||
|
||||
case "$TRIPLE" in
|
||||
aarch64-apple-darwin|x86_64-apple-darwin)
|
||||
for TOOL in ffmpeg ffprobe; do
|
||||
if [ "$TOOL" = "ffmpeg" ]; then
|
||||
URL="https://evermeet.cx/ffmpeg/getrelease/zip"
|
||||
else
|
||||
URL="https://evermeet.cx/ffmpeg/getrelease/${TOOL}/zip"
|
||||
fi
|
||||
echo "Fetching $TOOL from evermeet.cx"
|
||||
curl -fsSL "$URL" -o "$WORK/${TOOL}.zip"
|
||||
unzip -o -j "$WORK/${TOOL}.zip" -d "$WORK"
|
||||
mv "$WORK/${TOOL}" "$BINDIR/${TOOL}-${TRIPLE}"
|
||||
chmod +x "$BINDIR/${TOOL}-${TRIPLE}"
|
||||
done
|
||||
;;
|
||||
x86_64-unknown-linux-gnu)
|
||||
URL="https://github.com/BtbN/FFmpeg-Builds/releases/download/${FFMPEG_BTBN_VERSION}/ffmpeg-master-${FFMPEG_BTBN_VERSION}-linux64-gpl.tar.xz"
|
||||
echo "Fetching ffmpeg from BtbN (linux64) — version=${FFMPEG_BTBN_VERSION}"
|
||||
curl -fsSL "$URL" -o "$WORK/ffmpeg.tar.xz"
|
||||
tar -xJf "$WORK/ffmpeg.tar.xz" -C "$WORK"
|
||||
EXTRACTED=$(find "$WORK" -type d -name "bin" | head -1)
|
||||
mv "$EXTRACTED/ffmpeg" "$BINDIR/ffmpeg-${TRIPLE}"
|
||||
mv "$EXTRACTED/ffprobe" "$BINDIR/ffprobe-${TRIPLE}"
|
||||
chmod +x "$BINDIR/ffmpeg-${TRIPLE}" "$BINDIR/ffprobe-${TRIPLE}"
|
||||
;;
|
||||
x86_64-pc-windows-msvc)
|
||||
URL="https://github.com/BtbN/FFmpeg-Builds/releases/download/${FFMPEG_BTBN_VERSION}/ffmpeg-master-${FFMPEG_BTBN_VERSION}-win64-gpl.zip"
|
||||
echo "Fetching ffmpeg from BtbN (win64) — version=${FFMPEG_BTBN_VERSION}"
|
||||
curl -fsSL "$URL" -o "$WORK/ffmpeg.zip"
|
||||
unzip -o "$WORK/ffmpeg.zip" -d "$WORK"
|
||||
EXTRACTED=$(find "$WORK" -type f -name "ffmpeg.exe" | head -1)
|
||||
EXTRACTED_DIR=$(dirname "$EXTRACTED")
|
||||
mv "$EXTRACTED_DIR/ffmpeg.exe" "$BINDIR/ffmpeg-${TRIPLE}.exe"
|
||||
mv "$EXTRACTED_DIR/ffprobe.exe" "$BINDIR/ffprobe-${TRIPLE}.exe"
|
||||
;;
|
||||
*)
|
||||
echo "⚠ No ffmpeg bundling for target: $TRIPLE (will download at first run)"
|
||||
;;
|
||||
esac
|
||||
ls -la "$BINDIR/"
|
||||
|
||||
# ── Tauri build — UNSIGNED, NO PUBLISH ─────────────────────────────
|
||||
# Invokes the tauri CLI directly (not tauri-action) so there is no
|
||||
# release codepath at all. A --config overlay turns off
|
||||
# createUpdaterArtifacts (tauri.conf.json has it on for release.yml),
|
||||
# because updater payload signing requires TAURI_SIGNING_PRIVATE_KEY —
|
||||
# deliberately absent here. macOS bundles still get the valid ad-hoc
|
||||
# seal from tauri.conf.json (bundle.macOS.signingIdentity = "-").
|
||||
- name: Tauri build (unsigned)
|
||||
working-directory: frontend
|
||||
shell: bash
|
||||
env:
|
||||
# GH runners have no FUSE; linuxdeploy must extract-and-run.
|
||||
APPIMAGE_EXTRACT_AND_RUN: 1
|
||||
run: |
|
||||
set -euo pipefail
|
||||
printf '%s\n' '{"bundle": {"createUpdaterArtifacts": false}}' > test-install-overlay.json
|
||||
bunx tauri build --target ${{ matrix.rust_target }} --bundles ${{ matrix.bundles }} --config test-install-overlay.json
|
||||
|
||||
# ── Installer smoke (mirrors release.yml's structural checks) ──────
|
||||
- name: Installer smoke (macOS)
|
||||
if: runner.os == 'macOS'
|
||||
timeout-minutes: 5
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
DMG=$(find frontend/src-tauri/target/${{ matrix.rust_target }}/release/bundle/dmg -name "*.dmg" | head -1)
|
||||
echo "Smoke-testing DMG: $DMG"
|
||||
MOUNT=$(hdiutil attach -nobrowse -readonly "$DMG" | tail -1 | grep -oE '/Volumes/.*$')
|
||||
APP=$(find "$MOUNT" -maxdepth 2 -name "*.app" | head -1)
|
||||
fail() { echo "FAIL — $1"; find "$APP/Contents" -maxdepth 4 -type f 2>/dev/null | head -40; hdiutil detach "$MOUNT" || true; exit 1; }
|
||||
[ -n "$APP" ] || { echo "FAIL — no .app inside DMG"; hdiutil detach "$MOUNT" || true; exit 1; }
|
||||
ls "$APP/Contents/MacOS"/* >/dev/null 2>&1 || fail "no shell binary in Contents/MacOS"
|
||||
find "$APP/Contents" -type f -name 'uv' | grep -q . || fail "bundled uv sidecar missing"
|
||||
find "$APP/Contents" -type f -name 'pyproject.toml' | grep -q . || fail "backend resource pyproject.toml missing"
|
||||
find "$APP/Contents" -type f -path '*/backend/main.py' | grep -q . || fail "backend source backend/main.py missing"
|
||||
echo "OK — bundle has shell + uv + backend resources"
|
||||
hdiutil detach "$MOUNT" || true
|
||||
|
||||
# Report-only signing verification (same script release.yml runs on
|
||||
# unsigned/preview paths) — asserts the ad-hoc seal is valid.
|
||||
- name: Verify macOS signing (report-only)
|
||||
if: runner.os == 'macOS'
|
||||
shell: bash
|
||||
run: |
|
||||
set -uo pipefail
|
||||
APP=$(find "frontend/src-tauri/target/${{ matrix.rust_target }}/release/bundle/macos" -maxdepth 1 -name '*.app' | head -1)
|
||||
[ -n "$APP" ] || { echo "FAIL — no .app found to verify"; exit 1; }
|
||||
echo "Unsigned test build → report-only verification."
|
||||
bash scripts/verify-macos-signing.sh "$APP"
|
||||
|
||||
- name: Installer smoke (Windows)
|
||||
if: runner.os == 'Windows'
|
||||
timeout-minutes: 5
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
MSI=$(find frontend/src-tauri/target/${{ matrix.rust_target }}/release/bundle/msi -name "*.msi" | head -1)
|
||||
echo "Smoke-testing MSI: $MSI"
|
||||
msiexec.exe //i "$(cygpath -w "$MSI")" //quiet //norestart
|
||||
INSTALL="/c/Program Files/OmniVoice Studio"
|
||||
fail() { echo "FAIL — $1. Contents:"; find "$INSTALL" -maxdepth 4 -type f 2>/dev/null | head -40; exit 1; }
|
||||
test -f "$INSTALL/omnivoice-studio.exe" || fail "shell exe missing"
|
||||
test -f "$INSTALL/uv.exe" || fail "bundled uv missing"
|
||||
find "$INSTALL" -type f -name 'pyproject.toml' | grep -q . || fail "backend resource pyproject.toml missing"
|
||||
find "$INSTALL" -type f -path '*backend*main.py' | grep -q . || fail "backend source main.py missing"
|
||||
echo "OK — MSI installed shell + uv + backend resources"
|
||||
|
||||
- name: Installer smoke (Linux)
|
||||
if: runner.os == 'Linux'
|
||||
timeout-minutes: 5
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
APPIMAGE=$(find frontend/src-tauri/target/${{ matrix.rust_target }}/release/bundle/appimage -name "*.AppImage" | head -1)
|
||||
APPIMAGE=$(realpath "$APPIMAGE")
|
||||
echo "Smoke-testing AppImage: $APPIMAGE"
|
||||
chmod +x "$APPIMAGE"
|
||||
EXTRACT_DIR="$(mktemp -d)"
|
||||
cd "$EXTRACT_DIR"
|
||||
"$APPIMAGE" --appimage-extract >/dev/null
|
||||
ROOT="$EXTRACT_DIR/squashfs-root"
|
||||
fail() { echo "FAIL — $1"; find "$ROOT" -maxdepth 5 -type f 2>/dev/null | head -40; exit 1; }
|
||||
{ [ -f "$ROOT/AppRun" ] || find "$ROOT" -type f \( -name "OmniVoice Studio" -o -name "omnivoice-studio" \) | grep -q .; } || fail "shell binary / AppRun missing"
|
||||
find "$ROOT" -type f -name 'uv' | grep -q . || fail "bundled uv sidecar missing"
|
||||
find "$ROOT" -type f -name 'pyproject.toml' | grep -q . || fail "backend resource pyproject.toml missing"
|
||||
find "$ROOT" -type f -path '*/backend/main.py' | grep -q . || fail "backend source backend/main.py missing"
|
||||
echo "OK — AppImage has shell + uv + backend resources"
|
||||
|
||||
# ── Collect + upload installers as short-lived artifacts ───────────
|
||||
- name: Collect installers
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
BUNDLE_DIR="frontend/src-tauri/target/${{ matrix.rust_target }}/release/bundle"
|
||||
mkdir -p test-install-artifacts
|
||||
find "$BUNDLE_DIR" -type f \
|
||||
\( -name "*.dmg" -o -name "*.msi" -o -name "*.AppImage" -o -name "*.deb" \) \
|
||||
-exec cp {} test-install-artifacts/ \;
|
||||
echo "Installers built:"
|
||||
ls -la test-install-artifacts/
|
||||
|
||||
- name: Upload installers (artifact only — NOT a release)
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: test-install-${{ matrix.slug }}
|
||||
path: test-install-artifacts/*
|
||||
retention-days: 7
|
||||
if-no-files-found: error
|
||||
|
||||
# ── First-run with required models (headless backend, per OS) ───────────
|
||||
# Replicates what the installed app does on first launch, without the GUI:
|
||||
# the same venv sync the Tauri shell runs, then backend boot → default
|
||||
# model download (k2-fsa/OmniVoice, ~2.4 GB) → one real synthesis.
|
||||
# CPU-only runners: device auto-detect resolves to cpu (or mps on the M1
|
||||
# runner) exactly as it would on a user's machine.
|
||||
first-run-smoke:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- os: macos-14
|
||||
slug: macos-arm64
|
||||
label: "macOS Apple Silicon"
|
||||
- os: macos-15-intel
|
||||
slug: macos-x64
|
||||
label: "macOS Intel"
|
||||
- os: windows-2022
|
||||
slug: windows-x64
|
||||
label: "Windows x64"
|
||||
- os: ubuntu-22.04
|
||||
slug: linux-x64
|
||||
label: "Linux x64"
|
||||
|
||||
runs-on: ${{ matrix.os }}
|
||||
name: First-run smoke (${{ matrix.label }})
|
||||
timeout-minutes: 75
|
||||
env:
|
||||
# Restricted-network resilience (mirrors ci.yml smoke-matrix).
|
||||
UV_HTTP_TIMEOUT: "120"
|
||||
UV_HTTP_RETRIES: "5"
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
# The Linux venv pulls CUDA-enabled torch (+ nvidia libs); reclaim the
|
||||
# runner space the preinstalled toolchains occupy so venv + ~2.4 GB
|
||||
# model fit comfortably.
|
||||
- name: Free disk space (Linux)
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
sudo rm -rf /usr/local/lib/android /usr/share/dotnet /opt/ghc /usr/local/share/boost || true
|
||||
df -h /
|
||||
|
||||
# Graceful skip when a runner genuinely lacks disk: log clearly what
|
||||
# was (not) covered instead of failing the whole run on ENOSPC.
|
||||
- name: Disk space gate
|
||||
id: disk
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
df -Pk . "$HOME" || true
|
||||
FREE_WS=$(df -Pk . | awk 'NR==2 {print int($4/1048576)}')
|
||||
FREE_HOME=$(df -Pk "$HOME" | awk 'NR==2 {print int($4/1048576)}')
|
||||
FREE=$(( FREE_WS < FREE_HOME ? FREE_WS : FREE_HOME ))
|
||||
echo "Free disk: workspace=${FREE_WS}G home=${FREE_HOME}G -> min=${FREE}G"
|
||||
if [ "$FREE" -lt 12 ]; then
|
||||
echo "::warning::First-run model smoke SKIPPED on ${{ matrix.label }} — only ${FREE} GB free (< 12 GB needed for venv + ~2.4 GB default model). Installer build coverage is unaffected."
|
||||
echo "proceed=false" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "proceed=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Setup Python 3.11
|
||||
if: steps.disk.outputs.proceed == 'true'
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
- name: Install uv
|
||||
if: steps.disk.outputs.proceed == 'true'
|
||||
uses: astral-sh/setup-uv@v3
|
||||
with:
|
||||
enable-cache: true
|
||||
cache-dependency-glob: "uv.lock"
|
||||
|
||||
- name: System deps (macOS)
|
||||
if: steps.disk.outputs.proceed == 'true' && runner.os == 'macOS'
|
||||
run: brew install ffmpeg libsndfile || true
|
||||
|
||||
- name: System deps (Windows)
|
||||
if: steps.disk.outputs.proceed == 'true' && runner.os == 'Windows'
|
||||
shell: bash
|
||||
run: |
|
||||
choco install ffmpeg -y --no-progress
|
||||
ffmpeg -version
|
||||
|
||||
- name: System deps (Linux)
|
||||
if: steps.disk.outputs.proceed == 'true' && runner.os == 'Linux'
|
||||
uses: awalsh128/cache-apt-pkgs-action@latest
|
||||
with:
|
||||
packages: ffmpeg libsndfile1
|
||||
version: 1.0
|
||||
|
||||
# Exactly the command the installed app's first launch runs
|
||||
# (lib.rs::ensure_venv_ready → `uv sync --frozen --no-dev`).
|
||||
#
|
||||
# Known platform gap surfaced by this smoke (2026-07-02): torch is locked
|
||||
# to 2.8.0, and PyTorch ships no macOS x86_64 wheels past 2.2.x — so the
|
||||
# locked dependency set cannot install on Intel Macs AT ALL. A real
|
||||
# Intel-Mac user's first launch hits the exact same wall. That is a
|
||||
# product bug, not a harness bug: surface it as a loud warning and skip
|
||||
# the rest of the smoke instead of failing a leg that can never pass
|
||||
# until the dependency gap is fixed.
|
||||
- name: Install backend venv (first-launch parity)
|
||||
if: steps.disk.outputs.proceed == 'true'
|
||||
id: venv
|
||||
shell: bash
|
||||
run: |
|
||||
set -uo pipefail
|
||||
if uv sync --frozen --no-dev 2>&1 | tee uv-sync.log; then
|
||||
echo "proceed=true" >> "$GITHUB_OUTPUT"
|
||||
elif grep -q "doesn't have a source distribution or wheel for the current platform" uv-sync.log; then
|
||||
echo "::warning::First-run smoke SKIPPED on ${{ matrix.label }} — the LOCKED dependency set cannot install on this platform (e.g. torch 2.8.0 has no macOS x86_64 wheels; PyTorch dropped Intel-mac support after 2.2.x). An end-user first launch on this platform fails the same way — this is a product-level dependency gap, not a CI harness issue."
|
||||
echo "proceed=false" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: First-run smoke — backend boot, required-model download, real synthesis
|
||||
if: steps.disk.outputs.proceed == 'true' && steps.venv.outputs.proceed == 'true'
|
||||
shell: bash
|
||||
timeout-minutes: 60
|
||||
env:
|
||||
# Generous cold-load budget for CPU runners on a fresh HF cache.
|
||||
OMNIVOICE_MODEL_LOAD_TIMEOUT: "1800"
|
||||
run: |
|
||||
set -uo pipefail
|
||||
BASE="http://127.0.0.1:3900"
|
||||
|
||||
# GH macOS Apple Silicon runners ADVERTISE torch MPS, but the
|
||||
# virtualized Metal shared pool cannot actually allocate (even a
|
||||
# 256-byte alloc fails with "MPS backend out of memory") — a runner
|
||||
# limitation, not a product bug; real M1 machines run MPS fine.
|
||||
# Hide MPS via a CI-only sitecustomize so device auto-detect
|
||||
# resolves to CPU, keeping the smoke CPU-only as on the other legs.
|
||||
if [ "${RUNNER_OS:-}" = "macOS" ]; then
|
||||
mkdir -p ci-sitecustomize
|
||||
cat > ci-sitecustomize/sitecustomize.py <<'PY'
|
||||
# CI-only shim (lives ONLY inside the test-install workflow job):
|
||||
# GitHub's Apple Silicon runners expose torch.backends.mps as
|
||||
# available, but Metal allocations fail in the VM. Report MPS as
|
||||
# unavailable so the backend's device auto-detect picks CPU.
|
||||
try:
|
||||
import torch
|
||||
torch.backends.mps.is_available = lambda: False # type: ignore[assignment]
|
||||
except Exception:
|
||||
pass
|
||||
PY
|
||||
export PYTHONPATH="$PWD/ci-sitecustomize${PYTHONPATH:+:$PYTHONPATH}"
|
||||
echo "MPS hidden for this smoke (CI runner limitation) — forcing CPU."
|
||||
fi
|
||||
|
||||
uv run --no-sync python backend/main.py > backend.log 2>&1 &
|
||||
SERVER_PID=$!
|
||||
trap 'kill $SERVER_PID 2>/dev/null || true' EXIT
|
||||
echo "backend pid: $SERVER_PID"
|
||||
|
||||
fail() {
|
||||
echo "::error::${{ matrix.label }}: $1"
|
||||
echo "── backend.log (last 120 lines) ──"
|
||||
tail -120 backend.log || true
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Phase 1: liveness — /health (torch import makes cold boot slow).
|
||||
UP=0
|
||||
for i in $(seq 1 60); do
|
||||
if curl -sf "$BASE/health" >/dev/null 2>&1; then
|
||||
echo "Phase 1 OK — /health up after ~$((i*5))s"
|
||||
UP=1
|
||||
break
|
||||
fi
|
||||
kill -0 $SERVER_PID 2>/dev/null || fail "backend process died during boot"
|
||||
sleep 5
|
||||
done
|
||||
[ "$UP" = "1" ] || fail "backend /health not responding after 300s"
|
||||
|
||||
curl -sf "$BASE/system/info" | python -c "import sys,json; d=json.load(sys.stdin); print('device:', d.get('device'), '| platform:', d.get('platform'), '| python:', d.get('python'), '| version:', d.get('version'))" || true
|
||||
|
||||
# Phase 2: required-model bootstrap — the lifespan preload downloads
|
||||
# and loads the default checkpoint (k2-fsa/OmniVoice) on first run.
|
||||
echo "Phase 2 — waiting for required-model download + load (fresh HF cache)…"
|
||||
ELAPSED=0
|
||||
READY=0
|
||||
LAST=""
|
||||
while [ $ELAPSED -lt 1800 ]; do
|
||||
LAST=$(curl -sf "$BASE/model/status" 2>/dev/null || echo '{}')
|
||||
STATUS=$(printf '%s' "$LAST" | python -c "import sys,json; d=json.load(sys.stdin); print(d.get('status','?'))" 2>/dev/null || echo '?')
|
||||
DETAIL=$(printf '%s' "$LAST" | python -c "import sys,json; d=json.load(sys.stdin); print(d.get('sub_stage',''), d.get('progress',''), d.get('error',''))" 2>/dev/null || echo '')
|
||||
echo " [${ELAPSED}s] model status: $STATUS $DETAIL"
|
||||
if [ "$STATUS" = "ready" ]; then READY=1; break; fi
|
||||
kill -0 $SERVER_PID 2>/dev/null || fail "backend died during model load"
|
||||
sleep 15
|
||||
ELAPSED=$((ELAPSED+15))
|
||||
done
|
||||
[ "$READY" = "1" ] || fail "required model not ready after 1800s (last status: $LAST)"
|
||||
echo "Phase 2 OK — required model downloaded + loaded in ~${ELAPSED}s"
|
||||
|
||||
# Phase 3: one REAL synthesis through the default engine — the
|
||||
# end-to-end proof that a fresh install can produce audio.
|
||||
echo "Phase 3 — POST /generate (real synthesis)…"
|
||||
HTTP_CODE=$(curl -sS -o smoke_out.wav -w "%{http_code}" --max-time 1200 \
|
||||
-F "text=OmniVoice Studio first run smoke test. This sentence validates a fresh installation with the required model." \
|
||||
-F "num_step=4" \
|
||||
"$BASE/generate") || fail "generate request failed (curl transport error)"
|
||||
if [ "$HTTP_CODE" != "200" ]; then
|
||||
echo "response body (first 2000 bytes):"; head -c 2000 smoke_out.wav || true; echo
|
||||
fail "generate returned HTTP $HTTP_CODE"
|
||||
fi
|
||||
SIZE=$(wc -c < smoke_out.wav | tr -d ' ')
|
||||
HEAD4=$(head -c 4 smoke_out.wav)
|
||||
[ "$HEAD4" = "RIFF" ] || fail "output is not a RIFF/WAV file (got: $HEAD4)"
|
||||
[ "$SIZE" -gt 40000 ] || fail "output WAV suspiciously small (${SIZE} bytes)"
|
||||
echo "Phase 3 OK — real synthesis produced a ${SIZE}-byte WAV on a fresh install"
|
||||
|
||||
# Phase 4: report what was downloaded (model cache inventory).
|
||||
echo "Phase 4 — downloaded model inventory:"
|
||||
for C in "$HOME/.cache/huggingface" "${LOCALAPPDATA:-}/OmniVoice/hf_cache" "${HF_HOME:-}"; do
|
||||
if [ -n "$C" ] && [ -d "$C" ]; then
|
||||
du -sh "$C" 2>/dev/null || true
|
||||
find "$C" -maxdepth 3 -type d -name "models--*" 2>/dev/null | sed 's/^/ /' || true
|
||||
fi
|
||||
done
|
||||
|
||||
echo "FIRST-RUN SMOKE PASSED on ${{ matrix.label }}"
|
||||
|
||||
- name: Upload smoke evidence (log + WAV)
|
||||
if: always() && steps.disk.outputs.proceed == 'true'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: first-run-smoke-${{ matrix.slug }}
|
||||
path: |
|
||||
backend.log
|
||||
smoke_out.wav
|
||||
retention-days: 7
|
||||
if-no-files-found: ignore
|
||||
@@ -6,6 +6,47 @@ The format is loosely based on [Keep a Changelog](https://keepachangelog.com/).
|
||||
Versions track the desktop app (`tauri.conf.json` + `frontend/src-tauri/Cargo.toml`).
|
||||
The bundled TTS model package (`pyproject.toml`) is versioned independently.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Changed
|
||||
|
||||
- **The app now always opens maximized (not fullscreen).** Window size and
|
||||
position are no longer carried over from the previous session — one manual
|
||||
resize used to make every later launch reopen at that smaller size,
|
||||
overriding the intended maximized default. Same behavior on macOS
|
||||
(zoomed window, not a fullscreen Space), Windows, and Linux.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Confucius4-TTS is now validated end-to-end — and actually loads.** The
|
||||
opt-in engine's first live run (Apple Silicon, CPU) caught three
|
||||
scaffold-era faults: the sidecar could never import `confuciustts` (upstream
|
||||
ships no packaging, so the documented `pip install -e` fails — the sidecar
|
||||
and bootstrap probe now put the clone on `sys.path`, like upstream's own
|
||||
example), the assumed 24 kHz sample rate was wrong (confirmed **22 050 Hz**,
|
||||
now regression-tested), and the docs demanded an Amphion/MaskGCT install
|
||||
that doesn't exist (all weights auto-download from HuggingFace). CPU is
|
||||
~17× realtime, so CUDA stays the recommended path; `gpu_compat` now
|
||||
advertises `("cuda", "cpu")`. (#590)
|
||||
|
||||
- **Parakeet TDT transcription now works without an NVIDIA GPU.** The
|
||||
`nemo-parakeet` ASR engine (parakeet-tdt-0.6b-v3, 25 languages, word
|
||||
timestamps) was hard-gated behind CUDA — but a live measurement on an Apple
|
||||
Silicon M2 shows it transcribing at ~10× realtime *on CPU*, roughly 20×
|
||||
faster than the default whisper-large-v3 on the same machine at equal
|
||||
accuracy. The false GPU gate is removed, so Mac and CPU-only users can now
|
||||
pick the dramatically faster engine in Settings → Engines.
|
||||
|
||||
- **8 GB GPUs: voice-clone/dub transcription no longer kills the backend.**
|
||||
On cards where the TTS model already held most of the VRAM (e.g. RTX
|
||||
4060 Ti 8 GB), loading whisper `large-v3` in float16 for a reference-clip
|
||||
or dub transcription died as a *native* CUDA out-of-memory abort — the
|
||||
whole backend process vanished with no error logged, and the app showed
|
||||
"Can't reach the local OmniVoice backend." A new VRAM preflight re-checks
|
||||
free GPU memory right before the ASR load and steps down float16 →
|
||||
int8 → CPU instead of attempting a load that can't fit (opt-out:
|
||||
`OMNIVOICE_ASR_VRAM_PREFLIGHT=0`). (#723)
|
||||
|
||||
## [0.3.8] — 2026-07-01
|
||||
|
||||
A stability-focused release that makes first-run and Windows "just work," ships
|
||||
|
||||
@@ -192,7 +192,7 @@ Everything else (new engines, fancy features) is downstream of "the thing instal
|
||||
|
||||
**Versioning (hard rule, owner-set 2026-06-11; single-source 2026-06-16):** main is always **latest release + 1 patch**. **`frontend/package.json` is the SINGLE SOURCE OF TRUTH for the app version** — vite injects `__APP_VERSION__` from it (first-run footer + every auto bug report), and `frontend/src-tauri/tauri.conf.json` reads its bundle version from it (`"version": "../package.json"`, so the MSI/dmg/updater version can't drift from the UI). Three toolchain-required **mirrors** are kept equal to it and bumped in lockstep — `frontend/src-tauri/Cargo.toml` + `pyproject.toml` (cargo/uv need a literal) and `backend/core/version.py`'s `_FALLBACK_VERSION` (the frozen-backend last resort; at runtime the backend reads its version from package metadata via `importlib.metadata`, which `backend.spec`'s `copy_metadata('omnivoice')` makes work in the frozen build too). Never hand-edit any mirror or re-hardcode a literal in `tauri.conf.json`. Guarded by `tests/test_app_version.py` (`test_all_version_files_in_lockstep` + `test_tauri_version_derives_from_package_json`). The moment `vX.Y.Z` is released, bump `package.json` (+ the mirrors) to `X.Y.(Z+1)`. Consequences:
|
||||
- Every PR and preview build identifies as the **next** version. Preview builds stamp `X.Y.(Z+1)-N` (run number), which semver-sorts **above** the last stable `X.Y.Z` — the updater ordering is natural, no comparator tricks needed.
|
||||
- Releasing = tag `vX.Y.(Z+1)` from main (version files already match), then immediately bump main to `X.Y.(Z+2)`. The post-release bump is automated by the `version-bump` job in release.yml; if it fails, do it manually in the same day.
|
||||
- Releasing = tag `vX.Y.(Z+1)` from main (version files already match), then immediately bump main to `X.Y.(Z+2)`. **Owner override (2026-07-01): the post-release bump is now MANUAL — the `version-bump` job in release.yml is opt-in behind the `AUTO_VERSION_BUMP` repo variable (default off), so `main` stays at the released version until the owner explicitly asks to bump.** (Historically the bump auto-ran; re-enable that by setting `AUTO_VERSION_BUMP=true`.) When pinned, `main` == the released tag; preview-build ordering and "release + 1" only resume once a bump is requested.
|
||||
- Docker: `ghcr.io/debpalash/omnivoice-studio:latest` = **main** (rolling preview); `:X.Y.Z` + `:X.Y` + `:stable` = tagged releases. `:latest` is the preview channel by design — stable users pin `:stable` or a version tag.
|
||||
- Do not bump minor/major or invent RCs/codenames without the owner asking. No "defer to next version" labels — scope is absorbed or declined, never re-versioned.
|
||||
|
||||
|
||||
@@ -292,10 +292,11 @@ OmniVoice ships a multi-engine TTS backend. The default engine (OmniVoice) is al
|
||||
| **Supertonic 3** ⚡ | 31 | — | — | ✅ CPU | ✅ CPU | ✅ CPU | OpenRAIL-M |
|
||||
| **MOSS-TTS-v1.5** ⚡ (8B) | 31 | ✅ | — | ✅ CUDA/CPU | ✅ CPU | ✅ CUDA/CPU | Apache-2.0 |
|
||||
| **dots.tts** ⚡ (2B) | 24 | ✅ | — | ✅ CUDA/CPU | ✅ CPU | ❌ | Apache-2.0 |
|
||||
| **Confucius4-TTS** ⚡ | 14 | ✅ | — | ✅ CUDA/CPU | ✅ CPU | ✅ CUDA/CPU | Apache-2.0 |
|
||||
|
||||
> **CUDA** = GPU-accelerated · **MPS** = Apple Silicon Metal · **CPU** = runs everywhere, slower for large models · KittenTTS and MOSS-TTS-Nano run realtime on CPU · MLX-Audio is Apple Silicon only · ⚡ = lazy-registered (installed on first use)
|
||||
>
|
||||
> **MOSS-TTS-v1.5** (8B, ~16 GB weights) and **dots.tts** (2B, ~9 GB weights) are heavyweight opt-in engines that run in their own isolated venv from a local clone — see [MOSS-TTS-v1.5](docs/engines/moss-tts-v15.md) and [dots.tts](docs/engines/dots-tts.md). Neither claims Apple-Silicon **MPS** (upstream is CUDA/CPU only; on a Mac they run on CPU). dots.tts upstream is Linux/macOS only — no Windows path.
|
||||
> **MOSS-TTS-v1.5** (8B, ~16 GB weights) and **dots.tts** (2B, ~9 GB weights) are heavyweight opt-in engines that run in their own isolated venv from a local clone — see [MOSS-TTS-v1.5](docs/engines/moss-tts-v15.md) and [dots.tts](docs/engines/dots-tts.md). Neither claims Apple-Silicon **MPS** (upstream is CUDA/CPU only; on a Mac they run on CPU). dots.tts upstream is Linux/macOS only — no Windows path. **Confucius4-TTS** (14-language cross-lingual zero-shot cloning) is similar — its own Python 3.10 venv from a clone; CUDA recommended, CPU validated end-to-end (slow, ~17× realtime; no MPS — tested slower than CPU); see [Confucius4-TTS](docs/engines/confucius4-tts.md).
|
||||
|
||||
### ASR Engines
|
||||
|
||||
@@ -308,7 +309,7 @@ OmniVoice ships a multi-engine ASR (speech-to-text) backend that powers dictatio
|
||||
| **Faster-Whisper (isolated)** | `faster-whisper-isolated` | ~100 | Same as Faster-Whisper but crash-isolated in a subprocess — an ASR crash won't take down the app |
|
||||
| **MLX Whisper** | `mlx-whisper` | ~100 | Native Apple Silicon speed (Apple MLX / Metal) |
|
||||
| **PyTorch Whisper** | `pytorch-whisper` | ~100 | CUDA / CPU fallback via 🤗 Transformers (no cuDNN 8 needed) |
|
||||
| **Parakeet TDT** | `nemo-parakeet` | English + 25 EU | SOTA English accuracy, auto language detection (NVIDIA NeMo, GPU only) |
|
||||
| **Parakeet TDT** | `nemo-parakeet` | English + 25 EU | SOTA accuracy at ~10× realtime even on CPU, auto language detection (NVIDIA NeMo, CUDA/CPU) |
|
||||
| **Moonshine** | `moonshine` | English | Edge / low-latency, ONNX |
|
||||
| **FunASR** | `funasr` | 50+ | All-in-one multilingual — built-in VAD + inline speaker diarization (SenseVoice) |
|
||||
| **sherpa-onnx** (live dictation) | `sherpa-onnx-asr` | 25 EU + 90+ | Live, faster-than-real-time dictation — small streaming/offline ONNX models (Parakeet TDT v3/v2, streaming Zipformer & Paraformer, Whisper Tiny), CPU, identical on macOS / Windows / Linux. Picked per-model in **Settings → Voice**. |
|
||||
|
||||
@@ -382,6 +382,11 @@ async def dub_ingest_url(req: DubIngestUrlRequest):
|
||||
|
||||
TRANSCRIBE_CHUNK_S = float(os.environ.get("OMNIVOICE_TRANSCRIBE_CHUNK_S", "30.0"))
|
||||
TRANSCRIBE_CHUNK_TIMEOUT_S = float(os.environ.get("OMNIVOICE_TRANSCRIBE_CHUNK_TIMEOUT_S", "120.0"))
|
||||
#: How many times to attempt each transcribe chunk before giving up on it. A
|
||||
#: transient wedge (esp. the first chunk, where whisperx cold-loads its model)
|
||||
#: shouldn't silently drop that whole window — retry once on a fresh pool so the
|
||||
#: transcript doesn't come back "missing the beginning".
|
||||
_CHUNK_TRANSCRIBE_ATTEMPTS = max(1, int(os.environ.get("OMNIVOICE_TRANSCRIBE_CHUNK_ATTEMPTS", "2")))
|
||||
|
||||
|
||||
_sse_event = dub_pipeline.sse_event
|
||||
@@ -567,36 +572,54 @@ async def dub_transcribe_stream(
|
||||
logger.exception("chunk transcribe failed (backend=%s)", _asr_backend.id)
|
||||
return {"chunks": [], "language": None, "error": str(e)}
|
||||
|
||||
try:
|
||||
# wait_for in a loop to yield pings so the EventSource connection doesn't drop
|
||||
fut = loop.run_in_executor(_gpu_pool, _transcribe_chunk)
|
||||
waited = 0.0
|
||||
part = None
|
||||
while True:
|
||||
done, pending = await asyncio.wait([fut], timeout=5.0)
|
||||
if done:
|
||||
part = done.pop().result()
|
||||
break
|
||||
yield _sse_event("ping", {})
|
||||
waited += 5.0
|
||||
if waited >= TRANSCRIBE_CHUNK_TIMEOUT_S:
|
||||
# Re-raise TimeoutError if we exceed the overall limit
|
||||
raise asyncio.TimeoutError()
|
||||
except asyncio.TimeoutError:
|
||||
logger.error(
|
||||
"Transcribe chunk %d/%d timed out after %.0fs (job=%s)",
|
||||
i + 1, chunks_n, TRANSCRIBE_CHUNK_TIMEOUT_S, job_id,
|
||||
)
|
||||
# #730: the wedged chunk thread keeps holding its GPU-pool worker.
|
||||
# Abandon the poisoned pool so the next chunk (and any TTS work)
|
||||
# gets a fresh worker instead of queueing behind the stuck one —
|
||||
# same recovery the whole-file paths get via run_transcribe_guarded.
|
||||
_reset_pool_on_wedge(_gpu_pool)
|
||||
part = {
|
||||
"chunks": [], "language": None,
|
||||
"error": f"Chunk {i+1} timed out after {TRANSCRIBE_CHUNK_TIMEOUT_S:.0f}s — "
|
||||
f"ASR backend may be stuck. Try restarting the server.",
|
||||
}
|
||||
# Retry a failed/timed-out chunk once on a fresh pool before giving
|
||||
# up. Otherwise a transient wedge on the FIRST chunk (whisperx often
|
||||
# cold-loads its model there, the #730 hang) drops that whole window
|
||||
# and the transcript is "missing the beginning, only middle+end".
|
||||
# The retry reuses the same audio window, so a recovered chunk fills
|
||||
# the hole instead of leaving silent gaps.
|
||||
part = None
|
||||
for _attempt in range(1, _CHUNK_TRANSCRIBE_ATTEMPTS + 1):
|
||||
try:
|
||||
# wait_for in a loop to yield pings so the EventSource connection doesn't drop
|
||||
fut = loop.run_in_executor(_gpu_pool, _transcribe_chunk)
|
||||
waited = 0.0
|
||||
while True:
|
||||
done, pending = await asyncio.wait([fut], timeout=5.0)
|
||||
if done:
|
||||
part = done.pop().result()
|
||||
break
|
||||
yield _sse_event("ping", {})
|
||||
waited += 5.0
|
||||
if waited >= TRANSCRIBE_CHUNK_TIMEOUT_S:
|
||||
# Re-raise TimeoutError if we exceed the overall limit
|
||||
raise asyncio.TimeoutError()
|
||||
except asyncio.TimeoutError:
|
||||
logger.error(
|
||||
"Transcribe chunk %d/%d timed out after %.0fs (attempt %d/%d, job=%s)",
|
||||
i + 1, chunks_n, TRANSCRIBE_CHUNK_TIMEOUT_S, _attempt,
|
||||
_CHUNK_TRANSCRIBE_ATTEMPTS, job_id,
|
||||
)
|
||||
# #730: the wedged chunk thread keeps holding its GPU-pool
|
||||
# worker. Abandon the poisoned pool so the retry (and any TTS
|
||||
# work) gets a fresh worker instead of queueing behind it.
|
||||
_reset_pool_on_wedge(_gpu_pool)
|
||||
part = {
|
||||
"chunks": [], "language": None,
|
||||
"error": f"Chunk {i+1} timed out after {TRANSCRIBE_CHUNK_TIMEOUT_S:.0f}s — "
|
||||
f"ASR backend may be stuck. Try restarting the server.",
|
||||
}
|
||||
# Success → keep it. Failure/timeout → retry once on a fresh
|
||||
# worker (the internal _transcribe_chunk except returns an
|
||||
# error-part; the timeout path already reset the pool).
|
||||
if part is not None and not part.get("error"):
|
||||
break
|
||||
if _attempt < _CHUNK_TRANSCRIBE_ATTEMPTS:
|
||||
logger.warning(
|
||||
"Retrying transcribe chunk %d/%d after failure/timeout (next attempt %d/%d, job=%s)",
|
||||
i + 1, chunks_n, _attempt + 1, _CHUNK_TRANSCRIBE_ATTEMPTS, job_id,
|
||||
)
|
||||
_reset_pool_on_wedge(_gpu_pool)
|
||||
if part.get("error"):
|
||||
chunk_errors.append(part["error"])
|
||||
logger.warning("Chunk %d/%d error: %s", i + 1, chunks_n, part["error"])
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
"""Confucius4-TTS sidecar package (issue #590).
|
||||
|
||||
Confucius4-TTS (netease-youdao) is an LLM-based multilingual / cross-lingual
|
||||
zero-shot voice-cloning TTS: 14 languages, **no reference transcript required**,
|
||||
cross-lingual voice transfer, Apache-2.0 (https://github.com/netease-youdao/Confucius4-TTS).
|
||||
|
||||
Like IndexTTS / MOSS-TTS-v1.5 / dots.tts it runs in its **own subprocess venv**
|
||||
(upstream: Python 3.10 + CUDA 12.6 + its own deps), isolated from the OmniVoice
|
||||
parent. It is **opt-in** — selected in the engine picker and enabled only when
|
||||
the user points ``OMNIVOICE_CONFUCIUS4_TTS_DIR`` at a clone — so it can never
|
||||
become a broken default on any platform (the strict default-parity rule).
|
||||
|
||||
Status (#590): **validated end-to-end** (2026-07-02, Apple Silicon, CPU) — the
|
||||
synthesis API (``confuciustts.cli.inference.ConfuciusTTS`` →
|
||||
``.generate(text, lang, prompt_wav)`` → tensor, ``model.sample_rate``) produced
|
||||
audible speech at 22 050 Hz; the sidecar's pure logic is unit-tested
|
||||
(``tests/test_confucius4_sidecar.py``). CPU inference is slow (~17× realtime),
|
||||
so CUDA is the recommended path. Gated off by default, so this affects no one
|
||||
until they opt in.
|
||||
|
||||
Three entry points: ``Confucius4Backend`` (this module), ``main.py`` (the sidecar,
|
||||
runs under the Confucius4 venv — never imported by the parent), and
|
||||
``bootstrap.py`` (venv probe + lazy bootstrap).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from services.subprocess_backend import SubprocessBackend
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import torch # noqa: F401
|
||||
|
||||
logger = logging.getLogger("omnivoice.confucius4")
|
||||
|
||||
|
||||
class Confucius4Backend(SubprocessBackend):
|
||||
"""Confucius4-TTS (netease-youdao) — LLM-based, 14 langs, zero-shot clone.
|
||||
|
||||
Runs in a long-lived sidecar over length-prefixed JSON-over-stdio in a
|
||||
dedicated venv. First synthesize cold-loads the checkpoint; subsequent calls
|
||||
reuse the process.
|
||||
|
||||
Installation::
|
||||
|
||||
git clone https://github.com/netease-youdao/Confucius4-TTS.git
|
||||
cd Confucius4-TTS
|
||||
uv venv --python 3.10 && uv pip install -r requirements.txt
|
||||
|
||||
(Upstream ships no pyproject.toml/setup.py, so there is nothing to
|
||||
``pip install -e`` — the sidecar sys.path-inserts the clone instead.)
|
||||
Then set ``OMNIVOICE_CONFUCIUS4_TTS_DIR`` to the clone root and restart.
|
||||
License: Apache-2.0. CUDA recommended; CPU validated but ~17× realtime.
|
||||
"""
|
||||
|
||||
id = "confucius4-tts"
|
||||
display_name = (
|
||||
"Confucius4-TTS (LLM, 14 langs, cross-lingual zero-shot clone, CUDA/CPU, Apache-2.0)"
|
||||
)
|
||||
supports_voice_design = False # timbre comes from a reference clip
|
||||
# Upstream vocoder rate (config target_sample_rate) — confirmed 22 050 Hz by
|
||||
# a live run (2026-07-02); still re-read from the sidecar's ready/audio frames.
|
||||
_DEFAULT_SAMPLE_RATE = 22050
|
||||
# CUDA fast path + CPU fallback, both exercised (CPU end-to-end validated).
|
||||
# No MPS claim — upstream has no Metal path.
|
||||
gpu_compat = ("cuda", "cpu")
|
||||
|
||||
@classmethod
|
||||
def is_available(cls) -> tuple[bool, str]:
|
||||
# Verify the venv on disk only — do NOT import the engine here (separate
|
||||
# interpreter). A real health-check runs on the user's "Test engine"
|
||||
# action in Settings.
|
||||
from engines.confucius4.bootstrap import (
|
||||
CONFUCIUS4_SIDECAR_SCRIPT,
|
||||
is_confucius4_installed,
|
||||
)
|
||||
if not is_confucius4_installed():
|
||||
return False, (
|
||||
"Confucius4-TTS venv not found. Set OMNIVOICE_CONFUCIUS4_TTS_DIR "
|
||||
"to your Confucius4-TTS clone (the directory containing "
|
||||
"requirements.txt) and restart OmniVoice. CUDA GPU recommended "
|
||||
"(CPU works but is slow). See docs/engines/confucius4-tts.md."
|
||||
)
|
||||
if not CONFUCIUS4_SIDECAR_SCRIPT.exists():
|
||||
return False, (
|
||||
"Confucius4-TTS sidecar script missing at "
|
||||
f"{CONFUCIUS4_SIDECAR_SCRIPT} — reinstall OmniVoice."
|
||||
)
|
||||
return True, "ok"
|
||||
|
||||
@classmethod
|
||||
def venv_python(cls):
|
||||
from engines.confucius4.bootstrap import resolve_confucius4_venv
|
||||
return resolve_confucius4_venv()
|
||||
|
||||
@classmethod
|
||||
def sidecar_script(cls):
|
||||
from engines.confucius4.bootstrap import CONFUCIUS4_SIDECAR_SCRIPT
|
||||
return CONFUCIUS4_SIDECAR_SCRIPT
|
||||
|
||||
@property
|
||||
def sample_rate(self) -> int:
|
||||
return self._DEFAULT_SAMPLE_RATE
|
||||
|
||||
@property
|
||||
def supported_languages(self) -> list[str]:
|
||||
# 14 languages with the caller's language passed through at synthesize
|
||||
# time; "multi" on the protocol surface.
|
||||
return ["multi"]
|
||||
|
||||
def generate(self, text: str, **kw) -> "torch.Tensor":
|
||||
"""Synthesize one utterance through the Confucius4 sidecar.
|
||||
|
||||
kwargs honored:
|
||||
* ``ref_audio`` — reference clip path → ``prompt_wav`` (zero-shot
|
||||
cloning). Optional but recommended for a specific voice.
|
||||
* ``language`` — ISO code / name → ``lang`` (cross-lingual transfer).
|
||||
* ``ref_text`` is intentionally ignored — Confucius4 is unconstrained
|
||||
cloning (no reference transcript needed).
|
||||
|
||||
Returns a tensor of shape (1, n_samples) at :attr:`sample_rate`.
|
||||
"""
|
||||
forwarded: dict = {}
|
||||
ref_audio = kw.get("ref_audio")
|
||||
if ref_audio:
|
||||
forwarded["ref_audio"] = ref_audio
|
||||
language = kw.get("language")
|
||||
if language:
|
||||
forwarded["language"] = str(language)
|
||||
return super().generate(text, **forwarded)
|
||||
|
||||
|
||||
__all__ = ["Confucius4Backend"]
|
||||
@@ -0,0 +1,217 @@
|
||||
"""Confucius4-TTS venv probe + lazy bootstrap (issue #590).
|
||||
|
||||
Confucius4-TTS (netease-youdao) is an LLM-based multilingual zero-shot cloning
|
||||
TTS — 14 languages, no reference transcript required, Apache-2.0. Like the other
|
||||
heavyweight opt-in engines (IndexTTS / MOSS-TTS-v1.5 / dots.tts) it runs in its
|
||||
**own subprocess venv**: upstream targets Python 3.10 + CUDA 12.6 with its own
|
||||
dependency set, which we keep off the parent interpreter.
|
||||
|
||||
Probe order (existing power-user installs win — zero migration):
|
||||
|
||||
1. ``${OMNIVOICE_CONFUCIUS4_TTS_DIR}/.venv/`` — the user's clone-level venv.
|
||||
2. ``backend/engines/confucius4/.venv/`` — this package's own venv.
|
||||
3. Bootstrap: ``uv venv`` then ``uv pip install -r <clone>/requirements.txt``
|
||||
(+ ``uv pip install -e <clone>`` only if upstream ever ships packaging).
|
||||
|
||||
Validated end-to-end 2026-07-02 (Apple Silicon, CPU): upstream ships **no
|
||||
pyproject.toml/setup.py**, so ``confuciustts`` is importable only with the
|
||||
clone root on ``sys.path`` — the import probe and the sidecar both handle
|
||||
that. The engine is opt-in (env-dir gated) and never touched unless
|
||||
``OMNIVOICE_CONFUCIUS4_TTS_DIR`` is set, so this can't affect the default
|
||||
install on any platform.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
logger = logging.getLogger("omnivoice.confucius4.bootstrap")
|
||||
|
||||
#: Absolute path to the sidecar entrypoint.
|
||||
CONFUCIUS4_SIDECAR_SCRIPT: Path = Path(__file__).parent / "main.py"
|
||||
|
||||
#: This package's owned venv (Probe 2).
|
||||
_ENGINES_VENV_DIR: Path = Path(__file__).parent / ".venv"
|
||||
|
||||
#: Env var pointing at the user's Confucius4-TTS clone root.
|
||||
_CLONE_DIR_ENV: str = "OMNIVOICE_CONFUCIUS4_TTS_DIR"
|
||||
|
||||
#: The package importable from the clone (verify against upstream).
|
||||
_IMPORT_PROBE = "confuciustts"
|
||||
|
||||
_resolved_python: Optional[Path] = None
|
||||
|
||||
_IMPORT_PROBE_TIMEOUT_S = 15
|
||||
_UV_VENV_TIMEOUT_S = 120
|
||||
_UV_PIP_INSTALL_TIMEOUT_S = 1800
|
||||
|
||||
|
||||
def invalidate() -> None:
|
||||
"""Clear the resolved-python cache. Tests call this between scenarios."""
|
||||
global _resolved_python
|
||||
_resolved_python = None
|
||||
|
||||
|
||||
def is_confucius4_installed() -> bool:
|
||||
"""Cheap file-existence check for a usable venv (no subprocess spawn)."""
|
||||
return any(cand.is_file() for cand in _probe_paths())
|
||||
|
||||
|
||||
def resolve_confucius4_venv() -> Path:
|
||||
"""Resolve the sidecar's Python interpreter (probe order in the docstring).
|
||||
Memoised. Raises :exc:`RuntimeError` if none can be located and bootstrap
|
||||
is unavailable."""
|
||||
global _resolved_python
|
||||
if _resolved_python is not None:
|
||||
return _resolved_python
|
||||
|
||||
clone_dir = os.environ.get(_CLONE_DIR_ENV)
|
||||
|
||||
if clone_dir:
|
||||
cand = _venv_python_path(Path(clone_dir) / ".venv")
|
||||
if cand.is_file() and _venv_can_import(cand):
|
||||
logger.info("Confucius4 venv resolved from %s: %s", _CLONE_DIR_ENV, cand)
|
||||
_resolved_python = cand
|
||||
return cand
|
||||
|
||||
cand = _venv_python_path(_ENGINES_VENV_DIR)
|
||||
if cand.is_file() and _venv_can_import(cand):
|
||||
logger.info("Confucius4 venv resolved from engines path: %s", cand)
|
||||
_resolved_python = cand
|
||||
return cand
|
||||
|
||||
if not clone_dir:
|
||||
raise RuntimeError(
|
||||
"Confucius4-TTS is not installed. Set the "
|
||||
f"{_CLONE_DIR_ENV} environment variable to your Confucius4-TTS clone "
|
||||
"(the directory that contains requirements.txt), then restart "
|
||||
"OmniVoice. See docs/engines/confucius4-tts.md."
|
||||
)
|
||||
|
||||
cand = _bootstrap_engines_venv(Path(clone_dir))
|
||||
_resolved_python = cand
|
||||
return cand
|
||||
|
||||
|
||||
def _venv_python_path(venv_dir: Path) -> Path:
|
||||
if sys.platform == "win32":
|
||||
return venv_dir / "Scripts" / "python.exe"
|
||||
return venv_dir / "bin" / "python"
|
||||
|
||||
|
||||
def _probe_paths() -> list[Path]:
|
||||
out: list[Path] = []
|
||||
clone_dir = os.environ.get(_CLONE_DIR_ENV)
|
||||
if clone_dir:
|
||||
out.append(_venv_python_path(Path(clone_dir) / ".venv"))
|
||||
out.append(_venv_python_path(_ENGINES_VENV_DIR))
|
||||
return out
|
||||
|
||||
|
||||
def _import_probe_code() -> str:
|
||||
"""Probe snippet mirroring the sidecar's import semantics: upstream is not
|
||||
pip-installable, so ``confuciustts`` resolves via the clone on sys.path."""
|
||||
clone = os.environ.get(_CLONE_DIR_ENV, "")
|
||||
if clone:
|
||||
return f"import sys; sys.path.insert(0, {clone!r}); import {_IMPORT_PROBE}"
|
||||
return f"import {_IMPORT_PROBE}"
|
||||
|
||||
|
||||
def _venv_can_import(python_path: Path) -> bool:
|
||||
"""Spawn the candidate python and verify ``import confuciustts`` works."""
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
[str(python_path), "-c", _import_probe_code()],
|
||||
capture_output=True, timeout=_IMPORT_PROBE_TIMEOUT_S,
|
||||
)
|
||||
except (subprocess.TimeoutExpired, OSError) as exc:
|
||||
logger.debug("Confucius4 import probe failed for %s: %s", python_path, exc)
|
||||
return False
|
||||
if proc.returncode != 0:
|
||||
logger.debug(
|
||||
"Confucius4 import probe non-zero for %s: %s",
|
||||
python_path, proc.stderr.decode("utf-8", errors="replace")[:200],
|
||||
)
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _locate_uv() -> Optional[str]:
|
||||
bundled = os.environ.get("OMNIVOICE_BUNDLED_UV")
|
||||
if bundled and Path(bundled).is_file():
|
||||
return bundled
|
||||
return shutil.which("uv")
|
||||
|
||||
|
||||
def _bootstrap_engines_venv(clone_dir: Path) -> Path:
|
||||
"""Create engines/confucius4/.venv and install the user's clone."""
|
||||
uv = _locate_uv()
|
||||
if not uv:
|
||||
raise RuntimeError(
|
||||
"uv is required to bootstrap the Confucius4-TTS venv but was not "
|
||||
"found on PATH (and OMNIVOICE_BUNDLED_UV was not set). Install uv "
|
||||
"from https://docs.astral.sh/uv/ and re-launch OmniVoice."
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Bootstrapping Confucius4 venv at %s from %s (several minutes on first "
|
||||
"launch)", _ENGINES_VENV_DIR, clone_dir,
|
||||
)
|
||||
try:
|
||||
subprocess.run(
|
||||
[uv, "venv", "--python", "3.10", str(_ENGINES_VENV_DIR)],
|
||||
check=True, timeout=_UV_VENV_TIMEOUT_S, capture_output=True,
|
||||
)
|
||||
except subprocess.CalledProcessError as exc:
|
||||
raise RuntimeError(
|
||||
f"uv venv failed for Confucius4 bootstrap at {_ENGINES_VENV_DIR}: "
|
||||
f"{exc.stderr.decode('utf-8', errors='replace') if exc.stderr else exc}"
|
||||
) from exc
|
||||
|
||||
python_path = _venv_python_path(_ENGINES_VENV_DIR)
|
||||
requirements = clone_dir / "requirements.txt"
|
||||
try:
|
||||
if requirements.is_file():
|
||||
subprocess.run(
|
||||
[uv, "pip", "install", "--python", str(python_path),
|
||||
"-r", str(requirements)],
|
||||
check=True, timeout=_UV_PIP_INSTALL_TIMEOUT_S, capture_output=True,
|
||||
)
|
||||
# Editable install only if upstream ever ships packaging metadata —
|
||||
# as of 2026-07 there is none, and `uv pip install -e` on a bare clone
|
||||
# fails outright. Import resolution is handled via sys.path instead.
|
||||
if (clone_dir / "pyproject.toml").is_file() or (clone_dir / "setup.py").is_file():
|
||||
subprocess.run(
|
||||
[uv, "pip", "install", "--python", str(python_path), "-e", str(clone_dir)],
|
||||
check=True, timeout=_UV_PIP_INSTALL_TIMEOUT_S, capture_output=True,
|
||||
)
|
||||
except subprocess.CalledProcessError as exc:
|
||||
raise RuntimeError(
|
||||
"uv pip install failed during Confucius4 bootstrap "
|
||||
f"({clone_dir}): "
|
||||
f"{exc.stderr.decode('utf-8', errors='replace') if exc.stderr else exc}. "
|
||||
"See docs/engines/confucius4-tts.md."
|
||||
) from exc
|
||||
|
||||
if not _venv_can_import(python_path):
|
||||
raise RuntimeError(
|
||||
f"Confucius4 bootstrap completed but `import {_IMPORT_PROBE}` still "
|
||||
f"fails from {python_path}. Verify {clone_dir} is a valid clone. "
|
||||
"See docs/engines/confucius4-tts.md."
|
||||
)
|
||||
|
||||
logger.info("Confucius4 venv bootstrap successful: %s", python_path)
|
||||
return python_path
|
||||
|
||||
|
||||
__all__ = [
|
||||
"CONFUCIUS4_SIDECAR_SCRIPT",
|
||||
"invalidate",
|
||||
"is_confucius4_installed",
|
||||
"resolve_confucius4_venv",
|
||||
]
|
||||
@@ -0,0 +1,216 @@
|
||||
"""Confucius4-TTS sidecar entry point (issue #590).
|
||||
|
||||
Runs inside ``engines/confucius4/.venv`` (or the user's
|
||||
``${OMNIVOICE_CONFUCIUS4_TTS_DIR}/.venv``), isolated from the OmniVoice parent.
|
||||
Same isolation rationale as the IndexTTS / MOSS-TTS-v1.5 / dots.tts sidecars.
|
||||
|
||||
Stdlib-only at import time; ``confuciustts`` + torch are imported lazily on the
|
||||
first synthesize op so the ``ready`` frame fits inside the parent's 30 s spawn
|
||||
handshake.
|
||||
|
||||
Wire protocol — length-prefixed JSON over stdin/stdout, byte-identical to
|
||||
``backend/services/subprocess_backend.py``::
|
||||
|
||||
[ 4-byte big-endian uint32 length ][ N bytes UTF-8 JSON ]
|
||||
|
||||
Op flow: ready → ping/pong → synthesize (→ progress, → audio) → shutdown.
|
||||
|
||||
Status (#590): the model API below
|
||||
(``confuciustts.cli.inference.ConfuciusTTS(config_path=…, device=…)`` and
|
||||
``model.generate(text=, lang=, prompt_wav=)`` → audio tensor, ``model.sample_rate``)
|
||||
is **validated end-to-end** (2026-07-02, Apple Silicon, CPU): live generate()
|
||||
produced audible speech at 22 050 Hz. This sidecar's pure logic is unit-tested
|
||||
in ``tests/test_confucius4_sidecar.py``. Opt-in, so it affects no one until
|
||||
enabled.
|
||||
|
||||
Restrictions: NO imports from OmniVoice parent code. NO logging of os.environ.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import struct
|
||||
import sys
|
||||
import traceback
|
||||
|
||||
MAX_FRAME_BYTES = 64 * 1024 * 1024
|
||||
|
||||
#: Upstream BigVGAN vocoder rate — ``target_sample_rate: 22050`` in
|
||||
#: ``config/inference_config.yaml``, confirmed by a live end-to-end run
|
||||
#: (2026-07-02). The real value is still re-read from ``model.sample_rate``
|
||||
#: on each generate() so a future upstream change can't corrupt audio.
|
||||
CONFUCIUS_SAMPLE_RATE = 22050
|
||||
|
||||
|
||||
def _send(stream, obj: dict) -> None:
|
||||
body = json.dumps(obj, separators=(",", ":")).encode("utf-8")
|
||||
stream.write(struct.pack("!I", len(body)))
|
||||
stream.write(body)
|
||||
stream.flush()
|
||||
|
||||
|
||||
def _recv(stream):
|
||||
header = stream.read(4)
|
||||
if len(header) < 4:
|
||||
return None # EOF
|
||||
(n,) = struct.unpack("!I", header)
|
||||
if n > MAX_FRAME_BYTES:
|
||||
raise IOError(f"frame too large: {n}")
|
||||
body = bytearray()
|
||||
while len(body) < n:
|
||||
chunk = stream.read(n - len(body))
|
||||
if not chunk:
|
||||
raise IOError("short read")
|
||||
body.extend(chunk)
|
||||
return json.loads(bytes(body).decode("utf-8"))
|
||||
|
||||
|
||||
def _measure_vram_mb() -> float:
|
||||
try:
|
||||
import torch
|
||||
if torch.cuda.is_available():
|
||||
return round(torch.cuda.memory_allocated() / (1024 ** 2), 1)
|
||||
except Exception:
|
||||
pass
|
||||
return 0.0
|
||||
|
||||
|
||||
_model = None
|
||||
|
||||
|
||||
def _config_path() -> str:
|
||||
"""Locate Confucius4's inference config (``config/inference_config.yaml``)
|
||||
under the clone, or an explicit override."""
|
||||
explicit = os.environ.get("OMNIVOICE_CONFUCIUS4_CONFIG")
|
||||
if explicit:
|
||||
return explicit
|
||||
clone = os.environ.get("OMNIVOICE_CONFUCIUS4_TTS_DIR", "")
|
||||
return os.path.join(clone, "config", "inference_config.yaml")
|
||||
|
||||
|
||||
def _ensure_clone_on_sys_path() -> None:
|
||||
"""Make ``import confuciustts`` resolve from the user's clone.
|
||||
|
||||
Upstream Confucius4-TTS is **not pip-installable** (no pyproject.toml /
|
||||
setup.py as of 2026-07); its own ``example.py`` sys.path-inserts the repo
|
||||
root instead. Mirror that here so the sidecar works from a plain
|
||||
``uv pip install -r requirements.txt`` venv. Inserted at position 0 so the
|
||||
clone the user pointed at always wins over any stale installed copy.
|
||||
"""
|
||||
clone = os.environ.get("OMNIVOICE_CONFUCIUS4_TTS_DIR", "")
|
||||
if clone and clone not in sys.path:
|
||||
sys.path.insert(0, clone)
|
||||
|
||||
|
||||
def _load_model(stdout):
|
||||
"""Cold-construct the Confucius4 model (CUDA, else CPU — both validated)."""
|
||||
global _model
|
||||
if _model is not None:
|
||||
return _model
|
||||
|
||||
_send(stdout, {"op": "progress", "stage": "loading_model", "percent": 0})
|
||||
|
||||
_ensure_clone_on_sys_path()
|
||||
import torch
|
||||
from confuciustts.cli.inference import ConfuciusTTS # type: ignore[import-not-found]
|
||||
|
||||
device = "cuda" if torch.cuda.is_available() else "cpu"
|
||||
_send(stdout, {"op": "progress", "stage": "loading_model", "percent": 50})
|
||||
|
||||
_model = ConfuciusTTS(config_path=_config_path(), device=device)
|
||||
|
||||
_send(stdout, {"op": "progress", "stage": "loading_model", "percent": 100})
|
||||
return _model
|
||||
|
||||
|
||||
def _tensor_to_pcm_b64(audio, sample_rate: int) -> tuple[str, int, int]:
|
||||
import numpy as np
|
||||
arr = audio.detach().to("cpu").float().numpy() if hasattr(audio, "detach") else np.asarray(audio)
|
||||
arr = np.asarray(arr, dtype=np.float32).squeeze()
|
||||
if arr.ndim > 1:
|
||||
arr = arr.mean(axis=0)
|
||||
arr = np.clip(arr, -1.0, 1.0)
|
||||
pcm = (arr * 32767.0).astype(np.int16).tobytes()
|
||||
return base64.b64encode(pcm).decode("ascii"), int(sample_rate), int(arr.shape[0])
|
||||
|
||||
|
||||
def _normalize_language(raw):
|
||||
"""Confucius4 expects an ISO-ish language code (e.g. 'en', 'zh'). Empty /
|
||||
'auto' → 'en' as a safe default (the API requires a lang)."""
|
||||
if not raw or not isinstance(raw, str):
|
||||
return "en"
|
||||
s = raw.strip().lower()
|
||||
if not s or s == "auto":
|
||||
return "en"
|
||||
return s[:2] if (len(s) >= 2 and s[:2].isalpha()) else s
|
||||
|
||||
|
||||
def _handle_synthesize(msg: dict, stdout) -> None:
|
||||
text = msg.get("text")
|
||||
if not text or not isinstance(text, str):
|
||||
raise ValueError("synthesize: missing or non-string 'text'")
|
||||
|
||||
model = _load_model(stdout)
|
||||
|
||||
gen_kwargs: dict = {"text": text, "lang": _normalize_language(msg.get("language"))}
|
||||
ref_audio = msg.get("ref_audio")
|
||||
if ref_audio:
|
||||
gen_kwargs["prompt_wav"] = ref_audio
|
||||
|
||||
audio = model.generate(**gen_kwargs)
|
||||
sample_rate = int(getattr(model, "sample_rate", CONFUCIUS_SAMPLE_RATE))
|
||||
|
||||
pcm_b64, sr, n_samples = _tensor_to_pcm_b64(audio, sample_rate)
|
||||
_send(stdout, {
|
||||
"op": "audio",
|
||||
"audio_pcm_b64": pcm_b64,
|
||||
"sample_rate": sr,
|
||||
"n_samples": n_samples,
|
||||
})
|
||||
|
||||
|
||||
def main() -> int:
|
||||
stdin = sys.stdin.buffer
|
||||
stdout = sys.stdout.buffer
|
||||
|
||||
_send(stdout, {
|
||||
"op": "ready",
|
||||
"engine": "confucius4-tts",
|
||||
"sample_rate": CONFUCIUS_SAMPLE_RATE,
|
||||
})
|
||||
|
||||
while True:
|
||||
try:
|
||||
msg = _recv(stdin)
|
||||
except Exception as exc:
|
||||
_send(stdout, {
|
||||
"op": "error", "stage": "recv",
|
||||
"message": f"{type(exc).__name__}: {exc}",
|
||||
"traceback": traceback.format_exc(),
|
||||
})
|
||||
return 1
|
||||
if msg is None:
|
||||
return 0
|
||||
|
||||
op = msg.get("op") if isinstance(msg, dict) else None
|
||||
try:
|
||||
if op == "ping":
|
||||
_send(stdout, {"op": "pong", "vram_mb": _measure_vram_mb()})
|
||||
elif op == "synthesize":
|
||||
_handle_synthesize(msg, stdout)
|
||||
elif op == "shutdown":
|
||||
return 0
|
||||
else:
|
||||
_send(stdout, {"op": "error", "stage": "dispatch",
|
||||
"message": f"unknown op: {op!r}"})
|
||||
except Exception as exc:
|
||||
_send(stdout, {
|
||||
"op": "error", "stage": op or "unknown",
|
||||
"message": f"{type(exc).__name__}: {exc}",
|
||||
"traceback": traceback.format_exc(),
|
||||
})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -316,6 +316,74 @@ class WhisperXBackend(ASRBackend):
|
||||
pass
|
||||
return "cpu", "int8"
|
||||
|
||||
# Peak VRAM (GB) to load *and transcribe* whisper large-v3 per CTranslate2
|
||||
# compute type (weights + encoder/decoder workspace, with headroom). #723:
|
||||
# on an 8 GB card with the TTS model resident, loading fp16 large-v3 dies
|
||||
# as a *native* CUDA OOM abort — the process is killed, no Python
|
||||
# exception ever fires, and the UI reports "Can't reach the local
|
||||
# backend". The only defense is to never start that load, so the device
|
||||
# pick is re-checked against actually-free VRAM right before loading.
|
||||
_CUDA_VRAM_BUDGET_GB = {"float16": 5.0, "int8_float16": 3.5, "int8": 3.0}
|
||||
|
||||
#: Budget multiplier by model size (budgets above are for large-v3).
|
||||
_MODEL_VRAM_SCALE = (
|
||||
("large", 1.0), ("turbo", 0.55), ("medium", 0.5),
|
||||
("small", 0.25), ("base", 0.15), ("tiny", 0.1),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _free_vram_gb():
|
||||
"""Device-wide free VRAM in GB (counts other processes), or None."""
|
||||
try:
|
||||
import torch
|
||||
if torch.cuda.is_available():
|
||||
free, _total = torch.cuda.mem_get_info()
|
||||
return free / 1024**3
|
||||
except Exception: # noqa: BLE001 — preflight must never block ASR
|
||||
pass
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def _model_scale(cls, model_name: str) -> float:
|
||||
name = (model_name or "").lower()
|
||||
for key, scale in cls._MODEL_VRAM_SCALE:
|
||||
if key in name:
|
||||
return scale
|
||||
return 1.0 # unknown → assume large
|
||||
|
||||
def _degrade_for_vram(self, device: str, compute_type: str) -> tuple[str, str]:
|
||||
"""Downgrade the CUDA compute type (or fall to CPU) if free VRAM can't
|
||||
hold the model — preventing the un-catchable native OOM abort (#723).
|
||||
Opt-out: OMNIVOICE_ASR_VRAM_PREFLIGHT=0."""
|
||||
if device != "cuda" or os.environ.get(
|
||||
"OMNIVOICE_ASR_VRAM_PREFLIGHT", "1"
|
||||
).strip().lower() in ("0", "false", "no"):
|
||||
return device, compute_type
|
||||
free = self._free_vram_gb()
|
||||
if free is None:
|
||||
return device, compute_type
|
||||
scale = self._model_scale(self._model_name)
|
||||
candidates = list(self._CUDA_VRAM_BUDGET_GB)
|
||||
start = candidates.index(compute_type) if compute_type in candidates else 0
|
||||
for ct in candidates[start:]:
|
||||
if free >= self._CUDA_VRAM_BUDGET_GB[ct] * scale:
|
||||
if ct != compute_type:
|
||||
logger.warning(
|
||||
"whisperx VRAM preflight: %.1f GB free < %.1f GB needed "
|
||||
"for %s %s — degrading to %s (#723)",
|
||||
free, self._CUDA_VRAM_BUDGET_GB[compute_type] * scale,
|
||||
self._model_name, compute_type, ct,
|
||||
)
|
||||
return device, ct
|
||||
logger.warning(
|
||||
"whisperx VRAM preflight: %.1f GB free is too little for %s on CUDA "
|
||||
"(needs ≥%.1f GB even at int8) — using CPU int8 instead. Free VRAM "
|
||||
"(flush the TTS model, or close other GPU apps) for GPU-speed ASR. (#723)",
|
||||
free, self._model_name,
|
||||
self._CUDA_VRAM_BUDGET_GB["int8"] * scale,
|
||||
)
|
||||
return "cpu", "int8"
|
||||
|
||||
@classmethod
|
||||
def is_available(cls) -> tuple[bool, str]:
|
||||
try:
|
||||
@@ -346,6 +414,13 @@ class WhisperXBackend(ASRBackend):
|
||||
# (#630/#611/#647). No-op on macOS/Linux and when speechbrain is absent.
|
||||
_harden_speechbrain_lazy_imports()
|
||||
import whisperx
|
||||
# #723: re-check the CUDA pick against *currently free* VRAM — the TTS
|
||||
# model may have claimed the card since __init__. A too-big load dies
|
||||
# as a native abort (whole process, no exception), so it must be
|
||||
# avoided up front rather than caught below.
|
||||
self._device, self._compute_type = self._degrade_for_vram(
|
||||
self._device, self._compute_type
|
||||
)
|
||||
logger.info(
|
||||
"whisperx loading ASR %s on %s (%s)",
|
||||
self._model_name, self._device, self._compute_type,
|
||||
@@ -997,7 +1072,7 @@ class PyTorchWhisperBackend(ASRBackend):
|
||||
return result if isinstance(result, dict) else {"chunks": [], "raw": result}
|
||||
|
||||
|
||||
# ── NeMo Parakeet TDT (NVIDIA — English SOTA from ASR Leaderboard) ─────────
|
||||
# ── NeMo Parakeet TDT (NVIDIA — Open ASR Leaderboard SOTA, 25 langs) ────────
|
||||
|
||||
|
||||
class NeMoASRBackend(ASRBackend):
|
||||
@@ -1005,16 +1080,14 @@ class NeMoASRBackend(ASRBackend):
|
||||
|
||||
FastConformer encoder + Token-and-Duration Transducer decoder.
|
||||
Beats Whisper large-v3 on English benchmarks (~6% WER).
|
||||
Supports 25+ European languages with auto language detection.
|
||||
Requires NVIDIA GPU.
|
||||
Supports 25 (mostly European) languages with auto language detection.
|
||||
CUDA or CPU — parakeet-tdt-0.6b-v3 measured RTF 0.08–0.23 on an Apple
|
||||
Silicon M2 *CPU* (2026-07-02), ~20× faster than faster-whisper large-v3
|
||||
int8 on the same host, so the old hard CUDA gate was a false claim.
|
||||
"""
|
||||
id = "nemo-parakeet"
|
||||
# CUDA-only: is_available() hard-fails without a GPU ("Parakeet TDT requires
|
||||
# NVIDIA GPU (CUDA)"), so declaring a CPU path would be a false claim. On a
|
||||
# CPU host this correctly resolves to routing_status="unavailable", matching
|
||||
# is_available()=False (the matrix suppresses the routing badge there).
|
||||
gpu_compat = ("cuda",)
|
||||
display_name = "Parakeet TDT (NVIDIA NeMo — English SOTA)"
|
||||
gpu_compat = ("cuda", "cpu")
|
||||
display_name = "Parakeet TDT (NVIDIA NeMo — 25 langs, CUDA/CPU)"
|
||||
|
||||
def __init__(self):
|
||||
self._model_name = os.environ.get(
|
||||
@@ -1024,10 +1097,11 @@ class NeMoASRBackend(ASRBackend):
|
||||
|
||||
@classmethod
|
||||
def is_available(cls) -> tuple[bool, str]:
|
||||
# No CUDA gate: the 0.6B TDT model is comfortably faster than realtime
|
||||
# on CPU (see class docstring), so availability is a pure dependency
|
||||
# check and engine_routing picks the effective device from gpu_compat.
|
||||
try:
|
||||
import torch
|
||||
if not torch.cuda.is_available():
|
||||
return False, "Parakeet TDT requires NVIDIA GPU (CUDA)"
|
||||
import torch # noqa: F401
|
||||
except ImportError:
|
||||
return False, "PyTorch not installed"
|
||||
try:
|
||||
|
||||
@@ -94,18 +94,26 @@ def _looks_like_target_script(text: str, code: str, threshold: float = 0.5) -> b
|
||||
|
||||
|
||||
def _llm_client():
|
||||
"""Lazy-build the OpenAI-compatible client. Returns None if no key + no local base_url."""
|
||||
"""Lazy-build the OpenAI-compatible client for the ACTIVE LLM provider.
|
||||
|
||||
Resolves through the LLM Providers registry (Settings → LLM Providers) so a
|
||||
provider configured there actually powers Cinematic/Autofit — previously this
|
||||
only read ``TRANSLATE_*``/``OPENAI_*`` directly, so the registry-configured
|
||||
provider was ignored (the "LLM not wired" bug). The registry's ``custom``
|
||||
provider still maps ``TRANSLATE_BASE_URL``/``TRANSLATE_API_KEY``, so legacy
|
||||
env setups keep working. Returns None if no provider is configured.
|
||||
"""
|
||||
try:
|
||||
from openai import OpenAI
|
||||
except ImportError:
|
||||
logger.warning("openai package not installed — cinematic mode unavailable.")
|
||||
return None
|
||||
base_url = os.environ.get("TRANSLATE_BASE_URL")
|
||||
api_key = (
|
||||
os.environ.get("TRANSLATE_API_KEY")
|
||||
or os.environ.get("OPENAI_API_KEY")
|
||||
or ("local" if base_url else None) # local providers often accept any key
|
||||
)
|
||||
from services import llm_providers
|
||||
p = llm_providers.active_provider()
|
||||
if p is None:
|
||||
return None
|
||||
base_url = llm_providers.resolve_base_url(p)
|
||||
api_key = llm_providers.resolve_api_key(p)
|
||||
if not api_key:
|
||||
return None
|
||||
kw = {"api_key": api_key}
|
||||
@@ -115,6 +123,10 @@ def _llm_client():
|
||||
|
||||
|
||||
def _llm_model() -> str:
|
||||
from services import llm_providers
|
||||
p = llm_providers.active_provider()
|
||||
if p is not None:
|
||||
return llm_providers.resolve_model(p)
|
||||
return os.environ.get("TRANSLATE_MODEL", "gpt-4o-mini")
|
||||
|
||||
|
||||
@@ -125,6 +137,16 @@ def _llm_timeout() -> float:
|
||||
return 45.0
|
||||
|
||||
|
||||
def _cinematic_budget() -> float:
|
||||
"""Overall wall-clock cap for a whole cinematic/autofit refine pass (seconds).
|
||||
Unfinished segments degrade to their literal (Fast) translation once hit, so
|
||||
a slow provider can't hang the translate. Default 180s; <=0 disables."""
|
||||
try:
|
||||
return float(os.environ.get("OMNIVOICE_CINEMATIC_BUDGET_S", "180"))
|
||||
except ValueError:
|
||||
return 180.0
|
||||
|
||||
|
||||
def _glossary_text(glossary: Iterable[dict] | None) -> str:
|
||||
"""Format the project glossary as a preamble for the LLM prompts.
|
||||
|
||||
@@ -320,4 +342,35 @@ async def cinematic_refine_many(
|
||||
)
|
||||
return {"id": seg_id, **res}
|
||||
|
||||
return await asyncio.gather(*(_one(sid, src, lit) for sid, src, lit in pairs))
|
||||
# Overall wall-clock budget for the whole pass. Per-call timeout + bounded
|
||||
# concurrency already cap it, but a slow/rate-limited provider on a large dub
|
||||
# can still stall the "Translating…" spinner for minutes. Bound it: segments
|
||||
# that finish in time keep their cinematic refine; any still-running segment
|
||||
# degrades to its literal (Fast) translation so the translate ALWAYS returns
|
||||
# within the budget instead of hanging. 0/negative disables the bound.
|
||||
budget = _cinematic_budget()
|
||||
tasks = [asyncio.ensure_future(_one(sid, src, lit)) for sid, src, lit in pairs]
|
||||
if budget <= 0:
|
||||
return await asyncio.gather(*tasks)
|
||||
|
||||
done, pending = await asyncio.wait(tasks, timeout=budget)
|
||||
if pending:
|
||||
logger.warning(
|
||||
"Cinematic pass hit its %.0fs budget with %d/%d segment(s) unfinished "
|
||||
"— falling back to the literal translation for those (slow LLM "
|
||||
"provider?). Raise OMNIVOICE_CINEMATIC_BUDGET_S or pick a faster "
|
||||
"provider.", budget, len(pending), len(tasks),
|
||||
)
|
||||
out: list[dict] = []
|
||||
for task, (sid, _src, lit) in zip(tasks, pairs):
|
||||
if task in done and not task.cancelled():
|
||||
try:
|
||||
out.append(task.result())
|
||||
continue
|
||||
except Exception as e: # noqa: BLE001 — never let one seg sink the pass
|
||||
logger.warning("cinematic segment %s failed: %s", sid, e)
|
||||
else:
|
||||
task.cancel() # stop awaiting; the executor thread is abandoned (#730 pattern)
|
||||
out.append({"id": sid, "text": lit, "literal": lit, "critique": "",
|
||||
"error": "cinematic-budget"})
|
||||
return out
|
||||
|
||||
@@ -1158,6 +1158,12 @@ _LAZY_REGISTRY: dict[str, tuple[str, str]] = {
|
||||
# IndexTTS2. Lazy for the same import-cycle reason as the entries above.
|
||||
"moss-tts-v15": ("engines.moss_tts_v15", "MossTTSV15Backend"),
|
||||
"dots-tts": ("engines.dots_tts", "DotsTTSBackend"),
|
||||
# Issue #590: Confucius4-TTS (netease-youdao) — LLM-based, 14-language
|
||||
# cross-lingual zero-shot cloning, Apache-2.0. Opt-in + subprocess-isolated
|
||||
# (own Python 3.10 venv) like the entries above. Validated end-to-end
|
||||
# 2026-07-02 (CPU, Apple Silicon; 22.05 kHz output). Gated behind
|
||||
# OMNIVOICE_CONFUCIUS4_TTS_DIR so it's inert until enabled.
|
||||
"confucius4-tts": ("engines.confucius4", "Confucius4Backend"),
|
||||
}
|
||||
|
||||
|
||||
@@ -1253,6 +1259,7 @@ _INSTALL_HINTS: dict[str, str] = {
|
||||
"supertonic3": "uv sync --extra supertonic (CPU-only ONNX, 31 langs, ~400 MB model on first use; OpenRAIL-M model license)",
|
||||
"moss-tts-v15": "git clone OpenMOSS/MOSS-TTS + set OMNIVOICE_MOSS_TTS_V15_DIR (own venv, transformers==5.0; 8B, ~16 GB weights; CUDA/CPU, no MPS; Apache-2.0)",
|
||||
"dots-tts": "git clone rednote-hilab/dots.tts + set OMNIVOICE_DOTS_TTS_DIR (own venv, transformers==4.57; 2B, ~9 GB weights; CUDA/CPU, Linux/macOS only — no Windows; Apache-2.0)",
|
||||
"confucius4-tts":"git clone netease-youdao/Confucius4-TTS + set OMNIVOICE_CONFUCIUS4_TTS_DIR (own Python 3.10 venv; 14-lang cross-lingual zero-shot clone; ~5 GB weights auto-download; CUDA/CPU, no MPS; Apache-2.0)",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
# Confucius4-TTS (opt-in engine)
|
||||
|
||||
> **Status: validated end-to-end (2026-07-02).** The integration (engine
|
||||
> registration, dedicated-venv bootstrap, sidecar wire protocol, opt-in gating)
|
||||
> is done, the sidecar's pure logic is unit-tested
|
||||
> (`tests/test_confucius4_sidecar.py`), and a live synthesis run on Apple
|
||||
> Silicon (CPU) produced audible cloned speech — confirming the model API and
|
||||
> the true output sample rate of **22 050 Hz**. CUDA is the recommended
|
||||
> hardware; CPU works but is slow (~17× realtime — roughly 100 s for 6 s of
|
||||
> audio). MPS also runs but is *slower* than CPU (~64× realtime), so the
|
||||
> sidecar deliberately never selects it. The engine is gated behind
|
||||
> `OMNIVOICE_CONFUCIUS4_TTS_DIR`, so it's completely inert until you opt in —
|
||||
> it can't affect the default install on any platform.
|
||||
|
||||
[Confucius4-TTS](https://github.com/netease-youdao/Confucius4-TTS) (netease-youdao)
|
||||
is an LLM-based multilingual / cross-lingual zero-shot voice-cloning TTS.
|
||||
|
||||
- **14 languages**: Chinese, English, Japanese, Korean, German, French, Spanish,
|
||||
Indonesian, Italian, Thai, Portuguese, Russian, Malay, Vietnamese.
|
||||
- **Unconstrained cloning** — no reference transcript required.
|
||||
- **Cross-lingual voice transfer** — keep one voice across languages.
|
||||
- **License:** Apache-2.0. **Hardware:** NVIDIA GPU (CUDA 12.6) recommended;
|
||||
CPU validated on Apple Silicon but ~17× realtime. Output: 22 050 Hz mono.
|
||||
|
||||
Like IndexTTS-2 / MOSS-TTS-v1.5 / dots.tts, it runs in its **own subprocess venv**
|
||||
so its dependency stack never touches the default OmniVoice interpreter.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
git clone https://github.com/netease-youdao/Confucius4-TTS.git
|
||||
cd Confucius4-TTS
|
||||
uv venv --python 3.10
|
||||
uv pip install -r requirements.txt
|
||||
```
|
||||
|
||||
> Upstream ships **no `pyproject.toml`/`setup.py`**, so there is nothing to
|
||||
> `pip install -e` — don't try; it fails. The OmniVoice sidecar puts the clone
|
||||
> on `sys.path` itself (the same thing upstream's `example.py` does).
|
||||
|
||||
**Model weights — all fetched automatically from HuggingFace on first
|
||||
synthesis (~5 GB total, cached in `$HF_HUB_CACHE`):**
|
||||
|
||||
- `netease-youdao/Confucius4-TTS` — `t2s_model.safetensors` + `s2a_model.pt`
|
||||
(the tokenizer + `wav2vec2bert_stats.pt` already ship in the clone's
|
||||
`checkpoints/`).
|
||||
- `facebook/w2v-bert-2.0` — semantic feature extractor (~2.3 GB).
|
||||
- `funasr/campplus` — speaker-style encoder (small).
|
||||
- `nvidia/bigvgan_v2_22khz_80band_256x` — vocoder (BigVGAN and CAMPPlus
|
||||
*code* is vendored in the clone's `external/`; no Amphion install needed).
|
||||
|
||||
Set your `HF_TOKEN` (Settings → Credentials) if you hit rate limits.
|
||||
|
||||
Then point OmniVoice at the clone and restart:
|
||||
|
||||
- **macOS/Linux:** `export OMNIVOICE_CONFUCIUS4_TTS_DIR=/path/to/Confucius4-TTS`
|
||||
- **Windows (PowerShell):** `[Environment]::SetEnvironmentVariable("OMNIVOICE_CONFUCIUS4_TTS_DIR","C:\path\to\Confucius4-TTS","User")`
|
||||
|
||||
Select **Confucius4-TTS** in Settings → Engines. The first synthesize triggers
|
||||
the weight downloads above, then generates.
|
||||
|
||||
### Optional overrides
|
||||
|
||||
- `OMNIVOICE_CONFUCIUS4_CONFIG` — path to `inference_config.yaml` if it isn't at
|
||||
`<clone>/config/inference_config.yaml`.
|
||||
|
||||
## Validation record (2026-07-02, Apple Silicon M-series, CPU)
|
||||
|
||||
The sidecar (`backend/engines/confucius4/main.py`) uses:
|
||||
|
||||
```python
|
||||
from confuciustts.cli.inference import ConfuciusTTS
|
||||
model = ConfuciusTTS(config_path=..., device="cuda") # or "cpu"
|
||||
audio = model.generate(text=..., lang="en", prompt_wav="ref.wav") # → tensor
|
||||
sr = model.sample_rate # 22050
|
||||
```
|
||||
|
||||
- ✅ **Live end-to-end run**: English zero-shot clone from a 9.5 s reference —
|
||||
6.06 s of audible speech (peak 0.85) in 102 s on CPU. `model.sample_rate`
|
||||
returned **22 050**, matching `target_sample_rate` in
|
||||
`config/inference_config.yaml`; `CONFUCIUS_SAMPLE_RATE` /
|
||||
`_DEFAULT_SAMPLE_RATE` are pinned to it (regression-tested).
|
||||
- ✅ **Not pip-installable upstream** — discovered live; the bootstrap now skips
|
||||
the editable install unless upstream ships packaging, and both the import
|
||||
probe and the sidecar resolve `confuciustts` via the clone on `sys.path`.
|
||||
- ✅ **MPS probed and rejected**: runs, but ~4× slower than CPU (Metal op
|
||||
fallbacks) — the sidecar selects CUDA when available, else CPU, never MPS.
|
||||
- ✅ **Sidecar logic unit-tested** (`tests/test_confucius4_sidecar.py`):
|
||||
language normalization, tensor→PCM (mono/stereo/clip), config-path
|
||||
resolution, clone sys.path injection, wire framing, synthesize dispatch.
|
||||
@@ -52,6 +52,9 @@ tts_engines:
|
||||
- id: dots-tts
|
||||
readme: "**dots.tts**"
|
||||
doc: docs/engines/dots-tts.md
|
||||
- id: confucius4-tts
|
||||
readme: "**Confucius4-TTS**"
|
||||
doc: docs/engines/confucius4-tts.md
|
||||
|
||||
# Same contract against backend/services/asr_backend.py _REGISTRY.
|
||||
asr_engines:
|
||||
|
||||
@@ -274,10 +274,14 @@ pub fn run() {
|
||||
// launch if the user happened to be dictating when they quit,
|
||||
// overriding the WebviewWindowBuilder `.visible(false)` below.
|
||||
// Symptom: pill appears on app load with no shortcut press.
|
||||
// The main window is fine to persist (size/position are useful).
|
||||
// "main" is denylisted too (owner decision, 2026-07-02): the app
|
||||
// must ALWAYS open maximized — not fullscreen — per
|
||||
// tauri.conf.json (`maximized: true`, `fullscreen: false`).
|
||||
// Persisting geometry meant one manual resize made every later
|
||||
// launch reopen at that smaller size, overriding the config.
|
||||
app.handle().plugin(
|
||||
tauri_plugin_window_state::Builder::default()
|
||||
.with_denylist(&["widget"])
|
||||
.with_denylist(&["widget", "main"])
|
||||
.build(),
|
||||
)?;
|
||||
app.handle().plugin(
|
||||
|
||||
@@ -767,14 +767,14 @@ export default function AudioTrimmer({ file, maxSeconds = 15, onConfirm, onCance
|
||||
{/* 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)]"
|
||||
className="w-full h-[18px] bg-[#141414] rounded-md border-b border-solid border-b-transparent"
|
||||
/>
|
||||
|
||||
{/* Waveform */}
|
||||
<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"
|
||||
className="w-full h-[160px] bg-[#0f1112] rounded-lg border border-solid border-transparent cursor-crosshair touch-none"
|
||||
/>
|
||||
|
||||
{/* Numeric fields */}
|
||||
|
||||
@@ -91,7 +91,7 @@ export default function BatchAddDialog({
|
||||
className={`flex cursor-pointer flex-col items-center justify-center gap-[6px] rounded-[10px] border-2 border-dashed px-4 py-7 text-[0.82rem] transition-all hover:border-[var(--chrome-accent)] hover:bg-white/[0.02] hover:text-[var(--chrome-fg)] ${
|
||||
dragOver
|
||||
? 'border-[var(--chrome-accent)] bg-white/[0.02] text-[var(--chrome-fg)]'
|
||||
: 'border-[var(--chrome-border)] text-[var(--chrome-fg-muted)]'
|
||||
: 'border-transparent text-[var(--chrome-fg-muted)]'
|
||||
}`}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
|
||||
@@ -128,7 +128,7 @@ export default function CompareModal({
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="ml-auto inline-flex h-[var(--chrome-icon-btn,22px)] w-[var(--chrome-icon-btn,22px)] cursor-pointer items-center justify-center rounded-[var(--chrome-radius-pill)] bg-transparent text-[var(--chrome-fg-muted)] [border:1px_solid_transparent] transition-[background,color,border-color] duration-[var(--dur-fast)] hover:border-[var(--chrome-border-strong)] hover:bg-[var(--chrome-hover-bg)] hover:text-[var(--chrome-fg)]"
|
||||
className="ml-auto inline-flex h-[var(--chrome-icon-btn,22px)] w-[var(--chrome-icon-btn,22px)] cursor-pointer items-center justify-center rounded-[var(--chrome-radius-pill)] bg-transparent text-[var(--chrome-fg-muted)] [border:1px_solid_transparent] transition-[background,color,border-color] duration-[var(--dur-fast)] hover:border-transparent hover:bg-[var(--chrome-hover-bg)] hover:text-[var(--chrome-fg)]"
|
||||
onClick={onClose}
|
||||
aria-label={t('compare.close')}
|
||||
>
|
||||
|
||||
@@ -87,7 +87,7 @@ export default function DemoPresetGrid({ presets, onUse }) {
|
||||
return (
|
||||
<div
|
||||
key={p.id}
|
||||
className="flex flex-col gap-[6px] p-[12px] rounded-xl border border-border bg-[rgba(255,255,255,0.02)] [transition:border-color_120ms_ease,background_120ms_ease] hover:border-[rgba(243,165,182,0.35)] hover:bg-[rgba(255,255,255,0.04)]"
|
||||
className="flex flex-col gap-[6px] p-[12px] rounded-xl border border-border bg-[rgba(255,255,255,0.02)] [transition:border-color_120ms_ease,background_120ms_ease] hover:border-transparent hover:bg-[rgba(255,255,255,0.04)]"
|
||||
>
|
||||
<div className="inline-flex items-center gap-[6px]">
|
||||
<span className="text-[16px] leading-none" aria-hidden>
|
||||
@@ -102,7 +102,7 @@ export default function DemoPresetGrid({ presets, onUse }) {
|
||||
<div className="flex gap-[6px] mt-auto pt-[4px]">
|
||||
<button
|
||||
type="button"
|
||||
className="demo-preset-card__preview flex-1 inline-flex items-center justify-center gap-[4px] px-[8px] py-[5px] text-[11px] font-semibold rounded-lg border border-border bg-transparent text-fg cursor-pointer [transition:background_100ms_ease,border-color_100ms_ease] hover:bg-[rgba(255,255,255,0.05)] hover:border-[rgba(255,255,255,0.2)]"
|
||||
className="demo-preset-card__preview flex-1 inline-flex items-center justify-center gap-[4px] px-[8px] py-[5px] text-[11px] font-semibold rounded-lg border border-border bg-transparent text-fg cursor-pointer [transition:background_100ms_ease,border-color_100ms_ease] hover:bg-[rgba(255,255,255,0.05)] hover:border-transparent"
|
||||
onClick={() => handlePreview(p)}
|
||||
aria-label={isPlaying ? `Pause ${p.name}` : `Preview ${p.name}`}
|
||||
aria-pressed={isPlaying}
|
||||
@@ -112,7 +112,7 @@ export default function DemoPresetGrid({ presets, onUse }) {
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="flex-1 inline-flex items-center justify-center gap-[4px] px-[8px] py-[5px] text-[11px] font-semibold rounded-lg border border-[rgba(243,165,182,0.3)] bg-[rgba(243,165,182,0.12)] text-fg cursor-pointer [transition:background_100ms_ease,border-color_100ms_ease] hover:border-[rgba(255,255,255,0.2)] hover:bg-[rgba(243,165,182,0.22)]"
|
||||
className="flex-1 inline-flex items-center justify-center gap-[4px] px-[8px] py-[5px] text-[11px] font-semibold rounded-lg border border-transparent bg-[rgba(243,165,182,0.12)] text-fg cursor-pointer [transition:background_100ms_ease,border-color_100ms_ease] hover:border-transparent hover:bg-[rgba(243,165,182,0.22)]"
|
||||
onClick={() => onUse(p)}
|
||||
aria-label={`Use ${p.name} design`}
|
||||
>
|
||||
|
||||
@@ -192,7 +192,7 @@ export default function DictationDemo({ embedded = false }) {
|
||||
case 'verified':
|
||||
return (
|
||||
<span
|
||||
className={`${STATUS_BASE} border-[rgba(152,151,26,0.35)] bg-[rgba(152,151,26,0.12)] text-[#b8bb26]`}
|
||||
className={`${STATUS_BASE} border-transparent bg-[rgba(152,151,26,0.12)] text-[#b8bb26]`}
|
||||
>
|
||||
<CheckCircle2 size={12} /> {t('demo.dictation_status_ok')}
|
||||
</span>
|
||||
@@ -200,7 +200,7 @@ export default function DictationDemo({ embedded = false }) {
|
||||
case 'registered':
|
||||
return (
|
||||
<span
|
||||
className={`${STATUS_BASE} border-[rgba(215,153,33,0.30)] bg-[rgba(215,153,33,0.10)] text-[#fabd2f]`}
|
||||
className={`${STATUS_BASE} border-transparent bg-[rgba(215,153,33,0.10)] text-[#fabd2f]`}
|
||||
>
|
||||
<Keyboard size={12} /> {t('demo.dictation_status_pending')}{' '}
|
||||
<code className="font-mono text-[10px] px-[4px] py-[1px] bg-[rgba(0,0,0,0.3)] rounded-[3px]">
|
||||
@@ -211,7 +211,7 @@ export default function DictationDemo({ embedded = false }) {
|
||||
default:
|
||||
return (
|
||||
<span
|
||||
className={`${STATUS_BASE} border-[rgba(204,36,29,0.30)] bg-[rgba(204,36,29,0.10)] text-[#fb4934]`}
|
||||
className={`${STATUS_BASE} border-transparent bg-[rgba(204,36,29,0.10)] text-[#fb4934]`}
|
||||
>
|
||||
<AlertTriangle size={12} /> {t('demo.dictation_status_warn')}
|
||||
</span>
|
||||
@@ -270,7 +270,7 @@ export default function DictationDemo({ embedded = false }) {
|
||||
{t(s.labelKey)}
|
||||
</span>
|
||||
</div>
|
||||
<blockquote className="m-0 px-[8px] py-[6px] text-[11.5px] leading-[1.45] border-l-2 border-l-[rgba(243,165,182,0.4)] bg-[rgba(255,255,255,0.02)] text-fg italic">
|
||||
<blockquote className="m-0 px-[8px] py-[6px] text-[11.5px] leading-[1.45] border-l-2 border-l-transparent bg-[rgba(255,255,255,0.02)] text-fg italic">
|
||||
{s.text}
|
||||
</blockquote>
|
||||
<div className="flex gap-[6px] mt-[2px]">
|
||||
@@ -301,12 +301,12 @@ export default function DictationDemo({ embedded = false }) {
|
||||
</Button>
|
||||
</div>
|
||||
{tx.state === 'ok' && (
|
||||
<div className="flex items-start gap-[6px] text-[11px] px-[8px] py-[6px] rounded-lg leading-[1.4] text-[#b8bb26] bg-[rgba(152,151,26,0.08)] border border-[rgba(152,151,26,0.25)]">
|
||||
<div className="flex items-start gap-[6px] text-[11px] px-[8px] py-[6px] rounded-lg leading-[1.4] text-[#b8bb26] bg-[rgba(152,151,26,0.08)] border border-transparent">
|
||||
<CheckCircle2 size={11} /> <em className="not-italic">{tx.text}</em>
|
||||
</div>
|
||||
)}
|
||||
{tx.state === 'fail' && (
|
||||
<div className="flex items-start gap-[6px] text-[11px] px-[8px] py-[6px] rounded-lg leading-[1.4] text-[#fb4934] bg-[rgba(204,36,29,0.08)] border border-[rgba(204,36,29,0.25)]">
|
||||
<div className="flex items-start gap-[6px] text-[11px] px-[8px] py-[6px] rounded-lg leading-[1.4] text-[#fb4934] bg-[rgba(204,36,29,0.08)] border border-transparent">
|
||||
<AlertTriangle size={11} /> {tx.error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -194,7 +194,6 @@ function DubSegmentRow({
|
||||
onChange={(e) => onSelect(seg.id, idx, e.nativeEvent.shiftKey)}
|
||||
onClick={(e) => onSelect(seg.id, idx, e.shiftKey)}
|
||||
disabled={disabled}
|
||||
style={{ accentColor: '#d3869b' }}
|
||||
className="cursor-pointer justify-self-center"
|
||||
title={t('segment.select_title')}
|
||||
/>
|
||||
@@ -325,9 +324,9 @@ function DubSegmentRow({
|
||||
}
|
||||
style={
|
||||
overBudget
|
||||
? { borderColor: 'rgba(250,189,47,0.6)', background: 'rgba(250,189,47,0.06)' }
|
||||
? { background: 'rgba(250,189,47,0.10)' }
|
||||
: seg.translate_error
|
||||
? { borderColor: 'rgba(251,73,52,0.5)' }
|
||||
? { background: 'rgba(251,73,52,0.10)' }
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
@@ -423,7 +422,11 @@ function DubSegmentRow({
|
||||
className="seg-gain-slider"
|
||||
style={{
|
||||
accentColor:
|
||||
(seg.gain ?? 1.0) > 1.2 ? '#fb4934' : (seg.gain ?? 1.0) < 0.5 ? '#83a598' : '#a89984',
|
||||
(seg.gain ?? 1.0) > 1.2
|
||||
? 'var(--color-danger)'
|
||||
: (seg.gain ?? 1.0) < 0.5
|
||||
? 'var(--color-info)'
|
||||
: 'var(--color-fg-muted)',
|
||||
}}
|
||||
/>
|
||||
|
||||
|
||||
@@ -116,7 +116,7 @@ export default function DubbingDemo({ onDismiss }) {
|
||||
type="checkbox"
|
||||
checked={syncPlay}
|
||||
onChange={(e) => setSyncPlay(e.target.checked)}
|
||||
className="accent-[#f3a5b6]"
|
||||
className="accent-[var(--color-brand)]"
|
||||
/>
|
||||
{t('demo.dubbing_sync')}
|
||||
</label>
|
||||
@@ -186,7 +186,7 @@ export default function DubbingDemo({ onDismiss }) {
|
||||
<button
|
||||
key={d.code}
|
||||
type="button"
|
||||
className={`text-[11px] px-[10px] py-[3px] rounded-[999px] border border-border bg-transparent text-fg-muted cursor-pointer [transition:background_100ms_ease,border-color_100ms_ease,color_100ms_ease] hover:bg-[rgba(255,255,255,0.04)] hover:text-fg ${pickedCode === d.code ? 'bg-[rgba(243,165,182,0.18)] border-[rgba(243,165,182,0.45)] text-[#fff9ef]' : ''}`}
|
||||
className={`text-[11px] px-[10px] py-[3px] rounded-[999px] border border-border bg-transparent text-fg-muted cursor-pointer [transition:background_100ms_ease,border-color_100ms_ease,color_100ms_ease] hover:bg-[rgba(255,255,255,0.04)] hover:text-fg ${pickedCode === d.code ? 'bg-[rgba(243,165,182,0.18)] border-transparent text-[#fff9ef]' : ''}`}
|
||||
onClick={() => setPickedCode(d.code)}
|
||||
>
|
||||
{d.label}
|
||||
@@ -197,7 +197,7 @@ export default function DubbingDemo({ onDismiss }) {
|
||||
{onDismiss && (
|
||||
<button
|
||||
type="button"
|
||||
className="self-end inline-flex items-center gap-[6px] px-[12px] py-[6px] text-[11px] font-semibold rounded-lg border border-[rgba(243,165,182,0.4)] bg-[rgba(243,165,182,0.12)] text-fg cursor-pointer hover:bg-[rgba(243,165,182,0.22)]"
|
||||
className="self-end inline-flex items-center gap-[6px] px-[12px] py-[6px] text-[11px] font-semibold rounded-lg border border-transparent bg-[rgba(243,165,182,0.12)] text-fg cursor-pointer hover:bg-[rgba(243,165,182,0.22)]"
|
||||
onClick={onDismiss}
|
||||
>
|
||||
<Play size={12} /> {t('demo.dubbing_cta')}
|
||||
|
||||
@@ -76,7 +76,7 @@ export default class ErrorBoundary extends React.Component {
|
||||
<p className="m-0 mb-3 text-[0.82rem] leading-[1.5] text-[var(--chrome-fg-muted)]">
|
||||
{i18next.t('errors.desc')}
|
||||
</p>
|
||||
<pre className="m-0 mb-3.5 max-h-[140px] overflow-auto rounded-[var(--chrome-radius-pill)] border border-[var(--chrome-border)] bg-[var(--chrome-hover-bg)] px-2.5 py-2 text-left font-mono text-[0.72rem] text-[var(--chrome-severity-err)]">
|
||||
<pre className="m-0 mb-3.5 max-h-[140px] overflow-auto rounded-[var(--chrome-radius-pill)] border border-transparent bg-[var(--chrome-hover-bg)] px-2.5 py-2 text-left font-mono text-[0.72rem] text-[var(--chrome-severity-err)]">
|
||||
{msg}
|
||||
</pre>
|
||||
<div className="flex flex-wrap justify-center gap-2">
|
||||
|
||||
@@ -26,8 +26,8 @@ const trackCls = (on, kind) => {
|
||||
if (on && kind === 'dub')
|
||||
return `${TRACK_BASE} border-[var(--chrome-accent-border)] bg-[var(--chrome-accent-bg)] text-[var(--chrome-accent)]`;
|
||||
if (on)
|
||||
return `${TRACK_BASE} border-[var(--chrome-border-strong)] bg-[var(--chrome-hover-bg)] text-[var(--chrome-fg)]`;
|
||||
return `${TRACK_BASE} border-[var(--chrome-border)] text-[var(--chrome-fg-muted)]`;
|
||||
return `${TRACK_BASE} border-transparent bg-[var(--chrome-hover-bg)] text-[var(--chrome-fg)]`;
|
||||
return `${TRACK_BASE} border-transparent text-[var(--chrome-fg-muted)]`;
|
||||
};
|
||||
const TAB_BASE =
|
||||
'inline-flex items-center gap-[6px] px-[12px] py-[6px] bg-transparent border-0 border-b-2 cursor-pointer text-[length:var(--text-sm)] transition-[color,border-color] duration-[var(--dur-fast)]';
|
||||
@@ -288,7 +288,7 @@ export default function ExportModal({
|
||||
aria-label={t('exportModal.export_options')}
|
||||
>
|
||||
<div
|
||||
className="pointer-events-auto flex w-[min(880px,calc(100vw-24px))] max-h-[min(70vh,560px)] flex-col overflow-hidden rounded-t-lg border border-b-0 border-[var(--chrome-border-strong)] bg-[var(--chrome-bg)] shadow-[0_-8px_24px_rgba(0,0,0,0.45),0_-1px_0_var(--chrome-border)_inset] animate-in fade-in slide-in-from-bottom-full duration-200"
|
||||
className="pointer-events-auto flex w-[min(880px,calc(100vw-24px))] max-h-[min(70vh,560px)] flex-col overflow-hidden rounded-t-lg border border-b-0 border-transparent bg-[var(--chrome-bg)] shadow-[0_-8px_24px_rgba(0,0,0,0.45),0_-1px_0_var(--chrome-border)_inset] animate-in fade-in slide-in-from-bottom-full duration-200"
|
||||
ref={drawerRef}
|
||||
>
|
||||
<header className="relative flex items-center gap-[var(--space-3)] p-[6px_var(--space-4)_10px] [border-bottom:1px_solid_var(--chrome-border)] [background:linear-gradient(180deg,rgba(255,255,255,0.02),transparent)]">
|
||||
@@ -306,7 +306,7 @@ export default function ExportModal({
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="ml-auto inline-flex h-[var(--chrome-icon-btn,22px)] w-[var(--chrome-icon-btn,22px)] cursor-pointer items-center justify-center rounded-[var(--chrome-radius-pill)] bg-transparent text-[var(--chrome-fg-muted)] [border:1px_solid_transparent] transition-[background,color,border-color] duration-[var(--dur-fast)] hover:border-[var(--chrome-border-strong)] hover:bg-[var(--chrome-hover-bg)] hover:text-[var(--chrome-fg)]"
|
||||
className="ml-auto inline-flex h-[var(--chrome-icon-btn,22px)] w-[var(--chrome-icon-btn,22px)] cursor-pointer items-center justify-center rounded-[var(--chrome-radius-pill)] bg-transparent text-[var(--chrome-fg-muted)] [border:1px_solid_transparent] transition-[background,color,border-color] duration-[var(--dur-fast)] hover:border-transparent hover:bg-[var(--chrome-hover-bg)] hover:text-[var(--chrome-fg)]"
|
||||
onClick={onClose}
|
||||
aria-label={t('exportModal.close_drawer')}
|
||||
>
|
||||
@@ -323,7 +323,7 @@ export default function ExportModal({
|
||||
<button
|
||||
key={k}
|
||||
type="button"
|
||||
className="inline-flex cursor-pointer items-center gap-[4px] rounded-[var(--chrome-radius-pill)] bg-transparent px-[8px] py-[3px] font-sans text-[length:var(--text-xs)] text-[var(--chrome-fg-muted)] [border:1px_solid_var(--chrome-border)] transition-[background,color,border-color] duration-[var(--dur-fast)] hover:border-[var(--chrome-border-strong)] hover:bg-[var(--chrome-hover-bg)] hover:text-[var(--chrome-fg)]"
|
||||
className="inline-flex cursor-pointer items-center gap-[4px] rounded-[var(--chrome-radius-pill)] bg-transparent px-[8px] py-[3px] font-sans text-[length:var(--text-xs)] text-[var(--chrome-fg-muted)] [border:1px_solid_var(--chrome-border)] transition-[background,color,border-color] duration-[var(--dur-fast)] hover:border-transparent hover:bg-[var(--chrome-hover-bg)] hover:text-[var(--chrome-fg)]"
|
||||
onClick={() => applyPreset(k)}
|
||||
title={t('exportModal.preset_title', { tab: v.tab, label: t(v.labelKey) })}
|
||||
>
|
||||
|
||||
@@ -211,7 +211,9 @@ function OptionCard({ active, disabled, onSelect, name, desc, badge }) {
|
||||
className={cn(
|
||||
'flex flex-col gap-1 rounded-md border px-3 py-2.5 text-left transition-colors',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
||||
active ? 'border-primary bg-primary/10' : 'border-border bg-bg-elev-2 hover:bg-bg-elev-1',
|
||||
active
|
||||
? 'border-transparent bg-primary/10'
|
||||
: 'border-border bg-bg-elev-2 hover:bg-bg-elev-1',
|
||||
disabled && 'cursor-not-allowed opacity-40 hover:bg-bg-elev-2',
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -189,7 +189,7 @@ export default function GlossaryPanel({
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<table className="w-full border-collapse text-[length:var(--text-sm)] [&_td]:border-b [&_td]:border-b-white/[0.04] [&_td]:px-[6px] [&_td]:py-[3px] [&_td]:text-left [&_td]:align-middle [&_th]:border-b [&_th]:border-b-[var(--color-border)] [&_th]:px-[6px] [&_th]:py-[3px] [&_th]:text-left [&_th]:align-middle [&_th]:text-[length:var(--text-xs)] [&_th]:font-semibold [&_th]:uppercase [&_th]:tracking-[0.04em] [&_th]:text-[var(--color-fg-subtle)]">
|
||||
<table className="w-full border-collapse text-[length:var(--text-sm)] [&_td]:border-b [&_td]:border-b-transparent [&_td]:px-[6px] [&_td]:py-[3px] [&_td]:text-left [&_td]:align-middle [&_th]:border-b [&_th]:border-b-transparent [&_th]:px-[6px] [&_th]:py-[3px] [&_th]:text-left [&_th]:align-middle [&_th]:text-[length:var(--text-xs)] [&_th]:font-semibold [&_th]:uppercase [&_th]:tracking-[0.04em] [&_th]:text-[var(--color-fg-subtle)]">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t('glossary.source')}</th>
|
||||
@@ -228,7 +228,7 @@ export default function GlossaryPanel({
|
||||
onDelete={() => onDelete(term.id)}
|
||||
/>
|
||||
))}
|
||||
<tr className="border-t border-dashed border-[var(--color-border)] [&>td]:py-[4px]">
|
||||
<tr className="border-t border-dashed border-transparent [&>td]:py-[4px]">
|
||||
<td>
|
||||
<Input
|
||||
size="sm"
|
||||
|
||||
@@ -42,7 +42,7 @@ export default function HfTokenCard({ className = '' }) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-wrap items-center gap-2 rounded-md border border-success/45 bg-success/[0.09] px-3 py-2 text-sm',
|
||||
'flex flex-wrap items-center gap-2 rounded-md border border-transparent bg-success/[0.09] px-3 py-2 text-sm',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
@@ -57,7 +57,7 @@ export default function HfTokenCard({ className = '' }) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-wrap items-center gap-2 rounded-md border border-primary/30 bg-primary/[0.07] px-3 py-2 text-sm',
|
||||
'flex flex-wrap items-center gap-2 rounded-md border border-transparent bg-primary/[0.07] px-3 py-2 text-sm',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Dialog } from '../ui';
|
||||
|
||||
function Kbd({ children }) {
|
||||
return (
|
||||
<span className="inline-flex h-[22px] min-w-[28px] items-center justify-center gap-[2px] rounded-[var(--chrome-radius-pill)] border border-[var(--chrome-border-strong)] bg-[var(--chrome-hover-bg)] px-2 py-[2px] font-mono text-[0.7rem] font-medium text-[var(--chrome-fg)]">
|
||||
<span className="inline-flex h-[22px] min-w-[28px] items-center justify-center gap-[2px] rounded-[var(--chrome-radius-pill)] border border-transparent bg-[var(--chrome-hover-bg)] px-2 py-[2px] font-mono text-[0.7rem] font-medium text-[var(--chrome-fg)]">
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
@@ -78,7 +78,7 @@ export default function KeyboardCheatsheet({ open, onClose }) {
|
||||
<div className="grid grid-cols-[repeat(auto-fit,minmax(260px,1fr))] gap-[18px]">
|
||||
{SECTIONS.map((sec) => (
|
||||
<div key={sec.title}>
|
||||
<div className="mb-[10px] border-b border-[var(--chrome-border)] pb-[6px] font-mono text-[length:var(--chrome-label-size)] font-semibold uppercase tracking-[var(--chrome-label-track)] text-[var(--chrome-fg-muted)]">
|
||||
<div className="mb-[10px] border-b border-transparent pb-[6px] font-mono text-[length:var(--chrome-label-size)] font-semibold uppercase tracking-[var(--chrome-label-track)] text-[var(--chrome-fg-muted)]">
|
||||
{sec.title}
|
||||
</div>
|
||||
<div className="flex flex-col gap-[6px]">
|
||||
|
||||
@@ -76,7 +76,7 @@ export default function MultiLangPicker({
|
||||
{selected.map((s) => (
|
||||
<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"
|
||||
className="inline-flex items-center gap-[4px] px-[8px] py-[2px] bg-[var(--chrome-hover-bg)] border border-solid border-transparent 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>
|
||||
@@ -95,7 +95,7 @@ export default function MultiLangPicker({
|
||||
{!disabled && (
|
||||
<button
|
||||
type="button"
|
||||
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"
|
||||
className="flex items-center justify-center w-[24px] h-[24px] rounded-full border border-dashed border-transparent 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')}
|
||||
>
|
||||
@@ -112,7 +112,7 @@ export default function MultiLangPicker({
|
||||
|
||||
{dropOpen && (
|
||||
<div className="multi-lang__drop">
|
||||
<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)]">
|
||||
<div className="flex items-center gap-[6px] px-[10px] py-[8px] border-b border-solid border-b-transparent text-[color:var(--chrome-fg-muted)]">
|
||||
<Search size={10} />
|
||||
<input
|
||||
ref={inputRef}
|
||||
|
||||
@@ -80,7 +80,7 @@ export default function NetworkToggle() {
|
||||
return (
|
||||
<div className="relative inline-flex items-center flex-shrink-0">
|
||||
<button
|
||||
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'}`}
|
||||
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-transparent text-[#b8bb26] hover:bg-[rgba(184,187,38,0.18)]' : 'bg-transparent border-transparent text-[var(--color-fg-muted)] hover:bg-[var(--chrome-hover-bg)] 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')}
|
||||
@@ -92,7 +92,7 @@ export default function NetworkToggle() {
|
||||
</button>
|
||||
|
||||
{!st.enabled && confirming && (
|
||||
<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="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-transparent 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>
|
||||
@@ -102,7 +102,7 @@ export default function NetworkToggle() {
|
||||
<div className="flex gap-[6px] mt-[8px]">
|
||||
<button
|
||||
type="button"
|
||||
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"
|
||||
className="bg-transparent border border-solid border-transparent [color:inherit] text-[11px] [font-family:inherit] py-[5px] px-[12px] rounded-md cursor-pointer hover:bg-[var(--chrome-hover-bg)] disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
onClick={() => setConfirming(false)}
|
||||
disabled={busy}
|
||||
>
|
||||
@@ -110,7 +110,7 @@ export default function NetworkToggle() {
|
||||
</button>
|
||||
<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"
|
||||
className="bg-[rgba(184,187,38,0.15)] border border-solid border-transparent 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}
|
||||
>
|
||||
@@ -121,7 +121,7 @@ export default function NetworkToggle() {
|
||||
)}
|
||||
|
||||
{st.enabled && open && (
|
||||
<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="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-transparent 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>
|
||||
@@ -135,7 +135,7 @@ export default function NetworkToggle() {
|
||||
return (
|
||||
<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)]"
|
||||
className="flex flex-col items-center gap-[8px] p-[8px] rounded-lg bg-[rgba(255,255,255,0.03)] border border-solid border-transparent"
|
||||
>
|
||||
<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">
|
||||
@@ -182,7 +182,7 @@ export default function NetworkToggle() {
|
||||
</div>
|
||||
<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"
|
||||
className="bg-[rgba(251,73,52,0.12)] border border-solid border-transparent 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}
|
||||
>
|
||||
|
||||
@@ -125,7 +125,7 @@ export default function ReadinessChecklist({ compact = false, showWhenAllPass =
|
||||
const issues = checks.filter((c) => c.status !== 'pass');
|
||||
if (issues.length === 0) {
|
||||
return (
|
||||
<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)]">
|
||||
<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-transparent rounded-md text-success font-medium [font-size:var(--text-sm)]">
|
||||
<CheckCircle size={14} />
|
||||
{t('readiness.all_ready')}
|
||||
</div>
|
||||
|
||||
@@ -555,7 +555,7 @@ export default function SegmentTrack({
|
||||
<span className="inline-flex gap-[2px] mr-[8px] z-[3]">
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center justify-center w-[16px] h-[16px] p-0 border border-[rgba(168,153,132,0.4)] rounded-sm bg-[rgba(40,40,40,0.85)] text-[#ebdbb2] cursor-pointer hover:border-[#d3869b] hover:text-[#d3869b]"
|
||||
className="inline-flex items-center justify-center w-[16px] h-[16px] p-0 border border-transparent rounded-sm bg-[rgba(40,40,40,0.85)] text-[#ebdbb2] cursor-pointer hover:border-transparent hover:text-[#d3869b]"
|
||||
aria-label={t('timeline.play_slot')}
|
||||
title={t('timeline.play_slot')}
|
||||
onPointerDown={(ev) => ev.stopPropagation()}
|
||||
@@ -569,7 +569,7 @@ export default function SegmentTrack({
|
||||
{onPreviewSegment && (
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center justify-center w-[16px] h-[16px] p-0 border border-[rgba(168,153,132,0.4)] rounded-sm bg-[rgba(40,40,40,0.85)] text-[#ebdbb2] cursor-pointer hover:border-[#d3869b] hover:text-[#d3869b]"
|
||||
className="inline-flex items-center justify-center w-[16px] h-[16px] p-0 border border-transparent rounded-sm bg-[rgba(40,40,40,0.85)] text-[#ebdbb2] cursor-pointer hover:border-transparent hover:text-[#d3869b]"
|
||||
aria-label={t('timeline.preview_dub')}
|
||||
title={t('timeline.preview_dub')}
|
||||
onPointerDown={(ev) => ev.stopPropagation()}
|
||||
|
||||
@@ -395,7 +395,7 @@ export default function Sidebar(props) {
|
||||
<div className="flex items-center justify-between gap-2 min-w-0">
|
||||
<span
|
||||
className="history-kind"
|
||||
style={{ color: accent, borderColor: `${accent}40` }}
|
||||
style={{ color: accent, background: `${accent}22` }}
|
||||
>
|
||||
<KindIcon size={9} />{' '}
|
||||
{proj.is_locked
|
||||
@@ -610,7 +610,7 @@ export default function Sidebar(props) {
|
||||
<div className="flex items-center justify-between gap-2 min-w-0">
|
||||
<span
|
||||
className="history-kind"
|
||||
style={{ color: accent, borderColor: `${accent}40` }}
|
||||
style={{ color: accent, background: `${accent}22` }}
|
||||
>
|
||||
<KindIcon size={9} /> {item.mode || 'synth'}
|
||||
</span>
|
||||
@@ -768,7 +768,7 @@ export default function Sidebar(props) {
|
||||
<div className="flex items-center justify-between gap-2 min-w-0">
|
||||
<span
|
||||
className="history-kind"
|
||||
style={{ color: accent, borderColor: `${accent}40` }}
|
||||
style={{ color: accent, background: `${accent}22` }}
|
||||
>
|
||||
<KindIcon size={9} /> {item.mode}
|
||||
</span>
|
||||
|
||||
@@ -64,7 +64,7 @@ import { effectiveProfile, effectiveSpeed, castMember, nextCastColor } from '../
|
||||
|
||||
// ── Shared class strings (replacing the old stories-* BEM chrome) ─────────
|
||||
const ADD_BTN =
|
||||
'inline-flex items-center gap-[4px] bg-transparent border border-border text-fg [font-size:var(--text-xs)] px-[8px] py-[3px] rounded-sm cursor-pointer hover:border-accent hover:text-accent';
|
||||
'inline-flex items-center gap-[4px] bg-transparent border border-border text-fg [font-size:var(--text-xs)] px-[8px] py-[3px] rounded-sm cursor-pointer hover:text-accent';
|
||||
const NAME_INPUT =
|
||||
'bg-bg-elev-2 border border-border rounded-sm text-fg [font-size:var(--text-xs)] px-[8px] py-[4px]';
|
||||
const SELECT_CHROME =
|
||||
@@ -1035,9 +1035,7 @@ export default function StoriesEditor({ profiles = [] }) {
|
||||
role="listitem"
|
||||
className={[
|
||||
'group grid [grid-template-columns:32px_1fr_160px_100px_44px] gap-[8px] items-center px-[10px] py-[8px] bg-bg-elev-1 border border-border rounded-lg [transition:border-color_0.15s,box-shadow_0.15s] cursor-grab flex-wrap hover:border-border-strong hover:[box-shadow:var(--shadow-sm)]',
|
||||
activeTrack === track.id
|
||||
? 'border-brand [box-shadow:0_0_0_1px_var(--color-brand-glow)]'
|
||||
: '',
|
||||
activeTrack === track.id ? 'bg-primary/[0.12]' : '',
|
||||
track.character === 'narrator'
|
||||
? '[border-left:3px_solid_var(--color-accent)]'
|
||||
: '',
|
||||
@@ -1192,7 +1190,7 @@ export default function StoriesEditor({ profiles = [] }) {
|
||||
<button
|
||||
key={tn.tag}
|
||||
type="button"
|
||||
className="inline-flex items-center gap-[4px] bg-bg-elev-2 border border-border rounded-full text-fg [font-size:var(--text-xs)] px-[9px] py-[3px] cursor-pointer hover:border-accent hover:text-accent"
|
||||
className="inline-flex items-center gap-[4px] bg-bg-elev-2 border border-border rounded-full text-fg [font-size:var(--text-xs)] px-[9px] py-[3px] cursor-pointer hover:text-accent"
|
||||
onClick={() => insertTokenInto(track.id, tn.tag)}
|
||||
title={tn.tag}
|
||||
>
|
||||
|
||||
@@ -41,7 +41,7 @@ const LICENSE_URLS = {
|
||||
|
||||
const LINK_CLS =
|
||||
'text-[0.83rem] text-[color:var(--accent,#8ab4f8)] no-underline hover:underline focus-visible:underline';
|
||||
const SECTION_CLS = 'rounded-lg border border-white/10 bg-white/[0.04] px-[0.9rem] py-3';
|
||||
const SECTION_CLS = 'rounded-lg border border-transparent bg-white/[0.04] px-[0.9rem] py-3';
|
||||
const SECTION_H_CLS =
|
||||
'm-0 mb-[0.3rem] text-[0.85rem] font-semibold uppercase tracking-[0.02em] opacity-85';
|
||||
const SECTION_P_CLS = 'm-0 mb-2 text-[0.85rem] leading-[1.5] opacity-90';
|
||||
|
||||
@@ -97,8 +97,8 @@ export default function VoicePreview({
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-[calc(var(--logs-footer-height,28px)+16px)] right-[16px] z-[900] w-[320px] bg-[var(--chrome-bg)] border border-solid border-[var(--chrome-border-strong)] rounded-[12px] [box-shadow:0_8px_32px_rgba(0,0,0,0.4)] flex flex-col overflow-hidden animate-[voice-preview-in_0.2s_ease-out]">
|
||||
<div className="flex items-center justify-between py-[10px] px-[14px] border-b border-solid border-b-[var(--chrome-border)]">
|
||||
<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-transparent rounded-[12px] [box-shadow:0_8px_32px_rgba(0,0,0,0.4)] flex flex-col overflow-hidden animate-[voice-preview-in_0.2s_ease-out]">
|
||||
<div className="flex items-center justify-between py-[10px] px-[14px] border-b border-solid border-b-transparent">
|
||||
<span className="flex items-center gap-[6px] [font-family:var(--font-mono)] text-[0.72rem] font-semibold uppercase [letter-spacing:0.04em] text-[color:var(--chrome-fg)]">
|
||||
<Volume2 size={13} /> {t('voicePreview.title')}
|
||||
</span>
|
||||
@@ -171,7 +171,7 @@ export default function VoicePreview({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between pt-[8px] px-[14px] pb-[10px] border-t border-solid border-t-[var(--chrome-border)]">
|
||||
<div className="flex items-center justify-between pt-[8px] px-[14px] pb-[10px] border-t border-solid border-t-transparent">
|
||||
{loading ? (
|
||||
<Button variant="ghost" size="sm" onClick={handleStop} leading={<Square size={10} />}>
|
||||
{t('voicePreview.stop')}
|
||||
|
||||
@@ -276,7 +276,7 @@ export default function WaveformPlayer({
|
||||
if (missing) {
|
||||
return (
|
||||
<div
|
||||
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)] ${
|
||||
className={`flex items-center justify-center w-full min-w-0 box-border opacity-55 border border-solid border-transparent bg-[rgba(168,153,132,0.08)] ${
|
||||
compact
|
||||
? 'gap-[8px] py-[4px] px-[8px] rounded-[8px]'
|
||||
: 'gap-[10px] py-[6px] px-[10px] rounded-[10px]'
|
||||
@@ -328,7 +328,7 @@ export default function WaveformPlayer({
|
||||
|
||||
return (
|
||||
<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)] ${
|
||||
className={`flex items-center w-full min-w-0 box-border border border-solid border-transparent bg-[rgba(168,153,132,0.08)] ${
|
||||
compact
|
||||
? 'gap-[8px] py-[4px] px-[8px] rounded-[8px]'
|
||||
: 'gap-[10px] py-[6px] px-[10px] rounded-[10px]'
|
||||
|
||||
@@ -92,8 +92,8 @@ export default function WorkspaceHistory({
|
||||
// ── Dub variant: a flat list of dub jobs, no clone/design filter. ──
|
||||
if (variant === 'dub') {
|
||||
return (
|
||||
<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)]">
|
||||
<aside className="flex-[1_1_0] flex flex-col min-h-0 overflow-hidden">
|
||||
<div className="flex-[0_0_auto] flex flex-col gap-[8px] py-[10px] px-[12px]">
|
||||
<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>
|
||||
@@ -154,8 +154,8 @@ export default function WorkspaceHistory({
|
||||
}
|
||||
|
||||
return (
|
||||
<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)]">
|
||||
<aside className="flex-[1_1_0] flex flex-col min-h-0 overflow-hidden">
|
||||
<div className="flex-[0_0_auto] flex flex-col gap-[8px] py-[10px] px-[12px]">
|
||||
<span className="inline-flex items-center gap-[6px] [font-family:var(--chrome-font-mono,var(--font-mono))] text-[0.72rem] font-semibold [letter-spacing:0.04em] uppercase text-[color:var(--chrome-fg-muted)]">
|
||||
<History size={13} /> {t('history.title', { defaultValue: 'History' })}
|
||||
</span>
|
||||
@@ -164,10 +164,10 @@ export default function WorkspaceHistory({
|
||||
<button
|
||||
key={f.id}
|
||||
type="button"
|
||||
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] ${
|
||||
className={`flex-[0_0_auto] py-[2px] px-[10px] text-[0.68rem] font-medium rounded-[var(--chrome-radius-pill,999px)] cursor-pointer [transition:background_0.15s_ease,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)]'
|
||||
? 'text-[color:var(--color-brand,#d3869b)] bg-[color-mix(in_srgb,var(--color-brand,#d3869b)_12%,transparent)]'
|
||||
: 'bg-transparent text-[color:var(--chrome-fg-muted)] hover:bg-[var(--chrome-hover-bg)] hover:text-[color:var(--chrome-fg)]'
|
||||
}`}
|
||||
onClick={() => setFilter(f.id)}
|
||||
>
|
||||
@@ -193,7 +193,7 @@ export default function WorkspaceHistory({
|
||||
<div className="flex items-center justify-between gap-2 min-w-0">
|
||||
<span
|
||||
className="history-kind"
|
||||
style={{ color: accent, borderColor: `${accent}40` }}
|
||||
style={{ color: accent, background: `${accent}22` }}
|
||||
>
|
||||
<KindIcon size={9} /> {item.mode || 'synth'}
|
||||
</span>
|
||||
|
||||
@@ -69,12 +69,12 @@ export default function WorkspaceVoices({
|
||||
return (
|
||||
<section className={`wv ${items.length === 0 ? 'wv--collapsed' : ''}`}>
|
||||
{/* ── ACTIVE VOICE ─────────────────────────────────────────────── */}
|
||||
<div className="flex-[0_0_auto] py-[10px] px-[12px] border-b border-solid border-b-[var(--chrome-border)]">
|
||||
<div className="flex-[0_0_auto] py-[10px] px-[12px]">
|
||||
<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="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 flex-col gap-[6px] py-[8px] px-[10px] 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}
|
||||
@@ -83,7 +83,7 @@ export default function WorkspaceVoices({
|
||||
className="history-kind"
|
||||
style={{
|
||||
color: active.instruct ? '#8ec07c' : '#d3869b',
|
||||
borderColor: active.instruct ? '#8ec07c40' : '#d3869b40',
|
||||
background: active.instruct ? '#8ec07c22' : '#d3869b22',
|
||||
}}
|
||||
>
|
||||
{active.instruct ? t('sidebar.design_label') : t('sidebar.clone_label')}
|
||||
@@ -114,7 +114,7 @@ export default function WorkspaceVoices({
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<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]">
|
||||
<div className="text-[0.7rem] [line-height:1.5] text-[color:var(--chrome-fg-muted)] py-[8px] px-[10px] border border-dashed border-transparent rounded-[10px]">
|
||||
{t('voices.none_selected', {
|
||||
defaultValue: 'No voice selected — describe one, drop audio, or pick below.',
|
||||
})}
|
||||
@@ -144,7 +144,7 @@ export default function WorkspaceVoices({
|
||||
{/* Empty states carry verbs (10x §2). */}
|
||||
<button
|
||||
type="button"
|
||||
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"
|
||||
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-transparent rounded-[var(--chrome-radius-pill,999px)] cursor-default"
|
||||
onClick={() => setDefineMethod(defineMethod === 'audio' ? 'audio' : 'design')}
|
||||
>
|
||||
{defineMethod === 'audio'
|
||||
@@ -170,7 +170,7 @@ export default function WorkspaceVoices({
|
||||
<div className="flex items-center justify-between gap-2 min-w-0">
|
||||
<span
|
||||
className="history-kind"
|
||||
style={{ color: accent, borderColor: `${accent}40` }}
|
||||
style={{ color: accent, background: `${accent}22` }}
|
||||
>
|
||||
<KindIcon size={9} />{' '}
|
||||
{proj.is_locked
|
||||
|
||||
@@ -216,7 +216,7 @@ export default function ActionBar({
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-[4px] px-[10px] py-[4px] text-[0.7rem] text-[var(--chrome-fg-muted)] bg-transparent border border-[var(--chrome-border)] rounded-[var(--chrome-radius-pill)] cursor-pointer whitespace-nowrap flex-none transition-[color,border-color] duration-[var(--dur-fast)] hover:text-[var(--chrome-fg)] hover:border-[var(--chrome-border-strong)] focus-visible:[outline:2px_solid_var(--chrome-accent)] focus-visible:[outline-offset:1px]"
|
||||
className="inline-flex items-center gap-[4px] px-[10px] py-[4px] text-[0.7rem] text-[var(--chrome-fg-muted)] bg-transparent border border-transparent rounded-[var(--chrome-radius-pill)] cursor-pointer whitespace-nowrap flex-none transition-[color,border-color] duration-[var(--dur-fast)] hover:text-[var(--chrome-fg)] hover:border-transparent focus-visible:[outline:2px_solid_var(--chrome-accent)] focus-visible:[outline-offset:1px]"
|
||||
onClick={() => setShowOverrides(!showOverrides)}
|
||||
aria-expanded={showOverrides}
|
||||
>
|
||||
|
||||
@@ -21,12 +21,12 @@ const CHIP_FOCUS =
|
||||
'focus-visible:[outline:2px_solid_var(--chrome-accent)] focus-visible:[outline-offset:1px]';
|
||||
const PCHIP_BASE = `inline-flex items-center gap-[5px] px-[12px] py-[5px] font-[var(--font-sans)] text-[0.72rem] font-medium rounded-[var(--chrome-radius-pill)] border bg-transparent flex-none cursor-pointer transition-colors duration-[120ms] ${CHIP_FOCUS}`;
|
||||
const PCHIP_INACTIVE =
|
||||
'border-[var(--chrome-border)] text-[var(--chrome-fg-muted)] hover:bg-[var(--chrome-hover-bg)] hover:border-[var(--chrome-border-strong)] hover:text-[var(--chrome-fg)]';
|
||||
'border-transparent text-[var(--chrome-fg-muted)] hover:bg-[var(--chrome-hover-bg)] hover:border-transparent hover:text-[var(--chrome-fg)]';
|
||||
const PCHIP_ACTIVE =
|
||||
'bg-[var(--chrome-accent-bg)] border-[var(--chrome-accent-border)] text-[var(--chrome-accent)]';
|
||||
const CHIP_BASE = `font-[var(--font-sans)] font-medium text-[0.68rem] px-[10px] py-[3px] rounded-[var(--chrome-radius-pill)] border bg-transparent whitespace-nowrap cursor-pointer transition-colors duration-[120ms] ${CHIP_FOCUS}`;
|
||||
const CHIP_INACTIVE =
|
||||
'border-[var(--chrome-border)] text-[var(--chrome-fg-muted)] hover:text-[var(--chrome-fg)] hover:bg-[var(--chrome-hover-bg)] hover:border-[var(--chrome-border-strong)]';
|
||||
'border-transparent text-[var(--chrome-fg-muted)] hover:text-[var(--chrome-fg)] hover:bg-[var(--chrome-hover-bg)] hover:border-transparent';
|
||||
const CHIP_ACTIVE =
|
||||
'bg-[var(--chrome-accent-bg)] border-[var(--chrome-accent-border)] text-[var(--chrome-accent)]';
|
||||
|
||||
@@ -130,7 +130,7 @@ export default function DesignMethodPanel({
|
||||
(first run) starts expanded. */}
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-[8px] w-full mt-[4px] mb-[8px] px-[10px] py-[6px] bg-[var(--chrome-hover-bg)] border border-[var(--chrome-border)] rounded-[8px] cursor-pointer text-left transition-[border-color] duration-[var(--dur-fast)] hover:border-[var(--chrome-border-strong)] focus-visible:[outline:2px_solid_var(--chrome-accent)] focus-visible:[outline-offset:1px]"
|
||||
className="flex items-center gap-[8px] w-full mt-[4px] mb-[8px] px-[10px] py-[6px] bg-[var(--chrome-hover-bg)] border border-transparent rounded-[8px] cursor-pointer text-left transition-[border-color] duration-[var(--dur-fast)] hover:border-transparent focus-visible:[outline:2px_solid_var(--chrome-accent)] focus-visible:[outline-offset:1px]"
|
||||
onClick={() => setIdentityOpen((o) => !o)}
|
||||
aria-expanded={identityOpen}
|
||||
>
|
||||
|
||||
@@ -7,11 +7,11 @@ import { Sparkles, Square, Mic } from 'lucide-react';
|
||||
const MIC_BASE =
|
||||
'flex flex-col items-center justify-center gap-[var(--space-2)] px-4 py-2 min-w-[70px] rounded-[var(--radius-xl)] text-[length:var(--text-xs)] font-semibold cursor-pointer transition-all duration-[var(--dur-base)] ease-[var(--ease-out)]';
|
||||
const MIC_IDLE =
|
||||
'bg-white/[0.03] border border-white/10 text-[var(--color-fg-muted)] hover:border-[var(--color-danger)] hover:text-[var(--color-danger)]';
|
||||
'bg-[var(--chrome-hover-bg)] border border-transparent text-[var(--color-fg-muted)] hover:border-[var(--color-danger)] hover:text-[var(--color-danger)]';
|
||||
const MIC_RECORDING =
|
||||
'bg-[rgba(251,73,52,0.15)] border-2 border-[var(--color-danger)] text-[var(--color-danger)] animate-[pulse_1s_ease-in-out_infinite]';
|
||||
'bg-[color-mix(in_srgb,var(--color-danger)_15%,transparent)] border-2 border-[var(--color-danger)] text-[var(--color-danger)] animate-[pulse_1s_ease-in-out_infinite]';
|
||||
const MIC_CLEANING =
|
||||
'bg-[rgba(184,187,38,0.10)] border border-[rgba(184,187,38,0.20)] text-[#b8bb26] cursor-default';
|
||||
'bg-[rgba(184,187,38,0.10)] border border-transparent text-[#b8bb26] cursor-default';
|
||||
|
||||
export default function MicButton({ isCleaning, isRecording, recordingTime, onStart, onStop }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -6,14 +6,14 @@ import { TAGS } from '../../utils/constants';
|
||||
// Tailwind utilities (shadcn P4). Flat chrome pill, mono face — token utilities
|
||||
// reference the same --chrome-* vars the old rule used, so the look is unchanged.
|
||||
const TAG_BTN =
|
||||
'border border-[var(--chrome-border)] bg-transparent text-[var(--chrome-fg-muted)] px-[9px] py-[3px] rounded-[var(--chrome-radius-pill)] font-[var(--chrome-font-mono)] font-medium text-[0.66rem] whitespace-nowrap cursor-pointer transition-colors duration-[120ms] hover:bg-[var(--chrome-hover-bg)] hover:text-[var(--chrome-fg)] hover:border-[var(--chrome-border-strong)]';
|
||||
'border border-transparent bg-transparent text-[var(--chrome-fg-muted)] px-[9px] py-[3px] rounded-[var(--chrome-radius-pill)] font-[var(--chrome-font-mono)] font-medium text-[0.66rem] whitespace-nowrap cursor-pointer transition-colors duration-[120ms] hover:bg-[var(--chrome-hover-bg)] hover:text-[var(--chrome-fg)] hover:border-transparent';
|
||||
|
||||
// Studio shell migrated from the `studio-*` classes + CloneDesignTab.css to
|
||||
// utilities (fast shadcn). `.studio-panel` stays defined in index.css for the
|
||||
// dub area; clone reproduces it inline so its bespoke restack (flat stack,
|
||||
// overflow-visible insert popover) is self-contained.
|
||||
const STUDIO_PANEL =
|
||||
'flex flex-col min-h-0 bg-[var(--chrome-bg)] border border-[var(--chrome-border)] rounded-none py-[10px] px-[12px] max-[800px]:px-[10px] max-[600px]:px-[6px] max-[600px]:py-[8px]';
|
||||
'flex flex-col min-h-0 bg-[var(--chrome-bg)] border border-transparent rounded-none py-[10px] px-[12px] max-[800px]:px-[10px] max-[600px]:px-[6px] max-[600px]:py-[8px]';
|
||||
|
||||
export default function ScriptPanel({
|
||||
t,
|
||||
@@ -85,8 +85,8 @@ export default function ScriptPanel({
|
||||
type="button"
|
||||
className={`absolute right-[8px] bottom-[30px] inline-flex items-center gap-[4px] px-2 py-1 text-[0.66rem] bg-[var(--chrome-bg)] border rounded-[var(--chrome-radius-pill)] cursor-pointer transition-[color,border-color] duration-[var(--dur-fast)] focus-visible:[outline:2px_solid_var(--chrome-accent)] focus-visible:[outline-offset:1px] ${
|
||||
insertOpen
|
||||
? 'text-[var(--chrome-fg)] border-[var(--chrome-border-strong)]'
|
||||
: 'text-[var(--chrome-fg-muted)] border-[var(--chrome-border)] hover:text-[var(--chrome-fg)] hover:border-[var(--chrome-border-strong)]'
|
||||
? 'text-[var(--chrome-fg)] border-transparent'
|
||||
: 'text-[var(--chrome-fg-muted)] border-transparent hover:text-[var(--chrome-fg)] hover:border-transparent'
|
||||
}`}
|
||||
onClick={() => setInsertOpen((o) => !o)}
|
||||
aria-expanded={insertOpen}
|
||||
@@ -100,7 +100,7 @@ export default function ScriptPanel({
|
||||
)}
|
||||
{insertOpen && (
|
||||
<div
|
||||
className="absolute right-[8px] bottom-[60px] z-20 flex flex-wrap gap-1 max-w-[min(360px,calc(100vw-16px))] max-h-[min(280px,calc(100vh-120px))] overflow-y-auto overscroll-contain p-2 bg-[var(--chrome-bg)] border border-[var(--chrome-border-strong)] rounded-[10px] shadow-[0_8px_24px_rgba(0,0,0,0.45)]"
|
||||
className="absolute right-[8px] bottom-[60px] z-20 flex flex-wrap gap-1 max-w-[min(360px,calc(100vw-16px))] max-h-[min(280px,calc(100vh-120px))] overflow-y-auto overscroll-contain p-2 bg-[var(--chrome-bg)] border border-transparent rounded-[10px] shadow-[0_8px_24px_rgba(0,0,0,0.45)]"
|
||||
role="menu"
|
||||
>
|
||||
{TAGS.map((tag) => (
|
||||
@@ -117,7 +117,7 @@ export default function ScriptPanel({
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
className={`${TAG_BTN} !border-[#b8bb26] !text-[#b8bb26]`}
|
||||
className={`${TAG_BTN} !border-transparent !text-[#b8bb26]`}
|
||||
role="menuitem"
|
||||
onClick={() => {
|
||||
insertTag('[B EY1 S]');
|
||||
|
||||
@@ -11,11 +11,10 @@ const ERROR_AUTOCLEAR_MS = 12000;
|
||||
// Export-track toggle chips: flat pill outline, tinted by on/off/success state.
|
||||
const TRACK_LABEL =
|
||||
'inline-flex items-center gap-[4px] px-[8px] py-[2px] border border-transparent rounded-[var(--chrome-radius-pill)] cursor-pointer transition-colors';
|
||||
const TRACK_ON =
|
||||
'text-[var(--chrome-fg)] border-[var(--chrome-border-strong)] bg-[var(--chrome-hover-bg)]';
|
||||
const TRACK_ON = 'text-[var(--chrome-fg)] border-transparent bg-[var(--chrome-hover-bg)]';
|
||||
const TRACK_OFF = 'text-[var(--chrome-fg-dim)]';
|
||||
const TRACK_ON_SUCCESS =
|
||||
'text-[var(--chrome-severity-ok)] border-[color-mix(in_srgb,var(--chrome-severity-ok)_45%,transparent)] bg-[color-mix(in_srgb,var(--chrome-severity-ok)_10%,transparent)]';
|
||||
'text-[var(--chrome-severity-ok)] border-transparent bg-[color-mix(in_srgb,var(--chrome-severity-ok)_10%,transparent)]';
|
||||
|
||||
export default function DubFooter({
|
||||
t,
|
||||
@@ -43,7 +42,7 @@ export default function DubFooter({
|
||||
}, [canAutoClear, dubError, onDismissError]);
|
||||
|
||||
return (
|
||||
<div className="px-[var(--space-3)] py-[4px] shrink-0 bg-[var(--chrome-bg)] border border-[var(--chrome-border)]">
|
||||
<div className="px-[var(--space-3)] py-[4px] shrink-0 bg-[var(--chrome-bg)] border border-transparent">
|
||||
{dubStep === 'done' && (
|
||||
<div className="mb-[var(--space-2)]">
|
||||
<Badge tone="success">
|
||||
@@ -86,7 +85,7 @@ export default function DubFooter({
|
||||
)}
|
||||
{/* Output options + Timing moved to the top of the right (transcript) section. */}
|
||||
{dubTracks.length > 0 && (
|
||||
<div className="flex items-center gap-[var(--space-2)] mb-[2px] px-[var(--space-3)] py-[3px] text-[length:var(--text-xs)] text-[var(--chrome-fg-muted)] font-[family-name:var(--font-sans)] bg-[var(--chrome-bg)] rounded-[var(--chrome-radius-pill)] border border-[var(--chrome-border)] flex-wrap">
|
||||
<div className="flex items-center gap-[var(--space-2)] mb-[2px] px-[var(--space-3)] py-[3px] text-[length:var(--text-xs)] text-[var(--chrome-fg-muted)] font-[family-name:var(--font-sans)] bg-[var(--chrome-bg)] rounded-[var(--chrome-radius-pill)] border border-transparent flex-wrap">
|
||||
<span className="font-[family-name:var(--chrome-font-mono)] text-[length:var(--chrome-label-size)] tracking-[var(--chrome-label-track)] uppercase text-[var(--chrome-fg-muted)] font-semibold">
|
||||
{t('dub.export_tracks')}
|
||||
</span>
|
||||
@@ -95,7 +94,7 @@ export default function DubFooter({
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="accent-[var(--chrome-accent)]"
|
||||
className="accent-[var(--color-brand)]"
|
||||
checked={exportTracks['original'] !== false}
|
||||
onChange={(e) => setExportTracks((prev) => ({ ...prev, original: e.target.checked }))}
|
||||
/>
|
||||
@@ -108,7 +107,7 @@ export default function DubFooter({
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="accent-[var(--chrome-accent)]"
|
||||
className="accent-[var(--color-brand)]"
|
||||
checked={exportTracks[t] !== false}
|
||||
onChange={(e) => setExportTracks((prev) => ({ ...prev, [t]: e.target.checked }))}
|
||||
/>
|
||||
@@ -132,7 +131,7 @@ export default function DubFooter({
|
||||
const worst = hot.reduce((a, b) => (a.rate_ratio > b.rate_ratio ? a : b));
|
||||
return (
|
||||
<div
|
||||
className="flex items-start gap-[8px] px-[10px] py-[6px] my-[4px] bg-[color-mix(in_srgb,#fabd2f_12%,transparent)] border border-[color-mix(in_srgb,#fabd2f_35%,transparent)] border-l-2 border-l-[#fabd2f] rounded-[var(--chrome-radius-pill)] text-[0.72rem] text-[var(--chrome-fg)] leading-[1.35]"
|
||||
className="flex items-start gap-[8px] px-[10px] py-[6px] my-[4px] bg-[color-mix(in_srgb,#fabd2f_12%,transparent)] border border-transparent border-l-2 border-l-transparent rounded-[var(--chrome-radius-pill)] text-[0.72rem] text-[var(--chrome-fg)] leading-[1.35]"
|
||||
role="status"
|
||||
>
|
||||
<span className="text-[#fabd2f] text-[0.9rem] leading-none shrink-0">⚠</span>
|
||||
|
||||
@@ -34,24 +34,24 @@ export default function DubHeader({
|
||||
setExportOpen,
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-wrap justify-between items-center gap-x-[var(--space-3)] gap-y-[4px] px-[12px] py-[5px] shrink-0 bg-[rgba(255,255,255,0.015)] [border:1px_solid_rgba(255,255,255,0.04)] rounded-md mb-[2px]">
|
||||
<div className="flex flex-wrap justify-between items-center gap-x-[var(--space-2)] gap-y-[4px] min-w-0 px-[10px] py-[4px] shrink-0 bg-[var(--color-bg-elev-1)] rounded-md mb-[2px]">
|
||||
{/* Pipeline spine, inlined onto the header row (Upload → … → Export). */}
|
||||
<DubPipelineStepper dubStep={dubStep} inline />
|
||||
<div className="label-row dub-head__title">
|
||||
<div className="label-row dub-head__title !gap-[6px]">
|
||||
<FileText className="label-icon" size={11} />
|
||||
<span className="font-semibold text-[0.85rem] overflow-hidden text-ellipsis whitespace-nowrap text-fg">
|
||||
<span className="font-medium text-[0.78rem] min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-fg normal-case">
|
||||
{dubFilename}
|
||||
</span>
|
||||
<span className="text-fg-muted font-normal whitespace-nowrap text-[0.72rem]">
|
||||
<span className="text-fg-muted font-normal whitespace-nowrap text-[0.68rem] normal-case shrink-0">
|
||||
· {formatTime(dubDuration)} · {dubSegments.length} {t('dub.segs')}
|
||||
</span>
|
||||
{activeProjectName && activeProjectName !== dubFilename && (
|
||||
<span className="text-[#b8bb26] ml-[var(--space-3)] whitespace-nowrap text-[0.72rem]">
|
||||
<span className="text-[#b8bb26] ml-[var(--space-2)] whitespace-nowrap text-[0.68rem] normal-case overflow-hidden text-ellipsis min-w-0">
|
||||
— {activeProjectName}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-[var(--space-2)] items-center shrink-0">
|
||||
<div className="flex gap-[6px] items-center shrink-0">
|
||||
{/* Icon-only secondary actions (tooltips carry the labels);
|
||||
Generate Dub keeps its label as the primary verb. */}
|
||||
<Button
|
||||
@@ -73,7 +73,7 @@ export default function DubHeader({
|
||||
<RotateCcw size={12} />
|
||||
</Button>
|
||||
{/* Primary actions live on the header bar (compact) — moved up from the footer. */}
|
||||
<div className="flex gap-[var(--space-2)] items-center pl-[var(--space-2)] [border-left:1px_solid_var(--color-border,#3a3a3a)]">
|
||||
<div className="flex gap-[6px] items-center pl-[var(--space-2)] ml-[2px]">
|
||||
{dubStep === 'stopping' ? (
|
||||
<FooterBtn
|
||||
sm
|
||||
|
||||
@@ -29,23 +29,23 @@ import toast from 'react-hot-toast';
|
||||
|
||||
// ── Translation-settings bar utility class clusters ──────────────────────
|
||||
const SETTINGS_SUMMARY =
|
||||
'flex items-center gap-[var(--space-2)] px-[var(--space-3)] py-[3px] mb-[3px] bg-[var(--chrome-bg)] border border-[var(--chrome-border)] rounded-[var(--chrome-radius-pill)] font-[family-name:var(--font-sans)] text-[0.66rem] text-[var(--chrome-fg-muted)]';
|
||||
'flex items-center gap-[var(--space-2)] px-[var(--space-3)] py-[3px] mb-[3px] bg-[var(--chrome-bg)] border border-transparent rounded-[var(--chrome-radius-pill)] font-[family-name:var(--font-sans)] text-[0.66rem] text-[var(--chrome-fg-muted)]';
|
||||
const SUMMARY_TRIGGER =
|
||||
'inline-flex items-center gap-[5px] flex-1 min-w-0 bg-transparent border-none text-fg-muted cursor-pointer py-[2px] px-0 [font:inherit] text-left';
|
||||
const SETTINGS_BAR =
|
||||
'flex flex-col gap-[3px] max-[900px]:gap-[6px] mb-[4px] px-[8px] py-[4px] bg-[var(--chrome-bg)] border border-[var(--chrome-border)] rounded-[var(--chrome-radius-pill)]';
|
||||
'flex flex-col gap-[3px] max-[900px]:gap-[6px] mb-[4px] px-[8px] py-[4px] bg-[var(--chrome-bg)] border border-transparent rounded-[var(--chrome-radius-pill)]';
|
||||
const FIELD = 'flex flex-col gap-[1px] min-w-0';
|
||||
const FIELD_RESP = 'max-[960px]:basis-full max-[960px]:min-w-0';
|
||||
const FIELD_LABEL =
|
||||
'label-row !text-[0.58rem] !text-fg-muted !m-0 whitespace-nowrap overflow-hidden text-ellipsis';
|
||||
const FIELD_INPUT = 'input-base !w-full !text-[0.65rem] !px-[5px] !py-[3px]';
|
||||
const ENGINE_CHIP =
|
||||
'ml-[6px] px-[6px] py-[1px] text-[0.55rem] leading-[1.4] bg-[rgba(211,134,155,0.14)] border border-[rgba(211,134,155,0.35)] text-[#d3869b] rounded-[999px] whitespace-nowrap transition-colors';
|
||||
// Highlighted accent Install affordance — brand accent (#d3869b) filled pill,
|
||||
// deliberately louder than ENGINE_CHIP so an uninstalled selected engine is an
|
||||
// obvious call to action rather than a muted footnote.
|
||||
'ml-[6px] px-[6px] py-[1px] text-[0.55rem] leading-[1.4] bg-[color-mix(in_srgb,var(--color-brand)_14%,transparent)] border border-transparent text-[var(--color-brand)] rounded-[var(--radius-pill)] whitespace-nowrap transition-colors';
|
||||
// Highlighted accent Install affordance — brand-filled pill, deliberately louder
|
||||
// than ENGINE_CHIP so an uninstalled selected engine is an obvious call to action
|
||||
// rather than a muted footnote.
|
||||
const ENGINE_INSTALL_BTN =
|
||||
'inline-flex items-center gap-[3px] ml-[6px] px-[7px] py-[1px] text-[0.55rem] font-semibold leading-[1.5] bg-[#d3869b] hover:bg-[#e0a0b3] text-[#1d2021] border border-[#d3869b] rounded-[999px] whitespace-nowrap cursor-pointer transition-colors shadow-[0_0_0_2px_rgba(211,134,155,0.25)] disabled:opacity-60 disabled:cursor-default';
|
||||
'inline-flex items-center gap-[3px] ml-[6px] px-[7px] py-[1px] text-[0.55rem] font-semibold leading-[1.5] bg-[var(--color-brand)] hover:bg-[var(--color-brand-hover)] text-[var(--color-fg-inverse)] border border-transparent rounded-[var(--radius-pill)] whitespace-nowrap cursor-pointer transition-colors shadow-[0_0_0_2px_color-mix(in_srgb,var(--color-brand)_25%,transparent)] disabled:opacity-60 disabled:cursor-default';
|
||||
|
||||
export default function DubLeftColumn({
|
||||
hasDubbedTrack,
|
||||
@@ -245,7 +245,7 @@ export default function DubLeftColumn({
|
||||
dropdown. It's also pre-selected on the segments so "new
|
||||
language = same speaker's voice" works by default. */}
|
||||
{dubSegments.some((s) => s.speaker_id) && (
|
||||
<div className="mt-[2px] px-[var(--space-3)] py-[3px] bg-[var(--chrome-bg)] rounded-[var(--chrome-radius-pill)] border border-[var(--chrome-border)]">
|
||||
<div className="mt-[2px] px-[var(--space-3)] py-[3px] bg-[var(--chrome-bg)] rounded-[var(--chrome-radius-pill)] border border-transparent">
|
||||
<div className="flex gap-[var(--space-2)] items-center flex-wrap">
|
||||
<span
|
||||
className="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"
|
||||
@@ -492,7 +492,7 @@ export default function DubLeftColumn({
|
||||
<div
|
||||
role="dialog"
|
||||
aria-label={t('dub.install_popover_title')}
|
||||
className="absolute z-20 top-[calc(100%+6px)] left-0 w-[290px] max-w-[80vw] p-[10px] flex flex-col gap-[8px] bg-[var(--chrome-bg,#282828)] border border-[var(--chrome-border-strong,#504945)] rounded-[8px] shadow-[0_8px_24px_rgba(0,0,0,0.45)] normal-case text-left"
|
||||
className="absolute z-20 top-[calc(100%+6px)] left-0 w-[290px] max-w-[80vw] p-[10px] flex flex-col gap-[8px] bg-[var(--chrome-bg,#282828)] border border-transparent rounded-[8px] shadow-[0_8px_24px_rgba(0,0,0,0.45)] normal-case text-left"
|
||||
>
|
||||
<div className="text-[0.68rem] font-semibold text-[var(--chrome-fg,#ebdbb2)] normal-case tracking-normal">
|
||||
{t('dub.install_popover_title')}
|
||||
@@ -502,12 +502,12 @@ export default function DubLeftColumn({
|
||||
</p>
|
||||
{installCmd && (
|
||||
<div className="flex items-stretch gap-[4px]">
|
||||
<code className="flex-1 min-w-0 px-[6px] py-[4px] text-[0.6rem] leading-[1.4] font-[family-name:var(--chrome-font-mono,monospace)] text-[var(--chrome-fg,#ebdbb2)] bg-[rgba(0,0,0,0.35)] border border-[var(--chrome-border,#3c3836)] rounded-[5px] overflow-x-auto whitespace-nowrap">
|
||||
<code className="flex-1 min-w-0 px-[6px] py-[4px] text-[0.6rem] leading-[1.4] font-[family-name:var(--chrome-font-mono,monospace)] text-[var(--chrome-fg,#ebdbb2)] bg-[rgba(0,0,0,0.35)] border border-transparent rounded-[5px] overflow-x-auto whitespace-nowrap">
|
||||
{installCmd}
|
||||
</code>
|
||||
<button
|
||||
type="button"
|
||||
className="shrink-0 inline-flex items-center justify-center px-[6px] rounded-[5px] border border-[var(--chrome-border,#3c3836)] text-[var(--chrome-fg-muted,#a89984)] hover:text-[var(--chrome-fg,#ebdbb2)] hover:border-[var(--chrome-border-strong,#504945)] cursor-pointer bg-transparent"
|
||||
className="shrink-0 inline-flex items-center justify-center px-[6px] rounded-[5px] border border-transparent text-[var(--chrome-fg-muted,#a89984)] hover:text-[var(--chrome-fg,#ebdbb2)] hover:border-transparent cursor-pointer bg-transparent"
|
||||
onClick={copyInstallCmd}
|
||||
title={t('dub.copy_command')}
|
||||
aria-label={t('dub.copy_command')}
|
||||
@@ -518,7 +518,7 @@ export default function DubLeftColumn({
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center justify-center gap-[5px] px-[8px] py-[5px] text-[0.64rem] font-semibold bg-[#d3869b] hover:bg-[#e0a0b3] text-[#1d2021] border-none rounded-[6px] cursor-pointer transition-colors"
|
||||
className="inline-flex items-center justify-center gap-[5px] px-[8px] py-[5px] text-[0.64rem] font-semibold bg-[var(--color-brand)] hover:bg-[var(--color-brand-hover)] text-[var(--color-fg-inverse)] border-none rounded-[var(--radius-lg)] cursor-pointer transition-colors"
|
||||
onClick={() => {
|
||||
setTranslateProvider('argos');
|
||||
setInstallPopoverOpen(false);
|
||||
@@ -616,13 +616,11 @@ export default function DubLeftColumn({
|
||||
onChange={(e) => setDubInstruct(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={`${FIELD} basis-full pt-[3px] border-t border-[var(--chrome-border)] mt-[1px]`}
|
||||
>
|
||||
<div className={`${FIELD} basis-full pt-[3px] border-t border-transparent mt-[1px]`}>
|
||||
<label className="flex items-center gap-[6px] text-[0.65rem] text-[var(--chrome-fg-muted)] cursor-pointer mb-[2px]">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="accent-[var(--chrome-accent)] cursor-pointer"
|
||||
className="accent-[var(--color-brand)] cursor-pointer"
|
||||
checked={multiLangMode}
|
||||
onChange={(e) => setMultiLangMode(e.target.checked)}
|
||||
/>
|
||||
|
||||
@@ -16,7 +16,7 @@ const OUT_LABEL =
|
||||
'flex items-center gap-[var(--space-2)] cursor-pointer hover:text-[var(--chrome-fg)]';
|
||||
const OUT_TITLE =
|
||||
'font-[family-name:var(--chrome-font-mono)] text-[length:var(--chrome-label-size)] tracking-[var(--chrome-label-track)] uppercase text-[var(--chrome-fg-muted)] font-semibold';
|
||||
const CHK = 'accent-[var(--chrome-accent)]';
|
||||
const CHK = 'accent-[var(--color-brand)]';
|
||||
const BULK_SELECT = 'input-base !text-[0.62rem] !px-[4px] !py-[2px]';
|
||||
|
||||
export default function DubRightColumn({
|
||||
@@ -173,7 +173,7 @@ export default function DubRightColumn({
|
||||
{showTranscript ? <ChevronUp size={10} /> : <ChevronDown size={10} />}
|
||||
</div>
|
||||
{showTranscript && (
|
||||
<div className="bg-[var(--chrome-bg)] border border-[var(--chrome-border)] border-t-0 rounded-b-[var(--chrome-radius-pill)] p-[var(--space-3)] text-[length:var(--text-xs)] text-[var(--chrome-fg-muted)] leading-[1.5] max-h-[80px] overflow-y-auto">
|
||||
<div className="bg-[var(--chrome-bg)] border border-transparent border-t-0 rounded-b-[var(--chrome-radius-pill)] p-[var(--space-3)] text-[length:var(--text-xs)] text-[var(--chrome-fg-muted)] leading-[1.5] max-h-[80px] overflow-y-auto">
|
||||
{dubTranscript}
|
||||
</div>
|
||||
)}
|
||||
@@ -185,7 +185,7 @@ export default function DubRightColumn({
|
||||
{dubJobId && !glossaryVisible && (
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center px-[var(--space-3)] py-[3px] mb-[4px] font-[family-name:var(--chrome-font-mono)] text-[length:var(--chrome-label-size)] tracking-[var(--chrome-label-track)] uppercase text-[var(--chrome-fg-muted)] bg-transparent border border-[var(--chrome-border)] rounded-[var(--chrome-radius-pill)] cursor-pointer transition-colors hover:bg-[var(--chrome-hover-bg)] hover:border-[var(--chrome-border-strong)] hover:text-[var(--chrome-fg)]"
|
||||
className="inline-flex items-center px-[var(--space-3)] py-[3px] mb-[4px] font-[family-name:var(--chrome-font-mono)] text-[length:var(--chrome-label-size)] tracking-[var(--chrome-label-track)] uppercase text-[var(--chrome-fg-muted)] bg-transparent border border-transparent rounded-[var(--chrome-radius-pill)] cursor-pointer transition-colors hover:bg-[var(--chrome-hover-bg)] hover:border-transparent hover:text-[var(--chrome-fg)]"
|
||||
onClick={() => {
|
||||
setGlossaryOpen(true);
|
||||
setGlossaryHidden(false);
|
||||
@@ -216,7 +216,7 @@ export default function DubRightColumn({
|
||||
thing per-speaker (and handles the multi-speaker case cleanly). */}
|
||||
|
||||
{selectedSegIds.size > 0 && (
|
||||
<div className="flex items-center gap-[var(--space-3)] px-[6px] py-[3px] rounded-[var(--radius-md)] mb-[var(--space-2)] text-[length:var(--text-xs)] bg-[rgba(211,134,155,0.08)] border border-[rgba(211,134,155,0.25)]">
|
||||
<div className="flex items-center gap-[var(--space-3)] px-[6px] py-[3px] rounded-[var(--radius-md)] mb-[var(--space-2)] text-[length:var(--text-xs)] bg-[rgba(211,134,155,0.08)] border border-transparent">
|
||||
<span className="text-brand font-bold whitespace-nowrap">
|
||||
{t('dub.selected_count', { count: selectedSegIds.size })}
|
||||
</span>
|
||||
|
||||
@@ -14,20 +14,19 @@ const BASE =
|
||||
'disabled:opacity-45 disabled:cursor-not-allowed';
|
||||
|
||||
const TONES = {
|
||||
idle: 'text-[var(--chrome-fg-muted)] border-[var(--chrome-border)] hover:bg-[var(--chrome-hover-bg)]',
|
||||
stopping:
|
||||
'text-[var(--chrome-fg-muted)] border-[var(--chrome-border)] hover:bg-[var(--chrome-hover-bg)]',
|
||||
idle: 'text-[var(--chrome-fg-muted)] border-transparent hover:bg-[var(--chrome-hover-bg)]',
|
||||
stopping: 'text-[var(--chrome-fg-muted)] border-transparent hover:bg-[var(--chrome-hover-bg)]',
|
||||
danger:
|
||||
'text-[var(--chrome-severity-err)] border-[color-mix(in_srgb,var(--chrome-severity-err)_45%,transparent)] bg-[color-mix(in_srgb,var(--chrome-severity-err)_10%,transparent)] hover:bg-[color-mix(in_srgb,var(--chrome-severity-err)_18%,transparent)]',
|
||||
'text-[var(--chrome-severity-err)] border-transparent bg-[color-mix(in_srgb,var(--chrome-severity-err)_10%,transparent)] hover:bg-[color-mix(in_srgb,var(--chrome-severity-err)_18%,transparent)]',
|
||||
green:
|
||||
'text-[var(--chrome-severity-ok)] border-[color-mix(in_srgb,var(--chrome-severity-ok)_45%,transparent)] bg-[color-mix(in_srgb,var(--chrome-severity-ok)_10%,transparent)] hover:bg-[color-mix(in_srgb,var(--chrome-severity-ok)_18%,transparent)]',
|
||||
'text-[var(--chrome-severity-ok)] border-transparent bg-[color-mix(in_srgb,var(--chrome-severity-ok)_10%,transparent)] hover:bg-[color-mix(in_srgb,var(--chrome-severity-ok)_18%,transparent)]',
|
||||
pink: 'text-[var(--chrome-accent)] border-[var(--chrome-accent-border)] bg-[var(--chrome-accent-bg)] hover:bg-[color-mix(in_srgb,var(--chrome-accent)_20%,transparent)]',
|
||||
blue: 'text-[#83a598] border-[color-mix(in_srgb,#83a598_45%,transparent)] bg-[color-mix(in_srgb,#83a598_10%,transparent)]',
|
||||
lime: 'text-[#b8bb26] border-[color-mix(in_srgb,#b8bb26_45%,transparent)] bg-[color-mix(in_srgb,#b8bb26_10%,transparent)]',
|
||||
blue: 'text-[var(--color-info)] border-transparent bg-[color-mix(in_srgb,var(--color-info)_10%,transparent)]',
|
||||
lime: 'text-[#b8bb26] border-transparent bg-[color-mix(in_srgb,#b8bb26_10%,transparent)]',
|
||||
amber:
|
||||
'text-[var(--chrome-severity-warn)] border-[color-mix(in_srgb,var(--chrome-severity-warn)_45%,transparent)] bg-[color-mix(in_srgb,var(--chrome-severity-warn)_10%,transparent)]',
|
||||
'text-[var(--chrome-severity-warn)] border-transparent bg-[color-mix(in_srgb,var(--chrome-severity-warn)_10%,transparent)]',
|
||||
orange:
|
||||
'text-[#fe8019] border-[color-mix(in_srgb,#fe8019_45%,transparent)] bg-[color-mix(in_srgb,#fe8019_10%,transparent)]',
|
||||
'text-[var(--color-warn)] border-transparent bg-[color-mix(in_srgb,var(--color-warn)_10%,transparent)]',
|
||||
};
|
||||
|
||||
const FooterBtn = React.forwardRef(function FooterBtn(
|
||||
|
||||
@@ -366,7 +366,7 @@ export default function IdleSkeleton({
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="m-0 accent-[#d3869b]"
|
||||
className="m-0 accent-[var(--color-brand)]"
|
||||
checked={fetchYtSubs}
|
||||
onChange={(e) => setFetchYtSubs(e.target.checked)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
@@ -402,7 +402,7 @@ export default function IdleSkeleton({
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-[5px] px-[10px] py-[5px] text-[0.7rem] text-[var(--chrome-fg-muted)] bg-transparent border border-[var(--chrome-border)] rounded-[var(--chrome-radius-pill,999px)] cursor-pointer transition-colors hover:text-[var(--chrome-fg)] hover:border-[var(--chrome-border-strong)]"
|
||||
className="inline-flex items-center gap-[5px] px-[10px] py-[5px] text-[0.7rem] text-[var(--chrome-fg-muted)] bg-transparent border border-transparent rounded-[var(--chrome-radius-pill,999px)] cursor-pointer transition-colors hover:text-[var(--chrome-fg)] hover:border-transparent"
|
||||
onClick={() => setLandingAdvOpen((o) => !o)}
|
||||
aria-expanded={landingAdvOpen}
|
||||
>
|
||||
@@ -581,7 +581,7 @@ export default function IdleSkeleton({
|
||||
<Play size={11} /> {t('dub.generate_dub')}
|
||||
</Button>
|
||||
<button
|
||||
className="inline-flex items-center gap-[5px] bg-transparent border border-[var(--chrome-border)] text-[var(--chrome-fg-muted)] rounded-[8px] flex-[0_0_auto] px-[8px] py-[4px] text-[0.7rem] opacity-40"
|
||||
className="inline-flex items-center gap-[5px] bg-transparent border border-transparent text-[var(--chrome-fg-muted)] rounded-[8px] flex-[0_0_auto] px-[8px] py-[4px] text-[0.7rem] opacity-40"
|
||||
disabled
|
||||
>
|
||||
<Download size={11} /> {t('dub.export_btn', { defaultValue: 'Export' })}{' '}
|
||||
|
||||
@@ -30,34 +30,41 @@ export default function ArchetypeCard({
|
||||
const accentLabel = a.facets.accent
|
||||
? facetLabel(a.facets.accent)
|
||||
: dialect || (a.language === 'Chinese' ? 'Chinese' : null);
|
||||
const hasChips = Boolean(accentLabel || a.facets.whisper);
|
||||
|
||||
// Borderless by direction: the card keeps a transparent border only to reserve
|
||||
// the box width; the playing state is conveyed by an accent ring (box-shadow)
|
||||
// + lift, never a literal border.
|
||||
const cardBase =
|
||||
'group relative flex flex-col gap-[10px] p-[13px] rounded-[13px] ' +
|
||||
'bg-[linear-gradient(180deg,rgba(255,255,255,0.038),rgba(255,255,255,0.012))] border ' +
|
||||
'transition-[transform,border-color,box-shadow] duration-150 ' +
|
||||
'group relative flex flex-col gap-[11px] p-[14px] rounded-[13px] border border-transparent ' +
|
||||
'bg-[linear-gradient(180deg,rgba(255,255,255,0.038),rgba(255,255,255,0.012))] ' +
|
||||
'transition-[transform,box-shadow] duration-150 ' +
|
||||
'hover:-translate-y-[2px] hover:shadow-[0_6px_22px_rgba(0,0,0,0.4)] ' +
|
||||
'motion-reduce:transition-none motion-reduce:hover:translate-y-0';
|
||||
const cardState = isPlaying
|
||||
? 'border-[color:var(--card-accent)] shadow-[0_0_0_1px_var(--card-accent),0_6px_22px_rgba(0,0,0,0.4)]'
|
||||
: 'border-white/[0.07] hover:border-white/[0.13]';
|
||||
? 'shadow-[0_0_0_1px_var(--card-accent),0_6px_22px_rgba(0,0,0,0.4)]'
|
||||
: '';
|
||||
|
||||
return (
|
||||
<div className={`${cardBase} ${cardState}`} style={{ '--card-accent': color }}>
|
||||
<div className="flex items-center gap-[10px]">
|
||||
{/* Header — the name is the focal point; metadata recedes (smaller, muted). */}
|
||||
<div className="flex items-center gap-[11px]">
|
||||
<ArchetypeAvatar item={a} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-[0.84rem] font-semibold text-[var(--text-primary)] truncate">
|
||||
<div className="text-[0.86rem] font-semibold leading-tight text-[var(--color-fg)] truncate">
|
||||
{a.name}
|
||||
</div>
|
||||
{sub && (
|
||||
<div className="text-[0.68rem] text-[var(--text-secondary)] mt-[2px] truncate">
|
||||
<div className="text-[0.66rem] text-[var(--color-fg-muted)] mt-[3px] truncate">
|
||||
{sub}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
className={`flex-shrink-0 flex items-center justify-center w-[26px] h-[26px] rounded-[7px] cursor-pointer transition-colors hover:bg-white/[0.05] ${
|
||||
isFavorite ? 'text-[#fabd2f]' : 'text-[var(--text-secondary)] hover:text-[#fabd2f]'
|
||||
className={`flex-shrink-0 flex items-center justify-center w-[26px] h-[26px] rounded-[7px] cursor-pointer transition-[color,background-color,opacity] hover:bg-[var(--chrome-hover-bg)] ${
|
||||
isFavorite
|
||||
? 'text-[#fabd2f]'
|
||||
: 'text-[var(--color-fg-subtle)] opacity-70 group-hover:opacity-100 hover:text-[#fabd2f]'
|
||||
}`}
|
||||
onClick={() => onToggleFavorite(a.id)}
|
||||
title={t('gallery.favorite', { defaultValue: 'Favorite' })}
|
||||
@@ -66,25 +73,30 @@ export default function ArchetypeCard({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Always render the chip row (even when empty) so every card shares the
|
||||
same height and the action rows align across the grid. */}
|
||||
<div className="flex flex-wrap items-center gap-[5px] min-h-[21px]">
|
||||
{accentLabel && (
|
||||
<span className="inline-flex items-center gap-[5px] pl-[5px] pr-[8px] py-[2px] rounded-[7px] bg-white/[0.05] text-[var(--text-secondary)] text-[0.64rem] leading-[1.6]">
|
||||
<AccentFlag accent={a.facets.accent} lang={a.language} size={14} />
|
||||
{accentLabel}
|
||||
</span>
|
||||
)}
|
||||
{a.facets.whisper && (
|
||||
<span className="inline-flex items-center gap-[5px] px-[8px] py-[2px] rounded-[7px] bg-white/[0.05] text-[var(--text-secondary)] text-[0.64rem] leading-[1.6]">
|
||||
{t('archetypes.facet_whisper', { defaultValue: 'Whisper' })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{/* Chips only render when present — no empty reserved row. Cards without
|
||||
chips stay compact; the grid stretches each row to equal height so the
|
||||
`mt-auto` action row still bottom-aligns across the grid. */}
|
||||
{hasChips && (
|
||||
<div className="flex flex-wrap items-center gap-[5px]">
|
||||
{accentLabel && (
|
||||
<span className="inline-flex items-center gap-[5px] pl-[5px] pr-[8px] py-[2px] rounded-[7px] bg-[var(--color-bg-elev-2)] text-[var(--color-fg-muted)] text-[0.64rem] leading-[1.6]">
|
||||
<AccentFlag accent={a.facets.accent} lang={a.language} size={14} />
|
||||
{accentLabel}
|
||||
</span>
|
||||
)}
|
||||
{a.facets.whisper && (
|
||||
<span className="inline-flex items-center gap-[5px] px-[8px] py-[2px] rounded-[7px] bg-[var(--color-bg-elev-2)] text-[var(--color-fg-muted)] text-[0.64rem] leading-[1.6]">
|
||||
{t('archetypes.facet_whisper', { defaultValue: 'Whisper' })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions — quiet Preview (ghost, token hover), confident accent Use voice
|
||||
(tinted → solid accent with inverse text), subtle magic-wand icon. */}
|
||||
<div className="flex items-center gap-[6px] mt-auto">
|
||||
<button
|
||||
className="inline-flex items-center gap-[6px] px-[11px] py-[6px] border border-white/[0.09] bg-white/[0.03] text-[var(--text-primary)] rounded-[8px] text-[0.7rem] cursor-pointer transition-colors hover:border-[color:var(--card-accent)] hover:text-[var(--card-accent)]"
|
||||
className="inline-flex items-center gap-[6px] px-[11px] py-[6px] rounded-[8px] bg-transparent text-[var(--color-fg-muted)] text-[0.7rem] cursor-pointer transition-colors hover:bg-[var(--chrome-hover-bg)] hover:text-[var(--color-fg)]"
|
||||
onClick={() => onPreview(a)}
|
||||
title={t('gallery.preview', { defaultValue: 'Preview' })}
|
||||
>
|
||||
@@ -98,13 +110,13 @@ export default function ArchetypeCard({
|
||||
<span>{t('gallery.preview', { defaultValue: 'Preview' })}</span>
|
||||
</button>
|
||||
<button
|
||||
className="flex-1 inline-flex items-center justify-center gap-[6px] px-[10px] py-[6px] rounded-[8px] border border-[color:color-mix(in_srgb,var(--card-accent)_36%,transparent)] bg-[color-mix(in_srgb,var(--card-accent)_13%,transparent)] text-[var(--card-accent)] text-[0.72rem] font-semibold cursor-pointer transition-colors hover:bg-[var(--card-accent)] hover:border-[color:var(--card-accent)] hover:text-[#1d2021] focus-visible:bg-[var(--card-accent)] focus-visible:border-[color:var(--card-accent)] focus-visible:text-[#1d2021]"
|
||||
className="flex-1 inline-flex items-center justify-center gap-[6px] px-[10px] py-[6px] rounded-[8px] bg-[color-mix(in_srgb,var(--card-accent)_15%,transparent)] text-[var(--card-accent)] text-[0.72rem] font-semibold cursor-pointer transition-colors hover:bg-[var(--card-accent)] hover:text-[var(--color-fg-inverse)] focus-visible:bg-[var(--card-accent)] focus-visible:text-[var(--color-fg-inverse)]"
|
||||
onClick={() => onUse(a)}
|
||||
>
|
||||
<UserPlus size={14} /> {t('gallery.use_voice', { defaultValue: 'Use voice' })}
|
||||
</button>
|
||||
<button
|
||||
className="inline-flex items-center justify-center w-[30px] h-[30px] flex-shrink-0 border border-white/[0.09] bg-white/[0.03] text-[var(--text-secondary)] rounded-[8px] cursor-pointer opacity-50 transition-[opacity,border-color,color] duration-150 group-hover:opacity-100 focus-visible:opacity-100 hover:text-[var(--card-accent)] hover:border-[color:var(--card-accent)]"
|
||||
className="inline-flex items-center justify-center w-[30px] h-[30px] flex-shrink-0 rounded-[8px] bg-transparent text-[var(--color-fg-muted)] cursor-pointer opacity-50 transition-[opacity,color,background-color] duration-150 group-hover:opacity-100 focus-visible:opacity-100 hover:bg-[var(--chrome-hover-bg)] hover:text-[var(--card-accent)]"
|
||||
onClick={() => onDesign(a)}
|
||||
title={t('gallery.open_designer', { defaultValue: 'Open in Designer' })}
|
||||
>
|
||||
|
||||
@@ -117,7 +117,7 @@ export default function ArchetypesZone({
|
||||
const facetGroup =
|
||||
'flex items-center gap-[5px] flex-nowrap min-w-0 overflow-x-auto overflow-y-hidden [scrollbar-width:thin]';
|
||||
const facetToggle =
|
||||
'inline-flex items-center gap-[5px] h-[26px] box-border px-[9px] rounded-[7px] border border-[var(--chrome-border)] bg-[var(--chrome-hover-bg)] text-[var(--chrome-fg-muted)] text-[0.68rem] whitespace-nowrap cursor-pointer hover:text-[var(--chrome-fg)] hover:border-[color:var(--chrome-border-strong)]';
|
||||
'inline-flex items-center gap-[5px] h-[26px] box-border px-[9px] rounded-[7px] border border-transparent bg-[var(--chrome-hover-bg)] text-[var(--chrome-fg-muted)] text-[0.68rem] whitespace-nowrap cursor-pointer hover:text-[var(--chrome-fg)] hover:border-[color:var(--chrome-border-strong)]';
|
||||
const gridClass =
|
||||
viewMode === 'grid'
|
||||
? 'grid grid-cols-[repeat(auto-fill,minmax(248px,1fr))] gap-[10px]'
|
||||
@@ -125,7 +125,7 @@ export default function ArchetypesZone({
|
||||
|
||||
return (
|
||||
<div className="flex-1 min-h-0 flex flex-col overflow-y-auto">
|
||||
<div className="flex flex-row items-center gap-[10px] flex-nowrap shrink-0 pt-[2px] pb-[10px] mb-[8px] border-b border-[var(--chrome-border)]">
|
||||
<div className="flex flex-row items-center gap-[10px] flex-nowrap shrink-0 pt-[2px] pb-[10px] mb-[8px] border-b border-transparent">
|
||||
{/* Three filter lanes (categories · facets · toggles), each its own
|
||||
horizontally-scrollable portion; the view toggle is pinned right. */}
|
||||
<div className={`${facetGroup} flex-[2.4_1_0]`}>
|
||||
@@ -150,9 +150,7 @@ export default function ArchetypesZone({
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`${facetGroup} flex-[1.6_1_0] pl-[10px] border-l border-[var(--chrome-border)]`}
|
||||
>
|
||||
<div className={`${facetGroup} flex-[1.6_1_0] pl-[10px] border-l border-transparent`}>
|
||||
{['gender', 'age', 'pitch', 'accent', 'lang'].map((dim) => (
|
||||
<Select
|
||||
key={dim}
|
||||
@@ -172,9 +170,7 @@ export default function ArchetypesZone({
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`${facetGroup} flex-[1_1_0] pl-[10px] border-l border-[var(--chrome-border)]`}
|
||||
>
|
||||
<div className={`${facetGroup} flex-[1_1_0] pl-[10px] border-l border-transparent`}>
|
||||
<label className={facetToggle}>
|
||||
<input
|
||||
type="checkbox"
|
||||
|
||||
@@ -30,7 +30,7 @@ export default function CommunityZone({
|
||||
};
|
||||
|
||||
const submitBtn =
|
||||
'inline-flex items-center gap-[5px] px-[10px] py-[6px] border border-white/10 bg-white/[0.03] text-[var(--text-primary)] rounded-[8px] text-[0.7rem] cursor-pointer transition-colors hover:border-[color:var(--accent)] hover:text-[var(--accent)]';
|
||||
'inline-flex items-center gap-[5px] px-[10px] py-[6px] border border-transparent bg-white/[0.03] text-[var(--text-primary)] rounded-[8px] text-[0.7rem] cursor-pointer transition-colors hover:border-[color:var(--accent)] hover:text-[var(--accent)]';
|
||||
|
||||
return (
|
||||
<div className="flex-1 min-h-0 flex flex-col overflow-y-auto">
|
||||
|
||||
@@ -214,7 +214,7 @@ export default function ImportsZone({ t, playingId, loadingPreviewId, onPlayGall
|
||||
};
|
||||
|
||||
const voicePlay =
|
||||
'flex items-center justify-center w-[28px] h-[28px] rounded-full border border-[var(--chrome-border)] bg-bg-elev-1 text-[var(--text-primary)] cursor-pointer flex-shrink-0 hover:bg-[var(--accent)] hover:border-[color:var(--accent)] hover:text-white';
|
||||
'flex items-center justify-center w-[28px] h-[28px] rounded-full border border-transparent bg-bg-elev-1 text-[var(--text-primary)] cursor-pointer flex-shrink-0 hover:bg-[var(--accent)] hover:border-[color:var(--accent)] hover:text-white';
|
||||
const actionBtn =
|
||||
'flex items-center justify-center w-[24px] h-[24px] bg-transparent text-[var(--text-secondary)] rounded-[4px] cursor-pointer hover:bg-bg-elev-2 hover:text-[var(--text-primary)]';
|
||||
|
||||
@@ -305,7 +305,7 @@ export default function ImportsZone({ t, playingId, loadingPreviewId, onPlayGall
|
||||
{results.map((r, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex justify-between items-center px-[10px] py-[8px] gap-[8px] border-b border-[var(--chrome-border)] last:border-0"
|
||||
className="flex justify-between items-center px-[10px] py-[8px] gap-[8px] border-b border-transparent last:border-0"
|
||||
>
|
||||
<div className="flex-1 min-w-0 flex flex-col gap-[2px]">
|
||||
<span className="text-[0.75rem] truncate">{r.title}</span>
|
||||
|
||||
@@ -85,7 +85,7 @@ export default function ProfileActivity({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpenProject?.(p.project_id)}
|
||||
className="flex w-full cursor-pointer items-center gap-[var(--space-3)] rounded-[var(--radius-md)] border border-border bg-[rgba(255,255,255,0.02)] px-[var(--space-4)] py-[var(--space-3)] text-fg [font-size:var(--text-md)] transition-[background,border-color] duration-[var(--dur-fast)] ease-[var(--ease-out)] hover:border-[var(--color-border-strong)] hover:bg-[rgba(255,255,255,0.06)]"
|
||||
className="flex w-full cursor-pointer items-center gap-[var(--space-3)] rounded-[var(--radius-md)] border border-border bg-[rgba(255,255,255,0.02)] px-[var(--space-4)] py-[var(--space-3)] text-fg [font-size:var(--text-md)] transition-[background,border-color] duration-[var(--dur-fast)] ease-[var(--ease-out)] hover:border-transparent hover:bg-[rgba(255,255,255,0.06)]"
|
||||
>
|
||||
<FolderOpen size={11} />
|
||||
<span className="flex-1 text-left">{p.project_name}</span>
|
||||
|
||||
@@ -90,7 +90,7 @@ export default function ProfileDetails({
|
||||
)}
|
||||
</Field>
|
||||
{profile.is_locked && !editing && (
|
||||
<div className="mt-[var(--space-4)] flex flex-wrap items-center gap-[var(--space-4)] rounded-[var(--radius-md)] border border-[rgba(250,189,47,0.25)] bg-[rgba(250,189,47,0.06)] px-[var(--space-4)] py-[var(--space-3)]">
|
||||
<div className="mt-[var(--space-4)] flex flex-wrap items-center gap-[var(--space-4)] rounded-[var(--radius-md)] border border-transparent bg-[rgba(250,189,47,0.06)] px-[var(--space-4)] py-[var(--space-3)]">
|
||||
<Badge tone="warn" dot>
|
||||
<Lock size={10} /> {t('voice_profile.locked')}
|
||||
</Badge>
|
||||
@@ -115,7 +115,7 @@ export default function ProfileDetails({
|
||||
}
|
||||
>
|
||||
{profile.verified_own_voice ? (
|
||||
<div className="mt-[var(--space-4)] flex flex-wrap items-center gap-[var(--space-4)] rounded-[var(--radius-md)] border border-[rgba(250,189,47,0.25)] bg-[rgba(250,189,47,0.06)] px-[var(--space-4)] py-[var(--space-3)]">
|
||||
<div className="mt-[var(--space-4)] flex flex-wrap items-center gap-[var(--space-4)] rounded-[var(--radius-md)] border border-transparent bg-[rgba(250,189,47,0.06)] px-[var(--space-4)] py-[var(--space-3)]">
|
||||
<Badge tone="success" dot>
|
||||
<ShieldCheck size={10} /> {t('voice_profile.verified')}
|
||||
</Badge>
|
||||
|
||||
@@ -94,8 +94,8 @@ export default function ProfileHeader({
|
||||
<div
|
||||
className={`flex h-[54px] w-[54px] shrink-0 items-center justify-center rounded-[16px_20px_14px_22px/18px_14px_22px_16px] border ${
|
||||
isDesign
|
||||
? 'border-[rgba(142,192,124,0.35)] bg-[rgba(142,192,124,0.15)] text-success'
|
||||
: 'border-[rgba(211,134,155,0.35)] bg-[rgba(211,134,155,0.15)] text-brand'
|
||||
? 'border-transparent bg-[rgba(142,192,124,0.15)] text-success'
|
||||
: 'border-transparent bg-[rgba(211,134,155,0.15)] text-brand'
|
||||
}`}
|
||||
>
|
||||
<TypeIcon size={22} />
|
||||
|
||||
@@ -65,7 +65,7 @@ export default function AppearancePanel() {
|
||||
onChange={(e) => setUiScale(Number(e.target.value))}
|
||||
aria-label={scaleLabel}
|
||||
aria-valuetext={`${Math.round(uiScale * 100)}%`}
|
||||
className="min-w-0 flex-1 cursor-pointer accent-[var(--chrome-accent)]"
|
||||
className="min-w-0 flex-1 cursor-pointer accent-[var(--color-brand)]"
|
||||
/>
|
||||
<span className="min-w-[40px] text-right text-[length:var(--text-sm)] tabular-nums text-[var(--chrome-fg)]">
|
||||
{Math.round(uiScale * 100)}%
|
||||
|
||||
@@ -29,7 +29,7 @@ export default function SettingsSearch({ value, onChange, onClear }) {
|
||||
placeholder={t('settings.search_placeholder', { defaultValue: 'Search settings…' })}
|
||||
aria-label={t('settings.search_placeholder', { defaultValue: 'Search settings…' })}
|
||||
data-testid="settings-search"
|
||||
className="w-full min-w-0 box-border rounded-[var(--chrome-radius-pill)] border border-[var(--chrome-border)] bg-[color-mix(in_srgb,var(--chrome-bg)_94%,white)] py-[var(--space-2)] pl-[calc(var(--space-3)*2+13px)] pr-[calc(var(--space-3)*2+13px)] text-[color:var(--chrome-fg)] [font-family:var(--font-sans)] text-[length:var(--text-sm)] focus:border-[var(--chrome-accent)] focus:outline-none [&::-webkit-search-cancel-button]:appearance-none"
|
||||
className="w-full min-w-0 box-border rounded-[var(--chrome-radius-pill)] border border-transparent bg-[color-mix(in_srgb,var(--chrome-bg)_94%,white)] py-[var(--space-2)] pl-[calc(var(--space-3)*2+13px)] pr-[calc(var(--space-3)*2+13px)] text-[color:var(--chrome-fg)] [font-family:var(--font-sans)] text-[length:var(--text-sm)] focus:border-[var(--chrome-accent)] focus:outline-none [&::-webkit-search-cancel-button]:appearance-none"
|
||||
/>
|
||||
{value && (
|
||||
<button
|
||||
|
||||
@@ -35,7 +35,7 @@ export default function SettingsSidebar({ visibleIds, active, onSelect }) {
|
||||
onChange={(e) => onSelect(e.target.value)}
|
||||
aria-label={t('settings.title', { defaultValue: 'Settings' })}
|
||||
data-testid="settings-nav-select"
|
||||
className="w-full min-w-0 box-border rounded-[var(--chrome-radius-pill)] border border-[var(--chrome-border)] bg-[color-mix(in_srgb,var(--chrome-bg)_94%,white)] px-[var(--space-4)] py-[var(--space-3)] text-[color:var(--chrome-fg)] [font-family:var(--font-sans)] text-[length:var(--text-sm)] focus:border-[var(--chrome-accent)] focus:outline-none"
|
||||
className="w-full min-w-0 box-border rounded-[var(--chrome-radius-pill)] border border-transparent bg-[color-mix(in_srgb,var(--chrome-bg)_94%,white)] px-[var(--space-4)] py-[var(--space-3)] text-[color:var(--chrome-fg)] [font-family:var(--font-sans)] text-[length:var(--text-sm)] focus:border-[var(--chrome-accent)] focus:outline-none"
|
||||
>
|
||||
{GROUPS.map((g) => {
|
||||
const items = g.items.filter((it) => isVisible(it.id));
|
||||
|
||||
@@ -22,7 +22,7 @@ export default function Collapsible({ title, icon: Icon, defaultOpen = false, ba
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'border border-[var(--chrome-border)] rounded-[var(--chrome-radius-pill)] mt-[var(--space-4)] overflow-hidden',
|
||||
'border border-transparent rounded-[var(--chrome-radius-pill)] mt-[var(--space-4)] overflow-hidden',
|
||||
open && 'is-open',
|
||||
)}
|
||||
>
|
||||
@@ -50,7 +50,7 @@ export default function Collapsible({ title, icon: Icon, defaultOpen = false, ba
|
||||
)}
|
||||
</button>
|
||||
{open && (
|
||||
<div className="px-[var(--space-5)] pt-[var(--space-2)] pb-[var(--space-4)] border-t border-[var(--chrome-border)] [&>[data-slot=setting-row]:last-child]:pb-0">
|
||||
<div className="px-[var(--space-5)] pt-[var(--space-2)] pb-[var(--space-4)] border-t border-transparent [&>[data-slot=setting-row]:last-child]:pb-0">
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -45,7 +45,7 @@ export default function SettingRow({
|
||||
data-slot="setting-row"
|
||||
data-mono={mono ? '' : undefined}
|
||||
className={cn(
|
||||
'grid gap-y-[1px] py-[var(--space-4)] min-h-0 border-b border-[var(--chrome-border)] last:border-b-0 [font-family:var(--font-sans)]',
|
||||
'grid gap-y-[1px] py-[var(--space-4)] min-h-0 border-b border-transparent last:border-b-0 [font-family:var(--font-sans)]',
|
||||
align === 'start' ? 'items-start' : 'items-center',
|
||||
stack
|
||||
? 'grid-cols-[1fr] gap-[var(--space-3)]'
|
||||
|
||||
@@ -36,7 +36,7 @@ export default function SettingsInput({
|
||||
data-slot="settings-input"
|
||||
type={type}
|
||||
className={cn(
|
||||
'w-full min-w-0 max-w-[min(360px,100%)] box-border rounded-[var(--chrome-radius-pill)] border border-[var(--chrome-border)] bg-[color-mix(in_srgb,var(--chrome-bg)_94%,white)] px-[var(--space-4)] py-[var(--space-3)] text-[color:var(--chrome-fg)] [font-family:var(--font-sans)] text-[length:var(--text-sm)] focus:outline-none focus:border-[var(--chrome-accent)] disabled:opacity-50 disabled:cursor-not-allowed',
|
||||
'w-full min-w-0 max-w-[min(360px,100%)] box-border rounded-[var(--chrome-radius-pill)] border border-transparent bg-[color-mix(in_srgb,var(--chrome-bg)_94%,white)] px-[var(--space-4)] py-[var(--space-3)] text-[color:var(--chrome-fg)] [font-family:var(--font-sans)] text-[length:var(--text-sm)] focus:outline-none focus:border-[var(--chrome-accent)] disabled:opacity-50 disabled:cursor-not-allowed',
|
||||
mono && '[font-family:var(--chrome-font-mono)]',
|
||||
className,
|
||||
)}
|
||||
|
||||
@@ -25,7 +25,7 @@ import React from 'react';
|
||||
// to the primitive without re-deriving the token string. `data-slot` is the
|
||||
// stable hook Settings.css / panel CSS reach into.
|
||||
export const SETTINGS_SECTION_SURFACE =
|
||||
'bg-[var(--chrome-bg)] border border-[var(--chrome-border)] rounded-[var(--chrome-radius-pill)] px-[var(--space-6)] py-[var(--space-5)] mb-[var(--space-5)] last:mb-0';
|
||||
'bg-[var(--chrome-bg)] border border-transparent rounded-[var(--chrome-radius-pill)] px-[var(--space-6)] py-[var(--space-5)] mb-[var(--space-5)] last:mb-0';
|
||||
|
||||
export default function SettingsSection({
|
||||
icon: Icon,
|
||||
@@ -41,10 +41,10 @@ export default function SettingsSection({
|
||||
data-slot="settings-section"
|
||||
className={`${SETTINGS_SECTION_SURFACE} ${className}`.trim()}
|
||||
>
|
||||
<header className="flex items-center gap-[var(--space-3)] mb-[var(--space-3)] pb-[var(--space-3)] border-b border-[var(--chrome-border)]">
|
||||
<header className="flex items-center gap-[var(--space-3)] mb-[var(--space-3)] pb-[var(--space-3)] border-b border-transparent">
|
||||
{Icon && (
|
||||
<span
|
||||
className="shrink-0 inline-flex items-center justify-center w-[20px] h-[20px] rounded-[var(--chrome-radius-pill)] text-[color:var(--chrome-fg-muted)] bg-[color-mix(in_srgb,currentColor_12%,var(--chrome-bg))] border border-[color-mix(in_srgb,currentColor_26%,var(--chrome-border))]"
|
||||
className="shrink-0 inline-flex items-center justify-center w-[20px] h-[20px] rounded-[var(--chrome-radius-pill)] text-[color:var(--chrome-fg-muted)] bg-[color-mix(in_srgb,currentColor_12%,var(--chrome-bg))] border border-transparent"
|
||||
style={accent ? { color: accent } : undefined}
|
||||
aria-hidden="true"
|
||||
>
|
||||
|
||||
@@ -46,10 +46,10 @@ export default function SettingsToggle({
|
||||
{...rest}
|
||||
/>
|
||||
<span
|
||||
className="absolute inset-0 rounded-[999px] bg-[var(--chrome-hover-bg)] transition-[background] duration-[160ms] ease-in-out [.is-on_&]:bg-[var(--chrome-accent)] peer-focus-visible:outline peer-focus-visible:outline-2 peer-focus-visible:outline-[var(--chrome-accent)] peer-focus-visible:outline-offset-2"
|
||||
className="absolute inset-0 rounded-[var(--radius-pill)] bg-[var(--chrome-hover-bg)] transition-[background] duration-[160ms] ease-in-out [.is-on_&]:bg-[var(--color-brand)] peer-focus-visible:outline peer-focus-visible:outline-2 peer-focus-visible:outline-[var(--color-ring)] peer-focus-visible:outline-offset-2"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span className="absolute top-[3px] left-[3px] w-[18px] h-[18px] rounded-full bg-[var(--chrome-bg)] shadow-[0_1px_2px_rgba(0,0,0,0.35)] transition-transform duration-[160ms] ease-in-out [.is-on_&]:translate-x-[18px]" />
|
||||
<span className="absolute top-[3px] left-[3px] w-[18px] h-[18px] rounded-full bg-[var(--chrome-bg)] shadow-[var(--shadow-sm)] transition-transform duration-[160ms] ease-in-out [.is-on_&]:translate-x-[18px]" />
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
|
||||
@@ -10,9 +10,9 @@ import { cn } from '@/lib/utils';
|
||||
* Beyond the stock shadcn variants (default / secondary / destructive /
|
||||
* outline) the CVA carries the OmniVoice *tones* (neutral / brand / success /
|
||||
* warn / danger / info / violet) that back the legacy `src/ui/Badge.jsx`
|
||||
* wrapper. Tones render as chrome chips — a mono uppercase pill with an
|
||||
* explicit border + tinted fill — using palette token utilities so each tone
|
||||
* recolors with every [data-theme].
|
||||
* wrapper. Tones render as chrome chips — a mono uppercase pill with a
|
||||
* tinted fill (borders removed app-wide) — using palette token utilities so
|
||||
* each tone recolors with every [data-theme].
|
||||
*/
|
||||
const badgeVariants = cva(
|
||||
'inline-flex items-center gap-[2px] rounded-[var(--chrome-radius-pill)] font-mono font-semibold tracking-[var(--chrome-label-track)] uppercase whitespace-nowrap select-none leading-[1.2] [&>svg]:size-3 [&>svg]:pointer-events-none',
|
||||
@@ -23,15 +23,15 @@ const badgeVariants = cva(
|
||||
default: 'border border-transparent bg-primary text-primary-foreground',
|
||||
secondary: 'border border-transparent bg-secondary text-secondary-foreground',
|
||||
destructive: 'border border-transparent bg-destructive text-destructive-foreground',
|
||||
outline: 'border border-border text-foreground',
|
||||
outline: 'border border-transparent bg-secondary text-foreground',
|
||||
// ── OmniVoice tones ──
|
||||
neutral: 'text-muted-foreground border border-white/15 bg-transparent',
|
||||
brand: 'text-primary border border-primary/35 bg-primary/[0.12]',
|
||||
success: 'text-success border border-success/45 bg-success/10',
|
||||
warn: 'text-accent border border-accent/45 bg-accent/10',
|
||||
danger: 'text-destructive border border-destructive/45 bg-destructive/10',
|
||||
info: 'text-info border border-info/45 bg-info/10',
|
||||
violet: 'text-muted-foreground border border-white/15 bg-transparent',
|
||||
neutral: 'text-muted-foreground border border-transparent bg-transparent',
|
||||
brand: 'text-primary border border-transparent bg-primary/[0.12]',
|
||||
success: 'text-success border border-transparent bg-success/10',
|
||||
warn: 'text-accent border border-transparent bg-accent/10',
|
||||
danger: 'text-destructive border border-transparent bg-destructive/10',
|
||||
info: 'text-info border border-transparent bg-info/10',
|
||||
violet: 'text-muted-foreground border border-transparent bg-transparent',
|
||||
},
|
||||
size: {
|
||||
xs: 'px-1.5 py-0 text-[11px]',
|
||||
|
||||
@@ -32,7 +32,7 @@ const buttonVariants = cva(
|
||||
destructive:
|
||||
'bg-destructive text-destructive-foreground shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20',
|
||||
outline:
|
||||
'border border-input bg-background shadow-xs hover:bg-accent hover:text-accent-foreground',
|
||||
'border border-transparent bg-background shadow-xs hover:bg-accent hover:text-accent-foreground',
|
||||
secondary: 'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80',
|
||||
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
||||
link: 'text-primary underline-offset-4 hover:underline',
|
||||
@@ -44,20 +44,20 @@ const buttonVariants = cva(
|
||||
primary:
|
||||
'border border-transparent bg-primary text-primary-foreground font-semibold shadow-xs hover:bg-primary/90 active:scale-[0.98]',
|
||||
subtle:
|
||||
'border border-border bg-transparent text-muted-foreground hover:bg-white/[0.04] hover:text-foreground hover:border-white/15',
|
||||
'border border-transparent bg-transparent text-muted-foreground hover:bg-[var(--chrome-hover-bg)] hover:text-foreground hover:border-transparent',
|
||||
softGhost:
|
||||
'border border-transparent bg-transparent text-muted-foreground hover:bg-white/[0.04] hover:text-foreground',
|
||||
'border border-transparent bg-transparent text-muted-foreground hover:bg-[var(--chrome-hover-bg)] hover:text-foreground',
|
||||
danger:
|
||||
'text-destructive bg-destructive/10 border border-destructive/45 hover:bg-destructive/20 hover:border-destructive',
|
||||
chip: 'border border-border bg-transparent text-muted-foreground hover:bg-white/[0.04] hover:text-foreground hover:border-white/15',
|
||||
chipActive: 'text-success bg-success/10 border border-success/45',
|
||||
'text-destructive bg-destructive/10 border border-transparent hover:bg-destructive/20 hover:border-transparent',
|
||||
chip: 'border border-transparent bg-transparent text-muted-foreground hover:bg-[var(--chrome-hover-bg)] hover:text-foreground hover:border-transparent',
|
||||
chipActive: 'text-success bg-success/10 border border-transparent',
|
||||
preset:
|
||||
'justify-start text-left border border-border bg-transparent text-muted-foreground hover:bg-white/[0.04] hover:text-foreground hover:border-white/15',
|
||||
'justify-start text-left border border-transparent bg-transparent text-muted-foreground hover:bg-[var(--chrome-hover-bg)] hover:text-foreground hover:border-transparent',
|
||||
presetActive:
|
||||
'justify-start text-left text-primary bg-primary/[0.12] border border-primary/30',
|
||||
'justify-start text-left text-primary bg-primary/[0.12] border border-transparent',
|
||||
iconBtn:
|
||||
'border border-border bg-transparent text-muted-foreground hover:bg-white/[0.04] hover:text-foreground hover:border-white/15',
|
||||
iconBtnActive: 'text-primary bg-primary/[0.12] border border-primary/30',
|
||||
'border border-transparent bg-transparent text-muted-foreground hover:bg-[var(--chrome-hover-bg)] hover:text-foreground hover:border-transparent',
|
||||
iconBtnActive: 'text-primary bg-primary/[0.12] border border-transparent',
|
||||
},
|
||||
size: {
|
||||
// ── stock shadcn ──
|
||||
|
||||
@@ -25,7 +25,7 @@ function Card({
|
||||
<Comp
|
||||
data-slot="card"
|
||||
className={cn(
|
||||
'bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm',
|
||||
'bg-card text-card-foreground flex flex-col gap-6 rounded-xl border border-transparent py-6 shadow-sm',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -60,7 +60,7 @@ function DialogContent({
|
||||
<DialogPrimitive.Content
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200',
|
||||
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border border-transparent p-6 shadow-lg duration-200',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -39,7 +39,7 @@ function DropdownMenuContent({
|
||||
data-slot="dropdown-menu-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md',
|
||||
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border border-transparent p-1 shadow-md',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -65,7 +65,7 @@ function SelectContent({
|
||||
<SelectPrimitive.Content
|
||||
data-slot="select-content"
|
||||
className={cn(
|
||||
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md',
|
||||
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border border-transparent shadow-md',
|
||||
position === 'popper' &&
|
||||
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
|
||||
className,
|
||||
|
||||
@@ -23,7 +23,7 @@ const toggleVariants = cva(
|
||||
outline:
|
||||
'border border-input bg-transparent shadow-xs hover:bg-accent hover:text-accent-foreground',
|
||||
// ── OmniVoice segmented option ──
|
||||
seg: 'font-extrabold border-0 cursor-pointer rounded-[var(--radius-pill)] bg-transparent text-fg-subtle transition-[background,color] duration-[var(--dur-fast)] ease-[var(--ease-out)] data-[state=off]:hover:text-fg data-[state=off]:hover:bg-white/[0.04] data-[state=on]:bg-primary/25 data-[state=on]:text-[#fff9ef]',
|
||||
seg: 'font-extrabold border-0 cursor-pointer rounded-[var(--radius-pill)] bg-transparent text-fg-subtle transition-[background,color] duration-[var(--dur-fast)] ease-[var(--ease-out)] data-[state=off]:hover:text-fg data-[state=off]:hover:bg-[var(--chrome-hover-bg)] data-[state=on]:bg-primary/25 data-[state=on]:text-fg',
|
||||
},
|
||||
size: {
|
||||
default: 'h-9 px-2 min-w-9',
|
||||
|
||||
+159
-57
@@ -288,6 +288,13 @@
|
||||
/ Inter are no longer referenced by any rule. */
|
||||
|
||||
:root {
|
||||
/* Default theme is Gruvbox Dark (--bg #1d2021 / --chrome-bg #0f1011).
|
||||
Declaring `color-scheme: dark` (NOT the same as the prefers-color-scheme
|
||||
media query below) makes UA-rendered form chrome — native <select> option
|
||||
popups, scrollbars, autofill, spin-buttons — render in the dark scheme so
|
||||
they match the app instead of defaulting to the OS light scheme. Every
|
||||
[data-theme] block re-asserts the scheme matching its own bg lightness. */
|
||||
color-scheme: dark;
|
||||
--primary: #d3869b;
|
||||
--primary-hover: #b16286;
|
||||
--accent: #fabd2f;
|
||||
@@ -314,14 +321,27 @@
|
||||
--chrome-fg: #d5c4a1;
|
||||
--chrome-fg-muted: #a89984;
|
||||
--chrome-fg-dim: #7c6f64;
|
||||
--chrome-accent: #f3a5b6;
|
||||
--chrome-accent-bg: rgba(243, 165, 182, 0.12);
|
||||
--chrome-accent-border: rgba(243, 165, 182, 0.35);
|
||||
/* Accent family aliases the THEMED brand token (--color-brand, re-declared per
|
||||
[data-theme]) so every accent surface — donate/support/commercial CTAs,
|
||||
active tabs, .btn-primary, status pills, GoalBar/Pip — tracks the active
|
||||
theme instead of the old fixed pink. (--chrome-accent-border stays zeroed by
|
||||
the app-wide border-removal block below; the visible accent cue is the fg
|
||||
color + the -bg tint.) */
|
||||
--chrome-accent: var(--color-brand);
|
||||
--chrome-accent-bg: color-mix(in srgb, var(--color-brand) 12%, transparent);
|
||||
--chrome-accent-border: color-mix(in srgb, var(--color-brand) 35%, transparent);
|
||||
--chrome-severity-err: #fb4934;
|
||||
--chrome-severity-warn: #fabd2f;
|
||||
--chrome-severity-ok: #8ec07c;
|
||||
--chrome-radius-pill: 3px;
|
||||
--chrome-hover-bg: rgba(255, 255, 255, 0.04);
|
||||
/* Native <select> caret. One token consumed by both select.input-base and
|
||||
.ui-select so the chevron color lives in a single place and is overridden
|
||||
per [data-theme] to track that theme's muted foreground (was a hardcoded
|
||||
gray %23a1a1aa that ignored theme + accent). Stroke color = --chrome-fg-muted
|
||||
baked into the data-URI (a background-image SVG can't read a CSS var, so the
|
||||
color is inlined; each theme block re-declares this with its own fg-muted). */
|
||||
--select-caret: url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2210%22%20height%3D%226%22%3E%3Cpath%20d%3D%22M1%201l4%204%204-4%22%20fill%3D%22none%22%20stroke%3D%22%23a89984%22%20stroke-width%3D%221.5%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%2F%3E%3C%2Fsvg%3E");
|
||||
/* Chrome-label face is IBM Plex Mono now — defined in the @theme block as
|
||||
--font-mono. Keep this alias so existing `var(--chrome-font-mono)` rules
|
||||
don't need to change; it just picks up the new face automatically. */
|
||||
@@ -387,6 +407,10 @@
|
||||
--chrome-fg-muted: #94a3b8;
|
||||
--chrome-fg-dim: #475569;
|
||||
--chrome-border: #334155;
|
||||
|
||||
/* Midnight Blue — bg #0f172a is dark. */
|
||||
color-scheme: dark;
|
||||
--select-caret: url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2210%22%20height%3D%226%22%3E%3Cpath%20d%3D%22M1%201l4%204%204-4%22%20fill%3D%22none%22%20stroke%3D%22%2394a3b8%22%20stroke-width%3D%221.5%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%2F%3E%3C%2Fsvg%3E");
|
||||
}
|
||||
|
||||
/* ── Nord ──────────────────────────────────────────────────────────── */
|
||||
@@ -420,6 +444,10 @@
|
||||
--chrome-fg-muted: #d8dee9;
|
||||
--chrome-fg-dim: #4c566a;
|
||||
--chrome-border: #434c5e;
|
||||
|
||||
/* Nord — bg #2e3440 is dark. */
|
||||
color-scheme: dark;
|
||||
--select-caret: url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2210%22%20height%3D%226%22%3E%3Cpath%20d%3D%22M1%201l4%204%204-4%22%20fill%3D%22none%22%20stroke%3D%22%23d8dee9%22%20stroke-width%3D%221.5%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%2F%3E%3C%2Fsvg%3E");
|
||||
}
|
||||
|
||||
/* ── Solarized Dark ────────────────────────────────────────────────── */
|
||||
@@ -455,6 +483,10 @@
|
||||
--chrome-fg-muted: #899da4;
|
||||
--chrome-fg-dim: #586e75;
|
||||
--chrome-border: #073642;
|
||||
|
||||
/* Solarized Dark — bg #002b36 is dark. */
|
||||
color-scheme: dark;
|
||||
--select-caret: url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2210%22%20height%3D%226%22%3E%3Cpath%20d%3D%22M1%201l4%204%204-4%22%20fill%3D%22none%22%20stroke%3D%22%23899da4%22%20stroke-width%3D%221.5%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%2F%3E%3C%2Fsvg%3E");
|
||||
}
|
||||
|
||||
/* ── Rose Pine ─────────────────────────────────────────────────────── */
|
||||
@@ -488,6 +520,10 @@
|
||||
--chrome-fg-muted: #908caa;
|
||||
--chrome-fg-dim: #6e6a86;
|
||||
--chrome-border: #26233a;
|
||||
|
||||
/* Rose Pine — bg #191724 is dark. */
|
||||
color-scheme: dark;
|
||||
--select-caret: url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2210%22%20height%3D%226%22%3E%3Cpath%20d%3D%22M1%201l4%204%204-4%22%20fill%3D%22none%22%20stroke%3D%22%23908caa%22%20stroke-width%3D%221.5%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%2F%3E%3C%2Fsvg%3E");
|
||||
}
|
||||
|
||||
/* ── Catppuccin Mocha ──────────────────────────────────────────────── */
|
||||
@@ -521,6 +557,10 @@
|
||||
--chrome-fg-muted: #a6adc8;
|
||||
--chrome-fg-dim: #585b70;
|
||||
--chrome-border: #45475a;
|
||||
|
||||
/* Catppuccin Mocha — bg #1e1e2e is dark. */
|
||||
color-scheme: dark;
|
||||
--select-caret: url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2210%22%20height%3D%226%22%3E%3Cpath%20d%3D%22M1%201l4%204%204-4%22%20fill%3D%22none%22%20stroke%3D%22%23a6adc8%22%20stroke-width%3D%221.5%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%2F%3E%3C%2Fsvg%3E");
|
||||
}
|
||||
|
||||
/* ── System-preference sync ────────────────────────────────────────── */
|
||||
@@ -529,7 +569,12 @@
|
||||
community contributions. */
|
||||
@media (prefers-color-scheme: light) {
|
||||
[data-theme="auto"] {
|
||||
/* Future light theme tokens go here */
|
||||
/* Future light theme tokens go here. NOTE: no light theme ships yet, so
|
||||
"auto" still renders the dark default tokens even on a light-mode OS —
|
||||
which is why `color-scheme` is intentionally left at the :root `dark`
|
||||
here (a `light` value would make native form chrome mismatch the still
|
||||
-dark surface). When a real light theme lands, set `color-scheme: light`
|
||||
and its own --select-caret in this block. */
|
||||
}
|
||||
}
|
||||
|
||||
@@ -774,12 +819,9 @@ html[data-zoom-layout='off'] .app-container {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.glass-panel::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0; left: 0; right: 0;
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg, rgba(255,255,255,0), rgba(255,255,255,0.08) 30%, rgba(255,255,255,0.12) 50%, rgba(255,255,255,0.08) 70%, rgba(255,255,255,0));
|
||||
pointer-events: none;
|
||||
/* Decorative top-highlight hairline removed as part of the app-wide
|
||||
border/divider removal. */
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* ═══ HEADER — warm + cute ═══ */
|
||||
@@ -876,8 +918,10 @@ html[data-zoom-layout='off'] .app-container {
|
||||
/* ═══ INPUTS ═══ */
|
||||
.input-base {
|
||||
width: 100%;
|
||||
background: var(--chrome-hover-bg);
|
||||
border: 1px solid var(--chrome-border);
|
||||
/* Borders are removed app-wide; a recessed surface fill keeps text
|
||||
fields / selects / textareas perceivable as inputs. */
|
||||
background: var(--color-bg-elev-2);
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--chrome-radius-pill);
|
||||
padding: 5px 8px;
|
||||
color: var(--chrome-fg);
|
||||
@@ -888,51 +932,65 @@ html[data-zoom-layout='off'] .app-container {
|
||||
}
|
||||
.input-base:focus {
|
||||
outline: none;
|
||||
border-color: var(--chrome-border-strong);
|
||||
border-color: transparent;
|
||||
box-shadow: none;
|
||||
background: var(--chrome-bg);
|
||||
/* Subtle elevation shift on focus (keyboard focus also gets the global
|
||||
:focus-visible ring). */
|
||||
background: var(--color-bg-elev-1);
|
||||
}
|
||||
.input-base::placeholder { color: var(--chrome-fg-dim); }
|
||||
textarea.input-base { min-height: 60px; resize: vertical; line-height: 1.5; }
|
||||
|
||||
select.input-base {
|
||||
appearance: none;
|
||||
background-image: url("data:image/svg+xml;charset=US-ASCII,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%22292.4%22%20height%3D%22292.4%22%3E%3Cpath%20fill%3D%22%23a1a1aa%22%20d%3D%22M287%2069.4a17.6%2017.6%200%200%200-13-5.4H18.4c-5%200-9.3%201.8-12.9%205.4A17.6%2017.6%200%200%200%200%2082.2c0%205%201.8%209.3%205.4%2012.9l128%20127.9c3.6%203.6%207.8%205.4%2012.8%205.4s9.2-1.8%2012.8-5.4L287%2095c3.5-3.5%205.4-7.8%205.4-12.8%200-5-1.9-9.2-5.5-12.8z%22%2F%3E%3C%2Fsvg%3E");
|
||||
/* Caret color tracks the theme via --select-caret (see :root / [data-theme]). */
|
||||
background-image: var(--select-caret);
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 6px top 50%;
|
||||
background-size: 0.4rem auto;
|
||||
padding-right: 18px;
|
||||
background-position: right 7px top 50%;
|
||||
background-size: 0.5rem auto;
|
||||
padding-right: 20px;
|
||||
cursor: pointer;
|
||||
}
|
||||
select.input-base:hover {
|
||||
border-color: rgba(255,255,255,0.12);
|
||||
border-color: transparent;
|
||||
}
|
||||
/* Native option popup: paint from theme tokens so engines that honor <option>
|
||||
styling (Chromium on Windows/Linux) match the app; on macOS WebKit the popup
|
||||
is UA-drawn and instead follows `color-scheme` (set per theme above). */
|
||||
select.input-base option,
|
||||
select.input-base optgroup {
|
||||
background-color: var(--color-bg-elev-1);
|
||||
color: var(--chrome-fg);
|
||||
}
|
||||
|
||||
/* ═══ CHECKBOX STYLING ═══ */
|
||||
/* Checked fill uses the themed brand token so checkboxes recolor with every
|
||||
[data-theme] and match the primary/active affordances (sliders, segmented,
|
||||
toggles) — not the non-themed chrome-accent. */
|
||||
input[type="checkbox"] {
|
||||
accent-color: var(--chrome-accent);
|
||||
accent-color: var(--color-brand);
|
||||
width: 13px; height: 13px;
|
||||
border-radius: 3px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
/* ═══ RANGE INPUTS ═══ */
|
||||
input[type="range"] {
|
||||
-webkit-appearance: none; width: 100%; height: 3px;
|
||||
background: rgba(255,255,255,0.08); border-radius: 2px; outline: none; margin-top: 3px;
|
||||
background: var(--color-bg-elev-2); border-radius: var(--radius-xs); outline: none; margin-top: 3px;
|
||||
cursor: pointer;
|
||||
}
|
||||
input[type="range"]::-webkit-slider-thumb {
|
||||
-webkit-appearance: none; width: 10px; height: 10px; border-radius: 50%;
|
||||
background: var(--text-primary); cursor: pointer;
|
||||
box-shadow: 0 0 4px rgba(0,0,0,0.4);
|
||||
transition: transform var(--transition-fast);
|
||||
background: var(--color-fg); cursor: pointer;
|
||||
box-shadow: var(--shadow-sm);
|
||||
transition: transform var(--dur-fast);
|
||||
}
|
||||
input[type="range"]::-webkit-slider-thumb:hover {
|
||||
transform: scale(1.3);
|
||||
}
|
||||
input[type="range"]::-webkit-slider-thumb:active {
|
||||
transform: scale(1.1);
|
||||
background: var(--primary);
|
||||
background: var(--color-brand);
|
||||
}
|
||||
/* .val-bubble → Tailwind utilities inline in clone/ActionBar.jsx (P4). */
|
||||
|
||||
@@ -987,23 +1045,25 @@ input[type="range"]::-webkit-slider-thumb:active {
|
||||
max-height: none;
|
||||
overflow-y: auto;
|
||||
box-sizing: border-box;
|
||||
border-right: 1px solid rgba(255,255,255,0.04);
|
||||
border-right: 1px solid transparent;
|
||||
}
|
||||
.history-item {
|
||||
background: rgba(0,0,0,0.18); border: 1px solid rgba(255,255,255,0.04);
|
||||
background: rgba(0,0,0,0.18); border: 1px solid transparent;
|
||||
border-radius: 5px; padding: 6px 8px; margin-bottom: 4px;
|
||||
transition: all var(--transition-smooth);
|
||||
animation: fadeIn 0.2s ease-out;
|
||||
}
|
||||
.history-item:hover {
|
||||
background: rgba(0,0,0,0.28);
|
||||
border-color: rgba(255,255,255,0.08);
|
||||
border-color: transparent;
|
||||
}
|
||||
/* .history-header / .history-badge / .history-time / .history-text migrated to Badge + Sidebar.css. */
|
||||
.project-active {
|
||||
border-color: rgba(184,187,38,0.4) !important;
|
||||
background: rgba(184,187,38,0.06) !important;
|
||||
box-shadow: inset 0 0 0 1px rgba(184,187,38,0.12), 0 0 8px rgba(184,187,38,0.06);
|
||||
/* Selection cue via background tint (not a border) so the active project
|
||||
stays perceivable after the app-wide border removal. */
|
||||
border-color: transparent !important;
|
||||
background: rgba(184,187,38,0.14) !important;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/* ═══ AUDIO PLAYER ═══ */
|
||||
@@ -1012,7 +1072,7 @@ audio {
|
||||
}
|
||||
audio::-webkit-media-controls-enclosure {
|
||||
background-color: rgba(40,38,37,0.95);
|
||||
border: 1px solid rgba(255,255,255,0.04);
|
||||
border: 1px solid transparent;
|
||||
border-radius: 4px;
|
||||
}
|
||||
audio::-webkit-media-controls-play-button,
|
||||
@@ -1027,12 +1087,12 @@ audio::-webkit-media-controls-time-remaining-display { color: var(--chrome-fg);
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
background: rgba(0,0,0,0.18); padding: 4px 8px; border-radius: 4px;
|
||||
font-size: 0.72rem; color: var(--text-primary); cursor: pointer;
|
||||
border: 1px solid rgba(255,255,255,0.04); margin-top: 6px;
|
||||
border: 1px solid transparent; margin-top: 6px;
|
||||
transition: all var(--transition-smooth);
|
||||
}
|
||||
.override-toggle:hover { background: rgba(0,0,0,0.3); border-color: rgba(255,255,255,0.08); }
|
||||
.override-toggle:hover { background: rgba(0,0,0,0.3); border-color: transparent; }
|
||||
.override-content {
|
||||
background: rgba(0,0,0,0.1); border: 1px solid rgba(255,255,255,0.04); border-top: none;
|
||||
background: rgba(0,0,0,0.1); border: 1px solid transparent; border-top: none;
|
||||
padding: 6px 8px; border-radius: 0 0 4px 4px; margin-bottom: 6px;
|
||||
}
|
||||
.preset-grid {
|
||||
@@ -1045,14 +1105,14 @@ audio::-webkit-media-controls-time-remaining-display { color: var(--chrome-fg);
|
||||
.segment-table {
|
||||
/* No max-height cap — the list is virtualised via react-window and measures
|
||||
its own body container. Capping at 320 px left a huge gap below. */
|
||||
border: 1px solid rgba(255,255,255,0.04); border-radius: 4px; margin-top: 4px;
|
||||
border: 1px solid transparent; border-radius: 4px; margin-top: 4px;
|
||||
}
|
||||
.segment-header {
|
||||
display: flex; align-items: center; gap: 4px;
|
||||
padding: 3px 6px; background: rgba(0,0,0,0.35);
|
||||
font-size: 0.62rem; font-weight: 600; color: var(--text-secondary);
|
||||
position: sticky; top: 0; z-index: 1;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.06);
|
||||
border-bottom: 1px solid transparent;
|
||||
letter-spacing: 0.03em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
@@ -1060,7 +1120,7 @@ audio::-webkit-media-controls-time-remaining-display { color: var(--chrome-fg);
|
||||
display: flex; align-items: center; gap: 3px;
|
||||
padding: 2px 4px;
|
||||
box-sizing: border-box;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.02);
|
||||
border-bottom: 1px solid transparent;
|
||||
transition: background var(--transition-fast);
|
||||
}
|
||||
.segment-row:hover { background: var(--chrome-hover-bg); }
|
||||
@@ -1122,13 +1182,13 @@ audio::-webkit-media-controls-time-remaining-display { color: var(--chrome-fg);
|
||||
waveform-* family migrated to utilities on WaveformTimeline.jsx (P4). */
|
||||
.waveform-container {
|
||||
background: rgba(0,0,0,0.22);
|
||||
border: 1px solid rgba(255,255,255,0.05);
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius);
|
||||
padding: 4px;
|
||||
overflow-x: auto;
|
||||
height: 80px !important;
|
||||
position: relative;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.04);
|
||||
border-bottom: 1px solid transparent;
|
||||
}
|
||||
|
||||
.waveform-container [data-id^="wavesurfer-region"] {
|
||||
@@ -1140,7 +1200,7 @@ audio::-webkit-media-controls-time-remaining-display { color: var(--chrome-fg);
|
||||
top: 52px !important;
|
||||
height: 26px !important;
|
||||
border-radius: 3px !important;
|
||||
border: 1px solid rgba(255,255,255,0.15) !important;
|
||||
border: 1px solid transparent !important;
|
||||
display: flex !important; align-items: center !important;
|
||||
padding: 0 3px !important; color: white !important;
|
||||
font-size: 0.58rem !important;
|
||||
@@ -1177,6 +1237,16 @@ button:focus:not(:focus-visible),
|
||||
a:focus:not(:focus-visible),
|
||||
input:focus:not(:focus-visible),
|
||||
select:focus:not(:focus-visible) { outline: none; box-shadow: none; }
|
||||
/* Selects take the themed accent ring (--color-ring → --color-brand), matching
|
||||
the buttons/checkboxes tokenized last phase, instead of the non-theme
|
||||
-tracking --chrome-accent the base :focus-visible rule uses. */
|
||||
select:focus-visible,
|
||||
select.input-base:focus-visible,
|
||||
.ui-select:focus-visible {
|
||||
outline: 2px solid color-mix(in srgb, var(--color-ring) 70%, transparent);
|
||||
outline-offset: 2px;
|
||||
box-shadow: 0 0 0 4px color-mix(in srgb, var(--color-ring) 16%, transparent);
|
||||
}
|
||||
|
||||
/* 10x P4 (spec §3 a11y gate): one consistent, fully-opaque ring on the
|
||||
studio's 10x controls — shared rule, not per-component restyles. */
|
||||
@@ -1525,11 +1595,13 @@ select:focus:not(:focus-visible) { outline: none; box-shadow: none; }
|
||||
|
||||
/* Textarea — flat chrome surface for design/clone prompt box */
|
||||
textarea.input-base {
|
||||
background: var(--chrome-bg);
|
||||
/* Recessed fill (not --chrome-bg, which equals the panel background and
|
||||
would be invisible once the border is gone). */
|
||||
background: var(--color-bg-elev-2);
|
||||
font-family: var(--font-sans);
|
||||
font-size: 0.82rem; line-height: 1.55;
|
||||
color: var(--chrome-fg);
|
||||
border: 1px solid var(--chrome-border);
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--chrome-radius-pill);
|
||||
padding: 12px 14px;
|
||||
}
|
||||
@@ -2047,15 +2119,22 @@ input[type="file"]::file-selector-button:hover {
|
||||
.ui-select {
|
||||
appearance: none;
|
||||
padding-right: 20px;
|
||||
background-image: url("data:image/svg+xml;charset=US-ASCII,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%22292.4%22%20height%3D%22292.4%22%3E%3Cpath%20fill%3D%22%23a1a1aa%22%20d%3D%22M287%2069.4a17.6%2017.6%200%200%200-13-5.4H18.4c-5%200-9.3%201.8-12.9%205.4A17.6%2017.6%200%200%200%200%2082.2c0%205%201.8%209.3%205.4%2012.9l128%20127.9c3.6%203.6%207.8%205.4%2012.8%205.4s9.2-1.8%2012.8-5.4L287%2095c3.5-3.5%205.4-7.8%205.4-12.8%200-5-1.9-9.2-5.5-12.8z%22%2F%3E%3C%2Fsvg%3E");
|
||||
/* Caret color tracks the theme via --select-caret (see :root / [data-theme]). */
|
||||
background-image: var(--select-caret);
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 7px center;
|
||||
background-size: 0.42rem auto;
|
||||
background-size: 0.5rem auto;
|
||||
cursor: pointer;
|
||||
}
|
||||
.ui-select:hover {
|
||||
border-color: var(--color-border-strong);
|
||||
}
|
||||
/* Native option popup: theme-token colors (see select.input-base option note). */
|
||||
.ui-select option,
|
||||
.ui-select optgroup {
|
||||
background-color: var(--color-bg-elev-1);
|
||||
color: var(--chrome-fg);
|
||||
}
|
||||
|
||||
/* from Panel (ui/Panel.css) — glass surface only */
|
||||
/* Layout, border, radius, padding, header/title/actions, and the solid/flat
|
||||
@@ -2259,7 +2338,7 @@ input[type="file"]::file-selector-button:hover {
|
||||
}
|
||||
.voice-selector__btn:hover:not(:disabled) {
|
||||
color: var(--chrome-fg, #eee);
|
||||
border-color: var(--chrome-border-strong, rgba(255, 255, 255, 0.22));
|
||||
border-color: var(--chrome-border-strong, transparent);
|
||||
}
|
||||
.voice-selector__btn:disabled {
|
||||
opacity: 0.5;
|
||||
@@ -2596,7 +2675,7 @@ input[type="file"]::file-selector-button:hover {
|
||||
.history-item--dub { --row-accent: #83a598; }
|
||||
|
||||
/* ── History-item kind badge variants ────────────────────────── */
|
||||
.history-kind--audio { color: #83a598; border-color: rgba(131, 165, 152, 0.25); }
|
||||
.history-kind--audio { color: #83a598; background: rgba(131, 165, 152, 0.13); }
|
||||
|
||||
/* Locked project label + italic subtitle */
|
||||
.history-meta--locked { color: #b8bb26; font-style: italic; }
|
||||
@@ -2733,7 +2812,7 @@ input[type="file"]::file-selector-button:hover {
|
||||
background: rgba(18, 18, 22, 0.88);
|
||||
backdrop-filter: blur(24px) saturate(180%);
|
||||
-webkit-backdrop-filter: blur(24px) saturate(180%);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border: 1px solid transparent;
|
||||
border-radius: 100px;
|
||||
box-shadow:
|
||||
0 8px 32px rgba(0, 0, 0, 0.4),
|
||||
@@ -3382,7 +3461,7 @@ body:has(.capture-pill) {
|
||||
height: 22px;
|
||||
flex: 0 0 auto;
|
||||
border-radius: 50%;
|
||||
border: 1.5px solid var(--chrome-border-strong, rgba(255, 255, 255, 0.15));
|
||||
border: 1.5px solid var(--chrome-border-strong, transparent);
|
||||
color: var(--chrome-fg-dim, #665c54);
|
||||
}
|
||||
.dub-stepper__step.is-done .dub-stepper__icon {
|
||||
@@ -3410,9 +3489,19 @@ body:has(.capture-pill) {
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
/* Tighter footprint when inlined on the header row so the stages, title, and
|
||||
actions all fit before wrapping. */
|
||||
.dub-stepper--inline .dub-stepper__step {
|
||||
gap: 5px;
|
||||
font-size: 0.66rem;
|
||||
}
|
||||
.dub-stepper--inline .dub-stepper__icon {
|
||||
width: 19px;
|
||||
height: 19px;
|
||||
}
|
||||
.dub-stepper--inline .dub-stepper__step:not(:first-child)::before {
|
||||
width: 14px;
|
||||
margin: 0 5px;
|
||||
width: 10px;
|
||||
margin: 0 4px;
|
||||
}
|
||||
|
||||
/* ── Idle / drop-zone ─────────────────────────────────────────────────── */
|
||||
@@ -3424,7 +3513,7 @@ body:has(.capture-pill) {
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
cursor: pointer;
|
||||
border: 2px dashed rgba(255, 255, 255, 0.06);
|
||||
border: 2px dashed transparent;
|
||||
border-radius: 8px;
|
||||
transition: background 0.3s, border-color 0.3s;
|
||||
margin: 2px;
|
||||
@@ -3455,7 +3544,7 @@ body:has(.capture-pill) {
|
||||
border-radius: 4px;
|
||||
font-size: 0.7rem;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
border: 1px solid transparent;
|
||||
color: #665c54;
|
||||
cursor: default;
|
||||
}
|
||||
@@ -3516,7 +3605,7 @@ body:has(.capture-pill) {
|
||||
padding: 1px 6px;
|
||||
border-radius: var(--radius-xs);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid transparent;
|
||||
color: var(--color-fg-subtle);
|
||||
}
|
||||
.dub-prep-chip.is-active {
|
||||
@@ -3649,7 +3738,7 @@ body:has(.capture-pill) {
|
||||
aspect-ratio: 16 / 9;
|
||||
max-height: 60vh;
|
||||
background: #000; border-radius: 4px; overflow: hidden;
|
||||
border: 1px solid rgba(255,255,255,0.05); display: flex;
|
||||
border: 1px solid transparent; display: flex;
|
||||
}
|
||||
.wfm-wave-wrap {
|
||||
overflow: hidden; flex: 1 1 auto; min-height: 80px; max-height: 160px;
|
||||
@@ -3680,7 +3769,7 @@ body:has(.capture-pill) {
|
||||
.wfm-error {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
padding: 8px; background: rgba(0,0,0,0.15); border-radius: 4px;
|
||||
border: 1px solid rgba(255,255,255,0.04); color: #a89984; font-size: 0.7rem;
|
||||
border: 1px solid transparent; color: #a89984; font-size: 0.7rem;
|
||||
}
|
||||
|
||||
/* NOTE: the ErrorBoundary (`errbnd-*`) styles that used to live here were
|
||||
@@ -4901,3 +4990,16 @@ body:has(.capture-pill) {
|
||||
pointer-events: none;
|
||||
max-width: 240px;
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════════════
|
||||
APP-WIDE BORDER / DIVIDER REMOVAL (owner-approved)
|
||||
Zero every decorative border/hairline token. Kept LAST in source order so
|
||||
it wins over the default `:root` and every `[data-theme]` override at equal
|
||||
specificity. Focus indicators (--color-ring / --focus-ring) are deliberately
|
||||
NOT zeroed — keyboard a11y focus rings stay.
|
||||
═══════════════════════════════════════════════════════════════════════ */
|
||||
:root, [data-theme] {
|
||||
--color-border: transparent; --color-border-strong: transparent; --color-border-warm: transparent;
|
||||
--chrome-border: transparent; --chrome-border-strong: transparent; --chrome-accent-border: transparent;
|
||||
--glass-border: transparent;
|
||||
}
|
||||
|
||||
@@ -37,8 +37,8 @@ const STATUS_TONE = {
|
||||
|
||||
// Per-status card border accent (was .batch-queue__card--{status} in CSS).
|
||||
const CARD_BORDER = {
|
||||
running: 'border-[rgba(211,134,155,0.4)]',
|
||||
failed: 'border-[rgba(251,73,52,0.35)]',
|
||||
running: 'border-transparent',
|
||||
failed: 'border-transparent',
|
||||
};
|
||||
|
||||
const STAGE_LABELS = {
|
||||
|
||||
@@ -316,7 +316,7 @@ export default function CloneDesignTab(props) {
|
||||
|
||||
{/* ═══ VOICE — who says it ═══ */}
|
||||
<div className="flex flex-col gap-[6px] flex-none min-h-0 relative z-[1]">
|
||||
<div className="flex flex-col min-h-0 overflow-auto bg-[var(--chrome-bg)] border border-[var(--chrome-border)] rounded-none py-[10px] px-[12px] max-[800px]:px-[10px] max-[600px]:px-[6px] max-[600px]:py-[8px]">
|
||||
<div className="flex flex-col min-h-0 overflow-auto bg-[var(--chrome-bg)] border border-transparent rounded-none py-[10px] px-[12px] max-[800px]:px-[10px] max-[600px]:px-[6px] max-[600px]:py-[8px]">
|
||||
<div className="label-row justify-between">
|
||||
<span className="label-row mb-0">
|
||||
<Volume2 className="label-icon" size={14} />{' '}
|
||||
|
||||
@@ -83,7 +83,7 @@ export default function ContactPage({ onBack }) {
|
||||
<div className="relative z-[1] mx-auto flex w-full max-w-[640px] flex-1 flex-col justify-center gap-6 px-8 pb-10">
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="text-center">
|
||||
<span className="mx-auto mb-4 flex size-12 items-center justify-center rounded-md border border-[color-mix(in_srgb,#d3869b_30%,transparent)] bg-[color-mix(in_srgb,#d3869b_12%,transparent)]">
|
||||
<span className="mx-auto mb-4 flex size-12 items-center justify-center rounded-md border border-transparent bg-[color-mix(in_srgb,#d3869b_12%,transparent)]">
|
||||
<MessageCircle
|
||||
size={24}
|
||||
className="text-[#f3a5b6] drop-shadow-[0_0_12px_rgba(243,165,182,0.5)]"
|
||||
@@ -110,9 +110,9 @@ export default function ContactPage({ onBack }) {
|
||||
type="button"
|
||||
onClick={() => openExternal(c.url)}
|
||||
style={{ '--card-hue': c.hue }}
|
||||
className="flex w-full items-center gap-3 overflow-hidden rounded-md border border-border bg-transparent px-3.5 py-2.5 text-left transition-colors hover:border-[color-mix(in_srgb,var(--card-hue)_40%,transparent)] hover:bg-[color-mix(in_srgb,var(--card-hue)_6%,transparent)]"
|
||||
className="flex w-full items-center gap-3 overflow-hidden rounded-md border border-border bg-transparent px-3.5 py-2.5 text-left transition-colors hover:border-transparent hover:bg-[color-mix(in_srgb,var(--card-hue)_6%,transparent)]"
|
||||
>
|
||||
<span className="flex size-8 shrink-0 items-center justify-center rounded-md border border-[color-mix(in_srgb,var(--card-hue)_22%,transparent)] bg-[color-mix(in_srgb,var(--card-hue)_10%,transparent)]">
|
||||
<span className="flex size-8 shrink-0 items-center justify-center rounded-md border border-transparent bg-[color-mix(in_srgb,var(--card-hue)_10%,transparent)]">
|
||||
<Icon size={20} />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
|
||||
@@ -23,7 +23,7 @@ import ReadinessChecklist from '../components/ReadinessChecklist';
|
||||
// (P4 shadcn/Tailwind pass). Defined once here so the four card instances stay
|
||||
// in lockstep; Tailwind's scanner picks the literals up from this file.
|
||||
const projCard =
|
||||
'bg-[var(--chrome-bg)] border border-solid border-[var(--chrome-border)] rounded-[var(--chrome-radius-pill)] py-[10px] px-[14px] [transition:background_0.15s,border-color_0.15s] flex items-center gap-[12px] hover:bg-[var(--chrome-hover-bg)] hover:border-[var(--chrome-border-strong)]';
|
||||
'bg-[var(--chrome-bg)] border border-solid border-transparent rounded-[var(--chrome-radius-pill)] py-[10px] px-[14px] [transition:background_0.15s,border-color_0.15s] flex items-center gap-[12px] hover:bg-[var(--chrome-hover-bg)] hover:border-transparent';
|
||||
const projIcon =
|
||||
'w-[32px] h-[32px] rounded-[var(--chrome-radius-pill)] flex items-center justify-center shrink-0';
|
||||
const projInfo = 'flex-1 min-w-0';
|
||||
@@ -32,7 +32,7 @@ const projName =
|
||||
const projMeta =
|
||||
'[font-family:var(--chrome-font-mono)] text-[0.62rem] text-[color:var(--chrome-fg-dim)] mt-[2px] font-normal whitespace-nowrap overflow-hidden text-ellipsis';
|
||||
const projAction =
|
||||
'[font-family:var(--font-sans)] text-[0.7rem] font-medium py-[4px] px-[12px] rounded-[var(--chrome-radius-pill)] bg-transparent border border-solid border-[var(--chrome-border-strong)] text-[color:var(--chrome-fg-muted)] cursor-pointer [transition:background_var(--dur-fast),color_var(--dur-fast),border-color_var(--dur-fast)] shrink-0 whitespace-nowrap [letter-spacing:0.02em] hover:bg-[var(--chrome-hover-bg)] hover:text-[color:var(--chrome-fg)] hover:border-[var(--chrome-fg-muted)]';
|
||||
'[font-family:var(--font-sans)] text-[0.7rem] font-medium py-[4px] px-[12px] rounded-[var(--chrome-radius-pill)] bg-transparent border border-solid border-transparent text-[color:var(--chrome-fg-muted)] cursor-pointer [transition:background_var(--dur-fast),color_var(--dur-fast),border-color_var(--dur-fast)] shrink-0 whitespace-nowrap [letter-spacing:0.02em] hover:bg-[var(--chrome-hover-bg)] hover:text-[color:var(--chrome-fg)] hover:border-[var(--chrome-fg-muted)]';
|
||||
// Section divider label ("Cloned Voices" etc.) — the trailing dotted rule lives
|
||||
// on an ::after pseudo, expressed via the after: variant.
|
||||
const sectionTitle =
|
||||
@@ -369,7 +369,7 @@ export default function Launchpad({
|
||||
<div className={`${projMeta} italic`}>{p.instruct}</div>
|
||||
</div>
|
||||
{p.is_locked && (
|
||||
<span className="[font-family:var(--chrome-font-mono)] text-[length:var(--chrome-label-size)] [letter-spacing:var(--chrome-label-track)] py-[1px] px-[7px] rounded-[var(--chrome-radius-pill)] bg-[color-mix(in_srgb,#b8bb26_10%,transparent)] border border-solid border-[color-mix(in_srgb,#b8bb26_40%,transparent)] text-[#b8bb26] font-semibold">
|
||||
<span className="[font-family:var(--chrome-font-mono)] text-[length:var(--chrome-label-size)] [letter-spacing:var(--chrome-label-track)] py-[1px] px-[7px] rounded-[var(--chrome-radius-pill)] bg-[color-mix(in_srgb,#b8bb26_10%,transparent)] border border-solid border-transparent text-[#b8bb26] font-semibold">
|
||||
{t('launchpad.locked')}
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -87,7 +87,7 @@ function Card({ kind, accent, title, subtitle, trailing, onClick, IconC, view })
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={`flex cursor-pointer rounded-[var(--chrome-radius-pill)] border border-[var(--chrome-border)] [border-left:3px_solid_var(--card-accent,var(--chrome-accent))] bg-[var(--chrome-bg)] text-left [font-family:inherit] text-[var(--chrome-fg)] transition-[border-color,background,transform] duration-[0.12s] hover:border-[var(--chrome-border-strong)] hover:bg-[color-mix(in_srgb,var(--card-accent,var(--chrome-accent))_5%,var(--chrome-bg))] active:translate-y-[1px] ${
|
||||
className={`flex cursor-pointer rounded-[var(--chrome-radius-pill)] border border-transparent [border-left:3px_solid_var(--card-accent,var(--chrome-accent))] bg-[var(--chrome-bg)] text-left [font-family:inherit] text-[var(--chrome-fg)] transition-[border-color,background,transform] duration-[0.12s] hover:border-transparent hover:bg-[color-mix(in_srgb,var(--card-accent,var(--chrome-accent))_5%,var(--chrome-bg))] active:translate-y-[1px] ${
|
||||
list
|
||||
? 'flex-row items-center gap-[14px] px-[12px] py-[6px]'
|
||||
: 'flex-col gap-[6px] px-[12px] py-[10px]'
|
||||
|
||||
@@ -458,7 +458,7 @@ export default function Settings() {
|
||||
<header className="mb-[var(--space-4)] flex items-center gap-[var(--space-3)]">
|
||||
{CatIcon && (
|
||||
<span
|
||||
className="shrink-0 inline-flex items-center justify-center w-[26px] h-[26px] rounded-[var(--chrome-radius-pill)] text-[color:var(--chrome-accent)] bg-[color-mix(in_srgb,var(--chrome-accent)_12%,var(--chrome-bg))] border border-[color-mix(in_srgb,var(--chrome-accent)_26%,var(--chrome-border))]"
|
||||
className="shrink-0 inline-flex items-center justify-center w-[26px] h-[26px] rounded-[var(--chrome-radius-pill)] text-[color:var(--chrome-accent)] bg-[color-mix(in_srgb,var(--chrome-accent)_12%,var(--chrome-bg))] border border-transparent"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<CatIcon size={15} />
|
||||
|
||||
@@ -41,7 +41,9 @@ const METHODS = [
|
||||
},
|
||||
];
|
||||
|
||||
const DONATE_HUE = '#d3869b';
|
||||
// Donate/support accent tracks the themed brand token (per-[data-theme]) so the
|
||||
// panel recolors with the app theme instead of the old fixed pink.
|
||||
const DONATE_HUE = 'var(--color-brand)';
|
||||
|
||||
// PayPal.me carries the chosen amount straight into the checkout; Ko-fi opens
|
||||
// its tip page (no reliable preset-amount URL). A non-numeric/"custom" amount
|
||||
@@ -59,9 +61,9 @@ function LinkCard({ icon, label, desc, value, hue, onClick }) {
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
style={{ '--card-hue': hue }}
|
||||
className="flex w-full items-center gap-3 overflow-hidden rounded-md border border-border bg-transparent px-3.5 py-2.5 text-left transition-colors hover:border-[color-mix(in_srgb,var(--card-hue)_40%,transparent)] hover:bg-[color-mix(in_srgb,var(--card-hue)_6%,transparent)]"
|
||||
className="flex w-full items-center gap-3 overflow-hidden rounded-md border border-border bg-transparent px-3.5 py-2.5 text-left transition-colors hover:border-transparent hover:bg-[color-mix(in_srgb,var(--card-hue)_6%,transparent)]"
|
||||
>
|
||||
<span className="flex size-8 shrink-0 items-center justify-center rounded-md border border-[color-mix(in_srgb,var(--card-hue)_22%,transparent)] bg-[color-mix(in_srgb,var(--card-hue)_10%,transparent)] text-[1.1rem]">
|
||||
<span className="flex size-8 shrink-0 items-center justify-center rounded-md border border-transparent bg-[color-mix(in_srgb,var(--card-hue)_10%,transparent)] text-[1.1rem]">
|
||||
{icon}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
@@ -115,10 +117,10 @@ function SupportView() {
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="text-center">
|
||||
<span className="mx-auto mb-4 flex size-12 items-center justify-center rounded-md border border-[color-mix(in_srgb,#d3869b_30%,transparent)] bg-[color-mix(in_srgb,#d3869b_12%,transparent)]">
|
||||
<span className="mx-auto mb-4 flex size-12 items-center justify-center rounded-md border border-transparent bg-[color-mix(in_srgb,var(--color-brand)_12%,transparent)]">
|
||||
<Heart
|
||||
size={24}
|
||||
className="text-[#f3a5b6] [fill:rgba(243,165,182,0.35)] drop-shadow-[0_0_12px_rgba(243,165,182,0.5)]"
|
||||
className="text-[var(--color-brand)] [fill:color-mix(in_srgb,var(--color-brand)_35%,transparent)] drop-shadow-[0_0_12px_color-mix(in_srgb,var(--color-brand)_50%,transparent)]"
|
||||
/>
|
||||
</span>
|
||||
<h2 className="relative inline-block font-serif text-[2rem] font-normal leading-tight tracking-[-0.02em] text-[var(--chrome-fg)]">
|
||||
@@ -167,7 +169,7 @@ function SupportView() {
|
||||
className={`flex min-h-[52px] flex-col items-center justify-center gap-0.5 rounded-md border px-1.5 py-2 transition-colors ${
|
||||
selected
|
||||
? 'border-[var(--chrome-accent)] bg-[var(--chrome-accent-bg)]'
|
||||
: `${a.common ? 'border-[color-mix(in_srgb,var(--chrome-accent)_35%,transparent)]' : 'border-border'} hover:border-[color-mix(in_srgb,var(--chrome-accent)_40%,transparent)] hover:bg-[color-mix(in_srgb,var(--chrome-accent)_7%,transparent)]`
|
||||
: `${a.common ? 'border-transparent' : 'border-border'} hover:border-transparent hover:bg-[color-mix(in_srgb,var(--chrome-accent)_7%,transparent)]`
|
||||
}`}
|
||||
>
|
||||
<span className="font-serif text-[1.05rem] font-medium text-[var(--chrome-fg)]">
|
||||
@@ -188,7 +190,7 @@ function SupportView() {
|
||||
className={`flex min-h-[52px] flex-col items-center justify-center gap-0.5 rounded-md border px-1.5 py-2 transition-colors ${
|
||||
amount === 'custom'
|
||||
? 'border-[var(--chrome-accent)] bg-[var(--chrome-accent-bg)]'
|
||||
: 'border-border hover:border-[color-mix(in_srgb,var(--chrome-accent)_40%,transparent)] hover:bg-[color-mix(in_srgb,var(--chrome-accent)_7%,transparent)]'
|
||||
: 'border-border hover:border-transparent hover:bg-[color-mix(in_srgb,var(--chrome-accent)_7%,transparent)]'
|
||||
}`}
|
||||
>
|
||||
<span className="font-mono text-[0.78rem] uppercase tracking-[var(--chrome-label-track)] text-[var(--chrome-fg-muted)]">
|
||||
@@ -298,7 +300,7 @@ function LicenseView() {
|
||||
key={label}
|
||||
className="gap-0 rounded-md border-border bg-transparent p-4 shadow-none transition-colors hover:border-border-strong hover:bg-[var(--chrome-hover-bg)]"
|
||||
>
|
||||
<span className="mb-2.5 flex size-[30px] items-center justify-center rounded-md border border-[color-mix(in_srgb,#d3869b_22%,transparent)] bg-[color-mix(in_srgb,#d3869b_10%,transparent)] text-[#d3869b]">
|
||||
<span className="mb-2.5 flex size-[30px] items-center justify-center rounded-md border border-transparent bg-[color-mix(in_srgb,var(--color-brand)_10%,transparent)] text-[var(--color-brand)]">
|
||||
<Icon size={16} />
|
||||
</span>
|
||||
<div className="mb-1 font-mono text-[0.75rem] font-semibold uppercase tracking-[var(--chrome-label-track)] text-[var(--chrome-fg)]">
|
||||
@@ -325,7 +327,7 @@ function LicenseView() {
|
||||
variant="subtle"
|
||||
leading={<Mail size={13} />}
|
||||
onClick={() => openExternal(LICENSE_MAILTO)}
|
||||
className="border-[color-mix(in_srgb,#fe8019_50%,transparent)] bg-[color-mix(in_srgb,#fe8019_18%,transparent)] font-semibold text-[var(--chrome-fg)] hover:border-[color-mix(in_srgb,#fe8019_70%,transparent)] hover:bg-[color-mix(in_srgb,#fe8019_28%,transparent)]"
|
||||
className="border-transparent bg-[color-mix(in_srgb,#fe8019_18%,transparent)] font-semibold text-[var(--chrome-fg)] hover:border-transparent hover:bg-[color-mix(in_srgb,#fe8019_28%,transparent)]"
|
||||
>
|
||||
{t('enterprise.request_quote')}
|
||||
</Button>
|
||||
@@ -360,9 +362,9 @@ export default function SupportPage({ onBack, initialView = 'support' }) {
|
||||
const TAB_INACTIVE =
|
||||
'border-transparent text-[var(--chrome-fg-muted)] hover:text-[var(--chrome-fg)]';
|
||||
const TAB_SUPPORT_ACTIVE =
|
||||
'border-[color-mix(in_srgb,#d3869b_38%,transparent)] bg-[color-mix(in_srgb,#d3869b_18%,transparent)] text-[var(--chrome-fg)]';
|
||||
'border-transparent bg-[color-mix(in_srgb,var(--color-brand)_18%,transparent)] text-[var(--chrome-fg)]';
|
||||
const TAB_LICENSE_ACTIVE =
|
||||
'border-[color-mix(in_srgb,#83a598_40%,transparent)] bg-[color-mix(in_srgb,#83a598_18%,transparent)] text-[var(--chrome-fg)]';
|
||||
'border-transparent bg-[color-mix(in_srgb,#83a598_18%,transparent)] text-[var(--chrome-fg)]';
|
||||
|
||||
return (
|
||||
<div className="relative isolate flex flex-1 flex-col overflow-y-auto bg-[var(--chrome-bg)]">
|
||||
|
||||
@@ -100,7 +100,7 @@ describe('DubLeftColumn — translation-engine install affordance', () => {
|
||||
|
||||
// Highlighted accent button (not the muted chip): brand-accent bg class.
|
||||
const btn = screen.getByRole('button', { name: /install deep_translator/i });
|
||||
expect(btn.className).toMatch(/bg-\[#d3869b\]/);
|
||||
expect(btn.className).toMatch(/bg-\[var\(--color-brand\)\]/);
|
||||
|
||||
fireEvent.click(btn);
|
||||
expect(handleInstallEngine).toHaveBeenCalledWith('google');
|
||||
@@ -122,7 +122,7 @@ describe('DubLeftColumn — translation-engine install affordance', () => {
|
||||
|
||||
// The highlighted trigger opens the escape-hatch popover.
|
||||
const trigger = screen.getByRole('button', { name: /needs install/i });
|
||||
expect(trigger.className).toMatch(/bg-\[#d3869b\]/);
|
||||
expect(trigger.className).toMatch(/bg-\[var\(--color-brand\)\]/);
|
||||
fireEvent.click(trigger);
|
||||
|
||||
const dialog = screen.getByRole('dialog');
|
||||
|
||||
@@ -15,11 +15,11 @@ import { Card } from '@/components/ui/card';
|
||||
|
||||
const VARIANT = {
|
||||
// glass: surface (gradients + backdrop-filter) + ::before stay in residual.css.
|
||||
glass: 'border border-[var(--color-border-warm)]',
|
||||
glass: 'border border-transparent',
|
||||
solid:
|
||||
'border border-[var(--color-border-warm)] ' +
|
||||
'border border-transparent ' +
|
||||
'[background-image:linear-gradient(160deg,#2a2624_0%,#201c1b_100%)] [box-shadow:var(--shadow-md)]',
|
||||
flat: 'border border-[var(--color-border)] [background-color:rgba(0,0,0,0.08)]',
|
||||
flat: 'border border-transparent [background-color:rgba(0,0,0,0.08)]',
|
||||
};
|
||||
|
||||
const PAD = {
|
||||
@@ -72,7 +72,7 @@ const Panel = forwardRef(function Panel(
|
||||
<Card asChild className={classes}>
|
||||
<Tag ref={ref} {...rest}>
|
||||
{hasHeader && (
|
||||
<header className="ui-panel__header flex items-center justify-between py-[var(--space-4)] px-[var(--space-5)] gap-[var(--space-4)] [border-bottom:1px_solid_var(--color-border)]">
|
||||
<header className="ui-panel__header flex items-center justify-between py-[var(--space-4)] px-[var(--space-5)] gap-[var(--space-4)] [border-bottom:1px_solid_transparent]">
|
||||
{title != null && (
|
||||
<div className="ui-panel__title flex items-center gap-[var(--space-3)] min-w-0 [font-size:var(--text-md)] font-bold text-fg tracking-[-0.01em]">
|
||||
{title}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group.tsx';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const ROOT =
|
||||
'ui-seg inline-flex gap-[2px] bg-black/[0.28] p-[3px] rounded-[var(--radius-pill)] border border-[color:var(--color-border)] shrink';
|
||||
'ui-seg inline-flex gap-[2px] bg-bg-elev-2 p-[3px] rounded-[var(--radius-pill)] border border-transparent shrink';
|
||||
|
||||
const SIZE_MAP = { xs: 'segXs', sm: 'segSm' };
|
||||
|
||||
|
||||
@@ -39,8 +39,8 @@ export default function Tabs({
|
||||
// The leading utilities also reset shadcn's list/trigger box-model defaults
|
||||
// (h-9, bg-muted, rounded-lg, flex-1, active shadow) back to OmniVoice's.
|
||||
const listClass = isPill
|
||||
? 'h-auto inline-flex shrink-0 gap-[3px] rounded-[var(--chrome-radius-pill)] border border-[var(--chrome-border)] bg-[var(--chrome-bg)] p-[3px]'
|
||||
: 'h-auto inline-flex shrink-0 gap-[var(--space-5)] rounded-none border-0 border-b border-[var(--chrome-border)] bg-transparent p-0';
|
||||
? 'h-auto inline-flex shrink-0 gap-[3px] rounded-[var(--chrome-radius-pill)] border border-transparent bg-[var(--chrome-bg)] p-[3px]'
|
||||
: 'h-auto inline-flex shrink-0 gap-[var(--space-5)] rounded-none border-0 border-b border-transparent bg-transparent p-0';
|
||||
|
||||
return (
|
||||
<ShadcnTabs
|
||||
@@ -55,7 +55,7 @@ export default function Tabs({
|
||||
const Icon = item.icon;
|
||||
const tabClass = isPill
|
||||
? [
|
||||
'relative flex flex-none cursor-pointer items-center justify-center gap-[var(--space-3)] rounded-[var(--chrome-radius-pill)] border font-sans tracking-[0.01em] data-[state=active]:shadow-none',
|
||||
'relative flex flex-none cursor-pointer items-center justify-center gap-[var(--space-3)] rounded-[var(--chrome-radius-pill)] border border-transparent font-sans tracking-[0.01em] data-[state=active]:shadow-none',
|
||||
'[transition:background_var(--dur-fast)_var(--ease-out),color_var(--dur-fast)_var(--ease-out),border-color_var(--dur-fast)_var(--ease-out)]',
|
||||
'focus-visible:shadow-[var(--focus-ring)] focus-visible:outline-none',
|
||||
isSm
|
||||
|
||||
@@ -143,7 +143,6 @@ export function ArchetypeAvatar({ item, size = 44 }) {
|
||||
width: size,
|
||||
height: size,
|
||||
background: tint(color, 0.14),
|
||||
borderColor: tint(color, 0.32),
|
||||
}}
|
||||
>
|
||||
<ArchetypeIcon name={item.icon} size={Math.round(size * 0.46)} color={color} />
|
||||
|
||||
@@ -11,20 +11,21 @@ from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
# id → declared gpu_compat. NeMo is CUDA-only (its is_available() hard-fails
|
||||
# without a GPU), so it legitimately has no cpu path.
|
||||
# id → declared gpu_compat. nemo-parakeet was CUDA-gated until 2026-07-02,
|
||||
# when parakeet-tdt-0.6b-v3 was measured at RTF 0.08–0.23 on an M2 CPU —
|
||||
# every ASR engine now has a cpu path.
|
||||
_EXPECTED = {
|
||||
"whisperx": ("cuda", "cpu"),
|
||||
"faster-whisper": ("cuda", "cpu"),
|
||||
"mlx-whisper": ("mps", "cpu"),
|
||||
"pytorch-whisper": ("cuda", "mps", "cpu"),
|
||||
"nemo-parakeet": ("cuda",),
|
||||
"nemo-parakeet": ("cuda", "cpu"),
|
||||
"moonshine": ("cpu",),
|
||||
"funasr": ("cuda", "cpu"),
|
||||
}
|
||||
|
||||
# Engines that legitimately have NO cpu path (hard GPU gate in is_available).
|
||||
_GPU_ONLY = {"nemo-parakeet"}
|
||||
_GPU_ONLY: set[str] = set()
|
||||
|
||||
_VALID = {"cuda", "rocm", "mps", "xpu", "cpu"}
|
||||
|
||||
@@ -62,6 +63,18 @@ def test_no_asr_engine_falsely_claims_rocm():
|
||||
assert "rocm" not in _cls(engine_id).gpu_compat
|
||||
|
||||
|
||||
def test_nemo_parakeet_has_no_cuda_gate(monkeypatch):
|
||||
"""Regression (CPU un-gating, 2026-07-02): on a CUDA-less host,
|
||||
is_available() must never claim a GPU is required — availability is a
|
||||
pure nemo_toolkit dependency check now."""
|
||||
import torch
|
||||
monkeypatch.setattr(torch.cuda, "is_available", lambda: False)
|
||||
ok, reason = _cls("nemo-parakeet").is_available()
|
||||
assert "NVIDIA GPU" not in reason
|
||||
if not ok: # env without nemo_toolkit — the only legitimate blocker
|
||||
assert "nemo_toolkit" in reason
|
||||
|
||||
|
||||
def test_indextts2_overrides_cpu_only_default():
|
||||
from engines.indextts import IndexTTS2Backend
|
||||
assert IndexTTS2Backend.gpu_compat == ("cuda", "cpu")
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
"""WhisperX VRAM preflight (#723).
|
||||
|
||||
On an 8 GB card with the TTS model resident, loading whisper large-v3 fp16
|
||||
dies as a *native* CUDA OOM abort — the backend process is killed outright,
|
||||
no Python exception fires, and the UI reports "Can't reach the local
|
||||
backend". The load-time fp16→int8 / OOM→CPU fallbacks in `_ensure_asr` never
|
||||
run because nothing is raised. The only defense is a preflight: re-check the
|
||||
device pick against actually-free VRAM (`torch.cuda.mem_get_info`) right
|
||||
before loading, degrading fp16 → int8_float16 → int8 → CPU.
|
||||
|
||||
Backend classes are resolved at RUNTIME (see test_asr_gpu_compat.py rationale).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _backend():
|
||||
from services.asr_backend import _REGISTRY
|
||||
b = _REGISTRY["whisperx"].__new__(_REGISTRY["whisperx"]) # skip __init__ (no torch probe)
|
||||
b._model_name = "large-v3"
|
||||
return b
|
||||
|
||||
|
||||
def _degrade(b, free_gb, device="cuda", compute="float16"):
|
||||
b._free_vram_gb = lambda: free_gb
|
||||
return b._degrade_for_vram(device, compute)
|
||||
|
||||
|
||||
# ── The #723 crash scenario: TTS resident, ~2 GB free, fp16 requested ──────
|
||||
|
||||
def test_starved_card_falls_back_to_cpu():
|
||||
assert _degrade(_backend(), 2.0) == ("cpu", "int8")
|
||||
|
||||
|
||||
def test_mid_vram_degrades_to_int8_on_cuda():
|
||||
# 3.5 GB free: can't hold fp16 (5.0) or int8_float16 (3.5 is not > needed
|
||||
# headroom boundary — equal passes), int8 (3.0) certainly fits.
|
||||
dev, ct = _degrade(_backend(), 3.2)
|
||||
assert (dev, ct) == ("cuda", "int8")
|
||||
|
||||
|
||||
def test_ample_vram_keeps_fp16():
|
||||
assert _degrade(_backend(), 7.0) == ("cuda", "float16")
|
||||
|
||||
|
||||
# ── Preflight must never *break* ASR ────────────────────────────────────────
|
||||
|
||||
def test_unknown_vram_is_left_alone():
|
||||
assert _degrade(_backend(), None) == ("cuda", "float16")
|
||||
|
||||
|
||||
def test_cpu_pick_is_untouched():
|
||||
b = _backend()
|
||||
b._free_vram_gb = lambda: 0.5
|
||||
assert b._degrade_for_vram("cpu", "int8") == ("cpu", "int8")
|
||||
|
||||
|
||||
def test_small_models_not_over_evicted():
|
||||
# A 2 GB-free card comfortably runs whisper-small fp16 (5.0 * 0.25 budget);
|
||||
# the large-v3 budgets must not evict smaller models from CUDA.
|
||||
b = _backend()
|
||||
b._model_name = "small"
|
||||
assert _degrade(b, 2.0) == ("cuda", "float16")
|
||||
|
||||
|
||||
def test_env_opt_out(monkeypatch):
|
||||
monkeypatch.setenv("OMNIVOICE_ASR_VRAM_PREFLIGHT", "0")
|
||||
assert _degrade(_backend(), 0.5) == ("cuda", "float16")
|
||||
|
||||
|
||||
# ── Wiring: _ensure_asr must preflight BEFORE whisperx.load_model ──────────
|
||||
|
||||
def test_ensure_asr_applies_preflight_before_load(monkeypatch):
|
||||
import sys, types
|
||||
|
||||
calls = {}
|
||||
|
||||
fake_whisperx = types.ModuleType("whisperx")
|
||||
def _load_model(name, device=None, compute_type=None, **kw):
|
||||
calls["load"] = (device, compute_type)
|
||||
return object()
|
||||
fake_whisperx.load_model = _load_model
|
||||
monkeypatch.setitem(sys.modules, "whisperx", fake_whisperx)
|
||||
|
||||
b = _backend()
|
||||
b._asr = None
|
||||
b._device, b._compute_type = "cuda", "float16"
|
||||
b._free_vram_gb = lambda: 2.0 # the #723 card state
|
||||
b._allow_vad_pickle_globals = lambda: None
|
||||
|
||||
b._ensure_asr()
|
||||
assert calls["load"] == ("cpu", "int8") # degraded BEFORE the load call
|
||||
@@ -0,0 +1,44 @@
|
||||
"""Confucius4-TTS engine scaffold (#590).
|
||||
|
||||
The engine is opt-in (gated behind OMNIVOICE_CONFUCIUS4_TTS_DIR) and
|
||||
subprocess-isolated, so it must be wired into the registry yet completely inert
|
||||
on a default install — never importing the (unvalidated) upstream package, never
|
||||
reporting available without a clone. These tests pin exactly that.
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "backend"))
|
||||
|
||||
|
||||
def test_registered_in_lazy_registry():
|
||||
from services.tts_backend import _LAZY_REGISTRY
|
||||
assert _LAZY_REGISTRY.get("confucius4-tts") == ("engines.confucius4", "Confucius4Backend")
|
||||
|
||||
|
||||
def test_backend_class_metadata():
|
||||
from engines.confucius4 import Confucius4Backend
|
||||
assert Confucius4Backend.id == "confucius4-tts"
|
||||
assert Confucius4Backend.gpu_compat == ("cuda", "cpu") # CPU validated E2E; no MPS claim
|
||||
assert Confucius4Backend.supports_voice_design is False
|
||||
|
||||
|
||||
def test_inert_without_clone_dir(monkeypatch):
|
||||
monkeypatch.delenv("OMNIVOICE_CONFUCIUS4_TTS_DIR", raising=False)
|
||||
from engines.confucius4 import bootstrap
|
||||
bootstrap.invalidate()
|
||||
assert bootstrap.is_confucius4_installed() is False
|
||||
|
||||
from engines.confucius4 import Confucius4Backend
|
||||
ok, reason = Confucius4Backend.is_available()
|
||||
assert ok is False
|
||||
assert "OMNIVOICE_CONFUCIUS4_TTS_DIR" in reason
|
||||
|
||||
|
||||
def test_resolve_raises_actionable_without_clone(monkeypatch):
|
||||
monkeypatch.delenv("OMNIVOICE_CONFUCIUS4_TTS_DIR", raising=False)
|
||||
from engines.confucius4 import bootstrap
|
||||
bootstrap.invalidate()
|
||||
import pytest
|
||||
with pytest.raises(RuntimeError, match="OMNIVOICE_CONFUCIUS4_TTS_DIR"):
|
||||
bootstrap.resolve_confucius4_venv()
|
||||
@@ -0,0 +1,183 @@
|
||||
"""Confucius4-TTS sidecar unit tests (#590 — finalization).
|
||||
|
||||
The upstream synthesis API (``confuciustts.cli.inference.ConfuciusTTS`` →
|
||||
``generate(text, lang, prompt_wav)`` → tensor, ``model.sample_rate``) is
|
||||
**validated end-to-end** (2026-07-02, Apple Silicon, CPU): audible speech at
|
||||
22 050 Hz. Full generation needs ~5 GB of weights, so it can't run in CI — but
|
||||
the sidecar's *pure* logic (language normalization, tensor→PCM, config-path
|
||||
resolution, sys.path clone injection, wire framing) and the bootstrap probe are
|
||||
fully testable here, with the model mocked.
|
||||
|
||||
The sidecar is stdlib-only at import time (the model/torch imports are lazy),
|
||||
so we import it directly without spawning the engine venv.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import importlib.util
|
||||
import io
|
||||
import os
|
||||
import struct
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
_SIDECAR = (
|
||||
Path(__file__).resolve().parent.parent
|
||||
/ "backend" / "engines" / "confucius4" / "main.py"
|
||||
)
|
||||
|
||||
|
||||
def _load_sidecar():
|
||||
spec = importlib.util.spec_from_file_location("confucius4_sidecar_main", _SIDECAR)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def sc():
|
||||
return _load_sidecar()
|
||||
|
||||
|
||||
# ── Language normalization ────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.parametrize("raw,expected", [
|
||||
("en", "en"), ("EN", "en"), ("zh", "zh"), ("zh-CN", "zh"),
|
||||
("ja", "ja"), ("", "en"), ("auto", "en"), ("AUTO", "en"),
|
||||
(None, "en"), ("Vietnamese", "vi"), (" fr ", "fr"),
|
||||
])
|
||||
def test_normalize_language(sc, raw, expected):
|
||||
assert sc._normalize_language(raw) == expected
|
||||
|
||||
|
||||
# ── Tensor → PCM base64 ───────────────────────────────────────────────────
|
||||
|
||||
def test_pcm_roundtrip_mono(sc):
|
||||
import torch
|
||||
t = torch.tensor([0.0, 0.5, -0.5, 1.0, -1.0])
|
||||
b64, sr, n = sc._tensor_to_pcm_b64(t, 24000)
|
||||
pcm = np.frombuffer(base64.b64decode(b64), dtype=np.int16)
|
||||
assert (sr, n) == (24000, 5)
|
||||
assert pcm.max() == 32767 and pcm.min() == -32767 # full-scale clamp
|
||||
|
||||
|
||||
def test_pcm_clips_out_of_range(sc):
|
||||
import torch
|
||||
t = torch.tensor([2.0, -3.0]) # beyond [-1, 1]
|
||||
b64, _sr, n = sc._tensor_to_pcm_b64(t, 24000)
|
||||
pcm = np.frombuffer(base64.b64decode(b64), dtype=np.int16)
|
||||
assert n == 2 and pcm.max() == 32767 and pcm.min() == -32767
|
||||
|
||||
|
||||
def test_pcm_downmixes_stereo(sc):
|
||||
import torch
|
||||
stereo = torch.tensor([[0.2, 0.4], [0.6, 0.8]]) # (2, 2)
|
||||
_b64, _sr, n = sc._tensor_to_pcm_b64(stereo, 24000)
|
||||
assert n == 2 # mean over channel dim → 2 samples
|
||||
|
||||
|
||||
def test_pcm_accepts_numpy(sc):
|
||||
arr = np.array([0.1, -0.1, 0.0], dtype=np.float32)
|
||||
_b64, sr, n = sc._tensor_to_pcm_b64(arr, 16000)
|
||||
assert (sr, n) == (16000, 3)
|
||||
|
||||
|
||||
# ── Sample rate (confirmed 22 050 Hz by the 2026-07-02 live run) ──────────
|
||||
|
||||
def test_sample_rate_constant_is_confirmed_upstream_rate(sc):
|
||||
# Upstream config target_sample_rate — regression-pins the live-run value
|
||||
# so the pre-validation 24 000 guess can't come back.
|
||||
assert sc.CONFUCIUS_SAMPLE_RATE == 22050
|
||||
|
||||
|
||||
def test_sample_rate_lockstep_with_backend_default(sc):
|
||||
import os as _os, sys as _sys
|
||||
_sys.path.insert(0, _os.path.join(
|
||||
_os.path.dirname(_os.path.dirname(_os.path.abspath(__file__))), "backend"))
|
||||
from engines.confucius4 import Confucius4Backend
|
||||
assert Confucius4Backend._DEFAULT_SAMPLE_RATE == sc.CONFUCIUS_SAMPLE_RATE
|
||||
|
||||
|
||||
# ── Clone sys.path injection (upstream is not pip-installable) ────────────
|
||||
|
||||
def test_clone_dir_inserted_at_sys_path_front(sc, monkeypatch):
|
||||
import sys as _sys
|
||||
monkeypatch.setenv("OMNIVOICE_CONFUCIUS4_TTS_DIR", "/clone")
|
||||
monkeypatch.setattr(_sys, "path", ["existing"])
|
||||
sc._ensure_clone_on_sys_path()
|
||||
assert _sys.path[0] == "/clone"
|
||||
sc._ensure_clone_on_sys_path() # idempotent — no dup
|
||||
assert _sys.path.count("/clone") == 1
|
||||
|
||||
|
||||
def test_no_sys_path_change_without_clone_dir(sc, monkeypatch):
|
||||
import sys as _sys
|
||||
monkeypatch.delenv("OMNIVOICE_CONFUCIUS4_TTS_DIR", raising=False)
|
||||
monkeypatch.setattr(_sys, "path", ["existing"])
|
||||
sc._ensure_clone_on_sys_path()
|
||||
assert _sys.path == ["existing"]
|
||||
|
||||
|
||||
# ── Config path resolution ────────────────────────────────────────────────
|
||||
|
||||
def test_config_path_explicit_override(sc, monkeypatch):
|
||||
monkeypatch.setenv("OMNIVOICE_CONFUCIUS4_CONFIG", "/x/custom.yaml")
|
||||
assert sc._config_path() == "/x/custom.yaml"
|
||||
|
||||
|
||||
def test_config_path_from_clone_dir(sc, monkeypatch):
|
||||
monkeypatch.delenv("OMNIVOICE_CONFUCIUS4_CONFIG", raising=False)
|
||||
monkeypatch.setenv("OMNIVOICE_CONFUCIUS4_TTS_DIR", "/clone")
|
||||
assert sc._config_path() == os.path.join("/clone", "config", "inference_config.yaml")
|
||||
|
||||
|
||||
# ── Wire framing (length-prefixed JSON) ───────────────────────────────────
|
||||
|
||||
def test_send_recv_roundtrip(sc):
|
||||
buf = io.BytesIO()
|
||||
sc._send(buf, {"op": "ready", "engine": "confucius4-tts"})
|
||||
buf.seek(0)
|
||||
assert sc._recv(buf) == {"op": "ready", "engine": "confucius4-tts"}
|
||||
|
||||
|
||||
def test_recv_eof_returns_none(sc):
|
||||
assert sc._recv(io.BytesIO(b"")) is None
|
||||
|
||||
|
||||
def test_recv_rejects_oversize_frame(sc):
|
||||
hdr = struct.pack("!I", sc.MAX_FRAME_BYTES + 1)
|
||||
with pytest.raises(IOError):
|
||||
sc._recv(io.BytesIO(hdr))
|
||||
|
||||
|
||||
# ── synthesize dispatch (model mocked — no GPU/weights) ───────────────────
|
||||
|
||||
def test_synthesize_calls_generate_and_emits_audio(sc, monkeypatch):
|
||||
import torch
|
||||
|
||||
class _FakeModel:
|
||||
sample_rate = 22050
|
||||
|
||||
def generate(self, **kw):
|
||||
_FakeModel.last_kwargs = kw
|
||||
return torch.tensor([0.0, 1.0, -1.0])
|
||||
|
||||
monkeypatch.setattr(sc, "_load_model", lambda stdout: _FakeModel())
|
||||
out = io.BytesIO()
|
||||
sc._handle_synthesize(
|
||||
{"text": "hello", "language": "AUTO", "ref_audio": "/ref.wav"}, out,
|
||||
)
|
||||
out.seek(0)
|
||||
frame = sc._recv(out)
|
||||
assert frame["op"] == "audio"
|
||||
assert frame["sample_rate"] == 22050 # read from model.sample_rate
|
||||
assert frame["n_samples"] == 3
|
||||
# language normalized, ref audio forwarded as prompt_wav
|
||||
assert _FakeModel.last_kwargs == {"text": "hello", "lang": "en", "prompt_wav": "/ref.wav"}
|
||||
|
||||
|
||||
def test_synthesize_rejects_empty_text(sc):
|
||||
with pytest.raises(ValueError, match="text"):
|
||||
sc._handle_synthesize({"text": ""}, io.BytesIO())
|
||||
@@ -0,0 +1,167 @@
|
||||
"""Hard rule: no decorative literal-color borders in the frontend.
|
||||
|
||||
The app-wide border/divider removal (owner-approved) stripped every decorative
|
||||
border, hairline, divider, and panel frame. Perceivability is preserved through
|
||||
background/elevation cues (selection, inputs, chips) and the kept
|
||||
``:focus-visible`` / ``--color-ring`` / ``--focus-ring`` focus indicators.
|
||||
|
||||
This guard fails if the *regression class* reappears — the hardcoded neutral
|
||||
hairlines and inline border colors that ``--color-border`` token-zeroing can't
|
||||
reach:
|
||||
|
||||
1. Neutral (white / black) literal ``border[-x]:`` colors in ``index.css``.
|
||||
2. ``border-white/…`` / ``border-black/…`` Tailwind utilities, or
|
||||
``border-[#…]`` / ``border-[rgb(a)(…)]`` / ``border-[hsl(…)]`` literal-color
|
||||
arbitrary utilities, in ``*.jsx`` / ``*.tsx``.
|
||||
3. Inline ``style={{ borderColor: … }}`` (non-transparent) in ``*.jsx`` /
|
||||
``*.tsx``.
|
||||
4. Token-based STRUCTURAL border utilities in ``*.jsx`` / ``*.tsx`` —
|
||||
``border[-trbl]-[var(--chrome-border…)]`` and ``…-[var(--color-border…)]``
|
||||
(incl. ``-strong`` / ``-warm``). These render a hairline the moment the
|
||||
``--*-border`` token doesn't resolve transparent (a theme re-declare, or a
|
||||
bare ``border`` with no color under Tailwind v4's currentColor default), so
|
||||
the panel/aside/card/row frames were physically removed and converted to
|
||||
``border-transparent``. This catches their reappearance.
|
||||
|
||||
It deliberately does NOT flag: ``:focus-visible`` / ring rules, the
|
||||
``--color-ring`` / ``--focus-ring`` focus tokens, ``border-transparent`` /
|
||||
``border-0``; the intentional ``--chrome-accent-border`` accent state cue and
|
||||
severity token borders; the ``border-border`` / ``border-input`` design-system
|
||||
aliases (also zeroed) and the arbitrary ``[border:…var(--…-border)…]`` property
|
||||
form; the dashed drop-zone affordance; or borders kept in the functional
|
||||
waveform / segment editor and shadcn form-control primitives (allowlisted).
|
||||
"""
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
_REPO = Path(__file__).resolve().parents[1]
|
||||
_SRC = _REPO / "frontend" / "src"
|
||||
_INDEX_CSS = _SRC / "index.css"
|
||||
|
||||
# This enforcement file's own pattern literals must not trip the scan.
|
||||
_SELF = Path(__file__).name
|
||||
|
||||
# ── 1) Neutral literal border colors in index.css ────────────────────────────
|
||||
# `(?<![\w-])` keeps `--color-border` / `--chrome-border` custom-property
|
||||
# *definitions* (which are token-zeroed by the trailing override block) from
|
||||
# matching; only real `border[-x]:` declarations using a white/black rgba fill.
|
||||
_CSS_NEUTRAL_BORDER = re.compile(
|
||||
r"(?<![\w-])border[a-z-]*\s*:[^;{}]*?"
|
||||
r"rgba\(\s*(?:255\s*,\s*255\s*,\s*255|0\s*,\s*0\s*,\s*0)\s*,",
|
||||
)
|
||||
|
||||
# ── 2) Literal-color border utilities in JSX/TSX ─────────────────────────────
|
||||
_JSX_BORDER_UTIL = re.compile(
|
||||
r"border(?:-[trblxy])?-(?:white|black)(?:/|\b)"
|
||||
r"|border(?:-[trblxy])?-\[(?:#|rgba?\(|hsl\()",
|
||||
)
|
||||
|
||||
# ── 3) Inline borderColor (non-transparent) ──────────────────────────────────
|
||||
_JSX_BORDERCOLOR = re.compile(r"borderColor\s*:")
|
||||
|
||||
# ── 4) Token-based structural border utilities in JSX/TSX ────────────────────
|
||||
# `border[-trbl]-[var(--chrome-border…)]` / `…-[var(--color-border…)]` (any
|
||||
# direction, incl. `-strong` / `-warm`, and any variant prefix). NOT matched:
|
||||
# `--chrome-accent-border` (intentional accent state cue) — the char after
|
||||
# `--chrome-`/`--color-` must be `border`.
|
||||
_JSX_TOKEN_BORDER = re.compile(
|
||||
r"border(?:-[trblxy])?-\[var\(--(?:chrome|color)-border",
|
||||
)
|
||||
|
||||
# Functional-affordance files whose remaining borders are intentional and must
|
||||
# NOT be stripped: the waveform / segment editor (selection ring, drag handles,
|
||||
# segment boundaries) and the shadcn form-control primitives (the border IS the
|
||||
# control's own outline — removing it makes the field invisible).
|
||||
_BORDER_ALLOW = {
|
||||
"components/AudioTrimmer.jsx",
|
||||
"components/WaveformPlayer.jsx",
|
||||
"components/SegmentTrack.jsx",
|
||||
"components/ui/input.tsx",
|
||||
"components/ui/textarea.tsx",
|
||||
"components/ui/select.tsx",
|
||||
"components/ui/slider.tsx",
|
||||
"components/ui/table.tsx",
|
||||
}
|
||||
|
||||
|
||||
def _iter_frontend_files(suffixes):
|
||||
for p in _SRC.rglob("*"):
|
||||
if p.suffix in suffixes and p.name != _SELF:
|
||||
yield p
|
||||
|
||||
|
||||
def test_no_neutral_literal_borders_in_index_css():
|
||||
offenders = []
|
||||
for i, line in enumerate(_INDEX_CSS.read_text().splitlines(), 1):
|
||||
if _CSS_NEUTRAL_BORDER.search(line):
|
||||
offenders.append(f"index.css:{i}: {line.strip()}")
|
||||
assert not offenders, (
|
||||
"Decorative neutral (white/black) literal borders reappeared in "
|
||||
"index.css. Use `border: … transparent` (or drop the border) — the "
|
||||
"app-wide border removal zeroed these.\n" + "\n".join(offenders)
|
||||
)
|
||||
|
||||
|
||||
def test_no_literal_color_border_utilities_in_jsx():
|
||||
offenders = []
|
||||
for p in _iter_frontend_files({".jsx", ".tsx"}):
|
||||
for i, line in enumerate(p.read_text().splitlines(), 1):
|
||||
for m in _JSX_BORDER_UTIL.finditer(line):
|
||||
token = m.group(0)
|
||||
if "border-transparent" in token:
|
||||
continue
|
||||
offenders.append(f"{p.relative_to(_REPO)}:{i}: …{token}…")
|
||||
assert not offenders, (
|
||||
"Literal-color border utilities reappeared. Use `border-transparent` "
|
||||
"(keep the width to suppress the no-Preflight UA border) or a "
|
||||
"background tint for active/selected state.\n" + "\n".join(offenders)
|
||||
)
|
||||
|
||||
|
||||
def test_no_inline_border_color_in_jsx():
|
||||
offenders = []
|
||||
for p in _iter_frontend_files({".jsx", ".tsx"}):
|
||||
for i, line in enumerate(p.read_text().splitlines(), 1):
|
||||
if _JSX_BORDERCOLOR.search(line) and "transparent" not in line:
|
||||
offenders.append(f"{p.relative_to(_REPO)}:{i}: {line.strip()}")
|
||||
assert not offenders, (
|
||||
"Inline `borderColor` reappeared. Convey selection/error via a "
|
||||
"background tint (see WorkspaceHistory / DubSegmentRow) instead.\n"
|
||||
+ "\n".join(offenders)
|
||||
)
|
||||
|
||||
|
||||
def test_no_token_border_utilities_in_jsx():
|
||||
offenders = []
|
||||
for p in _iter_frontend_files({".jsx", ".tsx"}):
|
||||
if p.relative_to(_SRC).as_posix() in _BORDER_ALLOW:
|
||||
continue
|
||||
for i, line in enumerate(p.read_text().splitlines(), 1):
|
||||
if _JSX_TOKEN_BORDER.search(line):
|
||||
offenders.append(f"{p.relative_to(_REPO)}:{i}: {line.strip()[:120]}")
|
||||
assert not offenders, (
|
||||
"Token-based structural border utilities reappeared. A "
|
||||
"`border-[var(--chrome-border…)]` / `border-[var(--color-border…)]` "
|
||||
"renders a stray frame the moment the token isn't transparent. Drop the "
|
||||
"border or use `border-transparent` (keep the width to suppress the "
|
||||
"no-Preflight UA border); convey active/selected state with a background "
|
||||
"tint (`--chrome-accent-bg` / `--chrome-hover-bg` / `--color-bg-elev-*`)."
|
||||
"\n" + "\n".join(offenders)
|
||||
)
|
||||
|
||||
|
||||
def test_focus_indicators_are_preserved():
|
||||
"""Regression guard: the border removal must not strip focus a11y."""
|
||||
css = _INDEX_CSS.read_text()
|
||||
assert "--color-ring:" in css, "--color-ring focus token was removed"
|
||||
assert "--focus-ring:" in css, "--focus-ring token was removed"
|
||||
assert ":focus-visible" in css, ":focus-visible ring rules were removed"
|
||||
# The token-zeroing override must NOT have zeroed the focus ring.
|
||||
assert "--color-ring: transparent" not in css
|
||||
assert "--focus-ring: transparent" not in css
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(pytest.main([__file__, "-v"]))
|
||||
@@ -113,3 +113,58 @@ def test_cinematic_reflect_failure_returns_literal(monkeypatch):
|
||||
assert res["text"] == "Hola"
|
||||
assert res["literal"] == "Hola"
|
||||
assert "reflect" in res.get("error", "")
|
||||
|
||||
|
||||
# ── Cinematic pass wall-clock budget (#stall follow-up) ────────────────────
|
||||
|
||||
def test_cinematic_budget_degrades_slow_segments_to_literal(monkeypatch):
|
||||
"""A slow LLM must not hang the translate: the pass returns within the
|
||||
budget and unfinished segments fall back to their literal translation."""
|
||||
import asyncio
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
monkeypatch.setenv("OMNIVOICE_CINEMATIC_BUDGET_S", "0.3")
|
||||
|
||||
def _slow(src, lit, **kw):
|
||||
time.sleep(3.0) # far over the 0.3s budget
|
||||
return {"text": "REFINED", "literal": lit, "critique": ""}
|
||||
|
||||
monkeypatch.setattr(tr, "cinematic_refine_sync", _slow)
|
||||
pairs = [("s1", "hi", "hola"), ("s2", "world", "mundo")]
|
||||
|
||||
async def _run():
|
||||
ex = ThreadPoolExecutor(max_workers=4)
|
||||
t0 = time.time()
|
||||
out = await tr.cinematic_refine_many(
|
||||
pairs, source_lang="en", target_lang="es", executor=ex,
|
||||
)
|
||||
return time.time() - t0, out
|
||||
|
||||
dt, out = asyncio.run(_run())
|
||||
assert dt < 2.0, f"budget did not bound the pass (took {dt:.1f}s)"
|
||||
assert [r["id"] for r in out] == ["s1", "s2"] # order + length preserved
|
||||
for r in out:
|
||||
assert r["text"] == r["literal"] # degraded to literal
|
||||
assert r.get("error") == "cinematic-budget"
|
||||
|
||||
|
||||
def test_cinematic_budget_disabled_runs_to_completion(monkeypatch):
|
||||
"""Budget <= 0 disables the bound — every segment gets its refine."""
|
||||
import asyncio
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
monkeypatch.setenv("OMNIVOICE_CINEMATIC_BUDGET_S", "0")
|
||||
monkeypatch.setattr(
|
||||
tr, "cinematic_refine_sync",
|
||||
lambda src, lit, **kw: {"text": f"R:{lit}", "literal": lit, "critique": ""},
|
||||
)
|
||||
|
||||
async def _run():
|
||||
return await tr.cinematic_refine_many(
|
||||
[("s1", "hi", "hola")], source_lang="en", target_lang="es",
|
||||
executor=ThreadPoolExecutor(max_workers=2),
|
||||
)
|
||||
|
||||
out = asyncio.run(_run())
|
||||
assert out[0]["text"] == "R:hola" and "error" not in out[0]
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
"""Launch-window contract (owner decision, 2026-07-02): the app must ALWAYS
|
||||
open maximized — never fullscreen — on every platform.
|
||||
|
||||
Two halves enforce it, and both must hold:
|
||||
1. tauri.conf.json declares `maximized: true` + `fullscreen: false`.
|
||||
2. lib.rs denylists BOTH "widget" and "main" in tauri-plugin-window-state —
|
||||
otherwise restored geometry silently overrides the config, and one manual
|
||||
resize makes every later launch reopen at that smaller size.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
_ROOT = Path(__file__).resolve().parent.parent
|
||||
_CONF = _ROOT / "frontend" / "src-tauri" / "tauri.conf.json"
|
||||
_LIB = _ROOT / "frontend" / "src-tauri" / "src" / "lib.rs"
|
||||
|
||||
|
||||
def _main_window() -> dict:
|
||||
windows = json.loads(_CONF.read_text())["app"]["windows"]
|
||||
mains = [w for w in windows if w.get("label", "main") == "main"]
|
||||
assert len(mains) == 1, f"expected exactly one main window, got {len(mains)}"
|
||||
return mains[0]
|
||||
|
||||
|
||||
def test_main_window_opens_maximized_not_fullscreen():
|
||||
win = _main_window()
|
||||
assert win.get("maximized") is True
|
||||
assert win.get("fullscreen") is False
|
||||
|
||||
|
||||
def test_window_state_plugin_denylists_main_and_widget():
|
||||
src = _LIB.read_text()
|
||||
m = re.search(r"with_denylist\(&\[(?P<labels>[^\]]*)\]\)", src)
|
||||
assert m, "tauri-plugin-window-state denylist not found in lib.rs"
|
||||
labels = set(re.findall(r'"([^"]+)"', m.group("labels")))
|
||||
assert {"main", "widget"} <= labels, (
|
||||
f"window-state denylist must include main+widget, got {labels} — "
|
||||
"without 'main', restored geometry overrides maximized-on-open"
|
||||
)
|
||||
Reference in New Issue
Block a user