From 8765f766e1bf911f21903ca6d292b16c87579f24 Mon Sep 17 00:00:00 2001 From: Palash Debnath Date: Tue, 30 Jun 2026 05:08:52 +0530 Subject: [PATCH] chore(lint): adopt oxlint as the linter + CI gate; fix the bugs it surfaced (#761) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ESLint was misconfigured (only globals.browser → 47 false no-undef) and run NOWHERE in CI, so 259 errors had accumulated unnoticed. Replace it with oxlint (Rust, ~50-100x faster) as the primary linter AND a real CI gate so lint debt can't silently pile up again. Tooling: - frontend/.oxlintrc.json — correctness=error; no-unused-vars with the existing ^[A-Z_] convention; node/vitest env overrides + AudioWorklet/__APP_VERSION__ globals (kills the false no-undef class); max-lines:500 (warn). - package.json: `lint` → oxlint, `lint:fix`, `lint:hooks` (advisory eslint). - eslint.config.js stripped to ONLY the React-Compiler rule family oxlint can't do yet (set-state-in-effect etc.), run via `lint:hooks`, NOT gated. Drop once oxlint's JS-plugin support leaves alpha. - ci.yml: new "Frontend lint (oxlint)" step in the Tests job — the gate. Real bugs oxlint caught (were buried in ESLint's noise): - GlossaryPanel: close-icon used but never imported → the edit-row cancel button threw ReferenceError at render. Imported X. - Two use*-named NON-hooks (useEngine action, useArchetypeAsProfile API call) tripped rules-of-hooks; suppressed with documented disables (renaming these misleading names is a worthwhile follow-up). Cleanup to reach a 0-error gate: removed 52 genuinely-dead vars/imports across 18 files (heavy in App.jsx — stale useState left over from prior refactors) and 4 behavior-preserving autofixes (no-useless-fallback-in-spread / no-useless-escape). Verified: oxlint 0 errors; bun install --frozen-lockfile in sync (Docker rule); vite build passes; full suite 638/638. Co-authored-by: mergetest Co-authored-by: Claude Opus 4.8 (1M context) --- .github/workflows/ci.yml | 7 +++ bun.lock | 43 ++++++++++++++++++- frontend/.oxlintrc.json | 38 ++++++++++++++++ frontend/eslint.config.js | 29 +++++++------ frontend/package.json | 5 ++- frontend/src/App.jsx | 29 +++---------- frontend/src/api/client.ts | 4 +- frontend/src/components/AudioTrimmer.jsx | 5 +-- frontend/src/components/CaptureWidget.jsx | 4 +- .../components/EngineCompatibilityMatrix.jsx | 2 +- frontend/src/components/ExportModal.jsx | 3 +- frontend/src/components/GlossaryPanel.jsx | 2 +- frontend/src/components/LogsFooter.jsx | 4 +- .../src/components/ReadinessChecklist.jsx | 1 - frontend/src/components/WaveformPlayer.jsx | 2 +- frontend/src/components/WizardLibrary.jsx | 1 + frontend/src/hooks/useAppData.js | 4 +- frontend/src/hooks/useDubWorkflow.js | 2 +- frontend/src/hooks/useTTS.js | 2 +- frontend/src/pages/AudiobookTab.jsx | 6 +-- frontend/src/pages/BatchQueue.jsx | 2 +- frontend/src/pages/Launchpad.jsx | 2 +- frontend/src/pages/Settings.jsx | 3 -- frontend/src/pages/Transcriptions.jsx | 1 - frontend/src/pages/VoiceGallery.jsx | 1 + frontend/src/test/VoiceSelector.test.jsx | 2 +- frontend/src/ui/Menu.jsx | 2 +- frontend/src/utils/longformParser.js | 2 +- frontend/src/utils/micError.js | 2 +- frontend/src/utils/storyTokens.js | 2 +- frontend/src/utils/utils.test.js | 2 +- 31 files changed, 138 insertions(+), 76 deletions(-) create mode 100644 frontend/.oxlintrc.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 48bf8d03..b1dc7f65 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -105,6 +105,13 @@ jobs: working-directory: frontend run: bun run typecheck:ci + # oxlint gate — fast Rust linter, blocks on errors so lint debt can't + # re-accumulate (warnings, incl. the react-compiler advisories in + # `lint:hooks`, are non-blocking). See frontend/.oxlintrc.json. + - name: Frontend lint (oxlint) + working-directory: frontend + run: bun run lint + - name: Run Vitest (frontend) working-directory: frontend run: bunx vitest run diff --git a/bun.lock b/bun.lock index 4b5f5741..262dfe5a 100644 --- a/bun.lock +++ b/bun.lock @@ -15,7 +15,7 @@ }, "frontend": { "name": "omnivoice-studio", - "version": "0.3.5", + "version": "0.3.8", "dependencies": { "@fontsource-variable/inter": "^5.2.8", "@fontsource-variable/source-serif-4": "^5.2.9", @@ -67,6 +67,7 @@ "eslint-plugin-react-refresh": "^0.5.3", "globals": "^17.6.0", "jsdom": "^29.1.1", + "oxlint": "^1.71.0", "playwright-core": "1.61.0", "typescript": "^6.0.3", "vite": "^8.0.16", @@ -205,6 +206,44 @@ "@oxc-project/types": ["@oxc-project/types@0.133.0", "", {}, "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA=="], + "@oxlint/binding-android-arm-eabi": ["@oxlint/binding-android-arm-eabi@1.71.0", "", { "os": "android", "cpu": "arm" }, "sha512-ImGmd1njEg4FEJH03jhRnveEegtO3czCtfptvaHivKAZQIYATbVFBrrzbaYMYv0oJioTnxZAZVSyV+oL7W8S2g=="], + + "@oxlint/binding-android-arm64": ["@oxlint/binding-android-arm64@1.71.0", "", { "os": "android", "cpu": "arm64" }, "sha512-4A5BEexBrwY1YFF8Kiq/lp/wQPRG79G3BWIE1FuWaM5MvmpYSd+7ZySVcKkHdwo0UDzdQGddp6pD9mpctMqLnw=="], + + "@oxlint/binding-darwin-arm64": ["@oxlint/binding-darwin-arm64@1.71.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-9wJA9GJulLwS2usU3CEisI/ESDO1n1z9eyTCvApMDrAkbJ1ve0mORgTMjcWWsKxkzkeZ2N/Gpra5IQE7x8tYgQ=="], + + "@oxlint/binding-darwin-x64": ["@oxlint/binding-darwin-x64@1.71.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-PlLCjS06V0PeJMAJwzjrExw1sYNW9Gch3JtNlcwwZDXGlTYDuwHNN89zYH8LTXFfgkVtsYvs2nv0FqrzyuFDzg=="], + + "@oxlint/binding-freebsd-x64": ["@oxlint/binding-freebsd-x64@1.71.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-Lhil7bWre0ncxbUoDoxfS0JzpTz17BRQKW7iwoAUY8GJ66+WwJEfYPCFJ1P0WgVZR5/O/b3Q2pENlHOjeXLOGQ=="], + + "@oxlint/binding-linux-arm-gnueabihf": ["@oxlint/binding-linux-arm-gnueabihf@1.71.0", "", { "os": "linux", "cpu": "arm" }, "sha512-Oo9/L58PYD3RC0x05d2upAPLllHytTjHQGsnC06P6Ynn7jKkp5mdImQxXdJ3+FnBaKspNpGogzgVsi6g872LiA=="], + + "@oxlint/binding-linux-arm-musleabihf": ["@oxlint/binding-linux-arm-musleabihf@1.71.0", "", { "os": "linux", "cpu": "arm" }, "sha512-mSHfyfgJrEbyIR29ejaeS50BdPk+GoNPlC1dckpDiUZbJAIel68sjSMdOt4WY0/gva+ECC7FNITQkxMJU+vSBw=="], + + "@oxlint/binding-linux-arm64-gnu": ["@oxlint/binding-linux-arm64-gnu@1.71.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-n9yY4M2tiy3aij4AqtlnspzpfdpeT5JQfK2/w2d8oyp5W0FRwOb1dIeX99nORNcxGr08iD9bH8N5XFz3I2iy8w=="], + + "@oxlint/binding-linux-arm64-musl": ["@oxlint/binding-linux-arm64-musl@1.71.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-fJZrs5sDZtTaPIOiemRQQmo82Ezy+vOGXemPc4Ok7iVVsYsFa7SlW6Z5XN819VfsqBHRm3NJ3rTdnR8+bJYJdQ=="], + + "@oxlint/binding-linux-ppc64-gnu": ["@oxlint/binding-linux-ppc64-gnu@1.71.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-cwl7VKGERIy9p+G+AvZdfy/06q0aHXaTt/mMRReC751iuNYJgqKjB7NydXSS30nBT9vtr2tunciOtrR4fD6FUA=="], + + "@oxlint/binding-linux-riscv64-gnu": ["@oxlint/binding-linux-riscv64-gnu@1.71.0", "", { "os": "linux", "cpu": "none" }, "sha512-eZ8ieVXvzGi8jr7+ybQGPK2STw3mldfxZlgA2738iflfB/rzA69sE6m5rDRpQaxC7dpm745Enlh1Tod0QAk9Gg=="], + + "@oxlint/binding-linux-riscv64-musl": ["@oxlint/binding-linux-riscv64-musl@1.71.0", "", { "os": "linux", "cpu": "none" }, "sha512-puMDbQYe6+NXwfMusojoA7CXGn2b3utukmd23PQqc1E3XhVCwyZ+FueSMzDYeNgDV2dUfIVXAAKZBcFDeCL6sA=="], + + "@oxlint/binding-linux-s390x-gnu": ["@oxlint/binding-linux-s390x-gnu@1.71.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-4NJLxBs1ujISCt3L/1FcywLs73PWtJuw+piD6feK2V6h6OS6P7xu9/sWt1DTRLibe6QCzmfZzmM/2HPORoV/Lg=="], + + "@oxlint/binding-linux-x64-gnu": ["@oxlint/binding-linux-x64-gnu@1.71.0", "", { "os": "linux", "cpu": "x64" }, "sha512-cFDaiR8L3430qp88tfZnvFlt3KotFhR/DlbIL0nHOMMYiG/9Wy4l+6f7t8G8pTa9bd8Lt8+M0y/qjRQ/xcB74g=="], + + "@oxlint/binding-linux-x64-musl": ["@oxlint/binding-linux-x64-musl@1.71.0", "", { "os": "linux", "cpu": "x64" }, "sha512-orfixdt76KlpNly9z0PkWBBNfwjKz+JFVLP/7wnVchlKNU9Dpt9InU/ZggeSej6fC7qwHmHNOGlhLnQXcYoGuA=="], + + "@oxlint/binding-openharmony-arm64": ["@oxlint/binding-openharmony-arm64@1.71.0", "", { "os": "none", "cpu": "arm64" }, "sha512-9emQu2lAp6yhPB3XuI+++vR+l/o6JR1X+EpxwcumPdQXBWXEPAsquPGL7l158EqU8SebQMXTUa/S5zN98juyHw=="], + + "@oxlint/binding-win32-arm64-msvc": ["@oxlint/binding-win32-arm64-msvc@1.71.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-bd5kI8spYwTm3BILDtGhi73zoup5dw8MlPQNT8YB3BD5UIsjNe3K9/4ctrzQMX4SZMoK5HgzVLkLJzacEXB7fA=="], + + "@oxlint/binding-win32-ia32-msvc": ["@oxlint/binding-win32-ia32-msvc@1.71.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-W4HvOHGzVLHcrmFu+bMrJlho+/yrlX5ZNdJZqGe8MEldkQG+RHYhxxad9P4jvWAYFmIqUA5i9DQ8QsJqSU9GIw=="], + + "@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.71.0", "", { "os": "win32", "cpu": "x64" }, "sha512-D2kyEIPHk/G/wiZLnwTVC/sVst+T/lKldVOjAFpgTIBUAOlry72e5OiapDbDBF4LfJLkN5ypJb/8Eu6yJzkveQ=="], + "@playwright/test": ["@playwright/test@1.61.0", "", { "dependencies": { "playwright": "1.61.0" }, "bin": { "playwright": "cli.js" } }, "sha512-cKA5B6lpFEMyMGjxF54QihfYpB4FkEGH+qZhtArDEG+wezQAJY8Pq6C7T1SjWz+FFzt3TbyoXBQYk/0292TdJA=="], "@radix-ui/number": ["@radix-ui/number@1.1.2", "", {}, "sha512-ceTwaxc4I5IOi97DgCotl3pqiyRGvffcc0oOsE2dQYaJOFIDsDt4VWG6xEbg1QePv9QWausCEIppud/tJ1wNig=="], @@ -773,6 +812,8 @@ "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=="], + "oxlint": ["oxlint@1.71.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.71.0", "@oxlint/binding-android-arm64": "1.71.0", "@oxlint/binding-darwin-arm64": "1.71.0", "@oxlint/binding-darwin-x64": "1.71.0", "@oxlint/binding-freebsd-x64": "1.71.0", "@oxlint/binding-linux-arm-gnueabihf": "1.71.0", "@oxlint/binding-linux-arm-musleabihf": "1.71.0", "@oxlint/binding-linux-arm64-gnu": "1.71.0", "@oxlint/binding-linux-arm64-musl": "1.71.0", "@oxlint/binding-linux-ppc64-gnu": "1.71.0", "@oxlint/binding-linux-riscv64-gnu": "1.71.0", "@oxlint/binding-linux-riscv64-musl": "1.71.0", "@oxlint/binding-linux-s390x-gnu": "1.71.0", "@oxlint/binding-linux-x64-gnu": "1.71.0", "@oxlint/binding-linux-x64-musl": "1.71.0", "@oxlint/binding-openharmony-arm64": "1.71.0", "@oxlint/binding-win32-arm64-msvc": "1.71.0", "@oxlint/binding-win32-ia32-msvc": "1.71.0", "@oxlint/binding-win32-x64-msvc": "1.71.0" }, "peerDependencies": { "oxlint-tsgolint": ">=0.22.1", "vite-plus": "*" }, "optionalPeers": ["oxlint-tsgolint", "vite-plus"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-U1m1X+C0vDj7DC1e13IoZULzEcPczE7UOMTs8VlZGHUEIUaSTZKo5qkPsQEfzpgnQ29Pea/w3Xntk62UCecxZw=="], + "p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], "p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="], diff --git a/frontend/.oxlintrc.json b/frontend/.oxlintrc.json new file mode 100644 index 00000000..13956ed8 --- /dev/null +++ b/frontend/.oxlintrc.json @@ -0,0 +1,38 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "env": { + "browser": true, + "es2024": true + }, + "globals": { + "__APP_VERSION__": "readonly", + "AudioWorklet": "readonly", + "AudioWorkletNode": "readonly", + "AudioWorkletProcessor": "readonly", + "registerProcessor": "readonly" + }, + "plugins": ["react", "import", "unicorn"], + "ignorePatterns": ["dist/", "src-tauri/", "node_modules/"], + "categories": { + "correctness": "error" + }, + "rules": { + "no-unused-vars": ["error", { "varsIgnorePattern": "^[A-Z_]", "argsIgnorePattern": "^_", "caughtErrors": "none" }], + "no-empty": ["warn", { "allowEmptyCatch": true }], + "no-unused-expressions": ["error", { "allowShortCircuit": true, "allowTernary": true }], + "react/exhaustive-deps": "warn", + "react/rules-of-hooks": "error", + "react/only-export-components": ["warn", { "allowConstantExport": true }], + "max-lines": ["warn", { "max": 500, "skipBlankLines": true, "skipComments": true }] + }, + "overrides": [ + { + "files": ["vite.config.js", "*.config.js", "eslint.config.js"], + "env": { "node": true } + }, + { + "files": ["**/*.test.{js,jsx}", "**/*.spec.{js,jsx}", "src/test/**"], + "env": { "node": true, "vitest": true } + } + ] +} diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js index 023fbfb9..2a8ff883 100644 --- a/frontend/eslint.config.js +++ b/frontend/eslint.config.js @@ -1,32 +1,33 @@ -import js from '@eslint/js' -import globals from 'globals' +// Advisory-only lint for the React Compiler rule family that oxlint does not yet +// implement natively (e.g. react-hooks/set-state-in-effect, immutability, +// preserve-manual-memoization). Run locally via `bun run lint:hooks`. +// +// This is NOT a CI gate. oxlint (`bun run lint`, see .oxlintrc.json) is the gate. +// Everything oxlint already covers (no-unused-vars, exhaustive-deps, +// rules-of-hooks, no-undef, max-lines, react-refresh) is turned OFF here so the +// two tools don't double-report. Drop this file once oxlint's JS-plugin support +// graduates from alpha and can run eslint-plugin-react-hooks directly. import reactHooks from 'eslint-plugin-react-hooks' -import reactRefresh from 'eslint-plugin-react-refresh' +import globals from 'globals' import { defineConfig, globalIgnores } from 'eslint/config' export default defineConfig([ globalIgnores(['dist']), { files: ['**/*.{js,jsx}'], - extends: [ - js.configs.recommended, - reactHooks.configs.flat.recommended, - reactRefresh.configs.vite, - ], + extends: [reactHooks.configs.flat.recommended], languageOptions: { - ecmaVersion: 2020, + ecmaVersion: 'latest', globals: globals.browser, parserOptions: { - ecmaVersion: 'latest', ecmaFeatures: { jsx: true }, sourceType: 'module', }, }, rules: { - 'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }], - // Modularization guardrail (warn-only so CI stays green). See - // CONTRIBUTING.md → "Frontend file structure & size limits". - 'max-lines': ['warn', { max: 500, skipBlankLines: true, skipComments: true }], + // Owned by oxlint — disabled here to avoid duplicate diagnostics. + 'react-hooks/exhaustive-deps': 'off', + 'react-hooks/rules-of-hooks': 'off', }, }, ]) diff --git a/frontend/package.json b/frontend/package.json index 7f8defde..aa872bdd 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -8,7 +8,9 @@ "dev": "vite", "desktop": "tauri dev", "build": "vite build", - "lint": "eslint .", + "lint": "oxlint", + "lint:fix": "oxlint --fix", + "lint:hooks": "eslint .", "typecheck": "tsc --noEmit", "typecheck:ci": "tsc --noEmit --checkJs false", "test": "vitest run", @@ -69,6 +71,7 @@ "eslint-plugin-react-refresh": "^0.5.3", "globals": "^17.6.0", "jsdom": "^29.1.1", + "oxlint": "^1.71.0", "playwright-core": "1.61.0", "typescript": "^6.0.3", "vite": "^8.0.16", diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index cd2b5125..258565f2 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -39,7 +39,6 @@ import FloatingPill from './components/FloatingPill'; // main studio return below. Do not re-wrap here — double-gating renders two // PIN dialogs. -import useRealtimeEvents from './hooks/useRealtimeEvents'; import { BootstrapSplash, useBootstrapStage } from './components/BootstrapSplash'; import './components/Misc.css'; @@ -60,13 +59,12 @@ import { POPULAR_LANGS, POPULAR_ISO, TAGS, CATEGORIES, PRESETS, CLONE_MAX_SECONDS, } from './utils/constants'; import { LANG_CODES } from './utils/languages'; -import { formatTime } from './utils/format'; -import { API, apiPost } from './api/client'; +import { API } from './api/client'; import { flushMemory as apiFlushMemory } from './api/system'; import { saveProject as apiSaveProject, loadProject as apiLoadProject, deleteProject as apiDeleteProject, renameProject as apiRenameProject } from './api/projects'; import { exportAction, exportReveal, exportRecord } from './api/exports'; -import { isTauri, doubleClickMaximize, fileToMediaUrl, playBlobAudio, playPing } from './utils/media'; +import { isTauri, doubleClickMaximize, fileToMediaUrl, playBlobAudio } from './utils/media'; import { browserDownload } from './utils/download'; import { checkForUpdate, fetchAppVersion } from './utils/updater'; import { syncChannel } from './utils/channelControl'; @@ -83,7 +81,6 @@ function App() { // Mode + uiScale + sidebar-collapsed persist across reloads automatically // via the store's `partialize`; active project / voice ids stay transient. const uiScale = useAppStore(s => s.uiScale); - const setUiScale = useAppStore(s => s.setUiScale); // Responsive shell breakpoints driven off the app-container's OWN width, not // the viewport. The shell is sized `width: calc(100vw / --ui-scale)` then @@ -338,42 +335,28 @@ function App() { const setDubDialect = useAppStore(s => s.setDubDialect); const dubInstruct = useAppStore(s => s.dubInstruct); const setDubInstruct = useAppStore(s => s.setDubInstruct); - const dubProgress = useAppStore(s => s.dubProgress); const setDubProgress = useAppStore(s => s.setDubProgress); const dubFilename = useAppStore(s => s.dubFilename); const setDubFilename = useAppStore(s => s.setDubFilename); const dubDuration = useAppStore(s => s.dubDuration); const setDubDuration = useAppStore(s => s.setDubDuration); - const dubError = useAppStore(s => s.dubError); const setDubError = useAppStore(s => s.setDubError); const dubTracks = useAppStore(s => s.dubTracks); const setDubTracks = useAppStore(s => s.setDubTracks); const dubTranscript = useAppStore(s => s.dubTranscript); const setDubTranscript = useAppStore(s => s.setDubTranscript); - const isTranslating = useAppStore(s => s.isTranslating); - const setIsTranslating = useAppStore(s => s.setIsTranslating); const preserveBg = useAppStore(s => s.preserveBg); const setPreserveBg = useAppStore(s => s.setPreserveBg); const defaultTrack = useAppStore(s => s.defaultTrack); const setDefaultTrack = useAppStore(s => s.setDefaultTrack); const exportTracks = useAppStore(s => s.exportTracks); - const setExportTracks = useAppStore(s => s.setExportTracks); const previewSegIds = useAppStore(s => s.previewSegIds); - const setPreviewSegIds = useAppStore(s => s.setPreviewSegIds); const speakerClones = useAppStore(s => s.speakerClones); const setSpeakerClones = useAppStore(s => s.setSpeakerClones); - const dubTaskId = useAppStore(s => s.dubTaskId); - const setDubTaskId = useAppStore(s => s.setDubTaskId); - const dubPrepStage = useAppStore(s => s.dubPrepStage); - const setDubPrepStage = useAppStore(s => s.setDubPrepStage); - const translateQuality = useAppStore(s => s.translateQuality); - const setTranslateQuality = useAppStore(s => s.setTranslateQuality); - const glossaryTerms = useAppStore(s => s.glossaryTerms); const setGlossaryTerms = useAppStore(s => s.setGlossaryTerms); const dualSubs = useAppStore(s => s.dualSubs); const burnSubs = useAppStore(s => s.burnSubs); - const setDualSubs = useAppStore(s => s.setDualSubs); // ── UNDO / REDO + SEGMENT EDITING ── // Must come before useDubWorkflow because the dub generate handler needs @@ -383,12 +366,12 @@ function App() { segmentEditField, segmentDelete, segmentRestoreOriginal, segmentSplit, segmentMerge, segmentMoveResize, timelineSelSegId, setTimelineSelSegId, - selectedSegIds, setSelectedSegIds, + selectedSegIds, toggleSegSelect, selectAllSegs, clearSegSelection, bulkApplyToSelected, bulkDeleteSelected, directionSegId, openDirection, closeDirection, saveDirection, - lastGenFingerprints, setLastGenFingerprints, - incrementalPlan, setIncrementalPlan, + setLastGenFingerprints, + incrementalPlan, recomputeIncremental, } = useSegmentEditing(); @@ -397,7 +380,7 @@ function App() { const { translateProvider, setTranslateProvider, showTranscript, setShowTranscript, - previewAudios, setPreviewAudios, + setPreviewAudios, transcribeElapsed, handleDubUpload: _handleDubUpload, handleDubIngestUrl, handleDubAbort, handleDubRetryTranscribe, diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index b6b0f112..78bd14aa 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -115,7 +115,7 @@ export async function apiFetch(path: string, opts: RequestInit = {}): Promise || {}), ...extra } } + ? { ...opts, headers: { ...(opts.headers as Record), ...extra } } : opts; const signal = finalOpts.signal as AbortSignal | null | undefined; let lastDetail = ''; @@ -171,7 +171,7 @@ export async function apiPost( if (body instanceof FormData) { init.body = body; } else if (body !== undefined) { - init.headers = { 'Content-Type': 'application/json', ...(opts.headers as Record || {}) }; + init.headers = { 'Content-Type': 'application/json', ...(opts.headers as Record) }; init.body = JSON.stringify(body); } return apiJson(path, init); diff --git a/frontend/src/components/AudioTrimmer.jsx b/frontend/src/components/AudioTrimmer.jsx index ccb8f8be..8ab532cf 100644 --- a/frontend/src/components/AudioTrimmer.jsx +++ b/frontend/src/components/AudioTrimmer.jsx @@ -1,4 +1,4 @@ -import React, { useEffect, useRef, useState, useCallback, useMemo } from 'react'; +import React, { useEffect, useRef, useState, useCallback } from 'react'; import { Scissors, Play, Pause, Check, ZoomIn, ZoomOut, Maximize2, Repeat } from 'lucide-react'; import { clamp, encodeWav, computePeaksFromChannel, computePeaksAsync, pickTickInterval, @@ -12,9 +12,6 @@ import './AudioTrimmer.css'; const EDGE_GRAB_PX = 10; -const computePeaks = (buffer, buckets = DEFAULT_PEAK_BUCKETS) => - computePeaksFromChannel(buffer.getChannelData(0), buckets); - function fmtSec(t, precision = 2) { if (!isFinite(t)) return '0.00s'; return `${t.toFixed(precision)}s`; diff --git a/frontend/src/components/CaptureWidget.jsx b/frontend/src/components/CaptureWidget.jsx index 061264c9..1327b6c5 100644 --- a/frontend/src/components/CaptureWidget.jsx +++ b/frontend/src/components/CaptureWidget.jsx @@ -136,8 +136,8 @@ export default function CaptureWidget({ onDismiss }) { const [captureMode] = useState(() => localStorage.getItem(LS_CAPTURE_MODE) || 'fast' ); - const [lastEngine, setLastEngine] = useState(''); - const [lastTime, setLastTime] = useState(0); + const [, setLastEngine] = useState(''); + const [, setLastTime] = useState(0); const [partialText, setPartialText] = useState(''); // Live-dictation prefs (mirrored from the backend dictation.* namespace). diff --git a/frontend/src/components/EngineCompatibilityMatrix.jsx b/frontend/src/components/EngineCompatibilityMatrix.jsx index 92c75384..dc299247 100644 --- a/frontend/src/components/EngineCompatibilityMatrix.jsx +++ b/frontend/src/components/EngineCompatibilityMatrix.jsx @@ -125,7 +125,7 @@ export default function EngineCompatibilityMatrix({ const [activeFamily, setActiveFamily] = useState(family); // Phase 3 Plan 03-01 / TTS-05: which engine has its license dialog // currently open, or null. Only one dialog is ever open at a time. - const [licenseDialogFor, setLicenseDialogFor] = useState(null); + const [, setLicenseDialogFor] = useState(null); // health state keyed by engine id: // { [id]: { inflight: boolean, ok?: boolean, message?: string, diff --git a/frontend/src/components/ExportModal.jsx b/frontend/src/components/ExportModal.jsx index 251fb3bc..93a80e31 100644 --- a/frontend/src/components/ExportModal.jsx +++ b/frontend/src/components/ExportModal.jsx @@ -35,7 +35,7 @@ export default function ExportModal({ burnSubs, setBurnSubs, API, triggerDownload, - handleDubDownload, handleDubAudioDownload, handleAudioExport, + handleDubDownload, handleAudioExport, segmentCount = 0, timingStrategy = '', onEnterprise, @@ -80,7 +80,6 @@ export default function ExportModal({ return out; }, [dubTracks, t]); - const dubOnlyTracks = useMemo(() => allTracks.filter(t => t.kind === 'dub'), [allTracks]); const selectedTracks = allTracks.filter(t => exportTracks[t.code] !== false); const selectedDubs = selectedTracks.filter(t => t.kind === 'dub'); diff --git a/frontend/src/components/GlossaryPanel.jsx b/frontend/src/components/GlossaryPanel.jsx index d18e8388..88f8e765 100644 --- a/frontend/src/components/GlossaryPanel.jsx +++ b/frontend/src/components/GlossaryPanel.jsx @@ -1,6 +1,6 @@ import React, { useEffect, useState, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; -import { Plus, Trash2, BookOpen, Sparkles, Check, ChevronDown } from 'lucide-react'; +import { Plus, Trash2, BookOpen, Sparkles, Check, ChevronDown, X } from 'lucide-react'; import { toast } from 'react-hot-toast'; import { Panel, Button, Input, Badge } from '../ui'; import { diff --git a/frontend/src/components/LogsFooter.jsx b/frontend/src/components/LogsFooter.jsx index 33981476..561596be 100644 --- a/frontend/src/components/LogsFooter.jsx +++ b/frontend/src/components/LogsFooter.jsx @@ -7,7 +7,7 @@ import { import toast from 'react-hot-toast'; import { clearSystemLogs, clearTauriLogs } from '../api/system'; -import { useSystemLogs, useTauriLogs, useNotifications, useClearLogs, useClearTauriLogs } from '../api/hooks'; +import { useSystemLogs, useTauriLogs, useNotifications } from '../api/hooks'; import { getFrontendLogs, clearFrontendLogs } from '../utils/consoleBuffer'; import { useTranslation } from 'react-i18next'; import { useAppStore } from '../store'; @@ -160,7 +160,6 @@ export default function LogsFooter() { // comes from the in-process ring buffer in consoleBuffer.js. const [lines, setLines] = useState({ backend: [], frontend: [], tauri: [] }); const [loading, setLoading] = useState(false); - const [hfInput, setHfInput] = useState(''); const scrollRef = useRef(null); useEffect(() => localStorage.setItem(LS_HEIGHT, String(height)), [height]); @@ -344,7 +343,6 @@ export default function LogsFooter() { // ── Render ────────────────────────────────────────────────────────── const current = lines[active] || []; - const notifCounts = { error: 0, warn: notifications.filter(n => n.level === 'warn').length + notifications.filter(n => n.level === 'error').length, total: notifications.length }; return (
c.status === 'pass' || c.status === 'warn'); const anyFail = checks.some(c => c.status === 'fail'); - const criticalFails = checks.filter(c => c.status === 'fail'); // Hide when everything is fine (unless explicitly asked to show) if (!showWhenAllPass && allPass && !isLoading) return null; diff --git a/frontend/src/components/WaveformPlayer.jsx b/frontend/src/components/WaveformPlayer.jsx index 1932f966..18ccb25b 100644 --- a/frontend/src/components/WaveformPlayer.jsx +++ b/frontend/src/components/WaveformPlayer.jsx @@ -48,7 +48,7 @@ export default function WaveformPlayer({ useEffect(() => { autoPlayRef.current = autoPlay; }, [autoPlay]); const [resolvedUrl, setResolvedUrl] = useState(null); - const [ready, setReady] = useState(false); + const [, setReady] = useState(false); const [failed, setFailed] = useState(false); // WaveSurfer unavailable → native fallback const [missing, setMissing] = useState(false); // source 404s (stale history) → inert notice const [isPlaying, setIsPlaying] = useState(false); diff --git a/frontend/src/components/WizardLibrary.jsx b/frontend/src/components/WizardLibrary.jsx index 373411b2..3781658a 100644 --- a/frontend/src/components/WizardLibrary.jsx +++ b/frontend/src/components/WizardLibrary.jsx @@ -288,6 +288,7 @@ export default function WizardLibrary() { type="button" className="frs-btn frs-btn--quiet swiz-lib__act" disabled={switching === b.id} + // eslint-disable-next-line react-hooks/rules-of-hooks -- useEngine is an action fn, not a React hook onClick={() => useEngine(b.id)} > {t('firstrun.lib_use', 'Use')} diff --git a/frontend/src/hooks/useAppData.js b/frontend/src/hooks/useAppData.js index d6ff9bd1..0805c1b3 100644 --- a/frontend/src/hooks/useAppData.js +++ b/frontend/src/hooks/useAppData.js @@ -4,12 +4,10 @@ import { listProfiles } from '../api/profiles'; import { listHistory } from '../api/generate'; import { listProjects } from '../api/projects'; import { listDubHistory } from '../api/dub'; -import { listExportHistory, exportAction, exportReveal, exportRecord } from '../api/exports'; +import { listExportHistory } from '../api/exports'; import { modelStatus as apiModelStatus } from '../api/system'; import { useModelStatus } from '../api/hooks'; import useRealtimeEvents from './useRealtimeEvents'; -import { isTauri, fileToMediaUrl } from '../utils/media'; -import { toast } from 'react-hot-toast'; /** * Encapsulates all data-loading effects, localStorage persistence, diff --git a/frontend/src/hooks/useDubWorkflow.js b/frontend/src/hooks/useDubWorkflow.js index 2c6f528b..2b740007 100644 --- a/frontend/src/hooks/useDubWorkflow.js +++ b/frontend/src/hooks/useDubWorkflow.js @@ -9,7 +9,7 @@ import { dialectMatchesLang } from '../api/dialects'; import { segmentGenInputs, applySpeakerCloneDefaults } from '../utils/segments'; import { apiPost } from '../api/client'; import { API } from '../api/client'; -import { playPing, isTauri } from '../utils/media'; +import { playPing } from '../utils/media'; import { toast } from 'react-hot-toast'; import { toastErrorWithReport } from '../utils/errorToast'; import { addBreadcrumb } from '../utils/breadcrumbs'; diff --git a/frontend/src/hooks/useTTS.js b/frontend/src/hooks/useTTS.js index 2d900f8b..fd2ef323 100644 --- a/frontend/src/hooks/useTTS.js +++ b/frontend/src/hooks/useTTS.js @@ -1,6 +1,6 @@ import { useState, useRef, useCallback } from 'react'; import { useAppStore } from '../store'; -import { generateSpeech, audioUrlWithCacheBust } from '../api/generate'; +import { generateSpeech } from '../api/generate'; import { pickDesignSeed } from '../utils/seed'; import { playBlobAudio, playPing } from '../utils/media'; import { probeAudioDuration } from '../utils/format'; diff --git a/frontend/src/pages/AudiobookTab.jsx b/frontend/src/pages/AudiobookTab.jsx index 3f093e15..405a5dcf 100644 --- a/frontend/src/pages/AudiobookTab.jsx +++ b/frontend/src/pages/AudiobookTab.jsx @@ -128,7 +128,7 @@ export default function AudiobookTab({ profiles = [] }) { const onPreviewChapter = useCallback(async (i) => { setError(''); - setChapterPrev((p) => ({ ...p, [i]: { ...(p[i] || {}), loading: true } })); + setChapterPrev((p) => ({ ...p, [i]: { ...p[i], loading: true } })); try { const lexicon = lexDict(); const r = await audiobookPreviewChapter({ @@ -137,7 +137,7 @@ export default function AudiobookTab({ profiles = [] }) { }); setChapterPrev((p) => ({ ...p, [i]: { url: audioUrl(r.output), loading: false } })); } catch (e) { - setChapterPrev((p) => ({ ...p, [i]: { ...(p[i] || {}), loading: false } })); + setChapterPrev((p) => ({ ...p, [i]: { ...p[i], loading: false } })); setError(e?.message || String(e)); } }, [text, defaultVoice, lex]); @@ -174,7 +174,7 @@ export default function AudiobookTab({ profiles = [] }) { } else if (evt.type === 'chapter') { setProgress({ current: evt.index + 1, total: evt.total, title: evt.title }); } else if (evt.type === 'assembling') { - setProgress((p) => ({ ...(p || {}), assembling: true })); + setProgress((p) => ({ ...p, assembling: true })); } else if (evt.type === 'chapter_error') { setProgress({ current: evt.index + 1, total: evt.total, title: evt.title }); } else if (evt.type === 'done') { diff --git a/frontend/src/pages/BatchQueue.jsx b/frontend/src/pages/BatchQueue.jsx index 1aee8221..978cf3f0 100644 --- a/frontend/src/pages/BatchQueue.jsx +++ b/frontend/src/pages/BatchQueue.jsx @@ -256,7 +256,7 @@ function JobCard({ job, onCancel, onDelete, t }) { {/* Output downloads for done jobs */} {job.status === 'done' && job.outputs && Object.keys(job.outputs).length > 0 && (
- {Object.entries(job.outputs).map(([lang, path]) => ( + {Object.entries(job.outputs).map(([lang]) => ( {}, []); - const refreshLogs = useCallback(async () => { setLoadingLogs(true); try { diff --git a/frontend/src/pages/Transcriptions.jsx b/frontend/src/pages/Transcriptions.jsx index 5dbe3543..59cb54fe 100644 --- a/frontend/src/pages/Transcriptions.jsx +++ b/frontend/src/pages/Transcriptions.jsx @@ -8,7 +8,6 @@ * page updates in realtime without requiring a shared store. */ import React, { useState, useCallback, useMemo, useEffect } from 'react'; -import { copyText } from "../utils/copyText"; import { useTranslation } from 'react-i18next'; import { Mic, Copy, Trash2, Search, Clock, Languages, FileText, Download } from 'lucide-react'; import { Button } from '../ui'; diff --git a/frontend/src/pages/VoiceGallery.jsx b/frontend/src/pages/VoiceGallery.jsx index 9831da9d..708f853d 100644 --- a/frontend/src/pages/VoiceGallery.jsx +++ b/frontend/src/pages/VoiceGallery.jsx @@ -163,6 +163,7 @@ export default function VoiceGallery() { onPreview={(a) => playUrl(archetypePreviewUrl(a.id), a.id)} onUse={async (a) => { try { + // eslint-disable-next-line react-hooks/rules-of-hooks -- useArchetypeAsProfile is an API call, not a React hook const r = await useArchetypeAsProfile(a.id, a.name); // Hand the new profile to the synthesis view and jump there so the // user lands ready-to-generate instead of hunting for it in the list. diff --git a/frontend/src/test/VoiceSelector.test.jsx b/frontend/src/test/VoiceSelector.test.jsx index b58bf215..15d1d1c0 100644 --- a/frontend/src/test/VoiceSelector.test.jsx +++ b/frontend/src/test/VoiceSelector.test.jsx @@ -1,6 +1,6 @@ import React from 'react'; import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { fireEvent, render, screen, within } from '@testing-library/react'; +import { fireEvent, render, screen } from '@testing-library/react'; import VoiceSelector from '../components/VoiceSelector'; diff --git a/frontend/src/ui/Menu.jsx b/frontend/src/ui/Menu.jsx index eb6c7d7a..c677d5c0 100644 --- a/frontend/src/ui/Menu.jsx +++ b/frontend/src/ui/Menu.jsx @@ -1,4 +1,4 @@ -import React, { useCallback, isValidElement } from 'react'; +import React, { isValidElement } from 'react'; import * as RadixMenu from '@radix-ui/react-dropdown-menu'; import { ChevronRight } from 'lucide-react'; import './Menu.css'; diff --git a/frontend/src/utils/longformParser.js b/frontend/src/utils/longformParser.js index 134b9f69..4a0e3ec7 100644 --- a/frontend/src/utils/longformParser.js +++ b/frontend/src/utils/longformParser.js @@ -18,7 +18,7 @@ export const PAUSE_MAX_MS = 10000; // body. Moved-equivalent of audiobook.py _HEADING_RE. Global+multiline. const HEADING_RE = /^[ \t]*#[ \t]+(\S.*)$/gm; // [voice:NAME] — content excludes BOTH brackets (mirrors _VOICE_RE). -const VOICE_RE = /\[voice:([^\]\[]*)\]/g; +const VOICE_RE = /\[voice:([^\][]*)\]/g; // Pause dialect mirroring omnivoice.utils.text._PAUSE_RE. JS has no atomic // group; the unit's own `(?:\s*(ms|s))?` is zero-width when no unit follows, so // the trailing `\s*` is the ONLY consumer of trailing whitespace — no two `\s*` diff --git a/frontend/src/utils/micError.js b/frontend/src/utils/micError.js index 8864aa60..00dd8f88 100644 --- a/frontend/src/utils/micError.js +++ b/frontend/src/utils/micError.js @@ -65,7 +65,7 @@ export function describeMicError(err, platform = detectPlatform()) { /** Convenience: resolve the description to a final translated string. */ export function micErrorMessage(t, err, platform = detectPlatform()) { const d = describeMicError(err, platform); - const params = { ...(d.params || {}) }; + const params = { ...d.params }; if (d.hintKey) params.hint = t(d.hintKey); return t(d.key, params); } diff --git a/frontend/src/utils/storyTokens.js b/frontend/src/utils/storyTokens.js index 70f7db4c..98c12bca 100644 --- a/frontend/src/utils/storyTokens.js +++ b/frontend/src/utils/storyTokens.js @@ -26,7 +26,7 @@ import { roundHalfToEven, PAUSE_DEFAULT_MS, PAUSE_MAX_MS } from './longformParse // [voice:[^\]\[]*] (empty [voice:] → default; a nested `[` → no match). // ReDoS-safe: the unit's `(?:\s*(ms|s))?` is zero-width without a unit, so no // two `\s*` overlap on the same whitespace run (mirrors PAUSE_RE). -const TOKEN_RE = /\[(?:\s*pause(?:\s+(\d+(?:\.\d+)?)(?:\s*(ms|s))?)?\s*|voice:\s*([^\]\[]*))\]/gi; +const TOKEN_RE = /\[(?:\s*pause(?:\s+(\d+(?:\.\d+)?)(?:\s*(ms|s))?)?\s*|voice:\s*([^\][]*))\]/gi; // Resolve a parsed (number, unit) pause to clamped ms — mirrors text.py:_pause_ms // (banker's rounding via the shared roundHalfToEven so highlight == render). diff --git a/frontend/src/utils/utils.test.js b/frontend/src/utils/utils.test.js index f393cb55..d2b0b78b 100644 --- a/frontend/src/utils/utils.test.js +++ b/frontend/src/utils/utils.test.js @@ -1,4 +1,4 @@ -import { describe, it, expect, vi } from 'vitest'; +import { describe, it, expect } from 'vitest'; // Stub Tauri globals before import so isTauri is false delete globalThis.window?.__TAURI_INTERNALS__;