diff --git a/.agents/rules/frontend.md b/.agents/rules/frontend.md index 8f90f47a6..d9ca47b36 100644 --- a/.agents/rules/frontend.md +++ b/.agents/rules/frontend.md @@ -15,5 +15,11 @@ Follow `src/AGENTS.md` and `docs/design-principles.md`. - Keep high-frequency updates from rerendering unrelated subtrees; add a focused regression test when changing fan-out. - Add user-visible text to the language files and use existing i18n patterns. +- Keep imports downward-only per + [ADR 0027](../../docs/adr/0027-frontend-layer-boundaries-and-state-ownership.md): + no cross-feature imports, no `../`, and atoms only in `store/` modules. + Pending exceptions in `.config/biome/frontend-boundaries.jsonc` only + shrink. Prove any edit to that file with canaries, because a mistyped scope + silently disables the rule. - Verify visual and interaction changes in rendered desktop and compact views. Inspect E2E screenshots/artifacts before weakening selectors. diff --git a/.config/biome/frontend-boundaries.jsonc b/.config/biome/frontend-boundaries.jsonc new file mode 100644 index 000000000..94f76d3b4 --- /dev/null +++ b/.config/biome/frontend-boundaries.jsonc @@ -0,0 +1,1295 @@ +{ + "$schema": "../../node_modules/@biomejs/biome/configuration_schema.json", + // Frontend layer boundaries and state ownership (ADR 0027, + // docs/design/frontend-architecture.md). + // + // A later override that configures a rule REPLACES that rule's options from + // earlier overrides for the files it matches; options are not merged. Keep + // the boundary scopes disjoint, keep every override's constraint set + // complete, and keep the App override last so it reclassifies the files S4 + // moves into src/app/. + // + // "pending Sn (#2325)" marks a target constraint that still has violations. + // The slice Sn of #2325 removes the violations and restores the constraint. + // Never add a new pending exception to make new code pass. + // + // Plugin paths in this file resolve from the repository root (the config + // that extends this file), not from this directory. + "overrides": [ + // Module-scope `let` outside store modules. + { + "includes": [ + "src/**", + "!src/**/store/**", + "!src/**/*.test.ts", + "!src/**/*.test.tsx", + // Process-wide handle to the Tauri Store plugin, not application state. + "!src/lib/tauriStore.ts", + "!src/features/settings/hooks/useSettingsAtom.ts", // pending S3 (#2325) + "!src/features/settings/hooks/useDatabaseConversion.ts" // pending S5 (#2325) + ], + "plugins": ["./.config/biome/no-module-scope-let.grit"] + }, + // Foundation + { + "includes": [ + "src/lib/**", + "src/types/**", + "src/consts/**", + "!src/**/*.test.ts", + "!src/**/*.test.tsx" + ], + "linter": { + "rules": { + "style": { + "noRestrictedImports": { + "level": "error", + "options": { + "patterns": [ + { + "message": "This import crosses a frontend layer boundary. See docs/design/frontend-architecture.md.", + "group": [ + "@/features/**", + "@/hooks/**", + "@/components/**", + "@/store/**", + "@/app/**", + "@/App", + "@/lazyScreens", + "../**", + "react", + "react/**", + "react-dom", + "react-dom/**", + "jotai", + "jotai/**" + ] + } + ] + } + } + } + } + } + }, + // Shared store + { + "includes": ["src/store/**", "!src/**/*.test.ts", "!src/**/*.test.tsx"], + "linter": { + "rules": { + "style": { + "noRestrictedImports": { + "level": "error", + "options": { + "patterns": [ + { + "message": "This import crosses a frontend layer boundary. See docs/design/frontend-architecture.md.", + "group": [ + "@/features/**", + "@/hooks/**", + "@/components/**", + "@/app/**", + "@/App", + "@/lazyScreens", + "../**", + "react", + "react/**", + "react-dom", + "react-dom/**", + "jotai/react", + "jotai/react/**" + ] + } + ], + "paths": { + "jotai": { + "message": "This Jotai API is not allowed in this layer. See docs/design/frontend-architecture.md.", + "importNames": [ + "*", + "createStore", + "INTERNAL_overrideCreateStore", + "getDefaultStore", + "Provider", + "useAtom", + "useAtomValue", + "useAtomValueRaw", + "useAtomValueRawSync", + "useSetAtom", + "useStore" + ] + } + } + } + } + } + } + } + }, + // Shared hooks + { + "includes": ["src/hooks/**", "!src/**/*.test.ts", "!src/**/*.test.tsx"], + "linter": { + "rules": { + "style": { + "noRestrictedImports": { + "level": "error", + "options": { + "patterns": [ + { + "message": "This import crosses a frontend layer boundary. See docs/design/frontend-architecture.md.", + "group": [ + "@/features/**", + "@/components/**", + "@/app/**", + "@/App", + "@/lazyScreens", + "../**", + "jotai/**", + "!@/features/settings/**" // pending S3 (#2325): delete this exemption + ] + } + ], + "paths": { + "jotai": { + "message": "This Jotai API is not allowed in this layer. See docs/design/frontend-architecture.md.", + "importNames": [ + "*", + "createStore", + "INTERNAL_overrideCreateStore", + "getDefaultStore", + "Provider" + // "atom", -- pending S5 (#2325) + ] + } + } + } + } + } + } + } + }, + // UI primitives + { + "includes": [ + "src/components/ui/**", + "src/components/icons/**", + "!src/**/*.test.ts", + "!src/**/*.test.tsx" + ], + "linter": { + "rules": { + "style": { + "noRestrictedImports": { + "level": "error", + "options": { + "patterns": [ + { + "message": "This import crosses a frontend layer boundary. See docs/design/frontend-architecture.md.", + "group": [ + "@/features/**", + "@/hooks/**", + "@/store/**", + "@/components/charts/**", + "@/components/shared/**", + "@/app/**", + "@/App", + "@/lazyScreens", + "../**", + "jotai", + "jotai/**" + ] + } + ], + "paths": { + "@/rspc/bindings": { + "message": "UI primitives do not call IPC. See docs/design/frontend-architecture.md.", + "importNames": ["*", "commands", "events"] + } + } + } + } + } + } + } + }, + // Shared components: charts + { + "includes": [ + "src/components/charts/**", + "!src/**/*.test.ts", + "!src/**/*.test.tsx" + ], + "linter": { + "rules": { + "style": { + "noRestrictedImports": { + "level": "error", + "options": { + "patterns": [ + { + "message": "This import crosses a frontend layer boundary. See docs/design/frontend-architecture.md.", + "group": [ + "@/features/**", + "@/components/shared/**", + "@/app/**", + "@/App", + "@/lazyScreens", + "../**", + "jotai/**", + "!@/features/hardware/**", // pending S2 (#2325): delete this exemption + "!@/features/settings/**" // pending S3 (#2325): delete this exemption + ] + } + ], + "paths": { + "jotai": { + "message": "This Jotai API is not allowed in this layer. See docs/design/frontend-architecture.md.", + "importNames": [ + "*", + "atom", + // "useAtom", -- pending S5 (#2325) + "useSetAtom", + "useStore", + "createStore", + "INTERNAL_overrideCreateStore", + "getDefaultStore", + "Provider" + ] + } + } + } + } + } + } + } + }, + // Shared components + { + "includes": [ + "src/components/shared/**", + "src/components/*.tsx", + "!src/**/*.test.ts", + "!src/**/*.test.tsx" + ], + "linter": { + "rules": { + "style": { + "noRestrictedImports": { + "level": "error", + "options": { + "patterns": [ + { + "message": "This import crosses a frontend layer boundary. See docs/design/frontend-architecture.md.", + "group": [ + "@/features/**", + "@/app/**", + "@/App", + "@/lazyScreens", + // "../**", -- pending S1 (#2325) + "jotai/**", + "!@/features/settings/**" // pending S3 (#2325): delete this exemption + ] + } + ], + "paths": { + "jotai": { + "message": "This Jotai API is not allowed in this layer. See docs/design/frontend-architecture.md.", + "importNames": [ + "*", + "atom", + "useAtom", + // "useSetAtom", -- pending S5 (#2325) + "useStore", + "createStore", + "INTERNAL_overrideCreateStore", + "getDefaultStore", + "Provider" + ] + } + } + } + } + } + } + } + }, + // Feature catch-all: a feature without its own overrides below fails here + { + "includes": [ + "src/features/**", + "!src/**/*.test.ts", + "!src/**/*.test.tsx" + ], + "linter": { + "rules": { + "style": { + "noRestrictedImports": { + "level": "error", + "options": { + "patterns": [ + { + "message": "This import crosses a frontend layer boundary. See docs/design/frontend-architecture.md.", + "group": [ + "@/features/**", + "@/app/**", + "@/App", + "@/lazyScreens", + "../**", + "jotai/**" + ] + } + ], + "paths": { + "jotai": { + "message": "This Jotai API is not allowed in this layer. See docs/design/frontend-architecture.md.", + "importNames": [ + "*", + "atom", + "useAtom", + "useSetAtom", + "useStore", + "createStore", + "INTERNAL_overrideCreateStore", + "getDefaultStore", + "Provider" + ] + } + } + } + } + } + } + } + }, + // Feature hardware: ui + { + "includes": [ + "src/features/hardware/**", + "!src/features/hardware/store/**", + "!src/features/hardware/hooks/**", + "!src/features/hardware/**/hooks/**", + "!src/**/*.test.ts", + "!src/**/*.test.tsx" + ], + "linter": { + "rules": { + "style": { + "noRestrictedImports": { + "level": "error", + "options": { + "patterns": [ + { + "message": "This import crosses a frontend layer boundary. See docs/design/frontend-architecture.md.", + "group": [ + "@/features/**", + "@/app/**", + "@/App", + "@/lazyScreens", + // "../**", -- pending S1 (#2325) + "jotai/**", + "!@/features/hardware/**", + "!@/features/settings/**" // pending S3 (#2325): delete this exemption + ] + } + ], + "paths": { + "jotai": { + "message": "This Jotai API is not allowed in this layer. See docs/design/frontend-architecture.md.", + "importNames": [ + "*", + "atom", + // "useAtom", -- pending S5 (#2325) + "useSetAtom", + "useStore", + "createStore", + "INTERNAL_overrideCreateStore", + "getDefaultStore", + "Provider" + ] + } + } + } + } + } + } + } + }, + // Feature hardware: hooks + { + "includes": [ + "src/features/hardware/hooks/**", + "src/features/hardware/**/hooks/**", + "!src/**/*.test.ts", + "!src/**/*.test.tsx" + ], + "linter": { + "rules": { + "style": { + "noRestrictedImports": { + "level": "error", + "options": { + "patterns": [ + { + "message": "This import crosses a frontend layer boundary. See docs/design/frontend-architecture.md.", + "group": [ + "@/features/**", + "@/components/**", + "@/app/**", + "@/App", + "@/lazyScreens", + // "../**", -- pending S1 (#2325) + "jotai/**", + "!@/features/hardware/store/**", + "!@/features/hardware/**/types", + "!@/features/hardware/**/types/**", + "!@/features/hardware/**/consts", + "!@/features/hardware/**/consts/**", + "!@/features/hardware/**/utils", + "!@/features/hardware/**/utils/**", + "!@/features/hardware/**/funcs", + "!@/features/hardware/**/funcs/**", + "!@/features/hardware/**/hooks/**", + "!@/features/hardware/gpuIdentity", + "!@/features/settings/**" // pending S3 (#2325): delete this exemption + ] + } + ], + "paths": { + "jotai": { + "message": "This Jotai API is not allowed in this layer. See docs/design/frontend-architecture.md.", + "importNames": [ + "*", + "createStore", + "INTERNAL_overrideCreateStore", + "getDefaultStore", + "Provider" + // "atom", -- pending S5 (#2325) + ] + } + } + } + } + } + } + } + }, + // Feature hardware: store + { + "includes": [ + "src/features/hardware/store/**", + "!src/**/*.test.ts", + "!src/**/*.test.tsx" + ], + "linter": { + "rules": { + "style": { + "noRestrictedImports": { + "level": "error", + "options": { + "patterns": [ + { + "message": "This import crosses a frontend layer boundary. See docs/design/frontend-architecture.md.", + "group": [ + "@/features/**", + "@/hooks/**", + "@/components/**", + "@/app/**", + "@/App", + "@/lazyScreens", + "../**", + "react", + "react/**", + "react-dom", + "react-dom/**", + "jotai/react", + "jotai/react/**", + "!@/features/hardware/store/**", + "!@/features/hardware/**/types", + "!@/features/hardware/**/types/**", + "!@/features/hardware/**/consts", + "!@/features/hardware/**/consts/**", + "!@/features/hardware/**/utils", + "!@/features/hardware/**/utils/**", + "!@/features/hardware/**/funcs", + "!@/features/hardware/**/funcs/**", + "!@/features/hardware/gpuIdentity" + ] + } + ], + "paths": { + "jotai": { + "message": "This Jotai API is not allowed in this layer. See docs/design/frontend-architecture.md.", + "importNames": [ + "*", + "createStore", + "INTERNAL_overrideCreateStore", + "getDefaultStore", + "Provider", + "useAtom", + "useAtomValue", + "useAtomValueRaw", + "useAtomValueRawSync", + "useSetAtom", + "useStore" + ] + } + } + } + } + } + } + } + }, + // Feature settings: ui + { + "includes": [ + "src/features/settings/**", + "!src/features/settings/store/**", + "!src/features/settings/hooks/**", + "!src/features/settings/**/hooks/**", + "!src/**/*.test.ts", + "!src/**/*.test.tsx" + ], + "linter": { + "rules": { + "style": { + "noRestrictedImports": { + "level": "error", + "options": { + "patterns": [ + { + "message": "This import crosses a frontend layer boundary. See docs/design/frontend-architecture.md.", + "group": [ + "@/features/**", + "@/app/**", + "@/App", + "@/lazyScreens", + // "../**", -- pending S1 (#2325) + "jotai/**", + "!@/features/settings/**", + "!@/features/hardware/**", // pending S2 (#2325): delete this exemption + "!@/features/menu/**" // pending S5 (#2325): delete this exemption + ] + } + ], + "paths": { + "jotai": { + "message": "This Jotai API is not allowed in this layer. See docs/design/frontend-architecture.md.", + "importNames": [ + "*", + "atom", + // "useAtom", -- pending S5 (#2325) + // "useSetAtom", -- pending S5 (#2325) + "useStore", + "createStore", + "INTERNAL_overrideCreateStore", + "getDefaultStore", + "Provider" + ] + } + } + } + } + } + } + } + }, + // Feature settings: hooks + { + "includes": [ + "src/features/settings/hooks/**", + "src/features/settings/**/hooks/**", + "!src/**/*.test.ts", + "!src/**/*.test.tsx" + ], + "linter": { + "rules": { + "style": { + "noRestrictedImports": { + "level": "error", + "options": { + "patterns": [ + { + "message": "This import crosses a frontend layer boundary. See docs/design/frontend-architecture.md.", + "group": [ + "@/features/**", + "@/components/**", + "@/app/**", + "@/App", + "@/lazyScreens", + "../**", + "jotai/**", + "!@/features/settings/store/**", + "!@/features/settings/**/types", + "!@/features/settings/**/types/**", + "!@/features/settings/**/consts", + "!@/features/settings/**/consts/**", + "!@/features/settings/**/utils", + "!@/features/settings/**/utils/**", + "!@/features/settings/**/funcs", + "!@/features/settings/**/funcs/**", + "!@/features/settings/**/hooks/**", + "!@/features/hardware/**" // pending S2 (#2325): delete this exemption + ] + } + ], + "paths": { + "jotai": { + "message": "This Jotai API is not allowed in this layer. See docs/design/frontend-architecture.md.", + "importNames": [ + "*", + "createStore", + "INTERNAL_overrideCreateStore", + "getDefaultStore", + "Provider" + // "atom", -- pending S5 (#2325) + ] + } + } + } + } + } + } + } + }, + // Feature settings: store + { + "includes": [ + "src/features/settings/store/**", + "!src/**/*.test.ts", + "!src/**/*.test.tsx" + ], + "linter": { + "rules": { + "style": { + "noRestrictedImports": { + "level": "error", + "options": { + "patterns": [ + { + "message": "This import crosses a frontend layer boundary. See docs/design/frontend-architecture.md.", + "group": [ + "@/features/**", + "@/hooks/**", + "@/components/**", + "@/app/**", + "@/App", + "@/lazyScreens", + "../**", + "react", + "react/**", + "react-dom", + "react-dom/**", + "jotai/react", + "jotai/react/**", + "!@/features/settings/store/**", + "!@/features/settings/**/types", + "!@/features/settings/**/types/**", + "!@/features/settings/**/consts", + "!@/features/settings/**/consts/**", + "!@/features/settings/**/utils", + "!@/features/settings/**/utils/**", + "!@/features/settings/**/funcs", + "!@/features/settings/**/funcs/**" + ] + } + ], + "paths": { + "jotai": { + "message": "This Jotai API is not allowed in this layer. See docs/design/frontend-architecture.md.", + "importNames": [ + "*", + "createStore", + "INTERNAL_overrideCreateStore", + "getDefaultStore", + "Provider", + "useAtom", + "useAtomValue", + "useAtomValueRaw", + "useAtomValueRawSync", + "useSetAtom", + "useStore" + ] + } + } + } + } + } + } + } + }, + // Feature menu: ui + { + "includes": [ + "src/features/menu/**", + "!src/features/menu/store/**", + "!src/features/menu/hooks/**", + "!src/features/menu/**/hooks/**", + "!src/**/*.test.ts", + "!src/**/*.test.tsx" + ], + "linter": { + "rules": { + "style": { + "noRestrictedImports": { + "level": "error", + "options": { + "patterns": [ + { + "message": "This import crosses a frontend layer boundary. See docs/design/frontend-architecture.md.", + "group": [ + "@/features/**", + "@/app/**", + "@/App", + // "@/lazyScreens", -- pending S4 (#2325) + "../**", + "jotai/**", + "!@/features/menu/**" + ] + } + ], + "paths": { + "jotai": { + "message": "This Jotai API is not allowed in this layer. See docs/design/frontend-architecture.md.", + "importNames": [ + "*", + "atom", + "useAtom", + "useSetAtom", + "useStore", + "createStore", + "INTERNAL_overrideCreateStore", + "getDefaultStore", + "Provider" + ] + } + } + } + } + } + } + } + }, + // Feature menu: hooks + { + "includes": [ + "src/features/menu/hooks/**", + "src/features/menu/**/hooks/**", + "!src/**/*.test.ts", + "!src/**/*.test.tsx" + ], + "linter": { + "rules": { + "style": { + "noRestrictedImports": { + "level": "error", + "options": { + "patterns": [ + { + "message": "This import crosses a frontend layer boundary. See docs/design/frontend-architecture.md.", + "group": [ + "@/features/**", + "@/components/**", + "@/app/**", + "@/App", + "@/lazyScreens", + "../**", + "jotai/**", + "!@/features/menu/store/**", + "!@/features/menu/**/types", + "!@/features/menu/**/types/**", + "!@/features/menu/**/consts", + "!@/features/menu/**/consts/**", + "!@/features/menu/**/utils", + "!@/features/menu/**/utils/**", + "!@/features/menu/**/funcs", + "!@/features/menu/**/funcs/**", + "!@/features/menu/**/hooks/**" + ] + } + ], + "paths": { + "jotai": { + "message": "This Jotai API is not allowed in this layer. See docs/design/frontend-architecture.md.", + "importNames": [ + "*", + "createStore", + "INTERNAL_overrideCreateStore", + "getDefaultStore", + "Provider" + // "atom", -- pending S5 (#2325) + ] + } + } + } + } + } + } + } + }, + // Feature menu: store + { + "includes": [ + "src/features/menu/store/**", + "!src/**/*.test.ts", + "!src/**/*.test.tsx" + ], + "linter": { + "rules": { + "style": { + "noRestrictedImports": { + "level": "error", + "options": { + "patterns": [ + { + "message": "This import crosses a frontend layer boundary. See docs/design/frontend-architecture.md.", + "group": [ + "@/features/**", + "@/hooks/**", + "@/components/**", + "@/app/**", + "@/App", + "@/lazyScreens", + "../**", + "react", + "react/**", + "react-dom", + "react-dom/**", + "jotai/react", + "jotai/react/**", + "!@/features/menu/store/**", + "!@/features/menu/**/types", + "!@/features/menu/**/types/**", + "!@/features/menu/**/consts", + "!@/features/menu/**/consts/**", + "!@/features/menu/**/utils", + "!@/features/menu/**/utils/**", + "!@/features/menu/**/funcs", + "!@/features/menu/**/funcs/**" + ] + } + ], + "paths": { + "jotai": { + "message": "This Jotai API is not allowed in this layer. See docs/design/frontend-architecture.md.", + "importNames": [ + "*", + "createStore", + "INTERNAL_overrideCreateStore", + "getDefaultStore", + "Provider", + "useAtom", + "useAtomValue", + "useAtomValueRaw", + "useAtomValueRawSync", + "useSetAtom", + "useStore" + ] + } + } + } + } + } + } + } + }, + // Feature tray: ui + { + "includes": [ + "src/features/tray/**", + "!src/features/tray/store/**", + "!src/features/tray/hooks/**", + "!src/features/tray/**/hooks/**", + "!src/**/*.test.ts", + "!src/**/*.test.tsx" + ], + "linter": { + "rules": { + "style": { + "noRestrictedImports": { + "level": "error", + "options": { + "patterns": [ + { + "message": "This import crosses a frontend layer boundary. See docs/design/frontend-architecture.md.", + "group": [ + "@/features/**", + "@/app/**", + "@/App", + "@/lazyScreens", + "../**", + "jotai/**", + "!@/features/tray/**" + ] + } + ], + "paths": { + "jotai": { + "message": "This Jotai API is not allowed in this layer. See docs/design/frontend-architecture.md.", + "importNames": [ + "*", + "atom", + "useAtom", + "useSetAtom", + "useStore", + "createStore", + "INTERNAL_overrideCreateStore", + "getDefaultStore", + "Provider" + ] + } + } + } + } + } + } + } + }, + // Feature tray: hooks + { + "includes": [ + "src/features/tray/hooks/**", + "src/features/tray/**/hooks/**", + "!src/**/*.test.ts", + "!src/**/*.test.tsx" + ], + "linter": { + "rules": { + "style": { + "noRestrictedImports": { + "level": "error", + "options": { + "patterns": [ + { + "message": "This import crosses a frontend layer boundary. See docs/design/frontend-architecture.md.", + "group": [ + "@/features/**", + "@/components/**", + "@/app/**", + "@/App", + "@/lazyScreens", + "../**", + "jotai/**", + "!@/features/tray/store/**", + "!@/features/tray/**/types", + "!@/features/tray/**/types/**", + "!@/features/tray/**/consts", + "!@/features/tray/**/consts/**", + "!@/features/tray/**/utils", + "!@/features/tray/**/utils/**", + "!@/features/tray/**/funcs", + "!@/features/tray/**/funcs/**", + "!@/features/tray/**/hooks/**" + ] + } + ], + "paths": { + "jotai": { + "message": "This Jotai API is not allowed in this layer. See docs/design/frontend-architecture.md.", + "importNames": [ + "*", + "createStore", + "INTERNAL_overrideCreateStore", + "getDefaultStore", + "Provider", + "atom" + ] + } + } + } + } + } + } + } + }, + // Feature tray: store + { + "includes": [ + "src/features/tray/store/**", + "!src/**/*.test.ts", + "!src/**/*.test.tsx" + ], + "linter": { + "rules": { + "style": { + "noRestrictedImports": { + "level": "error", + "options": { + "patterns": [ + { + "message": "This import crosses a frontend layer boundary. See docs/design/frontend-architecture.md.", + "group": [ + "@/features/**", + "@/hooks/**", + "@/components/**", + "@/app/**", + "@/App", + "@/lazyScreens", + "../**", + "react", + "react/**", + "react-dom", + "react-dom/**", + "jotai/react", + "jotai/react/**", + "!@/features/tray/store/**", + "!@/features/tray/**/types", + "!@/features/tray/**/types/**", + "!@/features/tray/**/consts", + "!@/features/tray/**/consts/**", + "!@/features/tray/**/utils", + "!@/features/tray/**/utils/**", + "!@/features/tray/**/funcs", + "!@/features/tray/**/funcs/**" + ] + } + ], + "paths": { + "jotai": { + "message": "This Jotai API is not allowed in this layer. See docs/design/frontend-architecture.md.", + "importNames": [ + "*", + "createStore", + "INTERNAL_overrideCreateStore", + "getDefaultStore", + "Provider", + "useAtom", + "useAtomValue", + "useAtomValueRaw", + "useAtomValueRawSync", + "useSetAtom", + "useStore" + ] + } + } + } + } + } + } + } + }, + // Feature updater: ui + { + "includes": [ + "src/features/updater/**", + "!src/features/updater/store/**", + "!src/features/updater/hooks/**", + "!src/features/updater/**/hooks/**", + "!src/**/*.test.ts", + "!src/**/*.test.tsx" + ], + "linter": { + "rules": { + "style": { + "noRestrictedImports": { + "level": "error", + "options": { + "patterns": [ + { + "message": "This import crosses a frontend layer boundary. See docs/design/frontend-architecture.md.", + "group": [ + "@/features/**", + "@/app/**", + "@/App", + "@/lazyScreens", + "../**", + "jotai/**", + "!@/features/updater/**" + ] + } + ], + "paths": { + "jotai": { + "message": "This Jotai API is not allowed in this layer. See docs/design/frontend-architecture.md.", + "importNames": [ + "*", + "atom", + "useAtom", + "useSetAtom", + "useStore", + "createStore", + "INTERNAL_overrideCreateStore", + "getDefaultStore", + "Provider" + ] + } + } + } + } + } + } + } + }, + // Feature updater: hooks + { + "includes": [ + "src/features/updater/hooks/**", + "src/features/updater/**/hooks/**", + "!src/**/*.test.ts", + "!src/**/*.test.tsx" + ], + "linter": { + "rules": { + "style": { + "noRestrictedImports": { + "level": "error", + "options": { + "patterns": [ + { + "message": "This import crosses a frontend layer boundary. See docs/design/frontend-architecture.md.", + "group": [ + "@/features/**", + "@/components/**", + "@/app/**", + "@/App", + "@/lazyScreens", + "../**", + "jotai/**", + "!@/features/updater/store/**", + "!@/features/updater/**/types", + "!@/features/updater/**/types/**", + "!@/features/updater/**/consts", + "!@/features/updater/**/consts/**", + "!@/features/updater/**/utils", + "!@/features/updater/**/utils/**", + "!@/features/updater/**/funcs", + "!@/features/updater/**/funcs/**", + "!@/features/updater/**/hooks/**" + ] + } + ], + "paths": { + "jotai": { + "message": "This Jotai API is not allowed in this layer. See docs/design/frontend-architecture.md.", + "importNames": [ + "*", + "createStore", + "INTERNAL_overrideCreateStore", + "getDefaultStore", + "Provider", + "atom" + ] + } + } + } + } + } + } + } + }, + // Feature updater: store + { + "includes": [ + "src/features/updater/store/**", + "!src/**/*.test.ts", + "!src/**/*.test.tsx" + ], + "linter": { + "rules": { + "style": { + "noRestrictedImports": { + "level": "error", + "options": { + "patterns": [ + { + "message": "This import crosses a frontend layer boundary. See docs/design/frontend-architecture.md.", + "group": [ + "@/features/**", + "@/hooks/**", + "@/components/**", + "@/app/**", + "@/App", + "@/lazyScreens", + "../**", + "react", + "react/**", + "react-dom", + "react-dom/**", + "jotai/react", + "jotai/react/**", + "!@/features/updater/store/**", + "!@/features/updater/**/types", + "!@/features/updater/**/types/**", + "!@/features/updater/**/consts", + "!@/features/updater/**/consts/**", + "!@/features/updater/**/utils", + "!@/features/updater/**/utils/**", + "!@/features/updater/**/funcs", + "!@/features/updater/**/funcs/**" + ] + } + ], + "paths": { + "jotai": { + "message": "This Jotai API is not allowed in this layer. See docs/design/frontend-architecture.md.", + "importNames": [ + "*", + "createStore", + "INTERNAL_overrideCreateStore", + "getDefaultStore", + "Provider", + "useAtom", + "useAtomValue", + "useAtomValueRaw", + "useAtomValueRawSync", + "useSetAtom", + "useStore" + ] + } + } + } + } + } + } + } + }, + // App: listed files are what S4 moves to src/app/; keep this override last + { + "includes": [ + "src/App.tsx", + "src/lazyScreens.tsx", + "src/components/shared/CloseToTrayFirstRunDialog.tsx", + "src/components/shared/DatabaseConversionPromptDialog.tsx", + "src/components/shared/ElevationUnavailableNotice.tsx", + "src/components/shared/ExternalComponentGuidanceDialog.tsx", + "src/components/shared/NavigationRestructureNotice.tsx", + "src/components/shared/NsisMigrationNoticeDialog.tsx", + "src/components/shared/ScreenTemplate.tsx", + "src/components/ui/FullScreenExit.tsx" + ], + "linter": { + "rules": { + "style": { + "noRestrictedImports": { + "level": "error", + "options": { + "patterns": [ + { + "message": "This import crosses a frontend layer boundary. See docs/design/frontend-architecture.md.", + "group": ["jotai/**"] + } + ], + "paths": { + "jotai": { + "message": "This Jotai API is not allowed in this layer. See docs/design/frontend-architecture.md.", + "importNames": [ + "*", + "atom", + // "useAtom", -- pending S5 (#2325) + // "useSetAtom", -- pending S5 (#2325) + "useStore", + "createStore", + "INTERNAL_overrideCreateStore", + "getDefaultStore", + "Provider" + ] + } + } + } + } + } + } + } + } + ] +} diff --git a/.config/biome/no-module-scope-let.grit b/.config/biome/no-module-scope-let.grit new file mode 100644 index 000000000..05b3d5617 --- /dev/null +++ b/.config/biome/no-module-scope-let.grit @@ -0,0 +1,17 @@ +// Module-scope `let` is state the Jotai store cannot see, scope per Provider, +// or reset between tests. Shared state belongs in a `store/` module. +// See docs/design/frontend-architecture.md. +// +// Matched by node type and source text: the snippet form `let $x = $y` does not +// match in Biome 2.5 even though `const $x = $y` does. +JsVariableDeclaration() as $declaration where { + $declaration <: r"^let\s[\s\S]*", + not $declaration <: within or { + JsFunctionBody(), + JsArrowFunctionExpression(), + JsFunctionExpression(), + JsClassDeclaration(), + JsClassExpression() + }, + register_diagnostic(span=$declaration, message="Module-scope `let` is state outside the Jotai store. Declare it in a `store/` module (an atom, or store-keyed state) so it is scoped per Provider and resets between tests.") +} diff --git a/biome.jsonc b/biome.jsonc index 1161bca03..7d6b72e22 100644 --- a/biome.jsonc +++ b/biome.jsonc @@ -1,5 +1,7 @@ { "$schema": "./node_modules/@biomejs/biome/configuration_schema.json", + // Frontend layer boundaries live in their own file; see its header. + "extends": ["./.config/biome/frontend-boundaries.jsonc"], "assist": { "actions": { "source": { "organizeImports": "on" } } }, "vcs": { "enabled": true, @@ -14,6 +16,13 @@ // TypeScript's noPropertyAccessFromIndexSignature requires bracket access for index signatures. "useLiteralKeys": "off" }, + "performance": { + "noBarrelFile": "error", + "noReExportAll": "error" + }, + "suspicious": { + "noImportCycles": "error" + }, "nursery": { "useSortedClasses": { "level": "error", diff --git a/docs/README.md b/docs/README.md index 83ed6271d..029957103 100644 --- a/docs/README.md +++ b/docs/README.md @@ -28,6 +28,8 @@ documentation. - [Relicense to GPL-3.0-or-later decision](adr/0020-relicense-to-gpl-3.0-or-later.md) - [Sensor hardware specs (clean-room)](specs/sensors/) - [Frontend architecture](../src/README.md) +- [Frontend layer boundaries decision](adr/0027-frontend-layer-boundaries-and-state-ownership.md) +- [Frontend architecture Design Doc](design/frontend-architecture.md) - [Core crate guide](../core/README.md) - [Tauri app crate guide](../src-tauri/README.md) - [Add a new language](development/add-language.md) diff --git a/docs/adr/0027-frontend-layer-boundaries-and-state-ownership.md b/docs/adr/0027-frontend-layer-boundaries-and-state-ownership.md new file mode 100644 index 000000000..fd56418a8 --- /dev/null +++ b/docs/adr/0027-frontend-layer-boundaries-and-state-ownership.md @@ -0,0 +1,80 @@ +# Frontend Layer Boundaries and State Ownership + +Status: proposed + +Tracking issue: [#2325](https://github.com/shm11c3/HardwareVisualizer/issues/2325). + +The lint guardrails for constraints the code already satisfies are in place. +The structural migration is deferred to 1.12.0 development. Implementation +detail and the rule-to-lint mapping live in +[`docs/design/frontend-architecture.md`](../design/frontend-architecture.md). + +## Context + +The frontend grew without an enforced dependency direction. Generic layers +depend on features: shared components, charts, and shared hooks import +`features/settings`. Features also depend on one another: +`features/hardware` and `features/settings` import each other. + +Jotai state has no single home. Atoms are declared in hook files as well as in +store modules. Components write atoms directly, while other state hides behind +facade hooks. Some shared state sits in module-scope variables that a +Provider-scoped store cannot isolate between tests. + +Two placements cause most of the cross-layer imports: + +- Application Preference state lives inside `features/settings`, although every + screen reads it. +- Chart vocabulary lives inside `features/hardware`, although charts and + settings share it. + +## Decision + +1. **The frontend has ordered layers, and imports point only downward:** + foundation (`rspc`, `types`, `consts`, `lib`) → store → shared hooks → + UI primitives → shared components → features → app. + - A feature never imports another feature. + - Code that composes features belongs in the app layer. +2. **Cross-directory imports use the `@/` alias.** Parent-relative imports are + not allowed, because they bypass path-based boundary rules. +3. **State ownership is explicit:** + - Atoms are declared only in `store/` modules: `src/store` for cross-feature + state and `src/features//store` for feature state. + - Components read atoms. Writes go through named hook functions. + - Shared mutable state does not live in module-scope variables outside + store modules. + - `getDefaultStore` is not used. +4. **Application Preference state is cross-feature state.** It lives in the + shared store and hook layers, not inside the settings feature. +5. **Biome enforces these rules.** + - A constraint is enforced as an error as soon as the code satisfies it. + - A constraint that still has violations is recorded as a pending exception + with the migration slice that removes it. It is never weakened silently. + +## Alternatives Considered + +- **Document the layering without lint.** Rejected. The current state is what + convention-only layering produced. A rule nobody checks drifts the same way + again. +- **Use dependency-cruiser or eslint-plugin-boundaries.** Rejected for now. + Either one adds a second lint toolchain beside Biome. Biome's path rules + plus the alias-only import rule cover the same layer and feature + boundaries, and keep one lint command. +- **Keep atoms next to the hooks that use them.** Rejected. Co-location hides + shared state inside hooks that other features then import for the atom + alone. It also leaves no rule that tells a reader where state lives. +- **Migrate everything in one change.** Rejected. The migration touches most + frontend files and would conflict with every in-flight change. It lands as + ordered slices instead, each of which restores specific pending constraints. + +## Consequences + +- New code cannot add an import that crosses an enforced boundary, a barrel + file, an import cycle, or module-scope `let` outside store modules. +- Constraints with pending exceptions remain unenforced until their slice + lands. Until then, review has to catch new violations of those constraints. +- Biome replaces, rather than merges, a rule's options when a later override + matches the same file. The boundary configuration therefore needs disjoint + scopes and a complete constraint set in each override. +- The Live Metrics Buffer (#1638) has to live in a store module to satisfy the + module-state rule. Its module-level store fits that placement. diff --git a/docs/adr/README.md b/docs/adr/README.md index 709d3e778..f6eaa7967 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -47,3 +47,4 @@ ADR status describes decision maturity, not implementation or release status. - [0024 External Component Setup](0024-external-component-setup.md) - [0025 Retire the SQLite Conversion Path in v2.0.0](0025-retire-sqlite-conversion-path-in-v2.md) - [0026 Refresh Outdated External Component Files](0026-refresh-outdated-external-component-files.md) +- [0027 Frontend Layer Boundaries and State Ownership](0027-frontend-layer-boundaries-and-state-ownership.md) diff --git a/docs/agents/lessons/README.md b/docs/agents/lessons/README.md index fdb245c1d..78d1b72b0 100644 --- a/docs/agents/lessons/README.md +++ b/docs/agents/lessons/README.md @@ -124,6 +124,7 @@ shared enforcement surface. - [Place scripts with their owner](place-scripts-with-their-owner.md) - [Keep tray flyout geometry transient](keep-tray-flyout-geometry-transient.md) - [Claim Windows Open before database startup](claim-windows-open-before-database.md) +- [Prove Biome boundary config with canaries](prove-biome-boundary-config-with-canaries.md) - [Coordinate worker restoration with shutdown](coordinate-worker-restoration-with-shutdown.md) - [Exclude major upgrades from deferred setup](exclude-major-upgrades-from-deferred-setup.md) - [Correct inherited WinGet license metadata](correct-inherited-winget-license-metadata.md) diff --git a/docs/agents/lessons/prove-biome-boundary-config-with-canaries.md b/docs/agents/lessons/prove-biome-boundary-config-with-canaries.md new file mode 100644 index 000000000..e2fa0be47 --- /dev/null +++ b/docs/agents/lessons/prove-biome-boundary-config-with-canaries.md @@ -0,0 +1,43 @@ +--- +id: LRN-20261003-prove-biome-boundary-config-with-canaries +status: promoted +cause_status: confirmed +scope: biome.jsonc, .config/biome/** +trigger: when adding or editing Biome overrides, noRestrictedImports scopes, or GritQL plugins +failure_signature: a boundary rule reports zero diagnostics because it never ran; overlapping overrides silently drop earlier constraints +root_cause: "a later Biome override replaces, rather than merges, the options of a rule configured earlier for the same file; a mistyped scope, a plugin path that does not resolve the way the author expected, or a GritQL snippet that never matches produces no error, only no diagnostics" +guardrail: the boundary file keeps disjoint scopes with complete constraint sets and documents the replacement semantics; every edit is proven with per-scope canary imports that must be rejected or allowed +canonical_refs: .config/biome/frontend-boundaries.jsonc, docs/design/frontend-architecture.md, .agents/rules/frontend.md +verification: two overrides matching the same file with different noRestrictedImports options report only the later one's diagnostics; the 2026-10-03 canary run passed 107 expected-reject and expected-allow checks across every scope +evidence: "issue #2325; ADR 0027; experiments recorded in docs/design/frontend-architecture.md (Biome behaviors section)" +revalidate_when: Biome changes override merging, noRestrictedImports pattern matching, plugin path resolution in extended configs, or GritQL snippet matching for `let` +--- + +# Prove Biome boundary config with canaries + +## Observation + +Designing the frontend layer rules, several configurations reported zero +diagnostics for reasons unrelated to the code being clean: + +- An override listed earlier stopped producing diagnostics once a broader + override also configured `noRestrictedImports` for the same files. +- A GritQL snippet `let $x = $y` matched nothing, while `const $x = $y` + matched. +- A plugin path written relative to the extended config failed to load. The + same path written relative to the repository root loaded. +- Counting violations from `biome lint` output undercounted, because only 20 + diagnostics print by default. + +## Confirmed cause + +Biome applies the last matching override's options for a rule. It does not +combine them with earlier overrides. A configuration mistake never fails +loudly as a rule error. It shows up as an absence of diagnostics, which looks +exactly like compliant code. + +## Promotion + +The Design Doc records the verified behaviors and the canary procedure. The +boundary file's header states the replacement rule and the path resolution. +The frontend rule requires canaries for any edit to that file. diff --git a/docs/design/frontend-architecture.md b/docs/design/frontend-architecture.md new file mode 100644 index 000000000..390353ba6 --- /dev/null +++ b/docs/design/frontend-architecture.md @@ -0,0 +1,153 @@ +# Frontend Architecture Design + +Decision: [ADR 0027](../adr/0027-frontend-layer-boundaries-and-state-ownership.md) +(proposed). Migration tracking: [#2325](https://github.com/shm11c3/HardwareVisualizer/issues/2325). + +## Problem + +The frontend has no enforced dependency direction and no single owner for +Jotai state. Generic layers import features, features import each other, and +atoms are declared wherever they were first needed. Those are the conditions +under which a boundary erodes one convenient import at a time. + +## Layers + +A layer may import from the layers listed above it, never from the layers +listed below it. + +| Layer | Paths | May import | +| --- | --- | --- | +| Foundation | `src/rspc`, `src/types`, `src/consts`, `src/lib` | Foundation only. No React, no Jotai. | +| Store | `src/store`, `src/features//store` | Foundation, plus the feature's own non-UI modules. No React or Jotai React bindings. | +| Shared hooks | `src/hooks` | Foundation, store | +| UI primitives | `src/components/ui`, `src/components/icons` | Foundation. Never IPC `commands` or `events`. No Jotai. | +| Shared components | `src/components/charts`, `src/components/shared` | All of the above. Charts never import shared components. | +| Feature | `src/features/` | All of the above and its own feature. Never another feature. | +| App | `src/app` (planned), `src/App.tsx`, `src/lazyScreens.tsx`, `src/main*.tsx` | Anything | + +Within a feature, `store/` and `hooks/` import their own feature through a +closed allow-list rather than an open exemption: + +- `store/` may import the feature's `store`, `types`, `consts`, `utils`, and + `funcs` modules. +- `hooks/` may import the same modules plus the feature's `hooks`. +- Feature root modules such as `hardware/gpuIdentity` are allowed by name. + +An allow-list is used because specifiers carry no file extension, so a deny +pattern cannot tell a screen component from a logic module. + +Two rules close the obvious bypasses: + +- **No parent-relative imports.** Every import that leaves its own directory + uses `@/`, because the path rules match the specifier as written. +- **No barrels and no `export *`.** Every dependency stays visible at the + import site. + +## State Ownership + +| Where | Declare atoms | Write atoms | Read atoms | +| --- | --- | --- | --- | +| `store/` modules | yes | in derived atoms | yes | +| Hooks | no | yes | yes | +| Components and the app layer | no | no | `useAtomValue` only | + +- `src/store` holds cross-feature state, including Application Preferences once + #2325 moves them. `src/features//store` holds state owned by one feature. +- `getDefaultStore` and `createStore` stay out of production code. State reaches + React through the Provider-scoped store, which is what lets tests isolate + each case with their own store. +- Module-scope `let` is allowed only in store modules, because only there is it + visible as owned state. Anything shared between hook instances becomes an + atom or store-keyed state, for example the `WeakMap` keyed by the Jotai store + that `useProcessInfo` uses. +- The persistence boundary does not change. Application Preferences still + persist through typed Rust settings commands, and Tauri Store remains only + for UI-local state. + +## Enforcement + +Root `biome.jsonc` enables `noImportCycles`, `noBarrelFile`, and +`noReExportAll`. It extends `.config/biome/frontend-boundaries.jsonc`, which +holds one `noRestrictedImports` override per layer scope, plus the GritQL +plugin `.config/biome/no-module-scope-let.grit`. + +| Rule | Mechanism | +| --- | --- | +| Downward-only imports, no cross-feature imports | `noRestrictedImports` pattern groups per scope | +| Alias-only cross-directory imports | `../**` in every scope's group | +| Jotai API placement | `paths.jotai.importNames` per scope, plus `jotai/**` subpaths banned outside store modules | +| UI primitives never call IPC | `paths["@/rspc/bindings"].importNames` = `commands`, `events` | +| No bypass through namespace imports | `*` is listed wherever specific names are restricted | +| No module-scope `let` outside store modules | GritQL plugin through an override | +| No cycles, barrels, or `export *` | `noImportCycles`, `noBarrelFile`, `noReExportAll` | + +The restricted Jotai names follow jotai's root exports. Jotai 3 added +`useAtomValueRaw`, `useAtomValueRawSync`, and `INTERNAL_overrideCreateStore`, +and a new export is allowed until it is listed. Re-check the lists against +the package's exports whenever jotai is upgraded. + +Test files are outside the boundary scopes. They legitimately seed a +`createStore` and import across layers to build fixtures. + +`src/main*.tsx` and `src/e2e/**` are composition roots and test +infrastructure, so they are also unscoped. + +Every feature has three overrides: UI, `hooks/`, and `store/`. A feature +directory with no overrides of its own falls into a catch-all that bans every +`@/features/**` import, including its own. A new feature therefore fails lint +until it gets its own overrides. + +### Pending exceptions + +A target constraint that the current code still violates is commented out in +the boundary file as `pending Sn (#2325)`. Negated patterns +(`!@/features/settings/**`) exempt one dependency, and commented-out entries +disable one API or pattern. + +These rules keep the pending list honest: + +- The list shrinks only. Each migration slice deletes the exceptions it + resolves. +- New code that needs a new exception is a design discussion, not a config + edit. +- Files that the app-layer slice will move are listed in the last override + and get app-layer rules now, so their current directory's rules stay strict. + +### Biome behaviors this relies on + +These behaviors were verified on Biome 2.5. + +- **A later override replaces the options of a rule configured earlier for the + same file. It does not merge them.** Scopes must stay disjoint, every + override must carry its complete set, and the app override must stay last. +- `noRestrictedImports` group patterns are gitignore-style and support + negation (`!`). +- Plugin paths in an extended config resolve from the repository root. +- The snippet `let $x = $y` does not match in GritQL, while `const $x = $y` + does. The plugin therefore matches `JsVariableDeclaration()` by source text. +- Plugin diagnostics are suppressed with `// biome-ignore lint/plugin: `. +- `biome lint` prints only 20 diagnostics by default. Use `--max-diagnostics` + when counting. + +Changes to the boundary file have to be proven against canaries, because a +mistyped scope does not fail. It silently disables the rule. To prove a +change, place one file per affected scope with one import that each +constraint must reject and one that it must allow, then confirm the +diagnostics line by line. + +## Migration + +The slices, their order, and the baseline measurements live in #2325. They are +a starting plan. Re-measure and re-slice when 1.12.0 work begins. + +A slice is done when its pending exceptions are removed and `npm run lint:ci`, +`npm run build`, and `npm test` pass. A slice that changes 1 Hz atoms also +needs the fan-out regression test that `src/AGENTS.md` requires. + +## Open Questions + +- What the shared preference store and hook are called (`preferences` / + `usePreferences` or similar). This is decided in the preference slice. +- Whether presentation components may call IPC `commands` directly, or only + hooks may. Today only UI primitives are restricted. #2311 changes how + failed reads are reported and may settle this. diff --git a/src/AGENTS.md b/src/AGENTS.md index 0ecbfe860..42213c6a5 100644 --- a/src/AGENTS.md +++ b/src/AGENTS.md @@ -7,7 +7,13 @@ These instructions add to the repository root `AGENTS.md` for work under - Call the backend through generated commands from `@/rspc/bindings` and handle the generated Result shape. Never edit `src/rspc/bindings.ts` manually. -- Use Jotai for shared frontend state and keep state close to its feature. +- Use Jotai for shared frontend state. Declare new atoms in the owning + `store/` module and follow the layer boundaries in + [ADR 0027](../docs/adr/0027-frontend-layer-boundaries-and-state-ownership.md), + which `npm run lint:ci` enforces. +- Never add a pending exception to + `.config/biome/frontend-boundaries.jsonc` to make new code pass; change the + design instead. - Application Preferences go through typed Rust settings commands and `settings.json`. Use Tauri Store only for resettable UI-local/transient state. - `showGpuUsageSource` is a known legacy exception that still uses Tauri Store. diff --git a/src/README.md b/src/README.md index 0fa6eb368..f38f410ae 100644 --- a/src/README.md +++ b/src/README.md @@ -32,6 +32,27 @@ Keep feature-specific code under `features//` when the code is not meaningfully reused elsewhere. Put reusable primitives in `components/`, cross-feature hooks in `hooks/`, and pure helpers in `lib/`. +## Layer Boundaries + +Imports point only downward: + +```text +foundation (rspc, types, consts, lib) -> store -> hooks -> components/ui + -> components/{charts,shared} -> features/ -> app +``` + +- A feature never imports another feature. +- Cross-directory imports use `@/`. +- Atoms are declared only in `store/` modules. +- Components read atoms with `useAtomValue` and write through hooks. + +`npm run lint:ci` enforces these rules through +`.config/biome/frontend-boundaries.jsonc`. See +[ADR 0027](../docs/adr/0027-frontend-layer-boundaries-and-state-ownership.md) +and the [Design Doc](../docs/design/frontend-architecture.md). Existing code +that predates the rules is listed there as pending exceptions, which only +shrink. + ## Backend IPC - Use generated commands from `src/rspc/bindings.ts`. @@ -56,8 +77,8 @@ Use the existing boundary when choosing where state lives: `src/lib/tauriStore.ts` and `src/hooks/useTauriStore.ts`. Examples include ephemeral selections, cached UI choices, or view state that can be reset without losing an explicit user configuration. -- Cross-feature frontend state should use Jotai atoms under the owning feature - or `src/store/` when it is genuinely shared. +- Declare Jotai atoms in a `store/` module: `src/features//store/` + for feature state, and `src/store/` when the state is genuinely shared. Do not write user-facing preferences directly from the frontend with Tauri Store.