From a3b99531a7bb0dccf6ec3094ec20475cc562d8e5 Mon Sep 17 00:00:00 2001 From: utof Date: Fri, 1 May 2026 22:14:06 +0400 Subject: [PATCH 01/11] =?UTF-8?q?feat(desktop):=20design-system-v1=20found?= =?UTF-8?q?ation=20=E2=80=94=20tokens.css=20+=20ThemeProvider=20+=20Fontso?= =?UTF-8?q?urce?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Tailwind v4 @theme inline aliases bound to :root CSS vars; light theme via [data-theme="light"] - Self-hosted variable fonts via @fontsource-variable/{fraunces,inter,jetbrains-mono} - @phosphor-icons/react added (consumed in later tasks) - ThemeProvider context — dark/light/system, prefers-color-scheme aware, localStorage-persisted (NOT Zustand persist mw — different state category; preserves Batch H rule) - Existing components unchanged; they still render via built-in Tailwind colors not affected by @theme --- apps/desktop/bun.lock | 12 ++ apps/desktop/package.json | 4 + apps/desktop/src/App.css | 2 - .../src/__tests__/theme-provider.test.tsx | 93 +++++++++ apps/desktop/src/lib/theme-provider.tsx | 124 +++++++++++ apps/desktop/src/main.tsx | 18 +- apps/desktop/src/styles/fonts.ts | 7 + apps/desktop/src/styles/tokens.css | 197 ++++++++++++++++++ 8 files changed, 451 insertions(+), 6 deletions(-) delete mode 100644 apps/desktop/src/App.css create mode 100644 apps/desktop/src/__tests__/theme-provider.test.tsx create mode 100644 apps/desktop/src/lib/theme-provider.tsx create mode 100644 apps/desktop/src/styles/fonts.ts create mode 100644 apps/desktop/src/styles/tokens.css diff --git a/apps/desktop/bun.lock b/apps/desktop/bun.lock index 8265a04..63b8c96 100644 --- a/apps/desktop/bun.lock +++ b/apps/desktop/bun.lock @@ -5,6 +5,10 @@ "": { "name": "perima-desktop", "dependencies": { + "@fontsource-variable/fraunces": "^5.2.9", + "@fontsource-variable/inter": "^5.2.8", + "@fontsource-variable/jetbrains-mono": "^5.2.8", + "@phosphor-icons/react": "^2.1.10", "@tanstack/react-query": "^5", "@tanstack/react-router": "^1", "@tanstack/react-virtual": "^3.13.24", @@ -196,6 +200,12 @@ "@evilmartians/lefthook": ["@evilmartians/lefthook@2.1.6", "", { "os": [ "linux", "win32", "darwin", ], "cpu": [ "x64", "ia32", "arm64", ], "bin": { "lefthook": "bin/index.js" } }, "sha512-ysZbzryf74wlISmgm0PH/n1lJ0HD7AHmI6DoJgWO9qzIETQknhGdmKbkOi0MjLYtiOHWQl8Dr2qwg0ksDpNZjA=="], + "@fontsource-variable/fraunces": ["@fontsource-variable/fraunces@5.2.9", "", {}, "sha512-Y6IjunlN9Ni723np+GIgAaKzCDBrPRrqNi01TZxHs5wtHYROWFM9W6yiT+/gGwSjWIRD18oX17kD/BRWekc/Lw=="], + + "@fontsource-variable/inter": ["@fontsource-variable/inter@5.2.8", "", {}, "sha512-kOfP2D+ykbcX/P3IFnokOhVRNoTozo5/JxhAIVYLpea/UBmCQ/YWPBfWIDuBImXX/15KH+eKh4xpEUyS2sQQGQ=="], + + "@fontsource-variable/jetbrains-mono": ["@fontsource-variable/jetbrains-mono@5.2.8", "", {}, "sha512-WBA9elru6Jdp5df2mES55wuOO0WIrn3kpXnI4+W2ek5u3ZgLS9XS4gmIlcQhiZOWEKl95meYdvK7xI+ETLCq/Q=="], + "@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="], "@humanfs/node": ["@humanfs/node@0.16.7", "", { "dependencies": { "@humanfs/core": "^0.19.1", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ=="], @@ -228,6 +238,8 @@ "@oxc-project/types": ["@oxc-project/types@0.126.0", "", {}, "sha512-oGfVtjAgwQVVpfBrbtk4e1XDyWHRFta6BS3GWVzrF8xYBT2VGQAk39yJS/wFSMrZqoiCU4oghT3Ch0HaHGIHcQ=="], + "@phosphor-icons/react": ["@phosphor-icons/react@2.1.10", "", { "peerDependencies": { "react": ">= 16.8", "react-dom": ">= 16.8" } }, "sha512-vt8Tvq8GLjheAZZYa+YG/pW7HDbov8El/MANW8pOAz4eGxrwhnbfrQZq0Cp4q8zBEu8NIhHdnr+r8thnfRSNYA=="], + "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-rc.16", "", { "os": "android", "cpu": "arm64" }, "sha512-rhY3k7Bsae9qQfOtph2Pm2jZEA+s8Gmjoz4hhmx70K9iMQ/ddeae+xhRQcM5IuVx5ry1+bGfkvMn7D6MJggVSA=="], "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.0-rc.16", "", { "os": "darwin", "cpu": "arm64" }, "sha512-rNz0yK078yrNn3DrdgN+PKiMOW8HfQ92jQiXxwX8yW899ayV00MLVdaCNeVBhG/TbH3ouYVObo8/yrkiectkcQ=="], diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 8c429f2..2416752 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -16,6 +16,10 @@ "node": ">=20.19 <21 || >=22.12" }, "dependencies": { + "@fontsource-variable/fraunces": "^5.2.9", + "@fontsource-variable/inter": "^5.2.8", + "@fontsource-variable/jetbrains-mono": "^5.2.8", + "@phosphor-icons/react": "^2.1.10", "@tanstack/react-query": "^5", "@tanstack/react-router": "^1", "@tanstack/react-virtual": "^3.13.24", diff --git a/apps/desktop/src/App.css b/apps/desktop/src/App.css deleted file mode 100644 index 421167d..0000000 --- a/apps/desktop/src/App.css +++ /dev/null @@ -1,2 +0,0 @@ -/* WHY: Tailwind 4 uses @import instead of @tailwind directives from v3. */ -@import "tailwindcss"; diff --git a/apps/desktop/src/__tests__/theme-provider.test.tsx b/apps/desktop/src/__tests__/theme-provider.test.tsx new file mode 100644 index 0000000..5ef60da --- /dev/null +++ b/apps/desktop/src/__tests__/theme-provider.test.tsx @@ -0,0 +1,93 @@ +import { describe, test, expect, beforeEach, afterEach, vi } from "vitest"; +import { render, act } from "@testing-library/react"; +import { ThemeProvider, useTheme } from "../lib/theme-provider"; + +const ThemeProbe = () => { + const { theme, effectiveTheme, setTheme } = useTheme(); + return ( +
+ {theme} + {effectiveTheme} + + + +
+ ); +}; + +describe("ThemeProvider", () => { + beforeEach(() => { + localStorage.clear(); + document.documentElement.removeAttribute("data-theme"); + // WHY matchMedia mock: jsdom does not implement window.matchMedia by + // default; ThemeProvider's useEffect calls it for system-pref detection. + // Without this mock, every test would throw "matchMedia is not a function". + Object.defineProperty(window, "matchMedia", { + writable: true, + value: vi.fn().mockImplementation((query: string) => ({ + matches: false, // default to NOT-light → effectiveTheme = "dark" in system mode + media: query, + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), // legacy + removeListener: vi.fn(), // legacy + dispatchEvent: vi.fn(), + })), + }); + }); + + afterEach(() => { + document.documentElement.removeAttribute("data-theme"); + }); + + test("defaults to system mode when no localStorage value", () => { + const { getByTestId } = render( + , + ); + expect(getByTestId("theme").textContent).toBe("system"); + }); + + test("setTheme('light') sets data-theme=light on ", () => { + const { getByText } = render( + , + ); + act(() => { getByText("set-light").click(); }); + expect(document.documentElement.getAttribute("data-theme")).toBe("light"); + }); + + test("setTheme('dark') removes data-theme attr", () => { + const { getByText } = render( + , + ); + act(() => { getByText("set-light").click(); }); + expect(document.documentElement.getAttribute("data-theme")).toBe("light"); + act(() => { getByText("set-dark").click(); }); + expect(document.documentElement.getAttribute("data-theme")).toBe(null); + }); + + test("setTheme persists to localStorage under perima-theme key", () => { + const { getByText } = render( + , + ); + act(() => { getByText("set-light").click(); }); + expect(localStorage.getItem("perima-theme")).toBe("light"); + }); + + test("reads stored theme on mount", () => { + localStorage.setItem("perima-theme", "light"); + const { getByTestId } = render( + , + ); + expect(getByTestId("theme").textContent).toBe("light"); + expect(getByTestId("effective").textContent).toBe("light"); + }); + + test("useTheme outside provider throws", () => { + const spy = vi.spyOn(console, "error").mockImplementation(() => {}); + expect(() => render()).toThrow( + /useTheme must be used within /, + ); + spy.mockRestore(); + }); +}); diff --git a/apps/desktop/src/lib/theme-provider.tsx b/apps/desktop/src/lib/theme-provider.tsx new file mode 100644 index 0000000..2aa1201 --- /dev/null +++ b/apps/desktop/src/lib/theme-provider.tsx @@ -0,0 +1,124 @@ +/** + * Theme provider for dark / light / system mode. + * + * WHY raw localStorage (not Zustand persist mw): CLAUDE.md "Frontend + * state (Batch H)" forbids `persist` middleware on the Zustand UI store + * because that store holds ephemeral app state (search query, scan + * status, notifications) that should NOT survive restarts. Theme is a + * user setting, different category — persisting it is correct. + * Implementing it via raw localStorage inside this provider keeps the + * existing rule intact. + * + * WHY effectiveTheme exposed even though no consumer reads it this slice: + * future component-level reads (e.g. Phosphor weight switching on + * resolved theme) without re-deriving from theme + matchMedia. Marked + * here so the implementer doesn't drop it as dead code. + * + * WHY systemTheme as separate state (not derived in render): we need to + * subscribe to matchMedia "change" events and re-render when the OS pref + * changes. Derived computation alone does not re-render on OS change. + * effectiveTheme is then a pure derivation: theme === "system" ? + * systemTheme : theme. No setState inside an effect body. + */ +import { + createContext, + useContext, + useEffect, + useState, + type ReactNode, +} from "react"; + +export type Theme = "dark" | "light" | "system"; +export type EffectiveTheme = "dark" | "light"; + +interface ThemeContextValue { + theme: Theme; + effectiveTheme: EffectiveTheme; + setTheme: (theme: Theme) => void; +} + +const STORAGE_KEY = "perima-theme"; + +function readStoredTheme(): Theme { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (raw === "dark" || raw === "light" || raw === "system") return raw; + } catch { + // localStorage unavailable (private mode, SSR) — fall through. + } + return "system"; +} + +function getSystemTheme(): EffectiveTheme { + if (typeof window === "undefined") return "dark"; + return window.matchMedia("(prefers-color-scheme: light)").matches + ? "light" + : "dark"; +} + +function applyEffectiveTheme(effective: EffectiveTheme): void { + if (typeof document === "undefined") return; + if (effective === "light") { + document.documentElement.setAttribute("data-theme", "light"); + } else { + document.documentElement.removeAttribute("data-theme"); + } +} + +const ThemeContext = createContext(null); + +/** Provides dark/light/system theme switching with localStorage persistence. */ +export function ThemeProvider({ children }: { children: ReactNode }) { + const [theme, setThemeState] = useState(() => readStoredTheme()); + // WHY separate systemTheme state: subscribing to matchMedia "change" + // events requires a stateful update path. effectiveTheme is then a + // pure render-time derivation (no setState in useEffect body). + const [systemTheme, setSystemTheme] = useState(() => + getSystemTheme(), + ); + + // Listen to OS-level pref changes; update systemTheme via event callback + // (not synchronously inside the effect body — avoids react-hooks/set-state-in-effect). + useEffect(() => { + if (typeof window === "undefined") return; + const mql = window.matchMedia("(prefers-color-scheme: light)"); + const handler = (e: MediaQueryListEvent) => { + setSystemTheme(e.matches ? "light" : "dark"); + }; + mql.addEventListener("change", handler); + return () => { mql.removeEventListener("change", handler); }; + }, []); + + // Derive effectiveTheme at render time — no effect needed. + const effectiveTheme: EffectiveTheme = + theme === "system" ? systemTheme : theme; + + // Sync data-theme attribute to the DOM whenever effectiveTheme changes. + useEffect(() => { + applyEffectiveTheme(effectiveTheme); + }, [effectiveTheme]); + + const setTheme = (next: Theme) => { + try { + localStorage.setItem(STORAGE_KEY, next); + } catch { + // localStorage unavailable — theme still updates in-memory. + } + setThemeState(next); + }; + + return ( + + {children} + + ); +} + +/** Returns the current theme context. Must be used within {@link ThemeProvider}. */ +export function useTheme(): ThemeContextValue { + const ctx = useContext(ThemeContext); + if (ctx === null) { + throw new Error("useTheme must be used within "); + } + return ctx; +} diff --git a/apps/desktop/src/main.tsx b/apps/desktop/src/main.tsx index aa0d02c..fd36b97 100644 --- a/apps/desktop/src/main.tsx +++ b/apps/desktop/src/main.tsx @@ -4,15 +4,25 @@ import { QueryClientProvider } from "@tanstack/react-query"; import { RouterProvider } from "@tanstack/react-router"; import { queryClient } from "./lib/queryClient"; import { router } from "./router"; -import "./App.css"; +import { ThemeProvider } from "./lib/theme-provider"; + +// WHY this import order: fonts.ts emits @font-face rules (side-effect +// import) that tokens.css's --font-display / --font-sans / --font-mono +// reference. tokens.css MUST come AFTER fonts.ts so font-face rules +// are registered first. Reversing the order falls back to next font +// in stack on first paint. +import "./styles/fonts"; +import "./styles/tokens.css"; const rootEl = document.getElementById("root"); if (!rootEl) throw new Error("Root element not found"); createRoot(rootEl).render( - - - + + + + + , ); diff --git a/apps/desktop/src/styles/fonts.ts b/apps/desktop/src/styles/fonts.ts new file mode 100644 index 0000000..137b5a5 --- /dev/null +++ b/apps/desktop/src/styles/fonts.ts @@ -0,0 +1,7 @@ +// Fontsource side-effect imports — registers @font-face rules for the +// variable woff2 font files Vite bundles. Imported by main.tsx BEFORE +// tokens.css so the @font-face declarations are registered before +// tokens.css references them via --font-display / --font-sans / --font-mono. +import "@fontsource-variable/fraunces"; +import "@fontsource-variable/inter"; +import "@fontsource-variable/jetbrains-mono"; diff --git a/apps/desktop/src/styles/tokens.css b/apps/desktop/src/styles/tokens.css new file mode 100644 index 0000000..c7aff6e --- /dev/null +++ b/apps/desktop/src/styles/tokens.css @@ -0,0 +1,197 @@ +/* WHY @theme inline: Tailwind v4 utility-class values resolve at runtime + * via var(--*); bare @theme bakes literal values at build time and ignores + * [data-theme="light"] overrides. This is the canonical shadcn-tailwind-v4 + * pattern (https://ui.shadcn.com/docs/tailwind-v4). */ + +@import "tailwindcss"; + +/* === Raw CSS variables — perima dark (default) === */ +:root { + --background: #0A0C0F; + --foreground: #F5F1EC; + --card: #12151A; + --card-foreground: #F5F1EC; + --popover: #191D24; + --popover-foreground: #F5F1EC; + --muted: #22272F; + --muted-foreground: rgba(245, 241, 236, 0.72); + --primary: #F26A4F; + --primary-foreground: #0A0C0F; + --secondary: #191D24; + --secondary-foreground: #F5F1EC; + --accent: rgba(242, 106, 79, 0.16); + --accent-foreground: #F5F1EC; + --border: rgba(255, 255, 255, 0.08); + --input: rgba(255, 255, 255, 0.08); + --ring: #F26A4F; + --destructive: #F26A4F; + --destructive-foreground: #0A0C0F; + --success: #4EC28B; + --success-foreground: #0A0C0F; + --warning: #F5B945; + --warning-foreground: #0A0C0F; + --info: #4A8BF5; + --info-foreground: #0A0C0F; + + --shadow-e1: 0 1px 2px rgba(0, 0, 0, 0.4); + --shadow-e2: 0 8px 24px rgba(0, 0, 0, 0.45); + --shadow-e3: 0 24px 64px rgba(0, 0, 0, 0.55); + + /* Identity layer — referenced via var() directly, not utility-generated */ + --perima-spectrum-blue: #4A8BF5; + --perima-spectrum-aqua: #5BE2C2; + --perima-spectrum-amber: #F5B945; + --perima-spectrum-coral: #F26A4F; + --perima-spectrum: linear-gradient( + 135deg, + var(--perima-spectrum-blue) 0%, + var(--perima-spectrum-aqua) 35%, + var(--perima-spectrum-amber) 65%, + var(--perima-spectrum-coral) 100% + ); +} + +/* === Light theme overrides — color + shadow only === */ +/* Fonts/radii/motion are theme-invariant; spectrum is identity-invariant. */ +[data-theme="light"] { + --background: #FBF8F3; + --foreground: #141618; + --card: #FFFFFF; + --card-foreground: #141618; + --popover: #F5F1EC; + --popover-foreground: #141618; + --muted: #EAE4DA; + --muted-foreground: rgba(20, 22, 24, 0.72); + --primary: #F26A4F; + --primary-foreground: #FFFFFF; + --secondary: #EAE4DA; + --secondary-foreground: #141618; + --accent: rgba(242, 106, 79, 0.12); + --accent-foreground: #141618; + --border: rgba(20, 22, 24, 0.08); + --input: rgba(20, 22, 24, 0.08); + --ring: #F26A4F; + --destructive: #D8573E; + --destructive-foreground: #FFFFFF; + --shadow-e1: 0 1px 2px rgba(0, 0, 0, 0.05); + --shadow-e2: 0 8px 24px rgba(0, 0, 0, 0.08); + --shadow-e3: 0 24px 64px rgba(0, 0, 0, 0.12); +} + +/* === Tailwind v4 utility-class aliases === */ +@theme inline { + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); + --color-destructive: var(--destructive); + --color-destructive-foreground: var(--destructive-foreground); + --color-success: var(--success); + --color-success-foreground: var(--success-foreground); + --color-warning: var(--warning); + --color-warning-foreground: var(--warning-foreground); + --color-info: var(--info); + --color-info-foreground: var(--info-foreground); + + --radius-sm: 8px; + --radius-md: 14px; + --radius-lg: 20px; + --radius-xl: 28px; + + --shadow-e1: var(--shadow-e1); + --shadow-e2: var(--shadow-e2); + --shadow-e3: var(--shadow-e3); + + --ease-perima: cubic-bezier(0.22, 0.61, 0.36, 1); + --duration-micro: 180ms; + --duration-panel: 280ms; + --duration-page: 420ms; + + --font-display: "Fraunces Variable", "Times New Roman", Georgia, serif; + --font-sans: "Inter Variable", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; + --font-mono: "JetBrains Mono Variable", ui-monospace, "SF Mono", Menlo, monospace; +} + +/* === Spectrum helpers — identity layer, not utility-generated === */ +.bg-spectrum { background: var(--perima-spectrum); } +.text-spectrum { + background: var(--perima-spectrum); + -webkit-background-clip: text; + background-clip: text; + color: transparent; +} + +/* === Typography — semantic classes (Tailwind has no font-variation-settings utility) === */ +.display-xl { + font-family: var(--font-display); + font-size: 4.5rem; + line-height: 1.08; + letter-spacing: -0.02em; + font-weight: 600; + font-variation-settings: "opsz" 144; + margin: 0; +} +.h1 { + font-family: var(--font-display); + font-size: 3.5rem; + line-height: 1.08; + letter-spacing: -0.02em; + font-weight: 600; + font-variation-settings: "opsz" 96; + margin: 0; +} +.h2 { + font-family: var(--font-display); + font-size: 2.5rem; + line-height: 1.08; + letter-spacing: -0.02em; + font-weight: 600; + font-variation-settings: "opsz" 48; + margin: 0; +} +.h3 { + font-family: var(--font-display); + font-size: 1.75rem; + line-height: 1.2; + letter-spacing: -0.02em; + font-weight: 600; + font-variation-settings: "opsz" 36; + margin: 0; +} +.h4 { + font-family: var(--font-sans); + font-size: 1.25rem; + line-height: 1.2; + font-weight: 600; + margin: 0; +} +.eyebrow { + font-family: var(--font-sans); + font-size: 0.75rem; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; +} +.caption { + font-family: var(--font-sans); + font-size: 0.875rem; + line-height: 1.55; +} +.mono-metadata { + font-family: var(--font-mono); + font-size: 0.875rem; + line-height: 1.55; +} From 2991221511bcf44c52d89dfa1e2072484724f257 Mon Sep 17 00:00:00 2001 From: utof Date: Fri, 1 May 2026 22:23:33 +0400 Subject: [PATCH 02/11] feat(desktop): App shell rewrite + ThemeToggle component MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - App.tsx mounts ThemeToggle in topbar; removes legacy bg-gray-900/text-gray-100 classes; adds
slot - ThemeToggle: 3-state cycle (dark → light → system) via Phosphor MoonIcon/SunIcon/MonitorIcon - aria-label encodes current mode + next action for screen readers --- apps/desktop/src/App.tsx | 34 ++++++++----- .../src/__tests__/ThemeToggle.test.tsx | 51 +++++++++++++++++++ apps/desktop/src/components/ThemeToggle.tsx | 46 +++++++++++++++++ 3 files changed, 117 insertions(+), 14 deletions(-) create mode 100644 apps/desktop/src/__tests__/ThemeToggle.test.tsx create mode 100644 apps/desktop/src/components/ThemeToggle.tsx diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index 9b44b68..e67ce1d 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -1,16 +1,20 @@ /** - * Root-route component. Mounts inside via router.tsx. + * Root-route shell. Mounts inside via router.tsx, which + * wraps us as `` — i.e. the matched child route is + * passed via `children`. Preserve this contract; do NOT switch to + * importing here. * - * WHY this is "App" not "RootRoute": git-history continuity. The file - * is "the root component" before and after Batch H; tests + imports - * preserve their existing paths. Naming clarification: post-Batch-H, - * "App" = root-route component, NOT application root. Application - * root = main.tsx + RouterProvider + QueryClientProvider. + * Owns: layout chrome (header / main slot / footer) + useDomainEvents() + * mount + NotificationStack mount + ThemeToggle slot. * - * Owns: layout chrome (header/footer) + useDomainEvents() mount. - * Does NOT own: file/tag/search composition (lives in routes/index.tsx) - * or any local useState/useEffect — server state via TanStack Query, - * UI state via useUiStore. + * Does NOT own: ThemeProvider (mounted in main.tsx, wrapping us); + * file/tag/search composition (lives in routes/index.tsx). + * + * WHY direct utilities (font-display text-2xl font-semibold tracking-tight) + * for the wordmark instead of .h1 / .display-xl: the wordmark is small + * (~1.5rem), not hero-sized. The semantic typography classes bake in + * size + opsz + line-height for editorial display use; mixing them with + * utility-overrides relies on Tailwind layer order and is fragile. */ import type { ReactNode } from "react"; import { useDomainEvents } from "./hooks/useDomainEvents"; @@ -19,21 +23,23 @@ import ScanButton from "./components/ScanButton"; import StatusBar from "./components/StatusBar"; import ViewModeToggle from "./components/ViewModeToggle"; import NotificationStack from "./components/NotificationStack"; +import ThemeToggle from "./components/ThemeToggle"; export default function App({ children }: { children?: ReactNode }) { useDomainEvents(); return ( -
-
-

perima

+
+
+

perima

+
- {children} +
{children}
diff --git a/apps/desktop/src/__tests__/ThemeToggle.test.tsx b/apps/desktop/src/__tests__/ThemeToggle.test.tsx new file mode 100644 index 0000000..1f576c3 --- /dev/null +++ b/apps/desktop/src/__tests__/ThemeToggle.test.tsx @@ -0,0 +1,51 @@ +import { describe, test, expect, beforeEach, vi } from "vitest"; +import { render, fireEvent } from "@testing-library/react"; +import { ThemeProvider } from "../lib/theme-provider"; +import ThemeToggle from "../components/ThemeToggle"; + +describe("ThemeToggle", () => { + beforeEach(() => { + Object.defineProperty(window, "matchMedia", { + writable: true, + value: vi.fn().mockImplementation((query: string) => ({ + matches: false, + media: query, + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + }); + }); + + test("renders Moon icon initially (dark mode default for tests)", () => { + localStorage.clear(); + localStorage.setItem("perima-theme", "dark"); + const { container } = render( + , + ); + const btn = container.querySelector("button"); + expect(btn).not.toBeNull(); + expect(btn!.getAttribute("aria-label")).toMatch(/dark/i); + }); + + test("clicking cycles dark -> light -> system -> dark", () => { + localStorage.clear(); + localStorage.setItem("perima-theme", "dark"); + const { container } = render( + , + ); + const btn = container.querySelector("button")!; + + fireEvent.click(btn); + expect(btn.getAttribute("aria-label")).toMatch(/light/i); + + fireEvent.click(btn); + expect(btn.getAttribute("aria-label")).toMatch(/system/i); + + fireEvent.click(btn); + expect(btn.getAttribute("aria-label")).toMatch(/dark/i); + }); +}); diff --git a/apps/desktop/src/components/ThemeToggle.tsx b/apps/desktop/src/components/ThemeToggle.tsx new file mode 100644 index 0000000..b361e78 --- /dev/null +++ b/apps/desktop/src/components/ThemeToggle.tsx @@ -0,0 +1,46 @@ +/** + * Three-state theme cycle button. dark → light → system → dark. + * Icon shows the CURRENT mode; click advances to the next. + * + * WHY no useMemo on the icon map: React Compiler 1.0 handles memoization + * automatically (CLAUDE.md, Batch H — manual useMemo banned). + */ +import { MoonIcon, SunIcon, MonitorIcon } from "@phosphor-icons/react"; +import { useTheme, type Theme } from "../lib/theme-provider"; + +const NEXT: Record = { + dark: "light", + light: "system", + system: "dark", +}; + +export default function ThemeToggle() { + const { theme, setTheme } = useTheme(); + const next = NEXT[theme]; + + let Icon = MoonIcon; + let label = "Theme: dark (click for light)"; + if (theme === "light") { + Icon = SunIcon; + label = "Theme: light (click for system)"; + } else if (theme === "system") { + Icon = MonitorIcon; + label = "Theme: system (click for dark)"; + } + + return ( + + ); +} From d7b78df56ad482e379184d3745845730d531b27c Mon Sep 17 00:00:00 2001 From: utof Date: Fri, 1 May 2026 22:28:51 +0400 Subject: [PATCH 03/11] feat(desktop): StatusBar + NotificationStack on design tokens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - StatusBar: bg-card / border-border / text-muted-foreground; Backup button uses bg-secondary + design-token focus ring - NotificationStack: toasts use bg-popover + shadow-e2; error variant uses bg-destructive/10; container moved to top-4 right-4 with pointer-events isolation - No logic changed — className-only refactor --- apps/desktop/src/components/NotificationStack.tsx | 13 ++++++++----- apps/desktop/src/components/StatusBar.tsx | 8 ++++++-- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/apps/desktop/src/components/NotificationStack.tsx b/apps/desktop/src/components/NotificationStack.tsx index fc201fc..b3ae1a9 100644 --- a/apps/desktop/src/components/NotificationStack.tsx +++ b/apps/desktop/src/components/NotificationStack.tsx @@ -23,16 +23,19 @@ function NotificationItem({ notification }: { notification: Notification }) { return () => { clearTimeout(timer); }; }, [id, kind, dismiss]); - const bg = kind === "error" ? "bg-red-700" : "bg-blue-700"; + const variantClasses = + kind === "error" + ? "border-destructive bg-destructive/10 text-destructive-foreground" + : "border-info bg-popover text-popover-foreground"; return (
- {message} + {message} From 5a005b0db703383a755556fe5125caab14122c49 Mon Sep 17 00:00:00 2001 From: utof Date: Fri, 1 May 2026 22:37:17 +0400 Subject: [PATCH 04/11] feat(desktop): controls cluster on design tokens (ScanButton + SearchBar + ViewModeToggle) - ScanButton: bg-primary capsule with DownloadSimpleIcon; preserves existing dialog + scanMutation flow - SearchBar: bg-input capsule with MagnifyingGlassIcon prefix; debounce/MIN_QUERY_LEN logic untouched - ViewModeToggle: bg-secondary segmented with GridFourIcon/ListIcon; active state via bg-accent --- apps/desktop/src/components/ScanButton.tsx | 9 ++++- apps/desktop/src/components/SearchBar.tsx | 28 ++++++++++----- .../desktop/src/components/ViewModeToggle.tsx | 35 +++++++++++-------- 3 files changed, 49 insertions(+), 23 deletions(-) diff --git a/apps/desktop/src/components/ScanButton.tsx b/apps/desktop/src/components/ScanButton.tsx index a8294ce..2fe0b17 100644 --- a/apps/desktop/src/components/ScanButton.tsx +++ b/apps/desktop/src/components/ScanButton.tsx @@ -4,6 +4,7 @@ */ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { open } from "@tauri-apps/plugin-dialog"; +import { DownloadSimpleIcon } from "@phosphor-icons/react"; import * as api from "../api"; import { filesKeys } from "../queries/files"; import { tagsKeys } from "../queries/tags"; @@ -67,8 +68,14 @@ export default function ScanButton() { type="button" onClick={() => { void onClick(); }} disabled={busy} - className="px-3 py-1.5 text-sm font-medium rounded bg-emerald-600 text-white hover:bg-emerald-500 disabled:opacity-50 disabled:cursor-not-allowed" + className="inline-flex items-center gap-2 rounded-full px-5 py-2 text-sm font-medium + bg-primary text-primary-foreground hover:bg-primary/90 + transition-colors duration-micro ease-perima + focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring + focus-visible:ring-offset-2 focus-visible:ring-offset-background + disabled:opacity-40 disabled:pointer-events-none" > + {busy ? "Scanning…" : "Scan folder"} ); diff --git a/apps/desktop/src/components/SearchBar.tsx b/apps/desktop/src/components/SearchBar.tsx index b8d91f9..a738b15 100644 --- a/apps/desktop/src/components/SearchBar.tsx +++ b/apps/desktop/src/components/SearchBar.tsx @@ -10,6 +10,7 @@ * sanitiser (escape FTS5 metacharacters), clearedRef deduplication. */ import { useEffect, useRef } from "react"; +import { MagnifyingGlassIcon } from "@phosphor-icons/react"; import { useUiStore } from "../stores/ui"; import { MIN_QUERY_LEN } from "../queries/search"; import { buildFtsQuery } from "../lib/search"; @@ -45,13 +46,24 @@ export default function SearchBar() { }, [searchQuery, setDebouncedQuery]); return ( - { setSearchQuery(e.target.value); }} - className="px-3 py-1.5 bg-gray-900 text-gray-100 rounded border border-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm w-64" - /> +
+ + { setSearchQuery(e.target.value); }} + className="rounded-full bg-input text-foreground placeholder:text-muted-foreground + pl-9 pr-4 py-2 text-sm border border-border w-64 + focus-visible:outline-none focus-visible:ring-2 + focus-visible:ring-ring focus-visible:ring-offset-0 + focus-visible:border-ring transition-colors duration-micro" + /> +
); } diff --git a/apps/desktop/src/components/ViewModeToggle.tsx b/apps/desktop/src/components/ViewModeToggle.tsx index 5e46f9e..f13c45a 100644 --- a/apps/desktop/src/components/ViewModeToggle.tsx +++ b/apps/desktop/src/components/ViewModeToggle.tsx @@ -1,3 +1,4 @@ +import { GridFourIcon, ListIcon } from "@phosphor-icons/react"; import { useUiStore } from "../stores/ui"; /** @@ -5,37 +6,43 @@ import { useUiStore } from "../stores/ui"; * Reads + dispatches viewMode via the Zustand store. * * WHY segmented control (not a single button that flips): two explicit - * labels make the inactive option discoverable at a glance and match + * options make the inactive choice discoverable at a glance and match * desktop convention (Finder/Files-style switchers). */ export default function ViewModeToggle() { const viewMode = useUiStore((s) => s.viewMode); const setViewMode = useUiStore((s) => s.setViewMode); - const base = - "px-3 py-1.5 text-sm font-medium rounded transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500"; - const active = "bg-blue-600 text-white"; - const inactive = "bg-gray-700 text-gray-200 hover:bg-gray-600"; return (
); From 785c31d92826a9748e953cd33938ac6c766bf71c Mon Sep 17 00:00:00 2001 From: utof Date: Fri, 1 May 2026 22:41:51 +0400 Subject: [PATCH 05/11] fix(desktop): focus-visible ring on ViewModeToggle segmented buttons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A11y gap surfaced in T4 review — segmented buttons inside the bg-secondary pill had no visible focus indicator (Tailwind preflight resets browser default). Adds focus-visible:ring-2 ring-ring with offset-0 so the indicator stays inside the pill instead of breaking its rounded edge. --- apps/desktop/src/components/ViewModeToggle.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/desktop/src/components/ViewModeToggle.tsx b/apps/desktop/src/components/ViewModeToggle.tsx index f13c45a..69b0def 100644 --- a/apps/desktop/src/components/ViewModeToggle.tsx +++ b/apps/desktop/src/components/ViewModeToggle.tsx @@ -25,6 +25,8 @@ export default function ViewModeToggle() { aria-label="Grid view" className={`inline-flex items-center justify-center rounded-full px-3 py-1 transition-colors duration-micro ease-perima + focus-visible:outline-none focus-visible:ring-2 + focus-visible:ring-ring focus-visible:ring-offset-0 ${viewMode === "grid" ? "bg-accent text-accent-foreground" : "text-muted-foreground hover:text-foreground"}`} @@ -38,6 +40,8 @@ export default function ViewModeToggle() { aria-label="Table view" className={`inline-flex items-center justify-center rounded-full px-3 py-1 transition-colors duration-micro ease-perima + focus-visible:outline-none focus-visible:ring-2 + focus-visible:ring-ring focus-visible:ring-offset-0 ${viewMode === "table" ? "bg-accent text-accent-foreground" : "text-muted-foreground hover:text-foreground"}`} From 3911d1d1387d42600da9f12f3c11b4b768e4e7e3 Mon Sep 17 00:00:00 2001 From: utof Date: Fri, 1 May 2026 22:47:06 +0400 Subject: [PATCH 06/11] feat(desktop): file display cluster on design tokens (FileGrid + FileTable + FileSidebar) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - FileGrid: bg-card tiles + shadow-e1 → shadow-e2 hover; aspect-square moved to thumbnail wrapper so tile fits filename + tags without cropping - FileTable: hairline rows on border-border; selected row uses bg-accent; mono-metadata for hash/path/volume cells; eyebrow class for column headers - FileSidebar: bg-popover panel widened to w-96; XIcon close button; mono-metadata for hashes; primary capsule for Compute action; click-to-copy hover uses text-primary --- apps/desktop/src/components/FileGrid.tsx | 18 ++++++------- apps/desktop/src/components/FileSidebar.tsx | 25 ++++++++--------- apps/desktop/src/components/FileTable.tsx | 30 ++++++++++----------- 3 files changed, 37 insertions(+), 36 deletions(-) diff --git a/apps/desktop/src/components/FileGrid.tsx b/apps/desktop/src/components/FileGrid.tsx index 0ff24e4..0c14157 100644 --- a/apps/desktop/src/components/FileGrid.tsx +++ b/apps/desktop/src/components/FileGrid.tsx @@ -24,19 +24,19 @@ interface FileGridProps { export default function FileGrid({ files, loading = false }: FileGridProps) { if (loading) { return ( -
+
Loading...
); } if (files.length === 0) { return ( -
No files indexed yet
+
No files indexed yet
); } return (
{files.map((f) => ( @@ -55,10 +55,10 @@ function FileGridTile({ file }: { file: FileWithTagsPayload }) { const filename = file.relative_path.split("/").pop() ?? file.relative_path; return (
-
+
{ready ? ( // WHY convertFileSrc: Tauri's asset protocol maps absolute // disk paths to a custom URL scheme the WebView can load @@ -72,16 +72,16 @@ function FileGridTile({ file }: { file: FileWithTagsPayload }) { )}
-
+
{filename}
{file.tags.length > 0 && ( -
+
{file.tags.slice(0, 3).map((t) => ( ))} {file.tags.length > 3 && ( - + +{file.tags.length - 3} )} @@ -109,7 +109,7 @@ function PlaceholderIcon({ status }: { status: string | null | undefined }) { : "no thumbnail"; return (
{/* Header */}
-

File detail

+

File detail

{/* UUID */}
-

+

UUID

{file.file_uuid.slice(0, 8)}… @@ -93,11 +94,11 @@ export default function FileSidebar({ file, onClose }: FileSidebarProps) { {/* Quick hash */} {file.quick_hash !== null && (
-

+

Quick hash

{file.quick_hash} @@ -107,14 +108,14 @@ export default function FileSidebar({ file, onClose }: FileSidebarProps) { {/* Full hash */}
-

+

Full hash

{!isPlaceholder && file.hash !== null ? ( // WHY click-to-copy on the element: spec §4.6.3 UX win, // ≤10 LOC so we land it inline rather than deferring. ) : ( - + pending )} @@ -135,7 +136,7 @@ export default function FileSidebar({ file, onClose }: FileSidebarProps) { disabled={compute.isPending} aria-label="Compute canonical hash" data-testid="compute-hash-btn" - className="mt-auto bg-blue-600 hover:bg-blue-500 disabled:bg-gray-600 text-white text-xs font-medium rounded px-3 py-2 transition-colors" + className="mt-auto inline-flex items-center justify-center gap-2 rounded-full px-5 py-2 text-sm font-medium bg-primary text-primary-foreground hover:bg-primary/90 transition-colors duration-micro ease-perima focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:opacity-40 disabled:pointer-events-none" > {compute.isPending ? "Computing…" : "Compute canonical hash"} diff --git a/apps/desktop/src/components/FileTable.tsx b/apps/desktop/src/components/FileTable.tsx index 34efe46..819b0d7 100644 --- a/apps/desktop/src/components/FileTable.tsx +++ b/apps/desktop/src/components/FileTable.tsx @@ -81,7 +81,7 @@ function RowTagsCell({ placeholder="+ tag" aria-label={`Add tag to file ${labelKey.slice(0, 8)}`} disabled={isPending} - className="w-20 bg-gray-700 text-white text-xs rounded px-1.5 py-0.5 placeholder:text-gray-400 focus:outline-none focus:ring-1 focus:ring-blue-500" + className="w-20 bg-input text-foreground text-xs rounded-md border border-border px-2 py-0.5 placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" />
); @@ -148,7 +148,7 @@ export default function FileTable({ files, loading }: FileTableProps) { }); const thCls = - "px-3 py-2 text-left text-xs font-semibold uppercase tracking-wider text-gray-400 cursor-pointer select-none hover:text-gray-100"; + "px-4 py-2 text-left eyebrow text-muted-foreground cursor-pointer select-none hover:text-foreground"; function arrow(col: SortColumn) { if (sortBy !== col) return null; @@ -157,8 +157,8 @@ export default function FileTable({ files, loading }: FileTableProps) { return (
- - +
+ {loading ? ( - ) : sorted.length === 0 ? ( - @@ -208,14 +208,14 @@ export default function FileTable({ files, loading }: FileTableProps) { selectedFileUuid === f.file_uuid ? null : f.file_uuid, ); }} - className={`border-t border-gray-700 cursor-pointer ${ + className={`border-b border-border cursor-pointer transition-colors duration-micro ${ selectedFileUuid === f.file_uuid - ? "bg-blue-900" - : "odd:bg-gray-900 even:bg-gray-800 hover:bg-gray-700" + ? "bg-accent text-accent-foreground" + : "hover:bg-muted" }`} aria-selected={selectedFileUuid === f.file_uuid} > - - - + - - - +
{ handleSort("hash"); }}> HASH{arrow("hash")} @@ -181,13 +181,13 @@ export default function FileTable({ files, loading }: FileTableProps) {
+ Loading...
+ No files indexed yet
+ {/* WHY isPlaceholder check: pre-V012 blake3_hash is NOT NULL — * quick_hash is stored there until compute_full_hash promotes * the real hash. f.hash === null never fires today; equality @@ -224,15 +224,15 @@ export default function FileTable({ files, loading }: FileTableProps) { ? "pending" : f.hash.slice(0, 8)} {humanSize(f.size)} + {humanSize(f.size)} {f.volume_id.slice(0, 8)} + {f.relative_path} {f.status} + {f.status} Date: Fri, 1 May 2026 22:54:49 +0400 Subject: [PATCH 07/11] feat(desktop): tags cluster on design tokens (TagSidebar + TagChip) - TagSidebar: bg-card panel widened to w-64; bg-accent active row; rounded-md hover; tabular-nums on count badges - TagChip: uniform bg-muted capsule (drops 12-color procedural palette); XIcon replaces × on removable variant; text-sm --- apps/desktop/src/__tests__/TagChip.test.tsx | 22 ++-------- apps/desktop/src/components/TagChip.tsx | 48 ++++----------------- apps/desktop/src/components/TagSidebar.tsx | 14 +++--- 3 files changed, 19 insertions(+), 65 deletions(-) diff --git a/apps/desktop/src/__tests__/TagChip.test.tsx b/apps/desktop/src/__tests__/TagChip.test.tsx index db64ca7..295ec06 100644 --- a/apps/desktop/src/__tests__/TagChip.test.tsx +++ b/apps/desktop/src/__tests__/TagChip.test.tsx @@ -2,14 +2,6 @@ import { render, screen } from "@testing-library/react"; import { describe, expect, test, vi } from "vitest"; import TagChip from "../components/TagChip"; -// colorIndexFor is not exported, so we test it indirectly via the rendered chip. -function colorIndexFor(name: string): number { - const bytes = new TextEncoder().encode(name); - let sum = 0; - for (const b of bytes) sum = (sum + b) % 256; - return sum % 12; -} - describe("TagChip", () => { const sampleTag = { id: "00000000-0000-0000-0000-000000000001", @@ -35,17 +27,11 @@ describe("TagChip", () => { expect(onRemove).toHaveBeenCalledTimes(1); }); - test("chip has a color class derived from tag name", () => { + test("chip uses uniform bg-muted class (design-system-v1 drops per-tag coloring)", () => { render(); const chip = screen.getByTestId("tag-chip"); - // colorIndexFor("vacation") must be stable — pin the expected index so - // an accidental formula change is caught immediately. - const idx = colorIndexFor("vacation"); - const COLORS = [ - "bg-red-700","bg-orange-700","bg-amber-700","bg-yellow-700", - "bg-lime-700","bg-green-700","bg-emerald-700","bg-teal-700", - "bg-cyan-700","bg-sky-700","bg-blue-700","bg-indigo-700", - ]; - expect(chip.className).toContain(COLORS[idx]); + // WHY: design-system-v1 replaces the 12-color procedural palette with a + // single uniform bg-muted capsule. Tag identity is conveyed by name text. + expect(chip.className).toContain("bg-muted"); }); }); diff --git a/apps/desktop/src/components/TagChip.tsx b/apps/desktop/src/components/TagChip.tsx index 0f155a1..22187c1 100644 --- a/apps/desktop/src/components/TagChip.tsx +++ b/apps/desktop/src/components/TagChip.tsx @@ -1,55 +1,23 @@ +import { XIcon } from "@phosphor-icons/react"; import type { Tag } from "../bindings"; /** Props for {@link TagChip}. */ interface TagChipProps { /** Tag to render. */ tag: Tag; - /** Optional callback to remove the tag; when provided, renders an x button. */ + /** Optional callback to remove the tag; when provided, renders an X button. */ onRemove?: () => void; } /** - * Compute a procedural color index (0..11) from a tag name. - * - * WHY byte-sum mod 12 (not spec's blake3[0] % 12): blake3 isn't available - * in the TS bundle without a WASM dependency. The byte-sum is a deliberate - * intentional deviation from the spec for cosmetic coloring only — collisions - * (two tags sharing a chip color) are harmless. If a native/backend shell - * ever needs to agree on chip color, revisit with a shared WASM blake3. - */ -function colorIndexFor(name: string): number { - const bytes = new TextEncoder().encode(name); - let sum = 0; - for (const b of bytes) sum = (sum + b) % 256; - return sum % 12; -} - -const CHIP_COLORS = [ - "bg-red-700", - "bg-orange-700", - "bg-amber-700", - "bg-yellow-700", - "bg-lime-700", - "bg-green-700", - "bg-emerald-700", - "bg-teal-700", - "bg-cyan-700", - "bg-sky-700", - "bg-blue-700", - "bg-indigo-700", -] as const; - -/** - * A colored pill displaying a tag name, with optional remove button. + * A muted-background pill displaying a tag name, with an optional X icon + * for removal. Tag identity is conveyed by the name text — chip color is + * uniform across all tags by design (design-system-v1). */ export default function TagChip({ tag, onRemove }: TagChipProps) { - // WHY fallback to bg-blue-700: colorIndexFor is bounded to 0..11 which - // matches CHIP_COLORS.length exactly; the fallback satisfies strict-type-checked - // (noUncheckedIndexedAccess sees the index as `number`, not a literal). - const bg = CHIP_COLORS[colorIndexFor(tag.name)] ?? "bg-blue-700"; return ( {tag.name} @@ -58,9 +26,9 @@ export default function TagChip({ tag, onRemove }: TagChipProps) { type="button" onClick={onRemove} aria-label={`Remove ${tag.name}`} - className="ml-0.5 text-white/80 hover:text-white" + className="inline-flex items-center justify-center rounded-full text-muted-foreground hover:text-foreground transition-colors duration-micro ease-perima focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" > - × + )} diff --git a/apps/desktop/src/components/TagSidebar.tsx b/apps/desktop/src/components/TagSidebar.tsx index 1461f5d..04df69d 100644 --- a/apps/desktop/src/components/TagSidebar.tsx +++ b/apps/desktop/src/components/TagSidebar.tsx @@ -43,7 +43,7 @@ export default function TagSidebar({ return (