Merge pull request #1952 from debpalash/feat/local-workspace-dictation-polish

Improve dictation controls and creative workspace layouts
This commit is contained in:
Palash Debnath
2026-09-09 13:41:19 -07:00
committed by GitHub
94 changed files with 5959 additions and 2954 deletions
+8 -2
View File
@@ -85,14 +85,20 @@ names: there is no `desktop=prod` (note the **hyphen** in `desktop-prod`).
Requires [Rust](https://rustup.rs/) and platform-specific Tauri dependencies — see the [Tauri prerequisites](https://v2.tauri.app/start/prerequisites/).
After installing Rust with rustup on macOS/Linux, either open a new terminal or
load Cargo into the current one before starting the desktop app:
After installing Rust with rustup (or `uv` with its installer), a terminal that
was already open still has the old `PATH`. The desktop launchers (`bun desktop`,
`bun desktop-prod`, `bun desktop-fresh`) detect this and add `~/.cargo/bin` /
`~/.local/bin` for that run, printing a one-line note; to make it permanent,
open a new terminal, or on macOS/Linux load Cargo into the current one:
```bash
source "$HOME/.cargo/env"
bun desktop
```
If Rust is genuinely not installed, the launchers stop up front with the
install command instead of failing later inside `cargo metadata`.
On Linux, errors such as `Package gdk-3.0 was not found`, `pango.pc` missing,
or `javascriptcoregtk-4.1` missing mean the native packages above were not
installed; changing `PKG_CONFIG_PATH` does not fix libraries that are absent.
+5
View File
@@ -33,6 +33,11 @@ Binding for every AI agent (Claude, Codex, Cursor, review bots, …). CLAUDE.md
- `frontend/package.json` dep changes require regenerating root `bun.lock` (Docker runs `--frozen-lockfile`).
- Issues: absorb or decline — never defer to a future version. Check the open-PR queue before implementing community-reported fixes.
## Shared select controls
- Use `frontend/src/components/SearchableSelect.jsx` for all new or redesigned select boxes. Reuse `VoiceSelector` for voice choices. Do not introduce native `<select>` controls.
- Provide a localized `ariaLabel`; use `menuPortal` inside scrolling or clipping containers. Preserve keyboard selection and disabled states.
## Agent skills
Project development skills are pinned in `skills-lock.json` and installed under
+12
View File
@@ -9,6 +9,10 @@ the frozen-backend fallback mirror it for their toolchains.
## [Unreleased]
**Highlights**
- The floating dictation bubble adds pause, resume, stop, close, and a multiline preview (#1952)
- Transcriptions checks model readiness and offers an inline download and shortcut hints (#1952)
- Transcriptions' missing-model prompt lists every dictation model by accuracy vs latency, languages and size, so you install the one that fits — or switch to one already on disk (#1952)
- The Engines menu's Transcription tab picks the dictation model under Sherpa-ONNX, and that choice now also drives Sherpa transcription (#1952)
- A failure with no stage attached no longer borrows another stage's advice, so a text-to-speech error stops telling you the video server dropped the download (#1943)
- A generation failure that the app cannot classify now names the backend error class, so two unrelated faults stop arriving as the same untriageable report (#1800)
@@ -33,6 +37,8 @@ the frozen-backend fallback mirror it for their toolchains.
### Changed
- Tauri 2.11.5 with refreshed plugins (dialog, updater, log, opener, positioner, single-instance), React 19.3, TanStack Query 5.102, lucide 1.43, posthog-js 1.428, and the rest of the npm workspace on current minors; jsdom 30, jest-dom 7, concurrently 10, taze 21 (#1952)
- eslint ignores `src-tauri/`, so a local Tauri build no longer floods `lint:hooks` with parse errors from generated assets (#1952)
- Casting uses responsive SVG voice cards and searchable speaker menus that stay above surrounding panels (#1823)
- Dubbing aligns output settings, brings review status forward, and simplifies transcript and glossary editing; Launchpad files and voices reflow into responsive grids (#1823)
- Transcript segments use three readable rows for text, timing/status and voice controls, with heights that adapt to wrapping (#1823)
@@ -60,6 +66,8 @@ the frozen-backend fallback mirror it for their toolchains.
### Fixed
- Windows desktop launches no longer freeze at "Loading ML runtime (PyTorch)": the parent-liveness watchdog polls the stdin pipe instead of leaving a read pending, which deadlocked numpy's OpenBLAS initializer (#1952)
- `bun desktop-prod` and `bun desktop-fresh` find Rust and uv from a terminal opened before they were installed, as `bun desktop` already did; a missing Rust toolchain fails up front with the install steps (#1952)
- Windows desktop launches no longer freeze at "Loading ML runtime (PyTorch)": the parent-liveness watchdog polls the stdin pipe instead of leaving a read pending, which deadlocked numpy's OpenBLAS initializer (#1955)
- Voice synthesis progress no longer races to a fabricated 95%; it stays indeterminate until the active generation path reports real progress (#1907) — thanks @psiberfunk!
- Long audiobook chapters now use the same device- and text-length-aware synthesis timeout as other TTS routes (#1910) — thanks @psiberfunk!
@@ -91,6 +99,10 @@ the frozen-backend fallback mirror it for their toolchains.
- Speak tilde separators in integer, signed, and decimal ranges in English, Korean, Japanese, and Chinese (#1821) — thanks @flutterkage2k!
- Keep recording and conversion work safe while switching methods, synchronize dubbing language controls, and localize timeline controls and timing warnings (#1841)
- Audiobook is now a Write → Cast → Produce tab workspace matching the voice workspace, with the warnings/progress/result rail pinned below (#1841)
- Gallery uses a workspace header with zone tabs, hairline section dividers, theme-token cards, and borderless import rows (#1841)
- Gallery cards reset native button faces, cluster icon actions in the header so Use voice never wraps, and use a roomier grid floor (#1841)
- Gallery filters gain name search, removable iconified pills with clear-all, and dimension icons on every facet (#1841)
- Dubbing playback starts before waveform decoding, automatic cast names are readable, and transcript timestamps have more room (#1823)
- The title-bar engine button stays compact and stable while cycling labels, with engine names aligned right (#1823)
+3 -1
View File
@@ -59,7 +59,9 @@
The Voice workspace starts with three tabs: **From audio** for cloning, **By design** for creating a voice, and **Convert** for speech-to-speech conversion. Each tab displays its own workflow, with Synthesize Audio or Convert pinned below the scrolling form. The top-bar **Engines** panel combines engine selection, loaded models, and unload/flush controls; <kbd>Ctrl</kbd>/<kbd>Cmd</kbd>+<kbd>E</kbd> opens it. The searchable language picker shares Dubbings flags and language list layout, selects one output language, and retains Auto and the full cloning catalogue. Language options flow into multiple columns when space allows. Expand **Workspaces** in the sidebar to reveal navigation labels; Escape collapses it.
Dubbing places playback controls over the video with background blur and combines the waveform and timed transcript in one compact editing surface. Drag the zoomed waveform left or right to pan; click to seek. Translation language and ISO-code controls stay synchronized; Auto clears any previous language code and dialect. Transcript items group editable text, timing and status, and voice controls into three readable rows that wrap with the panel width. Output Options stays compact with the active settings shown in its summary; expand it to change output, timing, or voice matching. Transcript, glossary, and paste controls share a toolbar above the segment editor. Project details, workflow steps, and Generate/Verify/Export actions use an unfilled header.
Dubbing starts with file upload or URL import and nearby language choices. Its **Projects** panel lists previous dubs so they can be reopened by clicking anywhere on a card; action buttons operate independently. Advanced import options include captions and optional YouTube sign-in. Dubbing places playback controls over the video with background blur and combines the waveform and timed transcript in one compact editing surface. Drag the zoomed waveform left or right to pan; click to seek. Translation language and ISO-code controls stay synchronized; Auto clears any previous language code and dialect. Transcript items group editable text, timing and status, and voice controls into three readable rows that wrap with the panel width. Output Options stays compact with the active settings shown in its summary; expand it to change output, timing, or voice matching. Transcript, glossary, and paste controls share a toolbar above the segment editor. Project details, workflow steps, and Generate/Verify/Export actions use an unfilled header.
The Audiobook Script editor fills the available workspace beneath its markup toolbar; Voices and Book settings stay in their own tabs.
Output settings use aligned rows; review status appears before the collapsible transcript and glossary. Glossary terms have labelled entry fields and an explicit edit action. Launchpad arranges recent files and saved voices side by side when space allows, with responsive card grids and visible Open actions.
+9 -1
View File
@@ -349,6 +349,7 @@ async def ws_transcribe(websocket: WebSocket):
audio_chunks: list[bytes] = []
total_bytes = 0
last_audio_time = time.monotonic()
paused = False
running = True
partial_text = ""
# Track whether the client initiated the disconnect. When True the
@@ -366,7 +367,7 @@ async def ws_transcribe(websocket: WebSocket):
message as the authoritative result and skip the duplicate HTTP
POST that used to run on every dictation.
"""
nonlocal total_bytes, last_audio_time, running, client_disconnected
nonlocal total_bytes, last_audio_time, running, client_disconnected, paused
try:
while running:
msg = await websocket.receive()
@@ -397,6 +398,10 @@ async def ws_transcribe(websocket: WebSocket):
total_bytes += len(data)
last_audio_time = time.monotonic()
continue
if msg.get("text") in ("PAUSE", "RESUME"):
paused = msg["text"] == "PAUSE"
last_audio_time = time.monotonic()
continue
if _is_end_control(msg.get("text")):
# Client signals end-of-audio but stays connected for `final`.
running = False
@@ -429,6 +434,9 @@ async def ws_transcribe(websocket: WebSocket):
if not running:
break
if paused:
continue
# Check silence timeout
if time.monotonic() - last_audio_time > SILENCE_TIMEOUT_S and total_bytes > MIN_BUFFER_BYTES:
running = False
+12
View File
@@ -86,6 +86,18 @@ def list_dictation_models():
}
@router.get("/dictation/readiness", dependencies=[Depends(require_local)])
def dictation_readiness(model_id: str | None = None) -> dict:
"""Check capture's model selection without loading or downloading weights."""
from services.asr_backend import asr_model_missing_error
missing = asr_model_missing_error(
purpose="dictation",
sherpa_model_id=model_id or _read_prefs()["model_id"],
)
return {"ready": missing is None, "missing": missing}
@router.get("/dictation/prefs", dependencies=[Depends(require_local)])
def get_dictation_prefs():
return _read_prefs()
+27 -6
View File
@@ -1801,9 +1801,7 @@ class SherpaDictationBackend(ASRBackend):
def __init__(self, model_id: str | None = None):
from services import sherpa_dictation as _sd
mid = model_id or os.environ.get(
"OMNIVOICE_SHERPA_ASR_MODEL", _sd.DEFAULT_MODEL_ID
)
mid = model_id or sherpa_engine_model_id()
spec = _sd.get_spec(mid)
if spec is None:
raise ValueError(
@@ -3047,6 +3045,31 @@ def get_sherpa_dictation_backend(model_id: str) -> "SherpaDictationBackend":
return backend
def sherpa_engine_model_id() -> str:
"""The sherpa model the ``sherpa-onnx-asr`` engine loads when nothing pins
one explicitly: env var (power-user pin) → the dictation model the user
picked in Settings / the Engines menu → the catalogue default.
Unlike :func:`dictation_model_id` this ignores ``dictation.enabled`` — a
user who turned the hotkey off but chose the Sherpa engine for dub/batch
transcription still means *this* model — and never returns None: the
engine needs *some* model to construct. A demoted model (decoded nothing
on this host) falls through to the default rather than being re-picked.
"""
from services import sherpa_dictation as _sd
explicit = os.environ.get("OMNIVOICE_SHERPA_ASR_MODEL")
if explicit:
return explicit
try:
from core import prefs
mid = prefs.get("dictation.model_id")
except Exception: # noqa: BLE001 — prefs store unavailable → default
return _sd.DEFAULT_MODEL_ID
if _sd.is_sherpa_model(mid) and not _sd.is_demoted(mid):
return _sd.get_spec(mid).id
return _sd.DEFAULT_MODEL_ID
def dictation_model_id() -> str | None:
"""The selected sherpa dictation model id, or None when dictation is off /
no sherpa model is chosen. Env var wins (power-user pin), then prefs."""
@@ -3321,9 +3344,7 @@ def _offline_asr_repo(backend_id: str | None = None) -> str | None:
# Unknown/none → fail open.
try:
from services import sherpa_dictation as _sd
spec = _sd.get_spec(
os.environ.get("OMNIVOICE_SHERPA_ASR_MODEL", _sd.DEFAULT_MODEL_ID)
)
spec = _sd.get_spec(sherpa_engine_model_id())
return spec.repo_id if spec is not None else None
except Exception: # noqa: BLE001 — preflight must stay best-effort
return None
+149 -133
View File
@@ -5,9 +5,9 @@
"": {
"name": "omnivoice-studio-monorepo",
"devDependencies": {
"concurrently": "^9.2.4",
"concurrently": "^10",
"playwright": "^1.63.0",
"taze": "^19.17.2",
"taze": "^21",
"turbo": "^2.10.12",
"typescript": "^6.0.3",
"wait-on": "^9.1.0",
@@ -30,29 +30,29 @@
"@radix-ui/react-toggle": "^1.1.18",
"@radix-ui/react-toggle-group": "^1.1.19",
"@radix-ui/react-tooltip": "^1.2.16",
"@scalar/api-reference-react": "^0.9.63",
"@scalar/api-reference-react": "^0.9.67",
"@tailwindcss/vite": "^4.3.3",
"@tanstack/react-query": "^5.101.4",
"@tanstack/react-query": "^5.102.8",
"@tanstack/react-table": "^8.21.3",
"@tanstack/react-virtual": "^3.14.9",
"@tauri-apps/plugin-dialog": "^2.7.2",
"@tauri-apps/plugin-opener": "^2.5.4",
"@tanstack/react-virtual": "^3.14.11",
"@tauri-apps/plugin-dialog": "^2.7.3",
"@tauri-apps/plugin-opener": "^2.5.5",
"@tauri-apps/plugin-process": "^2.3.1",
"@tauri-apps/plugin-updater": "^2.10.1",
"@tauri-apps/plugin-updater": "^2.11.0",
"@tauri-apps/plugin-window-state": "^2.4.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"country-flag-icons": "^1.6.20",
"i18next": "^26.3.6",
"i18next": "^26.4.2",
"i18next-browser-languagedetector": "^8.2.1",
"lucide-react": "^1.31.0",
"posthog-js": "^1.417.0",
"lucide-react": "^1.43.0",
"posthog-js": "^1.428.11",
"qrcode": "^1.5.4",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"react": "^19.3.0",
"react-dom": "^19.3.0",
"react-hot-toast": "^2.6.0",
"react-i18next": "^17.0.11",
"react-window": "^2.3.0",
"react-i18next": "^17.0.13",
"react-window": "^2.3.1",
"tailwind-merge": "^3.6.0",
"tailwindcss": "^4.3.3",
"tw-animate-css": "^1.4.0",
@@ -60,24 +60,24 @@
"zustand": "^5.0.15",
},
"devDependencies": {
"@playwright/test": "^1.62.1",
"@playwright/test": "1.63.0",
"@tauri-apps/api": "^2.11.1",
"@tauri-apps/cli": "^2.11.4",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/jest-dom": "^7.0.1",
"@testing-library/react": "^16.3.3",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.4",
"@vitejs/plugin-react": "^6.0.5",
"eslint": "^10.8.1",
"@types/react-dom": "^19.2.7",
"@vitejs/plugin-react": "^6.1.1",
"eslint": "^10.10.0",
"eslint-plugin-react-hooks": "^7.1.1",
"globals": "^17.11.0",
"jsdom": "^29.1.1",
"knip": "^6.32.2",
"globals": "^17.12.0",
"jsdom": "^30.0.1",
"knip": "^6.35.1",
"oxfmt": "^0.57.0",
"oxlint": "1.71.0",
"playwright-core": "1.62.1",
"playwright-core": "1.63.0",
"typescript": "^6.0.3",
"vite": "^8.2.1",
"vite": "^8.2.2",
"vitest": "4.1.11",
},
},
@@ -95,13 +95,9 @@
"@antfu/ni": ["@antfu/ni@30.5.0", "", { "dependencies": { "fzf": "^0.5.2", "package-manager-detector": "^1.8.0", "tinyexec": "^1.3.0", "tinyglobby": "^0.2.17" }, "bin": { "na": "bin/na.mjs", "nd": "bin/nd.mjs", "ni": "bin/ni.mjs", "nr": "bin/nr.mjs", "nci": "bin/nci.mjs", "nlx": "bin/nlx.mjs", "nun": "bin/nun.mjs", "nup": "bin/nup.mjs" } }, "sha512-VwQoM9qF1dzDrye55b1qIBeLr4zQ1a5wZQMPCe496HTiquViBZqxtNBaq98WX3ze5kC9Yl7gKSU4W2vtLztxYw=="],
"@asamuzakjp/css-color": ["@asamuzakjp/css-color@5.1.11", "", { "dependencies": { "@asamuzakjp/generational-cache": "^1.0.1", "@csstools/css-calc": "^3.2.0", "@csstools/css-color-parser": "^4.1.0", "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg=="],
"@asamuzakjp/css-color": ["@asamuzakjp/css-color@6.0.7", "", { "dependencies": { "@csstools/css-calc": "^3.3.0", "@csstools/css-color-parser": "^4.1.10", "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0", "lru-cache": "^11.5.2" } }, "sha512-vC/bk1Lz7Tn/EfU9/apOTBk80/8dyGyWMowPoV1tJ52muDGsDqt2HPT2klrFUiY60MQmQv9q8yIht15JnBgDGw=="],
"@asamuzakjp/dom-selector": ["@asamuzakjp/dom-selector@7.1.1", "", { "dependencies": { "@asamuzakjp/generational-cache": "^1.0.1", "@asamuzakjp/nwsapi": "^2.3.9", "bidi-js": "^1.0.3", "css-tree": "^3.2.1", "is-potential-custom-element-name": "^1.0.1" } }, "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ=="],
"@asamuzakjp/generational-cache": ["@asamuzakjp/generational-cache@1.0.1", "", {}, "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg=="],
"@asamuzakjp/nwsapi": ["@asamuzakjp/nwsapi@2.3.9", "", {}, "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q=="],
"@asamuzakjp/dom-selector": ["@asamuzakjp/dom-selector@8.3.2", "", { "dependencies": { "bidi-js": "^1.0.3", "css-tree": "^3.2.1", "is-potential-custom-element-name": "^1.0.1", "lru-cache": "^11.5.2" } }, "sha512-93Z1N+BQNXysodoicpOIyNh2drHfz/CTf9nnT0FEx72GJcIiwgydD7tGAr78j41LsYn3hlRn+LdGPuBLn1Bl8Q=="],
"@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="],
@@ -139,6 +135,10 @@
"@bramus/specificity": ["@bramus/specificity@2.4.2", "", { "dependencies": { "css-tree": "^3.0.0" }, "bin": { "specificity": "bin/cli.js" } }, "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw=="],
"@cacheable/memory": ["@cacheable/memory@2.2.0", "", { "dependencies": { "@cacheable/utils": "^2.5.0", "@keyv/bigmap": "^1.3.1", "hookified": "^1.15.1", "keyv": "^5.6.0" } }, "sha512-CTLKqLItRCEixEAewD3/j9DB3/o96gpTPD4eJ1v+DGOlxZRZncRQkGYqqnAGCscYd6RNeXfGeiuCphsPtqyIfQ=="],
"@cacheable/utils": ["@cacheable/utils@2.5.0", "", { "dependencies": { "hashery": "^1.5.1", "keyv": "^5.6.0" } }, "sha512-buipgOVDkkPXNR5+xBpDw7Zk2n1EvU7qBJCNUcL7rhQ//kfpOXPAvQ511Os0vpLYJ1pZnvudNytkQt2hst3wqA=="],
"@codemirror/autocomplete": ["@codemirror/autocomplete@6.20.3", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.17.0", "@lezer/common": "^1.0.0" } }, "sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g=="],
"@codemirror/commands": ["@codemirror/commands@6.11.0", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.7.0", "@codemirror/view": "^6.27.0", "@lezer/common": "^1.1.0" } }, "sha512-/K4Rl5BN0OtTiPWmJCdqODu38XnDMsDxKY5rgrPnCkutPTJf2wVbkoixLfealF5Kwse/s8P8M5jAiURiwSwnFA=="],
@@ -255,6 +255,10 @@
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
"@keyv/bigmap": ["@keyv/bigmap@1.3.1", "", { "dependencies": { "hashery": "^1.4.0", "hookified": "^1.15.0" }, "peerDependencies": { "keyv": "^5.6.0" } }, "sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ=="],
"@keyv/serialize": ["@keyv/serialize@1.1.1", "", {}, "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA=="],
"@lezer/common": ["@lezer/common@1.5.2", "", {}, "sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ=="],
"@lezer/css": ["@lezer/css@1.3.6", "", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.3.0" } }, "sha512-YJE78Wcg+zX8f10hiHWQ4Az48Qr/c13eId0VtRQYLBpxHDmDeSrXIlkbl+fJGW42rWC/uoUco9mhBZeVWP/A1g=="],
@@ -279,45 +283,45 @@
"@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
"@oxc-parser/binding-android-arm-eabi": ["@oxc-parser/binding-android-arm-eabi@0.143.0", "", { "os": "android", "cpu": "arm" }, "sha512-n9uozULWflPqBtdmI8lAabLqGKNgLVNN0ZH8HfgCwpKGNtzRzauB76jTiW/3YLkcA7N1zskpi9GdVnZuu1SAvg=="],
"@oxc-parser/binding-android-arm-eabi": ["@oxc-parser/binding-android-arm-eabi@0.148.0", "", { "os": "android", "cpu": "arm" }, "sha512-pHASv9g5pASxb7akHERZNSkrEqPhFaUix98o7d9hbTpolnnFWl7UiRrcMhCsV1+iVO4/cJwKsbKRJTFNs2tdBQ=="],
"@oxc-parser/binding-android-arm64": ["@oxc-parser/binding-android-arm64@0.143.0", "", { "os": "android", "cpu": "arm64" }, "sha512-9BbdjHETk6O3zH/DDid9IgBtF0GlpLabNKN231uraXpRDSfY+iiZxTP5bk1Z63GBownVdhdINFIeddmMz4MzpQ=="],
"@oxc-parser/binding-android-arm64": ["@oxc-parser/binding-android-arm64@0.148.0", "", { "os": "android", "cpu": "arm64" }, "sha512-sg/6Ez0KdAygsu0POELux9wN1Po2CP93WY8eNl4DBKIGprsd4QSHBXOb471Pu9i2OCD5sLkISSb2agZEhVn2Zw=="],
"@oxc-parser/binding-darwin-arm64": ["@oxc-parser/binding-darwin-arm64@0.143.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gh+6ecoHUy4/sUcolBl/1qPXKBbYNxFY0Pk0ujgQvINTMSftJY7o4yb8gOkDJPeZeB8+a+u7xTe6umoP8N5HFA=="],
"@oxc-parser/binding-darwin-arm64": ["@oxc-parser/binding-darwin-arm64@0.148.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-yiSJmzGUvCUaJT8X3j40gVcX+ckuHQMuiOtF8DvzTs5+JtB/7XuHFPp4M+vv5u+HlBtDUd4Ks5pyHpWz8mfnkg=="],
"@oxc-parser/binding-darwin-x64": ["@oxc-parser/binding-darwin-x64@0.143.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-qd1hl2d+lXgHv/VQ/M9qm8TrMC5T4RqDBwtOnl+1D0QMjwcz+8AaB4JSg8STgeag0GP6a6L74XEGAsrTSJWNzQ=="],
"@oxc-parser/binding-darwin-x64": ["@oxc-parser/binding-darwin-x64@0.148.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-6ZeklaamrMy4H2JmhvcJg6iip59tYILtuLaILxyAHT3l5FDxnI5ihVievAft5ZmAbqtlWHErOi1OpJK8gy1wcA=="],
"@oxc-parser/binding-freebsd-x64": ["@oxc-parser/binding-freebsd-x64@0.143.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-M5XXcNa7aOqLPKTR41msfghKu2yQ4xWvCm11/gwU0JzOzHNk5sgW//rVEjJ+LO48+VDAMzXTSzurUVxIDKwozw=="],
"@oxc-parser/binding-freebsd-x64": ["@oxc-parser/binding-freebsd-x64@0.148.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-vFsPx+a/qFECPnz/H8nC6x6MDvnWscLTCo/5muojEF54ERUq1kdgbvnWo95YnkhjF9sTIcG/uDxQBh1gffaufQ=="],
"@oxc-parser/binding-linux-arm-gnueabihf": ["@oxc-parser/binding-linux-arm-gnueabihf@0.143.0", "", { "os": "linux", "cpu": "arm" }, "sha512-T/GXusuOkPNQhCQCSBbcU/N8j0rAypuDBl1IyFK+lyYT594XsVz80clPC/OtbSSpBGyJxj8uYEfctxVuxVYoww=="],
"@oxc-parser/binding-linux-arm-gnueabihf": ["@oxc-parser/binding-linux-arm-gnueabihf@0.148.0", "", { "os": "linux", "cpu": "arm" }, "sha512-eOr3M+6iGbbxNL4PSS0VtsyQ2eOUxSBh00BqO22SbolDimPSYsBuLr/LCrZBkiqW2BoabhR6V4R8jrRAay7hjg=="],
"@oxc-parser/binding-linux-arm-musleabihf": ["@oxc-parser/binding-linux-arm-musleabihf@0.143.0", "", { "os": "linux", "cpu": "arm" }, "sha512-oKu4RcBlXSqo3OC62dp6YTnQaZIurNDpCX3BnAM3+bJxt7s8J2TJKMnC0UYer1qhlRaDCg6wkTaTw+2IlsZ12w=="],
"@oxc-parser/binding-linux-arm-musleabihf": ["@oxc-parser/binding-linux-arm-musleabihf@0.148.0", "", { "os": "linux", "cpu": "arm" }, "sha512-58ZKDw0mQRbCNfrd2IDyV4o8T7enzGERJn41BH2tjrZVGyiKiFzcfDicuB7Zcpb/1xIOrObovr8Dja6lZi8dLw=="],
"@oxc-parser/binding-linux-arm64-gnu": ["@oxc-parser/binding-linux-arm64-gnu@0.143.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-WJBbD186AZmMGaSIhlktC+rPl8L3peCTXAh88Ih9uEvK0en2mPojGyCGYiL6mHtV1RPV3JyfJW5t6n5hh0lXhA=="],
"@oxc-parser/binding-linux-arm64-gnu": ["@oxc-parser/binding-linux-arm64-gnu@0.148.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-Fnu95O4eZ5i++GPvIzBEZ8y4ddTLR+D9paYa8JRaRk6ZK7nHQiWP5xtrhcPQsXqgat1d7sU/d5rbbI0p1FTHSQ=="],
"@oxc-parser/binding-linux-arm64-musl": ["@oxc-parser/binding-linux-arm64-musl@0.143.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-t1AcYOwEzgceadT4v5e+vaCCb0AncCA3v5AyzfBAz/tMq11qzVccXKzNHtkWdjBsgvTKwRkaUF3QvT4kot8vcQ=="],
"@oxc-parser/binding-linux-arm64-musl": ["@oxc-parser/binding-linux-arm64-musl@0.148.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-3CQy/BMdx7N7H3qrcPxUL+a2CwUZodUcf6oq8iJuNZ9C6Ol1aq3mcWzsgySJ7CHFLvpX21ZDPp1r1X0QLbu/AQ=="],
"@oxc-parser/binding-linux-ppc64-gnu": ["@oxc-parser/binding-linux-ppc64-gnu@0.143.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-RsnO/NoD8376LMJq8JS8TwI0ieNaFRTuNe2GVJntQg6gwZNMENZsEbknHdVwjpOmxdGLGodcwaGSbAeRr5Bgjw=="],
"@oxc-parser/binding-linux-ppc64-gnu": ["@oxc-parser/binding-linux-ppc64-gnu@0.148.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9LkaYvfiF8hMOw900csAvkf1oxE8XlmMeGowu5BcastSSwV8mKvKRMNU7HsV+ycyj1dQD8pX5qgOw8ja6SJacg=="],
"@oxc-parser/binding-linux-riscv64-gnu": ["@oxc-parser/binding-linux-riscv64-gnu@0.143.0", "", { "os": "linux", "cpu": "none" }, "sha512-48fSVfR9TZi5CASZFyv0VC6z6BCoeihFsX031mAD/oSH7d9PYsPgIqza7d9mjP7Z2KTEpTFyH6SIu0Ui6R1vdg=="],
"@oxc-parser/binding-linux-riscv64-gnu": ["@oxc-parser/binding-linux-riscv64-gnu@0.148.0", "", { "os": "linux", "cpu": "none" }, "sha512-2GBiM9h26dR4WJfhoMvnFMnFLf7m/kYs4UMqjvrOfQG4BV1nuTJDH22Zc2MQr3INZF7nSKYQ6xlhD3hQ7A6gug=="],
"@oxc-parser/binding-linux-riscv64-musl": ["@oxc-parser/binding-linux-riscv64-musl@0.143.0", "", { "os": "linux", "cpu": "none" }, "sha512-T8CpdD+SfE01DnIOD4HpVxu0ZJOfMJ/VhCvikKfaXAxkZ+9veyLM/D2hpi7Y2hFUyPmVQO3FNZHmYzV/WlVR4g=="],
"@oxc-parser/binding-linux-riscv64-musl": ["@oxc-parser/binding-linux-riscv64-musl@0.148.0", "", { "os": "linux", "cpu": "none" }, "sha512-uPqZexvKJmEgq4mAu36qe2xTfXZE7oyik1R7KtZ5tl8qKlq1U1fIqTFRUEBZqRGvforoTrGIpatRzcoPKO66RA=="],
"@oxc-parser/binding-linux-s390x-gnu": ["@oxc-parser/binding-linux-s390x-gnu@0.143.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-QLdeMsCcacenPEFsfxnBUDF1y6opyz5+fmOz9bfD5Y7fiGCMupUCuB3KTPQhNwshIG1P9fPqar9MHxuBDd4bwQ=="],
"@oxc-parser/binding-linux-s390x-gnu": ["@oxc-parser/binding-linux-s390x-gnu@0.148.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-9oUHvnTbp7ZraFsTC8PN6XhdhPSSxZumYvixWl7Smi353gEULvK6yV0sXNVrdFMHQeaDKFCi8TgDhNK7/A+Y+Q=="],
"@oxc-parser/binding-linux-x64-gnu": ["@oxc-parser/binding-linux-x64-gnu@0.143.0", "", { "os": "linux", "cpu": "x64" }, "sha512-659ujfqLy6k7cuH3sbzhd8b+ztSq+i6E2E9pG78Q0BmHjAExfGIdgc8cGgMdwAozDXeZFHkJ+LXYJdWsaGdgyw=="],
"@oxc-parser/binding-linux-x64-gnu": ["@oxc-parser/binding-linux-x64-gnu@0.148.0", "", { "os": "linux", "cpu": "x64" }, "sha512-2qhDSJwKzbSZzF7lDqqk8sr/yXsmwr3PeUa4/nazIF+zFAYz1gVPEfC34GQtGxzJUUmklaYAL63368LEfrMeyw=="],
"@oxc-parser/binding-linux-x64-musl": ["@oxc-parser/binding-linux-x64-musl@0.143.0", "", { "os": "linux", "cpu": "x64" }, "sha512-/Mw/9j4TfZcnKphPrzOE6t4MMknXadcAAuVUlDRTF/ETWB5xOgQvOJV2Mh9We/bWxZdoxaGAdc+hy4GuYwQ2yQ=="],
"@oxc-parser/binding-linux-x64-musl": ["@oxc-parser/binding-linux-x64-musl@0.148.0", "", { "os": "linux", "cpu": "x64" }, "sha512-qQoPDZUFV0bh9xA09XydmkjMBpgc1ukJuhMvzQ9QeVmFaHTS9W5TE5CoLmSl3QQyUP9OuHO3x/WPZTIIZPWR3Q=="],
"@oxc-parser/binding-openharmony-arm64": ["@oxc-parser/binding-openharmony-arm64@0.143.0", "", { "os": "none", "cpu": "arm64" }, "sha512-8rIKWR2BFuifbIK/1XB9wTaSdtuJ25dlE7ZQYDnEwj/2xH2vHsxnvIjHT3ZjSVuLLwGGlSslIG/fbOJ8TV8rTw=="],
"@oxc-parser/binding-openharmony-arm64": ["@oxc-parser/binding-openharmony-arm64@0.148.0", "", { "os": "none", "cpu": "arm64" }, "sha512-1UGbaQWEXUCLqAmaR5kwRDjx/R4S5LQKZkM9CHmaHkuKhriOF32aRLfS0jCRNE2yGQJLMEA1z9UucbBVqjXnDw=="],
"@oxc-parser/binding-win32-arm64-msvc": ["@oxc-parser/binding-win32-arm64-msvc@0.143.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-5U9kQYMfRRI6Zq7KDxgbIP0RMnKrfn3gLepRMgJuRkPSUALTiRCk9d/uyhb4lGDjUdzwK7mBkKqhLgzBPCmLpQ=="],
"@oxc-parser/binding-win32-arm64-msvc": ["@oxc-parser/binding-win32-arm64-msvc@0.148.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-pWKdzRDNG2+NK4h/V6U/CYERcfYD6u28h5IB/VJVsrZaD3muvE58tUj22lieL5vLZ+XFi1GPv9YXckZbJZ9BLA=="],
"@oxc-parser/binding-win32-ia32-msvc": ["@oxc-parser/binding-win32-ia32-msvc@0.143.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-25P7AaHk4R88Yv2XH4gToDVmh0cOu+bEURQU10CRrmvgabfRArSGAP5osmwUKeSUHj0VS50upbpbRWWW/m7mHA=="],
"@oxc-parser/binding-win32-ia32-msvc": ["@oxc-parser/binding-win32-ia32-msvc@0.148.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-i3p4x+mvwtjcE1J5HM6V7ggsbXiznExN/4MkNyOy3dfXrVV3bnkSfmZxvo6/84qCVX4ShkpNE1SKt9biIF31GQ=="],
"@oxc-parser/binding-win32-x64-msvc": ["@oxc-parser/binding-win32-x64-msvc@0.143.0", "", { "os": "win32", "cpu": "x64" }, "sha512-ORMh3JE1s6V7ySicdRK7vgaDQnn5o+UHg9ct989PlWHbel8O9ARrmWXM6kZjrBMtNucxNayQ8g69G0VfWzhANw=="],
"@oxc-parser/binding-win32-x64-msvc": ["@oxc-parser/binding-win32-x64-msvc@0.148.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Ye6vB7VQulghWYkYkECOBYFRVEizz4XyRTUAv+t8BuyurhKU7uD0P9eowL+mKG5Mf8MSYx+DI3Cm8SKZvYG7bQ=="],
"@oxc-project/types": ["@oxc-project/types@0.143.0", "", {}, "sha512-u6JZdLBTLotrNC9Vd6vPssINdzcCzleKAH6EJKImQb7GtYvX5keN2dxkoK44stCc4tffE6QQRtZTXVSzsLUlWA=="],
"@oxc-project/types": ["@oxc-project/types@0.148.0", "", {}, "sha512-Nm4s/jB+4FpFsPhWGEC4h7rzksesmtnMXomo6rCMcg/b8zLQuOziRgkCS1fxDCXOlJB/6Q8oABOZ/OP6RIPj9A=="],
"@oxc-resolver/binding-android-arm-eabi": ["@oxc-resolver/binding-android-arm-eabi@11.24.2", "", { "os": "android", "cpu": "arm" }, "sha512-y09e0L0SRI2OA2tUIrjBgoV3eH5hvUKXNkJqXmNo5V2WxIjyC7I7aJfRLMEVpA8yi95f90gFDvO0VMgrDw+vwA=="],
@@ -435,13 +439,13 @@
"@phosphor-icons/core": ["@phosphor-icons/core@2.1.1", "", {}, "sha512-v4ARvrip4qBCImOE5rmPUylOEK4iiED9ZyKjcvzuezqMaiRASCHKcRIuvvxL/twvLpkfnEODCOJp5dM4eZilxQ=="],
"@playwright/test": ["@playwright/test@1.62.1", "", { "dependencies": { "playwright": "1.62.1" }, "bin": { "playwright": "cli.js" } }, "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ=="],
"@playwright/test": ["@playwright/test@1.63.0", "", { "dependencies": { "playwright": "1.63.0" }, "bin": { "playwright": "cli.js" } }, "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ=="],
"@posthog/browser-common": ["@posthog/browser-common@0.5.2", "", { "dependencies": { "@posthog/core": "^1.48.11", "@posthog/types": "^1.405.3" } }, "sha512-8GvfEshFdeKIccuy3kpp6mDBxawQtRamMYRCwzy1r1ixLKQVLAGs3afhOf6r75yp59ZQBi5mtXQgUJ2Jz8eHuw=="],
"@posthog/browser-common": ["@posthog/browser-common@0.8.2", "", { "dependencies": { "@posthog/core": "^1.51.0", "@posthog/types": "^1.409.1" } }, "sha512-8g7+ijrx8bfWmpDjmP07e0ZmdRnJoFqZ6PCcMQvfBTUrr422GRXvQWpQn7yD028zIJHIIn/rUTipKgUC5UkWpw=="],
"@posthog/core": ["@posthog/core@1.50.5", "", { "dependencies": { "@posthog/types": "^1.409.0" } }, "sha512-afEchuShDaVIoxAIj76kDZQ1DhfesDmgfVp+mtTzsA3wlc8DF5uoz8YjuTjnxOicWkpP5HCDqYidK/1kT125Cg=="],
"@posthog/core": ["@posthog/core@1.52.0", "", { "dependencies": { "@posthog/types": "^1.409.4" } }, "sha512-pvbdeRRpizktmo8MXZQxjAI4aFfNajwkzxbm72wKV9n2wWcnQWa4m+AqTI2zmmCSsYk7F666rRUdvYqZLjjhWg=="],
"@posthog/types": ["@posthog/types@1.409.0", "", {}, "sha512-239umoaZVb2GBaXeEyJpwFvjhrrChJH8NHCwiao23EBSu3NA6EN0MTMoaHSmMEf4yjiXEFFoYJA6FjJAXF5HGA=="],
"@posthog/types": ["@posthog/types@1.409.4", "", {}, "sha512-X8egIUjMe1uItlxpbLfWypS2SWLOF+5Vzvne/44AsaHa/fYbtSy8itJNk5iQS1MZEwVYAEYUsQY1vuN61jpxTg=="],
"@quansync/fs": ["@quansync/fs@1.0.0", "", { "dependencies": { "quansync": "^1.0.0" } }, "sha512-4TJ3DFtlf1L5LDMaM6CanJ/0lckGNtJcMjQ1NAV6zDmA0tEHKZtxNKin8EgPaVX1YzljbxckyT2tJrpQKAtngQ=="],
@@ -553,55 +557,55 @@
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="],
"@scalar/agent-chat": ["@scalar/agent-chat@0.12.28", "", { "dependencies": { "@ai-sdk/vue": "3.0.33", "@scalar/api-client": "3.16.3", "@scalar/components": "0.28.1", "@scalar/helpers": "0.11.1", "@scalar/icons": "0.7.6", "@scalar/json-magic": "0.13.2", "@scalar/openapi-types": "0.9.5", "@scalar/schemas": "0.8.3", "@scalar/themes": "0.17.3", "@scalar/types": "0.18.2", "@scalar/use-toasts": "0.10.5", "@scalar/validation": "0.6.3", "@scalar/workspace-store": "0.58.1", "@vueuse/core": "13.9.0", "ai": "6.0.33", "js-base64": "^3.9.2", "neverpanic": "0.0.8", "truncate-json": "3.0.1", "vue": "^3.5.40" } }, "sha512-N1ZEIKHrOhbB6mSUuaeMbNEK8G0lhXu8aHj8RqPMjKAkm0IbZVGCEkFgRCzaqDMcdsXKqQKygzx2a5pV5gX6Ow=="],
"@scalar/agent-chat": ["@scalar/agent-chat@0.12.30", "", { "dependencies": { "@ai-sdk/vue": "3.0.33", "@scalar/api-client": "3.18.0", "@scalar/components": "0.29.1", "@scalar/helpers": "0.11.3", "@scalar/icons": "0.7.6", "@scalar/json-magic": "0.13.4", "@scalar/openapi-types": "0.9.5", "@scalar/schemas": "0.9.0", "@scalar/themes": "0.17.4", "@scalar/types": "0.19.0", "@scalar/use-toasts": "0.10.5", "@scalar/validation": "0.6.3", "@scalar/workspace-store": "0.60.0", "@vueuse/core": "13.9.0", "ai": "6.0.33", "js-base64": "^3.9.2", "neverpanic": "0.0.8", "truncate-json": "3.0.1", "vue": "^3.5.40" } }, "sha512-1+09CTY/eVLg6ygdrrKyNkhWXnbHcVGrr4Jq4HYr7XogrkIsTCuHCBcpPbYRWOFJ2zRpToPFu5M3R7eLE810zA=="],
"@scalar/api-client": ["@scalar/api-client@3.16.3", "", { "dependencies": { "@headlessui/tailwindcss": "^0.2.2", "@headlessui/vue": "1.7.23", "@scalar/blocks": "0.1.14", "@scalar/components": "0.28.1", "@scalar/helpers": "0.11.1", "@scalar/icons": "0.7.6", "@scalar/oas-utils": "0.19.14", "@scalar/openapi-types": "0.9.5", "@scalar/sidebar": "0.10.1", "@scalar/snippetz": "0.9.28", "@scalar/themes": "0.17.3", "@scalar/typebox": "^0.1.3", "@scalar/types": "0.18.2", "@scalar/use-codemirror": "0.14.15", "@scalar/use-hooks": "0.4.10", "@scalar/use-toasts": "0.10.5", "@scalar/workspace-store": "0.58.1", "@vueuse/core": "13.9.0", "@vueuse/integrations": "13.9.0", "focus-trap": "^7.8.0", "fuse.js": "^7.5.0", "js-base64": "^3.9.2", "jsonc-parser": "3.3.1", "nanoid": "^5.1.6", "pretty-ms": "^9.3.0", "radix-vue": "^1.9.17", "set-cookie-parser": "3.1.0", "vue": "^3.5.40", "yaml": "^2.9.0", "zod": "^4.3.5" } }, "sha512-4F0aZtdnCWZN5EvJQTAlXPzVetniu9pQqPeukck/AUNtvsk59CdNHjd8DjAB3+SS3wv3gfNj0C+obhzjUbCNZg=="],
"@scalar/api-client": ["@scalar/api-client@3.18.0", "", { "dependencies": { "@headlessui/tailwindcss": "^0.2.2", "@headlessui/vue": "1.7.23", "@scalar/blocks": "0.1.16", "@scalar/components": "0.29.1", "@scalar/helpers": "0.11.3", "@scalar/icons": "0.7.6", "@scalar/oas-utils": "0.19.16", "@scalar/openapi-types": "0.9.5", "@scalar/sidebar": "0.11.1", "@scalar/snippetz": "0.9.30", "@scalar/themes": "0.17.4", "@scalar/typebox": "^0.1.3", "@scalar/types": "0.19.0", "@scalar/use-codemirror": "0.14.15", "@scalar/use-hooks": "0.4.11", "@scalar/use-toasts": "0.10.5", "@scalar/workspace-store": "0.60.0", "@vueuse/core": "13.9.0", "@vueuse/integrations": "13.9.0", "focus-trap": "^7.8.0", "fuse.js": "^7.5.0", "js-base64": "^3.9.2", "jsonc-parser": "3.3.1", "nanoid": "^5.1.6", "pretty-ms": "^9.3.0", "radix-vue": "^1.9.17", "set-cookie-parser": "3.1.0", "vue": "^3.5.40", "yaml": "^2.9.0", "zod": "^4.4.3" } }, "sha512-FlseC6xWfx0ganh4s0IAOelJ5kF+aJ07BJfjUriwKDdK8Y4oAFkRlneKQNoT/S+4mrNCXdQ/T5VPEjYFlx4Zxw=="],
"@scalar/api-reference": ["@scalar/api-reference@1.66.1", "", { "dependencies": { "@headlessui/vue": "1.7.23", "@scalar/agent-chat": "0.12.28", "@scalar/api-client": "3.16.3", "@scalar/blocks": "0.1.14", "@scalar/code-highlight": "0.4.5", "@scalar/components": "0.28.1", "@scalar/helpers": "0.11.1", "@scalar/icons": "0.7.6", "@scalar/oas-utils": "0.19.14", "@scalar/schemas": "0.8.3", "@scalar/sidebar": "0.10.1", "@scalar/snippetz": "0.9.28", "@scalar/themes": "0.17.3", "@scalar/types": "0.18.2", "@scalar/use-hooks": "0.4.10", "@scalar/use-toasts": "0.10.5", "@scalar/validation": "0.6.3", "@scalar/workspace-store": "0.58.1", "@unhead/vue": "^2.1.4", "@vueuse/core": "13.9.0", "fuse.js": "^7.5.0", "microdiff": "^1.5.0", "nanoid": "^5.1.6", "vue": "^3.5.40", "yaml": "^2.9.0" } }, "sha512-+iHSJX8HPUyDGinNiLRV8qgFb+fbNwAjGDWzl8Wg/VNEcbmA40t1ZuL/WoWNqphI2QaPfjiCLtmsIhanWAed3w=="],
"@scalar/api-reference": ["@scalar/api-reference@1.68.0", "", { "dependencies": { "@headlessui/vue": "1.7.23", "@scalar/agent-chat": "0.12.30", "@scalar/api-client": "3.18.0", "@scalar/blocks": "0.1.16", "@scalar/code-highlight": "0.4.5", "@scalar/components": "0.29.1", "@scalar/helpers": "0.11.3", "@scalar/icons": "0.7.6", "@scalar/oas-utils": "0.19.16", "@scalar/schemas": "0.9.0", "@scalar/sidebar": "0.11.1", "@scalar/snippetz": "0.9.30", "@scalar/themes": "0.17.4", "@scalar/types": "0.19.0", "@scalar/use-hooks": "0.4.11", "@scalar/use-toasts": "0.10.5", "@scalar/validation": "0.6.3", "@scalar/workspace-store": "0.60.0", "@unhead/vue": "^2.1.4", "@vueuse/core": "13.9.0", "fuse.js": "^7.5.0", "microdiff": "^1.5.0", "nanoid": "^5.1.6", "vue": "^3.5.40", "yaml": "^2.9.0" } }, "sha512-rY43w3REwCxp+rDDx/0CncZxmlzISnGTK9zZ8moq0Ij2vRHhLQCJ0/BXut9pBAupVrOZF7MoqKXcG+gISgTu5g=="],
"@scalar/api-reference-react": ["@scalar/api-reference-react@0.9.65", "", { "dependencies": { "@scalar/api-reference": "1.66.1", "@scalar/types": "0.18.2" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0" } }, "sha512-0kBuLtyZcvLcMIhuLGr3WHalytf5eqGPvT5soMSP0tdHZkIoTkzeOHCQNiDCiuq7Jct3a2IqsRrDrokukf9N2A=="],
"@scalar/api-reference-react": ["@scalar/api-reference-react@0.9.67", "", { "dependencies": { "@scalar/api-reference": "1.68.0", "@scalar/types": "0.19.0" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0" } }, "sha512-0L+/XvPwbNbwhMaSJEpGamDcqUaYyqO7LyhQdjOSA/VnsykrljPN+G4mr23Ocr9aZFl5FhzWv88XCdy9WTsHHA=="],
"@scalar/asyncapi-upgrader": ["@scalar/asyncapi-upgrader@0.1.7", "", { "dependencies": { "@scalar/helpers": "0.11.1" } }, "sha512-RU3CrNV77hWiZ9Ik0GJHDf/bEg8C0iF9Rg5b6GDJz3F9Y7ZyBJcgqq++do9GUzPTKzftfOORfQ9180s09XQp7Q=="],
"@scalar/asyncapi-upgrader": ["@scalar/asyncapi-upgrader@0.1.9", "", { "dependencies": { "@scalar/helpers": "0.11.3" } }, "sha512-+kK4dp1J8GvOcTU2OCMFwj62VMP3Y0lppjQ2mdfYsczhTTP9saf12vkRzuHQ+ILkLJJXo98km5zj7pEVMdOSPA=="],
"@scalar/blocks": ["@scalar/blocks@0.1.14", "", { "dependencies": { "@scalar/components": "0.28.1", "@scalar/helpers": "0.11.1", "@scalar/icons": "0.7.6", "@scalar/snippetz": "0.9.28", "@scalar/themes": "0.17.3", "@scalar/types": "0.18.2", "@scalar/workspace-store": "0.58.1", "@types/har-format": "^1.2.16", "js-base64": "^3.9.2", "vue": "^3.5.40" } }, "sha512-4goVCRnz8QWCzQIuMV58GUSoj+WJNZBSGS5L6n3kc8TAKY6qiTOf+Unc5oy2DgwHCJJ6AO531hnGDAsMx7sNaQ=="],
"@scalar/blocks": ["@scalar/blocks@0.1.16", "", { "dependencies": { "@scalar/components": "0.29.1", "@scalar/helpers": "0.11.3", "@scalar/icons": "0.7.6", "@scalar/snippetz": "0.9.30", "@scalar/themes": "0.17.4", "@scalar/types": "0.19.0", "@scalar/workspace-store": "0.60.0", "@types/har-format": "^1.2.16", "js-base64": "^3.9.2", "vue": "^3.5.40" } }, "sha512-k72Dxwj2Bh9jhJkIyTlrpja6QRnvnT5Cb+LH++BGMV70lSu22cyzJBmhtKORTfQVb671s2I7WLUBU6zirxkhsA=="],
"@scalar/code-highlight": ["@scalar/code-highlight@0.4.5", "", { "dependencies": { "hast-util-to-text": "^4.0.2", "highlight.js": "^11.11.1", "lowlight": "^3.3.0", "rehype-external-links": "^3.0.0", "rehype-format": "^5.0.1", "rehype-parse": "^9.0.1", "rehype-raw": "^7.0.0", "rehype-sanitize": "^6.0.0", "rehype-stringify": "^10.0.1", "remark-gfm": "^4.0.1", "remark-parse": "^11.0.0", "remark-rehype": "^11.1.2", "remark-stringify": "^11.0.0", "unified": "^11.0.5", "unist-util-visit": "^5.1.0" } }, "sha512-OCZvBYwodCR1cWemO3MXMwXumI18kFOy8k2nwP+Ic8zFLK1ObyXECRoM+UH+GTw+O4TJsrjNIdLjsnXPO1MvyA=="],
"@scalar/components": ["@scalar/components@0.28.1", "", { "dependencies": { "@floating-ui/utils": "0.2.10", "@floating-ui/vue": "1.1.9", "@headlessui/tailwindcss": "^0.2.2", "@headlessui/vue": "1.7.23", "@scalar/code-highlight": "0.4.5", "@scalar/helpers": "0.11.1", "@scalar/icons": "0.7.6", "@scalar/themes": "0.17.3", "@scalar/use-hooks": "0.4.10", "@vueuse/core": "13.9.0", "cva": "1.0.0-beta.4", "radix-vue": "^1.9.17", "vue": "^3.5.40", "vue-component-type-helpers": "^3.2.6" } }, "sha512-mYI2WwvVM6a9E/o6vrft9FKoLP7Chcit5kOc+Iz+MV6xQ63Cjx2e/dACagEXE9O5i3b4+8+8C00iy4p1TS6P0A=="],
"@scalar/components": ["@scalar/components@0.29.1", "", { "dependencies": { "@floating-ui/utils": "0.2.10", "@floating-ui/vue": "1.1.9", "@headlessui/tailwindcss": "^0.2.2", "@headlessui/vue": "1.7.23", "@scalar/code-highlight": "0.4.5", "@scalar/helpers": "0.11.3", "@scalar/icons": "0.7.6", "@scalar/themes": "0.17.4", "@scalar/use-hooks": "0.4.11", "@vueuse/core": "13.9.0", "cva": "1.0.0-beta.4", "radix-vue": "^1.9.17", "vue": "^3.5.40", "vue-component-type-helpers": "^3.2.6" } }, "sha512-mxlJ/3Pv1YqqaXSBU2SLAiTZSGdam3ZyoYW7CAITSoVlruDBIEfSm6a8eABpM9mWSsoNQhmscLEq2QSC0szUPA=="],
"@scalar/helpers": ["@scalar/helpers@0.11.1", "", {}, "sha512-Knwbe0IYqFk0PPDoOKLasqglBHfyf9/zwWWqFsSNi/AtdjM29wSZXN6p8DFid6iB5B9epYH9YiSgJ6tpD00TEw=="],
"@scalar/helpers": ["@scalar/helpers@0.11.3", "", {}, "sha512-4zPzuNTXObDUtZS93xzAoK83ddHDgBGifv/vKFe6bHqEl5i+IMLTTtEHOaaOZK4dusPEXcXsU5TBSaY/I6Mi+A=="],
"@scalar/icons": ["@scalar/icons@0.7.6", "", { "dependencies": { "@phosphor-icons/core": "^2.1.1", "@types/node": "^24.1.0", "chalk": "^5.6.2", "vue": "^3.5.40" } }, "sha512-UIQ7K/xNDo7Mgez/Z+ArG7JZnS8QRZNOl+s9frdS1T8uEJYuTqT1lnS7R0ECwIw3y6dn6EIfjaZKPihjYC7+qw=="],
"@scalar/json-magic": ["@scalar/json-magic@0.13.2", "", { "dependencies": { "@scalar/helpers": "0.11.1", "pathe": "^2.0.3", "yaml": "^2.9.0" } }, "sha512-T8rQw5u7+MSTDpUcd5ShX1taOUxpZMv2b/P6xsahdlv/u68VX/Bq/+uzuAf2xW8IIOy7BEP4MBggle/vMDgAXw=="],
"@scalar/json-magic": ["@scalar/json-magic@0.13.4", "", { "dependencies": { "@scalar/helpers": "0.11.3", "pathe": "^2.0.3", "yaml": "^2.9.0" } }, "sha512-pOZdlzkgLB+/4OlIlzMToV/cr4vsvWy/MtbtJoRcNHIzDnT8sfNtv/cH8MR2pNDg/29sPpTV1HaE4SZ8b9jUOQ=="],
"@scalar/oas-utils": ["@scalar/oas-utils@0.19.14", "", { "dependencies": { "@scalar/helpers": "0.11.1", "@scalar/themes": "0.17.3", "@scalar/types": "0.18.2", "@scalar/workspace-store": "0.58.1", "flatted": "^3.4.0", "vue": "^3.5.40", "yaml": "^2.9.0" } }, "sha512-rnVIOK6+oHTc4SeXQBkEj+Kb2xB/VUJxckKGNdHnHHlsLjpN4VXhwUBldYAvV9dA/AENfeMj5ys6GubP0RyNwQ=="],
"@scalar/oas-utils": ["@scalar/oas-utils@0.19.16", "", { "dependencies": { "@scalar/helpers": "0.11.3", "@scalar/themes": "0.17.4", "@scalar/types": "0.19.0", "@scalar/workspace-store": "0.60.0", "flatted": "^3.4.0", "vue": "^3.5.40", "yaml": "^2.9.0" } }, "sha512-0u0/vd62lEektF9u6d7ywAYwamkrG1xTfxMf5gOkRGTVZJ7jV+J9LoSfUv+NCR3mmQpeGyKSaiD/3/Psa4OwRA=="],
"@scalar/openapi-types": ["@scalar/openapi-types@0.9.5", "", {}, "sha512-czrz/zkVm1oPzrpYo3hI/iymfiw1s4dgJiQtwNi2U77Sqf3EQOGKsif4VNRa5suWMYRuPaFx5EiFM1FNEV4Whg=="],
"@scalar/openapi-upgrader": ["@scalar/openapi-upgrader@0.2.15", "", { "dependencies": { "@scalar/openapi-types": "0.9.5" } }, "sha512-yqROK9U96ElasEL4Wl/+PIjQZlqrQXVUUpXk9PA6xBnrp8KdEv7at8pR5gsZkvddJfYVwLILPlctyqUg4u4YZA=="],
"@scalar/schemas": ["@scalar/schemas@0.8.3", "", { "dependencies": { "@scalar/helpers": "0.11.1", "@scalar/validation": "0.6.3" } }, "sha512-cTjgiJxXFXMqXlFZafXOyTOKm1lUfbDTbe9La+dPcQPe6q0zXAiVtys0IKILQWNrjXPidY0eaB9Va/rd16bfxw=="],
"@scalar/schemas": ["@scalar/schemas@0.9.0", "", { "dependencies": { "@scalar/helpers": "0.11.3", "@scalar/validation": "0.6.3" } }, "sha512-yYRlIWzw+7HuIX4z7rk7tg4y1nERwvvWKbolZOm7LveSTrppllGKyjtnIqS5uXsmJddERxuurSgDW224gGJlFQ=="],
"@scalar/sidebar": ["@scalar/sidebar@0.10.1", "", { "dependencies": { "@scalar/components": "0.28.1", "@scalar/helpers": "0.11.1", "@scalar/icons": "0.7.6", "@scalar/themes": "0.17.3", "@scalar/use-hooks": "0.4.10", "@scalar/workspace-store": "0.58.1", "vue": "^3.5.40" } }, "sha512-RbDJD22tAMGquDh7ItUeVujSvQxLoc7Eo95gPhHaokEYJprBs/B30Es3J1qhNZQ6IVZ0kcxCjuD4haI1h/UmrQ=="],
"@scalar/sidebar": ["@scalar/sidebar@0.11.1", "", { "dependencies": { "@scalar/components": "0.29.1", "@scalar/helpers": "0.11.3", "@scalar/icons": "0.7.6", "@scalar/themes": "0.17.4", "@scalar/use-hooks": "0.4.11", "@scalar/workspace-store": "0.60.0", "vue": "^3.5.40" } }, "sha512-xXgB0WYWG4WJTFL93WkvoAVdD1H4k+A2n2jgwa/PrUM6RO8TTh+7BLcekpwvPLvXd5muWxzWDTcR413saocphw=="],
"@scalar/snippetz": ["@scalar/snippetz@0.9.28", "", { "dependencies": { "@scalar/helpers": "0.11.1", "@scalar/types": "0.18.2", "js-base64": "^3.9.2", "stringify-object": "^6.0.0" } }, "sha512-xpzQ5NgJDfV5Y5Xmpo2lDZclbXuIRolIm6qAaShNVBvO3q2GYtxJ9RSsrMFTpuk1NIqcQ4WKVAVRllRhYuzlWg=="],
"@scalar/snippetz": ["@scalar/snippetz@0.9.30", "", { "dependencies": { "@scalar/helpers": "0.11.3", "@scalar/types": "0.19.0", "js-base64": "^3.9.2", "stringify-object": "^6.0.0" } }, "sha512-mDluVSGZet1Go8NgJK9s9Z8zNKqePG7zNn8PMCphAzwXNomvMy6j8WRuLDln+Dz33jILfiKlUtv4cnLkmzB+7g=="],
"@scalar/themes": ["@scalar/themes@0.17.3", "", { "dependencies": { "nanoid": "^5.1.6" } }, "sha512-QJPHeGCg0hF30IGPjX2nMcMOvBYyd62d5vey1mIC17CLhOe5tLVTn9D6G175D+jPVb0WhVPcSbaax6KxSZTUEQ=="],
"@scalar/themes": ["@scalar/themes@0.17.4", "", { "dependencies": { "nanoid": "^5.1.6" } }, "sha512-tSCtGLb0noijR8GzgH6H/tlbSuZoLupe66ta6I9FzZxdvgj4SdPM+2Q7E8k1uwPRfrE5NvMyp61s1BsA3DTFsg=="],
"@scalar/typebox": ["@scalar/typebox@0.1.3", "", {}, "sha512-lU055AUccECZMIfGA0z/C1StYmboAYIPJLDFBzOO81yXBi35Pxdq+I4fWX6iUZ8qcoHneiLGk9jAUM1rA93iEg=="],
"@scalar/types": ["@scalar/types@0.18.2", "", { "dependencies": { "@scalar/helpers": "0.11.1", "nanoid": "^5.1.6", "type-fest": "^5.8.0", "zod": "^4.3.5" } }, "sha512-q7fGMn0IygdLbYk9W4quM0w1caHDfL9FIxsYXNsgIep6uuO0T/t4BOStyf0qyzQ3pjt0B7rWFxO//EM9I7F/Tw=="],
"@scalar/types": ["@scalar/types@0.19.0", "", { "dependencies": { "@scalar/helpers": "0.11.3", "nanoid": "^5.1.6", "type-fest": "^5.8.0", "zod": "^4.4.3" } }, "sha512-EKeoWgUlP+uepbM/zEHKbsdpBNyOmSrw6DdU/WC0p53gz5uRzI7d/IEecIP9SQMUkResPR9DmWeWfFQftG7vqg=="],
"@scalar/use-codemirror": ["@scalar/use-codemirror@0.14.15", "", { "dependencies": { "@codemirror/autocomplete": "^6.18.3", "@codemirror/commands": "^6.7.1", "@codemirror/lang-css": "^6.3.1", "@codemirror/lang-html": "^6.4.8", "@codemirror/lang-json": "^6.0.0", "@codemirror/lang-xml": "^6.0.0", "@codemirror/lang-yaml": "^6.1.2", "@codemirror/language": "^6.10.7", "@codemirror/lint": "^6.8.4", "@codemirror/state": "^6.5.0", "@codemirror/view": "^6.35.3", "@lezer/common": "^1.2.3", "@lezer/highlight": "^1.2.1", "@replit/codemirror-css-color-picker": "^6.3.0", "vue": "^3.5.40" } }, "sha512-Uvjx0qvOsWPs+I32FL+3v//+VKwtp8ROXgbIAMauNypuw52VeF7R4RM7YoN2Xg1EAEiMtM8ZhWVzCoYTFr6AKQ=="],
"@scalar/use-hooks": ["@scalar/use-hooks@0.4.10", "", { "dependencies": { "@scalar/use-toasts": "0.10.5", "@scalar/validation": "0.6.3", "@vueuse/core": "13.9.0", "cva": "1.0.0-beta.4", "tailwind-merge": "3.5.0", "vue": "^3.5.40" } }, "sha512-YDIohEujqRmPCLpRlE+NTzjKPjeMJZtI4oJcZ5uH2vA1gR8wiaEStK8djBsldOFIn+LmhHagl+HHn6NX054f7Q=="],
"@scalar/use-hooks": ["@scalar/use-hooks@0.4.11", "", { "dependencies": { "@scalar/helpers": "0.11.3", "@scalar/use-toasts": "0.10.5", "@scalar/validation": "0.6.3", "@vueuse/core": "13.9.0", "cva": "1.0.0-beta.4", "tailwind-merge": "3.5.0", "vue": "^3.5.40" } }, "sha512-wCUn9WWKv4abiFZOcjBp8nIcyZ30t7QNPgHbcYu5MXgLgPclYwKb/A+TZiyp63TORWJOK8sp1RzrNGw2CT+m/Q=="],
"@scalar/use-toasts": ["@scalar/use-toasts@0.10.5", "", { "dependencies": { "vue": "^3.5.40", "vue-sonner": "^1.3.2" } }, "sha512-5a8qTVd9eXvMDeD0ifqHvnzg0i2v15clhyFToXDHYmInQD51oBRwByCFqFO+m9ymxSLOHZ7/FiEcU6F90/QTlg=="],
"@scalar/validation": ["@scalar/validation@0.6.3", "", {}, "sha512-j3s9XPv8Wo1EG5/naBi4XGF0uXYQ2A3QZNI0NKWgCMxRHVrDJGlZEYymcHDHY7fquRm73P8U3c8xqJqB5yad6w=="],
"@scalar/workspace-store": ["@scalar/workspace-store@0.58.1", "", { "dependencies": { "@scalar/asyncapi-upgrader": "0.1.7", "@scalar/helpers": "0.11.1", "@scalar/json-magic": "0.13.2", "@scalar/openapi-upgrader": "0.2.15", "@scalar/schemas": "0.8.3", "@scalar/snippetz": "0.9.28", "@scalar/typebox": "0.1.3", "@scalar/types": "0.18.2", "@scalar/validation": "0.6.3", "js-base64": "^3.9.2", "type-fest": "^5.8.0", "vue": "^3.5.40", "yaml": "^2.9.0" } }, "sha512-aKBwM7Tp+VzdxqVC971c8uxM8w9cw+RS7JY5V/VHj29HyNwKQTSJX6+qf8gma46bgUK0W15Ra/T8rN1mAR+EIw=="],
"@scalar/workspace-store": ["@scalar/workspace-store@0.60.0", "", { "dependencies": { "@scalar/asyncapi-upgrader": "0.1.9", "@scalar/helpers": "0.11.3", "@scalar/json-magic": "0.13.4", "@scalar/openapi-upgrader": "0.2.15", "@scalar/schemas": "0.9.0", "@scalar/snippetz": "0.9.30", "@scalar/typebox": "0.1.3", "@scalar/types": "0.19.0", "@scalar/validation": "0.6.3", "js-base64": "^3.9.2", "type-fest": "^5.8.0", "vue": "^3.5.40", "yaml": "^2.9.0" } }, "sha512-O3Zp6Olq7+L2Yp6xd7Z9sIQ4VG5SwR2oHkiGTagZBSrqEuvuce5Z93q3NVPux6j3cID/geW0vC0sPJ5LxNTnBA=="],
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
@@ -637,17 +641,17 @@
"@tailwindcss/vite": ["@tailwindcss/vite@4.3.3", "", { "dependencies": { "@tailwindcss/node": "4.3.3", "@tailwindcss/oxide": "4.3.3", "tailwindcss": "4.3.3" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw=="],
"@tanstack/query-core": ["@tanstack/query-core@5.101.4", "", {}, "sha512-gNwcvOJcRbLWPOLG/2OBm+zM+Yv+MKsXKEOWC57USuZDEsI71hEErQsiEGx5wX9rzWWkfwM0fVSPoiIFSsxfiw=="],
"@tanstack/query-core": ["@tanstack/query-core@5.102.8", "", {}, "sha512-ZNjkJ33CqvPNec/6lZBnHqLc3EVGPZ9ySLhYahU9TcuRFdmwXewuj0c4hwSWcGHqEUwcSrKeZ+oGcvPBqXcQcg=="],
"@tanstack/react-query": ["@tanstack/react-query@5.101.4", "", { "dependencies": { "@tanstack/query-core": "5.101.4" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-yRg2pfOCxIs4ZJW3XYYHU/WgtD04FHSnfHlpRT7h7pR77hwkdRG4wxbKe4aq6P0RvXUTBSQpQeadS1SUYUe+KA=="],
"@tanstack/react-query": ["@tanstack/react-query@5.102.8", "", { "dependencies": { "@tanstack/query-core": "5.102.8" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-TYBea4OuXWD7MhaSHq069TWbFe7rcwWN6kzT7JF0OKi1K6c1gTv2IzD6A6ExJsCMozdkqBWeuIUZmu4KQg0O5A=="],
"@tanstack/react-table": ["@tanstack/react-table@8.21.3", "", { "dependencies": { "@tanstack/table-core": "8.21.3" }, "peerDependencies": { "react": ">=16.8", "react-dom": ">=16.8" } }, "sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww=="],
"@tanstack/react-virtual": ["@tanstack/react-virtual@3.14.10", "", { "dependencies": { "@tanstack/virtual-core": "3.17.8" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-SRyoUbdFMRHuYXMijV5H4ZarQWpXkj3iANq8OFre+pybeVap8ZJjZ3Nz9bVjx4d8PfobVUQUdKyyyHYk3E+djw=="],
"@tanstack/react-virtual": ["@tanstack/react-virtual@3.14.11", "", { "dependencies": { "@tanstack/virtual-core": "3.17.9" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-SStWf8bYdTgAquYqG4Pi2+d1XimQKoCIJ94H3jsm0D6UA0yqffvWuvUzrrQ4idewFWq8BSPpahnmLVosJIAs6g=="],
"@tanstack/table-core": ["@tanstack/table-core@8.21.3", "", {}, "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg=="],
"@tanstack/virtual-core": ["@tanstack/virtual-core@3.17.8", "", {}, "sha512-BfEvehNpOT75r5Ksc5xW6NZuXujTfb7nlSEyVu4XHG3gdxNg1KqXruWbDewXOUaUYIo4oRbSfkjIajz4MAT8tA=="],
"@tanstack/virtual-core": ["@tanstack/virtual-core@3.17.9", "", {}, "sha512-M8Bzy7CCMvUjRiuoHVH9mRjyUVczRs8v8RcUEphLFGc445ZP4KQ15Y1sLqhQqJi/HgGJrxfCHnEnIX0x9mVrBg=="],
"@tanstack/vue-virtual": ["@tanstack/vue-virtual@3.13.36", "", { "dependencies": { "@tanstack/virtual-core": "3.17.8" }, "peerDependencies": { "vue": "^2.7.0 || ^3.0.0" } }, "sha512-gKpExv4RbB9luVG+SucTXoqPZv/gzu/Yvz6BNO+8kpNxJ2x+I/ulryzl5W9BRciahZGp5Tls3Dp5XP1ztVGbMw=="],
@@ -677,21 +681,21 @@
"@tauri-apps/cli-win32-x64-msvc": ["@tauri-apps/cli-win32-x64-msvc@2.11.4", "", { "os": "win32", "cpu": "x64" }, "sha512-+vDiqBIU5dMISg/wNvX3sF+ZHfgJGJ5T0AcO+EHNXV9GGAG+P5fzodlDXD3QdKCRgZxMoCm5PPvj3BqLNjBthw=="],
"@tauri-apps/plugin-dialog": ["@tauri-apps/plugin-dialog@2.7.2", "", { "dependencies": { "@tauri-apps/api": "^2.11.0" } }, "sha512-pX0IGm1I3I6wc+zeKYcq1GSqogK6okCNX5fOdaNU5ab1AjGS6l1E5wFNjEb7meg7ZFSp0JUs+0jQGQNyOvLrsg=="],
"@tauri-apps/plugin-dialog": ["@tauri-apps/plugin-dialog@2.7.3", "", { "dependencies": { "@tauri-apps/api": "^2.11.0" } }, "sha512-CRgE+7TP4tvq9MjBU6f04NLTFIqVMLKHk3hAqlhil00ngK9ACTrXPH3oHpKMProxILodd3YjBoKbMwSI4IEcfA=="],
"@tauri-apps/plugin-opener": ["@tauri-apps/plugin-opener@2.5.4", "", { "dependencies": { "@tauri-apps/api": "^2.11.0" } }, "sha512-1HnPkb+AmgO29HBazm4uPLKB+r7zzcTBW1d0fyYp1uP+jwtpoiNDGKMMzz58SFp49nOIrxdE3aUJtT57lfO9CQ=="],
"@tauri-apps/plugin-opener": ["@tauri-apps/plugin-opener@2.5.5", "", { "dependencies": { "@tauri-apps/api": "^2.11.0" } }, "sha512-xvzGai5aQds8j8R8RsUK/lW6pGG50YgOYIPLzvkqmkwAj7dfySOD7sGtejRzvVdMmv1EQfKVEFh1MvmDp8QR0g=="],
"@tauri-apps/plugin-process": ["@tauri-apps/plugin-process@2.3.1", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-nCa4fGVaDL/B9ai03VyPOjfAHRHSBz5v6F/ObsB73r/dA3MHHhZtldaDMIc0V/pnUw9ehzr2iEG+XkSEyC0JJA=="],
"@tauri-apps/plugin-updater": ["@tauri-apps/plugin-updater@2.10.1", "", { "dependencies": { "@tauri-apps/api": "^2.10.1" } }, "sha512-NFYMg+tWOZPJdzE/PpFj2qfqwAWwNS3kXrb1tm1gnBJ9mYzZ4WDRrwy8udzWoAnfGCHLuePNLY1WVCNHnh3eRA=="],
"@tauri-apps/plugin-updater": ["@tauri-apps/plugin-updater@2.11.0", "", { "dependencies": { "@tauri-apps/api": "^2.11.0" } }, "sha512-AE36XkOoSna24G40jZMY15nzAnkXEPL/73tGoseGrtGOHuI/cZwWzHpZFLjKXDPgzYZ435z1gHu28LgrsBwIxQ=="],
"@tauri-apps/plugin-window-state": ["@tauri-apps/plugin-window-state@2.4.1", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-OuvdrzyY8Q5Dbzpj+GcrnV1iCeoZbcFdzMjanZMMcAEUNy/6PH5pxZPXpaZLOR7whlzXiuzx0L9EKZbH7zpdRw=="],
"@testing-library/dom": ["@testing-library/dom@10.4.1", "", { "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", "@types/aria-query": "^5.0.1", "aria-query": "5.3.0", "dom-accessibility-api": "^0.5.9", "lz-string": "^1.5.0", "picocolors": "1.1.1", "pretty-format": "^27.0.2" } }, "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg=="],
"@testing-library/jest-dom": ["@testing-library/jest-dom@6.9.1", "", { "dependencies": { "@adobe/css-tools": "^4.4.0", "aria-query": "^5.0.0", "css.escape": "^1.5.1", "dom-accessibility-api": "^0.6.3", "picocolors": "^1.1.1", "redent": "^3.0.0" } }, "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA=="],
"@testing-library/jest-dom": ["@testing-library/jest-dom@7.0.1", "", { "dependencies": { "@adobe/css-tools": "^4.4.0", "aria-query": "^5.0.0", "css.escape": "^1.5.1", "dom-accessibility-api": "^0.6.3", "picocolors": "^1.1.1", "redent": "^3.0.0" }, "peerDependencies": { "@testing-library/dom": ">=10 <11", "vitest": ">= 0.32" }, "optionalPeers": ["vitest"] }, "sha512-oMDTC3oA+6CXSO2JZnvOI7CA6oVub6kij5ggk9ohwye5slmkwxYDXcPOVxgMw/RQlticjtO0C1RZkR97HgrWMw=="],
"@testing-library/react": ["@testing-library/react@16.3.2", "", { "dependencies": { "@babel/runtime": "^7.12.5" }, "peerDependencies": { "@testing-library/dom": "^10.0.0", "@types/react": "^18.0.0 || ^19.0.0", "@types/react-dom": "^18.0.0 || ^19.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g=="],
"@testing-library/react": ["@testing-library/react@16.3.3", "", { "dependencies": { "@babel/runtime": "^7.12.5" }, "peerDependencies": { "@testing-library/dom": "^10.0.0", "@types/react": "^18.0.0 || ^19.0.0", "@types/react-dom": "^18.0.0 || ^19.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg=="],
"@turbo/darwin-64": ["@turbo/darwin-64@2.10.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-9nKgKoF6ZOUsM+or0OtNf+TTJSfGvDNP7ZFv/ZGWVwOSCkumyctQiTeHwB4UNljHTnC41AqylgbunLDHoccNrA=="],
@@ -733,7 +737,7 @@
"@types/react": ["@types/react@19.2.18", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w=="],
"@types/react-dom": ["@types/react-dom@19.2.4", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw=="],
"@types/react-dom": ["@types/react-dom@19.3.0", "", { "peerDependencies": { "@types/react": "^19.3.0" } }, "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q=="],
"@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="],
@@ -747,7 +751,7 @@
"@vercel/oidc": ["@vercel/oidc@3.1.0", "", {}, "sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w=="],
"@vitejs/plugin-react": ["@vitejs/plugin-react@6.1.0", "", { "dependencies": { "@rolldown/pluginutils": "^1.0.1" }, "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", "oxc-transform-react": "^0.145.0", "vite": "^8.0.0" }, "optionalPeers": ["@rolldown/plugin-babel", "babel-plugin-react-compiler", "oxc-transform-react"] }, "sha512-qd2BzUBehkov86WFhg0JkEFEYyCLG9uPCe6qWTY/kRlss9OvJrOF2UbIWT7p+8IzZHkEu0DNGHc4HSv+JdDLsw=="],
"@vitejs/plugin-react": ["@vitejs/plugin-react@6.1.1", "", { "dependencies": { "@rolldown/pluginutils": "^1.0.1" }, "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", "oxc-transform-react": "^0.145.0", "vite": "^8.0.0" }, "optionalPeers": ["@rolldown/plugin-babel", "babel-plugin-react-compiler", "oxc-transform-react"] }, "sha512-yxLaQV9gkhS8ezJqCM6+ndU7mDY6gqAg75NQ+0IjwEI8IYOmQCgkRwHKVSfWXW076DsqMo0Dk+0FK1U+M5RgFw=="],
"@vitest/expect": ["@vitest/expect@4.1.11", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.11", "@vitest/utils": "4.1.11", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw=="],
@@ -801,7 +805,7 @@
"ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
"ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="],
"aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="],
@@ -827,6 +831,8 @@
"cac": ["cac@7.0.0", "", {}, "sha512-tixWYgm5ZoOD+3g6UTea91eow5z6AAHaho3g0V9CNSNb45gM8SmflpAc+GRd1InC4AqN/07Unrgp56Y94N9hJQ=="],
"cacheable": ["cacheable@2.5.0", "", { "dependencies": { "@cacheable/memory": "^2.2.0", "@cacheable/utils": "^2.5.0", "hookified": "^1.15.0", "keyv": "^5.6.0", "qified": "^0.10.1" } }, "sha512-60cyAOytib/OzBw1JNSoSV/boK1AtHryDIjvVBk7XbN4ugfkM3+Sry7fEjNgPMGgOjuaZPAp8ruZ0Cxafwyq9g=="],
"call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
"camelcase": ["camelcase@5.3.1", "", {}, "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg=="],
@@ -837,7 +843,7 @@
"chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="],
"chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
"chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
"character-entities": ["character-entities@2.0.2", "", {}, "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ=="],
@@ -847,7 +853,7 @@
"class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="],
"cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="],
"cliui": ["cliui@9.0.1", "", { "dependencies": { "string-width": "^7.2.0", "strip-ansi": "^7.1.0", "wrap-ansi": "^9.0.0" } }, "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w=="],
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
@@ -859,7 +865,7 @@
"comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="],
"concurrently": ["concurrently@9.2.4", "", { "dependencies": { "chalk": "4.1.2", "rxjs": "7.8.2", "shell-quote": "1.9.0", "supports-color": "8.1.1", "tree-kill": "1.2.2", "yargs": "17.7.2" }, "bin": { "conc": "dist/bin/concurrently.js", "concurrently": "dist/bin/concurrently.js" } }, "sha512-TZ0CEhyzvFjgtAvHTusDMgj7wNdihCh7LLLrzdUOXIhdlnL2JBBGA9eJxR24rtqgmdjh3OA3hrN1rCHj6HM8qA=="],
"concurrently": ["concurrently@10.0.5", "", { "dependencies": { "chalk": "5.6.2", "rxjs": "7.8.2", "shell-quote": "1.9.0", "supports-color": "10.2.2", "tree-kill": "1.2.2", "yargs": "18.0.0" }, "bin": { "conc": "dist/bin/index.js", "concurrently": "dist/bin/index.js" } }, "sha512-JaP/CoftUrCcAFW/g//RbgEGwlelnEae6cfBLgH6ZdO6s8jPkn6p9SB9u6pdVxYXoiSnFqseOlHfrEfF82TVOg=="],
"convert-hrtime": ["convert-hrtime@5.0.0", "", {}, "sha512-lOETlkIeYSJWcbbcvjRKGxVMXJR+8+OQb/mTPbA4ObPMytYIsUbuOE0Jzy60hjARYszq1id0j8KgVhC+WGZVTg=="],
@@ -917,7 +923,7 @@
"electron-to-chromium": ["electron-to-chromium@1.5.422", "", {}, "sha512-UvA/32XqrLDdZSn7Jllo1AYNcWji/G0d5M0GTViE7KoGBiMunw3a34Sb2KO4ZZyrSEhqsxFoVhWWJshdyfKqJA=="],
"emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
"emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="],
"enhanced-resolve": ["enhanced-resolve@5.24.5", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A=="],
@@ -937,7 +943,7 @@
"escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="],
"eslint": ["eslint@10.8.1", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", "@eslint/config-array": "^0.23.5", "@eslint/config-helpers": "^0.7.0", "@eslint/core": "^1.2.1", "@eslint/plugin-kit": "^0.7.2", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^9.1.2", "eslint-visitor-keys": "^5.0.1", "espree": "^11.2.0", "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "minimatch": "^10.2.5", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-wqA7W2jbsC/BnV9Iv1UZpKVFkO1AdNoSmYW8NWG4HNOBbkAMvIqDZ27pI2f07dqn583NcIC44ckjAcOXDL1QbQ=="],
"eslint": ["eslint@10.10.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", "@eslint/config-array": "^0.23.5", "@eslint/config-helpers": "^0.7.0", "@eslint/core": "^1.2.1", "@eslint/plugin-kit": "^0.7.3", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^9.1.2", "eslint-visitor-keys": "^5.0.1", "espree": "^11.2.0", "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "11.1.5 || >11.1.6 <12", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "minimatch": "^10.2.5", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-NPXn6r5zl4uET1DAVPaOwzX3rut4c0wcmw3dWJAfOsTM5+TogXo0DDjz8pwm/hL8cyVNpHqeK4JpN0NjnyFFNw=="],
"eslint-plugin-react-hooks": ["eslint-plugin-react-hooks@7.1.1", "", { "dependencies": { "@babel/core": "^7.24.4", "@babel/parser": "^7.24.4", "hermes-parser": "^0.25.1", "zod": "^3.25.0 || ^4.0.0", "zod-validation-error": "^3.5.0 || ^4.0.0" }, "peerDependencies": { "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0" } }, "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g=="],
@@ -975,11 +981,11 @@
"fflate": ["fflate@0.4.9", "", {}, "sha512-zdxgIEddhfsyCaWpJ2SdXEP8ZMrKJ6+5jl4OupODcywU0IhRk6gdXuVGcPICyfx2H97hVK7xmJtRLPjkxAX8Vw=="],
"file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="],
"file-entry-cache": ["file-entry-cache@11.1.5", "", { "dependencies": { "flat-cache": "^6.1.23" } }, "sha512-+PFTHITI08JIGhnNpGNI8T8inUpgZfk3GNEqfT9R2zZV2iFXg3CvqzSl/uEhs7TSGujYRELEANyDvS8Fj7+S7Q=="],
"find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="],
"flat-cache": ["flat-cache@4.0.1", "", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" } }, "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw=="],
"flat-cache": ["flat-cache@6.1.23", "", { "dependencies": { "cacheable": "^2.5.0", "flatted": "^3.4.2", "hookified": "^1.15.0" } }, "sha512-f++BY9pTk+983xK1FLzlLpmM0i0z+jHmx3QESGkURMXujQZz1k5wzwX6hjnQ8goaD0B+sYnDK1yZ6MTyZfUaqA=="],
"flatted": ["flatted@3.4.4", "", {}, "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q=="],
@@ -989,7 +995,7 @@
"form-data": ["form-data@4.0.6", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.4", "mime-types": "^2.1.35" } }, "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ=="],
"formatly": ["formatly@0.3.0", "", { "dependencies": { "fd-package-json": "^2.0.0" }, "bin": { "formatly": "bin/index.mjs" } }, "sha512-9XNj/o4wrRFyhSMJOvsuyMwy8aUfBaZ1VrqHVfohyXf0Sw0e+yfKG+xZaY3arGCOMdwFsqObtzVOc1gU9KiT9w=="],
"formatly": ["formatly@0.7.0", "", { "dependencies": { "fd-package-json": "^2.0.0", "package-manager-detector": "^1.8.0" }, "bin": { "formatly": "bin/index.mjs" } }, "sha512-7CXJtIIA0zy/u12StsYk25qVKxvdLA2ep2sTNxK3ov0mGNIIDqIvAXDSgTnAfDJFsPfWjuz0WjfYSdpvnLA5Tg=="],
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
@@ -1005,6 +1011,8 @@
"get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="],
"get-east-asian-width": ["get-east-asian-width@1.6.0", "", {}, "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA=="],
"get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
"get-nonce": ["get-nonce@1.0.1", "", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="],
@@ -1013,11 +1021,11 @@
"get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
"get-tsconfig": ["get-tsconfig@4.14.1", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-Dz/6HxkrxgNehhxLVeyv8sad9UzF2xBVeaKBQNDfJ5XiSXmp2gTR0eO0RWiT2NCKS5aGP9jjkOMggTN90qU50A=="],
"get-tsconfig": ["get-tsconfig@4.14.3", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-++QEw4DIY7WGoukz+/+A/8dGYPT9l9yIadnmSgZ8Rjr3YVSVDipQSO9CdnJo9ePqFqUUqh+wk9uIaoiAwsiPkA=="],
"glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="],
"globals": ["globals@17.11.0", "", {}, "sha512-Z2I8hM+PbJDXQDq3Icgpzv+mPdwr68iZUU9d5WW4FuXfDUQfkZaZuvjMv42/5crNyw154+9+VWXbYrUgDXbxNw=="],
"globals": ["globals@17.12.0", "", {}, "sha512-cezEd/DTyyht9cvSSURyygXPfy04GtWO/5e6ZPvH7fCtjKz9PYOmuawphw1Ctd1f6C+5JypXfGD7ahNMXvevBA=="],
"goober": ["goober@2.1.19", "", { "peerDependencies": { "csstype": "^3.0.10" } }, "sha512-U7veizMqxyKlM58+Z5j2ngJBH/r9siDmxpvNxSw0PylF6WQvrASJEZrxh1hidRBJc2jqoBVSyOban5u8m+6Rxg=="],
@@ -1027,12 +1035,12 @@
"guess-json-indent": ["guess-json-indent@3.0.1", "", {}, "sha512-LWZ3Vr8BG7DHE3TzPYFqkhjNRw4vYgFSsv2nfMuHklAlOfiy54/EwiDQuQfFVLxENCVv20wpbjfTayooQHrEhQ=="],
"has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
"has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],
"has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="],
"hashery": ["hashery@1.5.1", "", { "dependencies": { "hookified": "^1.15.0" } }, "sha512-iZyKG96/JwPz1N55vj2Ie2vXbhu440zfUfJvSwEqEbeLluk7NnapfGqa7LH0mOsnDxTF85Mx8/dyR6HfqcbmbQ=="],
"hasown": ["hasown@2.0.4", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="],
"hast-util-embedded": ["hast-util-embedded@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0", "hast-util-is-element": "^3.0.0" } }, "sha512-naH8sld4Pe2ep03qqULEtvYr7EjrLK2QHY8KJR6RJkTUjPGObe1vnx585uzem2hGra+s1q08DZZpfgDVYRbaXA=="],
@@ -1077,6 +1085,8 @@
"hookable": ["hookable@6.1.1", "", {}, "sha512-U9LYDy1CwhMCnprUfeAZWZGByVbhd54hwepegYTK7Pi5NvqEj63ifz5z+xukznehT7i6NIZRu89Ay1AZmRsLEQ=="],
"hookified": ["hookified@1.15.1", "", {}, "sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg=="],
"html-encoding-sniffer": ["html-encoding-sniffer@6.0.0", "", { "dependencies": { "@exodus/bytes": "^1.6.0" } }, "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg=="],
"html-parse-stringify": ["html-parse-stringify@4.0.1", "", {}, "sha512-0zHsZJrK7S3K2aucXWL6ycoYJ/iNtIcFHC/nYQgFklPtrv5LpJctIiSCroWZWeuoXvuyFdzp6KzjJQ+OT5MfFw=="],
@@ -1087,7 +1097,7 @@
"https-proxy-agent": ["https-proxy-agent@5.0.1", "", { "dependencies": { "agent-base": "6", "debug": "4" } }, "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA=="],
"i18next": ["i18next@26.4.0", "", { "peerDependencies": { "typescript": "^5 || ^6 || ^7" }, "optionalPeers": ["typescript"] }, "sha512-rsmK5bFqsD1AetSFSIa43wtNR4WpvvH4p0tLEsTxkC7QTrfdFm06nbQ95bh8Og4wwaCnUEcm9DVYL2cgxitiQg=="],
"i18next": ["i18next@26.4.2", "", { "peerDependencies": { "typescript": "^5 || ^6 || ^7" }, "optionalPeers": ["typescript"] }, "sha512-RX+R0VLg13IbvRuJSxnqykUFS9vQZTl8wYpWPCIUDWVrSGjsQywB5Y+pjzrkboxGAuYfJZVH1InFTdgBdxq6ug=="],
"i18next-browser-languagedetector": ["i18next-browser-languagedetector@8.2.1", "", { "dependencies": { "@babel/runtime": "^7.23.2" } }, "sha512-bZg8+4bdmaOiApD7N7BPT9W8MLZG+nPTOFlLiJiT8uzKXFjhxw4v2ierCXOwB5sFDMtuA5G4kgYZ0AznZxQ/cw=="],
@@ -1127,12 +1137,10 @@
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
"jsdom": ["jsdom@29.1.1", "", { "dependencies": { "@asamuzakjp/css-color": "^5.1.11", "@asamuzakjp/dom-selector": "^7.1.1", "@bramus/specificity": "^2.4.2", "@csstools/css-syntax-patches-for-csstree": "^1.1.3", "@exodus/bytes": "^1.15.0", "css-tree": "^3.2.1", "data-urls": "^7.0.0", "decimal.js": "^10.6.0", "html-encoding-sniffer": "^6.0.0", "is-potential-custom-element-name": "^1.0.1", "lru-cache": "^11.3.5", "parse5": "^8.0.1", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", "tough-cookie": "^6.0.1", "undici": "^7.25.0", "w3c-xmlserializer": "^5.0.0", "webidl-conversions": "^8.0.1", "whatwg-mimetype": "^5.0.0", "whatwg-url": "^16.0.1", "xml-name-validator": "^5.0.0" }, "peerDependencies": { "canvas": "^3.0.0" }, "optionalPeers": ["canvas"] }, "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q=="],
"jsdom": ["jsdom@30.0.1", "", { "dependencies": { "@asamuzakjp/css-color": "^6.0.5", "@asamuzakjp/dom-selector": "^8.3.0", "@bramus/specificity": "^2.4.2", "@csstools/css-syntax-patches-for-csstree": "^1.1.7", "@exodus/bytes": "^1.15.1", "css-tree": "^3.2.1", "data-urls": "^7.0.0", "decimal.js": "^10.6.0", "html-encoding-sniffer": "^6.0.0", "is-potential-custom-element-name": "^1.0.1", "lru-cache": "^11.5.2", "parse5": "^8.0.1", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", "tough-cookie": "^6.0.2", "undici": "^8.9.0", "w3c-xmlserializer": "^5.0.0", "webidl-conversions": "^8.0.1", "whatwg-mimetype": "^5.0.0", "whatwg-url": "^17.1.0", "xml-name-validator": "^5.0.0" }, "peerDependencies": { "canvas": "^3.2.3" }, "optionalPeers": ["canvas"] }, "sha512-52v7mUVUfNQVYYqE1lcdaymWL0njO7lTLUog6ZvW2U5KsbiLk/GnZlVJ+qx0xfNJZ6Gn+KSpPNE52vurbxZwrA=="],
"jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
"json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="],
"json-schema": ["json-schema@0.4.0", "", {}, "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA=="],
"json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
@@ -1143,9 +1151,9 @@
"jsonc-parser": ["jsonc-parser@3.3.1", "", {}, "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ=="],
"keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="],
"keyv": ["keyv@5.6.0", "", { "dependencies": { "@keyv/serialize": "^1.1.1" } }, "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw=="],
"knip": ["knip@6.32.2", "", { "dependencies": { "fdir": "^6.5.0", "formatly": "^0.3.0", "get-tsconfig": "4.14.1", "jiti": "^2.7.0", "oxc-parser": "^0.143.0", "oxc-resolver": "11.24.2", "picomatch": "^4.0.5", "smol-toml": "^1.7.1", "strip-json-comments": "5.0.3", "tinyglobby": "^0.2.17", "unbash": "^4.0.9", "yaml": "^2.9.0", "zod": "^4.4.3" }, "bin": { "knip": "bin/knip.js", "knip-bun": "bin/knip-bun.js" } }, "sha512-WXTXbmocrw7gqm1A1TQvFN0OgJ7hUSU6E1g6SPRIzzHFogUBhXByc7cYeOFVtJ2uODg7DP4VbESYBYnfbtBYsg=="],
"knip": ["knip@6.35.1", "", { "dependencies": { "fdir": "^6.5.0", "formatly": "^0.7.0", "get-tsconfig": "4.14.3", "jiti": "^2.7.0", "oxc-parser": "^0.148.0", "oxc-resolver": "11.24.2", "picomatch": "^4.0.7", "smol-toml": "^1.8.0", "strip-json-comments": "5.0.3", "tinyglobby": "^0.2.17", "unbash": "^4.0.11", "yaml": "^2.9.0", "zod": "^4.4.3" }, "bin": { "knip": "bin/knip.js", "knip-bun": "bin/knip-bun.js" } }, "sha512-22wnEnv4do2fvoeJsxpFCG/MBxReNxoCVBccaCl7suUJsG+F0UvxI9ycxZ9YgtLHSSjrZl5tOas0JZ/R1vJ93g=="],
"levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="],
@@ -1183,7 +1191,7 @@
"lru-cache": ["lru-cache@11.5.2", "", {}, "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g=="],
"lucide-react": ["lucide-react@1.33.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-MTRwMy0ZlL8Ur/vOAiJ9XGHE+kFPC7brq6MxAm0GiGXEBj0qy0jA/pG4N675oSzciO/UCdX8T+5yUQdmDeTLxg=="],
"lucide-react": ["lucide-react@1.43.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-ubtnda1fVK5ky0PNEpOmB0wiwhpZUyJiol4K14KCm+QKvuCkfUu54Et/rIjyupJpwiJ5Hg+BLQE86/GEsS+IgQ=="],
"lz-string": ["lz-string@1.5.0", "", { "bin": { "lz-string": "bin/bin.js" } }, "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ=="],
@@ -1313,7 +1321,7 @@
"optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="],
"oxc-parser": ["oxc-parser@0.143.0", "", { "dependencies": { "@oxc-project/types": "^0.143.0" }, "optionalDependencies": { "@oxc-parser/binding-android-arm-eabi": "0.143.0", "@oxc-parser/binding-android-arm64": "0.143.0", "@oxc-parser/binding-darwin-arm64": "0.143.0", "@oxc-parser/binding-darwin-x64": "0.143.0", "@oxc-parser/binding-freebsd-x64": "0.143.0", "@oxc-parser/binding-linux-arm-gnueabihf": "0.143.0", "@oxc-parser/binding-linux-arm-musleabihf": "0.143.0", "@oxc-parser/binding-linux-arm64-gnu": "0.143.0", "@oxc-parser/binding-linux-arm64-musl": "0.143.0", "@oxc-parser/binding-linux-ppc64-gnu": "0.143.0", "@oxc-parser/binding-linux-riscv64-gnu": "0.143.0", "@oxc-parser/binding-linux-riscv64-musl": "0.143.0", "@oxc-parser/binding-linux-s390x-gnu": "0.143.0", "@oxc-parser/binding-linux-x64-gnu": "0.143.0", "@oxc-parser/binding-linux-x64-musl": "0.143.0", "@oxc-parser/binding-openharmony-arm64": "0.143.0", "@oxc-parser/binding-win32-arm64-msvc": "0.143.0", "@oxc-parser/binding-win32-ia32-msvc": "0.143.0", "@oxc-parser/binding-win32-x64-msvc": "0.143.0" } }, "sha512-ov0NzaDCOInknS7mP1cwKdJERt3utPW8ldjtdUXQ8Ty0GEFD08wk422vCUN0d7pST6kqtV7dxoI9w1Zi0l/9TA=="],
"oxc-parser": ["oxc-parser@0.148.0", "", { "dependencies": { "@oxc-project/types": "^0.148.0" }, "optionalDependencies": { "@oxc-parser/binding-android-arm-eabi": "0.148.0", "@oxc-parser/binding-android-arm64": "0.148.0", "@oxc-parser/binding-darwin-arm64": "0.148.0", "@oxc-parser/binding-darwin-x64": "0.148.0", "@oxc-parser/binding-freebsd-x64": "0.148.0", "@oxc-parser/binding-linux-arm-gnueabihf": "0.148.0", "@oxc-parser/binding-linux-arm-musleabihf": "0.148.0", "@oxc-parser/binding-linux-arm64-gnu": "0.148.0", "@oxc-parser/binding-linux-arm64-musl": "0.148.0", "@oxc-parser/binding-linux-ppc64-gnu": "0.148.0", "@oxc-parser/binding-linux-riscv64-gnu": "0.148.0", "@oxc-parser/binding-linux-riscv64-musl": "0.148.0", "@oxc-parser/binding-linux-s390x-gnu": "0.148.0", "@oxc-parser/binding-linux-x64-gnu": "0.148.0", "@oxc-parser/binding-linux-x64-musl": "0.148.0", "@oxc-parser/binding-openharmony-arm64": "0.148.0", "@oxc-parser/binding-win32-arm64-msvc": "0.148.0", "@oxc-parser/binding-win32-ia32-msvc": "0.148.0", "@oxc-parser/binding-win32-x64-msvc": "0.148.0" } }, "sha512-syxUKHeUll89RIABQADcI7sikYrwyssvA6gj4phSSIPezKVM8yMaLAiLLSc7fmzVvrwybfFGFbW5zme9sX87rg=="],
"oxc-resolver": ["oxc-resolver@11.24.2", "", { "optionalDependencies": { "@oxc-resolver/binding-android-arm-eabi": "11.24.2", "@oxc-resolver/binding-android-arm64": "11.24.2", "@oxc-resolver/binding-darwin-arm64": "11.24.2", "@oxc-resolver/binding-darwin-x64": "11.24.2", "@oxc-resolver/binding-freebsd-x64": "11.24.2", "@oxc-resolver/binding-linux-arm-gnueabihf": "11.24.2", "@oxc-resolver/binding-linux-arm-musleabihf": "11.24.2", "@oxc-resolver/binding-linux-arm64-gnu": "11.24.2", "@oxc-resolver/binding-linux-arm64-musl": "11.24.2", "@oxc-resolver/binding-linux-ppc64-gnu": "11.24.2", "@oxc-resolver/binding-linux-riscv64-gnu": "11.24.2", "@oxc-resolver/binding-linux-riscv64-musl": "11.24.2", "@oxc-resolver/binding-linux-s390x-gnu": "11.24.2", "@oxc-resolver/binding-linux-x64-gnu": "11.24.2", "@oxc-resolver/binding-linux-x64-musl": "11.24.2", "@oxc-resolver/binding-openharmony-arm64": "11.24.2", "@oxc-resolver/binding-wasm32-wasi": "11.24.2", "@oxc-resolver/binding-win32-arm64-msvc": "11.24.2", "@oxc-resolver/binding-win32-x64-msvc": "11.24.2" } }, "sha512-FY91FiDBj7ls5MsFS9jN3tjz2o0/zsdSsymlakySaBwVJZorHhkWyICLZMKxlu1R9vYo+sd3z1jwb4J8x7bNDw=="],
@@ -1349,7 +1357,7 @@
"playwright": ["playwright@1.63.0", "", { "dependencies": { "playwright-core": "1.63.0" }, "bin": { "playwright": "cli.js" } }, "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg=="],
"playwright-core": ["playwright-core@1.62.1", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw=="],
"playwright-core": ["playwright-core@1.63.0", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg=="],
"pngjs": ["pngjs@5.0.0", "", {}, "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw=="],
@@ -1357,7 +1365,7 @@
"postcss": ["postcss@8.5.28", "", { "dependencies": { "nanoid": "^3.3.18", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A=="],
"posthog-js": ["posthog-js@1.418.6", "", { "dependencies": { "@posthog/browser-common": "^0.5.0", "@posthog/core": "^1.48.6", "@posthog/types": "^1.405.0", "core-js": "^3.49.0", "dompurify": "^3.4.13", "fflate": "^0.4.8", "preact": "^10.29.3", "query-selector-shadow-dom": "^1.0.1", "web-vitals": "^5.3.0", "web-vitals-soft-navs": "npm:web-vitals@6.0.0" } }, "sha512-s1+5sPSOElZ8twPNRQytBhY3fOp+rOkf6tZzS5xSZlNZG7UE/jRWcI0zRqykhMaeJOVwSABMuaeaap7rmX82Pg=="],
"posthog-js": ["posthog-js@1.428.11", "", { "dependencies": { "@posthog/browser-common": "^0.8.2", "@posthog/core": "^1.52.0", "@posthog/types": "^1.409.4", "core-js": "^3.49.0", "dompurify": "^3.4.13", "fflate": "^0.4.8", "preact": "^10.29.3", "query-selector-shadow-dom": "^1.0.1", "web-vitals": "^6.2.1", "web-vitals-soft-navs": "npm:web-vitals@6.2.1" }, "peerDependencies": { "@types/react": ">=16.8.0", "react": ">=16.8.0" }, "optionalPeers": ["@types/react", "react"] }, "sha512-98hMmCYgEclXefLmDHSaZIFzGS2D/1ypytNG51WJsWSJkEznyx6JlizT6m+aVqCXh0SR4u8TEvdloFoxCbP3UQ=="],
"preact": ["preact@10.29.8", "", { "peerDependencies": { "preact-render-to-string": ">=5" }, "optionalPeers": ["preact-render-to-string"] }, "sha512-ej2aVZ+vZ8WO7tvlQWRM9N63A0KzF9q4mWJfDUHgYaIofWY9hu74QdnQrjoPMmZi2/nZ5gN0bJCQF49xQqx09Q=="],
@@ -1373,6 +1381,8 @@
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
"qified": ["qified@0.10.1", "", { "dependencies": { "hookified": "^2.1.1" } }, "sha512-+Owyggi9IxT1ePKGafcI87ubSmxol6smwJ+RAHDQlx9+9cPwFWDiKFFCPuWhr9ignlGpZ9vDQLw67N4dcTVFEA=="],
"qrcode": ["qrcode@1.5.4", "", { "dependencies": { "dijkstrajs": "^1.0.1", "pngjs": "^5.0.0", "yargs": "^15.3.1" }, "bin": { "qrcode": "bin/qrcode" } }, "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg=="],
"quansync": ["quansync@1.0.0", "", {}, "sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA=="],
@@ -1381,13 +1391,13 @@
"radix-vue": ["radix-vue@1.9.17", "", { "dependencies": { "@floating-ui/dom": "^1.6.7", "@floating-ui/vue": "^1.1.0", "@internationalized/date": "^3.5.4", "@internationalized/number": "^3.5.3", "@tanstack/vue-virtual": "^3.8.1", "@vueuse/core": "^10.11.0", "@vueuse/shared": "^10.11.0", "aria-hidden": "^1.2.4", "defu": "^6.1.4", "fast-deep-equal": "^3.1.3", "nanoid": "^5.0.7" }, "peerDependencies": { "vue": ">= 3.2.0" } }, "sha512-mVCu7I2vXt1L2IUYHTt0sZMz7s1K2ZtqKeTIxG3yC5mMFfLBG4FtE1FDeRMpDd+Hhg/ybi9+iXmAP1ISREndoQ=="],
"react": ["react@19.2.8", "", {}, "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw=="],
"react": ["react@19.3.0", "", {}, "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog=="],
"react-dom": ["react-dom@19.2.8", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.8" } }, "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ=="],
"react-dom": ["react-dom@19.3.0", "", { "dependencies": { "scheduler": "^0.28.0" }, "peerDependencies": { "react": "^19.3.0" } }, "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q=="],
"react-hot-toast": ["react-hot-toast@2.6.0", "", { "dependencies": { "csstype": "^3.1.3", "goober": "^2.1.16" }, "peerDependencies": { "react": ">=16", "react-dom": ">=16" } }, "sha512-bH+2EBMZ4sdyou/DPrfgIouFpcRLCJ+HoCA32UoAYHn6T3Ur5yfcDCeSr5mwldl6pFOsiocmrXMuoCJ1vV8bWg=="],
"react-i18next": ["react-i18next@17.0.12", "", { "dependencies": { "@babel/runtime": "^7.29.7", "html-parse-stringify": "^4.0.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "i18next": ">= 26.2.0", "react": ">= 16.8.0", "react-dom": "*", "react-native": "*", "typescript": "^5 || ^6 || ^7" }, "optionalPeers": ["react-dom", "react-native", "typescript"] }, "sha512-lFWPEGkxQ6RhusdUkysFBD58VHfSSzvHBzqMgN0SvfVpdQGfwtNkStTqdy08/sJd7s807qqutgx93fRpD0DJ3Q=="],
"react-i18next": ["react-i18next@17.0.13", "", { "dependencies": { "@babel/runtime": "^7.29.7", "html-parse-stringify": "^4.0.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "i18next": ">= 26.2.0", "react": ">= 16.8.0", "react-dom": "*", "react-native": "*", "typescript": "^5 || ^6 || ^7" }, "optionalPeers": ["react-dom", "react-native", "typescript"] }, "sha512-Cc1PscmblIHA1kljTqDwrcVMI21ydgmUzw0UAeQBe7pAOgfuRLfzXze4EUBQoeDiICzFIXXhHFoZxuetNg5D0Q=="],
"react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="],
@@ -1397,7 +1407,7 @@
"react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="],
"react-window": ["react-window@2.3.0", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } }, "sha512-FW6TIpaOH646k51X7yE+LSCWGkt5Pfsnc1fVyq/sCI9h0pTqmMiBXM04pzFKg3Bt7NGkeV6kqbU8d/QjmFS7Ug=="],
"react-window": ["react-window@2.3.1", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } }, "sha512-x/+N6b7FNVtlKE7ZQlfOY2bYdA7VXvT2B1Emo/ndAsFsSQ98FBIO88MsatNELbSIlYJgkCd7unBps5XMfv0Eyg=="],
"redent": ["redent@3.0.0", "", { "dependencies": { "indent-string": "^4.0.0", "strip-indent": "^3.0.0" } }, "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg=="],
@@ -1439,7 +1449,7 @@
"saxes": ["saxes@6.0.0", "", { "dependencies": { "xmlchars": "^2.2.0" } }, "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA=="],
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
"scheduler": ["scheduler@0.28.0", "", {}, "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw=="],
"semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
@@ -1471,13 +1481,13 @@
"string-byte-slice": ["string-byte-slice@3.0.1", "", {}, "sha512-GWv2K4lYyd2+AhmKH3BV+OVx62xDX+99rSLfKpaqFiQU7uOMaUY1tDjdrRD4gsrCr9lTyjMgjna7tZcCOw+Smg=="],
"string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
"string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="],
"stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="],
"stringify-object": ["stringify-object@6.0.0", "", { "dependencies": { "get-own-enumerable-keys": "^1.0.0", "is-identifier": "^1.0.1", "is-obj": "^3.0.0", "is-regexp": "^3.1.0" } }, "sha512-6f94vIED6vmJJfh3lyVsVWxCYSfI5uM+16ntED/Ql37XIyV6kj0mRAAiTeMMc/QLYIaizC3bUprQ8pQnDDrKfA=="],
"strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="],
"strip-indent": ["strip-indent@3.0.0", "", { "dependencies": { "min-indent": "^1.0.0" } }, "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ=="],
@@ -1487,7 +1497,7 @@
"super-regex": ["super-regex@1.1.0", "", { "dependencies": { "function-timeout": "^1.0.1", "make-asynchronous": "^1.0.1", "time-span": "^5.1.0" } }, "sha512-WHkws2ZflZe41zj6AolvvmaTrWds/VuyeYr9iPVv/oQeaIoVxMKaushfFWpOGDT+GuBrM/sVqF8KUCYQlSSTdQ=="],
"supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="],
"supports-color": ["supports-color@10.2.2", "", {}, "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g=="],
"swrv": ["swrv@1.2.0", "", { "peerDependencies": { "vue": ">=3.2.26 < 4" } }, "sha512-lH/g4UcNyj+7lzK4eRGT4C68Q4EhQ6JtM9otPRIASfhhzfLWtbZPHcMuhuba7S9YVYuxkMUGImwMyGpfbkH07A=="],
@@ -1503,7 +1513,7 @@
"tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="],
"taze": ["taze@19.17.2", "", { "dependencies": { "@antfu/ni": "^30.3.0", "@henrygd/queue": "^1.2.0", "cac": "^7.0.0", "obug": "^2.1.4", "ofetch": "^1.5.1", "package-manager-detector": "^1.8.0", "pathe": "^2.0.3", "pnpm-workspace-yaml": "^1.7.0", "restore-cursor": "^5.1.0", "tinyexec": "^1.3.0", "tinyglobby": "^0.2.17", "unconfig": "^7.5.0", "verkit": "^0.3.1", "yaml": "^2.9.0" }, "bin": { "taze": "bin/taze.mjs" } }, "sha512-1WH+LUf5H0R07EF606fuP5TuHPVvtrMIPpn69EkqaOVJYg0foL8xnD+lEs0s19Zfmplul5nRd3TpbbF0twXCkw=="],
"taze": ["taze@21.1.0", "", { "dependencies": { "@antfu/ni": "^30.5.0", "@henrygd/queue": "^1.2.0", "cac": "^7.0.0", "obug": "^2.1.4", "ofetch": "^1.5.1", "package-manager-detector": "^1.8.0", "pathe": "^2.0.3", "pnpm-workspace-yaml": "^1.8.0", "restore-cursor": "^5.1.0", "tinyexec": "^1.3.0", "tinyglobby": "^0.2.17", "unconfig": "^7.5.0", "verkit": "^0.3.2", "yaml": "^2.9.0" }, "bin": { "taze": "bin/taze.mjs" } }, "sha512-NkFkadmqqpaVZ9x3bV4cul1xQnUknhs1HzE3Q/VXHKS7np2Kg7ZNL7nNsEsBXKsafmgepa+aZNtzoegDgsymPw=="],
"time-span": ["time-span@5.1.0", "", { "dependencies": { "convert-hrtime": "^5.0.0" } }, "sha512-75voc/9G4rDIJleOo4jPvN4/YC4GRZrY8yy1uU4lwrB3XEQbWve8zXoO5No4eFrGcTAMYyoY67p8jRQdtA1HbA=="],
@@ -1553,7 +1563,7 @@
"unconfig-core": ["unconfig-core@7.5.0", "", { "dependencies": { "@quansync/fs": "^1.0.0", "quansync": "^1.0.0" } }, "sha512-Su3FauozOGP44ZmKdHy2oE6LPjk51M/TRRjHv2HNCWiDvfvCoxC2lno6jevMA91MYAdCdwP05QnWdWpSbncX/w=="],
"undici": ["undici@7.29.1", "", {}, "sha512-RYONW2MeafgYlkVOKYKkA/Ag7BmXqgIWCa8t1m0JcxrQg9pI9lEqRhAOruOBCbAohOa/gkCF+iPi9hrgvTzu6Q=="],
"undici": ["undici@8.10.2", "", {}, "sha512-/y4/bH9YNU5hi9NIrpOuvGXFcxrj3CMrV+/AYpowAYTpHn8gX/XPFjNy766FPoYY0miQhdW977JFWKGNhBdwyQ=="],
"undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
@@ -1615,9 +1625,9 @@
"web-namespaces": ["web-namespaces@2.0.1", "", {}, "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ=="],
"web-vitals": ["web-vitals@5.3.0", "", {}, "sha512-q6LWsLatGYZp5VGBIOvbTj6JBV2nOmC8KvWztXBmwJcfFAzhwKwbOxhUH306XY3CcaZDUlSmSuNPBsCn0bFu+g=="],
"web-vitals": ["web-vitals@6.2.1", "", {}, "sha512-rLcLXA2sx6+9dE88NHFubwTtGxpK4yYBLj6qHPdFoCaLr0cXGb4efOqtKLlm4loGA4OEKHIQKMKzZkKyOh5ctw=="],
"web-vitals-soft-navs": ["web-vitals@6.0.0", "", {}, "sha512-Guaibvy/+uNtL6Bsu4jmMJGzuSl91oeRH5iO9pPRbYftnFUr3yqT1TUNX/OE4o9HexuEMU3Kb/Wg7iKhlffZUA=="],
"web-vitals-soft-navs": ["web-vitals@6.2.1", "", {}, "sha512-rLcLXA2sx6+9dE88NHFubwTtGxpK4yYBLj6qHPdFoCaLr0cXGb4efOqtKLlm4loGA4OEKHIQKMKzZkKyOh5ctw=="],
"web-worker": ["web-worker@1.5.0", "", {}, "sha512-RiMReJrTAiA+mBjGONMnjVDP2u3p9R1vkcGz6gDIrOMT3oGuYwX2WRMYI9ipkphSuE5XKEhydbhNEJh4NY9mlw=="],
@@ -1625,7 +1635,7 @@
"whatwg-mimetype": ["whatwg-mimetype@5.0.0", "", {}, "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw=="],
"whatwg-url": ["whatwg-url@16.0.1", "", { "dependencies": { "@exodus/bytes": "^1.11.0", "tr46": "^6.0.0", "webidl-conversions": "^8.0.1" } }, "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw=="],
"whatwg-url": ["whatwg-url@17.1.0", "", { "dependencies": { "@exodus/bytes": "^1.15.1", "tr46": "^6.0.0", "webidl-conversions": "^8.0.1" } }, "sha512-3GeworPmc2ZfEEHP7lEbUfBX/L75wdEsi0rLNhXcXxnoN5jyq0SL5gCy06SGW2cyTIZdTvWIDQNQoza++vKeaw=="],
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
@@ -1635,7 +1645,7 @@
"word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="],
"wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
"wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="],
"xml-name-validator": ["xml-name-validator@5.0.0", "", {}, "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg=="],
@@ -1647,9 +1657,9 @@
"yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="],
"yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="],
"yargs": ["yargs@18.0.0", "", { "dependencies": { "cliui": "^9.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "string-width": "^7.2.0", "y18n": "^5.0.5", "yargs-parser": "^22.0.0" } }, "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg=="],
"yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="],
"yargs-parser": ["yargs-parser@22.0.0", "", {}, "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw=="],
"yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
@@ -1671,14 +1681,10 @@
"@floating-ui/vue/@floating-ui/utils": ["@floating-ui/utils@0.2.12", "", {}, "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww=="],
"@playwright/test/playwright": ["playwright@1.62.1", "", { "dependencies": { "playwright-core": "1.62.1" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg=="],
"@scalar/api-client/nanoid": ["nanoid@5.1.16", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-kVrnsrJqMR8+oLJnGEmSWw9BivK5mt7H3FZatVRjrc5wGqFYuBxX1yG7+A7Gi5AefkX6t/oCkizcQgpu0cY1dQ=="],
"@scalar/api-reference/nanoid": ["nanoid@5.1.16", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-kVrnsrJqMR8+oLJnGEmSWw9BivK5mt7H3FZatVRjrc5wGqFYuBxX1yG7+A7Gi5AefkX6t/oCkizcQgpu0cY1dQ=="],
"@scalar/icons/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
"@scalar/themes/nanoid": ["nanoid@5.1.16", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-kVrnsrJqMR8+oLJnGEmSWw9BivK5mt7H3FZatVRjrc5wGqFYuBxX1yG7+A7Gi5AefkX6t/oCkizcQgpu0cY1dQ=="],
"@scalar/types/nanoid": ["nanoid@5.1.16", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-kVrnsrJqMR8+oLJnGEmSWw9BivK5mt7H3FZatVRjrc5wGqFYuBxX1yG7+A7Gi5AefkX6t/oCkizcQgpu0cY1dQ=="],
@@ -1699,6 +1705,8 @@
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"@tanstack/vue-virtual/@tanstack/virtual-core": ["@tanstack/virtual-core@3.17.8", "", {}, "sha512-BfEvehNpOT75r5Ksc5xW6NZuXujTfb7nlSEyVu4XHG3gdxNg1KqXruWbDewXOUaUYIo4oRbSfkjIajz4MAT8tA=="],
"@testing-library/dom/aria-query": ["aria-query@5.3.0", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A=="],
"@testing-library/dom/dom-accessibility-api": ["dom-accessibility-api@0.5.16", "", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="],
@@ -1709,7 +1717,7 @@
"@vue/compiler-sfc/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
"chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
"data-urls/whatwg-url": ["whatwg-url@16.0.1", "", { "dependencies": { "@exodus/bytes": "^1.11.0", "tr46": "^6.0.0", "webidl-conversions": "^8.0.1" } }, "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw=="],
"hast-util-from-html/parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="],
@@ -1721,9 +1729,7 @@
"mdast-util-find-and-replace/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="],
"playwright/playwright-core": ["playwright-core@1.63.0", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg=="],
"pretty-format/ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="],
"qified/hookified": ["hookified@2.2.0", "", {}, "sha512-p/LgFzRN5FeoD3DLS6bkUapeye6E4SI6yJs6KetENd18S+FBthqYq2amJUWpt5z0EQwwHemidjY5OqJGEKm5uA=="],
"qrcode/yargs": ["yargs@15.4.1", "", { "dependencies": { "cliui": "^6.0.0", "decamelize": "^1.2.0", "find-up": "^4.1.0", "get-caller-file": "^2.0.1", "require-directory": "^2.1.1", "require-main-filename": "^2.0.0", "set-blocking": "^2.0.0", "string-width": "^4.2.0", "which-module": "^2.0.0", "y18n": "^4.0.0", "yargs-parser": "^18.1.2" } }, "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A=="],
@@ -1735,9 +1741,9 @@
"radix-vue/nanoid": ["nanoid@5.1.16", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-kVrnsrJqMR8+oLJnGEmSWw9BivK5mt7H3FZatVRjrc5wGqFYuBxX1yG7+A7Gi5AefkX6t/oCkizcQgpu0cY1dQ=="],
"rolldown/@oxc-project/types": ["@oxc-project/types@0.148.0", "", {}, "sha512-Nm4s/jB+4FpFsPhWGEC4h7rzksesmtnMXomo6rCMcg/b8zLQuOziRgkCS1fxDCXOlJB/6Q8oABOZ/OP6RIPj9A=="],
"strip-ansi/ansi-regex": ["ansi-regex@6.3.0", "", {}, "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ=="],
"@playwright/test/playwright/fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="],
"wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
"@tailwindcss/node/lightningcss/lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="],
@@ -1769,6 +1775,8 @@
"qrcode/yargs/find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="],
"qrcode/yargs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
"qrcode/yargs/y18n": ["y18n@4.0.3", "", {}, "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ=="],
"qrcode/yargs/yargs-parser": ["yargs-parser@18.1.3", "", { "dependencies": { "camelcase": "^5.0.0", "decamelize": "^1.2.0" } }, "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ=="],
@@ -1779,10 +1787,18 @@
"radix-vue/@vueuse/core/@vueuse/metadata": ["@vueuse/metadata@10.11.1", "", {}, "sha512-IGa5FXd003Ug1qAZmyE8wF3sJ81xGLSqTqtQ6jaVfkeZ4i5kS2mwQF61yhVqojRnenVew5PldLyRgvdl4YYuSw=="],
"qrcode/yargs/cliui/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"qrcode/yargs/cliui/wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="],
"qrcode/yargs/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="],
"qrcode/yargs/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
"qrcode/yargs/string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"qrcode/yargs/cliui/wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
"qrcode/yargs/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="],
"qrcode/yargs/find-up/locate-path/p-locate/p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="],
+11 -1
View File
@@ -12,7 +12,7 @@ own microphone audio to the versioned WebSocket API. See the
## Use it
1. Choose an installed dictation model in the Model Catalogue.
1. Install a dictation model from the Model Catalogue, then pick it in **Settings → Voice** or from the top-bar Engines menu (**Transcription → Sherpa-ONNX dictation → Dictation model**). The same choice is what the Sherpa-ONNX engine loads for dubbing and batch transcription.
2. Set the shortcut and hold/toggle behavior in **Settings → Hotkey**.
3. Put the cursor in a text field, press the shortcut, speak, then release or
press again.
@@ -75,3 +75,13 @@ changes. `dotool` needs direct write access to `/dev/uinput`; `ydotool` 1.0+
needs a running `ydotoold` with that access and a user-readable socket.
VoiceStudio checks these prerequisites before selection. Tray-started Wayland
dictation always stays copy-only.
### Transcriptions model setup
Transcriptions checks the active dictation model before enabling **Start dictation**. If weights are missing, the page lists every dictation model, grouped by the trade-off you are choosing between — best accuracy (offline, transcribes after you stop) versus lowest latency (streaming, live text while you speak) — with languages and download size on each row, so you install the one that fits your work rather than only the recommended default. A model already on disk can be switched to without a download. The chosen model's name, download size, and installation progress are shown. Downloads require an explicit click. Failed downloads can be retried, and model state refreshes when returning from Settings. Once installation is verified, Start dictation becomes available; recording never starts automatically. Existing transcription history remains accessible during setup.
### Floating recording controls
The recording bubble includes **Pause / Resume**, **Stop**, and **Close**. Pause disables microphone tracks, stops sending new audio, and freezes the elapsed recording timer while preserving the session. Resume continues the same session. Stop (or the recording shortcut) finishes and transcribes, including when paused. Close cancels pending recording/transcription and releases the microphone; text already delivered to another app cannot be retracted. Pausing is manual, not triggered by silence or desktop inactivity.
The Transcriptions page displays the effective recording shortcut and the platform paste shortcut. The floating bubble places its live transcript below the controls in a multiline preview, so controls cannot squeeze the text into a few characters.
+2 -1
View File
@@ -300,7 +300,8 @@ version** reverts to the build the app shipped with.
First update yt-dlp under **Settings → Audio tools**. If YouTube still requires
your signed-in session, export its cookies in Netscape `cookies.txt` format,
then choose that file beside the URL field before importing. VoiceStudio uses
then open **Advanced** on the dubbing import card and choose that file under
**YouTube sign-in** before importing. VoiceStudio uses
the export for that import only and makes two best-effort attempts to delete
its temporary copy.
@@ -31,11 +31,11 @@ Nothing studio-grade is on the default surface; everything is one click away.
### Full-height pro workflow (2026-08-11)
1. **Start working immediately:** on a pristine install, Stories creates and opens **The Lighthouse at Wits' End** as a normal saved project. Its 2 chapters, 3-character cast, pauses, expressive tags, and voice assignments exercise the real preview, stems, and Generate paths—not a visual mock.
2. **Set up on the left:** a persistent production rail owns project naming/saving, saved projects, cast-to-voice mapping, global pacing, and stems. Sections collapse independently without covering the manuscript.
2. **Choose a workspace tab:** Script holds the manuscript, Cast maps characters to voices, Export holds pacing and output options, and Projects handles naming, saving, and opening stories. Tabs share the Clone workspace navigation and support keyboard arrow keys.
3. **Write in the center:** the full-height manuscript canvas owns import, paste/auto-cast, line and chapter creation, editing, reorder, preview, and per-line direction. Long stories stay fast through `content-visibility`.
4. **Deliver from the header:** story length, runtime, format, progress, and Generate stay reachable while the manuscript scrolls.
4. **Deliver from Export:** choose the audio format and reading speed, generate the complete story, or export character stems. Story length and estimated runtime remain visible in the header.
The hierarchy is spatial instead of label-heavy: setup rail → manuscript → output header. The default sample is authored content stored through the same project actions as user work, automatically adopts installed voice profiles, and remains fully editable or deletable.
The task tabs give the manuscript the full workspace width; project data and unfinished text remain intact when switching tabs. The default sample is authored content stored through the same project actions as user work, automatically adopts installed voice profiles, and remains fully editable or deletable.
## 3. Interaction model (chosen: line cards)
+1 -1
View File
@@ -12,7 +12,7 @@ import globals from 'globals';
import { defineConfig, globalIgnores } from 'eslint/config';
export default defineConfig([
globalIgnores(['dist']),
globalIgnores(['dist', 'src-tauri']),
{
files: ['**/*.{js,jsx}'],
extends: [reactHooks.configs.flat.recommended],
+24 -24
View File
@@ -40,29 +40,29 @@
"@radix-ui/react-toggle": "^1.1.18",
"@radix-ui/react-toggle-group": "^1.1.19",
"@radix-ui/react-tooltip": "^1.2.16",
"@scalar/api-reference-react": "^0.9.63",
"@scalar/api-reference-react": "^0.9.67",
"@tailwindcss/vite": "^4.3.3",
"@tanstack/react-query": "^5.101.4",
"@tanstack/react-query": "^5.102.8",
"@tanstack/react-table": "^8.21.3",
"@tanstack/react-virtual": "^3.14.9",
"@tauri-apps/plugin-dialog": "^2.7.2",
"@tauri-apps/plugin-opener": "^2.5.4",
"@tanstack/react-virtual": "^3.14.11",
"@tauri-apps/plugin-dialog": "^2.7.3",
"@tauri-apps/plugin-opener": "^2.5.5",
"@tauri-apps/plugin-process": "^2.3.1",
"@tauri-apps/plugin-updater": "^2.10.1",
"@tauri-apps/plugin-updater": "^2.11.0",
"@tauri-apps/plugin-window-state": "^2.4.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"country-flag-icons": "^1.6.20",
"i18next": "^26.3.6",
"i18next": "^26.4.2",
"i18next-browser-languagedetector": "^8.2.1",
"lucide-react": "^1.31.0",
"posthog-js": "^1.417.0",
"lucide-react": "^1.43.0",
"posthog-js": "^1.428.11",
"qrcode": "^1.5.4",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"react": "^19.3.0",
"react-dom": "^19.3.0",
"react-hot-toast": "^2.6.0",
"react-i18next": "^17.0.11",
"react-window": "^2.3.0",
"react-i18next": "^17.0.13",
"react-window": "^2.3.1",
"tailwind-merge": "^3.6.0",
"tailwindcss": "^4.3.3",
"tw-animate-css": "^1.4.0",
@@ -70,24 +70,24 @@
"zustand": "^5.0.15"
},
"devDependencies": {
"@playwright/test": "^1.62.1",
"@playwright/test": "1.63.0",
"@tauri-apps/api": "^2.11.1",
"@tauri-apps/cli": "^2.11.4",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/jest-dom": "^7.0.1",
"@testing-library/react": "^16.3.3",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.4",
"@vitejs/plugin-react": "^6.0.5",
"eslint": "^10.8.1",
"@types/react-dom": "^19.2.7",
"@vitejs/plugin-react": "^6.1.1",
"eslint": "^10.10.0",
"eslint-plugin-react-hooks": "^7.1.1",
"globals": "^17.11.0",
"jsdom": "^29.1.1",
"knip": "^6.32.2",
"globals": "^17.12.0",
"jsdom": "^30.0.1",
"knip": "^6.35.1",
"oxfmt": "^0.57.0",
"oxlint": "1.71.0",
"playwright-core": "1.62.1",
"playwright-core": "1.63.0",
"typescript": "^6.0.3",
"vite": "^8.2.1",
"vite": "^8.2.2",
"vitest": "4.1.11"
}
}
+699 -923
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1136,7 +1136,7 @@ pub fn run() {
WebviewUrl::App("index.html".into()),
)
.title("Capture")
.inner_size(300.0, 64.0)
.inner_size(460.0, 164.0)
.resizable(false)
.transparent(true)
.decorations(false)
+2 -2
View File
@@ -31,8 +31,8 @@
{
"label": "widget",
"title": "Capture",
"width": 300,
"height": 64,
"width": 460,
"height": 164,
"resizable": false,
"fullscreen": false,
"transparent": true,
+2 -2
View File
@@ -21,8 +21,8 @@
{
"label": "widget",
"title": "Capture",
"width": 300,
"height": 64,
"width": 460,
"height": 164,
"resizable": false,
"fullscreen": false,
"transparent": true,
+6 -20
View File
@@ -45,7 +45,7 @@ import NavRail from './components/NavRail';
import TitleTabs from './components/TitleTabs';
import WorkspaceHistory from './components/WorkspaceHistory';
import WorkspaceVoices from './components/WorkspaceVoices';
import WorkspaceProjects from './components/WorkspaceProjects';
import DubWorkspaceSidebar from './components/DubWorkspaceSidebar';
import ErrorBoundary from './components/ErrorBoundary';
import FloatingPill from './components/FloatingPill';
import GlobalAudioPlayer from './components/GlobalAudioPlayer';
@@ -1578,19 +1578,6 @@ function App() {
<div
className={`studio-with-history ${dubStep === 'idle' ? '' : 'studio-with-history--editing'}`}
>
{dubStep === 'idle' && (
<div className="studio-projects">
<WorkspaceProjects
projects={studioProjects}
activeProjectId={activeProjectId}
canSave={false}
saveProject={saveProject}
loadProject={loadProject}
deleteProject={deleteProject}
renameProject={renameProject}
/>
</div>
)}
<div className="studio-with-history__main">
<ErrorBoundary name="dub">
<Suspense fallback={<LazyFallback />}>
@@ -1654,13 +1641,11 @@ function App() {
</Suspense>
</ErrorBoundary>
</div>
{/* Dub home: the Projects + History landing shows only when no project
is being edited. Opening/creating one switches to the full-width
editor (dubStep !== 'idle'). */}
{dubStep === 'idle' && (
{/* Keep the start screen focused after a source or project is selected.
The combined library rail is only part of the pristine Dub landing. */}
{dubStep === 'idle' && !dubVideoFile && !dubJobId && !activeProjectId && (
<div className="studio-right">
<WorkspaceHistory
variant="dub"
<DubWorkspaceSidebar
dubHistory={dubHistory}
restoreDubHistory={restoreDubHistory}
deleteHistory={deleteHistory}
@@ -1800,6 +1785,7 @@ function App() {
saveProject={saveProject}
loadProject={loadProject}
deleteProject={deleteProject}
renameProject={renameProject}
handleSelectProfile={handleSelectProfile}
handleDeleteProfile={handleDeleteProfile}
handleOpenVoiceProfile={openVoiceProfile}
+155
View File
@@ -0,0 +1,155 @@
/**
* AsrModelChooser the "no speech-to-text model installed" empty state's
* model picker.
*
* The page used to offer exactly one button: download the backend's
* recommended model. That hid the six other catalogue models the English
* Parakeet that is markedly more accurate, the 2544 MB streaming models that
* show text while you speak, the bilingual zh/en ones behind Settings, and
* a user who already had one of them on disk still saw "download Whisper
* Tiny". This lists the whole sherpa-onnx catalogue grouped by what the user
* is choosing between (accuracy vs latency), with languages and size on every
* row, and lets them install any of them, or switch to one already installed.
*
* Falls back to the single recommended-download button when the catalogue
* can't be read (backend restarting, older backend), so the page is never
* left without a way forward.
*/
import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Check, Download } from 'lucide-react';
import { apiJson } from '../api/client';
import { Badge, Button } from '../ui';
function fmtSize(sizeGb) {
if (sizeGb == null) return '';
if (sizeGb < 1) return `${Math.round(sizeGb * 1000)} MB`;
return `${sizeGb.toFixed(sizeGb < 10 ? 1 : 0)} GB`;
}
// Offline models transcribe once you stop (best accuracy); streaming ones emit
// partial text as you speak (lowest latency). That is the real trade-off, so
// it is the grouping not the engine's internal `kind`.
const GROUPS = [
{ tag: 'offline', title: 'asr_missing.group_offline' },
{ tag: 'streaming', title: 'asr_missing.group_streaming' },
];
export default function AsrModelChooser({ fallback, onInstall, onSelect, disabled = false }) {
const { t } = useTranslation();
// null = loading, [] = catalogue unavailable.
const [models, setModels] = useState(null);
useEffect(() => {
let live = true;
apiJson('/dictation/models')
.then((data) => {
if (live) setModels(Array.isArray(data?.models) ? data.models : []);
})
.catch(() => {
if (live) setModels([]);
});
return () => {
live = false;
};
}, []);
if (models === null) return null;
if (models.length === 0) {
if (!fallback?.repo_id) return null;
return (
<Button
size="sm"
variant="primary"
leading={<Download size={13} />}
disabled={disabled}
onClick={() => onInstall(fallback)}
>
{t('asr_missing.download', { label: fallback.label, size: fallback.size_gb })}
</Button>
);
}
const desc = (m) => t(`voicePanel.model_desc.${m.id}`, { defaultValue: m.languages || '' });
return (
<div className="flex flex-col gap-3" data-testid="asr-model-chooser">
<p className="m-0 text-xs text-fg-muted">{t('asr_missing.choose')}</p>
{GROUPS.map(({ tag, title }) => {
const rows = models
.filter((m) => m.tag === tag)
.sort((a, b) => Number(b.recommended) - Number(a.recommended));
if (rows.length === 0) return null;
return (
<section key={tag} aria-labelledby={`asr-group-${tag}`} className="flex flex-col gap-1">
<h3
id={`asr-group-${tag}`}
className="m-0 text-[11px] font-semibold uppercase tracking-[0.06em] text-fg-muted"
>
{t(title)}
</h3>
<ul className="m-0 flex list-none flex-col gap-1 p-0">
{rows.map((m) => (
<li
key={m.id}
data-testid={`asr-choice-${m.id}`}
className="flex flex-wrap items-center gap-2 rounded-md border border-border bg-bg px-3 py-2"
>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-1.5">
<span className="text-sm font-semibold text-fg">{m.label}</span>
{m.recommended && (
<Badge tone="success" size="xs">
{t('voicePanel.badge_recommended')}
</Badge>
)}
{m.installed && (
<Badge tone="neutral" size="xs">
{t('asr_missing.installed')}
</Badge>
)}
<span className="text-xs tabular-nums text-fg-muted">
{fmtSize(m.size_gb)}
</span>
</div>
<p className="m-0 text-xs text-fg-muted">
{desc(m)}
{m.languages && (
<>
{' · '}
<span className="whitespace-nowrap">{m.languages}</span>
</>
)}
</p>
</div>
{m.installed ? (
<Button
size="sm"
variant="subtle"
leading={<Check size={13} />}
disabled={disabled}
onClick={() => onSelect(m)}
>
{t('asr_missing.use', { label: m.label })}
</Button>
) : (
<Button
size="sm"
variant={m.recommended ? 'primary' : 'subtle'}
leading={<Download size={13} />}
disabled={disabled}
onClick={() => onInstall(m)}
>
{t('asr_missing.download', { label: m.label, size: m.size_gb })}
</Button>
)}
</li>
))}
</ul>
</section>
);
})}
</div>
);
}
+78 -11
View File
@@ -1,6 +1,6 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { copyText } from '../utils/copyText';
import { X, Loader } from 'lucide-react';
import { X, Loader, Pause, Play, Square } from 'lucide-react';
import { toast } from 'react-hot-toast';
import { useAppStore } from '../store';
import { useTranslation } from 'react-i18next';
@@ -374,6 +374,8 @@ export default function CaptureWidget({ onDismiss }) {
const { t } = useTranslation();
const [state, setState] = useState('idle'); // idle | setup | recording | transcribing | done | error
const [transcript, setTranscript] = useState('');
const [paused, setPaused] = useState(false);
const pausedRef = useRef(false);
const [duration, setDuration] = useState(0);
const [captureMode] = useState(() => localStorage.getItem(LS_CAPTURE_MODE) || 'fast');
const [, setLastEngine] = useState('');
@@ -968,13 +970,17 @@ export default function CaptureWidget({ onDismiss }) {
// Timer while recording
useEffect(() => {
if (state === 'recording') {
const t0 = Date.now();
timerRef.current = setInterval(() => setDuration(Date.now() - t0), 100);
if (state === 'recording' && !paused) {
let previous = Date.now();
timerRef.current = setInterval(() => {
const now = Date.now();
setDuration((elapsed) => elapsed + now - previous);
previous = now;
}, 100);
return () => clearInterval(timerRef.current);
}
clearInterval(timerRef.current);
}, [state]);
}, [state, paused]);
// Waveform poll: 50 ms 23 worklet frames, so bars visibly move well
// within ~100 ms of mic start. Only runs while the worklet is feeding us.
@@ -992,6 +998,8 @@ export default function CaptureWidget({ onDismiss }) {
dismissTimerRef.current = null;
}
if (aecModeRef.current || sherpaModeRef.current || pcmModeRef.current) teardownAec();
pausedRef.current = false;
setPaused(false);
setState('idle');
setTranscript('');
setPartialText('');
@@ -1525,6 +1533,7 @@ export default function CaptureWidget({ onDismiss }) {
}
}
wsPendingRef.current = [];
if (pausedRef.current) ws.send('PAUSE');
};
ws.onmessage = async (evt) => {
if (!isCurrent() || wsRef.current !== ws) return;
@@ -1780,7 +1789,7 @@ export default function CaptureWidget({ onDismiss }) {
return;
}
const sendBuf = (buf) => {
if (!isCurrent()) return;
if (!isCurrent() || pausedRef.current) return;
const ws = wsRef.current;
if (ws && ws.readyState === WebSocket.OPEN) {
try {
@@ -1806,6 +1815,7 @@ export default function CaptureWidget({ onDismiss }) {
const stopMicCapture = await startMicCapture(
stream,
(f) => {
if (pausedRef.current) return;
waveRef.current.push(f);
sendTagged(f, AEC_NEAR);
},
@@ -1825,6 +1835,7 @@ export default function CaptureWidget({ onDismiss }) {
const stopMicCapture = await startMicCapture(
stream,
(f) => {
if (pausedRef.current) return;
waveRef.current.push(f);
const i16 = floatToInt16(f);
sendBuf(i16.buffer.slice(i16.byteOffset, i16.byteOffset + i16.byteLength));
@@ -1855,6 +1866,8 @@ export default function CaptureWidget({ onDismiss }) {
stopCaptureGraph();
return;
}
pausedRef.current = false;
setPaused(false);
startTimeRef.current = Date.now();
setTrayRecording(true);
setWaveOn(pcmMode);
@@ -1965,6 +1978,21 @@ export default function CaptureWidget({ onDismiss }) {
[startRecordingImpl],
);
const togglePause = useCallback(() => {
if (stateRef.current !== 'recording') return;
const next = !pausedRef.current;
const recorder = mediaRecorderRef.current;
if (recorder?.state === 'recording' && next) recorder.pause();
else if (recorder?.state === 'paused' && !next) recorder.resume();
pausedRef.current = next;
streamRef.current?.getTracks().forEach((track) => {
track.enabled = !next;
});
const ws = wsRef.current;
if (ws?.readyState === WebSocket.OPEN) ws.send(next ? 'PAUSE' : 'RESUME');
setPaused(next);
}, []);
const stopRecording = useCallback(() => {
const generation = captureGenerationRef.current;
const sessionId = outputSessionIdRef.current;
@@ -2180,7 +2208,9 @@ export default function CaptureWidget({ onDismiss }) {
// Pill label
let label = '';
let emoji = '';
if (state === 'setup') {
if (state === 'recording' && paused) {
label = t('common.paused');
} else if (state === 'setup') {
// One-time Accessibility setup shown instead of pretending to work.
emoji = '🔒';
label = t('capture.a11y_setup');
@@ -2224,9 +2254,13 @@ export default function CaptureWidget({ onDismiss }) {
state === 'error' && errorInfo?.kind === 'mic' && errorInfo?.deniedByOs && inTauri();
return (
<div className={`capture-pill capture-pill--${state}`} role="status" aria-live="polite">
<div
className={`capture-pill capture-pill--${state === 'recording' && paused ? 'paused' : state}`}
role="status"
aria-live="polite"
>
{/* Live waveform while the worklet feeds us; pulsing dot otherwise */}
{state === 'recording' && waveOn && !modelStatus ? (
{state === 'recording' && !paused && waveOn && !modelStatus ? (
<div className="capture-pill__wave" aria-hidden="true">
{bars.map((v, i) => (
<span
@@ -2241,9 +2275,9 @@ export default function CaptureWidget({ onDismiss }) {
)}
{/* Content */}
<div className="min-w-0 flex-1 overflow-hidden">
<div className="capture-pill__preview">
<span
className="block overflow-hidden text-ellipsis whitespace-nowrap text-[12.5px] font-medium tracking-[0.01em]"
className="block text-[14px] leading-[1.5] font-medium"
title={state === 'error' ? errorInfo?.message || label || undefined : label || undefined}
>
{emoji} {label}
@@ -2287,6 +2321,39 @@ export default function CaptureWidget({ onDismiss }) {
</button>
)}
{state === 'recording' && (
<>
<button
type="button"
className="capture-pill__control"
onClick={togglePause}
aria-label={t(paused ? 'common.resume' : 'common.pause')}
title={t(paused ? 'common.resume' : 'common.pause')}
>
{paused ? <Play size={14} /> : <Pause size={14} />}
</button>
<button
type="button"
className="capture-pill__control"
onClick={stopRecording}
aria-label={t('common.stop')}
title={t('common.stop')}
>
<Square size={12} />
</button>
</>
)}
{(state === 'recording' || state === 'transcribing') && (
<button
type="button"
className="capture-pill__control"
onClick={cancelSession}
aria-label={t('common.close')}
title={t('common.close')}
>
<X size={14} />
</button>
)}
{/* Dismiss — done/error/setup */}
{(state === 'done' || state === 'error' || state === 'setup') && (
<button
@@ -0,0 +1,129 @@
import React, { useEffect, useState } from 'react';
import { Check, Download, Loader } from 'lucide-react';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { apiJson } from '../api/client';
import { useInstallModel } from '../api/hooks';
import { useAppStore } from '../store';
function fmtSize(sizeGb) {
if (sizeGb == null) return '';
if (sizeGb < 1) return `${Math.round(sizeGb * 1000)} MB`;
return `${sizeGb.toFixed(sizeGb < 10 ? 1 : 0)} GB`;
}
/**
* The sherpa-onnx dictation model list, nested under the "Sherpa-ONNX
* dictation" row of the Transcription engine picker. The engine row switches
* the *engine*; this switches which of the seven catalogue models that engine
* (and the live-dictation hotkey) loads the same `dictation.model_id` pref
* the Settings Voice panel writes, so both surfaces always agree.
*
* Picking a model that isn't downloaded yet persists the choice and starts
* the download in one click (mirrors VoicePanel.onPick): the user gets the
* model the moment it lands rather than a second "now install it" step.
*/
export default function DictationModelPicker({ embedded = false }) {
const { t } = useTranslation();
const modelId = useAppStore((s) => s.dictationModelId);
const setModelId = useAppStore((s) => s.setDictationModelId);
const loadPrefs = useAppStore((s) => s.loadDictationPrefs);
const dictationLoaded = useAppStore((s) => s.dictationLoaded);
const installMutation = useInstallModel();
const [installing, setInstalling] = useState(() => new Set());
useEffect(() => {
if (!dictationLoaded) loadPrefs();
}, [dictationLoaded, loadPrefs]);
const { data, refetch } = useQuery({
queryKey: ['dictation-models'],
queryFn: () => apiJson('/dictation/models'),
staleTime: 10_000,
retry: false,
// While a download runs, poll so the "installed" state flips without a
// reopen the quick menu has no SSE progress stream of its own.
refetchInterval: installing.size > 0 ? 2_000 : false,
});
const models = Array.isArray(data?.models) ? data.models : [];
if (models.length === 0) return null;
const selectedId = models.some((m) => m.id === modelId)
? modelId
: (models.find((m) => m.recommended) || models[0]).id;
const pick = async (model) => {
if (model.id !== selectedId) setModelId(model.id);
if (model.installed || installing.has(model.repo_id)) return;
setInstalling((prev) => new Set(prev).add(model.repo_id));
try {
await installMutation.mutateAsync(model.repo_id);
await refetch();
} catch {
// The install surface (Settings Voice / Model Catalogue) reports the
// failure with full detail; here the row simply returns to "download".
} finally {
setInstalling((prev) => {
const next = new Set(prev);
next.delete(model.repo_id);
return next;
});
}
};
return (
<div
role="group"
aria-label={t('engines.dictation_model')}
title={t('engines.dictation_model_hint')}
data-testid="dictation-model-picker"
className={`ml-[20px] flex flex-col border-l border-transparent pl-1 ${embedded ? 'mb-2' : 'mb-1'}`}
>
<p className="m-0 px-2 pb-[2px] pt-[2px] text-[10px] font-semibold uppercase tracking-[0.06em] text-[var(--chrome-fg-muted)]">
{t('engines.dictation_model')}
</p>
{models.map((m) => {
const isSel = m.id === selectedId;
const busy = installing.has(m.repo_id);
const desc = t(`voicePanel.model_desc.${m.id}`, { defaultValue: m.languages || '' });
return (
<button
key={m.id}
type="button"
onClick={() => pick(m)}
disabled={busy}
aria-pressed={isSel}
data-testid={`dictation-model-${m.id}`}
title={m.installed ? desc : `${desc}${t('engines.dictation_not_installed')}`}
className={`flex w-full items-center gap-2 rounded-md border-0 px-2 py-[5px] text-left text-xs text-[color:var(--chrome-fg)] hover:bg-[var(--chrome-hover-bg)] disabled:cursor-default ${isSel ? 'bg-[var(--chrome-accent-bg)]' : 'bg-transparent'}`}
>
<span className="w-[12px] shrink-0">
{isSel && <Check size={12} aria-label={t('engines.active')} />}
</span>
<span className="min-w-0 flex-1 truncate">
<span className="font-medium">{m.label}</span>
<span className="ml-[6px] text-[10px] text-[var(--chrome-fg-muted)]">
{m.tag === 'streaming'
? t('voicePanel.badge_streaming')
: t('voicePanel.badge_offline')}
{m.recommended && ` · ${t('voicePanel.badge_recommended')}`}
</span>
</span>
<span className="flex shrink-0 items-center gap-1 text-[10px] tabular-nums text-[color:var(--chrome-fg-muted)]">
{fmtSize(m.size_gb)}
{busy ? (
<Loader
size={12}
className="animate-spin"
aria-label={t('voicePanel.downloading')}
/>
) : m.installed ? null : (
<Download size={12} aria-hidden="true" />
)}
</span>
</button>
);
})}
</div>
);
}
@@ -0,0 +1,169 @@
import React from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
vi.mock('react-hot-toast', () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
const { listEngines, selectEngine, listLoadedModels, apiJson, apiPost, installMutate } = vi.hoisted(
() => ({
listEngines: vi.fn(),
selectEngine: vi.fn(),
listLoadedModels: vi.fn(),
apiJson: vi.fn(),
apiPost: vi.fn(),
installMutate: vi.fn(),
}),
);
vi.mock('../api/engines', () => ({ listEngines, selectEngine }));
vi.mock('../api/system', () => ({ listLoadedModels }));
vi.mock('../api/client', async (importOriginal) => ({
...(await importOriginal()),
apiJson: (...a) => apiJson(...a),
apiPost: (...a) => apiPost(...a),
}));
vi.mock('../api/hooks', async (importOriginal) => ({
...(await importOriginal()),
useInstallModel: () => ({ mutateAsync: installMutate }),
}));
import EngineQuickSwitch from './EngineQuickSwitch';
import { useAppStore } from '../store';
const ASR_INVENTORY = {
tts: {
active: 'omnivoice',
backends: [{ id: 'omnivoice', display_name: 'OmniVoice', available: true }],
},
asr: {
active: 'whisperx',
backends: [
{ id: 'whisperx', display_name: 'WhisperX (faster-whisper + wav2vec2)', available: true },
{
id: 'sherpa-onnx-asr',
display_name: 'Sherpa-ONNX dictation (live, CPU — streaming + offline)',
available: true,
},
{ id: 'nemo-parakeet', display_name: 'Parakeet TDT (NVIDIA NeMo)', available: false },
],
},
llm: { active: 'off', backends: [] },
};
const MODELS = {
models: [
{
id: 'sherpa-whisper-tiny',
repo_id: 'csukuangfj/sherpa-onnx-whisper-tiny',
label: 'Whisper Tiny',
tag: 'offline',
recommended: true,
size_gb: 0.104,
languages: '90+ languages',
installed: true,
},
{
id: 'sherpa-parakeet-tdt-v3',
repo_id: 'csukuangfj/sherpa-onnx-nemo-parakeet-tdt-0.6b-v3-int8',
label: 'Parakeet TDT v3',
tag: 'offline',
recommended: false,
size_gb: 0.67,
languages: '25 European languages',
installed: true,
},
{
id: 'sherpa-zipformer-en-20m',
repo_id: 'csukuangfj/sherpa-onnx-streaming-zipformer-en-20M-2023-02-17',
label: 'Zipformer Streaming EN',
tag: 'streaming',
recommended: false,
size_gb: 0.044,
languages: 'English',
installed: false,
},
],
default_model_id: 'sherpa-whisper-tiny',
};
function renderSwitch(props = {}) {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={client}>
<EngineQuickSwitch family="asr" embedded {...props} />
</QueryClientProvider>,
);
}
describe('EngineQuickSwitch → dictation model picker', () => {
beforeEach(() => {
vi.clearAllMocks();
listEngines.mockResolvedValue(ASR_INVENTORY);
listLoadedModels.mockResolvedValue({ models: [] });
apiJson.mockImplementation(async (path) => {
if (path === '/dictation/models') return MODELS;
if (path === '/dictation/prefs')
return { enabled: true, mode: 'toggle', model_id: 'sherpa-whisper-tiny' };
throw new Error(`unexpected GET ${path}`);
});
apiPost.mockImplementation(async (path, body) => {
if (path === '/dictation/prefs')
return { enabled: true, mode: 'toggle', model_id: body.model_id };
throw new Error(`unexpected POST ${path}`);
});
installMutate.mockResolvedValue({});
useAppStore.setState({ dictationModelId: 'sherpa-whisper-tiny', dictationLoaded: true });
});
it('lists the sherpa dictation models under the Sherpa-ONNX engine row only', async () => {
renderSwitch();
const picker = await screen.findByTestId('dictation-model-picker');
expect(picker).toHaveTextContent('Whisper Tiny');
expect(picker).toHaveTextContent('Parakeet TDT v3');
expect(picker).toHaveTextContent('Zipformer Streaming EN');
// Nested directly after the Sherpa row, not after WhisperX.
const sherpaRow = screen.getByText('Sherpa-ONNX dictation').closest('button');
expect(sherpaRow.nextElementSibling).toBe(picker);
// Engine rows themselves are untouched.
expect(screen.getByText('WhisperX').closest('button')).toBeInTheDocument();
expect(screen.getByTestId('dictation-model-sherpa-whisper-tiny')).toHaveAttribute(
'aria-pressed',
'true',
);
});
it('does not render the picker for a family without the Sherpa engine', async () => {
renderSwitch({ family: 'tts' });
expect(await screen.findByText('OmniVoice')).toBeInTheDocument();
expect(screen.queryByTestId('dictation-model-picker')).not.toBeInTheDocument();
});
it('selecting an installed model writes dictation.model_id and does not download', async () => {
renderSwitch();
fireEvent.click(await screen.findByTestId('dictation-model-sherpa-parakeet-tdt-v3'));
await waitFor(() =>
expect(apiPost).toHaveBeenCalledWith('/dictation/prefs', {
model_id: 'sherpa-parakeet-tdt-v3',
}),
);
expect(installMutate).not.toHaveBeenCalled();
expect(useAppStore.getState().dictationModelId).toBe('sherpa-parakeet-tdt-v3');
expect(screen.getByTestId('dictation-model-sherpa-parakeet-tdt-v3')).toHaveAttribute(
'aria-pressed',
'true',
);
});
it('selecting a model that is not installed persists the choice and starts the download', async () => {
renderSwitch();
fireEvent.click(await screen.findByTestId('dictation-model-sherpa-zipformer-en-20m'));
await waitFor(() =>
expect(installMutate).toHaveBeenCalledWith(
'csukuangfj/sherpa-onnx-streaming-zipformer-en-20M-2023-02-17',
),
);
expect(apiPost).toHaveBeenCalledWith('/dictation/prefs', {
model_id: 'sherpa-zipformer-en-20m',
});
});
});
@@ -0,0 +1,25 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import WorkspaceHistory from './WorkspaceHistory';
export default function DubWorkspaceSidebar({
dubHistory = [],
restoreDubHistory,
deleteHistory,
clearHistory,
}) {
const { t } = useTranslation();
return (
<div className="dub-project-library flex min-h-0 flex-1 flex-col">
<WorkspaceHistory
variant="dub"
title={t('projects.title')}
clearLabel={t('transcriptions.clear_title')}
dubHistory={dubHistory}
restoreDubHistory={restoreDubHistory}
deleteHistory={deleteHistory}
clearHistory={clearHistory}
/>
</div>
);
}
@@ -0,0 +1,39 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import DubWorkspaceSidebar from './DubWorkspaceSidebar';
describe('Dubbing projects panel', () => {
it('shows the previous dubs as Projects without separate tabs and preserves actions', () => {
const item = {
id: 'dub-1',
filename: 'Completed dub.mp4',
duration: 42,
segments_count: 3,
job_data: { input_type: 'video' },
};
const open = vi.fn();
const remove = vi.fn();
render(
<DubWorkspaceSidebar
projects={[{ id: 'old', name: 'Old saved list' }]}
dubHistory={[item]}
restoreDubHistory={open}
deleteHistory={remove}
/>,
);
expect(screen.getByText('Projects')).toBeVisible();
expect(screen.queryByRole('tab')).not.toBeInTheDocument();
expect(screen.queryByText('Old saved list')).not.toBeInTheDocument();
expect(screen.getByText(item.filename)).toBeVisible();
fireEvent.click(screen.getByRole('button', { name: `Open: ${item.filename}` }));
expect(open).toHaveBeenCalledTimes(1);
open.mockClear();
fireEvent.click(screen.getByRole('button', { name: 'Open' }));
expect(open).toHaveBeenCalledWith(item);
expect(open).toHaveBeenCalledTimes(1);
open.mockClear();
fireEvent.click(screen.getByRole('button', { name: 'Delete' }));
expect(remove).toHaveBeenCalledWith(item.id, 'dub');
expect(open).not.toHaveBeenCalled();
});
});
+47 -37
View File
@@ -8,6 +8,7 @@ import { notifyEngineSelected } from '../utils/engineSelectToast';
import { useAppStore } from '../store';
import { MENU_SURFACE } from './computeTarget';
import { engineDisplayName } from '../utils/engineDisplayName';
import DictationModelPicker from './DictationModelPicker';
/**
* A compact TTS/ASR/LLM picker for chrome that needs to expose the active
@@ -154,45 +155,54 @@ export default function EngineQuickSwitch({
const warm = residentIds.has(engine.id);
const displayName = engineDisplayName(engine.display_name);
const parts = displayName.match(/^(.+?)\s*\((.*)\)$/);
// The Sherpa engine is one engine but seven catalogue models; the
// model choice lives right under its row so "which dictation
// model?" is answerable from the same menu that shows the engine.
const nestedPicker =
family === 'asr' && engine.id === 'sherpa-onnx-asr' ? (
<DictationModelPicker embedded={embedded} />
) : null;
return (
<button
key={engine.id}
type="button"
disabled={isActive || locked || selectMutation.isPending}
onClick={() => choose(engine.id)}
className={`flex w-full items-center gap-2 rounded-md border-0 px-2 py-3 text-left text-xs text-[color:var(--chrome-fg)] hover:bg-[var(--chrome-hover-bg)] disabled:cursor-default ${embedded && isActive ? 'bg-[var(--chrome-accent-bg)]' : 'bg-transparent'}`}
>
<span className="w-[12px] shrink-0">
{isActive && <Check size={12} aria-label={t('engines.active')} />}
</span>
<span
className={
embedded
? 'min-w-0 flex-1 whitespace-normal break-words'
: 'min-w-0 flex-1 truncate'
}
<React.Fragment key={engine.id}>
<button
type="button"
disabled={isActive || locked || selectMutation.isPending}
onClick={() => choose(engine.id)}
className={`flex w-full items-center gap-2 rounded-md border-0 px-2 py-3 text-left text-xs text-[color:var(--chrome-fg)] hover:bg-[var(--chrome-hover-bg)] disabled:cursor-default ${embedded && isActive ? 'bg-[var(--chrome-accent-bg)]' : 'bg-transparent'}`}
>
{embedded && parts ? (
<>
<span className="block font-medium text-sm">{parts[1]}</span>
<span className="block mt-1 text-xs leading-relaxed text-[var(--chrome-fg-muted)]">
{parts[2]}
</span>
</>
) : (
displayName
)}
</span>
<span className="shrink-0 text-[10px] text-[color:var(--chrome-fg-muted)]">
{warm
? t('engines.inMemory')
: isActive
? t('engines.active')
: embedded
? ''
: t('engines.available')}
</span>
</button>
<span className="w-[12px] shrink-0">
{isActive && <Check size={12} aria-label={t('engines.active')} />}
</span>
<span
className={
embedded
? 'min-w-0 flex-1 whitespace-normal break-words'
: 'min-w-0 flex-1 truncate'
}
>
{embedded && parts ? (
<>
<span className="block font-medium text-sm">{parts[1]}</span>
<span className="block mt-1 text-xs leading-relaxed text-[var(--chrome-fg-muted)]">
{parts[2]}
</span>
</>
) : (
displayName
)}
</span>
<span className="shrink-0 text-[10px] text-[color:var(--chrome-fg-muted)]">
{warm
? t('engines.inMemory')
: isActive
? t('engines.active')
: embedded
? ''
: t('engines.available')}
</span>
</button>
{nestedPicker}
</React.Fragment>
);
})}
{switchError && (
+100 -20
View File
@@ -19,6 +19,7 @@ import {
Download as DownloadIcon,
Volume2,
Search,
Pencil,
X,
} from 'lucide-react';
import { toast } from 'react-hot-toast';
@@ -90,6 +91,7 @@ export default function Sidebar(props) {
saveProject,
loadProject,
deleteProject,
renameProject,
handleSelectProfile,
handleDeleteProfile,
handleOpenVoiceProfile,
@@ -118,6 +120,25 @@ export default function Sidebar(props) {
const { t } = useTranslation();
const [sbQuery, setSbQuery] = useState('');
// Inline project rename. The Dub landing's own projects panel was retired
// when that surface became history-only, which left the rename endpoint with
// no caller anywhere in the UI (#1952). Projects now live only in this rail,
// so the affordance belongs here.
const [editingId, setEditingId] = useState(null);
const [draftName, setDraftName] = useState('');
const startRename = (proj) => {
setEditingId(proj.id);
setDraftName(proj.name || '');
};
const cancelRename = () => {
setEditingId(null);
setDraftName('');
};
const commitRename = (proj) => {
const next = draftName.trim();
if (next && next !== proj.name) renameProject?.(proj.id, next);
cancelRename();
};
const qLower = sbQuery.trim().toLowerCase();
const matchesSearch = (s) => !qLower || (s || '').toLowerCase().includes(qLower);
const filteredProjects = useMemo(
@@ -302,7 +323,28 @@ export default function Sidebar(props) {
{timeAgo(proj.updated_at)}
</span>
</div>
<div className="history-title">{proj.name}</div>
{editingId === proj.id ? (
<input
className="input-base wv__rename-input"
autoFocus
value={draftName}
aria-label={t('sidebar.rename')}
onClick={(e) => e.stopPropagation()}
onChange={(e) => setDraftName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.stopPropagation();
commitRename(proj);
} else if (e.key === 'Escape') {
e.stopPropagation();
cancelRename();
}
}}
onBlur={() => commitRename(proj)}
/>
) : (
<div className="history-title">{proj.name}</div>
)}
<div className="history-subtitle">
{proj.duration ? `${Math.round(proj.duration)}s` : 'audio'}
{(() => {
@@ -314,25 +356,63 @@ export default function Sidebar(props) {
})()}
</div>
<div className="history-actions">
<button
className="history-action-btn accent"
onClick={(e) => {
e.stopPropagation();
loadProject(proj.id);
}}
>
<FolderOpen size={10} /> {t('sidebar.open')}
</button>
<button
className="history-action-btn danger history-action-icon"
onClick={(e) => {
e.stopPropagation();
deleteProject(proj.id);
}}
title="Delete"
>
<Trash2 size={10} />
</button>
{editingId === proj.id ? (
<>
<button
className="history-action-btn accent"
onClick={(e) => {
e.stopPropagation();
commitRename(proj);
}}
>
<Check size={10} /> {t('common.save')}
</button>
<button
className="history-action-btn history-action-icon"
onClick={(e) => {
e.stopPropagation();
cancelRename();
}}
title={t('common.cancel')}
>
<X size={10} />
</button>
</>
) : (
<>
<button
className="history-action-btn accent"
onClick={(e) => {
e.stopPropagation();
loadProject(proj.id);
}}
>
<FolderOpen size={10} /> {t('sidebar.open')}
</button>
{renameProject && (
<button
className="history-action-btn history-action-icon"
onClick={(e) => {
e.stopPropagation();
startRename(proj);
}}
title={t('sidebar.rename')}
>
<Pencil size={10} />
</button>
)}
<button
className="history-action-btn danger history-action-icon"
onClick={(e) => {
e.stopPropagation();
deleteProject(proj.id);
}}
title={t('common.delete')}
>
<Trash2 size={10} />
</button>
</>
)}
</div>
</div>
))
@@ -0,0 +1,127 @@
/**
* Sidebar Projects rail: inline rename (#1952 regression).
*
* The Dub landing used to carry its own projects panel, and that panel was the
* only caller of the rename endpoint. When the landing became history-only,
* the rename affordance disappeared from the entire UI while the backend route
* and the project list both stayed a silently lost capability, and the reason
* `renameProject` in App.jsx became an unused variable that failed CI lint.
*
* These tests pin the affordance to where projects actually live now.
*/
import React from 'react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
const mocks = vi.hoisted(() => ({
state: {
mode: 'dub',
defineMethod: 'audio',
isSidebarCollapsed: false,
dubStep: 'idle',
activeProjectId: null,
},
}));
vi.mock('../store', () => ({
useAppStore: Object.assign((selector) => selector(mocks.state), {
getState: () => mocks.state,
setState: vi.fn(),
}),
}));
vi.mock('react-hot-toast', () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
vi.mock('../api/client', () => ({ API: 'http://localhost:3900' }));
vi.mock('../api/dub', () => ({ clearDubHistory: vi.fn() }));
vi.mock('../api/generate', () => ({ clearHistory: vi.fn() }));
vi.mock('../utils/dialog', () => ({ askConfirm: vi.fn().mockResolvedValue(true) }));
vi.mock('./WaveformPlayer', () => ({ default: () => null }));
import Sidebar from './Sidebar';
const PROJECT = {
id: 'p1',
name: 'Documentary voice-over',
updated_at: '2026-09-08T12:00:00Z',
duration: 128,
video_path: 'documentary.mp4',
};
function renderSidebar(overrides = {}) {
const props = {
sidebarTab: 'projects',
setSidebarTab: vi.fn(),
studioProjects: [PROJECT],
profiles: [],
history: [],
dubHistory: [],
exportHistory: [],
saveProject: vi.fn(),
loadProject: vi.fn(),
deleteProject: vi.fn(),
renameProject: vi.fn(),
deleteHistory: vi.fn(),
loadHistory: vi.fn(),
loadDubHistory: vi.fn(),
...overrides,
};
render(<Sidebar {...props} />);
return props;
}
describe('Sidebar projects rail — inline rename', () => {
beforeEach(() => {
mocks.state.mode = 'dub';
mocks.state.activeProjectId = null;
});
it('renames a project without opening it', () => {
const props = renderSidebar();
fireEvent.click(screen.getByRole('button', { name: 'Rename' }));
const input = screen.getByRole('textbox', { name: 'Rename' });
expect(input).toHaveValue('Documentary voice-over');
fireEvent.change(input, { target: { value: ' Documentary — final cut ' } });
fireEvent.click(screen.getByRole('button', { name: 'Save' }));
expect(props.renameProject).toHaveBeenCalledWith('p1', 'Documentary — final cut');
// Clicking inside the row must not also open the project.
expect(props.loadProject).not.toHaveBeenCalled();
});
it('commits on Enter and abandons on Escape', () => {
const props = renderSidebar();
fireEvent.click(screen.getByRole('button', { name: 'Rename' }));
fireEvent.change(screen.getByRole('textbox', { name: 'Rename' }), {
target: { value: 'Renamed by keyboard' },
});
fireEvent.keyDown(screen.getByRole('textbox', { name: 'Rename' }), { key: 'Enter' });
expect(props.renameProject).toHaveBeenCalledWith('p1', 'Renamed by keyboard');
props.renameProject.mockClear();
fireEvent.click(screen.getByRole('button', { name: 'Rename' }));
fireEvent.change(screen.getByRole('textbox', { name: 'Rename' }), {
target: { value: 'Discarded' },
});
fireEvent.keyDown(screen.getByRole('textbox', { name: 'Rename' }), { key: 'Escape' });
expect(props.renameProject).not.toHaveBeenCalled();
expect(screen.getByText('Documentary voice-over')).toBeVisible();
});
it('ignores an empty or unchanged name', () => {
const props = renderSidebar();
fireEvent.click(screen.getByRole('button', { name: 'Rename' }));
fireEvent.change(screen.getByRole('textbox', { name: 'Rename' }), { target: { value: ' ' } });
fireEvent.click(screen.getByRole('button', { name: 'Save' }));
expect(props.renameProject).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole('button', { name: 'Rename' }));
fireEvent.keyDown(screen.getByRole('textbox', { name: 'Rename' }), { key: 'Enter' });
expect(props.renameProject).not.toHaveBeenCalled();
});
it('hides the affordance when no rename handler is wired', () => {
renderSidebar({ renameProject: undefined });
expect(screen.queryByRole('button', { name: 'Rename' })).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: /Open/ })).toBeInTheDocument();
});
});
File diff suppressed because it is too large Load Diff
+14 -5
View File
@@ -114,6 +114,8 @@ function LazyWaveform({ height = 36, className = '', ...rest }) {
}
export default function WorkspaceHistory({
title,
clearLabel,
variant = 'voice', // 'voice' (clone/design synth) | 'dub'
history = [],
dubHistory = [],
@@ -140,9 +142,9 @@ export default function WorkspaceHistory({
type="button"
className="history-action-btn danger flex-[0_0_auto]"
onClick={clearHistory}
title={t('sidebar.clear_history')}
title={clearLabel || t('sidebar.clear_history')}
>
<Trash2 size={10} /> {t('sidebar.clear_history')}
<Trash2 size={10} /> {clearLabel || t('sidebar.clear_history')}
</button>
) : null;
@@ -161,7 +163,8 @@ export default function WorkspaceHistory({
<div className="flex-[0_0_auto] flex flex-col gap-[8px] py-[10px] px-[12px]">
<div className="flex items-center justify-between gap-[6px]">
<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' })}
<History size={13} />{' '}
{title || t('history.dub_title', { defaultValue: 'Dub history' })}
</span>
{clearAllButton(dubHistory.length)}
</div>
@@ -176,7 +179,13 @@ export default function WorkspaceHistory({
const inputType = dubInputType(item);
const MediaIcon = inputType === 'audio' ? AudioWaveform : Film;
return (
<div key={`dub-${item.id}`} className="history-item history-item--dub">
<div key={`dub-${item.id}`} className="history-item history-item--dub relative">
<button
type="button"
className="absolute inset-0 z-[1] cursor-pointer rounded-[inherit] border-0 bg-transparent focus-visible:outline focus-visible:outline-2 focus-visible:outline-[var(--chrome-accent)]"
aria-label={`${t('sidebar.open')}: ${item.filename}`}
onClick={() => restoreDubHistory(item)}
/>
<div className="flex min-w-0 gap-[8px]">
<DubMediaPreview item={item} inputType={inputType} />
<div className="flex min-w-0 flex-1 flex-col gap-[2px]">
@@ -200,7 +209,7 @@ export default function WorkspaceHistory({
</div>
</div>
</div>
<div className="history-actions">
<div className="history-actions relative z-[2]">
<button
type="button"
className="history-action-btn accent"
@@ -1,192 +0,0 @@
/**
* WorkspaceProjects the compact left-side "Dub projects" panel.
*
* Relocates the saved-dub-project list (and the Save-project button) out of the
* left Sidebar so the Dub workspace can dissolve its global sidebar, mirroring
* the Voice workspace's WorkspaceVoices rail. Card markup + actions mirror the former
* Sidebar section 1:1 (open/load, delete).
*/
import React, { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Search, Film, FolderOpen, Trash2, Save, Pencil, Check, X } from 'lucide-react';
import { Button } from '../ui';
import { absoluteTime, timeAgo } from '../utils/relativeTime';
export default function WorkspaceProjects({
projects = [],
activeProjectId,
canSave = false,
saveProject,
loadProject,
deleteProject,
renameProject,
}) {
const { t } = useTranslation();
const [q, setQ] = useState('');
const qLower = q.trim().toLowerCase();
// Inline rename: which project id is being edited + the draft name.
const [editingId, setEditingId] = useState(null);
const [draftName, setDraftName] = useState('');
const startRename = (proj) => {
setEditingId(proj.id);
setDraftName(proj.name || '');
};
const cancelRename = () => {
setEditingId(null);
setDraftName('');
};
const commitRename = (proj) => {
const next = draftName.trim();
if (next && next !== proj.name) renameProject?.(proj.id, next);
cancelRename();
};
const items = useMemo(() => {
if (!qLower) return projects;
return projects.filter((p) => (p.name || '').toLowerCase().includes(qLower));
}, [projects, qLower]);
return (
<section className={`wv ${items.length === 0 ? 'wv--collapsed' : ''}`}>
<div className="wv__head">
<span className="wv__title">
{t('sidebar.dub_projects', { defaultValue: 'Dub projects' })}
</span>
{canSave && (
<Button
variant="subtle"
block
onClick={saveProject}
leading={<Save size={13} />}
className={`sidebar__save-btn sidebar__save-btn--full ${activeProjectId ? 'is-active-project' : ''}`}
>
{activeProjectId ? t('sidebar.save_project') : t('sidebar.save_new_project')}
</Button>
)}
<div className="wv__search">
<Search size={12} className="wv__search-icon" />
<input
className="input-base wv__search-input"
placeholder={t('sidebar.search', { defaultValue: 'Search…' })}
value={q}
onChange={(e) => setQ(e.target.value)}
/>
</div>
</div>
<div className="wv__scroll">
{items.length === 0 ? (
<div className="wv__empty">
{t('sidebar.no_dub_projects', { defaultValue: 'No dub projects yet' })}
</div>
) : (
items.map((proj) => (
<div
key={proj.id}
className={`history-item history-item--dub ${activeProjectId === proj.id ? 'project-active' : ''}`}
onClick={() => loadProject(proj.id)}
>
<div className="flex items-center justify-between gap-2 min-w-0">
<span className="history-kind history-kind--audio">
<Film size={9} /> {t('sidebar.dub_label')}
</span>
<span className="history-meta" title={absoluteTime(proj.updated_at)}>
{timeAgo(proj.updated_at)}
</span>
</div>
{editingId === proj.id ? (
<input
className="input-base wv__rename-input"
autoFocus
value={draftName}
onClick={(e) => e.stopPropagation()}
onChange={(e) => setDraftName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.stopPropagation();
commitRename(proj);
} else if (e.key === 'Escape') {
e.stopPropagation();
cancelRename();
}
}}
onBlur={() => commitRename(proj)}
/>
) : (
<div className="history-title">{proj.name}</div>
)}
<div className="history-subtitle">
{proj.duration ? `${Math.round(proj.duration)}s` : 'audio'}
{(() => {
const basename = proj.video_path ? proj.video_path.split(/[\\/]/).pop() : '';
return basename && basename !== proj.name ? ` · ${basename}` : '';
})()}
</div>
<div className="history-actions">
{editingId === proj.id ? (
<>
<button
className="history-action-btn accent"
onClick={(e) => {
e.stopPropagation();
commitRename(proj);
}}
title={t('sidebar.rename_save', { defaultValue: 'Save name' })}
>
<Check size={10} /> {t('common.save', { defaultValue: 'Save' })}
</button>
<button
className="history-action-btn history-action-icon"
onClick={(e) => {
e.stopPropagation();
cancelRename();
}}
title={t('common.cancel', { defaultValue: 'Cancel' })}
>
<X size={10} />
</button>
</>
) : (
<>
<button
className="history-action-btn accent"
onClick={(e) => {
e.stopPropagation();
loadProject(proj.id);
}}
>
<FolderOpen size={10} /> {t('sidebar.open')}
</button>
{renameProject && (
<button
className="history-action-btn history-action-icon"
onClick={(e) => {
e.stopPropagation();
startRename(proj);
}}
title={t('sidebar.rename', { defaultValue: 'Rename' })}
>
<Pencil size={10} />
</button>
)}
<button
className="history-action-btn danger history-action-icon"
onClick={(e) => {
e.stopPropagation();
deleteProject(proj.id);
}}
title="Delete"
>
<Trash2 size={10} />
</button>
</>
)}
</div>
</div>
))
)}
</div>
</section>
);
}
@@ -0,0 +1,104 @@
import { BookText, Disc3, SpellCheck, Code } from 'lucide-react';
import Section from './Section';
import SearchableSelect from '../SearchableSelect';
import BookDetails from './BookDetails';
import LexiconEditor from './LexiconEditor';
/**
* AudiobookBookPanel the Produce tab: the book as an artefact.
*
* Output format + loudness stay pinned on top (they shape the render); book
* details, pronunciation lexicon, and the markup cheat-sheet fold into
* collapsible sections so the tab stays scannable. Mirrors the clone
* workspace's section rhythm (label-row kickers, borderless surfaces).
*/
export default function AudiobookBookPanel({
t,
format,
setFormat,
loudness,
setLoudness,
coverPreview,
onCoverPick,
clearCover,
meta,
setMetaField,
lex,
setLexRow,
addLexRow,
removeLexRow,
detailCount,
lexiconCount,
}) {
return (
<div className="flex min-h-0 flex-1 flex-col gap-[14px] overflow-y-auto px-[2px] py-[2px]">
<div className="grid grid-cols-2 gap-[10px] max-[640px]:grid-cols-1">
<div className="flex min-w-0 flex-col gap-[4px]">
<span className="label-row mb-0">
<Disc3 className="label-icon" size={14} aria-hidden="true" /> {t('audiobook.format')}
</span>
<SearchableSelect
value={format}
onChange={setFormat}
ariaLabel={t('audiobook.format')}
menuPortal
options={[
{ value: 'm4b', label: t('audiobook.format_m4b') },
{ value: 'mp3', label: t('audiobook.format_mp3') },
]}
/>
</div>
<div className="flex min-w-0 flex-col gap-[4px]">
<span className="label-row mb-0">{t('audiobook.loudness')}</span>
<SearchableSelect
value={loudness}
onChange={setLoudness}
ariaLabel={t('audiobook.loudness')}
menuPortal
options={[
{ value: 'off', label: t('audiobook.loudness_off') },
{ value: 'acx', label: t('audiobook.loudness_acx') },
{ value: 'podcast', label: t('audiobook.loudness_podcast') },
]}
/>
</div>
</div>
<Section
title={`${t('audiobook.details')}${detailCount > 0 ? ` · ${detailCount}` : ''}`}
icon={<BookText size={12} aria-hidden="true" />}
defaultOpen={detailCount > 0}
>
<BookDetails
t={t}
coverPreview={coverPreview}
onCoverPick={onCoverPick}
clearCover={clearCover}
meta={meta}
setMetaField={setMetaField}
/>
</Section>
<Section
title={`${t('audiobook.lexicon')}${lexiconCount > 0 ? ` · ${lexiconCount}` : ''}`}
icon={<SpellCheck size={12} aria-hidden="true" />}
defaultOpen={lexiconCount > 0}
>
<LexiconEditor
t={t}
lex={lex}
setLexRow={setLexRow}
addLexRow={addLexRow}
removeLexRow={removeLexRow}
/>
</Section>
<Section title={t('audiobook.markup_help')} icon={<Code size={12} aria-hidden="true" />}>
<p className="m-0 text-[0.68rem] leading-[1.55] text-fg-muted">
{t('audiobook.markup_hint')}
</p>
</Section>
</div>
);
}
@@ -1,254 +0,0 @@
import { useState } from 'react';
import {
BookText,
Code,
Languages,
Mic2,
SlidersHorizontal,
SpellCheck,
Users,
} from 'lucide-react';
import VoiceSelector from '../VoiceSelector';
import SearchableSelect from '../SearchableSelect';
import AudiobookOverrides from './AudiobookOverrides';
import BookDetails from './BookDetails';
import CastPanel from './CastPanel';
import LexiconEditor from './LexiconEditor';
import ALL_LANGUAGES from '../../languages.json';
import { POPULAR_LANGS } from '../../utils/constants';
const FIELD_LABEL =
'flex items-center gap-[5px] [font-family:var(--chrome-font-mono)] [font-size:var(--chrome-label-size)] font-semibold [letter-spacing:var(--chrome-label-track)] uppercase text-fg-muted';
const TOOL_BUTTON =
'relative flex h-[46px] min-w-0 flex-col items-center justify-center gap-[3px] rounded-[8px] border border-transparent bg-transparent px-[6px] text-[0.62rem] text-fg-muted cursor-pointer transition-[background,color,box-shadow] duration-[120ms] hover:bg-[var(--chrome-hover-bg)] hover:text-fg focus-visible:[outline:2px_solid_var(--chrome-accent)] focus-visible:[outline-offset:-2px] aria-pressed:bg-primary/[0.12] aria-pressed:text-primary aria-pressed:shadow-[inset_0_-2px_0_var(--color-brand)]';
function ToolButton({ active, badge = 0, icon, label, onClick }) {
return (
<button
type="button"
className={TOOL_BUTTON}
aria-pressed={active}
title={label}
onClick={onClick}
>
{icon}
<span className="w-full truncate text-center">{label}</span>
{badge > 0 && (
<span
className="absolute right-[5px] top-[4px] min-w-[15px] rounded-full bg-[var(--chrome-hover-bg)] px-[4px] text-center text-[0.55rem] leading-[15px] text-fg [font-variant-numeric:tabular-nums]"
aria-hidden="true"
>
{badge}
</span>
)}
</button>
);
}
/** Compact right-rail property inspector with one optional tool panel at a time. */
export default function AudiobookInspector({
t,
profiles,
defaultVoice,
setDefaultVoice,
language,
setLanguage,
format,
setFormat,
loudness,
setLoudness,
castNames,
voiceCast,
setVoiceCast,
overrides,
setOverrides,
emotionSupported,
coverPreview,
onCoverPick,
clearCover,
meta,
setMetaField,
lex,
setLexRow,
addLexRow,
removeLexRow,
}) {
// `undefined` means the user has not chosen a panel yet: Cast becomes the
// useful default as soon as the script contains [voice:] tags. Once the user
// switches or closes a panel, their explicit choice wins.
const [activePanel, setActivePanel] = useState(undefined);
const panel =
activePanel === undefined
? castNames.length > 0
? 'cast'
: null
: activePanel === 'cast' && castNames.length === 0
? null
: activePanel;
const togglePanel = (next) => setActivePanel(panel === next ? null : next);
const detailCount =
Object.values(meta).filter((value) => value?.trim()).length + (coverPreview ? 1 : 0);
const lexiconCount = lex.filter((row) => row.word.trim() || row.say.trim()).length;
const outputCount =
(loudness !== 'off' ? 1 : 0) +
(Object.values(overrides).some((value) => value !== null && value !== false && value !== '')
? 1
: 0);
return (
<div className="flex flex-col gap-[9px] [container-type:inline-size] [container-name:audiobook-inspector]">
<div className="grid grid-cols-1 gap-[8px] rounded-[11px] bg-[var(--chrome-bg)] p-[10px] @min-[360px]/audiobook-inspector:grid-cols-2 @min-[560px]/audiobook-inspector:[grid-template-columns:minmax(170px,1.35fr)_minmax(120px,0.9fr)_minmax(100px,0.7fr)]">
<div className="flex min-w-0 flex-col gap-[4px] @min-[360px]/audiobook-inspector:col-span-2 @min-[560px]/audiobook-inspector:col-span-1">
<label className={FIELD_LABEL}>
<Mic2 size={11} aria-hidden="true" /> {t('audiobook.default_voice')}
</label>
<VoiceSelector
value={defaultVoice}
onChange={setDefaultVoice}
profiles={profiles}
defaultLabel={t('audiobook.engine_default')}
ariaLabel={t('audiobook.default_voice')}
/>
</div>
<div className="flex min-w-0 flex-col gap-[4px]">
<label className={FIELD_LABEL}>
<Languages size={11} aria-hidden="true" /> {t('audiobook.language')}
</label>
<SearchableSelect
value={language}
options={ALL_LANGUAGES}
popular={POPULAR_LANGS}
recentsKey="omnivoice.recents.audiobookLang"
onChange={setLanguage}
ariaLabel={t('audiobook.language')}
/>
</div>
<div className="flex min-w-0 flex-col gap-[4px]">
<label className={FIELD_LABEL}>{t('audiobook.format')}</label>
<select
className="input-base"
name="audiobook-format"
value={format}
onChange={(event) => setFormat(event.target.value)}
aria-label={t('audiobook.format')}
>
<option value="m4b">{t('audiobook.format_m4b')}</option>
<option value="mp3">{t('audiobook.format_mp3')}</option>
</select>
</div>
</div>
<div className="overflow-hidden rounded-[11px] bg-[var(--chrome-bg)] shadow-[inset_0_0_0_1px_var(--chrome-border)]">
<div
className="grid grid-cols-[repeat(auto-fit,minmax(68px,1fr))] gap-[2px] p-[4px]"
role="toolbar"
aria-label={t('audiobook.title')}
>
{castNames.length > 0 && (
<ToolButton
active={panel === 'cast'}
badge={castNames.length}
icon={<Users size={13} aria-hidden="true" />}
label={t('audiobook.cast')}
onClick={() => togglePanel('cast')}
/>
)}
<ToolButton
active={panel === 'output'}
badge={outputCount}
icon={<SlidersHorizontal size={13} aria-hidden="true" />}
label={t('audiobook.output')}
onClick={() => togglePanel('output')}
/>
<ToolButton
active={panel === 'details'}
badge={detailCount}
icon={<BookText size={13} aria-hidden="true" />}
label={t('audiobook.details')}
onClick={() => togglePanel('details')}
/>
<ToolButton
active={panel === 'lexicon'}
badge={lexiconCount}
icon={<SpellCheck size={13} aria-hidden="true" />}
label={t('audiobook.lexicon')}
onClick={() => togglePanel('lexicon')}
/>
<ToolButton
active={panel === 'markup'}
icon={<Code size={13} aria-hidden="true" />}
label={t('audiobook.markup_help')}
onClick={() => togglePanel('markup')}
/>
</div>
{panel && (
<div className="border-t border-transparent p-[11px]">
{panel === 'cast' && (
<CastPanel
t={t}
castNames={castNames}
voiceCast={voiceCast}
setVoiceCast={setVoiceCast}
profiles={profiles}
/>
)}
{panel === 'output' && (
<div className="flex flex-col gap-[8px]">
<div className="flex flex-col gap-[4px]">
<label className={FIELD_LABEL}>{t('audiobook.loudness')}</label>
<select
className="input-base"
name="audiobook-loudness"
value={loudness}
onChange={(event) => setLoudness(event.target.value)}
aria-label={t('audiobook.loudness')}
>
<option value="off">{t('audiobook.loudness_off')}</option>
<option value="acx">{t('audiobook.loudness_acx')}</option>
<option value="podcast">{t('audiobook.loudness_podcast')}</option>
</select>
</div>
<AudiobookOverrides
t={t}
overrides={overrides}
onChange={setOverrides}
emotionSupported={emotionSupported}
/>
</div>
)}
{panel === 'details' && (
<BookDetails
t={t}
coverPreview={coverPreview}
onCoverPick={onCoverPick}
clearCover={clearCover}
meta={meta}
setMetaField={setMetaField}
/>
)}
{panel === 'lexicon' && (
<div className="flex flex-col gap-[6px]">
<LexiconEditor
t={t}
lex={lex}
setLexRow={setLexRow}
addLexRow={addLexRow}
removeLexRow={removeLexRow}
/>
</div>
)}
{panel === 'markup' && (
<p className="m-0 text-[0.68rem] leading-[1.55] text-fg-muted">
{t('audiobook.markup_hint')}
</p>
)}
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,51 @@
import MarkupToolbar from './MarkupToolbar';
import StatsBar from './StatsBar';
/**
* AudiobookScriptPanel the Write tab: the chapter-delimited manuscript.
*
* Markdown `# H1` headings delimit chapters; inline `[voice:NAME]` and
* `[pause …]` tags are honoured by the backend parser. Mirrors the clone
* workspace's ScriptPanel: one labelled surface, toolbar docked on top, the
* editor filling the remaining height.
*/
export default function AudiobookScriptPanel({
t,
text,
setText,
textareaRef,
onScriptKeyDown,
warningsDismissed,
setWarningsDismissed,
}) {
return (
<div className="flex min-h-0 flex-1 flex-col gap-[7px]">
<div className="flex min-h-[18px] flex-wrap items-center justify-between gap-x-[12px] gap-y-1 px-[4px]">
<span className="label-row mb-0">{t('audiobook.script')}</span>
{text.trim() ? <StatsBar t={t} text={text} /> : null}
</div>
<div className="audiobook-tab__manuscript flex min-h-0 flex-1 flex-col overflow-hidden rounded-[14px]">
<div className="border-b border-transparent px-[10px] py-[7px]">
<MarkupToolbar t={t} textareaRef={textareaRef} text={text} setText={setText} />
</div>
<textarea
ref={textareaRef}
className="input-base"
value={text}
onChange={(e) => {
setText(e.target.value);
if (warningsDismissed) setWarningsDismissed(false);
}}
onKeyDown={onScriptKeyDown}
placeholder={t('audiobook.script_placeholder')}
aria-label={t('audiobook.script')}
/>
{!text.trim() && (
<p className="m-0 border-t border-transparent px-[14px] py-[9px] text-[var(--text-sm)] text-fg-muted">
{t('audiobook.empty_hint')}
</p>
)}
</div>
</div>
);
}
@@ -0,0 +1,93 @@
import { Mic2, Languages, Users } from 'lucide-react';
import VoiceSelector from '../VoiceSelector';
import SearchableSelect from '../SearchableSelect';
import CastPanel from './CastPanel';
import AudiobookOverrides from './AudiobookOverrides';
import ALL_LANGUAGES from '../../languages.json';
import { POPULAR_LANGS } from '../../utils/constants';
/**
* AudiobookVoicesPanel the Cast tab: who says it.
*
* Default voice + language stay pinned on top (the two controls every book
* needs); the `[voice:NAME]` cast map and the expressive overrides follow as
* labelled groups. Mirrors the clone workspace's Voice section (label-row
* kickers, borderless surfaces).
*/
export default function AudiobookVoicesPanel({
t,
profiles,
defaultVoice,
setDefaultVoice,
language,
setLanguage,
castNames,
voiceCast,
setVoiceCast,
overrides,
setOverrides,
emotionSupported,
}) {
return (
<div className="flex min-h-0 flex-1 flex-col gap-[14px] overflow-y-auto px-[2px] py-[2px]">
<div className="grid grid-cols-2 gap-[10px] max-[640px]:grid-cols-1">
<div className="flex min-w-0 flex-col gap-[4px]">
<span className="label-row mb-0">
<Mic2 className="label-icon" size={14} aria-hidden="true" />{' '}
{t('audiobook.default_voice')}
</span>
<VoiceSelector
value={defaultVoice}
onChange={setDefaultVoice}
profiles={profiles}
defaultLabel={t('audiobook.engine_default')}
ariaLabel={t('audiobook.default_voice')}
/>
</div>
<div className="flex min-w-0 flex-col gap-[4px]">
<span className="label-row mb-0">
<Languages className="label-icon" size={14} aria-hidden="true" />{' '}
{t('audiobook.language')}
</span>
<SearchableSelect
value={language}
options={ALL_LANGUAGES}
popular={POPULAR_LANGS}
recentsKey="omnivoice.recents.audiobookLang"
onChange={setLanguage}
ariaLabel={t('audiobook.language')}
/>
</div>
</div>
<div className="flex min-w-0 flex-col gap-[6px]">
<span className="label-row mb-0">
<Users className="label-icon" size={14} aria-hidden="true" /> {t('audiobook.cast')}
{castNames.length > 0 && (
<span
className="ml-auto rounded-full bg-[var(--chrome-hover-bg)] px-[8px] text-center [font-variant-numeric:tabular-nums]"
aria-hidden="true"
>
{castNames.length}
</span>
)}
</span>
<CastPanel
t={t}
castNames={castNames}
voiceCast={voiceCast}
setVoiceCast={setVoiceCast}
profiles={profiles}
/>
</div>
<AudiobookOverrides
t={t}
overrides={overrides}
onChange={setOverrides}
emotionSupported={emotionSupported}
/>
</div>
);
}
+10 -12
View File
@@ -377,24 +377,22 @@ export default function DubRightColumn({
{multiLangMode && batchTargets?.length > 1 && (
<label className="mb-[4px] flex max-w-[320px] items-center gap-[7px] px-[2px]">
<span className={OUT_TITLE}>{t('dub.language')}:</span>
<select
className="input-base min-w-0 flex-1 !px-[7px] !py-[3px] !text-[0.68rem]"
<SearchableSelect
ariaLabel={t('dub.language')}
value={dubLangCode}
disabled={multiBatchBusy}
aria-label={t('dub.language')}
onChange={(event) => {
const target = batchTargets.find((item) => item.code === event.target.value);
menuPortal
options={batchTargets.map((target) => ({
value: target.code,
label: `${target.lang} · ${target.code.toUpperCase()}`,
}))}
onChange={(code) => {
const target = batchTargets.find((item) => item.code === code);
if (!target) return;
setDubLang(target.lang);
setDubLangCode(target.code);
}}
>
{batchTargets.map((target) => (
<option key={target.code} value={target.code}>
{target.lang} · {target.code.toUpperCase()}
</option>
))}
</select>
/>
</label>
)}
@@ -161,7 +161,7 @@ describe('DubRightColumn language targets', () => {
({ rerender } = render(column(true, setDubLang, setDubLangCode)));
await Promise.resolve();
});
const language = screen.getByRole('combobox', { name: 'dub.language' });
const language = screen.getByRole('button', { name: 'dub.language' });
expect(language).toBeDisabled();
expect(setDubLangCode).not.toHaveBeenCalled();
@@ -170,9 +170,8 @@ describe('DubRightColumn language targets', () => {
rerender(column(false, setDubLang, setDubLangCode));
await Promise.resolve();
});
fireEvent.change(screen.getByRole('combobox', { name: 'dub.language' }), {
target: { value: 'es' },
});
fireEvent.click(screen.getByRole('button', { name: 'dub.language' }));
fireEvent.mouseDown(screen.getByRole('option', { name: /Spanish/ }));
expect(setDubLang).toHaveBeenCalledWith('Spanish');
expect(setDubLangCode).toHaveBeenCalledWith('es');
});
+256 -260
View File
@@ -22,6 +22,7 @@ import {
import { Button, Progress } from '../../ui';
import { useEffect, useRef } from 'react';
import WaveformTimeline from '../WaveformTimeline';
import SearchableSelect from '../SearchableSelect';
import DubbingDemo from '../DubbingDemo';
import DubFailureNotice from './DubFailureNotice';
import PrepOverlay from './PrepOverlay';
@@ -108,52 +109,66 @@ export default function IdleSkeleton({
onOpenQueue,
}) {
const youtubeCookieInputRef = useRef(null);
const videoUploadInputRef = useRef(null);
useEffect(() => {
if (!youtubeCookieFile && youtubeCookieInputRef.current) {
youtubeCookieInputRef.current.value = '';
}
}, [youtubeCookieFile]);
const languageOptions = LANG_CODES.map((language) => ({
value: language.code,
label: `${languageLabel(language.code, uiLocale, language.label)}${language.code}`,
}));
const sourceOptions = [{ value: 'auto', label: t('bootstrap.auto_detect') }, ...languageOptions];
const isPristineIdle = !dubVideoFile && dubStep === 'idle' && !dubJobId;
return (
<div className="flex-1 flex flex-col min-h-0">
{/* Header bar */}
<div className="flex justify-between items-center 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="label-row dub-head__title">
<Film className="label-icon" size={11} />
<span className="font-semibold text-[0.85rem] overflow-hidden text-ellipsis whitespace-nowrap text-fg">
{dubVideoFile ? dubVideoFile.name : t('dub.video_dubbing_studio')}
</span>
{dubVideoFile && (
<span className="text-fg-muted font-normal whitespace-nowrap text-[0.72rem]">
· {(dubVideoFile.size / 1024 / 1024).toFixed(1)} MB
</span>
)}
{activeProjectName && activeProjectName !== dubFilename && (
<span className="text-[#b8bb26] ml-[var(--space-3)] whitespace-nowrap text-[0.72rem]">
{activeProjectName}
<div className={`flex-1 flex flex-col min-h-0${isPristineIdle ? ' dub-start-screen' : ''}`}>
{/* Selected source details appear only after choosing media. */}
{dubVideoFile && (
<div className="flex justify-between items-center 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="label-row dub-head__title">
<Film className="label-icon" size={11} />
<span className="font-semibold text-[0.85rem] overflow-hidden text-ellipsis whitespace-nowrap text-fg">
{dubVideoFile.name}
</span>
{dubVideoFile && (
<span className="text-fg-muted font-normal whitespace-nowrap text-[0.72rem]">
· {(dubVideoFile.size / 1024 / 1024).toFixed(1)} MB
</span>
)}
{activeProjectName && activeProjectName !== dubFilename && (
<span className="text-[#b8bb26] ml-[var(--space-3)] whitespace-nowrap text-[0.72rem]">
{activeProjectName}
</span>
)}
</div>
{!isPristineIdle && (
<div className="flex gap-[var(--space-2)] items-center shrink-0">
<Button
variant="subtle"
size="sm"
disabled
title={t('dub.save')}
aria-label={t('dub.save')}
>
<Save size={12} />
</Button>
<Button
variant="ghost"
size="sm"
disabled
title={t('dub.reset')}
aria-label={t('dub.reset')}
>
<RotateCcw size={12} />
</Button>
</div>
)}
</div>
<div className="flex gap-[var(--space-2)] items-center shrink-0">
<Button
variant="subtle"
size="sm"
disabled
title={t('dub.save')}
aria-label={t('dub.save')}
>
<Save size={12} />
</Button>
<Button
variant="ghost"
size="sm"
disabled
title={t('dub.reset')}
aria-label={t('dub.reset')}
>
<RotateCcw size={12} />
</Button>
</div>
</div>
)}
{/* Transcription failure banner shown in the idle state when a
job exists but transcription produced zero segments (or threw).
@@ -277,23 +292,14 @@ export default function IdleSkeleton({
)}
<label className="inline-flex items-center gap-[5px] text-[12px] text-[var(--muted,#a89984)] whitespace-nowrap">
<Globe size={13} /> {t('dub.source_language')}
<select
className="input-base text-[0.65rem]"
<SearchableSelect
ariaLabel={t('dub.source_language')}
value={dubSourceLangCode}
disabled={
dubStep === 'uploading' ||
dubStep === 'transcribing' ||
dubStep === 'installing-asr'
}
onChange={(event) => setDubSourceLangCode(event.target.value)}
>
<option value="auto">{t('bootstrap.auto_detect')}</option>
{LANG_CODES.map((language) => (
<option key={language.code} value={language.code}>
{languageLabel(language.code, uiLocale, language.label)} {language.code}
</option>
))}
</select>
options={sourceOptions}
onChange={setDubSourceLangCode}
menuPortal
disabled={['uploading', 'transcribing', 'installing-asr'].includes(dubStep)}
/>
</label>
<label
className="inline-flex items-center gap-[5px] text-[12px] text-[var(--muted,#a89984)] whitespace-nowrap"
@@ -367,232 +373,218 @@ export default function IdleSkeleton({
) : dubStep === 'idle' ? (
<>
{!demoDismissed && <DubbingDemo onDismiss={dismissDubDemo} />}
<label
htmlFor="video-upload"
className="dub-idle-drop"
onDragOver={(e) => {
e.preventDefault();
e.currentTarget.classList.add('is-dragging');
}}
onDragLeave={(e) => {
e.currentTarget.classList.remove('is-dragging');
}}
onDrop={(e) => {
e.preventDefault();
e.currentTarget.classList.remove('is-dragging');
const file = e.dataTransfer.files[0];
if (
file &&
(file.type.startsWith('video/') ||
file.type.startsWith('audio/') ||
/\.(mp3|wav|flac|m4a|aac|ogg|opus|wma)$/i.test(file.name))
) {
setDubVideoFile(file);
// #119: an audio file audio-only dubbing (skip video work, output audio).
setDubInputType(
file.type.startsWith('audio/') ||
/\.(mp3|wav|flac|m4a|aac|ogg|opus|wma)$/i.test(file.name)
? 'audio'
: 'video',
);
setDubStep('idle');
fileToMediaUrl(file, null).then((urls) => setDubLocalBlobUrl(urls));
}
}}
>
<div className="dub-idle-drop__puck">
<UploadCloud color="#d3869b" size={28} />
</div>
<div className="text-center">
<div className="text-[0.9rem] text-fg font-medium mb-[4px]">
{t('dub.drop_here')}
</div>
<div className="text-[0.7rem] text-[#665c54]">{t('dub.supported_formats')}</div>
</div>
<div className="dub-start-card">
<div
className="flex gap-[6px] items-center px-[10px] py-[6px] mt-[10px] bg-[rgba(255,255,255,0.02)] [border:1px_solid_rgba(255,255,255,0.06)] rounded-[6px] w-[min(420px,80%)]"
onClick={(e) => e.preventDefault()}
className="dub-idle-drop dub-start-drop"
role="group"
aria-labelledby="dub-start-title"
onDragOver={(e) => {
e.preventDefault();
e.currentTarget.classList.add('is-dragging');
}}
onDragLeave={(e) => {
e.currentTarget.classList.remove('is-dragging');
}}
onDrop={(e) => {
e.preventDefault();
e.currentTarget.classList.remove('is-dragging');
const file = e.dataTransfer.files[0];
if (
file &&
(file.type.startsWith('video/') ||
file.type.startsWith('audio/') ||
/\.(mp3|wav|flac|m4a|aac|ogg|opus|wma)$/i.test(file.name))
) {
setDubVideoFile(file);
// #119: an audio file audio-only dubbing (skip video work, output audio).
setDubInputType(
file.type.startsWith('audio/') ||
/\.(mp3|wav|flac|m4a|aac|ogg|opus|wma)$/i.test(file.name)
? 'audio'
: 'video',
);
setDubStep('idle');
fileToMediaUrl(file, null).then((urls) => setDubLocalBlobUrl(urls));
}
}}
>
<Link2 size={13} color="#a89984" />
<div className="dub-idle-drop__puck dub-start-mark" aria-hidden="true">
<UploadCloud size={28} />
</div>
<div className="text-center">
<div id="dub-start-title" className="dub-start-title text-fg font-medium">
{t('dub.drop_here')}
</div>
<div className="dub-start-formats text-fg-muted">
{t('dub.supported_formats')}
</div>
</div>
<Button
variant="primary"
size="sm"
className="dub-start-choose"
onClick={() => videoUploadInputRef.current?.click()}
leading={<UploadCloud size={14} aria-hidden="true" />}
>
{t('gallery.upload')}
</Button>
</div>
<div className="dub-start-url">
<Link2 size={15} aria-hidden="true" />
<input
type="text"
type="url"
aria-label={t('dub.paste_url')}
placeholder={t('dub.paste_url')}
value={ingestUrl}
onChange={(e) => setIngestUrl(e.target.value)}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
e.stopPropagation();
onIngestUrl();
}
}}
className="flex-1 bg-transparent border-none outline-none text-fg text-[0.75rem]"
className="input-base flex-1"
/>
<button
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onIngestUrl();
}}
onClick={onIngestUrl}
disabled={!ingestUrl.trim()}
className={`dub-ingest-row__cta ${ingestUrl.trim() ? 'is-ready' : ''}`}
>
{t('dub.ingest')}
</button>
</div>
<label
className="flex items-center gap-[6px] mt-[6px] px-[6px] py-[4px] text-[0.62rem] text-fg-muted cursor-pointer rounded-[4px] bg-[rgba(255,255,255,0.02)] hover:text-fg hover:bg-[rgba(255,255,255,0.05)]"
title={t('dub.pull_captions_title')}
onClick={(e) => {
e.stopPropagation();
}}
>
<input
type="checkbox"
className="m-0 accent-[var(--color-brand)]"
checked={fetchYtSubs}
onChange={(e) => setFetchYtSubs(e.target.checked)}
onClick={(e) => e.stopPropagation()}
/>
<span>{t('dub.pull_captions')}</span>
</label>
<div
className="flex items-center gap-[6px] mt-[4px] text-[0.62rem] text-fg-muted"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
>
<span>{t('dub.youtube_auth')}</span>
<input
ref={youtubeCookieInputRef}
type="file"
accept=".txt,text/plain"
aria-label={t('dub.youtube_cookie_file')}
className="max-w-[230px] text-[0.6rem] file:mr-[6px] file:rounded-[4px] file:border-0 file:px-[7px] file:py-[3px] file:bg-[rgba(255,255,255,0.08)] file:text-fg file:cursor-pointer"
onClick={(e) => e.stopPropagation()}
onChange={(e) => setYoutubeCookieFile(e.target.files?.[0] || null)}
/>
{youtubeCookieFile && (
<button
type="button"
className="text-fg-muted hover:text-fg"
onClick={() => setYoutubeCookieFile(null)}
aria-label={t('dub.remove_cookie_file')}
>
×
</button>
)}
<div className="dub-start-languages">
<label className="dub-landing-opts__lang inline-flex min-w-0 text-[var(--chrome-fg-muted)]">
<Globe size={13} aria-hidden="true" />
<span className="text-[0.72rem] font-medium">{t('dub.source_language')}</span>
<SearchableSelect
ariaLabel={t('dub.source_language')}
value={dubSourceLangCode}
options={sourceOptions}
onChange={setDubSourceLangCode}
menuPortal
buttonClassName="input-base dub-start-language-select"
/>
</label>
<label className="dub-landing-opts__lang inline-flex min-w-0 text-[var(--chrome-fg-muted)]">
<Globe size={13} aria-hidden="true" />
<span className="text-[0.72rem] font-medium">{t('dub.target_language')}</span>
<SearchableSelect
ariaLabel={t('dub.target_language')}
value={dubLangCode}
options={languageOptions}
onChange={(code) => {
setDubLangCode(code);
const language = LANG_CODES.find((item) => item.code === code);
if (language) setDubLang(language.label);
}}
menuPortal
buttonClassName="input-base dub-start-language-select"
/>
</label>
</div>
{/* Quiet path into the batch dubbing queue the queue page
itself (many videos × many languages, plus the watch
folder) had no UI entry point anywhere in the app. */}
<button
type="button"
className="dub-start-advanced"
onClick={() => setLandingAdvOpen((open) => !open)}
aria-expanded={landingAdvOpen}
aria-controls="dub-start-advanced-options"
>
<span>{t('dub.advanced')}</span>
{landingAdvOpen ? (
<ChevronUp size={13} aria-hidden="true" />
) : (
<ChevronDown size={13} aria-hidden="true" />
)}
</button>
<div
id="dub-start-advanced-options"
className="dub-start-options"
hidden={!landingAdvOpen}
>
<label className="dub-start-caption-option" title={t('dub.pull_captions_title')}>
<input
type="checkbox"
className="m-0 accent-[var(--color-brand)]"
checked={fetchYtSubs}
onChange={(e) => setFetchYtSubs(e.target.checked)}
/>
<FileText size={13} aria-hidden="true" />
<span>{t('dub.pull_captions')}</span>
</label>
<div className="dub-start-cookie-option">
<span>{t('dub.youtube_auth')}</span>
<input
ref={youtubeCookieInputRef}
type="file"
accept=".txt,text/plain"
aria-label={t('dub.youtube_cookie_file')}
onChange={(e) => setYoutubeCookieFile(e.target.files?.[0] || null)}
/>
{youtubeCookieFile && (
<button
type="button"
className="text-fg-muted hover:text-fg"
onClick={() => setYoutubeCookieFile(null)}
aria-label={t('dub.remove_cookie_file')}
>
×
</button>
)}
</div>
<div className="dub-start-generation-options">
<label className="dub-landing-adv__field" title={t('dub.num_speakers_help')}>
<span>
<Users size={12} aria-hidden="true" /> {t('dub.num_speakers_label')}
</span>
<input
type="number"
min={1}
max={20}
step={1}
className={SPEAKERS_INPUT}
placeholder={t('dub.num_speakers_auto')}
value={dubNumSpeakers ?? ''}
onChange={(e) => {
const value = parseInt(e.target.value, 10);
setDubNumSpeakers(
Number.isFinite(value) && value > 0 ? Math.min(value, 20) : null,
);
}}
/>
</label>
<label className="dub-landing-adv__field dub-landing-adv__field--grow">
<span>
<UserSquare2 size={12} aria-hidden="true" /> {t('dub.style')}
</span>
<input
type="text"
className="input-base"
placeholder={t('dub.style_placeholder')}
value={dubInstruct}
onChange={(e) => setDubInstruct(e.target.value)}
/>
</label>
</div>
</div>
<button
type="button"
data-testid="dub-open-batch-queue"
className="mt-[8px] inline-flex cursor-pointer items-center gap-[6px] rounded-[4px] border-0 bg-[rgba(255,255,255,0.02)] px-[8px] py-[4px] text-[0.62rem] text-fg-muted hover:bg-[rgba(255,255,255,0.05)] hover:text-fg"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onOpenQueue?.();
}}
className="dub-start-batch"
onClick={() => onOpenQueue?.()}
>
<Activity size={11} aria-hidden="true" />
<Activity size={12} aria-hidden="true" />
<span>{t('dub.batch_queue_link')}</span>
</button>
</label>
{/* One decision up front: the target language. Everything else
(speakers, style) hides behind Advanced ElevenLabs-style
flow, VoiceStudio chrome. The pick pre-seeds the editor. */}
<div className="flex items-center justify-between gap-[10px] mt-[10px] px-[10px] py-[8px] [border:1px_solid_var(--chrome-border)] rounded-[10px] bg-[var(--chrome-hover-bg)]">
<label className="dub-landing-opts__lang inline-flex items-center gap-[7px] min-w-0 text-[var(--chrome-fg-muted)]">
<Globe size={13} />
<span className="text-[0.72rem] font-medium whitespace-nowrap">
{t('dub.source_language')}
</span>
<select
className="input-base text-[0.65rem]"
value={dubSourceLangCode}
onChange={(event) => setDubSourceLangCode(event.target.value)}
>
<option value="auto">{t('bootstrap.auto_detect')}</option>
{LANG_CODES.map((language) => (
<option key={language.code} value={language.code}>
{languageLabel(language.code, uiLocale, language.label)} {language.code}
</option>
))}
</select>
</label>
<label className="dub-landing-opts__lang inline-flex items-center gap-[7px] min-w-0 text-[var(--chrome-fg-muted)]">
<Globe size={13} />
<span className="text-[0.72rem] font-medium whitespace-nowrap">
{t('dub.target_language', { defaultValue: 'Dub into' })}
</span>
<select
className="input-base text-[0.65rem]"
value={dubLangCode}
onChange={(e) => {
const lc = LANG_CODES.find((l) => l.code === e.target.value);
setDubLangCode(e.target.value);
if (lc) setDubLang(lc.label);
}}
>
{LANG_CODES.map((lc) => (
<option key={lc.code} value={lc.code}>
{languageLabel(lc.code, uiLocale, lc.label)} {lc.code}
</option>
))}
</select>
</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-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}
>
{t('dub.advanced', { defaultValue: 'Advanced' })}
{landingAdvOpen ? <ChevronUp size={11} /> : <ChevronDown size={11} />}
</button>
</div>
{landingAdvOpen && (
<div className="flex flex-wrap items-center gap-[12px] mt-[6px] px-[10px] py-[8px] [border:1px_solid_var(--chrome-border)] rounded-[10px]">
<label
className="dub-landing-adv__field inline-flex items-center gap-[6px] text-[0.7rem] text-[var(--chrome-fg-muted)]"
title={t('dub.num_speakers_help')}
>
<Users size={12} /> {t('dub.num_speakers_label')}
<input
type="number"
min={1}
max={20}
step={1}
className={SPEAKERS_INPUT}
placeholder={t('dub.num_speakers_auto')}
value={dubNumSpeakers ?? ''}
onChange={(e) => {
const v = parseInt(e.target.value, 10);
setDubNumSpeakers(Number.isFinite(v) && v > 0 ? Math.min(v, 20) : null);
}}
/>
</label>
<label className="dub-landing-adv__field dub-landing-adv__field--grow inline-flex items-center gap-[6px] text-[0.7rem] text-[var(--chrome-fg-muted)]">
<UserSquare2 size={12} /> {t('dub.style')}
<input
type="text"
className="input-base text-[0.65rem]"
placeholder={t('dub.style_placeholder')}
value={dubInstruct}
onChange={(e) => setDubInstruct(e.target.value)}
/>
</label>
</div>
)}
</>
) : (
// Any other active pipeline step reaching the no-file path (e.g.
@@ -604,6 +596,7 @@ export default function IdleSkeleton({
)}
<input
ref={videoUploadInputRef}
type="file"
accept="video/*,audio/*,.mp3,.wav,.m4a,.aac,.flac,.ogg,.opus,.wma"
id="video-upload"
@@ -623,10 +616,7 @@ export default function IdleSkeleton({
: 'video',
);
setDubStep('idle');
setDubLocalBlobUrl((prev) => {
fileToMediaUrl(file, prev).then((urls) => setDubLocalBlobUrl(urls));
return prev;
});
fileToMediaUrl(file, dubLocalBlobUrl).then((urls) => setDubLocalBlobUrl(urls));
}}
/>
@@ -655,15 +645,21 @@ export default function IdleSkeleton({
<div className="label-row">
<Globe className="label-icon" size={9} /> {t('dub.language')}
</div>
<select className="input-base text-[0.65rem]" disabled>
<option>{t('dub.auto')}</option>
</select>
<SearchableSelect
ariaLabel={t('dub.language')}
value="auto"
options={[{ value: 'auto', label: t('dub.auto') }]}
disabled
/>
</div>
<div className="flex-1 min-w-[80px]">
<div className="label-row">{t('dub.iso_code')}</div>
<select className="input-base text-[0.65rem]" disabled>
<option>en {t('dub.original_audio')}</option>
</select>
<SearchableSelect
ariaLabel={t('dub.iso_code')}
value="en"
options={[{ value: 'en', label: `en — ${t('dub.original_audio')}` }]}
disabled
/>
</div>
<div className="flex-1 min-w-[90px]">
<div className="label-row">
@@ -40,49 +40,115 @@ export default function ArchetypeCard({
const hasChips = Boolean(accentLabel || a.facets.whisper);
const cardBase =
'group relative flex min-h-[168px] flex-col gap-[9px] p-[13px] rounded-[10px] ' +
'border border-transparent bg-[rgba(255,255,255,0.026)] ' +
'group relative flex min-h-[168px] flex-col gap-[9px] p-[14px] rounded-[12px] ' +
'border border-transparent bg-[color-mix(in_srgb,var(--chrome-fg)_3%,transparent)] ' +
'transition-[transform,box-shadow,background-color] duration-150 ' +
'hover:-translate-y-px ' +
'hover:bg-[rgba(255,255,255,0.042)] hover:shadow-[0_8px_24px_rgba(0,0,0,0.32)] ' +
'hover:bg-[color-mix(in_srgb,var(--chrome-fg)_5.5%,transparent)] hover:shadow-[0_8px_24px_rgba(0,0,0,0.32)] ' +
'motion-reduce:transition-none motion-reduce:hover:translate-y-0';
const cardState = isPlaying
? 'shadow-[0_0_0_1px_var(--card-accent),0_6px_22px_rgba(0,0,0,0.4)]'
? 'bg-[color-mix(in_srgb,var(--card-accent)_9%,transparent)] shadow-[0_0_0_1px_var(--card-accent),0_6px_22px_rgba(0,0,0,0.4)]'
: '';
// No Tailwind preflight in this app (theme + utilities only) every native
// <button> carries the OS face (grey fill, outset border) unless reset, so
// each ghost control repeats the border-transparent + bg-transparent reset.
const iconBtn =
'inline-flex items-center justify-center w-[28px] h-[28px] flex-shrink-0 rounded-[8px] border border-transparent bg-transparent text-[var(--color-fg-muted)] cursor-pointer transition-[opacity,color,background-color] duration-150 hover:bg-[var(--chrome-hover-bg)] disabled:cursor-not-allowed disabled:opacity-30';
const designerBtn = onDesign ? (
<button
type="button"
className={`${iconBtn} opacity-50 group-hover:opacity-100 focus-visible:opacity-100 hover:text-[var(--card-accent)]`}
onClick={() => onDesign(a)}
disabled={materializationLocked}
title={t('gallery.open_designer', { defaultValue: 'Open in Designer' })}
aria-label={t('gallery.open_designer', { defaultValue: 'Open in Designer' })}
>
<Wand2 size={14} aria-hidden="true" />
</button>
) : null;
const moreBtn =
onUseInStories || onUseAsAudiobookDefault ? (
<Menu
placement="bottom-end"
disabled={materializationLocked}
items={[
onUseInStories
? {
id: 'stories',
icon: BookOpen,
label: t('gallery.use_in_stories', { defaultValue: 'Use in Stories' }),
onSelect: () => onUseInStories(a),
}
: null,
onUseAsAudiobookDefault
? {
id: 'audiobook',
icon: Headphones,
label: t('gallery.set_audiobook_default', {
defaultValue: 'Set as Audiobook default',
}),
onSelect: () => onUseAsAudiobookDefault(a),
}
: null,
].filter(Boolean)}
>
<button
type="button"
className={`${iconBtn} opacity-50 group-hover:opacity-100 focus-visible:opacity-100 hover:text-[var(--card-accent)]`}
aria-label={t('gallery.more_actions', { defaultValue: 'More actions' })}
title={t('gallery.more_actions', { defaultValue: 'More actions' })}
>
<Ellipsis size={15} aria-hidden="true" />
</button>
</Menu>
) : null;
return (
<div
data-testid="gallery-persona-card"
className={`${cardBase} ${cardState}`}
style={{ '--card-accent': color }}
>
{/* Header — the name is the focal point; metadata recedes (smaller, muted). */}
<div className="flex items-start gap-[10px]">
<ArchetypeAvatar item={a} size={40} />
{/* Header the name is the focal point; metadata recedes (smaller, muted).
Icon actions cluster top-right so the bottom row fits Preview + Use. */}
<div className="flex items-center gap-[10px]">
<ArchetypeAvatar item={a} size={44} />
<div className="flex-1 min-w-0">
<div className="text-[0.82rem] font-semibold leading-tight text-[var(--color-fg)] truncate">
<div
className="text-[0.82rem] font-semibold leading-tight text-[var(--color-fg)] truncate"
title={a.name}
>
{a.name}
</div>
{sub && (
<div className="text-[0.66rem] text-[var(--color-fg-muted)] mt-[3px] truncate">
<div
className="text-[0.66rem] text-[var(--color-fg-muted)] mt-[3px] truncate"
title={sub}
>
{sub}
</div>
)}
</div>
<button
type="button"
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(favoriteId)}
title={t('gallery.favorite', { defaultValue: 'Favorite' })}
aria-label={t('gallery.favorite', { defaultValue: 'Favorite' })}
aria-pressed={isFavorite}
>
<Star size={15} fill={isFavorite ? 'currentColor' : 'none'} aria-hidden="true" />
</button>
<div className="flex shrink-0 items-center gap-[2px]">
{designerBtn}
{moreBtn}
<button
type="button"
className={`flex items-center justify-center w-[28px] h-[28px] flex-shrink-0 rounded-[8px] border border-transparent bg-transparent 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(favoriteId)}
title={t('gallery.favorite', { defaultValue: 'Favorite' })}
aria-label={t('gallery.favorite', { defaultValue: 'Favorite' })}
aria-pressed={isFavorite}
>
<Star size={15} fill={isFavorite ? 'currentColor' : 'none'} aria-hidden="true" />
</button>
</div>
</div>
{/* Chips only render when present no empty reserved row. Cards without
@@ -105,11 +171,12 @@ export default function ArchetypeCard({
)}
{/* Actions quiet Preview (ghost, token hover), confident accent Use voice
(tinted solid accent with inverse text), subtle magic-wand icon. */}
(tinted solid accent with inverse text). Use voice never wraps: the
icon cluster lives in the header, so this row always fits. */}
<div className="mt-auto flex items-center gap-[6px] pt-[9px]">
<button
type="button"
className="inline-flex items-center gap-[6px] px-[9px] py-[6px] rounded-[6px] bg-transparent text-[var(--color-fg-muted)] text-[0.68rem] cursor-pointer transition-colors hover:bg-[var(--chrome-hover-bg)] hover:text-[var(--color-fg)] disabled:cursor-not-allowed disabled:opacity-50"
className="inline-flex shrink-0 items-center gap-[6px] border border-transparent bg-transparent px-[9px] py-[6px] rounded-[6px] text-[var(--color-fg-muted)] text-[0.68rem] cursor-pointer transition-colors hover:bg-[var(--chrome-hover-bg)] hover:text-[var(--color-fg)] disabled:cursor-not-allowed disabled:opacity-50"
onClick={() => onPreview(a)}
disabled={previewLocked}
aria-busy={isLoadingPreview}
@@ -126,7 +193,7 @@ export default function ArchetypeCard({
</button>
<button
type="button"
className="flex-1 inline-flex items-center justify-center gap-[6px] px-[10px] py-[6px] rounded-[6px] bg-[color-mix(in_srgb,var(--card-accent)_13%,transparent)] text-[var(--card-accent)] text-[0.7rem] 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)] disabled:cursor-not-allowed disabled:opacity-50"
className="flex-1 inline-flex min-w-0 items-center justify-center gap-[6px] whitespace-nowrap border border-transparent px-[10px] py-[6px] rounded-[6px] bg-[color-mix(in_srgb,var(--card-accent)_13%,transparent)] text-[var(--card-accent)] text-[0.7rem] 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)] disabled:cursor-not-allowed disabled:opacity-50"
onClick={() => onUse(a)}
disabled={materializationLocked}
aria-busy={isMaterializing}
@@ -138,53 +205,6 @@ export default function ArchetypeCard({
)}{' '}
{t('gallery.use_voice', { defaultValue: 'Use voice' })}
</button>
{onDesign ? (
<button
type="button"
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)}
disabled={materializationLocked}
title={t('gallery.open_designer', { defaultValue: 'Open in Designer' })}
aria-label={t('gallery.open_designer', { defaultValue: 'Open in Designer' })}
>
<Wand2 size={14} aria-hidden="true" />
</button>
) : null}
{onUseInStories || onUseAsAudiobookDefault ? (
<Menu
placement="bottom-end"
disabled={materializationLocked}
items={[
onUseInStories
? {
id: 'stories',
icon: BookOpen,
label: t('gallery.use_in_stories', { defaultValue: 'Use in Stories' }),
onSelect: () => onUseInStories(a),
}
: null,
onUseAsAudiobookDefault
? {
id: 'audiobook',
icon: Headphones,
label: t('gallery.set_audiobook_default', {
defaultValue: 'Set as Audiobook default',
}),
onSelect: () => onUseAsAudiobookDefault(a),
}
: null,
].filter(Boolean)}
>
<button
type="button"
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)] disabled:cursor-not-allowed disabled:opacity-30"
aria-label={t('gallery.more_actions', { defaultValue: 'More actions' })}
title={t('gallery.more_actions', { defaultValue: 'More actions' })}
>
<Ellipsis size={15} aria-hidden="true" />
</button>
</Menu>
) : null}
</div>
</div>
);
@@ -1,11 +1,42 @@
import React, { useState, useMemo, useEffect } from 'react';
import { Loader, Star, RotateCcw, Grid, List, SlidersHorizontal } from 'lucide-react';
import { Button, Select, Segmented } from '../../ui';
import {
Loader,
Star,
RotateCcw,
Grid,
List,
SlidersHorizontal,
Sparkles,
SearchX,
Search,
X,
User,
Cake,
AudioLines,
Flag,
Languages,
VolumeX,
LayoutGrid,
} from 'lucide-react';
import { Button, Input, Select, Segmented } from '../../ui';
import { useArchetypeCategories, useArchetypes } from '../../api/hooks';
import { titleCase, facetLabel } from './constants';
import { titleCase, facetLabel, GALLERY_GRID } from './constants';
import ArchetypeCard from './ArchetypeCard';
import GallerySectionHeader from './GallerySectionHeader';
const BROWSE_PAGE = 60;
const SEARCH_DEBOUNCE_MS = 200;
// Tiny local debounce so typing in the search box doesn't fire a backend
// query per keystroke (same pattern as VoiceSelector's picker search).
function useDebounced(value, ms) {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const id = setTimeout(() => setDebounced(value), ms);
return () => clearTimeout(id);
}, [value, ms]);
return debounced;
}
// Facet vocabularies values must match the backend taxonomy tokens exactly.
const FACETS = {
@@ -44,6 +75,22 @@ const FACETS = {
const hasActiveFilters = (f) => Object.values(f).some((v) => v !== null && v !== '');
// One icon per filter dimension shared by the Filters panel selects and
// the active-filter pills so both read as one family.
const FACET_ICONS = {
use_case: LayoutGrid,
gender: User,
age: Cake,
pitch: AudioLines,
accent: Flag,
lang: Languages,
whisper: VolumeX,
q: Search,
favOnly: Star,
};
const facetIconCls = 'shrink-0 text-[var(--chrome-fg-muted)]';
// Archetypes zone
export default function ArchetypesZone({
t,
@@ -66,9 +113,13 @@ export default function ArchetypesZone({
const [favOnly, setFavOnly] = useState(false);
const [filtersOpen, setFiltersOpen] = useState(false);
const [offset, setOffset] = useState(0);
// Free-text search over name/instruct (backend `q`) the facet filters
// alone can't reach a specific voice by name in a several-hundred catalog.
const [rawQ, setRawQ] = useState('');
const q = useDebounced(rawQ.trim(), SEARCH_DEBOUNCE_MS);
useEffect(() => {
setOffset(0);
}, [filters]);
}, [filters, q]);
const cleanFilters = useMemo(() => {
const out = {};
@@ -78,17 +129,16 @@ export default function ArchetypesZone({
return out;
}, [filters]);
// The Featured strip shows only when nothing is filtered; in that case Browse
// excludes featured to avoid duplicating it. Once any filter is active the
// Featured strip is hidden (see below), so Browse must include featured too
// otherwise the curated multilingual languages (Spanish/French/), which have
// *only* featured archetypes, would filter down to an empty list.
const showFeatured = !hasActiveFilters(filters) && !favOnly;
// Featured/browse exclusion mirrors the filter logic: once the user narrows
// by facet OR by search text, the Featured strip hides and Browse includes
// everything otherwise curated-only languages would filter to empty.
const showFeatured = !hasActiveFilters(filters) && !favOnly && !q;
const categoriesQ = useArchetypeCategories();
const featuredQ = useArchetypes({ featured: true, limit: 100 });
const browseQ = useArchetypes({
...cleanFilters,
...(q ? { q } : {}),
...(showFeatured ? { featured: false } : {}),
limit: BROWSE_PAGE,
offset,
@@ -100,6 +150,53 @@ export default function ArchetypesZone({
const total = browseQ.data?.total ?? 0;
const favSet = useMemo(() => new Set(favorites), [favorites]);
const categoryName = (id) => categories.find((c) => c.id === id)?.name || titleCase(id || '');
// Active-filter pills: every applied narrowing as a removable chip, so the
// bar says what it is doing without opening the Filters panel. Clearing a
// pill clears just that dimension; Clear-all resets everything incl. search.
const pills = [
favOnly
? {
key: 'favOnly',
Icon: FACET_ICONS.favOnly,
label: t('gallery.favorites', { defaultValue: 'Favorites' }),
clear: () => setFavOnly(false),
}
: null,
filters.use_case
? {
key: 'use_case',
Icon: FACET_ICONS.use_case,
label: categoryName(filters.use_case),
clear: () => setFilter('use_case', null),
}
: null,
...['gender', 'age', 'pitch', 'accent', 'lang'].map((dim) =>
filters[dim]
? {
key: dim,
Icon: FACET_ICONS[dim],
label: facetLabel(filters[dim]),
clear: () => setFilter(dim, null),
}
: null,
),
filters.whisper === true
? {
key: 'whisper',
Icon: FACET_ICONS.whisper,
label: t('archetypes.facet_whisper', { defaultValue: 'Whisper' }),
clear: () => setFilter('whisper', null),
}
: null,
q ? { key: 'q', Icon: FACET_ICONS.q, label: `${q}`, clear: () => setRawQ('') } : null,
].filter(Boolean);
const clearAll = () => {
resetFilters();
setFavOnly(false);
setRawQ('');
};
const applyFav = (list) => (favOnly ? list.filter((a) => favSet.has(a.id)) : list);
const advancedFilterCount = ['gender', 'age', 'pitch', 'accent', 'lang', 'whisper'].filter(
(key) => filters[key] !== null && filters[key] !== '',
@@ -126,11 +223,8 @@ export default function ArchetypesZone({
});
const facetToggle =
'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]'
: 'flex flex-col gap-[6px]';
'inline-flex items-center gap-[5px] h-[26px] box-border px-[9px] rounded-[var(--chrome-radius-pill)] 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)]';
const gridClass = viewMode === 'grid' ? GALLERY_GRID : 'flex flex-col gap-[6px]';
return (
// data-testid: stable e2e hook locale-independent, unlike the translated
@@ -138,20 +232,48 @@ export default function ArchetypesZone({
<div data-testid="archetypes-zone" className="flex-1 min-h-0 flex flex-col overflow-y-auto">
<div className="shrink-0 mb-[8px] pb-[8px] border-b border-transparent">
<div className="flex items-center gap-[6px] min-w-0">
<Select
size="sm"
className="w-auto min-w-[132px] max-w-[190px] shrink-0"
aria-label={t('gallery.zone_archetypes', { defaultValue: 'Archetypes' })}
value={filters.use_case ?? ''}
onChange={(e) => setFilter('use_case', e.target.value || null)}
>
<option value="">{t('gallery.all', { defaultValue: 'All' })}</option>
{categories.map((c) => (
<option key={c.id} value={c.id}>
{t(`archetypes.use_${c.id}`, { defaultValue: c.name })}
</option>
))}
</Select>
<div className="relative min-w-[140px] flex-[1_1_200px]">
<Search
size={14}
aria-hidden="true"
className="pointer-events-none absolute left-[9px] top-1/2 -translate-y-1/2 text-[var(--chrome-fg-muted)]"
/>
<Input
size="sm"
className="w-full pl-[30px] pr-[26px]"
value={rawQ}
onChange={(e) => setRawQ(e.target.value)}
placeholder={t('common.search', { defaultValue: 'Search…' })}
aria-label={t('common.search', { defaultValue: 'Search…' })}
/>
{rawQ && (
<button
type="button"
className="absolute right-[5px] top-1/2 flex h-[20px] w-[20px] -translate-y-1/2 items-center justify-center rounded-[6px] border border-transparent bg-transparent text-[var(--chrome-fg-muted)] transition-colors hover:bg-[var(--chrome-hover-bg)] hover:text-[var(--color-fg)]"
onClick={() => setRawQ('')}
aria-label={t('common.clear', { defaultValue: 'Clear' })}
>
<X size={12} aria-hidden="true" />
</button>
)}
</div>
<span className="inline-flex min-w-0 shrink-0 items-center gap-[5px]">
<LayoutGrid size={13} aria-hidden="true" className={facetIconCls} />
<Select
size="sm"
className="w-auto min-w-[118px] max-w-[170px]"
aria-label={t('gallery.zone_archetypes', { defaultValue: 'Archetypes' })}
value={filters.use_case ?? ''}
onChange={(e) => setFilter('use_case', e.target.value || null)}
>
<option value="">{t('gallery.all', { defaultValue: 'All' })}</option>
{categories.map((c) => (
<option key={c.id} value={c.id}>
{t(`archetypes.use_${c.id}`, { defaultValue: c.name })}
</option>
))}
</Select>
</span>
<Button
variant="ghost"
size="sm"
@@ -169,28 +291,19 @@ export default function ArchetypesZone({
>
{t('gallery.filters', { defaultValue: 'Filters' })}
</Button>
<label className={facetToggle}>
<input
type="checkbox"
checked={favOnly}
onChange={(e) => setFavOnly(e.target.checked)}
/>
<Star size={12} /> {t('gallery.favorites', { defaultValue: 'Favorites' })}
</label>
{hasActiveFilters(filters) || favOnly ? (
<Button
variant="icon"
iconSize="md"
onClick={() => {
resetFilters();
setFavOnly(false);
}}
title={t('gallery.reset', { defaultValue: 'Reset' })}
aria-label={t('gallery.reset', { defaultValue: 'Reset' })}
>
<RotateCcw size={13} />
</Button>
) : null}
<button
type="button"
className={`inline-flex items-center gap-[5px] h-[26px] box-border px-[9px] rounded-[var(--chrome-radius-pill)] border border-transparent bg-transparent text-[0.68rem] whitespace-nowrap cursor-pointer transition-colors hover:bg-[var(--chrome-hover-bg)] ${
favOnly
? 'text-[#fabd2f]'
: 'text-[var(--chrome-fg-muted)] hover:text-[var(--chrome-fg)]'
}`}
aria-pressed={favOnly}
onClick={() => setFavOnly((v) => !v)}
>
<Star size={12} fill={favOnly ? 'currentColor' : 'none'} aria-hidden="true" />{' '}
{t('gallery.favorites', { defaultValue: 'Favorites' })}
</button>
<div className="ml-auto shrink-0">
<Segmented
size="xs"
@@ -210,26 +323,32 @@ export default function ArchetypesZone({
{filtersOpen ? (
<div className="mt-[6px] flex items-center gap-[6px] overflow-x-auto pb-px [scrollbar-width:thin]">
{['gender', 'age', 'pitch', 'accent', 'lang'].map((dim) => (
<Select
key={dim}
size="sm"
className="w-auto min-w-[94px] max-w-[132px] shrink-0"
aria-label={t(`archetypes.facet_${dim}`, { defaultValue: titleCase(dim) })}
value={filters[dim] ?? ''}
onChange={(e) => setFilter(dim, e.target.value || null)}
>
<option value="">
{t(`archetypes.facet_${dim}`, { defaultValue: titleCase(dim) })}
</option>
{FACETS[dim].map((opt) => (
<option key={opt} value={opt}>
{facetLabel(opt)}
</option>
))}
</Select>
))}
{['gender', 'age', 'pitch', 'accent', 'lang'].map((dim) => {
const DimIcon = FACET_ICONS[dim];
return (
<span key={dim} className="inline-flex shrink-0 items-center gap-[5px]">
<DimIcon size={13} aria-hidden="true" className={facetIconCls} />
<Select
size="sm"
className="w-auto min-w-[88px] max-w-[124px]"
aria-label={t(`archetypes.facet_${dim}`, { defaultValue: titleCase(dim) })}
value={filters[dim] ?? ''}
onChange={(e) => setFilter(dim, e.target.value || null)}
>
<option value="">
{t(`archetypes.facet_${dim}`, { defaultValue: titleCase(dim) })}
</option>
{FACETS[dim].map((opt) => (
<option key={opt} value={opt}>
{facetLabel(opt)}
</option>
))}
</Select>
</span>
);
})}
<label className={`${facetToggle} shrink-0`}>
<VolumeX size={13} aria-hidden="true" className={facetIconCls} />
<input
type="checkbox"
checked={filters.whisper === true}
@@ -239,15 +358,50 @@ export default function ArchetypesZone({
</label>
</div>
) : null}
{/* Active narrowings as removable pills + one Clear-all the bar says
what it is doing without opening the Filters panel. */}
{pills.length > 0 && (
<div className="mt-[6px] flex flex-wrap items-center gap-[5px]">
{pills.map((pill) => (
<span
key={pill.key}
className="inline-flex max-w-full items-center gap-[5px] rounded-[var(--chrome-radius-pill)] bg-[var(--chrome-accent-bg)] py-[2px] pl-[8px] pr-[5px] text-[0.66rem] text-[color:var(--chrome-accent)]"
>
<pill.Icon size={11} aria-hidden="true" className="shrink-0" />
<span className="min-w-0 truncate">{pill.label}</span>
<button
type="button"
className="flex h-[16px] w-[16px] shrink-0 items-center justify-center rounded-full border border-transparent bg-transparent transition-colors hover:bg-[color-mix(in_srgb,var(--chrome-accent)_22%,transparent)]"
onClick={pill.clear}
aria-label={t('common.remove', {
defaultValue: 'Remove {{term}}',
term: pill.label,
})}
>
<X size={10} aria-hidden="true" />
</button>
</span>
))}
<button
type="button"
className="inline-flex shrink-0 cursor-pointer items-center gap-[4px] border border-transparent bg-transparent px-[6px] text-[0.66rem] text-[var(--chrome-fg-muted)] transition-colors hover:text-[var(--color-fg)]"
onClick={clearAll}
aria-label={t('gallery.reset', { defaultValue: 'Reset' })}
>
<RotateCcw size={11} aria-hidden="true" />
{t('gallery.reset', { defaultValue: 'Reset' })}
</button>
</div>
)}
</div>
{showFeatured && (
<section className="mb-[14px]">
<div className="flex justify-between items-center pb-[8px] shrink-0">
<div className="text-[0.85rem] font-medium">
{t('archetypes.featured', { defaultValue: 'Featured' })}
</div>
</div>
<section className="mb-[16px]">
<GallerySectionHeader
icon={<Sparkles size={12} strokeWidth={1.5} color="#fabd2f" aria-hidden="true" />}
title={t('archetypes.featured', { defaultValue: 'Featured' })}
/>
<div className={gridClass}>
{applyFav(featured).map((a) => (
<ArchetypeCard key={a.id} {...cardProps(a)} />
@@ -257,16 +411,12 @@ export default function ArchetypesZone({
)}
<section className="mb-[14px]">
<div className="flex justify-between items-center pb-[8px] shrink-0">
<div className="text-[0.85rem] font-medium">
{t('archetypes.browse_all', { defaultValue: 'Browse all' })}
<span className="ml-[6px] px-[7px] py-[1px] rounded-[10px] bg-bg-elev-2 text-[var(--text-secondary)] text-[0.65rem] font-normal">
{total}
</span>
</div>
</div>
<GallerySectionHeader
title={t('archetypes.browse_all', { defaultValue: 'Browse all' })}
count={total}
/>
{browseQ.isLoading ? (
<div className="flex items-center justify-center p-[24px] text-[var(--text-secondary)]">
<div className="flex items-center justify-center gap-[8px] p-[24px] text-[var(--text-secondary)]">
<Loader className="spin" size={18} />
</div>
) : (
@@ -277,8 +427,11 @@ export default function ArchetypesZone({
))}
</div>
{applyFav(browse).length === 0 && (
<div className="flex flex-col items-center justify-center px-[16px] py-[32px] text-[var(--text-secondary)] text-center">
{t('gallery.no_matches', { defaultValue: 'No voices match these filters.' })}
<div className="flex flex-col items-center justify-center gap-[8px] px-[16px] py-[32px] text-center text-[var(--text-secondary)]">
<SearchX size={20} strokeWidth={1.5} aria-hidden="true" />
<span className="max-w-[300px] text-[0.78rem] leading-[1.6]">
{t('gallery.no_matches', { defaultValue: 'No voices match these filters.' })}
</span>
</div>
)}
{offset + BROWSE_PAGE < total && !favOnly && (
@@ -1,9 +1,10 @@
import React from 'react';
import { describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen } from '@testing-library/react';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import ArchetypesZone from './ArchetypesZone';
import ArchetypeCard from './ArchetypeCard';
import { useArchetypes } from '../../api/hooks';
vi.mock('../../api/hooks', () => ({
useArchetypeCategories: () => ({
@@ -12,11 +13,11 @@ vi.mock('../../api/hooks', () => ({
{ id: 'social', name: 'Social Media', icon: 'Radio' },
],
}),
useArchetypes: (filters) => ({
useArchetypes: vi.fn((filters) => ({
data: filters.featured ? { items: [] } : { items: [], total: 0 },
isLoading: false,
isFetching: false,
}),
})),
}));
const t = (_key, options = {}) => options.defaultValue || _key;
@@ -134,3 +135,67 @@ describe('ArchetypeCard accessibility', () => {
expect(onUseAsAudiobookDefault).toHaveBeenCalledWith(archetype);
});
});
describe('ArchetypesZone enhanced filters', () => {
it('searches the whole catalog by name after a short debounce', async () => {
render(<ArchetypesZone {...baseProps} />);
const search = screen.getByRole('textbox', { name: 'Search…' });
fireEvent.change(search, { target: { value: 'Librarian' } });
await waitFor(
() => {
const calls = vi.mocked(useArchetypes).mock.calls;
expect(calls.some(([f]) => f?.q === 'Librarian')).toBe(true);
},
{ timeout: 2000 },
);
});
it('clears the search from its inline button', async () => {
render(<ArchetypesZone {...baseProps} />);
fireEvent.change(screen.getByRole('textbox', { name: 'Search…' }), {
target: { value: 'Librarian' },
});
fireEvent.click(screen.getByRole('button', { name: 'Clear' }));
expect(screen.getByRole('textbox', { name: 'Search…' })).toHaveValue('');
});
it('shows each active filter as an iconified removable pill', () => {
const setFilter = vi.fn();
render(
<ArchetypesZone
{...baseProps}
setFilter={setFilter}
filters={{ ...baseProps.filters, gender: 'female' }}
/>,
);
// Pill carries the facet icon + readable label.
const pill = screen.getByText('Female').closest('span[class*="rounded-"]');
expect(pill.querySelector('svg')).not.toBeNull();
// The test t() mock leaves {{term}} uninterpolated match that literally.
fireEvent.click(screen.getByRole('button', { name: 'Remove {{term}}' }));
expect(setFilter).toHaveBeenCalledWith('gender', null);
});
it('clears everything from the pills row at once', () => {
const setFilter = vi.fn();
const resetFilters = vi.fn();
render(
<ArchetypesZone
{...baseProps}
setFilter={setFilter}
resetFilters={resetFilters}
filters={{ ...baseProps.filters, gender: 'female' }}
/>,
);
fireEvent.click(screen.getByRole('button', { name: 'Reset' }));
expect(resetFilters).toHaveBeenCalledOnce();
});
it('iconifies the facet selects in the Filters panel', () => {
const { container } = render(<ArchetypesZone {...baseProps} />);
fireEvent.click(screen.getByRole('button', { name: 'Filters' }));
// Category + 5 facet selects each sit beside their dimension icon.
const icons = container.querySelectorAll('.border-b span[class*="inline-flex"] > svg');
expect(icons.length).toBeGreaterThanOrEqual(6);
});
});
@@ -1,9 +1,11 @@
import React, { useMemo } from 'react';
import { Loader, Send } from 'lucide-react';
import { Loader, Send, Store, CloudOff } from 'lucide-react';
import { useCommunityItems } from '../../api/hooks';
import { communitySubmitUrl } from '../../api/community';
import { openExternal } from '../../api/external';
import ArchetypeCard from './ArchetypeCard';
import GallerySectionHeader from './GallerySectionHeader';
import { GALLERY_GRID } from './constants';
const itemKey = (item) => `community:${item._source_repo || item.source || 'default'}:${item.id}`;
@@ -36,18 +38,18 @@ export default function CommunityZone({
};
const submitBtn =
'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)]';
'inline-flex items-center gap-[5px] px-[10px] py-[6px] border border-transparent bg-transparent text-[var(--text-secondary)] rounded-[var(--chrome-radius-pill)] text-[0.7rem] cursor-pointer transition-colors hover:bg-[var(--chrome-hover-bg)] hover:text-[var(--color-fg)]';
return (
<div className="flex-1 min-h-0 flex flex-col overflow-y-auto">
<div className="shrink-0 px-[10px] py-[8px] mb-[8px] bg-bg-elev-2 rounded-[8px] text-[0.72rem] text-[var(--text-secondary)] leading-[1.4] flex items-center justify-between gap-[12px] flex-wrap">
<span>
<div className="mb-[10px] flex shrink-0 flex-wrap items-center justify-between gap-x-[12px] gap-y-[8px]">
<p className="m-0 min-w-0 flex-1 text-[0.72rem] leading-[1.5] text-[var(--text-secondary)]">
{t('gallery.community_explainer', {
defaultValue:
'Designed presets and recorded voices shared by the community, loaded from the omnivoice-gallery.',
})}
</span>
<div className="flex gap-[6px] shrink-0">
</p>
<div className="flex shrink-0 gap-[6px]">
<button className={submitBtn} onClick={() => submit('preset')}>
<Send size={13} /> {t('gallery.submit_preset', { defaultValue: 'Submit a preset' })}
</button>
@@ -58,39 +60,49 @@ export default function CommunityZone({
</div>
{itemsQ.isLoading ? (
<div className="flex items-center justify-center p-[24px] text-[var(--text-secondary)]">
<div className="flex items-center justify-center gap-[8px] p-[24px] text-[var(--text-secondary)]">
<Loader className="spin" size={18} />
</div>
) : items.length === 0 ? (
<div className="flex flex-col items-center justify-center px-[16px] py-[32px] text-[var(--text-secondary)] text-center">
{t('gallery.community_empty', {
defaultValue:
'No community voices loaded yet — connect to the internet and reopen, or be the first to submit one.',
})}
<div className="flex flex-col items-center justify-center gap-[8px] px-[16px] py-[32px] text-center text-[var(--text-secondary)]">
<CloudOff size={20} strokeWidth={1.5} aria-hidden="true" />
<span className="max-w-[340px] text-[0.78rem] leading-[1.6]">
{t('gallery.community_empty', {
defaultValue:
'No community voices loaded yet — connect to the internet and reopen, or be the first to submit one.',
})}
</span>
</div>
) : (
<div className="grid grid-cols-[repeat(auto-fill,minmax(248px,1fr))] gap-[10px]">
{items.map((it) => (
<ArchetypeCard
key={itemKey(it)}
a={it}
t={t}
favoriteId={itemKey(it)}
isFavorite={favSet.has(itemKey(it))}
isPlaying={playingId === itemKey(it)}
isLoadingPreview={loadingPreviewId === itemKey(it)}
previewLocked={Boolean(loadingPreviewId)}
onToggleFavorite={toggleFavorite}
onPreview={onPreview}
onUse={onUse}
onDesign={it.type === 'preset' && it.instruct ? onDesign : null}
onUseInStories={onUseInStories}
onUseAsAudiobookDefault={onUseAsAudiobookDefault}
isMaterializing={materializingId === it.id}
materializationLocked={Boolean(materializingId)}
/>
))}
</div>
<>
<GallerySectionHeader
icon={<Store size={12} strokeWidth={1.5} aria-hidden="true" />}
title={t('gallery.zone_community', { defaultValue: 'Community' })}
count={items.length}
/>
<div className={GALLERY_GRID}>
{items.map((it) => (
<ArchetypeCard
key={itemKey(it)}
a={it}
t={t}
favoriteId={itemKey(it)}
isFavorite={favSet.has(itemKey(it))}
isPlaying={playingId === itemKey(it)}
isLoadingPreview={loadingPreviewId === itemKey(it)}
previewLocked={Boolean(loadingPreviewId)}
onToggleFavorite={toggleFavorite}
onPreview={onPreview}
onUse={onUse}
onDesign={it.type === 'preset' && it.instruct ? onDesign : null}
onUseInStories={onUseInStories}
onUseAsAudiobookDefault={onUseAsAudiobookDefault}
isMaterializing={materializingId === it.id}
materializationLocked={Boolean(materializingId)}
/>
))}
</div>
</>
)}
</div>
);
@@ -0,0 +1,106 @@
// Gallery polish the borderless workspace redesign.
//
// Guards the design contract without snapshotting pixels: one shared fluid
// card grid (no fixed px floor that overflows narrow shells), theme-token
// card surfaces (no hardcoded white overlays), and the single section-header
// component every zone uses.
import React from 'react';
import { describe, expect, it, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import ArchetypeCard from './ArchetypeCard';
import GallerySectionHeader from './GallerySectionHeader';
import { GALLERY_GRID } from './constants';
const t = (_key, options = {}) => options.defaultValue || _key;
const archetype = {
id: 'narrator',
name: 'Narrator',
language: 'English',
use_case: 'narration',
facets: { gender: 'female', age: 'adult', pitch: 'moderate pitch' },
attrs: {},
};
describe('Gallery polish', () => {
it('shares one fluid card grid with no fixed pixel floor', () => {
expect(GALLERY_GRID).toContain('gallery-cards');
expect(GALLERY_GRID).toContain('min(264px,100%)');
expect(GALLERY_GRID).not.toContain('minmax(248px');
});
it('renders section headers with a title, count pill, and hairline hook', () => {
render(<GallerySectionHeader title="Browse all" count={128} />);
expect(screen.getByText('Browse all')).toBeTruthy();
expect(screen.getByTestId('gallery-section-count')).toHaveTextContent('128');
expect(document.querySelector('.gallery-section-header')).toBeTruthy();
});
it('keeps cards on theme-token surfaces with a rounded shell', () => {
const { container } = render(
<ArchetypeCard
a={archetype}
t={t}
isFavorite={false}
isPlaying={false}
isLoadingPreview={false}
onPreview={vi.fn()}
onUse={vi.fn()}
onDesign={vi.fn()}
onToggleFavorite={vi.fn()}
/>,
);
const card = container.querySelector('[data-testid="gallery-persona-card"]');
expect(card.className).toContain('rounded-[12px]');
expect(card.className).toContain('color-mix(in_srgb,var(--chrome-fg)');
expect(card.className).not.toContain('rgba(255,255,255');
});
it('tints (not just rings) the playing card', () => {
const { container } = render(
<ArchetypeCard
a={archetype}
t={t}
isFavorite={false}
isPlaying
isLoadingPreview={false}
onPreview={vi.fn()}
onUse={vi.fn()}
onDesign={vi.fn()}
onToggleFavorite={vi.fn()}
/>,
);
const card = container.querySelector('[data-testid="gallery-persona-card"]');
expect(card.className).toContain('var(--card-accent)');
});
it('resets native button faces and never wraps the Use-voice label', () => {
const { container } = render(
<ArchetypeCard
a={archetype}
t={t}
isFavorite={false}
isPlaying={false}
isLoadingPreview={false}
onPreview={vi.fn()}
onUse={vi.fn()}
onDesign={vi.fn()}
onUseInStories={vi.fn()}
onUseAsAudiobookDefault={vi.fn()}
onToggleFavorite={vi.fn()}
/>,
);
const card = container.querySelector('[data-testid="gallery-persona-card"]');
// Every native <button> on the card carries the reset (no preflight).
for (const btn of card.querySelectorAll('button')) {
expect(btn.className).toContain('border-transparent');
}
const useBtn = screen.getByRole('button', { name: 'Use voice' });
expect(useBtn.className).toContain('whitespace-nowrap');
// Designer + overflow live in the header cluster, not the action row.
const header = card.querySelector('.truncate').closest('.flex');
expect(header.querySelector('[aria-label="Open in Designer"]')).not.toBeNull();
expect(header.querySelector('[aria-label="More actions"]')).not.toBeNull();
});
});
@@ -0,0 +1,23 @@
/**
* GallerySectionHeader one quiet section divider for every gallery zone.
*
* Mono kicker + optional count pill + a hairline that fades to the right
* (the Launchpad collection pattern): structure from spacing and type, never
* from boxes. `icon` tints the kicker; `count` renders the pill.
*/
export default function GallerySectionHeader({ icon = null, title, count = null }) {
return (
<div className="gallery-section-header m-0 mb-[10px] flex shrink-0 items-center gap-[9px] [font-family:var(--chrome-font-mono)] text-[length:var(--chrome-label-size)] font-medium uppercase [letter-spacing:var(--chrome-label-track)] text-[color:var(--chrome-fg-dim)] after:h-px after:flex-1 after:content-[''] after:[background-image:linear-gradient(90deg,color-mix(in_srgb,var(--chrome-fg)_14%,transparent),transparent)]">
{icon}
<span className="min-w-0 truncate">{title}</span>
{count !== null && count !== undefined && (
<span
className="shrink-0 rounded-full bg-[var(--chrome-hover-bg)] px-[8px] py-[1px] normal-case tracking-normal text-[color:var(--chrome-fg-muted)] [font-variant-numeric:tabular-nums]"
data-testid="gallery-section-count"
>
{count}
</span>
)}
</div>
);
}
+28 -23
View File
@@ -27,6 +27,7 @@ import {
previewVoiceUrl,
} from '../../api/gallery';
import AudioTrimmer from '../AudioTrimmer';
import GallerySectionHeader from './GallerySectionHeader';
import { apiFetch } from '../../api/client';
import { askConfirm } from '../../utils/dialog';
@@ -265,18 +266,20 @@ export default function ImportsZone({
};
const voicePlay =
'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';
'flex items-center justify-center w-[30px] h-[30px] rounded-full border border-transparent bg-[var(--chrome-hover-bg)] text-[var(--text-primary)] cursor-pointer flex-shrink-0 transition-colors hover:bg-[var(--chrome-accent-bg)] hover:text-[var(--chrome-accent)] disabled:cursor-not-allowed disabled:opacity-50';
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)]';
'flex items-center justify-center w-[28px] h-[28px] bg-transparent text-[var(--text-secondary)] rounded-[8px] cursor-pointer transition-colors hover:bg-[var(--chrome-hover-bg)] hover:text-[var(--text-primary)]';
const dangerBtn =
'flex items-center justify-center w-[28px] h-[28px] bg-transparent text-[var(--text-secondary)] rounded-[8px] cursor-pointer transition-colors hover:bg-[color-mix(in_srgb,var(--chrome-severity-err,#cc241d)_12%,transparent)] hover:text-[color:var(--chrome-severity-err,#cc241d)]';
return (
<div className="flex-1 min-h-0 flex flex-col overflow-hidden">
<div className="shrink-0 px-[10px] py-[8px] mb-[8px] bg-bg-elev-2 rounded-[8px] text-[0.72rem] text-[var(--text-secondary)] leading-[1.4]">
<p className="m-0 mb-[8px] shrink-0 text-[0.72rem] leading-[1.5] text-[var(--text-secondary)]">
{t('gallery.import_explainer', {
defaultValue:
'Paste a URL you have the rights to (or upload a file), trim the part you need, and save it as a voice. You are responsible for the licensing of anything you import.',
})}
</div>
</p>
<div className="shrink-0 flex flex-col gap-[10px]">
<div className="flex gap-[6px]">
@@ -341,8 +344,8 @@ export default function ImportsZone({
</div>
{results.length > 0 && (
<div className="shrink-0 bg-bg-elev-2 rounded-[8px] max-h-[180px] overflow-hidden flex flex-col">
<div className="flex justify-between items-center px-[10px] py-[8px] bg-bg-elev-1 text-[0.75rem] font-medium shrink-0">
<div className="shrink-0 rounded-[12px] bg-[color-mix(in_srgb,var(--chrome-fg)_2.5%,transparent)] max-h-[180px] overflow-hidden flex flex-col mb-[8px]">
<div className="flex justify-between items-center px-[12px] py-[8px] text-[0.75rem] font-medium shrink-0 text-[var(--text-secondary)]">
<span>
{t('gallery.search_results', {
defaultValue: '{{count}} results',
@@ -350,8 +353,9 @@ export default function ImportsZone({
})}
</span>
<button
className="bg-none border-none text-[var(--text-secondary)] cursor-pointer p-[2px]"
className="bg-transparent border border-transparent rounded-[8px] text-[var(--text-secondary)] cursor-pointer p-[4px] transition-colors hover:bg-[var(--chrome-hover-bg)] hover:text-[var(--color-fg)]"
onClick={() => setResults([])}
aria-label={t('common.close', { defaultValue: 'Close' })}
>
<X size={14} />
</button>
@@ -377,31 +381,31 @@ export default function ImportsZone({
</div>
)}
<div className="flex justify-between items-center pb-[8px] shrink-0">
<div className="text-[0.85rem] font-medium">
{t('gallery.my_imports', { defaultValue: 'My Imports' })}
<span className="ml-[6px] px-[7px] py-[1px] rounded-[10px] bg-bg-elev-2 text-[var(--text-secondary)] text-[0.65rem] font-normal">
{voices.length}
</span>
</div>
</div>
<GallerySectionHeader
icon={<Upload size={12} strokeWidth={1.5} aria-hidden="true" />}
title={t('gallery.my_imports', { defaultValue: 'My Imports' })}
count={voices.length}
/>
{voicesQ.isLoading ? (
<div className="flex items-center justify-center p-[24px] text-[var(--text-secondary)]">
<div className="flex items-center justify-center gap-[8px] p-[24px] text-[var(--text-secondary)]">
<Loader className="spin" size={18} />
</div>
) : voices.length === 0 ? (
<div className="flex flex-col items-center justify-center px-[16px] py-[32px] text-[var(--text-secondary)] text-center">
{t('gallery.no_imports', {
defaultValue: 'Nothing imported yet. Paste a URL above to get started.',
})}
<div className="flex flex-col items-center justify-center gap-[8px] px-[16px] py-[32px] text-center text-[var(--text-secondary)]">
<Upload size={20} strokeWidth={1.5} aria-hidden="true" />
<span className="max-w-[300px] text-[0.78rem] leading-[1.6]">
{t('gallery.no_imports', {
defaultValue: 'Nothing imported yet. Paste a URL above to get started.',
})}
</span>
</div>
) : (
<div className="flex flex-col gap-[4px] overflow-y-auto flex-1 pr-[4px]">
<div className="flex flex-col gap-[2px] overflow-y-auto flex-1 pr-[4px]">
{voices.map((v) => (
<div
key={v.id}
className="flex items-center gap-[8px] px-[10px] py-[8px] bg-bg-elev-2 rounded-[8px] transition-colors hover:bg-bg-elev-1"
className="group flex min-h-[56px] min-w-0 items-center gap-[10px] rounded-[10px] border border-transparent bg-transparent px-[10px] py-[8px] transition-colors hover:bg-[color-mix(in_srgb,var(--chrome-fg)_4%,transparent)]"
>
<button
className={`${voicePlay} disabled:cursor-not-allowed disabled:opacity-50`}
@@ -479,9 +483,10 @@ export default function ImportsZone({
</Menu>
) : null}
<button
className="flex items-center justify-center w-[24px] h-[24px] bg-transparent text-[var(--text-secondary)] rounded-[4px] cursor-pointer hover:bg-[#3d1f1f] hover:text-[#fb4934]"
className={dangerBtn}
onClick={() => handleDelete(v)}
title={t('gallery.delete', { defaultValue: 'Delete' })}
aria-label={t('gallery.delete', { defaultValue: 'Delete' })}
>
<Trash2 size={14} />
</button>
@@ -1,3 +1,10 @@
// Shared archetype facet helpers — used by ArchetypesZone + ArchetypeCard.
export const titleCase = (s) => (s ? String(s).replace(/\b\w/g, (c) => c.toUpperCase()) : s);
export const facetLabel = (v) => titleCase(String(v).replace(' pitch', '').replace(' accent', ''));
// One responsive card grid for every gallery zone: auto-filling columns with
// a fluid floor — `min(264px,100%)` (not a bare px value) so a narrow shell
// packs to a single column instead of overflowing sideways. 264px is the
// narrowest width that still fits the card's Preview + Use-voice row.
export const GALLERY_GRID =
'gallery-cards grid grid-cols-[repeat(auto-fill,minmax(min(264px,100%),1fr))] gap-[10px]';
+114
View File
@@ -0,0 +1,114 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useAppStore } from '../store';
import { apiJson } from '../api/client';
import { installRecommendedAsr } from '../utils/asrModelMissing';
// Keep model checks separate from microphone access: checking the page must
// never record audio or start a download. Capture still validates server-side.
export function useDictationReadiness() {
const modelId = useAppStore((store) => store.dictationModelId);
const [state, setState] = useState({ phase: 'checking', missing: null });
const task = useRef(null);
const mounted = useRef(false);
const checking = useRef(false);
const check = useCallback(async () => {
if (task.current || checking.current) return false;
checking.current = true;
try {
const result = await apiJson(
`/dictation/readiness?model_id=${encodeURIComponent(useAppStore.getState().dictationModelId || '')}`,
);
if (mounted.current && !task.current)
setState((previous) => ({
phase: result.ready === true ? 'ready' : result.missing ? 'missing' : 'error',
missing: result.missing,
error: result.ready !== true && previous.error,
}));
return result.ready === true;
} catch {
if (mounted.current && !task.current) setState({ phase: 'error', missing: null });
return false;
} finally {
checking.current = false;
}
}, [modelId]);
useEffect(() => {
mounted.current = true;
void check();
const refresh = () => {
if (document.visibilityState !== 'hidden') void check();
};
const timer = setInterval(refresh, 5000);
window.addEventListener('focus', refresh);
return () => {
mounted.current = false;
clearInterval(timer);
window.removeEventListener('focus', refresh);
task.current?.abort();
};
}, [check]);
// Install a catalogue model (`/dictation/models` row) or, with no argument,
// the backend's recommended one. The chooser on the Transcriptions page lets
// the user pick by accuracy / latency / languages instead of being handed
// the recommended download only; the installer persists the pick as the
// dictation model, so capture uses it the moment the weights land.
const install = useCallback(
async (model) => {
if (task.current) return;
const payload = model?.repo_id
? {
recommended: {
repo_id: model.repo_id,
label: model.label,
size_gb: model.size_gb,
dictation_id: model.id,
},
}
: state.missing;
if (!payload?.recommended?.repo_id) return;
const controller = new AbortController();
task.current = controller;
setState((previous) => ({
...previous,
phase: 'installing',
percent: 0,
error: false,
target: payload.recommended,
}));
try {
const installed = await installRecommendedAsr(payload, {
signal: controller.signal,
onProgress: ({ percent }) => {
if (mounted.current) setState((previous) => ({ ...previous, percent }));
},
});
task.current = null;
if (installed?.dictation_id && mounted.current) {
// The installer persisted this selection; keep the recorder's store
// in sync before the next readiness check and capture request.
useAppStore.setState({ dictationModelId: installed.dictation_id });
}
if (mounted.current) await check();
} catch {
if (mounted.current)
setState((previous) => ({ ...previous, phase: 'missing', error: true, target: null }));
} finally {
task.current = null;
}
},
[state.missing, check],
);
// Switch capture to a model that is already on disk — no download, just the
// pref write the store already does — then re-check so the page unblocks.
const select = useCallback(
async (model) => {
if (task.current || !model?.id) return false;
useAppStore.getState().setDictationModelId(model.id);
return check();
},
[check],
);
return { ...state, check, install, select };
}
@@ -0,0 +1,112 @@
import { act, renderHook, waitFor } from '@testing-library/react';
import { beforeEach, expect, it, vi } from 'vitest';
const { apiJson, installRecommendedAsr, setDictationModelId } = vi.hoisted(() => ({
apiJson: vi.fn(),
installRecommendedAsr: vi.fn(),
setDictationModelId: vi.fn(),
}));
vi.mock('../store', () => ({
useAppStore: Object.assign((selector) => selector({ dictationModelId: 'sherpa-whisper-tiny' }), {
getState: () => ({ dictationModelId: 'sherpa-whisper-tiny', setDictationModelId }),
setState: vi.fn(),
}),
}));
vi.mock('../api/client', () => ({ apiJson }));
vi.mock('../utils/asrModelMissing', () => ({ installRecommendedAsr }));
import { useDictationReadiness } from './useDictationReadiness';
const missing = { recommended: { repo_id: 'test/model', label: 'Tiny', size_gb: 0.1 } };
beforeEach(() => {
vi.resetAllMocks();
});
it('checks without downloading, then waits for explicit installation and rechecks', async () => {
apiJson.mockResolvedValueOnce({ ready: false, missing }).mockResolvedValue({ ready: true });
let finish;
installRecommendedAsr.mockImplementation(
() =>
new Promise((resolve) => {
finish = resolve;
}),
);
const { result } = renderHook(useDictationReadiness);
expect(result.current.phase).toBe('checking');
await waitFor(() => expect(result.current.phase).toBe('missing'));
expect(installRecommendedAsr).not.toHaveBeenCalled();
let pending;
act(() => {
pending = result.current.install();
});
expect(result.current.phase).toBe('installing');
await act(async () => {
finish();
await pending;
});
expect(result.current.phase).toBe('ready');
expect(apiJson).toHaveBeenCalledTimes(2);
});
it('keeps failures retryable without claiming a connection error is missing weights', async () => {
apiJson.mockRejectedValueOnce(new Error('offline')).mockResolvedValue({ ready: false, missing });
installRecommendedAsr.mockRejectedValue(new Error('download failed'));
const { result } = renderHook(useDictationReadiness);
await waitFor(() => expect(result.current.phase).toBe('error'));
expect(result.current.missing).toBeNull();
await act(async () => {
await result.current.check();
});
await act(async () => {
await result.current.install();
});
expect(result.current.phase).toBe('missing');
expect(result.current.error).toBe(true);
});
it('refreshes when returning from settings', async () => {
apiJson.mockResolvedValueOnce({ ready: false, missing }).mockResolvedValue({ ready: true });
const { result } = renderHook(useDictationReadiness);
await waitFor(() => expect(result.current.phase).toBe('missing'));
act(() => window.dispatchEvent(new Event('focus')));
await waitFor(() => expect(result.current.phase).toBe('ready'));
});
it('installs the model the user picked and names it as the install target', async () => {
apiJson.mockResolvedValueOnce({ ready: false, missing }).mockResolvedValue({ ready: true });
installRecommendedAsr.mockResolvedValue({
repo_id: 'test/parakeet',
dictation_id: 'sherpa-parakeet-tdt-v2',
});
const { result } = renderHook(useDictationReadiness);
await waitFor(() => expect(result.current.phase).toBe('missing'));
const pick = {
id: 'sherpa-parakeet-tdt-v2',
repo_id: 'test/parakeet',
label: 'Parakeet TDT v2',
size_gb: 0.66,
};
let pending;
act(() => {
pending = result.current.install(pick);
});
expect(result.current.phase).toBe('installing');
expect(result.current.target).toEqual({
repo_id: 'test/parakeet',
label: 'Parakeet TDT v2',
size_gb: 0.66,
dictation_id: 'sherpa-parakeet-tdt-v2',
});
await act(async () => {
await pending;
});
expect(installRecommendedAsr).toHaveBeenCalledWith(
{ recommended: expect.objectContaining({ repo_id: 'test/parakeet' }) },
expect.any(Object),
);
expect(result.current.phase).toBe('ready');
});
it('switches to a model already on disk without downloading anything', async () => {
apiJson.mockResolvedValueOnce({ ready: false, missing }).mockResolvedValue({ ready: true });
const { result } = renderHook(useDictationReadiness);
await waitFor(() => expect(result.current.phase).toBe('missing'));
await act(async () => {
await result.current.select({ id: 'sherpa-zipformer-en-20m', installed: true });
});
expect(setDictationModelId).toHaveBeenCalledWith('sherpa-zipformer-en-20m');
expect(installRecommendedAsr).not.toHaveBeenCalled();
expect(result.current.phase).toBe('ready');
});
+15 -1
View File
@@ -472,6 +472,9 @@
"deleteProjectConfirm": "هل تريد حذف هذا المشروع نهائيًا؟"
},
"common": {
"stop": "توقف",
"resume": "استئناف",
"paused": "متوقف مؤقتًا",
"open": "مفتوح",
"cancel": "إلغاء",
"save": "حفظ",
@@ -778,6 +781,9 @@
"retry": "أعد المحاولة",
"activeEngine": "نشط {{family}}: {{engine}}",
"engineCompatLabel": "{{family}} توافق المحرك",
"dictation_model": "نموذج الإملاء",
"dictation_model_hint": "يُستخدم في الإملاء المباشر وفي هذا المحرك عند التفريغ.",
"dictation_not_installed": "غير مثبّت — اختره للتنزيل",
"active": "نشط",
"whyUnavailable": "ما الذي يحتاجه",
"diskDetails": "تفاصيل القرص",
@@ -2523,6 +2529,9 @@
"dismiss": "تجاهل",
"output": "الإخراج",
"title": "كتاب مسموع",
"tab_script": "النص",
"tab_voices": "الأصوات",
"tab_book": "الكتاب",
"subtitle": "اكتب قصتك أو استوردها، وأسند كل دور إلى صوت، ثم صدّر كتابًا صوتيًا متقنًا بفصول حقيقية.",
"script": "البرنامج النصي",
"script_placeholder": "#الفصل الأول\nذات مرة... [وقفة 500 مللي ثانية] النهاية.\n\n#الفصل الثاني\n[صوت: الراوي] وهكذا استمر.",
@@ -2722,7 +2731,12 @@
"message": "لا يوجد نموذج لتحويل الكلام إلى نص مثبّت — تحتاج هذه الميزة إلى نموذج لتفريغ الصوت.",
"download": "تنزيل {{label}} ({{size}} غيغابايت)",
"started": "جارٍ تنزيل {{label}} — تابع التقدم في الإعدادات ← النماذج، ثم حاول مرة أخرى.",
"install_failed": "تعذّر بدء التنزيل: {{message}}"
"install_failed": "تعذّر بدء التنزيل: {{message}}",
"choose": "اختر النموذج المناسب لعملك — تختلف الدقة واللغات وحجم التنزيل. لا يبدأ التنزيل إلا عند النقر.",
"group_offline": "أعلى دقة — يفرّغ الكلام بعد أن تتوقف عن التحدث",
"group_streaming": "أقل تأخير — نص مباشر أثناء حديثك",
"use": "استخدام {{label}}",
"installed": "مثبّت"
},
"model_missing": {
"message": "لم يتم تنزيل هذا النموذج على {{target}}.",
+15 -1
View File
@@ -472,6 +472,9 @@
"deleteProjectConfirm": "Dieses Projekt dauerhaft löschen?"
},
"common": {
"stop": "Stopp",
"resume": "Fortsetzen",
"paused": "Pausiert",
"open": "Offen",
"cancel": "Abbrechen",
"save": "Speichern",
@@ -778,6 +781,9 @@
"retry": "Versuchen Sie es noch einmal",
"activeEngine": "Aktiv {{family}}: {{engine}}",
"engineCompatLabel": "{{family}} Motorkompatibilität",
"dictation_model": "Diktiermodell",
"dictation_model_hint": "Wird vom Live-Diktat und von dieser Engine beim Transkribieren verwendet.",
"dictation_not_installed": "Nicht installiert zum Herunterladen auswählen",
"active": "aktiv",
"whyUnavailable": "Was benötigt wird",
"diskDetails": "Speicherplatzdetails",
@@ -2523,6 +2529,9 @@
"dismiss": "Verwerfen",
"output": "Ausgabe",
"title": "Hörbuch",
"tab_script": "Skript",
"tab_voices": "Stimmen",
"tab_book": "Buch",
"subtitle": "Schreiben oder importieren Sie Ihre Geschichte, weisen Sie jeder Rolle eine Stimme zu und exportieren Sie ein fertiges Hörbuch mit echten Kapiteln.",
"script": "Skript",
"script_placeholder": "# Kapitel eins\nEs war einmal… [Pause 500 ms] das Ende.\n\n# Kapitel Zwei\n[Stimme:Erzähler] Und so ging es weiter.",
@@ -2722,7 +2731,12 @@
"message": "Es ist kein Sprache-zu-Text-Modell installiert — diese Funktion benötigt eines zum Transkribieren.",
"download": "{{label}} herunterladen ({{size}} GB)",
"started": "{{label}} wird heruntergeladen — Fortschritt unter Einstellungen → Modelle, danach erneut versuchen.",
"install_failed": "Download konnte nicht gestartet werden: {{message}}"
"install_failed": "Download konnte nicht gestartet werden: {{message}}",
"choose": "Wähle das Modell, das zu deiner Arbeit passt — Genauigkeit, Sprachen und Downloadgröße unterscheiden sich. Downloads starten erst per Klick.",
"group_offline": "Beste Genauigkeit — transkribiert, nachdem du aufhörst zu sprechen",
"group_streaming": "Geringste Latenz — Live-Text, während du sprichst",
"use": "{{label}} verwenden",
"installed": "Installiert"
},
"model_missing": {
"message": "Dieses Modell wurde auf {{target}} nicht heruntergeladen.",
+15 -1
View File
@@ -199,6 +199,9 @@
"dismiss": "Dismiss",
"output": "Output",
"title": "Audiobook",
"tab_script": "Script",
"tab_voices": "Voices",
"tab_book": "Book",
"subtitle": "Write or import your story, cast every voice, then export a polished audiobook with real chapters.",
"script": "Script",
"script_placeholder": "# Chapter One\nOnce upon a time… [pause 500ms] the end.\n\n# Chapter Two\n[voice:narrator] And so it continued.",
@@ -2119,6 +2122,9 @@
"retry": "Retry",
"activeEngine": "Active {{family}}: {{engine}}",
"engineCompatLabel": "{{family}} engine compatibility",
"dictation_model": "Dictation model",
"dictation_model_hint": "Used by live dictation and by this engine when it transcribes.",
"dictation_not_installed": "Not installed — select to download",
"active": "active",
"whyUnavailable": "What it needs",
"diskDetails": "Disk details",
@@ -2265,6 +2271,9 @@
"restart_failed": "The voice backend keeps crashing and couldn't be restarted — check the crash notice or Settings → Logs → Backend."
},
"common": {
"stop": "Stop",
"resume": "Resume",
"paused": "Paused",
"open": "Open",
"cancel": "Cancel",
"save": "Save",
@@ -3220,7 +3229,12 @@
"message": "No speech-to-text model is installed — this feature needs one to transcribe audio.",
"download": "Download {{label}} ({{size}} GB)",
"started": "Downloading {{label}} — watch progress in Settings → Models, then try again.",
"install_failed": "Couldn't start the download: {{message}}"
"install_failed": "Couldn't start the download: {{message}}",
"choose": "Pick the model that fits your work — accuracy, languages and download size differ. Downloads start only when you click.",
"group_offline": "Best accuracy — transcribes after you stop speaking",
"group_streaming": "Lowest latency — live text while you speak",
"use": "Use {{label}}",
"installed": "Installed"
},
"model_missing": {
"message": "This model is not downloaded on {{target}}.",
+15 -1
View File
@@ -472,6 +472,9 @@
"deleteProjectConfirm": "¿Eliminar este proyecto permanentemente?"
},
"common": {
"stop": "Detener",
"resume": "Reanudar",
"paused": "En pausa",
"open": "Abierto",
"cancel": "Cancelar",
"save": "Guardar",
@@ -778,6 +781,9 @@
"retry": "Reintentar",
"activeEngine": "Activo {{family}}: {{engine}}",
"engineCompatLabel": "{{family}} compatibilidad del motor",
"dictation_model": "Modelo de dictado",
"dictation_model_hint": "Lo usan el dictado en vivo y este motor al transcribir.",
"dictation_not_installed": "No instalado: selecciónalo para descargarlo",
"active": "activo",
"whyUnavailable": "Qué necesita",
"diskDetails": "Detalles de disco",
@@ -2523,6 +2529,9 @@
"dismiss": "Descartar",
"output": "Salida",
"title": "Audiolibro",
"tab_script": "Guion",
"tab_voices": "Voces",
"tab_book": "Libro",
"subtitle": "Escribe o importa tu historia, asigna cada voz y exporta un audiolibro pulido con capítulos reales.",
"script": "Guión",
"script_placeholder": "# Capítulo uno\nÉrase una vez… [pausa 500ms] el final.\n\n# Capítulo Dos\n[voz:narrador] Y así continuó.",
@@ -2722,7 +2731,12 @@
"message": "No hay ningún modelo de voz a texto instalado — esta función necesita uno para transcribir audio.",
"download": "Descargar {{label}} ({{size}} GB)",
"started": "Descargando {{label}} — sigue el progreso en Ajustes → Modelos y vuelve a intentarlo.",
"install_failed": "No se pudo iniciar la descarga: {{message}}"
"install_failed": "No se pudo iniciar la descarga: {{message}}",
"choose": "Elige el modelo que se ajuste a tu trabajo: la precisión, los idiomas y el tamaño de descarga varían. Las descargas solo empiezan al hacer clic.",
"group_offline": "Máxima precisión — transcribe cuando dejas de hablar",
"group_streaming": "Mínima latencia — texto en vivo mientras hablas",
"use": "Usar {{label}}",
"installed": "Instalado"
},
"model_missing": {
"message": "Este modelo no está descargado en {{target}}.",
+15 -1
View File
@@ -472,6 +472,9 @@
"deleteProjectConfirm": "Supprimer définitivement ce projet ?"
},
"common": {
"stop": "Arrêter",
"resume": "Reprendre",
"paused": "En pause",
"open": "Ouvert",
"cancel": "Annuler",
"save": "Enregistrer",
@@ -778,6 +781,9 @@
"retry": "Réessayer",
"activeEngine": "Actif {{family}} : {{engine}}",
"engineCompatLabel": "Compatibilité moteur {{family}}",
"dictation_model": "Modèle de dictée",
"dictation_model_hint": "Utilisé par la dictée en direct et par ce moteur pour la transcription.",
"dictation_not_installed": "Non installé — sélectionnez pour télécharger",
"active": "actif",
"whyUnavailable": "Ce qu'il lui faut",
"diskDetails": "Détails du disque",
@@ -2523,6 +2529,9 @@
"dismiss": "Ignorer",
"output": "Sortie",
"title": "Livre audio",
"tab_script": "Script",
"tab_voices": "Voix",
"tab_book": "Livre",
"subtitle": "Écrivez ou importez votre histoire, attribuez chaque voix, puis exportez un livre audio soigné avec de vrais chapitres.",
"script": "Scénario",
"script_placeholder": "# Chapitre un\nIl était une fois… [pause 500ms] la fin.\n\n# Chapitre deux\n[voix:narrateur] Et ainsi de suite.",
@@ -2722,7 +2731,12 @@
"message": "Aucun modèle de reconnaissance vocale n'est installé — cette fonction en a besoin pour transcrire l'audio.",
"download": "Télécharger {{label}} ({{size}} Go)",
"started": "Téléchargement de {{label}} — suivez la progression dans Réglages → Modèles, puis réessayez.",
"install_failed": "Impossible de démarrer le téléchargement : {{message}}"
"install_failed": "Impossible de démarrer le téléchargement : {{message}}",
"choose": "Choisissez le modèle adapté à votre usage : précision, langues et taille de téléchargement varient. Les téléchargements ne démarrent qu'au clic.",
"group_offline": "Meilleure précision — transcrit une fois que vous avez fini de parler",
"group_streaming": "Latence minimale — texte en direct pendant que vous parlez",
"use": "Utiliser {{label}}",
"installed": "Installé"
},
"model_missing": {
"message": "Ce modèle nest pas téléchargé sur {{target}}.",
+15 -1
View File
@@ -472,6 +472,9 @@
"deleteProjectConfirm": "इस प्रोजेक्ट को स्थायी रूप से मिटाएँ?"
},
"common": {
"stop": "रुकें",
"resume": "फिर शुरू करें",
"paused": "रोका गया",
"open": "खुला",
"cancel": "रद्द करें",
"save": "सहेजें",
@@ -778,6 +781,9 @@
"retry": "पुनः प्रयास करें",
"activeEngine": "सक्रिय {{family}}: {{engine}}",
"engineCompatLabel": "{{family}} इंजन अनुकूलता",
"dictation_model": "डिक्टेशन मॉडल",
"dictation_model_hint": "लाइव डिक्टेशन और ट्रांसक्रिप्शन के समय इस इंजन द्वारा उपयोग किया जाता है।",
"dictation_not_installed": "इंस्टॉल नहीं है — डाउनलोड करने के लिए चुनें",
"active": "सक्रिय",
"whyUnavailable": "इसे क्या चाहिए",
"diskDetails": "डिस्क विवरण",
@@ -2523,6 +2529,9 @@
"dismiss": "खारिज करें",
"output": "आउटपुट",
"title": "ऑडियोबुक",
"tab_script": "स्क्रिप्ट",
"tab_voices": "आवाज़ें",
"tab_book": "किताब",
"subtitle": "अपनी कहानी लिखें या आयात करें, हर पात्र को आवाज़ दें, फिर वास्तविक अध्यायों वाली तैयार ऑडियोबुक निर्यात करें।",
"script": "स्क्रिप्ट",
"script_placeholder": "#अध्याय एक\nएक बार की बात है... [500 एमएस रोकें] अंत।\n\n#अध्याय दो\n[आवाज:वर्णनकर्ता] और इसी तरह यह जारी रहा।",
@@ -2722,7 +2731,12 @@
"message": "कोई स्पीच-टू-टेक्स्ट मॉडल इंस्टॉल नहीं है — ऑडियो ट्रांसक्राइब करने के लिए इस सुविधा को एक मॉडल चाहिए।",
"download": "{{label}} डाउनलोड करें ({{size}} GB)",
"started": "{{label}} डाउनलोड हो रहा है — प्रगति सेटिंग्स → मॉडल में देखें, फिर दोबारा कोशिश करें।",
"install_failed": "डाउनलोड शुरू नहीं हो सका: {{message}}"
"install_failed": "डाउनलोड शुरू नहीं हो सका: {{message}}",
"choose": "अपने काम के अनुरूप मॉडल चुनें — सटीकता, भाषाएँ और डाउनलोड आकार अलग-अलग हैं। डाउनलोड केवल क्लिक करने पर शुरू होता है।",
"group_offline": "सर्वोत्तम सटीकता — बोलना बंद करने के बाद लिप्यंतरण",
"group_streaming": "न्यूनतम विलंब — बोलते समय लाइव टेक्स्ट",
"use": "{{label}} उपयोग करें",
"installed": "इंस्टॉल है"
},
"model_missing": {
"message": "यह मॉडल {{target}} पर डाउनलोड नहीं है।",
+15 -1
View File
@@ -472,6 +472,9 @@
"deleteProjectConfirm": "Hapus proyek ini secara permanen?"
},
"common": {
"stop": "Berhenti",
"resume": "Lanjutkan",
"paused": "Dijeda",
"open": "Buka",
"cancel": "Batalkan",
"save": "Simpan",
@@ -778,6 +781,9 @@
"retry": "Coba lagi",
"activeEngine": "Aktif {{family}}: {{engine}}",
"engineCompatLabel": "{{family}} kompatibilitas mesin",
"dictation_model": "Model dikte",
"dictation_model_hint": "Dipakai oleh dikte langsung dan oleh mesin ini saat mentranskripsi.",
"dictation_not_installed": "Belum terpasang — pilih untuk mengunduh",
"active": "aktif",
"whyUnavailable": "Apa yang dibutuhkan",
"diskDetails": "Rincian disk",
@@ -2523,6 +2529,9 @@
"dismiss": "Tutup",
"output": "Keluaran",
"title": "Buku Audio",
"tab_script": "Naskah",
"tab_voices": "Suara",
"tab_book": "Buku",
"subtitle": "Tulis atau impor cerita Anda, pilih suara untuk setiap peran, lalu ekspor buku audio rapi dengan bab sungguhan.",
"script": "naskah",
"script_placeholder": "# Bab Satu\nSuatu ketika… [pause 500ms] berakhir.\n\n# Bab Dua\n[suara: narator] Dan hal itu berlanjut.",
@@ -2722,7 +2731,12 @@
"message": "Tidak ada model ucapan-ke-teks yang terpasang — fitur ini membutuhkannya untuk mentranskripsi audio.",
"download": "Unduh {{label}} ({{size}} GB)",
"started": "Mengunduh {{label}} — pantau progresnya di Pengaturan → Model, lalu coba lagi.",
"install_failed": "Tidak dapat memulai unduhan: {{message}}"
"install_failed": "Tidak dapat memulai unduhan: {{message}}",
"choose": "Pilih model yang sesuai dengan pekerjaan Anda — akurasi, bahasa, dan ukuran unduhan berbeda-beda. Unduhan hanya dimulai saat Anda mengklik.",
"group_offline": "Akurasi terbaik — mentranskripsi setelah Anda berhenti bicara",
"group_streaming": "Latensi terendah — teks langsung saat Anda berbicara",
"use": "Gunakan {{label}}",
"installed": "Terpasang"
},
"model_missing": {
"message": "Model ini belum diunduh di {{target}}.",
+15 -1
View File
@@ -472,6 +472,9 @@
"deleteProjectConfirm": "Eliminare definitivamente questo progetto?"
},
"common": {
"stop": "Fermati",
"resume": "Riprendi",
"paused": "In pausa",
"open": "Aperto",
"cancel": "Annulla",
"save": "Salva",
@@ -778,6 +781,9 @@
"retry": "Riprova",
"activeEngine": "Attivo {{family}}: {{engine}}",
"engineCompatLabel": "{{family}} compatibilità motore",
"dictation_model": "Modello di dettatura",
"dictation_model_hint": "Usato dalla dettatura dal vivo e da questo motore per la trascrizione.",
"dictation_not_installed": "Non installato: seleziona per scaricare",
"active": "attivo",
"whyUnavailable": "Cosa serve",
"diskDetails": "Dettagli disco",
@@ -2523,6 +2529,9 @@
"dismiss": "Ignora",
"output": "Uscita",
"title": "Audiolibro",
"tab_script": "Copione",
"tab_voices": "Voci",
"tab_book": "Libro",
"subtitle": "Scrivi o importa la tua storia, assegna ogni voce e poi esporta un audiolibro rifinito con veri capitoli.",
"script": "Copione",
"script_placeholder": "# Capitolo primo\nC'era una volta… [pausa 500ms] fine.\n\n# Capitolo due\n[voce: narratore] E così è continuato.",
@@ -2722,7 +2731,12 @@
"message": "Nessun modello di riconoscimento vocale è installato — questa funzione ne richiede uno per trascrivere l'audio.",
"download": "Scarica {{label}} ({{size}} GB)",
"started": "Download di {{label}} in corso — segui l'avanzamento in Impostazioni → Modelli, poi riprova.",
"install_failed": "Impossibile avviare il download: {{message}}"
"install_failed": "Impossibile avviare il download: {{message}}",
"choose": "Scegli il modello adatto al tuo lavoro: precisione, lingue e dimensione del download variano. I download partono solo al clic.",
"group_offline": "Massima precisione — trascrive quando smetti di parlare",
"group_streaming": "Latenza minima — testo in tempo reale mentre parli",
"use": "Usa {{label}}",
"installed": "Installato"
},
"model_missing": {
"message": "Questo modello non è stato scaricato su {{target}}.",
+15 -1
View File
@@ -472,6 +472,9 @@
"deleteProjectConfirm": "このプロジェクトを完全に削除しますか?"
},
"common": {
"stop": "停止",
"resume": "再開",
"paused": "一時停止中",
"open": "開く",
"cancel": "キャンセル",
"save": "保存",
@@ -778,6 +781,9 @@
"retry": "再試行",
"activeEngine": "アクティブ {{family}}: {{engine}}",
"engineCompatLabel": "{{family}} エンジンの互換性",
"dictation_model": "ディクテーションモデル",
"dictation_model_hint": "ライブディクテーションと、このエンジンでの文字起こしに使用されます。",
"dictation_not_installed": "未インストール — 選択してダウンロード",
"active": "アクティブな",
"whyUnavailable": "必要なもの",
"diskDetails": "ディスクの詳細",
@@ -2523,6 +2529,9 @@
"dismiss": "閉じる",
"output": "出力",
"title": "オーディオブック",
"tab_script": "台本",
"tab_voices": "音声",
"tab_book": "ブック",
"subtitle": "物語を書いたり読み込んだり、声を配役して、本格的な章付きオーディオブックを書き出せます。",
"script": "スクリプト",
"script_placeholder": "# 第一章\nむかしむかし… [500 ミリ秒停止] 終了。\n\n# 第二章\n[音声:ナレーター] そして、それは続きました。",
@@ -2722,7 +2731,12 @@
"message": "音声認識モデルがインストールされていません — この機能で文字起こしを行うにはモデルが必要です。",
"download": "{{label}} をダウンロード({{size}} GB",
"started": "{{label}} をダウンロード中 — 設定 → モデルで進行状況を確認し、完了後にもう一度お試しください。",
"install_failed": "ダウンロードを開始できませんでした: {{message}}"
"install_failed": "ダウンロードを開始できませんでした: {{message}}",
"choose": "用途に合ったモデルを選んでください — 精度、対応言語、ダウンロードサイズが異なります。ダウンロードはクリックしたときにのみ開始します。",
"group_offline": "最高精度 — 話し終えてから文字起こし",
"group_streaming": "最低遅延 — 話しながらリアルタイムでテキスト表示",
"use": "{{label}} を使用",
"installed": "インストール済み"
},
"model_missing": {
"message": "このモデルは {{target}} にダウンロードされていません。",
+15 -1
View File
@@ -704,6 +704,9 @@
"deleteProjectConfirm": "이 프로젝트를 영구적으로 삭제할까요?"
},
"common": {
"stop": "중지",
"resume": "재개",
"paused": "일시 중지됨",
"open": "열기",
"cancel": "취소",
"save": "저장",
@@ -1022,6 +1025,9 @@
"retry": "재시도",
"activeEngine": "활성 {{family}}: {{engine}}",
"engineCompatLabel": "{{family}} 엔진 호환성",
"dictation_model": "받아쓰기 모델",
"dictation_model_hint": "실시간 받아쓰기와 이 엔진의 전사에 사용됩니다.",
"dictation_not_installed": "설치되지 않음 — 선택하여 다운로드",
"active": "활성",
"whyUnavailable": "필요한 것",
"diskDetails": "디스크 세부 정보",
@@ -2927,6 +2933,9 @@
"dismiss": "닫기",
"output": "출력",
"title": "오디오북",
"tab_script": "대본",
"tab_voices": "음성",
"tab_book": "책",
"subtitle": "이야기를 쓰거나 가져오고, 각 배역의 목소리를 정한 다음 실제 챕터가 있는 완성도 높은 오디오북으로 내보내세요.",
"script": "스크립트",
"script_placeholder": "# Chapter One\nOnce upon a time… [pause 500ms] the end.\n\n# Chapter Two\n[voice:narrator] And so it continued.",
@@ -3143,7 +3152,12 @@
"message": "음성 인식 모델이 설치되어 있지 않습니다 — 이 기능으로 오디오를 전사하려면 모델이 필요합니다.",
"download": "{{label}} 다운로드 ({{size}} GB)",
"started": "{{label}} 다운로드 중 — 설정 → 모델에서 진행 상황을 확인한 뒤 다시 시도하세요.",
"install_failed": "다운로드를 시작할 수 없습니다: {{message}}"
"install_failed": "다운로드를 시작할 수 없습니다: {{message}}",
"choose": "작업에 맞는 모델을 선택하세요 — 정확도, 언어, 다운로드 크기가 다릅니다. 다운로드는 클릭할 때만 시작됩니다.",
"group_offline": "최고 정확도 — 말을 멈춘 뒤 받아쓰기",
"group_streaming": "최저 지연 — 말하는 동안 실시간 텍스트",
"use": "{{label}} 사용",
"installed": "설치됨"
},
"model_missing": {
"message": "이 모델은 {{target}}에 다운로드되지 않았습니다.",
+15 -1
View File
@@ -472,6 +472,9 @@
"deleteProjectConfirm": "Dit project permanent verwijderen?"
},
"common": {
"stop": "Stop",
"resume": "Hervatten",
"paused": "Gepauzeerd",
"open": "Openen",
"cancel": "Annuleer",
"save": "Opslaan",
@@ -778,6 +781,9 @@
"retry": "Opnieuw proberen",
"activeEngine": "Actief {{family}}: {{engine}}",
"engineCompatLabel": "{{family}} motorcompatibiliteit",
"dictation_model": "Dicteermodel",
"dictation_model_hint": "Gebruikt door live dicteren en door deze engine bij het transcriberen.",
"dictation_not_installed": "Niet geïnstalleerd — selecteer om te downloaden",
"active": "actief",
"whyUnavailable": "Wat het nodig heeft",
"diskDetails": "Schijfdetails",
@@ -2523,6 +2529,9 @@
"dismiss": "Sluiten",
"output": "Uitvoer",
"title": "Audioboek",
"tab_script": "Script",
"tab_voices": "Stemmen",
"tab_book": "Boek",
"subtitle": "Schrijf of importeer je verhaal, wijs elke stem toe en exporteer een verzorgd luisterboek met echte hoofdstukken.",
"script": "Script",
"script_placeholder": "# Hoofdstuk één\nEr was eens… [pauze 500 ms] het einde.\n\n# Hoofdstuk twee\n[stem:verteller] En zo ging het verder.",
@@ -2722,7 +2731,12 @@
"message": "Er is geen spraak-naar-tekstmodel geïnstalleerd — deze functie heeft er een nodig om audio te transcriberen.",
"download": "{{label}} downloaden ({{size}} GB)",
"started": "{{label}} wordt gedownload — volg de voortgang in Instellingen → Modellen en probeer het daarna opnieuw.",
"install_failed": "Kon de download niet starten: {{message}}"
"install_failed": "Kon de download niet starten: {{message}}",
"choose": "Kies het model dat bij je werk past — nauwkeurigheid, talen en downloadgrootte verschillen. Downloads starten pas als je klikt.",
"group_offline": "Hoogste nauwkeurigheid — transcribeert nadat je stopt met spreken",
"group_streaming": "Laagste latentie — live tekst terwijl je spreekt",
"use": "{{label}} gebruiken",
"installed": "Geïnstalleerd"
},
"model_missing": {
"message": "Dit model is niet gedownload op {{target}}.",
+15 -1
View File
@@ -472,6 +472,9 @@
"deleteProjectConfirm": "Usunąć ten projekt na stałe?"
},
"common": {
"stop": "Zatrzymaj się",
"resume": "Wznów",
"paused": "Wstrzymany",
"open": "Otwórz",
"cancel": "Anuluj",
"save": "Zapisz",
@@ -778,6 +781,9 @@
"retry": "Spróbuj ponownie",
"activeEngine": "Aktywny {{family}}: {{engine}}",
"engineCompatLabel": "{{family}} kompatybilność silnika",
"dictation_model": "Model dyktowania",
"dictation_model_hint": "Używany przez dyktowanie na żywo i przez ten silnik podczas transkrypcji.",
"dictation_not_installed": "Nie zainstalowano — wybierz, aby pobrać",
"active": "aktywny",
"whyUnavailable": "Czego potrzebuje",
"diskDetails": "Szczegóły dysku",
@@ -2523,6 +2529,9 @@
"dismiss": "Odrzuć",
"output": "Wyjście",
"title": "Książka audio",
"tab_script": "Scenariusz",
"tab_voices": "Głosy",
"tab_book": "Książka",
"subtitle": "Napisz lub zaimportuj historię, obsadź każdy głos, a następnie wyeksportuj dopracowany audiobook z prawdziwymi rozdziałami.",
"script": "Skrypt",
"script_placeholder": "#Rozdział pierwszy\nDawno, dawno temu… [pauza 500 ms] koniec.\n\n#Rozdział drugi\n[głos:narrator] I tak to trwało.",
@@ -2722,7 +2731,12 @@
"message": "Nie zainstalowano modelu zamiany mowy na tekst — ta funkcja wymaga go do transkrypcji dźwięku.",
"download": "Pobierz {{label}} ({{size}} GB)",
"started": "Pobieranie {{label}} — postęp znajdziesz w Ustawienia → Modele, potem spróbuj ponownie.",
"install_failed": "Nie udało się rozpocząć pobierania: {{message}}"
"install_failed": "Nie udało się rozpocząć pobierania: {{message}}",
"choose": "Wybierz model dopasowany do Twojej pracy — dokładność, języki i rozmiar pobierania się różnią. Pobieranie zaczyna się dopiero po kliknięciu.",
"group_offline": "Najwyższa dokładność — transkrybuje, gdy przestaniesz mówić",
"group_streaming": "Najniższe opóźnienie — tekst na żywo podczas mówienia",
"use": "Użyj {{label}}",
"installed": "Zainstalowany"
},
"model_missing": {
"message": "Ten model nie został pobrany na {{target}}.",
+15 -1
View File
@@ -472,6 +472,9 @@
"deleteProjectConfirm": "Excluir este projeto permanentemente?"
},
"common": {
"stop": "Pare",
"resume": "Retomar",
"paused": "Em pausa",
"open": "Abrir",
"cancel": "Cancelar",
"save": "Salvar",
@@ -778,6 +781,9 @@
"retry": "Tentar novamente",
"activeEngine": "Ativo {{family}}: {{engine}}",
"engineCompatLabel": "{{family}} compatibilidade do motor",
"dictation_model": "Modelo de ditado",
"dictation_model_hint": "Usado pelo ditado ao vivo e por este mecanismo ao transcrever.",
"dictation_not_installed": "Não instalado — selecione para baixar",
"active": "ativo",
"whyUnavailable": "O que falta",
"diskDetails": "Detalhes do disco",
@@ -2523,6 +2529,9 @@
"dismiss": "Dispensar",
"output": "Saída",
"title": "Audiolivro",
"tab_script": "Roteiro",
"tab_voices": "Vozes",
"tab_book": "Livro",
"subtitle": "Escreva ou importe sua história, escolha cada voz e exporte um audiolivro bem-acabado com capítulos de verdade.",
"script": "Roteiro",
"script_placeholder": "# Capítulo Um\nEra uma vez… [pausa de 500ms] o fim.\n\n# Capítulo Dois\n[voz: narrador] E assim continuou.",
@@ -2722,7 +2731,12 @@
"message": "Nenhum modelo de fala para texto está instalado — este recurso precisa de um para transcrever áudio.",
"download": "Baixar {{label}} ({{size}} GB)",
"started": "Baixando {{label}} — acompanhe o progresso em Configurações → Modelos e tente novamente.",
"install_failed": "Não foi possível iniciar o download: {{message}}"
"install_failed": "Não foi possível iniciar o download: {{message}}",
"choose": "Escolha o modelo que se ajusta ao seu trabalho — precisão, idiomas e tamanho do download variam. Os downloads só começam ao clicar.",
"group_offline": "Melhor precisão — transcreve depois que você para de falar",
"group_streaming": "Menor latência — texto ao vivo enquanto você fala",
"use": "Usar {{label}}",
"installed": "Instalado"
},
"model_missing": {
"message": "Este modelo não foi baixado em {{target}}.",
+15 -1
View File
@@ -472,6 +472,9 @@
"deleteProjectConfirm": "Удалить этот проект навсегда?"
},
"common": {
"stop": "Останавливаться",
"resume": "Возобновить",
"paused": "Приостановлен",
"open": "Открыть",
"cancel": "Отмена",
"save": "Сохранять",
@@ -778,6 +781,9 @@
"retry": "Повторить попытку",
"activeEngine": "Активен {{family}}: {{engine}}",
"engineCompatLabel": "Совместимость с двигателем {{family}}",
"dictation_model": "Модель диктовки",
"dictation_model_hint": "Используется для живой диктовки и этим движком при транскрибации.",
"dictation_not_installed": "Не установлена — выберите, чтобы скачать",
"active": "активный",
"whyUnavailable": "Что нужно",
"diskDetails": "Сведения о диске",
@@ -2523,6 +2529,9 @@
"dismiss": "Закрыть",
"output": "Вывод",
"title": "Аудиокнига",
"tab_script": "Сценарий",
"tab_voices": "Голоса",
"tab_book": "Книга",
"subtitle": "Напишите или импортируйте историю, назначьте голоса и экспортируйте готовую аудиокнигу с настоящими главами.",
"script": "Скрипт",
"script_placeholder": "# Глава первая\nОднажды… [пауза 500 мс] конец.\n\n# Глава вторая\n[голос: диктор] И так продолжалось.",
@@ -2722,7 +2731,12 @@
"message": "Модель распознавания речи не установлена — для транскрипции аудио она необходима.",
"download": "Скачать {{label}} ({{size}} ГБ)",
"started": "Скачивание {{label}} — следите за прогрессом в Настройки → Модели, затем повторите попытку.",
"install_failed": "Не удалось начать загрузку: {{message}}"
"install_failed": "Не удалось начать загрузку: {{message}}",
"choose": "Выберите модель под свою задачу — точность, языки и размер загрузки различаются. Загрузка начнётся только после нажатия.",
"group_offline": "Максимальная точность — расшифровка после окончания речи",
"group_streaming": "Минимальная задержка — текст появляется, пока вы говорите",
"use": "Использовать {{label}}",
"installed": "Установлена"
},
"model_missing": {
"message": "Эта модель не загружена на {{target}}.",
+15 -1
View File
@@ -472,6 +472,9 @@
"deleteProjectConfirm": "Ta bort projektet permanent?"
},
"common": {
"stop": "Sluta",
"resume": "Återuppta",
"paused": "Pausad",
"open": "Öppna",
"cancel": "Avbryt",
"save": "Spara",
@@ -778,6 +781,9 @@
"retry": "Försök igen",
"activeEngine": "Aktiv {{family}}: {{engine}}",
"engineCompatLabel": "{{family}} motorkompatibilitet",
"dictation_model": "Dikteringsmodell",
"dictation_model_hint": "Används av livediktering och av den här motorn vid transkribering.",
"dictation_not_installed": "Inte installerad — välj för att ladda ner",
"active": "aktiv",
"whyUnavailable": "Vad som krävs",
"diskDetails": "Diskdetaljer",
@@ -2523,6 +2529,9 @@
"dismiss": "Avfärda",
"output": "Utdata",
"title": "Ljudbok",
"tab_script": "Manus",
"tab_voices": "Röster",
"tab_book": "Bok",
"subtitle": "Skriv eller importera din berättelse, tilldela varje roll en röst och exportera en finslipad ljudbok med riktiga kapitel.",
"script": "Manus",
"script_placeholder": "# Kapitel ett\nDet var en gång... [paus 500ms] slutet.\n\n# Kapitel två\n[voice:berättare] Och så fortsatte det.",
@@ -2722,7 +2731,12 @@
"message": "Ingen tal-till-text-modell är installerad — den här funktionen behöver en för att transkribera ljud.",
"download": "Ladda ner {{label}} ({{size}} GB)",
"started": "Laddar ner {{label}} — följ förloppet i Inställningar → Modeller och försök sedan igen.",
"install_failed": "Kunde inte starta nedladdningen: {{message}}"
"install_failed": "Kunde inte starta nedladdningen: {{message}}",
"choose": "Välj den modell som passar ditt arbete — noggrannhet, språk och nedladdningsstorlek skiljer sig. Nedladdningar startar först när du klickar.",
"group_offline": "Bäst noggrannhet — transkriberar när du slutat tala",
"group_streaming": "Lägst latens — text i realtid medan du talar",
"use": "Använd {{label}}",
"installed": "Installerad"
},
"model_missing": {
"message": "Modellen har inte hämtats på {{target}}.",
+15 -1
View File
@@ -472,6 +472,9 @@
"deleteProjectConfirm": "ลบโปรเจ็กต์นี้อย่างถาวรหรือไม่"
},
"common": {
"stop": "หยุด",
"resume": "ดำเนินต่อ",
"paused": "หยุดชั่วคราว",
"open": "เปิด",
"cancel": "ยกเลิก",
"save": "บันทึก",
@@ -778,6 +781,9 @@
"retry": "ลองอีกครั้ง",
"activeEngine": "ใช้งาน {{family}}: {{engine}}",
"engineCompatLabel": "{{family}} ความเข้ากันได้ของเครื่องยนต์",
"dictation_model": "โมเดลการป้อนเสียง",
"dictation_model_hint": "ใช้โดยการป้อนเสียงสดและโดยเอนจินนี้เมื่อถอดความ",
"dictation_not_installed": "ยังไม่ได้ติดตั้ง — เลือกเพื่อดาวน์โหลด",
"active": "ใช้งานอยู่",
"whyUnavailable": "สิ่งที่ต้องมี",
"diskDetails": "รายละเอียดดิสก์",
@@ -2523,6 +2529,9 @@
"dismiss": "ปิด",
"output": "เอาต์พุต",
"title": "หนังสือเสียง",
"tab_script": "บท",
"tab_voices": "เสียงพูด",
"tab_book": "หนังสือ",
"subtitle": "เขียนหรือนำเข้าเรื่องราว เลือกเสียงให้แต่ละบทบาท แล้วส่งออกเป็นหนังสือเสียงที่สมบูรณ์พร้อมบทจริง",
"script": "สคริปต์",
"script_placeholder": "#บทที่หนึ่ง\nกาลครั้งหนึ่ง… [หยุดชั่วคราว 500ms] สิ้นสุด\n\n#บทที่สอง\n[เสียง: ผู้บรรยาย] และมันก็ดำเนินต่อไป",
@@ -2722,7 +2731,12 @@
"message": "ยังไม่ได้ติดตั้งโมเดลแปลงเสียงเป็นข้อความ — ฟีเจอร์นี้ต้องใช้โมเดลเพื่อถอดเสียง",
"download": "ดาวน์โหลด {{label}} ({{size}} GB)",
"started": "กำลังดาวน์โหลด {{label}} — ดูความคืบหน้าได้ที่ การตั้งค่า → โมเดล แล้วลองอีกครั้ง",
"install_failed": "ไม่สามารถเริ่มการดาวน์โหลดได้: {{message}}"
"install_failed": "ไม่สามารถเริ่มการดาวน์โหลดได้: {{message}}",
"choose": "เลือกโมเดลที่เหมาะกับงานของคุณ — ความแม่นยำ ภาษา และขนาดดาวน์โหลดแตกต่างกัน การดาวน์โหลดจะเริ่มเมื่อคุณคลิกเท่านั้น",
"group_offline": "แม่นยำที่สุด — ถอดเสียงหลังจากคุณหยุดพูด",
"group_streaming": "หน่วงน้อยที่สุด — แสดงข้อความสดขณะที่คุณพูด",
"use": "ใช้ {{label}}",
"installed": "ติดตั้งแล้ว"
},
"model_missing": {
"message": "ยังไม่ได้ดาวน์โหลดโมเดลนี้บน {{target}}",
+15 -1
View File
@@ -472,6 +472,9 @@
"deleteProjectConfirm": "Bu proje kalıcı olarak silinsin mi?"
},
"common": {
"stop": "Durdur",
"resume": "Sürdür",
"paused": "Duraklatıldı",
"open": "Açık",
"cancel": "İptal",
"save": "Kaydet",
@@ -778,6 +781,9 @@
"retry": "Yeniden dene",
"activeEngine": "Etkin {{family}}: {{engine}}",
"engineCompatLabel": "{{family}} motor uyumluluğu",
"dictation_model": "Dikte modeli",
"dictation_model_hint": "Canlı dikte ve bu motorun transkripsiyonu tarafından kullanılır.",
"dictation_not_installed": "Yüklü değil — indirmek için seçin",
"active": "aktif",
"whyUnavailable": "Neye ihtiyacı var",
"diskDetails": "Disk ayrıntıları",
@@ -2523,6 +2529,9 @@
"dismiss": "Kapat",
"output": "Çıktı",
"title": "Sesli kitap",
"tab_script": "Metin",
"tab_voices": "Sesler",
"tab_book": "Kitap",
"subtitle": "Hikâyenizi yazın veya içe aktarın, her rolü seslendirin ve gerçek bölümleri olan tamamlanmış bir sesli kitap dışa aktarın.",
"script": "Senaryo",
"script_placeholder": "# Birinci Bölüm\nBir varmış bir yokmuş… [500ms durakla] son.\n\n# İkinci Bölüm\n[ses:anlatıcı] Ve böyle devam etti.",
@@ -2722,7 +2731,12 @@
"message": "Yüklü bir konuşmadan metne modeli yok — bu özellik sesi yazıya dökmek için bir model gerektirir.",
"download": "{{label}} indir ({{size}} GB)",
"started": "{{label}} indiriliyor — ilerlemeyi Ayarlar → Modeller'den izleyin, sonra yeniden deneyin.",
"install_failed": "İndirme başlatılamadı: {{message}}"
"install_failed": "İndirme başlatılamadı: {{message}}",
"choose": "İşinize uyan modeli seçin — doğruluk, diller ve indirme boyutu farklılık gösterir. İndirme yalnızca tıkladığınızda başlar.",
"group_offline": "En yüksek doğruluk — konuşmayı bitirdikten sonra yazıya döker",
"group_streaming": "En düşük gecikme — siz konuşurken canlı metin",
"use": "{{label}} kullan",
"installed": "Yüklü"
},
"model_missing": {
"message": "Bu model {{target}} üzerine indirilmemiş.",
+15 -1
View File
@@ -472,6 +472,9 @@
"deleteProjectConfirm": "Видалити цей проєкт назавжди?"
},
"common": {
"stop": "Стоп",
"resume": "Відновити",
"paused": "Призупинено",
"open": "відкритий",
"cancel": "Скасувати",
"save": "зберегти",
@@ -778,6 +781,9 @@
"retry": "Повторіть спробу",
"activeEngine": "Активний {{family}}: {{engine}}",
"engineCompatLabel": "{{family}} сумісність з двигуном",
"dictation_model": "Модель диктування",
"dictation_model_hint": "Використовується для живого диктування та цим рушієм під час транскрибування.",
"dictation_not_installed": "Не встановлено — виберіть, щоб завантажити",
"active": "активний",
"whyUnavailable": "Що потрібно",
"diskDetails": "Відомості про диск",
@@ -2523,6 +2529,9 @@
"dismiss": "Закрити",
"output": "Вивід",
"title": "Аудіокнига",
"tab_script": "Сценарій",
"tab_voices": "Голоси",
"tab_book": "Книга",
"subtitle": "Напишіть або імпортуйте історію, призначте голоси та експортуйте готову аудіокнигу зі справжніми розділами.",
"script": "Сценарій",
"script_placeholder": "# Розділ перший\nОдного разу... [пауза 500 мс] кінець.\n\n# Розділ другий\n[голос: оповідач] І так тривало.",
@@ -2722,7 +2731,12 @@
"message": "Модель розпізнавання мовлення не встановлено — ця функція потребує її для транскрипції аудіо.",
"download": "Завантажити {{label}} ({{size}} ГБ)",
"started": "Завантаження {{label}} — стежте за прогресом у Налаштування → Моделі, потім повторіть спробу.",
"install_failed": "Не вдалося розпочати завантаження: {{message}}"
"install_failed": "Не вдалося розпочати завантаження: {{message}}",
"choose": "Оберіть модель під своє завдання — точність, мови та розмір завантаження відрізняються. Завантаження почнеться лише після натискання.",
"group_offline": "Найвища точність — розшифровує після завершення мовлення",
"group_streaming": "Найменша затримка — текст з’являється, поки ви говорите",
"use": "Використати {{label}}",
"installed": "Встановлено"
},
"model_missing": {
"message": "Цю модель не завантажено на {{target}}.",
+15 -1
View File
@@ -472,6 +472,9 @@
"deleteProjectConfirm": "Xóa vĩnh viễn dự án này?"
},
"common": {
"stop": "Dừng lại",
"resume": "Tiếp tục",
"paused": "Đã tạm dừng",
"open": "Mở",
"cancel": "Hủy bỏ",
"save": "Lưu",
@@ -778,6 +781,9 @@
"retry": "Thử lại",
"activeEngine": "Đang hoạt động {{family}}: {{engine}}",
"engineCompatLabel": "{{family}} khả năng tương thích động cơ",
"dictation_model": "Mô hình đọc chính tả",
"dictation_model_hint": "Dùng cho đọc chính tả trực tiếp và cho engine này khi phiên âm.",
"dictation_not_installed": "Chưa cài đặt — chọn để tải xuống",
"active": "hoạt động",
"whyUnavailable": "Cần những gì",
"diskDetails": "Chi tiết ổ đĩa",
@@ -2523,6 +2529,9 @@
"dismiss": "Bỏ qua",
"output": "Đầu ra",
"title": "Sách nói",
"tab_script": "Kịch bản",
"tab_voices": "Giọng đọc",
"tab_book": "Sách",
"subtitle": "Viết hoặc nhập câu chuyện, chọn giọng cho từng vai, rồi xuất sách nói hoàn chỉnh với các chương thực sự.",
"script": "kịch bản",
"script_placeholder": "# Chương Một\nNgày xửa ngày xưa… [tạm dừng 500ms] đoạn cuối.\n\n# Chương hai\n[giọng:người kể chuyện] Và cứ thế mọi chuyện tiếp tục.",
@@ -2722,7 +2731,12 @@
"message": "Chưa cài đặt mô hình chuyển giọng nói thành văn bản — tính năng này cần một mô hình để phiên âm.",
"download": "Tải xuống {{label}} ({{size}} GB)",
"started": "Đang tải {{label}} — theo dõi tiến trình trong Cài đặt → Mô hình, sau đó thử lại.",
"install_failed": "Không thể bắt đầu tải xuống: {{message}}"
"install_failed": "Không thể bắt đầu tải xuống: {{message}}",
"choose": "Chọn mô hình phù hợp với công việc của bạn — độ chính xác, ngôn ngữ và dung lượng tải khác nhau. Chỉ tải xuống khi bạn nhấp.",
"group_offline": "Chính xác nhất — chuyển thành văn bản sau khi bạn ngừng nói",
"group_streaming": "Độ trễ thấp nhất — văn bản trực tiếp khi bạn đang nói",
"use": "Dùng {{label}}",
"installed": "Đã cài đặt"
},
"model_missing": {
"message": "Mô hình này chưa được tải xuống trên {{target}}.",
+15 -1
View File
@@ -87,6 +87,9 @@
"deleteProjectConfirm": "永久删除此项目?"
},
"common": {
"stop": "停止",
"resume": "恢复",
"paused": "已暂停",
"open": "打开",
"cancel": "取消",
"save": "保存",
@@ -735,6 +738,9 @@
"retry": "重试",
"activeEngine": "当前 {{family}}{{engine}}",
"engineCompatLabel": "{{family}} 引擎兼容性",
"dictation_model": "听写模型",
"dictation_model_hint": "用于实时听写,以及此引擎进行转写时。",
"dictation_not_installed": "未安装 — 选择以下载",
"active": "当前",
"whyUnavailable": "所需条件",
"diskDetails": "磁盘详情",
@@ -2530,6 +2536,9 @@
"dismiss": "关闭",
"output": "输出",
"title": "有声读物",
"tab_script": "文稿",
"tab_voices": "配音",
"tab_book": "书籍",
"subtitle": "编写或导入故事,为每个角色分配声音,再导出带有真实章节的精致有声书。",
"script": "脚本",
"script_placeholder": "#第一章\n从前……[停顿 500 毫秒]结束。\n\n# 第二章\n[声音:旁白] 就这样继续下去。",
@@ -2729,7 +2738,12 @@
"message": "尚未安装语音转文字模型 — 此功能需要一个模型来转写音频。",
"download": "下载 {{label}}{{size}} GB",
"started": "正在下载 {{label}} — 请在 设置 → 模型 中查看进度,完成后重试。",
"install_failed": "无法开始下载:{{message}}"
"install_failed": "无法开始下载:{{message}}",
"choose": "选择适合你工作的模型 — 各模型的准确度、语言和下载大小不同。只有点击后才会开始下载。",
"group_offline": "最高准确度 — 停止说话后再转写",
"group_streaming": "最低延迟 — 边说边实时出字",
"use": "使用 {{label}}",
"installed": "已安装"
},
"model_missing": {
"message": "此模型尚未下载到 {{target}}。",
+15 -1
View File
@@ -472,6 +472,9 @@
"deleteProjectConfirm": "要永久刪除此專案嗎?"
},
"common": {
"stop": "停止",
"resume": "繼續",
"paused": "已暫停",
"open": "打開",
"cancel": "取消",
"save": "儲存",
@@ -778,6 +781,9 @@
"retry": "重試",
"activeEngine": "活動 {{family}}{{engine}}",
"engineCompatLabel": "{{family}} 引擎相容性",
"dictation_model": "聽寫模型",
"dictation_model_hint": "用於即時聽寫,以及此引擎進行轉錄時。",
"dictation_not_installed": "未安裝 — 選取以下載",
"active": "活躍的",
"whyUnavailable": "所需條件",
"diskDetails": "磁碟詳細資料",
@@ -2523,6 +2529,9 @@
"dismiss": "關閉",
"output": "輸出",
"title": "有聲書",
"tab_script": "文稿",
"tab_voices": "配音",
"tab_book": "書籍",
"subtitle": "撰寫或匯入故事,為每個角色分配聲音,再匯出具備真實章節的精緻有聲書。",
"script": "腳本",
"script_placeholder": "#第一章\n從前…[停頓 500 毫秒]結束。\n\n# 第二章\n[聲音:旁白] 就這樣繼續下去。",
@@ -2722,7 +2731,12 @@
"message": "尚未安裝語音轉文字模型 — 此功能需要一個模型來轉錄音訊。",
"download": "下載 {{label}}{{size}} GB",
"started": "正在下載 {{label}} — 請在 設定 → 模型 中查看進度,完成後再試一次。",
"install_failed": "無法開始下載:{{message}}"
"install_failed": "無法開始下載:{{message}}",
"choose": "選擇適合你工作的模型 — 各模型的準確度、語言與下載大小不同。只有點擊後才會開始下載。",
"group_offline": "最高準確度 — 停止說話後再轉寫",
"group_streaming": "最低延遲 — 邊說邊即時出字",
"use": "使用 {{label}}",
"installed": "已安裝"
},
"model_missing": {
"message": "此模型尚未下載到 {{target}}。",
+372 -4
View File
@@ -5208,7 +5208,7 @@ button.dub-stepper__action:focus-visible {
padding: 24px 16px;
}
/* Inline project rename (WorkspaceProjects) — fills the card row like the title. */
/* Inline project rename (Sidebar projects rail) — fills the card row like the title. */
.wv__rename-input {
width: 100%;
font-size: 0.78rem;
@@ -5811,7 +5811,7 @@ button.dub-stepper__action:focus-visible {
box-shadow: inset 0 0 0 1px var(--chrome-border-strong), 0 14px 34px rgba(0, 0, 0, 0.12);
}
.audiobook-tab__script textarea {
.audiobook-tab__manuscript textarea {
flex: 1 1 auto;
min-height: 0;
width: 100%;
@@ -5826,14 +5826,14 @@ button.dub-stepper__action:focus-visible {
box-shadow: none;
}
.audiobook-tab__script textarea:focus {
.audiobook-tab__manuscript textarea:focus {
border: 0;
background: transparent;
box-shadow: none;
}
@media (max-width: 900px) {
.audiobook-tab__script textarea { min-height: 240px; }
.audiobook-tab__manuscript textarea { min-height: 240px; }
}
/* Synced-lyrics player (audiobook result): chapter text under the native
@@ -6974,6 +6974,215 @@ button.dub-stepper__action:focus-visible {
}
}
/* Stories shares the Clone workspace tab navigation, with one task per panel. */
.stories-tabbed .stories-tabbar {
flex-shrink: 0;
padding: 12px 16px;
border-bottom: 1px solid var(--color-border);
}
.stories-tabbed .stories-tab span {
min-width: 0;
overflow-wrap: anywhere;
}
.stories-tabbed .stories-tab-panel {
min-height: 0;
min-width: 0;
flex: 1;
overflow-y: auto;
margin-top: 0;
}
.stories-tabbed .stories-tab-panel[data-state="inactive"] {
display: none;
}
.stories-tabbed .stories-workspace {
display: flex;
flex-direction: column;
}
.stories-tabbed .stories-manuscript {
height: 100%;
}
.stories-tabbed .stories-rail-section {
width: min(100% - 32px, 1040px);
margin: 20px auto;
padding: 20px;
gap: 18px;
border: 1px solid var(--color-border);
border-radius: 14px;
background: var(--color-bg-elev-1);
box-shadow: none;
}
.stories-tabbed .stories-export-panel .stories-rail-section {
max-width: 640px;
}
.stories-tabbed .stories-rail-heading {
flex-wrap: wrap;
gap: 12px;
}
.stories-tabbed .stories-section-toggle {
cursor: default;
}
.stories-tabbed .stories-section-toggle,
.stories-tabbed .stories-manuscript-heading {
font-family: var(--font-sans);
font-size: 13px;
letter-spacing: normal;
text-transform: none;
}
.stories-tabbed .stories-section-count {
font-size: 12px;
min-width: 24px;
height: 24px;
}
.stories-tabbed .stories-cast-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 300px), 1fr));
gap: 12px;
}
.stories-tabbed .stories-cast-member {
grid-template-columns: 8px minmax(0, 1fr) 32px;
gap: 12px;
padding: 16px;
border: 1px solid var(--color-border);
border-radius: 10px;
}
.stories-tabbed .stories-cast-member > span.min-w-0 {
grid-column: 2;
grid-row: 2;
}
.stories-tabbed .stories-cast-member > button {
border: 0;
grid-column: 3;
grid-row: 1;
}
.stories-tabbed .stories-cast-member input {
min-height: 36px;
font-size: 13px;
}
.stories-tabbed .stories-project-list {
max-height: none;
gap: 8px;
}
.stories-tabbed .stories-project-row {
padding: 8px;
border: 1px solid var(--color-border);
}
.stories-tabbed .stories-project-editor {
gap: 12px;
}
.stories-tabbed .stories-project-editor input {
min-height: 38px;
}
.stories-tabbed .stories-manuscript-toolbar {
padding: 12px 16px;
flex-wrap: wrap;
}
.stories-tabbed .stories-manuscript-toolbar > div:last-child {
flex-wrap: wrap;
}
.stories-tabbed .stories-track-list {
gap: 12px;
padding: 16px;
}
.stories-tabbed .stories-line {
grid-template-columns: 28px minmax(100px, 1fr) minmax(120px, 1fr) auto;
grid-template-areas:
'drag text text text'
'. character voice actions'
'. drawer drawer drawer';
gap: 10px;
padding: 14px;
cursor: default;
box-shadow: none;
border: 1px solid var(--color-border);
contain-intrinsic-size: 112px;
}
.stories-tabbed .stories-line__text {
min-height: 56px;
font-size: 15px;
line-height: 1.65;
}
.stories-tabbed .stories-line-number {
font-size: 11px;
}
.stories-tabbed .stories-line__actions {
opacity: 1;
}
.stories-tabbed .stories-line__actions button {
border: 0;
color: var(--color-fg-muted);
min-width: 32px;
min-height: 32px;
}
.stories-tabbed .stories-line__actions button:hover:enabled {
color: var(--color-fg);
background: var(--color-bg-elev-2);
}
.stories-tabbed .stories-line__actions button:last-child:hover:enabled {
color: var(--color-danger);
}
.stories-tabbed .stories-kicker {
font-size: 10px;
line-height: 1.4;
}
.stories-tabbed .stories-header {
padding: 14px 16px;
}
@media (max-width: 600px) {
.stories-tabbed .stories-tab {
flex-direction: column;
gap: 4px;
padding: 8px 4px;
}
.stories-tabbed .stories-header {
flex-wrap: wrap;
}
.stories-tabbed .stories-tabbar {
padding: 8px;
}
.stories-tabbed .stories-line {
grid-template-columns: 22px minmax(0, 1fr) minmax(0, 1fr);
grid-template-areas: 'drag text text' '. character voice' '. actions actions' '. drawer drawer';
padding: 10px;
}
.stories-tabbed .stories-rail-section {
width: calc(100% - 16px);
padding: 14px;
margin: 8px;
}
}
.stories-tabbed .stories-rail-label { font-size: 13px; }
.stories-tabbed .stories-rail-label > span { flex-shrink: 0; }
.stories-tabbed .stories-export-panel select { min-height: 40px; max-width: 220px; }
.shell-mini .stories-tabbed .stories-line {
grid-template-columns: 22px minmax(0, 1fr) minmax(0, 1fr);
grid-template-areas:
'drag text text'
'. character voice'
'. actions actions'
'. drawer drawer';
padding: 10px;
}
/* Compact two-row Stories command header. */
.stories-tabbed .stories-header { padding: 8px 16px; }
.stories-tabbed .stories-tabbar {
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 6px 16px;
flex-wrap: wrap;
}
.stories-tabbed .stories-tab { min-height: 36px; padding: 6px 14px; }
.stories-tabbed .stories-script-tools { flex-wrap: wrap; }
@media (max-width: 600px) {
.stories-tabbed .stories-tabbar { padding: 6px 8px; gap: 6px; }
.stories-tabbed .stories-tabbar [role="tablist"] { width: 100%; }
.stories-tabbed .stories-tab { flex-direction: row; padding: 6px 4px; }
.stories-tabbed .stories-tab svg { display: none; }
}
/* ── Sticky header row ─────────────────────────────────────────── */
.ui-table-header {
display: flex;
@@ -7262,3 +7471,162 @@ button.dub-stepper__action:focus-visible {
justify-content: flex-start;
}
}
/* Dubbing start workspace: a focused import card with nearby language choices. */
.dub-start-screen .dub-panel-col {
overflow-y: auto;
padding: clamp(16px, 4vw, 48px);
background:
radial-gradient(ellipse at 50% 24%, color-mix(in srgb, var(--color-brand) 7%, transparent), transparent 62%),
var(--chrome-bg);
}
.dub-start-card {
width: min(100%, 740px);
min-width: 0;
flex-shrink: 0;
margin: auto;
padding: clamp(18px, 3vw, 32px);
border: 1px solid var(--color-border);
border-radius: 22px;
background: var(--color-bg-elev-1);
box-shadow: 0 20px 60px color-mix(in srgb, var(--chrome-bg) 35%, transparent);
}
.dub-start-card .dub-idle-drop {
flex: none;
margin: 0;
min-height: 220px;
padding: 26px 16px;
gap: 16px;
border: 1px dashed color-mix(in srgb, var(--color-brand) 45%, var(--color-border));
border-radius: 16px;
background: color-mix(in srgb, var(--color-brand) 4%, var(--chrome-bg));
}
.dub-start-card .dub-idle-drop:hover,
.dub-start-card .dub-idle-drop.is-dragging {
border-color: var(--color-brand);
background: color-mix(in srgb, var(--color-brand) 9%, var(--chrome-bg));
}
.dub-start-card .dub-idle-drop__puck {
width: 64px;
height: 64px;
border-radius: 20px;
color: var(--color-brand);
background: color-mix(in srgb, var(--color-brand) 12%, var(--color-bg-elev-1));
border-color: color-mix(in srgb, var(--color-brand) 24%, transparent);
box-shadow: 0 0 0 8px color-mix(in srgb, var(--color-brand) 3%, transparent);
}
.dub-start-card .dub-idle-drop__puck svg { color: inherit; }
.dub-start-card .dub-landing-opts__lang {
flex: 1;
flex-direction: column;
align-items: stretch;
gap: 8px;
}
.dub-start-card .dub-landing-opts__lang > svg { display: none; }
.dub-start-card .dub-landing-opts__lang select {
width: 100%;
max-width: none;
min-height: 42px;
font-size: 13px;
}
.dub-start-card input[type="text"],
.dub-start-card input[type="url"] { min-width: 0; font-size: 13px; }
.dub-start-card details { margin-top: 16px; }
.dub-start-card summary { cursor: pointer; padding: 10px 0; font-size: 13px; color: var(--color-fg-muted); }
.dub-start-card .dub-ingest-row__cta { min-height: 36px; font-size: 13px; }
@media (max-width: 600px) {
.dub-start-screen .dub-panel-col { padding: 12px; }
.dub-start-card { padding: 16px; border-radius: 16px; }
.dub-start-card .dub-idle-drop { min-height: 190px; padding: 20px 12px; }
}
.dub-start-title { margin: 0; color: var(--color-fg); font-size: 22px; font-weight: 600; line-height: 1.3; text-wrap: balance; }
.dub-start-formats { color: var(--color-fg-muted); font-size: 12px; line-height: 1.6; }
.dub-start-languages { display: flex; gap: 16px; align-items: stretch; margin-top: 24px; }
.dub-start-url { display: flex; align-items: center; gap: 10px; margin-top: 20px; padding: 8px 12px; border: 1px solid var(--color-border); border-radius: 10px; background: var(--chrome-bg); }
.dub-start-url input { flex: 1; min-height: 36px; }
.dub-start-url:focus-within { box-shadow: var(--focus-ring); }
@media (max-width: 600px) {
.dub-start-title { font-size: 19px; }
.dub-start-languages { flex-direction: column; gap: 12px; }
}
.dub-start-choose { display: inline-flex; align-items: center; justify-content: center; gap: 8px; min-height: 42px; padding: 10px 22px; border-radius: 10px; background: var(--color-brand); color: var(--color-bg); font-size: 14px; font-weight: 600; }
.dub-start-advanced, .dub-start-batch { display: flex; align-items: center; gap: 8px; border: 0; background: transparent; color: var(--color-fg-muted); cursor: pointer; font-size: 13px; min-height: 40px; }
.dub-start-advanced { width: 100%; justify-content: space-between; margin-top: 12px; padding: 8px 0; }
.dub-start-batch { justify-content: center; width: 100%; margin-top: 16px; padding: 16px 8px 0; border-top: 1px solid var(--color-border); text-wrap: pretty; }
.dub-start-advanced:hover, .dub-start-batch:hover { color: var(--color-fg); }
.dub-start-advanced:focus-visible, .dub-start-batch:focus-visible { outline: none; box-shadow: var(--focus-ring); border-radius: 6px; }
.dub-start-options { display: flex; flex-direction: column; gap: 18px; padding: 16px; border-radius: 10px; background: var(--chrome-bg); font-size: 13px; color: var(--color-fg-muted); }
.dub-start-caption-option, .dub-start-cookie-option { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; }
.dub-start-caption-option input { width: 16px; height: 16px; }
.dub-start-cookie-option input { min-width: 0; max-width: 100%; font-size: 12px; }
.dub-start-generation-options { display: flex; flex-wrap: wrap; gap: 16px; }
.dub-start-generation-options label { display: flex; flex-direction: column; gap: 8px; }
.dub-start-generation-options label > span { display: flex; align-items: center; gap: 6px; }
.dub-start-generation-options input { min-height: 36px; margin-left: 0; }
.dub-start-generation-options input[type="number"] { width: 88px; }
.dub-start-languages label > span { font-size: 13px; }
.dub-start-options[hidden] { display: none; }
/* Layout utilities must not reveal inactive Radix tab panels. */
[data-slot="tabs-content"][hidden] {
display: none !important;
}
/* Give the stacked library its own scrollable area below the import form. */
.shell-narrow .studio-right:has(.dub-project-library),
.shell-mini .studio-right:has(.dub-project-library) {
flex: 0 0 min(420px, 60vh);
min-height: 240px;
}
.dub-start-language-select { width: 100%; min-height: 42px; font-size: 13px; }
/* Keep project cards stationary as their actions become visible. */
.dub-project-library .history-actions {
min-height: 30px;
max-height: none;
transition: opacity 0.2s;
}
@media (hover: none) {
.dub-project-library .history-actions { opacity: 1; }
}
.capture-pill__control {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 28px;
height: 28px;
padding: 0;
border: 0;
border-radius: 50%;
background: rgb(255 255 255 / 10%);
color: rgb(255 255 255 / 90%);
cursor: pointer;
}
.capture-pill__control:hover { background: rgb(255 255 255 / 20%); }
.capture-pill__control:focus-visible { outline: 2px solid white; outline-offset: 2px; }
/* Keep live text independent of the recording controls. */
.capture-pill {
width: 420px;
max-width: calc(100vw - 16px);
height: auto;
min-height: 48px;
flex-wrap: wrap;
border-radius: 20px;
}
.capture-pill__preview {
order: 2;
flex: 0 0 100%;
min-width: 0;
max-height: 84px;
overflow-y: auto;
white-space: pre-wrap;
overflow-wrap: anywhere;
color: white;
}
+158 -73
View File
@@ -1,5 +1,6 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { FileText, Users, BookText } from 'lucide-react';
import {
audiobookPlan,
audiobookGenerate,
@@ -12,28 +13,40 @@ import { audioUrl } from '../api/generate';
import { useEngines } from '../api/hooks';
import { consumeLongformStream } from '../utils/longformStream';
import { useAppStore } from '../store';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '../components/ui/tabs';
import { overridesToRequest } from '../components/audiobook/AudiobookOverrides';
import GenerationProgress from '../components/audiobook/GenerationProgress';
import PlanList from '../components/audiobook/PlanList';
import AudiobookResult from '../components/audiobook/AudiobookResult';
import MarkupToolbar from '../components/audiobook/MarkupToolbar';
import StatsBar from '../components/audiobook/StatsBar';
import ValidationWarnings from '../components/audiobook/ValidationWarnings';
import AudiobookHero from '../components/audiobook/AudiobookHero';
import AudiobookInspector from '../components/audiobook/AudiobookInspector';
import AudiobookScriptPanel from '../components/audiobook/AudiobookScriptPanel';
import AudiobookVoicesPanel from '../components/audiobook/AudiobookVoicesPanel';
import AudiobookBookPanel from '../components/audiobook/AudiobookBookPanel';
import AudiobookRecovery from '../components/audiobook/AudiobookRecovery';
import { useAudiobookLexicon } from '../hooks/useAudiobookLexicon';
import { parseCastNames, validateScript } from '../utils/audiobookScript';
import { SAMPLE_AUDIOBOOK_SCRIPT } from '../data/sampleAudiobook';
// Chrome-mono uppercase form label (was the scoped `.audiobook-tab .field-label`
// rule; `.field-label` has no global styling, so it's reproduced as utilities).
// Stable empty-cast fallback: a literal `?? {}` mints a new object every render,
// which defeats the useMemos keyed on voiceCast (they'd recompute every render).
const EMPTY_CAST = Object.freeze({});
const FIELD_LABEL =
'[font-family:var(--chrome-font-mono)] [font-size:var(--chrome-label-size)] font-semibold [letter-spacing:var(--chrome-label-track)] uppercase [color:var(--chrome-fg-muted)]';
// Write Cast Produce: the audiobook pipeline as workspace tabs, mirroring
// the clone workspace (Script / Voice + pinned actions). Persisted per visit;
// validated on read so a stale value can never strand the workspace.
const BOOK_TABS = ['script', 'voices', 'book'];
const BOOK_TAB_KEY = 'omnivoice.audiobook.tab';
function readStoredBookTab() {
try {
const stored = localStorage.getItem(BOOK_TAB_KEY);
return BOOK_TABS.includes(stored) ? stored : 'script';
} catch {
return 'script';
}
}
/**
* AudiobookTab turn a chapter-delimited script into a chapterized m4b.
@@ -44,6 +57,18 @@ const FIELD_LABEL =
*/
export default function AudiobookTab({ profiles = [] }) {
const { t } = useTranslation();
// Workspace tab (Script / Voices / Book) local + persisted, like the
// catalogue pane. Manual activation so arrowing across the strip never
// yanks the panel out from under keyboard users.
const [bookTab, setBookTabRaw] = useState(readStoredBookTab);
const setBookTab = useCallback((next) => {
setBookTabRaw(next);
try {
localStorage.setItem(BOOK_TAB_KEY, next);
} catch {
/* private mode / quota — the tab still switches, it just won't persist */
}
}, []);
// Persisted via the unified LongformProject store (#31b) book identity,
// script, voice, and output prefs now survive a tab switch / reload (they
// used to live in component useState and evaporate).
@@ -150,17 +175,26 @@ export default function AudiobookTab({ profiles = [] }) {
return validateScript(text, { mappedNames, profileIds: profiles.map((p) => p.id) });
}, [text, voiceCast, profiles]);
const onCoverPick = useCallback((e) => {
const f = e.target.files?.[0];
if (!f) return;
setCoverFile(f);
setCoverPreview(URL.createObjectURL(f));
}, []);
// Tab badges (the inspector's counts, moved onto the strip): cast size on
// Voices, filled details + lexicon rows on Book.
const detailCount =
Object.values(meta).filter((value) => value?.trim()).length + (coverPreview ? 1 : 0);
const lexiconCount = lex.filter((row) => row.word.trim() || row.say.trim()).length;
const onCoverPick = useCallback(
(e) => {
const f = e.target.files?.[0];
if (!f) return;
setCoverFile(f);
setCoverPreview(URL.createObjectURL(f));
},
[setCoverFile, setCoverPreview],
);
const clearCover = useCallback(() => {
setCoverFile(null);
if (coverPreview) URL.revokeObjectURL(coverPreview);
setCoverPreview('');
}, [coverPreview]);
}, [coverPreview, setCoverFile, setCoverPreview]);
// Revoke the cover blob URL when it's replaced or the tab unmounts (React
// doesn't reclaim object URLs on its own).
useEffect(
@@ -430,6 +464,26 @@ export default function AudiobookTab({ profiles = [] }) {
[canRun, onCreate],
);
// The pinned status rail (warnings / progress / result / plan) only takes
// space once there is something to show a fresh book is just the tabs.
const showWarnings = !warningsDismissed && !generating && warnings.length > 0;
const hasStatus =
showWarnings || error || generating || (stopped && !generating) || output || plan;
const tabDefs = useMemo(
() => [
{ id: 'script', label: t('audiobook.tab_script'), Icon: FileText, badge: 0 },
{ id: 'voices', label: t('audiobook.tab_voices'), Icon: Users, badge: castNames.length },
{
id: 'book',
label: t('audiobook.tab_book'),
Icon: BookText,
badge: detailCount + lexiconCount,
},
],
[t, castNames.length, detailCount, lexiconCount],
);
return (
<div className="audiobook-tab flex h-full flex-col box-border px-[1.25rem] py-[1rem] gap-[10px] max-[1120px]:overflow-y-auto">
<AudiobookHero
@@ -448,68 +502,99 @@ export default function AudiobookTab({ profiles = [] }) {
<AudiobookRecovery t={t} generating={generating} onResume={onResume} />
<div className="audiobook-tab__body grid flex-auto grid-cols-[minmax(0,1fr)_minmax(440px,500px)] max-[1120px]:grid-cols-1 gap-[14px] min-h-0">
{/* Left: script editor fills the height */}
<div className="audiobook-tab__script flex flex-col min-h-0 gap-[7px]">
<div className="flex min-h-[18px] items-center justify-between gap-[12px] px-[4px]">
<label className={FIELD_LABEL}>{t('audiobook.script')}</label>
{text.trim() ? <StatsBar t={t} text={text} /> : null}
</div>
<div className="audiobook-tab__manuscript flex min-h-0 flex-1 flex-col overflow-hidden rounded-[14px]">
<div className="border-b border-transparent px-[10px] py-[7px]">
<MarkupToolbar t={t} textareaRef={textareaRef} text={text} setText={setText} />
</div>
<textarea
ref={textareaRef}
className="input-base"
value={text}
onChange={(e) => {
setText(e.target.value);
if (warningsDismissed) setWarningsDismissed(false);
}}
onKeyDown={onScriptKeyDown}
placeholder={t('audiobook.script_placeholder')}
aria-label={t('audiobook.script')}
/>
{!text.trim() && (
<p className="m-0 border-t border-transparent px-[14px] py-[9px] text-[var(--text-sm)] text-fg-muted">
{t('audiobook.empty_hint')}
</p>
)}
</div>
<Tabs
value={bookTab}
onValueChange={setBookTab}
activationMode="manual"
className="flex min-h-0 flex-1 flex-col gap-0"
>
<div className="relative z-10 flex shrink-0 flex-wrap items-center gap-x-4 gap-y-2 px-1 pb-3 pt-1 max-[600px]:px-0">
<TabsList
aria-label={t('audiobook.title')}
className="grid h-auto w-auto min-w-0 flex-[1_1_320px] grid-cols-3 gap-[3px] rounded-[var(--chrome-radius-pill)] border border-transparent bg-[var(--chrome-bg)] p-[3px]"
>
{tabDefs.map(({ id, label, Icon, badge }) => (
<TabsTrigger
key={id}
value={id}
data-book-tab={id}
disabled={busy}
className="min-h-11 h-auto min-w-0 cursor-pointer whitespace-normal rounded-[var(--chrome-radius-pill)] border border-transparent bg-transparent px-3 py-2 text-sm font-medium text-[color:var(--chrome-fg-muted)] transition-colors data-[state=active]:border-[var(--chrome-accent-border)] data-[state=active]:bg-[var(--chrome-accent-bg)] data-[state=active]:font-semibold data-[state=active]:text-[color:var(--chrome-accent)] data-[state=active]:shadow-none dark:data-[state=active]:border-[var(--chrome-accent-border)] dark:data-[state=active]:bg-[var(--chrome-accent-bg)] dark:data-[state=active]:text-[color:var(--chrome-accent)] hover:data-[state=inactive]:bg-[var(--chrome-hover-bg)]"
>
<Icon size={16} aria-hidden="true" />
{label}
{badge > 0 && (
<span
className="rounded-full bg-[var(--chrome-hover-bg)] px-[8px] text-[0.7rem] [font-variant-numeric:tabular-nums]"
aria-hidden="true"
>
{badge}
</span>
)}
</TabsTrigger>
))}
</TabsList>
</div>
{/* Right: settings + results, scrolls independently */}
<div className="audiobook-tab__side flex flex-col gap-[9px] min-h-0 overflow-y-auto max-[1120px]:overflow-visible rounded-[12px] bg-[var(--color-bg-elev-2)] p-[10px]">
<AudiobookInspector
t={t}
profiles={profiles}
defaultVoice={defaultVoice}
setDefaultVoice={setDefaultVoice}
language={language}
setLanguage={setLanguage}
format={format}
setFormat={setFormat}
loudness={loudness}
setLoudness={setLoudness}
castNames={castNames}
voiceCast={voiceCast}
setVoiceCast={setVoiceCast}
overrides={overrides}
setOverrides={setLongformOverrides}
emotionSupported={emotionSupported}
coverPreview={coverPreview}
onCoverPick={onCoverPick}
clearCover={clearCover}
meta={meta}
setMetaField={setMetaField}
lex={lex}
setLexRow={setLexRow}
addLexRow={addLexRow}
removeLexRow={removeLexRow}
/>
<TabsContent value={bookTab} className="flex min-h-0 flex-1 flex-col">
<div className="flex min-h-0 flex-1 flex-col">
{bookTab === 'script' && (
<AudiobookScriptPanel
t={t}
text={text}
setText={setText}
textareaRef={textareaRef}
onScriptKeyDown={onScriptKeyDown}
warningsDismissed={warningsDismissed}
setWarningsDismissed={setWarningsDismissed}
/>
)}
{bookTab === 'voices' && (
<AudiobookVoicesPanel
t={t}
profiles={profiles}
defaultVoice={defaultVoice}
setDefaultVoice={setDefaultVoice}
language={language}
setLanguage={setLanguage}
castNames={castNames}
voiceCast={voiceCast}
setVoiceCast={setVoiceCast}
overrides={overrides}
setOverrides={setLongformOverrides}
emotionSupported={emotionSupported}
/>
)}
{bookTab === 'book' && (
<AudiobookBookPanel
t={t}
format={format}
setFormat={setFormat}
loudness={loudness}
setLoudness={setLoudness}
coverPreview={coverPreview}
onCoverPick={onCoverPick}
clearCover={clearCover}
meta={meta}
setMetaField={setMetaField}
lex={lex}
setLexRow={setLexRow}
addLexRow={addLexRow}
removeLexRow={removeLexRow}
detailCount={detailCount}
lexiconCount={lexiconCount}
/>
)}
</div>
</TabsContent>
</Tabs>
{!warningsDismissed && !generating && (
{hasStatus && (
<div
className="flex shrink-0 flex-col gap-[9px] overflow-y-auto rounded-[12px] bg-[var(--color-bg-elev-2)] p-[10px]"
data-testid="audiobook-status-rail"
>
{showWarnings && (
<ValidationWarnings
t={t}
warnings={warnings}
@@ -551,7 +636,7 @@ export default function AudiobookTab({ profiles = [] }) {
/>
)}
</div>
</div>
)}
</div>
);
}
+88 -3
View File
@@ -11,6 +11,9 @@ import React, { useState, useCallback, useMemo, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Mic, Copy, Trash2, Search, Clock, Languages, FileText, Download } from 'lucide-react';
import { Button } from '../ui';
import { detectPlatform } from '../utils/micError';
import { useDictationReadiness } from '../hooks/useDictationReadiness';
import AsrModelChooser from '../components/AsrModelChooser';
import { toast } from 'react-hot-toast';
import { copyText as copyToClipboard } from '../utils/copyText';
import { toMillis } from '../utils/relativeTime';
@@ -67,17 +70,28 @@ export default function TranscriptionsPage() {
const [search, setSearch] = useState('');
const [selectedId, setSelectedId] = useState(null);
const { info: shortcut } = useEffectiveDictationShortcut();
const readiness = useDictationReadiness();
const checkReadiness = readiness.check;
// What the progress bar names: the model the user picked, else the recommended one.
const installTarget = readiness.target || readiness.missing?.recommended;
const [starting, setStarting] = useState(false);
const captureDisabled = readiness.phase !== 'ready' || starting;
const emptyDescription = t('transcriptions.empty_desc', { shortcut: shortcut.display });
const normalizedSearch = search.trim();
const startCapture = useCallback(async () => {
if (captureDisabled) return;
setStarting(true);
try {
if (!(await checkReadiness())) return;
await requestDictationCapture('start');
} catch (error) {
console.warn('Could not start dictation:', error);
toast.error(t('transcriptions.capture_failed'));
} finally {
setStarting(false);
}
}, [t]);
}, [t, captureDisabled, checkReadiness]);
// Listen for new transcriptions added from CaptureButton
useEffect(() => {
@@ -181,7 +195,13 @@ export default function TranscriptionsPage() {
</div>
<div className="txn-header__right flex items-center gap-[6px]">
{transcriptions.length > 0 && (
<Button size="sm" variant="primary" leading={<Mic size={13} />} onClick={startCapture}>
<Button
size="sm"
variant="primary"
leading={<Mic size={13} />}
disabled={captureDisabled}
onClick={startCapture}
>
{t('transcriptions.capture')}
</Button>
)}
@@ -221,6 +241,66 @@ export default function TranscriptionsPage() {
</div>
</div>
{readiness.phase !== 'ready' && (
<div
className="rounded-lg border border-border bg-bg-elev-1 p-4 flex flex-col gap-3"
role="status"
aria-live="polite"
>
<p className="text-sm text-fg m-0">
{readiness.phase === 'checking'
? t('setup.checking')
: readiness.phase === 'error'
? t('common.error')
: readiness.phase === 'installing'
? t('dub.install_progress', { engine: installTarget?.label })
: t('asr_missing.message')}
</p>
{readiness.phase === 'installing' ? (
<progress
className="w-full"
max={100}
value={readiness.percent ?? undefined}
aria-label={t('dub.install_progress', { engine: installTarget?.label })}
/>
) : (
readiness.phase !== 'checking' && (
<>
{readiness.phase === 'missing' && (
<AsrModelChooser
fallback={readiness.missing?.recommended}
onInstall={readiness.install}
onSelect={readiness.select}
/>
)}
<div className="flex items-center gap-3">
<Button size="sm" variant="ghost" onClick={readiness.check}>
{t('common.refresh')}
</Button>
{readiness.error && <span role="alert">{t('common.error')}</span>}
</div>
</>
)
)}
</div>
)}
<div className="flex flex-wrap items-center gap-2 text-xs text-fg-muted">
<kbd className="rounded border border-border bg-bg-elev-1 px-2 py-1 font-mono">
{shortcut.display}
</kbd>
<span>
{t('transcriptions.capture')} / {t('common.stop')}
</span>
<span aria-hidden="true" className="mx-2">
·
</span>
<kbd className="rounded border border-border bg-bg-elev-1 px-2 py-1 font-mono">
{detectPlatform() === 'mac' ? '⌘+V' : 'Ctrl+V'}
</kbd>
<span>{t('clone.paste')}</span>
</div>
{/* Content */}
<div className="txn-content grid flex-1 grid-cols-[1fr_1fr] gap-[12px] min-h-0">
{/* List */}
@@ -234,13 +314,18 @@ export default function TranscriptionsPage() {
: t('transcriptions.empty_title')}
</p>
<p className="txn-empty__desc m-0 max-w-[280px] text-[var(--text-xs)] leading-[1.6] text-fg-muted">
{normalizedSearch ? t('transcriptions.empty_search_desc') : emptyDescription}
{normalizedSearch
? t('transcriptions.empty_search_desc')
: readiness.phase === 'ready'
? emptyDescription
: ''}
</p>
{!normalizedSearch && (
<Button
size="sm"
variant="primary"
leading={<Mic size={13} />}
disabled={captureDisabled}
onClick={startCapture}
>
{t('transcriptions.capture')}
+113 -2
View File
@@ -1,10 +1,25 @@
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
const { requestDictationCapture, copyToClipboard, toast } = vi.hoisted(() => ({
const { requestDictationCapture, copyToClipboard, toast, readiness, apiJson } = vi.hoisted(() => ({
readiness: {
phase: 'ready',
check: vi.fn().mockResolvedValue(true),
install: vi.fn(),
select: vi.fn(),
},
requestDictationCapture: vi.fn(),
copyToClipboard: vi.fn(),
toast: { error: vi.fn(), success: vi.fn() },
apiJson: vi.fn(),
}));
vi.mock('../hooks/useDictationReadiness', () => ({
useDictationReadiness: () => readiness,
}));
vi.mock('../api/client', async (importOriginal) => ({
...(await importOriginal()),
apiJson: (...args) => apiJson(...args),
}));
vi.mock('../utils/copyText', () => ({ copyText: copyToClipboard }));
@@ -25,6 +40,8 @@ import TranscriptionsPage, { addTranscription, segTimeRange } from './Transcript
describe('Transcriptions capture entry point', () => {
beforeEach(() => {
readiness.phase = 'ready';
readiness.missing = null;
localStorage.clear();
requestDictationCapture.mockReset().mockResolvedValue(undefined);
toast.error.mockReset();
@@ -32,7 +49,7 @@ describe('Transcriptions capture entry point', () => {
it('shows the effective shortcut and starts the shared recorder from the empty state', async () => {
render(<TranscriptionsPage />);
expect(screen.getByText(/Super\+Shift\+V/)).toBeInTheDocument();
expect(screen.getByText('Super+Shift+V', { selector: 'kbd' })).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Start dictation' }));
await waitFor(() => expect(requestDictationCapture).toHaveBeenCalledWith('start'));
@@ -91,6 +108,8 @@ describe('Transcriptions capture entry point', () => {
// one the user could not read at all.
describe('segments without timings (#1798)', () => {
beforeEach(() => {
readiness.phase = 'ready';
readiness.missing = null;
localStorage.clear();
});
@@ -122,6 +141,8 @@ describe('segments without timings (#1798)', () => {
describe('transcription clipboard', () => {
beforeEach(() => {
readiness.phase = 'ready';
readiness.missing = null;
localStorage.clear();
toast.success.mockReset();
toast.error.mockReset();
@@ -148,3 +169,93 @@ describe('transcription clipboard', () => {
expect(toast.success).not.toHaveBeenCalled();
});
});
const CATALOGUE = {
models: [
{
id: 'sherpa-whisper-tiny',
repo_id: 'test/model',
label: 'Tiny',
tag: 'offline',
recommended: true,
size_gb: 0.1,
languages: '90+ languages (auto-detect)',
installed: false,
},
{
id: 'sherpa-parakeet-tdt-v2',
repo_id: 'test/parakeet',
label: 'Parakeet TDT v2',
tag: 'offline',
size_gb: 0.66,
languages: 'English',
installed: false,
},
{
id: 'sherpa-zipformer-en-20m',
repo_id: 'test/zipformer',
label: 'Zipformer Streaming EN',
tag: 'streaming',
size_gb: 0.044,
languages: 'English',
installed: true,
},
],
};
it('blocks recording and lets the user pick which model to install when one is missing', async () => {
localStorage.clear();
readiness.phase = 'missing';
readiness.missing = { recommended: { repo_id: 'test/model', label: 'Tiny', size_gb: 0.1 } };
readiness.install.mockReset();
readiness.select.mockReset();
apiJson.mockResolvedValue(CATALOGUE);
render(<TranscriptionsPage />);
expect(screen.getByRole('button', { name: 'Start dictation' })).toBeDisabled();
// Every catalogue model is offered, grouped by the accuracy/latency trade-off,
// with languages and size not just the recommended download.
expect(
await screen.findByText('Best accuracy — transcribes after you stop speaking'),
).toBeInTheDocument();
expect(screen.getByText('Lowest latency — live text while you speak')).toBeInTheDocument();
expect(screen.getAllByText('English', { selector: 'span' })).toHaveLength(2);
fireEvent.click(screen.getByRole('button', { name: 'Download Parakeet TDT v2 (0.66 GB)' }));
expect(readiness.install).toHaveBeenCalledWith(
expect.objectContaining({ id: 'sherpa-parakeet-tdt-v2', repo_id: 'test/parakeet' }),
);
// A model already on disk is a switch, not a download.
fireEvent.click(screen.getByRole('button', { name: 'Use Zipformer Streaming EN' }));
expect(readiness.select).toHaveBeenCalledWith(
expect.objectContaining({ id: 'sherpa-zipformer-en-20m' }),
);
expect(readiness.install).toHaveBeenCalledTimes(1);
expect(screen.getByText('Super+Shift+V', { selector: 'kbd' })).toBeInTheDocument();
});
it('falls back to the recommended download when the catalogue cannot be read', async () => {
localStorage.clear();
readiness.phase = 'missing';
readiness.missing = { recommended: { repo_id: 'test/model', label: 'Tiny', size_gb: 0.1 } };
readiness.install.mockReset();
apiJson.mockRejectedValue(new Error('backend restarting'));
render(<TranscriptionsPage />);
fireEvent.click(await screen.findByRole('button', { name: 'Download Tiny (0.1 GB)' }));
expect(readiness.install).toHaveBeenCalledWith(
expect.objectContaining({ repo_id: 'test/model', label: 'Tiny' }),
);
});
it('names the model being installed in the progress bar', () => {
localStorage.clear();
readiness.phase = 'installing';
readiness.percent = 40;
readiness.missing = { recommended: { repo_id: 'test/model', label: 'Tiny', size_gb: 0.1 } };
readiness.target = { repo_id: 'test/parakeet', label: 'Parakeet TDT v2', size_gb: 0.66 };
render(<TranscriptionsPage />);
expect(screen.getByRole('progressbar')).toHaveAccessibleName(/Parakeet TDT v2/);
readiness.target = null;
readiness.percent = undefined;
});
+47 -26
View File
@@ -6,8 +6,8 @@
// upload a file, trim it, save it. The project ships no celebrity catalog.
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Sparkles, Store, Upload } from 'lucide-react';
import { Segmented } from '../ui';
import { Sparkles, Store, Upload, LibraryBig } from 'lucide-react';
import { Tabs, TabsList, TabsTrigger } from '../components/ui/tabs';
import { archetypePreviewUrl, useArchetypeAsProfile } from '../api/archetypes';
import { addCommunityItem, communityPreviewUrl } from '../api/community';
import { previewVoiceUrl } from '../api/gallery';
@@ -218,50 +218,71 @@ export default function VoiceGallery({ clearSelectedProfile = NOOP }) {
const zoneItems = [
{
value: 'archetypes',
label: (
<span className="inline-flex items-center gap-[5px]">
<Sparkles size={14} /> {t('gallery.zone_archetypes', { defaultValue: 'Archetypes' })}
</span>
),
Icon: Sparkles,
label: t('gallery.zone_archetypes', { defaultValue: 'Archetypes' }),
},
{
value: 'community',
label: (
<span className="inline-flex items-center gap-[5px]">
<Store size={14} /> {t('gallery.zone_community', { defaultValue: 'Community' })}
</span>
),
Icon: Store,
label: t('gallery.zone_community', { defaultValue: 'Community' }),
},
{
value: 'imports',
label: (
<span className="inline-flex items-center gap-[5px]">
<Upload size={14} /> {t('gallery.zone_imports', { defaultValue: 'My Imports' })}
</span>
),
Icon: Upload,
label: t('gallery.zone_imports', { defaultValue: 'My Imports' }),
},
];
return (
<div className="flex flex-col gap-[12px] p-[12px] h-full overflow-hidden">
<div className="shrink-0">
<div className="flex justify-between items-center">
<div>
<h2 className="text-[1.1rem] font-semibold m-0 text-[var(--text-primary)]">
<div
data-testid="voice-gallery"
className="gallery flex h-full min-h-0 flex-col gap-[10px] overflow-hidden px-[20px] pb-[14px] pt-[16px] [container-type:inline-size] [container-name:gallery] @max-[640px]/gallery:px-[12px]"
>
<header className="flex shrink-0 flex-wrap items-end justify-between gap-x-[24px] gap-y-[12px]">
<div className="flex min-w-0 items-center gap-[12px]">
<span
className="inline-flex h-[34px] w-[34px] shrink-0 items-center justify-center rounded-[12px] bg-[color-mix(in_srgb,#fabd2f_11%,transparent)] text-[#fabd2f]"
aria-hidden="true"
>
<LibraryBig size={17} strokeWidth={1.6} />
</span>
<div className="min-w-0">
<h2 className="m-0 truncate [font-family:var(--font-serif)] text-[1.5rem] font-normal leading-tight tracking-[-0.02em] text-[color:var(--chrome-fg)]">
{t('gallery.title', { defaultValue: 'VoiceStudio Gallery' })}
</h2>
<p className="mt-[2px] mr-0 mb-0 ml-0 text-[0.72rem] text-[var(--text-secondary)]">
<p className="m-0 mt-[2px] truncate text-[0.72rem] text-[color:var(--chrome-fg-muted)]">
{t('gallery.subtitle', {
defaultValue: 'Hundreds of ready-made designed voices — pick one and go.',
})}
</p>
</div>
<Segmented items={zoneItems} value={zone} onChange={setZone} />
</div>
</div>
<Tabs value={zone} onValueChange={setZone} activationMode="manual" className="gap-0">
<TabsList
aria-label={t('gallery.title', { defaultValue: 'VoiceStudio Gallery' })}
className="grid h-auto w-auto grid-cols-3 gap-[3px] rounded-[var(--chrome-radius-pill)] border border-transparent bg-[var(--chrome-bg)] p-[3px]"
>
{zoneItems.map(({ value, Icon, label }) => (
<TabsTrigger
key={value}
value={value}
data-gallery-zone={value}
className="h-auto min-h-9 min-w-0 cursor-pointer whitespace-nowrap rounded-[var(--chrome-radius-pill)] border border-transparent bg-transparent px-3 py-[6px] text-[0.72rem] font-medium text-[color:var(--chrome-fg-muted)] transition-colors data-[state=active]:border-[var(--chrome-accent-border)] data-[state=active]:bg-[var(--chrome-accent-bg)] data-[state=active]:font-semibold data-[state=active]:text-[color:var(--chrome-accent)] data-[state=active]:shadow-none dark:data-[state=active]:border-[var(--chrome-accent-border)] dark:data-[state=active]:bg-[var(--chrome-accent-bg)] dark:data-[state=active]:text-[color:var(--chrome-accent)] hover:data-[state=inactive]:bg-[var(--chrome-hover-bg)]"
>
<Icon size={14} aria-hidden="true" />
{label}
</TabsTrigger>
))}
</TabsList>
</Tabs>
</header>
<div
className="h-px shrink-0 [background-image:linear-gradient(90deg,color-mix(in_srgb,var(--chrome-fg)_12%,transparent),transparent_70%)]"
aria-hidden="true"
/>
{notice && (
<div className="shrink-0 px-[10px] py-[7px] bg-bg-elev-2 border-l-[3px] border-l-[color:var(--accent)] rounded-[6px] text-[0.75rem] text-[var(--text-primary)]">
<div className="shrink-0 rounded-[var(--chrome-radius-pill)] bg-[color-mix(in_srgb,var(--chrome-accent)_9%,transparent)] px-[14px] py-[7px] text-[0.75rem] text-[color:var(--chrome-fg)]">
{notice}
</div>
)}
+62 -41
View File
@@ -1,7 +1,9 @@
// Audiobook tab layout the prod-polish compaction (#1214).
// Audiobook tab layout the Write Cast Produce workspace redesign.
//
// The right-hand settings column is a compact property inspector: essentials
// stay visible and one optional production tool opens at a time.
// The inspector rail is gone: Script / Voices / Book tabs (shadcn, manual
// activation, clone-workspace pill styling) own the column, and the
// warnings/progress/result/plan status rail only takes space once there is
// something to show.
import React from 'react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
@@ -38,69 +40,88 @@ const withI18n = (node) => (
<I18nextProvider i18n={i18n}>{node}</I18nextProvider>
</QueryClientProvider>
);
describe('AudiobookTab — compact grouped layout (#1214)', () => {
function tabTrigger(name) {
return screen.getByRole('tab', { name });
}
// Real browsers focus on mousedown and activate on click; fireEvent.click
// alone skips the focus half, which manual-activation Radix tabs need.
function switchTab(name) {
const tab = tabTrigger(name);
fireEvent.mouseDown(tab);
fireEvent.click(tab);
return tab;
}
describe('AudiobookTab — Write/Cast/Produce tabs', () => {
beforeEach(() => {
localStorage.clear();
useAppStore.getState().setLastOutput('');
useAppStore.getState().setScript('');
});
it('keeps the primary inputs always visible', () => {
const { container } = render(withI18n(<AudiobookTab profiles={[]} />));
// Script editor, default voice, language the three always-on controls.
expect(screen.getByLabelText(en.audiobook.script)).toBeTruthy();
expect(screen.getByText(en.audiobook.default_voice)).toBeTruthy();
expect(screen.getByText(en.audiobook.language)).toBeTruthy();
expect(screen.getByLabelText(en.audiobook.format)).toBeTruthy();
// Secondary actions stay discoverable through accessible icon labels.
it('opens on the Script tab with the manuscript editor', () => {
render(withI18n(<AudiobookTab profiles={[]} />));
for (const label of [en.audiobook.tab_script, en.audiobook.tab_voices, en.audiobook.tab_book]) {
expect(screen.getByRole('tab', { name: new RegExp(label) })).toBeTruthy();
}
expect(tabTrigger(en.audiobook.tab_script)).toHaveAttribute('aria-selected', 'true');
expect(screen.getByLabelText(en.audiobook.script, { selector: 'textarea' })).toBeTruthy();
// Hero actions stay discoverable through accessible labels.
expect(screen.getByLabelText(en.audiobook.load_sample)).toBeTruthy();
expect(screen.getByLabelText(en.audiobook.import)).toBeTruthy();
expect(screen.getByLabelText(en.audiobook.preview_plan)).toBeTruthy();
expect(screen.getByText(en.audiobook.create)).toBeTruthy();
expect(screen.getByRole('heading', { level: 2, name: en.audiobook.title })).toBeTruthy();
expect(container.querySelector('[class*="container-name:audiobook-inspector"]')).toBeTruthy();
expect(
container.querySelector('[class*="@min-[360px]/audiobook-inspector:grid-cols-2"]'),
).toBeTruthy();
});
it('groups optional controls into an icon-led tool strip', () => {
it('keeps the status rail out of the way until there is something to show', () => {
render(withI18n(<AudiobookTab profiles={[]} />));
for (const title of [
en.audiobook.output,
en.audiobook.details,
en.audiobook.lexicon,
en.audiobook.markup_help,
]) {
expect(screen.getByRole('button', { name: title })).toBeTruthy();
expect(screen.queryByTestId('audiobook-status-rail')).toBeNull();
});
it('shows voices controls only on the Voices tab', () => {
render(withI18n(<AudiobookTab profiles={[]} />));
expect(screen.queryByText(en.audiobook.default_voice)).toBeNull();
switchTab(en.audiobook.tab_voices);
expect(tabTrigger(en.audiobook.tab_voices)).toHaveAttribute('aria-selected', 'true');
expect(screen.getByText(en.audiobook.default_voice)).toBeTruthy();
expect(screen.getByText(en.audiobook.language)).toBeTruthy();
expect(screen.getByText(en.audiobook.cast)).toBeTruthy();
expect(screen.getByText(en.audiobook.expressive)).toBeTruthy();
});
it('shows production controls only on the Book tab', () => {
render(withI18n(<AudiobookTab profiles={[]} />));
expect(screen.queryByLabelText(en.audiobook.loudness)).toBeNull();
switchTab(en.audiobook.tab_book);
expect(screen.getByLabelText(en.audiobook.format)).toBeTruthy();
expect(screen.getByLabelText(en.audiobook.loudness)).toBeTruthy();
// Details / lexicon / markup fold into collapsible sections.
for (const title of [en.audiobook.details, en.audiobook.lexicon, en.audiobook.markup_help]) {
expect(screen.getByText(new RegExp(title))).toBeTruthy();
}
});
it('keeps optional panels closed by default', () => {
it('persists the workspace tab across visits', () => {
const { unmount } = render(withI18n(<AudiobookTab profiles={[]} />));
switchTab(en.audiobook.tab_voices);
expect(localStorage.getItem('omnivoice.audiobook.tab')).toBe('voices');
unmount();
render(withI18n(<AudiobookTab profiles={[]} />));
expect(screen.queryByLabelText(en.audiobook.loudness)).toBeNull();
expect(screen.queryByLabelText(en.audiobook.meta_title)).toBeNull();
expect(tabTrigger(en.audiobook.tab_voices)).toHaveAttribute('aria-selected', 'true');
expect(screen.getByText(en.audiobook.default_voice)).toBeTruthy();
});
it('opens Cast by default when the script contains cast tags', () => {
it('opens Cast content when the script contains cast tags', () => {
useAppStore.getState().setScript('# Chapter\n[voice:Mara] Hello');
render(withI18n(<AudiobookTab profiles={[]} />));
expect(screen.getByRole('button', { name: en.audiobook.cast })).toHaveAttribute(
'aria-pressed',
'true',
);
expect(tabTrigger(en.audiobook.tab_voices)).toHaveTextContent(String(1));
switchTab(en.audiobook.tab_voices);
expect(screen.getByLabelText(`${en.audiobook.cast}: Mara`)).toBeTruthy();
});
it('shows only the selected optional panel', () => {
render(withI18n(<AudiobookTab profiles={[]} />));
fireEvent.click(screen.getByRole('button', { name: en.audiobook.details }));
expect(screen.getByLabelText(en.audiobook.meta_title)).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: en.audiobook.output }));
expect(screen.queryByLabelText(en.audiobook.meta_title)).toBeNull();
expect(screen.getByLabelText(en.audiobook.loudness)).toBeTruthy();
});
it('pairs a persisted output with its render-time script after later edits', () => {
useAppStore
.getState()
@@ -8,7 +8,7 @@
*/
import React from 'react';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
const { toastMock, eventHandlers, eventState, eventUnlisteners } = vi.hoisted(() => ({
toastMock: Object.assign(vi.fn(), {
@@ -57,13 +57,14 @@ vi.mock('../utils/asrModelMissing', () => ({
// Deferred startMicCapture so each test controls WHEN (and HOW resolve or
// reject) the mic graph finishes setting up relative to the WS error frame.
const { micControl, micStop } = vi.hoisted(() => ({
micControl: { resolve: null, reject: null },
micControl: { resolve: null, reject: null, onFrame: null },
micStop: vi.fn(async () => {}),
}));
vi.mock('../utils/aec/micCapture', () => ({
startMicCapture: vi.fn(
() =>
(_stream, onFrame) =>
new Promise((resolve, reject) => {
micControl.onFrame = onFrame;
micControl.resolve = resolve;
micControl.reject = reject;
}),
@@ -349,3 +350,26 @@ describe('CaptureWidget — connect-time asr_model_missing during mic setup', ()
expect(invokeMock).not.toHaveBeenCalledWith('set_tray_recording', { recording: true });
});
});
it('pauses and resumes the microphone, then closes and releases capture', async () => {
const track = { enabled: true, stop: vi.fn() };
navigator.mediaDevices.getUserMedia.mockResolvedValue({ getTracks: () => [track] });
render(<CaptureWidget />);
await waitFor(() => expect(eventHandlers['tray-dictate']).toBeTypeOf('function'));
await eventHandlers['tray-dictate']({
payload: { sessionId: 7, deliveryId: 9, registrationId: 1 },
});
await waitFor(() => expect(FakeWS.instances.length).toBe(1));
micControl.resolve(micStop);
const pause = await screen.findByRole('button', { name: 'Pause' });
fireEvent.click(pause);
expect(track.enabled).toBe(false);
fireEvent.click(screen.getByRole('button', { name: 'Resume' }));
expect(track.enabled).toBe(true);
fireEvent.click(screen.getByRole('button', { name: 'Pause' }));
fireEvent.click(screen.getByRole('button', { name: 'Close' }));
await waitFor(() => expect(track.stop).toHaveBeenCalled());
await waitFor(() =>
expect(screen.queryByRole('button', { name: 'Resume' })).not.toBeInTheDocument(),
);
});
+118 -8
View File
@@ -1,6 +1,6 @@
import React from 'react';
import { describe, it, expect, vi } from 'vitest';
import { fireEvent, render, screen } from '@testing-library/react';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { I18nextProvider } from 'react-i18next';
import i18n from '../i18n';
@@ -105,10 +105,93 @@ describe('IdleSkeleton — pipeline-stage vs idle dropzone', () => {
});
it('shows the idle dropzone only when the pipeline is truly idle (no job)', () => {
const { container } = renderIdle({ dubStep: 'idle', dubJobId: null });
expect(container.querySelector('.dub-idle-drop')).not.toBeNull();
expect(container.querySelector('.dub-start-screen')).not.toBeNull();
expect(container.querySelector('.dub-start-card')).not.toBeNull();
expect(container.querySelector('.dub-idle-drop.dub-start-drop')).not.toBeNull();
expect(screen.getByText(DROP_HINT)).toBeInTheDocument();
expect(screen.getByText(i18n.t('gallery.upload'))).toBeInTheDocument();
expect(screen.getByPlaceholderText(URL_PLACEHOLDER)).toBeInTheDocument();
expect(screen.getByLabelText('Choose a cookies.txt export')).toBeInTheDocument();
expect(screen.getByRole('button', { name: i18n.t('dub.source_language') })).toBeVisible();
expect(screen.getByRole('button', { name: i18n.t('dub.target_language') })).toBeVisible();
expect(screen.getByLabelText('Choose a cookies.txt export')).not.toBeVisible();
});
it('opens the native file picker from the keyboard-accessible import button', () => {
const { container } = renderIdle();
const input = container.querySelector('#video-upload');
const clickInput = vi.spyOn(input, 'click');
const upload = screen.getByRole('button', { name: i18n.t('gallery.upload') });
expect(upload.tagName).toBe('BUTTON');
upload.focus();
expect(upload).toHaveFocus();
fireEvent.click(upload);
expect(clickInput).toHaveBeenCalledOnce();
});
it('accepts a local audio file from the central import control', async () => {
const setDubVideoFile = vi.fn();
const setDubInputType = vi.fn();
const setDubStep = vi.fn();
const setDubLocalBlobUrl = vi.fn((next) => {
if (typeof next === 'function') next(null);
});
const urls = { audioUrl: 'blob:audio', videoUrl: null };
const fileToMediaUrl = vi.fn().mockResolvedValue(urls);
const { container } = renderIdle({
setDubVideoFile,
setDubInputType,
setDubStep,
setDubLocalBlobUrl,
fileToMediaUrl,
});
const file = new File(['audio'], 'chapter.wav', { type: 'audio/wav' });
fireEvent.change(container.querySelector('#video-upload'), { target: { files: [file] } });
expect(setDubVideoFile).toHaveBeenCalledWith(file);
expect(setDubInputType).toHaveBeenCalledWith('audio');
expect(setDubStep).toHaveBeenCalledWith('idle');
await waitFor(() => expect(fileToMediaUrl).toHaveBeenCalledWith(file, null));
expect(setDubLocalBlobUrl).toHaveBeenLastCalledWith(urls);
});
it('submits a pasted URL from both the button and Enter key', () => {
const onIngestUrl = vi.fn();
renderIdle({ ingestUrl: 'https://example.test/video', onIngestUrl });
const input = screen.getByRole('textbox', { name: URL_PLACEHOLDER });
fireEvent.click(screen.getByRole('button', { name: i18n.t('dub.ingest') }));
fireEvent.keyDown(input, { key: 'Enter' });
expect(onIngestUrl).toHaveBeenCalledTimes(2);
});
it('keeps optional import and generation settings behind Advanced', () => {
const setLandingAdvOpen = vi.fn();
const { rerender } = renderIdle({ landingAdvOpen: false, setLandingAdvOpen });
const advanced = screen.getByRole('button', { name: i18n.t('dub.advanced') });
expect(advanced).toHaveAttribute('aria-expanded', 'false');
expect(screen.getByLabelText('Choose a cookies.txt export')).not.toBeVisible();
fireEvent.click(advanced);
expect(setLandingAdvOpen).toHaveBeenCalledOnce();
expect(setLandingAdvOpen.mock.calls[0][0](false)).toBe(true);
rerender(
<I18nextProvider i18n={i18n}>
<IdleSkeleton {...baseProps({ landingAdvOpen: true, setLandingAdvOpen })} />
</I18nextProvider>,
);
expect(screen.getByRole('button', { name: i18n.t('dub.advanced') })).toHaveAttribute(
'aria-expanded',
'true',
);
expect(screen.getByLabelText('Choose a cookies.txt export')).toBeVisible();
expect(screen.getByRole('checkbox', { name: i18n.t('dub.pull_captions') })).toBeVisible();
expect(screen.getByRole('spinbutton')).toBeVisible();
expect(screen.getByPlaceholderText(i18n.t('dub.style_placeholder'))).toBeVisible();
});
it('keeps source-language selection available after choosing a local file', () => {
@@ -118,9 +201,8 @@ describe('IdleSkeleton — pipeline-stage vs idle dropzone', () => {
setDubSourceLangCode,
});
fireEvent.change(screen.getByRole('combobox', { name: i18n.t('dub.source_language') }), {
target: { value: 'th' },
});
fireEvent.click(screen.getByRole('button', { name: i18n.t('dub.source_language') }));
fireEvent.mouseDown(screen.getByRole('option', { name: /Thai.*th/ }));
expect(setDubSourceLangCode).toHaveBeenCalledWith('th');
});
@@ -130,6 +212,7 @@ describe('IdleSkeleton — pipeline-stage vs idle dropzone', () => {
uiLocale: 'fr',
});
fireEvent.click(screen.getByRole('button', { name: i18n.t('dub.source_language') }));
const thai = new Intl.DisplayNames(['fr', 'en'], { type: 'language' }).of('th');
expect(screen.getByRole('option', { name: `${thai} — th` })).toBeInTheDocument();
});
@@ -138,19 +221,46 @@ describe('IdleSkeleton — pipeline-stage vs idle dropzone', () => {
const selected = new File(['# Netscape HTTP Cookie File\n'], 'cookies.txt', {
type: 'text/plain',
});
const { rerender } = renderIdle({ youtubeCookieFile: selected });
const { rerender } = renderIdle({ youtubeCookieFile: selected, landingAdvOpen: true });
const input = screen.getByLabelText('Choose a cookies.txt export');
fireEvent.change(input, { target: { files: [selected] } });
expect(input.files).toHaveLength(1);
rerender(
<I18nextProvider i18n={i18n}>
<IdleSkeleton {...baseProps({ youtubeCookieFile: null })} />
<IdleSkeleton {...baseProps({ youtubeCookieFile: null, landingAdvOpen: true })} />
</I18nextProvider>,
);
expect(input.value).toBe('');
});
it('preserves the native cookie selection while Advanced is closed and reopened', () => {
const selected = new File(['# Netscape HTTP Cookie File\n'], 'cookies.txt', {
type: 'text/plain',
});
const { rerender } = renderIdle({ youtubeCookieFile: selected, landingAdvOpen: true });
const input = screen.getByLabelText('Choose a cookies.txt export');
fireEvent.change(input, { target: { files: [selected] } });
rerender(
<I18nextProvider i18n={i18n}>
<IdleSkeleton {...baseProps({ youtubeCookieFile: selected, landingAdvOpen: false })} />
</I18nextProvider>,
);
expect(screen.getByLabelText('Choose a cookies.txt export')).toBe(input);
expect(input).not.toBeVisible();
expect(input.files).toHaveLength(1);
rerender(
<I18nextProvider i18n={i18n}>
<IdleSkeleton {...baseProps({ youtubeCookieFile: selected, landingAdvOpen: true })} />
</I18nextProvider>,
);
expect(screen.getByLabelText('Choose a cookies.txt export')).toBe(input);
expect(input).toBeVisible();
expect(input.files).toHaveLength(1);
});
it('does NOT show the idle dropzone while transcribing a URL-ingested job', () => {
const { container } = renderIdle({ dubStep: 'transcribing', dubJobId: 'job-url-1' });
// The desync: dropzone + paste-URL input must be gone during transcribe.
@@ -78,23 +78,65 @@ describe('StoriesEditor voice pickers (#1220)', () => {
it('cast picker renders VoiceSelector and stores the character voice', () => {
renderEditor();
const castRegion = screen.getByRole('complementary', { name: /Stories/ });
fireEvent.mouseDown(screen.getByRole('tab', { name: 'Cast' }), { button: 0 });
const castRegion = screen.getByRole('tabpanel', { name: 'Cast' });
const trigger = within(castRegion).getByRole('button', { name: /Default/ });
fireEvent.click(trigger);
fireEvent.mouseDown(screen.getByText('Aria'));
expect(useAppStore.getState().cast[0].profileId).toBe('p_clone');
fireEvent.mouseDown(screen.getByRole('tab', { name: 'Script' }), { button: 0 });
expect(within(screen.getByRole('list')).getByRole('button', { name: /Aria/ })).toBeVisible();
});
it('renders a calm writing hierarchy with the project stats and line canvas', () => {
it('defaults to Script and renders the project stats and line canvas', () => {
renderEditor();
expect(screen.getByRole('heading', { level: 1, name: /Untitled story/ })).toBeInTheDocument();
expect(screen.getAllByText('1 lines').length).toBeGreaterThan(0);
expect(screen.getByRole('tab', { name: 'Script' })).toHaveAttribute('aria-selected', 'true');
expect(screen.getByRole('main')).toHaveClass('stories-manuscript');
expect(screen.getByRole('complementary')).toHaveClass('stories-sidebar');
expect(screen.getByRole('list')).toHaveClass('stories-track-list');
expect(screen.getByRole('listitem')).toHaveClass('stories-line');
});
it('preserves script edits while moving through the workspace tabs', () => {
renderEditor();
const line = screen.getByDisplayValue('Once upon a time');
fireEvent.change(line, { target: { value: 'A revised opening line' } });
fireEvent.mouseDown(screen.getByRole('tab', { name: 'Cast' }), { button: 0 });
expect(screen.getByRole('tabpanel', { name: 'Cast' })).toBeVisible();
fireEvent.mouseDown(screen.getByRole('tab', { name: 'Export' }), { button: 0 });
expect(screen.getByRole('tabpanel', { name: 'Export' })).toBeVisible();
fireEvent.mouseDown(screen.getByRole('tab', { name: 'Script' }), { button: 0 });
expect(screen.getByDisplayValue('A revised opening line')).toBeVisible();
expect(useAppStore.getState().storyTracks[0].text).toBe('A revised opening line');
});
it('supports keyboard navigation across all workspace tabs', async () => {
renderEditor();
const script = screen.getByRole('tab', { name: 'Script' });
const cast = screen.getByRole('tab', { name: 'Cast' });
const projects = screen.getByRole('tab', { name: 'Projects' });
script.focus();
fireEvent.keyDown(script, { key: 'ArrowRight' });
await waitFor(() => expect(cast).toHaveFocus());
expect(script).toHaveAttribute('aria-selected', 'true');
expect(cast).toHaveAttribute('aria-selected', 'false');
fireEvent.keyDown(cast, { key: 'Enter' });
expect(cast).toHaveAttribute('aria-selected', 'true');
fireEvent.keyDown(cast, { key: 'End' });
await waitFor(() => expect(projects).toHaveFocus());
expect(cast).toHaveAttribute('aria-selected', 'true');
expect(projects).toHaveAttribute('aria-selected', 'false');
fireEvent.keyDown(projects, { key: 'Enter' });
expect(projects).toHaveAttribute('aria-selected', 'true');
});
it('loads a comprehensive working sample by default', async () => {
useAppStore.setState({ storyTracks: [], storyProjects: [], currentProjectId: null });
renderEditor();
+6 -3
View File
@@ -45,11 +45,14 @@ describe('retryInitialLoad (#1158 class)', () => {
// and the fix is silently inert again (skeptic finding F1).
it('integration: useAppData wires rethrowing loaders into the retry', async () => {
const { readFileSync } = await import('node:fs');
// jsdom replaces the global URL; Node's fileURLToPath needs a REAL node:
// URL instance, so build one from the specifier's pathname directly.
// jsdom replaces the global URL, so build a REAL node: URL instance and
// hand that to fileURLToPath. Taking `.pathname` instead looks equivalent
// but is not on Windows: a file:///C:/… URL's pathname is "/C:/…", which
// readFileSync then resolves against the current drive as "C:\C:\…" and
// the whole test file fails with ENOENT on every Windows checkout.
const nodeUrl = await import('node:url');
const NodeURL = nodeUrl.URL;
const hookPath = new NodeURL('../hooks/useAppData.js', import.meta.url).pathname;
const hookPath = nodeUrl.fileURLToPath(new NodeURL('../hooks/useAppData.js', import.meta.url));
const src = readFileSync(hookPath, 'utf8');
const initialBlock = src.slice(src.indexOf('retryInitialLoad('));
for (const loader of [
@@ -0,0 +1,21 @@
import { expect, test } from '@playwright/test';
for (const width of [1280, 390]) {
test(`audiobook manuscript fills its workspace at ${width}px`, async ({ page }) => {
await page.setViewportSize({ width, height: 900 });
await page.goto('/src/test/visual/harness.html?component=AudiobookWorkspace');
const editor = page.getByRole('textbox', { name: 'Script', exact: true });
await expect(editor).toBeVisible();
const box = await editor.boundingBox();
expect(box!.height).toBeGreaterThan(300);
expect(await editor.evaluate((el) => getComputedStyle(el).resize)).toBe('none');
for (const name of ['Voices', 'Book', 'Script']) {
await page.getByRole('tab', { name: new RegExp(name) }).click();
await expect(page.getByRole('tabpanel')).toBeVisible();
}
await expect(editor).toHaveValue(/Chapter One/);
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(
true,
);
await page.screenshot({ path: `/tmp/audiobook-layout-${width}.png`, fullPage: true });
});
}
@@ -0,0 +1,118 @@
import { expect, test } from '@playwright/test';
const START_CONTROLS = [
'.dub-start-card',
'.dub-start-drop',
'.dub-start-choose',
'.dub-start-url',
'.dub-start-url input',
'.dub-start-url button',
'.dub-start-languages',
'.dub-start-languages label',
'.dub-start-languages button',
'.dub-start-advanced',
];
const ADVANCED_CONTROLS = [
'.dub-start-options',
'.dub-start-caption-option',
'.dub-start-cookie-option',
'.dub-start-generation-options',
'.dub-start-generation-options input',
];
async function expectControlsInsideViewport(page, selectors: string[]) {
const measurements = await page.locator(selectors.join(',')).evaluateAll((elements) =>
elements.map((element) => {
const rect = element.getBoundingClientRect();
return {
selector: element.className || element.tagName,
left: rect.left,
right: rect.right,
width: rect.width,
};
}),
);
expect(measurements.length).toBeGreaterThanOrEqual(selectors.length);
for (const control of measurements) {
expect(control.width, `${control.selector} has no rendered width`).toBeGreaterThan(0);
expect(control.left, `${control.selector} is clipped on the left`).toBeGreaterThanOrEqual(-1);
expect(control.right, `${control.selector} is clipped on the right`).toBeLessThanOrEqual(
(await page.viewportSize())!.width + 1,
);
}
}
for (const width of [1280, 390]) {
test(`dubbing start screen keeps import controls usable at ${width}px`, async ({ page }) => {
const pageErrors: string[] = [];
page.on('pageerror', (error) => pageErrors.push(error.message));
await page.setViewportSize({ width, height: 900 });
await page.goto('/src/test/visual/harness.html?component=DubIdleStart');
await page.waitForFunction(
() => document.documentElement.getAttribute('data-visual-ready') === 'true',
);
const start = page.locator('.dub-start-screen');
const card = page.locator('.dub-start-card');
await expect(start).toBeVisible();
await expect(page.getByText('Video Dubbing Studio', { exact: true })).toHaveCount(0);
await expect(card).toBeVisible();
await expect(page.getByRole('group', { name: 'Drop video or audio here' })).toBeVisible();
await expect(page.getByRole('button', { name: /Upload/ })).toBeVisible();
await expect(
page.getByRole('textbox', { name: '…or paste YouTube / video URL' }),
).toBeVisible();
await expect(page.getByRole('button', { name: 'Spoken language' })).toBeVisible();
await expect(page.getByRole('button', { name: 'Dub into' })).toBeVisible();
const source = page.getByRole('button', { name: 'Spoken language' });
await source.click();
const menu = page.getByRole('listbox');
await expect(menu).toBeVisible();
await menu.locator('input').fill('Thai');
await menu.locator('input').press('Enter');
await expect(source).toContainText('Thai');
await expect(menu).toHaveCount(0);
const advanced = page.getByRole('button', { name: 'Advanced' });
await expect(advanced).toHaveAttribute('aria-expanded', 'false');
await expect(page.locator('#dub-start-advanced-options')).toBeHidden();
await expectControlsInsideViewport(page, START_CONTROLS);
await expect
.poll(() => card.evaluate((element) => element.scrollWidth <= element.clientWidth + 1))
.toBe(true);
expect(
await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth),
).toBe(true);
await page.screenshot({
path: `/tmp/dub-idle-start-${width}-default.png`,
fullPage: true,
animations: 'disabled',
});
await advanced.click();
await expect(advanced).toHaveAttribute('aria-expanded', 'true');
const options = page.locator('#dub-start-advanced-options');
await expect(options).toBeVisible();
await expect(page.getByRole('checkbox', { name: /Pull YouTube captions/ })).toBeVisible();
await expect(page.getByLabel('Choose a cookies.txt export')).toBeVisible();
await expect(page.getByRole('spinbutton', { name: 'Speakers' })).toBeVisible();
await expect(page.getByRole('textbox', { name: 'Voice style' })).toBeVisible();
await options.scrollIntoViewIfNeeded();
await expectControlsInsideViewport(page, [...START_CONTROLS, ...ADVANCED_CONTROLS]);
await expect
.poll(() => options.evaluate((element) => element.scrollWidth <= element.clientWidth + 1))
.toBe(true);
expect(
await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth),
).toBe(true);
expect(pageErrors).toEqual([]);
await page.screenshot({
path: `/tmp/dub-idle-start-${width}-advanced.png`,
fullPage: true,
animations: 'disabled',
});
});
}
@@ -0,0 +1,50 @@
import { expect, test } from '@playwright/test';
for (const width of [1280, 390]) {
test(`Projects list stays stable on hover at ${width}px`, async ({ page }) => {
await page.setViewportSize({ width, height: 900 });
await page.goto('/src/test/visual/harness.html?component=DubIdleWorkspace');
await page.waitForFunction(
() => document.documentElement.getAttribute('data-visual-ready') === 'true',
);
if (width === 390)
await page.locator('#visual-root').evaluate((root) => root.classList.add('shell-mini'));
const library = page.locator('.dub-project-library');
await expect(library.getByText('Projects', { exact: true })).toBeVisible();
await expect(page.getByRole('tab')).toHaveCount(0);
await expect(library.getByText('Documentary final.mp4')).toBeVisible();
const cards = library.locator('.history-item');
await cards.first().scrollIntoViewIfNeeded();
const before = await cards.evaluateAll((nodes) =>
nodes.map((n) => {
const r = n.getBoundingClientRect();
return { y: r.y, height: r.height };
}),
);
const title = cards.first().locator('.history-title');
const titleHit = await title.evaluate((el) => {
const r = el.getBoundingClientRect();
return document
.elementFromPoint(r.x + r.width / 2, r.y + r.height / 2)
?.closest('button')
?.getAttribute('aria-label');
});
expect(titleHit).toBe('Open: Documentary final.mp4');
await cards.first().hover();
await expect(cards.first().getByRole('button', { name: 'Open', exact: true })).toBeVisible();
await page.waitForTimeout(250);
const after = await cards.evaluateAll((nodes) =>
nodes.map((n) => {
const r = n.getBoundingClientRect();
return { y: r.y, height: r.height };
}),
);
expect(after).toEqual(before);
await cards.first().getByRole('button', { name: 'Open', exact: true }).focus();
await expect(cards.first().getByRole('button', { name: 'Open', exact: true })).toBeFocused();
expect(
await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth),
).toBe(true);
await page.screenshot({ path: `/tmp/dub-projects-${width}.png`, fullPage: true });
});
}
+232
View File
@@ -12,12 +12,16 @@
import React from 'react';
import DubSegmentTable from '../../components/DubSegmentTable.jsx';
import DubWorkspaceSidebar from '../../components/DubWorkspaceSidebar.jsx';
import DubSelectionToolbar from '../../components/dub/DubSelectionToolbar.jsx';
import IdleSkeleton from '../../components/dub/IdleSkeleton.jsx';
import i18n from '../../i18n';
import WaveformTimeline from '../../components/WaveformTimeline.jsx';
import DubWorkspaceFixture from './DubWorkspaceFixture.jsx';
import '../../components/dub/DubRightColumn.css';
import Header from '../../components/Header';
import StoriesEditor from '../../components/StoriesEditor.jsx';
import AudiobookTab from '../../pages/AudiobookTab.jsx';
import { Download, Mic, Search, Sparkles, Trash2 } from 'lucide-react';
import Badge from '../../ui/Badge.jsx';
@@ -199,7 +203,235 @@ function WaveformPanFixture() {
);
}
function DubIdleStartFixture() {
const [ingestUrl, setIngestUrl] = React.useState('');
const [sourceLanguage, setSourceLanguage] = React.useState('auto');
const [targetLanguage, setTargetLanguage] = React.useState('es');
const [optionalOpen, setOptionalOpen] = React.useState(false);
const noop = () => {};
return (
<div style={{ height: 760, minWidth: 0 }}>
<IdleSkeleton
t={i18n.t.bind(i18n)}
uiLocale="en"
dubVideoFile={null}
activeProjectName=""
dubFilename=""
dubError=""
dubJobId={null}
dubStep="idle"
dubFailure={null}
asrInstall={null}
handleInstallMissingAsr={noop}
handleDubRetryTranscribe={noop}
handleDubImportSrt={noop}
dubLocalBlobUrl={null}
dubPrepStage={null}
dubPrepProgress={{ percent: null, speedBps: null, etaS: null, stageStartedAt: null }}
handleDubAbort={noop}
transcribeElapsed={0}
transcribeProgress={null}
dubDuration={0}
dubNumSpeakers={null}
setDubNumSpeakers={noop}
handleDubUpload={noop}
demoDismissed
dismissDubDemo={noop}
setDubVideoFile={noop}
setDubInputType={noop}
setDubStep={noop}
fileToMediaUrl={async () => ({ audioUrl: null, videoUrl: null })}
setDubLocalBlobUrl={noop}
ingestUrl={ingestUrl}
setIngestUrl={setIngestUrl}
onIngestUrl={noop}
fetchYtSubs={false}
setFetchYtSubs={noop}
youtubeCookieFile={null}
setYoutubeCookieFile={noop}
dubLangCode={targetLanguage}
dubSourceLangCode={sourceLanguage}
setDubSourceLangCode={setSourceLanguage}
setDubLangCode={setTargetLanguage}
setDubLang={noop}
landingAdvOpen={optionalOpen}
setLandingAdvOpen={setOptionalOpen}
dubInstruct=""
setDubInstruct={noop}
onOpenQueue={noop}
/>
</div>
);
}
const DUB_SIDEBAR_PROJECTS = [
{
id: 'dub-project-1',
name: 'Documentary voice-over',
updated_at: '2026-09-08T12:00:00Z',
duration: 128,
video_path: 'documentary.mp4',
},
{
id: 'dub-project-2',
name: 'Product launch — Spanish',
updated_at: '2026-09-07T12:00:00Z',
duration: 64,
video_path: 'product-launch.mov',
},
];
const DUB_SIDEBAR_HISTORY = [
{
id: 'dub-history-1',
filename: 'Documentary final.mp4',
duration: 128,
segments_count: 18,
language: 'Spanish',
language_code: 'es',
job_data: { input_type: 'video' },
},
{
id: 'dub-history-2',
filename: 'Interview voice track.wav',
duration: 76,
segments_count: 9,
language: 'French',
language_code: 'fr',
job_data: { input_type: 'audio' },
},
];
function DubIdleWorkspaceFixture() {
const noop = () => {};
return (
<div className="studio-with-history" style={{ height: 760, minWidth: 0 }}>
<div className="studio-with-history__main">
<DubIdleStartFixture />
</div>
<div className="studio-right">
<DubWorkspaceSidebar
projects={DUB_SIDEBAR_PROJECTS}
activeProjectId={null}
loadProject={noop}
deleteProject={noop}
renameProject={noop}
dubHistory={DUB_SIDEBAR_HISTORY}
restoreDubHistory={noop}
deleteHistory={noop}
clearHistory={noop}
/>
</div>
</div>
);
}
const STORIES_CAST = [
{ id: 'narrator', name: 'Narrator', color: '#b8bb26', profileId: 'voice-aria' },
{ id: 'mara', name: 'Mara', color: '#d3869b', profileId: 'voice-mara' },
{ id: 'cole', name: 'Cole', color: '#83a598', profileId: 'voice-cole' },
];
const STORIES_TRACKS = [
{
id: 101,
character: 'narrator',
text: '# The Signal at Sundown',
profileId: null,
emotion: null,
speed: null,
generating: false,
audioUrl: null,
},
{
id: 102,
character: 'narrator',
text: 'The lighthouse had been silent for eleven winters.',
profileId: null,
emotion: null,
speed: null,
generating: false,
audioUrl: null,
},
{
id: 103,
character: 'mara',
text: 'Cole, did you hear that? [pause 0.4s] The old radio is calling us.',
profileId: null,
emotion: null,
speed: 0.95,
generating: false,
audioUrl: null,
},
{
id: 104,
character: 'cole',
text: 'I heard it. Stay close, and keep the lantern low.',
profileId: null,
emotion: null,
speed: null,
generating: false,
audioUrl: null,
},
];
const STORIES_PROJECT = {
id: 'visual-story',
name: 'The Signal at Sundown',
cast: STORIES_CAST,
tracks: STORIES_TRACKS,
updatedAt: 1,
};
export const SPECS = {
AudiobookWorkspace: {
width: '100%',
providers: {
store: {
script:
'# Chapter One\n\nA light shone across the water.\n\n# Chapter Two\n\nThe boat returned safely.',
lastOutput: '',
},
fetch: () => ({}),
},
render: () => (
<div style={{ height: 800 }}>
<AudiobookTab profiles={[]} />
</div>
),
},
DubIdleStart: {
width: '100%',
providers: {},
render: () => <DubIdleStartFixture />,
},
DubIdleWorkspace: {
width: '100%',
providers: {},
render: () => <DubIdleWorkspaceFixture />,
},
StoriesWorkspaceLayout: {
width: '100%',
providers: {
store: {
cast: STORIES_CAST,
storyTracks: STORIES_TRACKS,
storyProjects: [STORIES_PROJECT],
currentProjectId: STORIES_PROJECT.id,
},
},
render: () => (
<div style={{ height: 780, minWidth: 0 }}>
<StoriesEditor
profiles={[
{ id: 'voice-aria', name: 'Aria — warm narrator' },
{ id: 'voice-mara', name: 'Mara — intimate alto' },
{ id: 'voice-cole', name: 'Cole — steady baritone' },
]}
/>
</div>
),
},
DubWorkspaceLayout: {
width: '100%',
providers: {},
@@ -0,0 +1,40 @@
import { expect, test } from '@playwright/test';
for (const { width, shellMini } of [
{ width: 1280, shellMini: false },
{ width: 390, shellMini: false },
{ width: 1280, shellMini: true },
]) {
const scenario = shellMini ? `${width}px shell-mini` : `${width}px`;
test(`Stories tabs stay usable without horizontal overflow at ${scenario}`, async ({ page }) => {
await page.setViewportSize({ width, height: 900 });
await page.goto('/src/test/visual/harness.html?component=StoriesWorkspaceLayout');
await page.waitForFunction(
() => document.documentElement.getAttribute('data-visual-ready') === 'true',
);
if (shellMini) {
await page.locator('#visual-root').evaluate((root) => root.classList.add('shell-mini'));
}
const tabs = ['Script', 'Cast', 'Export', 'Projects'];
await expect(page.getByRole('tab', { name: 'Script' })).toHaveAttribute(
'aria-selected',
'true',
);
for (const tab of tabs) {
const trigger = page.getByRole('tab', { name: tab });
await trigger.click();
await expect(trigger).toHaveAttribute('aria-selected', 'true');
await expect(page.getByRole('tabpanel', { name: tab })).toBeVisible();
expect(
await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth),
).toBe(true);
const suffix = shellMini ? `${width}-shell-mini` : String(width);
await page.screenshot({
path: `/tmp/stories-tabs-${suffix}-${tab.toLowerCase()}.png`,
animations: 'disabled',
});
}
});
}
@@ -67,18 +67,6 @@ describe('workspace narrow-shell reflow (#476 CTA-clipping guard)', () => {
expect(app).toMatch(/<div className="studio-right">\s*<WorkspaceHistory\s+history=\{history\}/);
});
it('gives Dub Projects its own narrower rail than Dub History', () => {
expect(app).toMatch(/className="studio-projects">\s*<WorkspaceProjects/);
expect(css).toMatch(/\.studio-projects\s*\{[^}]*flex:\s*0 0 240px/s);
expect(css).toMatch(/\.studio-right\s*\{[^}]*flex:\s*0 0 340px/s);
});
it('keeps Save unavailable in the idle-only Projects rail', () => {
expect(app).toMatch(
/dubStep === 'idle'[\s\S]*?className="studio-projects"[\s\S]*?canSave=\{false\}/,
);
});
it('keeps the Script editor useful without pushing voice setup below the fold', () => {
expect(indexRaw).toMatch(/\.studio-script-input\s*\{[^}]*min-height:\s*168px/s);
expect(indexRaw).toMatch(/\.shell-narrow\s+\.studio-script-input[^}]*min-height:\s*144px/s);
+2 -2
View File
@@ -35,9 +35,9 @@
"frontend"
],
"devDependencies": {
"concurrently": "^9.2.4",
"concurrently": "^10",
"playwright": "^1.63.0",
"taze": "^19.17.2",
"taze": "^21",
"turbo": "^2.10.12",
"typescript": "^6.0.3",
"wait-on": "^9.1.0"
+13 -49
View File
@@ -18,30 +18,15 @@
// @tauri-apps/cli. All extra args are forwarded untouched.
// ──────────────────────────────────────────────────────────────────────────
import { spawnSync } from "node:child_process";
import { existsSync } from "node:fs";
import { join, delimiter } from "node:path";
import { homedir } from "node:os";
import process from "node:process";
import { DEV_APP_PROCESS_NAME } from "./desktop-common.mjs";
import { launchTauriDev } from "./desktop-dev-launch.mjs";
import { desktopRuntimeReady } from "./desktop-runtime-preflight.mjs";
/** The env's PATH key — Windows uses "Path", others "PATH"; match case-insensitively. */
function pathKeyOf(env) {
return Object.keys(env).find((k) => k.toLowerCase() === "path") ?? "PATH";
}
/** Is `cargo` resolvable via the given env's PATH? Uses a child that searches
* its own PATH (`cmd`/`sh`), which mirrors how the Tauri CLI's Rust resolves
* `cargo` downstream unlike Bun's own launcher resolution, which snapshots
* PATH and would give a false negative after we heal it. */
function cargoResolvable(env) {
const probe =
process.platform === "win32"
? spawnSync("cmd", ["/c", "cargo --version"], { env, stdio: "ignore" })
: spawnSync("sh", ["-c", "command -v cargo"], { env, stdio: "ignore" });
return probe.status === 0;
}
import {
cargoMissingMessage,
healToolchainPath,
healedPathNotes,
} from "./desktop-toolchain-path.mjs";
/**
* Take down a leftover dev app before starting a new one.
@@ -86,35 +71,14 @@ if (!desktopRuntimeReady()) process.exit(1);
killStaleDevApp();
// Start from the real environment; heal a stale PATH into a *copy* (mutating
// process.env doesn't reliably propagate to children under Bun).
const childEnv = { ...process.env };
const key = pathKeyOf(childEnv);
if (!cargoResolvable(childEnv)) {
const cargoBin = join(homedir(), ".cargo", "bin");
const cargoExe = join(cargoBin, process.platform === "win32" ? "cargo.exe" : "cargo");
if (existsSync(cargoExe)) {
childEnv[key] = cargoBin + delimiter + (childEnv[key] ?? "");
console.log(
`[desktop-dev] added '${cargoBin}' to PATH for this run - cargo is installed but wasn't visible to ` +
`this terminal (a stale PATH from before rustup). Open a new terminal to make it permanent.`,
);
} else {
console.error(
[
"",
"❌ `tauri dev` needs Rust/cargo, and none was found.",
"",
" Install the Rust toolchain, then reopen your terminal:",
" Windows: winget install Rust.Rustup",
" macOS/Linux: https://rustup.rs",
"",
" Or download a prebuilt installer from the Releases page (no toolchain needed).",
"",
].join("\n"),
);
process.exit(1);
}
// process.env doesn't reliably propagate to children under Bun). Shared with
// desktop-prod / desktop-fresh so every launcher fixes the same class of
// "installed but not on this terminal's PATH" failure.
const { env: childEnv, added, missing } = healToolchainPath(process.env);
for (const note of healedPathNotes(added, "desktop-dev")) console.log(note);
if (missing.includes("cargo")) {
console.error(cargoMissingMessage("`tauri dev`"));
process.exit(1);
}
// `frontend/dist` is a required bundle resource even under `tauri dev`.
+5
View File
@@ -56,6 +56,7 @@ import {
sanitizedEnv,
tauriBuildArgs,
} from "./desktop-common.mjs";
import { healToolchainPath, healedPathNotes } from "./desktop-toolchain-path.mjs";
// ── macOS only ─────────────────────────────────────────────────────────────
if (process.platform !== "darwin") {
@@ -263,9 +264,13 @@ if (dryRun) {
rmSync(appBundle, { recursive: true, force: true });
// #962: invoke the Tauri CLI via the frontend workspace's `tauri` script,
// not `bunx tauri` (bunx can fetch the unrelated npm `tauri` v1 package).
// Same stale-PATH healing as `bun desktop` / `bun desktop-prod`.
const { env: buildEnv, added } = healToolchainPath(process.env);
for (const note of healedPathNotes(added, "desktop-fresh")) console.log(note);
const res = spawnSync("bun", ["run", "--cwd", "frontend", ...tauriBuildArgs("darwin")], {
cwd: repoRoot,
stdio: "inherit",
env: buildEnv,
});
if (res.status !== 0) {
console.error(`\n❌ Build failed (exit ${res.status ?? `signal ${res.signal}`}).`);
+18 -1
View File
@@ -25,6 +25,11 @@ import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import process from "node:process";
import { desktopRuntimeReady } from "./desktop-runtime-preflight.mjs";
import {
cargoMissingMessage,
healToolchainPath,
healedPathNotes,
} from "./desktop-toolchain-path.mjs";
const scriptDir = dirname(fileURLToPath(import.meta.url));
const shellScript = join(scriptDir, "desktop-prod.sh");
@@ -33,6 +38,17 @@ const isWindows = process.platform === "win32";
if (!desktopRuntimeReady()) process.exit(1);
// A terminal opened before rustup / uv were installed can't see them even
// though a fresh terminal would; heal PATH for the bash child exactly like
// `bun desktop` does, and fail early with the real remedy when Rust is absent
// (the shell script would otherwise die mid-way in `cargo metadata`).
const { env: childEnv, added, missing } = healToolchainPath(process.env);
for (const note of healedPathNotes(added, "desktop-prod")) console.log(note);
if (missing.includes("cargo") && !args.includes("--skip-build")) {
console.error(cargoMissingMessage("`bun desktop-prod`"));
process.exit(1);
}
/** WSL's bash.exe lives under System32 running the script there would
* detect "Linux" and wipe/launch the wrong paths. Never use it. */
function isWslBash(p) {
@@ -115,10 +131,11 @@ if (isWindows) {
// Git Bash understands Windows paths, but forward slashes are safest.
result = spawnSync(bash, [shellScript.replace(/\\/g, "/"), ...args], {
stdio: "inherit",
env: childEnv,
});
} else {
// macOS / Linux: bash is part of the base system.
result = spawnSync("bash", [shellScript, ...args], { stdio: "inherit" });
result = spawnSync("bash", [shellScript, ...args], { stdio: "inherit", env: childEnv });
}
if (result.error) {
+109
View File
@@ -0,0 +1,109 @@
// ──────────────────────────────────────────────────────────────────────────
// desktop-toolchain-path.mjs — heal a stale PATH for the desktop launchers.
//
// Why this exists: every desktop launcher (`bun desktop`, `bun desktop-prod`,
// `bun desktop-fresh`) shells out to `cargo` (via the Tauri CLI) and to `uv`.
// A terminal opened *before* rustup / the uv installer ran keeps a PATH
// snapshot that lacks `~/.cargo/bin` / `~/.local/bin`, so the launcher dies with
// failed to run 'cargo metadata' command … program not found
// even though the tool IS installed and IS on the persisted user PATH — a
// brand-new terminal would find it. `desktop-dev.mjs` healed this for `bun
// desktop` alone; `bun desktop-prod` then failed the exact same way on the
// next command the user typed. Every launcher now shares this one helper.
//
// Pure w.r.t. process state: it never mutates the env it is given (mutating
// process.env doesn't reliably propagate to children under Bun) and every
// probe is injectable so the behavior is unit-tested without a toolchain.
// ──────────────────────────────────────────────────────────────────────────
import { spawnSync } from "node:child_process";
import { existsSync } from "node:fs";
import path from "node:path";
import { homedir } from "node:os";
import process from "node:process";
/** The env's PATH key — Windows uses "Path", others "PATH"; match case-insensitively. */
export function pathKeyOf(env) {
return Object.keys(env).find((k) => k.toLowerCase() === "path") ?? "PATH";
}
/** Tools the launchers need, with the per-user bin dir their installers use
* on every platform (rustup ~/.cargo/bin; astral's uv ~/.local/bin). */
export const TOOLCHAIN_TOOLS = [
{ tool: "cargo", dirParts: [".cargo", "bin"], installer: "rustup" },
{ tool: "uv", dirParts: [".local", "bin"], installer: "the uv installer" },
];
/** Is `tool` resolvable via the given env's PATH? Uses a child that searches
* its own PATH (`cmd`/`sh`), which mirrors how the Tauri CLI's Rust resolves
* `cargo` downstream unlike Bun's own launcher resolution, which snapshots
* PATH and would give a false negative after we heal it. */
export function toolResolvable(tool, env, platform = process.platform) {
const probe =
platform === "win32"
? spawnSync("cmd", ["/c", `${tool} --version`], { env, stdio: "ignore" })
: spawnSync("sh", ["-c", `command -v ${tool}`], { env, stdio: "ignore" });
return probe.status === 0;
}
/**
* Return a *copy* of `env` whose PATH also reaches every installed-but-invisible
* toolchain binary, plus what was added and what is missing outright.
*
* @returns {{ env: Record<string,string|undefined>, added: {tool:string, dir:string}[], missing: string[] }}
*/
export function healToolchainPath(
env,
{
platform = process.platform,
home = homedir(),
exists = existsSync,
resolvable = (tool, e) => toolResolvable(tool, e, platform),
tools = TOOLCHAIN_TOOLS,
} = {},
) {
// Path rules follow the *emulated* platform, not the host's, so the unit
// tests are deterministic on every CI runner.
const P = platform === "win32" ? path.win32 : path.posix;
const healed = { ...env };
const key = pathKeyOf(healed);
const added = [];
const missing = [];
for (const { tool, dirParts } of tools) {
if (resolvable(tool, healed)) continue;
const dir = P.join(home, ...dirParts);
const exe = P.join(dir, platform === "win32" ? `${tool}.exe` : tool);
if (exists(exe)) {
healed[key] = dir + P.delimiter + (healed[key] ?? "");
added.push({ tool, dir });
} else {
missing.push(tool);
}
}
return { env: healed, added, missing };
}
/** One line per healed tool, for the launcher to print so the fix is visible
* and the permanent remedy (a new terminal) is named. */
export function healedPathNotes(added, tag) {
return added.map(
({ tool, dir }) =>
`[${tag}] added '${dir}' to PATH for this run - ${tool} is installed but wasn't visible to ` +
`this terminal (a stale PATH from before it was installed). Open a new terminal to make it permanent.`,
);
}
/** The actionable failure text when Rust is genuinely absent. `command` is
* what needed it (e.g. "`tauri dev`", "`bun desktop-prod`"). */
export function cargoMissingMessage(command) {
return [
"",
`${command} needs Rust/cargo, and none was found.`,
"",
" Install the Rust toolchain, then reopen your terminal:",
" Windows: winget install Rust.Rustup",
" macOS/Linux: https://rustup.rs",
"",
" Or download a prebuilt installer from the Releases page (no toolchain needed).",
"",
].join("\n");
}
+1
View File
@@ -58,6 +58,7 @@ GET /community/sources
GET /community/submit-url
GET /dictation/models
GET /dictation/prefs
GET /dictation/readiness
GET /docs
GET /dub/ass/{job_id}
GET /dub/ass/{job_id}/{filename}
+101
View File
@@ -196,3 +196,104 @@ test('stale-instance cleanup ignores unrelated or empty process names', () => {
assert.equal(isDevAppProcess(undefined), false);
assert.equal(isDevAppProcess(null), false);
});
// ── Stale-PATH healing shared by every desktop launcher ────────────────────
// `bun desktop` healed a pre-rustup terminal's PATH; `bun desktop-prod` did
// not and died with "cargo metadata … program not found" on the very next
// command. The helper is pure and probe-injected so this pins the contract
// for all launchers without a toolchain on the CI box.
import {
healToolchainPath,
healedPathNotes,
cargoMissingMessage,
pathKeyOf,
} from '../../scripts/desktop-toolchain-path.mjs';
const winOpts = (overrides) => ({
platform: 'win32',
home: 'C:\\Users\\dev',
...overrides,
});
test('healToolchainPath prepends installed-but-invisible tool dirs, never mutates input', () => {
const input = { Path: 'C:\\Windows\\System32', HOME: 'x' };
const { env, added, missing } = healToolchainPath(
input,
winOpts({ resolvable: () => false, exists: () => true }),
);
assert.equal(
env.Path,
'C:\\Users\\dev\\.local\\bin;C:\\Users\\dev\\.cargo\\bin;C:\\Windows\\System32',
);
assert.deepEqual(
added.map((a) => a.tool),
['cargo', 'uv'],
);
assert.deepEqual(missing, []);
assert.equal(input.Path, 'C:\\Windows\\System32'); // untouched
assert.equal(env.HOME, 'x'); // other keys carried over
});
test('healToolchainPath is a no-op when every tool already resolves', () => {
const input = { PATH: '/usr/bin' };
const { env, added, missing } = healToolchainPath(input, {
platform: 'darwin',
home: '/Users/dev',
resolvable: () => true,
exists: () => {
throw new Error('must not probe the filesystem when the tool resolves');
},
});
assert.deepEqual(env, input);
assert.deepEqual(added, []);
assert.deepEqual(missing, []);
});
test('healToolchainPath reports tools that are genuinely absent, heals the rest', () => {
const { env, added, missing } = healToolchainPath(
{ PATH: '/usr/bin' },
{
platform: 'linux',
home: '/home/dev',
resolvable: () => false,
exists: (exe) => exe.endsWith('/.local/bin/uv'), // uv installed, cargo not
},
);
assert.equal(env.PATH, '/home/dev/.local/bin:/usr/bin');
assert.deepEqual(added, [{ tool: 'uv', dir: '/home/dev/.local/bin' }]);
assert.deepEqual(missing, ['cargo']);
});
test('healToolchainPath probes the platform-specific executable name', () => {
const probed = [];
healToolchainPath(
{ PATH: '' },
winOpts({
resolvable: () => false,
exists: (exe) => {
probed.push(exe);
return false;
},
}),
);
assert.deepEqual(probed, [
'C:\\Users\\dev\\.cargo\\bin\\cargo.exe',
'C:\\Users\\dev\\.local\\bin\\uv.exe',
]);
});
test('pathKeyOf matches the PATH key case-insensitively and defaults to PATH', () => {
assert.equal(pathKeyOf({ Path: '' }), 'Path');
assert.equal(pathKeyOf({ PATH: '' }), 'PATH');
assert.equal(pathKeyOf({}), 'PATH');
});
test('launcher messages name the tag, the permanent remedy, and the install path', () => {
const [note] = healedPathNotes([{ tool: 'cargo', dir: '/home/dev/.cargo/bin' }], 'desktop-prod');
assert.match(note, /^\[desktop-prod\] added '\/home\/dev\/\.cargo\/bin' to PATH/);
assert.match(note, /Open a new terminal/);
const msg = cargoMissingMessage('`bun desktop-prod`');
assert.match(msg, /`bun desktop-prod` needs Rust\/cargo/);
assert.match(msg, /winget install Rust\.Rustup/);
assert.match(msg, /rustup\.rs/);
});
+23
View File
@@ -287,3 +287,26 @@ def test_capture_preload_ram_guard(monkeypatch):
raise RuntimeError("no vm info")
monkeypatch.setattr(psutil, "virtual_memory", _boom)
assert main._capture_preload_ram_ok()
def test_pause_outlasts_silence_timeout_and_resume_keeps_audio(client, monkeypatch):
from api.routers import capture_ws as cw
monkeypatch.setattr(cw, 'SILENCE_TIMEOUT_S', 0.05)
monkeypatch.setattr(cw, 'PARTIAL_INTERVAL_S', 0.01)
sizes = []
async def final(chunks, **kwargs):
sizes.append(sum(map(len, chunks)))
return {'text': 'kept both parts', 'segments': [], 'language': 'en', 'engine': 'stub'}
monkeypatch.setattr(cw, '_transcribe_buffer_full', final)
with client.websocket_connect('/ws/transcribe') as ws:
ws.send_bytes(_audio_chunk())
ws.send_text('PAUSE')
time.sleep(0.15)
ws.send_text('RESUME')
ws.send_bytes(_audio_chunk())
ws.send_text('EOF')
while ws.receive_json().get('type') != 'final':
pass
assert sizes == [40_000]
+21
View File
@@ -112,3 +112,24 @@ def test_reset_failure_does_not_persist_new_preferences(monkeypatch):
assert getattr(caught.value, "status_code", None) == 503
assert store == {dr.PREF_MODE: "toggle"}
@pytest.mark.parametrize('missing', [None, {'error': 'asr_model_missing', 'recommended': {'repo_id': 'test/model'}}])
def test_readiness_uses_capture_preflight(client, monkeypatch, missing):
from services import asr_backend
calls = []
def probe(**kwargs):
calls.append(kwargs)
return missing
monkeypatch.setattr(asr_backend, 'asr_model_missing_error', probe)
response = client.get('/dictation/readiness')
assert response.status_code == 200
assert response.json() == {'ready': missing is None, 'missing': missing}
assert calls == [{'purpose': 'dictation', 'sherpa_model_id': 'sherpa-whisper-tiny'}]
def test_readiness_honors_recorder_model_override(client, monkeypatch):
from services import asr_backend
calls = []
monkeypatch.setattr(asr_backend, 'asr_model_missing_error', lambda **kw: calls.append(kw))
assert client.get('/dictation/readiness?model_id=sherpa-zipformer-en-20m').status_code == 200
assert calls == [{'purpose': 'dictation', 'sherpa_model_id': 'sherpa-zipformer-en-20m'}]
+35
View File
@@ -460,3 +460,38 @@ def test_capture_backend_falls_back_when_dictation_disabled(monkeypatch):
# cleanup singleton so other tests start clean
ab._capture_backend = None
ab._capture_backend_key = None
def test_sherpa_engine_defaults_to_dictation_model_pref(fake_sherpa, no_download, monkeypatch):
"""The ``sherpa-onnx-asr`` *engine* (dub/batch transcription, and the
no-arg constructor) must load the model the user picked for dictation
not silently fall back to Whisper Tiny while the Voice panel / Engines
menu shows Parakeet selected. Env pin still wins; a demoted or unknown
pref falls through to the catalogue default; ``dictation.enabled`` is
irrelevant to the engine."""
from services import asr_backend as ab
from services import sherpa_dictation as sd
prefs_store = {"dictation.enabled": False, "dictation.model_id": "sherpa-parakeet-tdt-v3"}
from core import prefs as _prefs
monkeypatch.setattr(_prefs, "get", lambda k, d=None: prefs_store.get(k, d))
monkeypatch.delenv("OMNIVOICE_SHERPA_ASR_MODEL", raising=False)
monkeypatch.setattr(sd, "is_demoted", lambda mid: False)
assert ab.sherpa_engine_model_id() == "sherpa-parakeet-tdt-v3"
assert ab.SherpaDictationBackend().spec.id == "sherpa-parakeet-tdt-v3"
assert ab._offline_asr_repo("sherpa-onnx-asr") == sd.get_spec("sherpa-parakeet-tdt-v3").repo_id
# Demoted on this host → default, never the demoted pick.
monkeypatch.setattr(sd, "is_demoted", lambda mid: mid == "sherpa-parakeet-tdt-v3")
assert ab.sherpa_engine_model_id() == sd.DEFAULT_MODEL_ID
# Unknown / stale pref → default.
monkeypatch.setattr(sd, "is_demoted", lambda mid: False)
prefs_store["dictation.model_id"] = "not-a-model"
assert ab.sherpa_engine_model_id() == sd.DEFAULT_MODEL_ID
# Explicit env pin beats the pref.
prefs_store["dictation.model_id"] = "sherpa-parakeet-tdt-v3"
monkeypatch.setenv("OMNIVOICE_SHERPA_ASR_MODEL", "sherpa-zipformer-en-20m")
assert ab.SherpaDictationBackend().spec.id == "sherpa-zipformer-en-20m"