From 47ad8479cb6aca8845d41398b522d65645431a12 Mon Sep 17 00:00:00 2001 From: AKHIL Date: Sat, 15 Aug 2026 20:22:01 +0530 Subject: [PATCH 1/9] Delete ci.yml --- .github/workflows/ci.yml | 1 - 1 file changed, 1 deletion(-) delete mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml deleted file mode 100644 index 58439813..00000000 --- a/.github/workflows/ci.yml +++ /dev/null @@ -1 +0,0 @@ -name: CI From 774db357494a74f31f092a822a58091475c519f4 Mon Sep 17 00:00:00 2001 From: AKHIL Date: Sat, 15 Aug 2026 21:38:53 +0530 Subject: [PATCH 2/9] Update platform-pages.ts --- apps/web/src/lib/seo/platform-pages.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/web/src/lib/seo/platform-pages.ts b/apps/web/src/lib/seo/platform-pages.ts index 38636766..10e2e937 100644 --- a/apps/web/src/lib/seo/platform-pages.ts +++ b/apps/web/src/lib/seo/platform-pages.ts @@ -33,7 +33,6 @@ export const platformSeoPages: PlatformSeoPage[] = [ intro: 'MyDevTools brings the utilities developers reach for every day into one desktop workspace that runs offline: format data, test APIs, generate tokens, inspect encodings, manage secure notes, and stay in flow — with everything processed locally.', primaryCta: { href: '/tools', label: 'Browse all tools' }, - secondaryCta: { href: '/login', label: 'Open dashboard' }, sections: [ { title: 'Built for search-worthy developer workflows', From 40b7dcdf40b82a3c79cc01ba063c8b8baca1afe4 Mon Sep 17 00:00:00 2001 From: AKHIL Date: Sat, 15 Aug 2026 21:47:01 +0530 Subject: [PATCH 3/9] Update header.tsx --- apps/web/src/components/header.tsx | 26 +------------------------- 1 file changed, 1 insertion(+), 25 deletions(-) diff --git a/apps/web/src/components/header.tsx b/apps/web/src/components/header.tsx index d8c2787b..b4bdd551 100644 --- a/apps/web/src/components/header.tsx +++ b/apps/web/src/components/header.tsx @@ -49,8 +49,6 @@ type HeaderProps = { export function Header({ showThemeToggle = true }: HeaderProps) { const [isMenuOpen, setIsMenuOpen] = useState(false); const [scrolled, setScrolled] = useState(false); - // No accounts — the only action is getting the app. - const cta = { href: "/download", label: "Download" }; useEffect(() => { const onScroll = () => setScrolled(window.scrollY > 8); @@ -170,12 +168,6 @@ export function Header({ showThemeToggle = true }: HeaderProps) { {/* Desktop Actions */}
{showThemeToggle ? : null} - - {cta.label} -
@@ -231,25 +223,9 @@ export function Header({ showThemeToggle = true }: HeaderProps) { ))} - - setIsMenuOpen(false)} - className="mdt-btn-grad flex h-12 items-center justify-center rounded-xl px-4 text-base font-medium" - > - {cta.label} - - - {showThemeToggle ? ( Date: Sat, 15 Aug 2026 21:52:51 +0530 Subject: [PATCH 4/9] Update header.tsx --- apps/web/src/components/header.tsx | 13 +------------ 1 file changed, 1 insertion(+), 12 deletions(-) diff --git a/apps/web/src/components/header.tsx b/apps/web/src/components/header.tsx index b4bdd551..732ca398 100644 --- a/apps/web/src/components/header.tsx +++ b/apps/web/src/components/header.tsx @@ -3,7 +3,6 @@ import { useState, useEffect, type ElementType } from "react"; import Link from "next/link"; import { Menu, X } from "lucide-react"; import { Button } from "@/components/ui/button"; -import { ModeToggle } from "./modeToggle"; import { Logo } from "./logo"; import { motion, AnimatePresence } from "framer-motion"; import { cn } from "@/lib/utils"; @@ -41,12 +40,7 @@ const menuItemVariants = { }), }; -type HeaderProps = { - /** When false, theme toggle is hidden (e.g. marketing page fixed to dark). */ - showThemeToggle?: boolean; -}; - -export function Header({ showThemeToggle = true }: HeaderProps) { +export function Header() { const [isMenuOpen, setIsMenuOpen] = useState(false); const [scrolled, setScrolled] = useState(false); @@ -164,11 +158,6 @@ export function Header({ showThemeToggle = true }: HeaderProps) { )} - - {/* Desktop Actions */} -
- {showThemeToggle ? : null} -
From 37f9986e48dff518415f4341df8c0a02c68bf54f Mon Sep 17 00:00:00 2001 From: AKHIL Date: Sat, 15 Aug 2026 22:00:25 +0530 Subject: [PATCH 5/9] theme toggle removal --- apps/web/src/app/blog/[slug]/page.tsx | 2 +- apps/web/src/app/blog/page.tsx | 2 +- apps/web/src/app/changelog/page.tsx | 2 +- apps/web/src/app/compare/[slug]/page.tsx | 2 +- apps/web/src/app/page.tsx | 2 +- apps/web/src/app/tools/[slug]/page.tsx | 2 +- apps/web/src/app/tools/page.tsx | 2 +- apps/web/src/components/header.tsx | 15 ---- .../web/src/components/marketing-seo-page.tsx | 2 +- apps/web/src/components/modeToggle.tsx | 80 ------------------- 10 files changed, 8 insertions(+), 103 deletions(-) delete mode 100644 apps/web/src/components/modeToggle.tsx diff --git a/apps/web/src/app/blog/[slug]/page.tsx b/apps/web/src/app/blog/[slug]/page.tsx index f1de8cf5..66091ebc 100644 --- a/apps/web/src/app/blog/[slug]/page.tsx +++ b/apps/web/src/app/blog/[slug]/page.tsx @@ -148,7 +148,7 @@ export default async function BlogPostPage({ type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} /> -
+
diff --git a/apps/web/src/app/blog/page.tsx b/apps/web/src/app/blog/page.tsx index 9c901408..7d87f6de 100644 --- a/apps/web/src/app/blog/page.tsx +++ b/apps/web/src/app/blog/page.tsx @@ -73,7 +73,7 @@ export default function BlogPage() { type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} /> -
+
diff --git a/apps/web/src/app/changelog/page.tsx b/apps/web/src/app/changelog/page.tsx index d0a0d341..e980c6b0 100644 --- a/apps/web/src/app/changelog/page.tsx +++ b/apps/web/src/app/changelog/page.tsx @@ -104,7 +104,7 @@ export default function ChangelogPage() { type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} /> -
+
diff --git a/apps/web/src/app/compare/[slug]/page.tsx b/apps/web/src/app/compare/[slug]/page.tsx index 3495e37f..e47e015e 100644 --- a/apps/web/src/app/compare/[slug]/page.tsx +++ b/apps/web/src/app/compare/[slug]/page.tsx @@ -100,7 +100,7 @@ export default async function ComparisonPage({ type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} /> -
+
diff --git a/apps/web/src/app/page.tsx b/apps/web/src/app/page.tsx index 204308a3..a225e0cb 100644 --- a/apps/web/src/app/page.tsx +++ b/apps/web/src/app/page.tsx @@ -239,7 +239,7 @@ export default function Page() {
-
+
{/* ── Hero ────────────────────────────────────────────────────────────── */}
diff --git a/apps/web/src/app/tools/[slug]/page.tsx b/apps/web/src/app/tools/[slug]/page.tsx index a2a5a1e5..358167d1 100644 --- a/apps/web/src/app/tools/[slug]/page.tsx +++ b/apps/web/src/app/tools/[slug]/page.tsx @@ -83,7 +83,7 @@ export default async function ToolLandingPage({ /> )} -
+
{/* ── Hero ── */} diff --git a/apps/web/src/app/tools/page.tsx b/apps/web/src/app/tools/page.tsx index c102d2e0..2509c8f0 100644 --- a/apps/web/src/app/tools/page.tsx +++ b/apps/web/src/app/tools/page.tsx @@ -50,7 +50,7 @@ export default function ToolsIndexPage() { -
+
{/* ── Hero ── */} diff --git a/apps/web/src/components/header.tsx b/apps/web/src/components/header.tsx index 732ca398..2ecccd58 100644 --- a/apps/web/src/components/header.tsx +++ b/apps/web/src/components/header.tsx @@ -211,21 +211,6 @@ export function Header() { ))} - - {showThemeToggle ? ( - -
- Theme - -
-
- ) : null} diff --git a/apps/web/src/components/marketing-seo-page.tsx b/apps/web/src/components/marketing-seo-page.tsx index b0145bb2..f080e784 100644 --- a/apps/web/src/components/marketing-seo-page.tsx +++ b/apps/web/src/components/marketing-seo-page.tsx @@ -86,7 +86,7 @@ export function MarketingSeoPage({ dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} /> ) : null} -
+
diff --git a/apps/web/src/components/modeToggle.tsx b/apps/web/src/components/modeToggle.tsx deleted file mode 100644 index 46f4ba6d..00000000 --- a/apps/web/src/components/modeToggle.tsx +++ /dev/null @@ -1,80 +0,0 @@ -"use client" - -import * as React from "react" -import { Moon, Sun } from "lucide-react" -import { useTheme } from "next-themes" -import { useThemeAnimation } from "@space-man/react-theme-animation" -import * as SwitchPrimitives from "@radix-ui/react-switch" -import { cn } from "@/lib/utils" - -export function ModeToggle() { - const { setTheme } = useTheme() - const { theme, toggleTheme, ref } = useThemeAnimation() - const [mounted, setMounted] = React.useState(false) - - // Avoid hydration mismatch - React.useEffect(() => { - setMounted(true) - }, []) - - // Sync theme from animation hook to next-themes - React.useEffect(() => { - if (mounted && theme) { - setTheme(theme) - } - }, [theme, mounted, setTheme]) - - const isDark = theme === "dark" - - const handleToggle = (checked: boolean) => { - // The toggleTheme from useThemeAnimation handles both theme change and animation - toggleTheme() - } - - if (!mounted) { - return ( - - - - - - ) - } - - return ( - - - {isDark ? ( - - ) : ( - - )} - - - ) -} From 7839f31163be8c5da4fa0a0c4a0ef9440022ef44 Mon Sep 17 00:00:00 2001 From: AKHIL Date: Sun, 16 Aug 2026 21:50:44 +0530 Subject: [PATCH 6/9] Update header.tsx --- apps/web/src/components/header.tsx | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/header.tsx b/apps/web/src/components/header.tsx index 2ecccd58..f251200d 100644 --- a/apps/web/src/components/header.tsx +++ b/apps/web/src/components/header.tsx @@ -1,9 +1,10 @@ "use client"; import { useState, useEffect, type ElementType } from "react"; import Link from "next/link"; -import { Menu, X } from "lucide-react"; +import { Github, Menu, X } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Logo } from "./logo"; +import { SOURCE_URL } from "./footer"; import { motion, AnimatePresence } from "framer-motion"; import { cn } from "@/lib/utils"; @@ -63,6 +64,7 @@ export function Header() { { href: "/developer-tools", label: "Platform" }, { href: "/tools", label: "Tools" }, { href: "/changelog", label: "Changelog" }, + { href: SOURCE_URL, label: "GitHub", icon: Github, external: true }, ]; return ( @@ -125,6 +127,23 @@ export function Header() { {/* Desktop Actions */}
+ + {/* Mobile Menu Toggle */} + ); diff --git a/apps/desktop-ui/src/components/desktop/desktop-update-dialog.tsx b/apps/desktop-ui/src/components/desktop/desktop-update-dialog.tsx index 48f904a1..4b5ef174 100644 --- a/apps/desktop-ui/src/components/desktop/desktop-update-dialog.tsx +++ b/apps/desktop-ui/src/components/desktop/desktop-update-dialog.tsx @@ -1,6 +1,7 @@ "use client"; import { useState } from "react"; +import { useTranslations } from "next-intl"; import { CheckCircle2, Download, RefreshCw } from "lucide-react"; import { toast } from "sonner"; @@ -20,6 +21,7 @@ import type { UpdateInfo } from "@/lib/desktop/updater"; * data is never touched). Desktop-only — callers gate on isDesktop(). */ export function DesktopUpdateDialog() { + const t = useTranslations("SettingsPage.about"); const [open, setOpen] = useState(false); const [version, setVersion] = useState(""); const [checking, setChecking] = useState(false); @@ -33,7 +35,7 @@ export function DesktopUpdateDialog() { if (!version) setVersion(await m.currentAppVersion()); setUpdate(await m.checkForUpdate()); } catch { - toast.error("Couldn't check for updates. Are you online?"); + toast.error(t("checkError")); setOpen(false); } finally { setChecking(false); @@ -50,28 +52,27 @@ export function DesktopUpdateDialog() { <> setOpen(o)}> - Updates + {t("dialogTitle")} - MyDevTools updates itself in place — no reinstall, and your - offline data is never touched. - {version ? ` You're on version ${version}.` : ""} + {t("dialogDescription")} + {version ? ` ${t("onVersion", { version })}` : ""} {checking ? (
- Checking… + {t("checking")}
) : update ? (

- Version {update.version} is available. + {t("available", { version: update.version })}

{update.notes ? (

@@ -80,12 +81,12 @@ export function DesktopUpdateDialog() { ) : null}

) : (
- You're up to date. + {t("upToDate")}
)}
diff --git a/apps/desktop-ui/src/components/notes/NotionEditor.tsx b/apps/desktop-ui/src/components/notes/NotionEditor.tsx index 11e43f5e..6a8b2314 100644 --- a/apps/desktop-ui/src/components/notes/NotionEditor.tsx +++ b/apps/desktop-ui/src/components/notes/NotionEditor.tsx @@ -40,6 +40,11 @@ import { readingTimeMinutes, } from "@/app/app/notes/utils/noteContentUtils"; import type { Note } from "@/app/app/notes/types/Note"; +import { + mergePendingUpdate, + requeuePendingUpdates, + type PendingUpdates, +} from "@/components/notes/notes-helpers"; export default function NotionEditor() { const tEditor = useTranslations("Notes.editor"); @@ -100,33 +105,46 @@ export default function NotionEditor() { return () => clearTimeout(timeout); }, [saveState]); - const handleUpdate = useCallback(async (id: string, updates: Partial) => { - if (id) { - setSaveState("saving"); + // Title, tags and body all share one debounced save, so pending edits are + // queued per note and merged — a title keystroke inside the body's debounce + // window must not replace the queued body edit with itself. + const pendingUpdatesRef = useRef(new Map()); + + const flushUpdates = useCallback(async () => { + const batch = [...pendingUpdatesRef.current]; + if (!batch.length) return; + pendingUpdatesRef.current.clear(); + setSaveState("saving"); + try { + for (const [id, updates] of batch) await updateNote(id, updates); isDirtyRef.current = false; - try { - await updateNote(id, updates); - setLastSavedAt(new Date()); - setSaveState("saved"); - } catch (err) { - // Vault locked / no workspace: surface the reason and keep the - // note dirty so the next edit retries instead of silently dropping. - isDirtyRef.current = true; - setSaveState("idle"); - toast.error(err instanceof Error ? err.message : String(err)); - } + setLastSavedAt(new Date()); + setSaveState("saved"); + } catch (err) { + // Vault locked / no workspace: surface the reason and requeue so the + // next edit retries instead of silently dropping. + requeuePendingUpdates(pendingUpdatesRef.current, batch); + isDirtyRef.current = true; + setSaveState("idle"); + toast.error(err instanceof Error ? err.message : String(err)); } }, [updateNote]); - const debouncedUpdate = useDebouncedCallback(handleUpdate, 1000); + // flushOnExit: unmount (navigating away, vault auto-lock swapping in the + // locked placeholder) must not drop an edit that already showed "Saving…". + const debouncedUpdate = useDebouncedCallback(flushUpdates, 1000, { flushOnExit: true }); + + const queueUpdate = useCallback((id: string | null, updates: Partial) => { + if (!id) return; + mergePendingUpdate(pendingUpdatesRef.current, id, updates); + setSaveState("saving"); + debouncedUpdate(); + }, [debouncedUpdate]); const handleTitleChange = (e: React.ChangeEvent) => { const newTitle = e.target.value; setTitle(newTitle); - if (activeNoteId) { - setSaveState("saving"); - debouncedUpdate(activeNoteId, { title: newTitle }); - } + queueUpdate(activeNoteId, { title: newTitle }); }; const handleEditorChange = (markdown: string) => { @@ -134,8 +152,7 @@ export default function NotionEditor() { pushWordCount(markdown); if (activeNoteId) { isDirtyRef.current = true; - setSaveState("saving"); - debouncedUpdate(activeNoteId, { content: markdown }); + queueUpdate(activeNoteId, { content: markdown }); } }; @@ -191,14 +208,14 @@ export default function NotionEditor() { if (!tag || tags.includes(tag) || tags.length >= 20) return; const next = [...tags, tag]; setTags(next); - if (activeNoteId) debouncedUpdate(activeNoteId, { tags: next }); - }, [tags, activeNoteId, debouncedUpdate]); + queueUpdate(activeNoteId, { tags: next }); + }, [tags, activeNoteId, queueUpdate]); const removeTag = useCallback((tag: string) => { const next = tags.filter(t => t !== tag); setTags(next); - if (activeNoteId) debouncedUpdate(activeNoteId, { tags: next }); - }, [tags, activeNoteId, debouncedUpdate]); + queueUpdate(activeNoteId, { tags: next }); + }, [tags, activeNoteId, queueUpdate]); // Defer word-count compute: the heavy extract runs at most every 500ms // instead of every keystroke. @@ -237,8 +254,7 @@ export default function NotionEditor() { const handleApplyTemplate = useCallback((tpl: NoteTemplate) => { if (!activeNoteId) return; - setSaveState("saving"); - debouncedUpdate(activeNoteId, { content: tpl.content, title: tpl.defaultTitle }); + queueUpdate(activeNoteId, { content: tpl.content, title: tpl.defaultTitle }); setTitle(tpl.defaultTitle); latestMarkdownRef.current = tpl.content; setWordCountSource(tpl.content); @@ -248,7 +264,7 @@ export default function NotionEditor() { // clobber the title/content we just set. setPendingTemplateContent({ noteId: activeNoteId, markdown: tpl.content }); setEditorKey(k => k + 1); - }, [activeNoteId, debouncedUpdate]); + }, [activeNoteId, queueUpdate]); useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { diff --git a/apps/desktop-ui/src/components/notes/__tests__/notes-helpers.test.ts b/apps/desktop-ui/src/components/notes/__tests__/notes-helpers.test.ts index ace8032a..e2bc7616 100644 --- a/apps/desktop-ui/src/components/notes/__tests__/notes-helpers.test.ts +++ b/apps/desktop-ui/src/components/notes/__tests__/notes-helpers.test.ts @@ -4,6 +4,9 @@ import { deleteCachedPlainText, clearPlainTextCache, buildChildrenMap, + mergePendingUpdate, + requeuePendingUpdates, + type PendingUpdates, } from "../notes-helpers"; import type { Note } from "@/app/app/notes/types/Note"; @@ -65,3 +68,27 @@ describe("buildChildrenMap", () => { expect(map.get("root")?.map((n) => n.id)).toEqual(["kid"]); }); }); + +describe("pending update queue", () => { + it("merges fields instead of replacing (body edit survives a title keystroke)", () => { + const queue: PendingUpdates = new Map(); + mergePendingUpdate(queue, "a", { content: "body text" }); + mergePendingUpdate(queue, "a", { title: "Renamed" }); + expect(queue.get("a")).toEqual({ content: "body text", title: "Renamed" }); + }); + + it("keeps notes separate", () => { + const queue: PendingUpdates = new Map(); + mergePendingUpdate(queue, "a", { content: "a body" }); + mergePendingUpdate(queue, "b", { content: "b body" }); + expect(queue.get("a")).toEqual({ content: "a body" }); + expect(queue.get("b")).toEqual({ content: "b body" }); + }); + + it("requeues a failed batch without clobbering newer edits", () => { + const queue: PendingUpdates = new Map(); + mergePendingUpdate(queue, "a", { content: "newer", tags: ["t"] }); + requeuePendingUpdates(queue, [["a", { content: "failed", title: "T" }]]); + expect(queue.get("a")).toEqual({ content: "newer", tags: ["t"], title: "T" }); + }); +}); diff --git a/apps/desktop-ui/src/components/notes/notes-helpers.ts b/apps/desktop-ui/src/components/notes/notes-helpers.ts index bdf84a42..928526f2 100644 --- a/apps/desktop-ui/src/components/notes/notes-helpers.ts +++ b/apps/desktop-ui/src/components/notes/notes-helpers.ts @@ -47,6 +47,30 @@ export function getCachedPlainText(note: Note): string { return v; } +// ── Pending-save queue ──────────────────────────────────────────────────────── +// The editor shares ONE debounced save across title, tags and body, so a title +// keystroke landing inside the body's debounce window would otherwise replace +// the queued body edit with itself. Updates are queued per note and merged. + +export type PendingUpdates = Map>; + +/** Queue one note update; later edits win field-by-field. */ +export function mergePendingUpdate( + queue: PendingUpdates, + id: string, + updates: Partial, +): void { + queue.set(id, { ...queue.get(id), ...updates }); +} + +/** Put a failed batch back in the queue without clobbering newer edits. */ +export function requeuePendingUpdates( + queue: PendingUpdates, + batch: Array<[string, Partial]>, +): void { + for (const [id, updates] of batch) queue.set(id, { ...updates, ...queue.get(id) }); +} + // Pre-computed map from parentId -> children (avoids O(n²) per render) export type ChildrenMap = Map; diff --git a/apps/desktop-ui/src/components/settings/profile-card.tsx b/apps/desktop-ui/src/components/settings/profile-card.tsx index 492a4238..57fe8e38 100644 --- a/apps/desktop-ui/src/components/settings/profile-card.tsx +++ b/apps/desktop-ui/src/components/settings/profile-card.tsx @@ -11,7 +11,6 @@ import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { PROFILE_UPDATED_EVENT } from '@/hooks/use-app-user' import { getUserPreferences, patchUserPreferences } from '@/lib/user-preferences-api' -import { DesktopUpdateDialog } from '@/components/desktop/desktop-update-dialog' /** * Local profile: the name and avatar the app shows you. Stored in local @@ -92,12 +91,9 @@ export function ProfileCard() {
-
- - -
+ ) From 212e9649176f0f85e83a3353e78ae87ad5f4735e Mon Sep 17 00:00:00 2001 From: AKHIL Date: Mon, 17 Aug 2026 21:12:04 +0530 Subject: [PATCH 9/9] Add option to rearrange tabs accent color picker --- apps/desktop-ui/messages/af.json | 2 + apps/desktop-ui/messages/ar.json | 2 + apps/desktop-ui/messages/ca.json | 2 + apps/desktop-ui/messages/cs.json | 2 + apps/desktop-ui/messages/da.json | 2 + apps/desktop-ui/messages/de.json | 2 + apps/desktop-ui/messages/el.json | 2 + apps/desktop-ui/messages/en.json | 2 + apps/desktop-ui/messages/es.json | 2 + apps/desktop-ui/messages/fa.json | 2 + apps/desktop-ui/messages/fr.json | 2 + apps/desktop-ui/messages/id.json | 2 + apps/desktop-ui/messages/it.json | 2 + apps/desktop-ui/messages/ja.json | 2 + apps/desktop-ui/messages/ko.json | 2 + apps/desktop-ui/messages/ms.json | 2 + apps/desktop-ui/messages/nb.json | 2 + apps/desktop-ui/messages/nl.json | 2 + apps/desktop-ui/messages/pl.json | 2 + apps/desktop-ui/messages/pt-BR.json | 2 + apps/desktop-ui/messages/pt.json | 2 + apps/desktop-ui/messages/ru.json | 2 + apps/desktop-ui/messages/sv.json | 2 + apps/desktop-ui/messages/tr.json | 2 + apps/desktop-ui/messages/uk.json | 2 + apps/desktop-ui/messages/vi.json | 2 + apps/desktop-ui/messages/zh.json | 2 + apps/desktop-ui/src/app/globals.css | 126 +----------------- apps/desktop-ui/src/app/settings/page.tsx | 116 +++++++++------- .../src/components/shell/top-nav-strip.tsx | 52 +++++++- .../src/components/user-preferences-sync.tsx | 8 +- apps/desktop-ui/src/hooks/use-color-theme.tsx | 48 +++---- .../src/lib/__tests__/accent-color.test.ts | 29 ++++ apps/desktop-ui/src/lib/accent-color.ts | 121 +++++++++++++++++ .../src/store/__tests__/tab-store.test.ts | 27 ++++ apps/desktop-ui/src/store/tab-store.ts | 13 ++ apps/desktop/src-tauri/tauri.conf.json | 1 + 37 files changed, 387 insertions(+), 208 deletions(-) create mode 100644 apps/desktop-ui/src/lib/__tests__/accent-color.test.ts create mode 100644 apps/desktop-ui/src/lib/accent-color.ts create mode 100644 apps/desktop-ui/src/store/__tests__/tab-store.test.ts diff --git a/apps/desktop-ui/messages/af.json b/apps/desktop-ui/messages/af.json index 069966a5..22c4a1ef 100644 --- a/apps/desktop-ui/messages/af.json +++ b/apps/desktop-ui/messages/af.json @@ -195,6 +195,8 @@ "title": "Voorkoms", "description": "Pas aan hoe die toepassing op jou toestel lyk.", "themePreference": "Temavoorkeur", + "accentColor": "Aksentkleur", + "customColor": "Pasgemaakte kleur", "themes": { "light": "Lig", "dark": "Donker", diff --git a/apps/desktop-ui/messages/ar.json b/apps/desktop-ui/messages/ar.json index 8bd17ed4..05b9549e 100644 --- a/apps/desktop-ui/messages/ar.json +++ b/apps/desktop-ui/messages/ar.json @@ -195,6 +195,8 @@ "title": "المظهر", "description": "خصّص مظهر التطبيق على جهازك.", "themePreference": "تفضيل السمة", + "accentColor": "لون التمييز", + "customColor": "لون مخصص", "themes": { "light": "فاتح", "dark": "داكن", diff --git a/apps/desktop-ui/messages/ca.json b/apps/desktop-ui/messages/ca.json index 5b013a1c..e3931b1d 100644 --- a/apps/desktop-ui/messages/ca.json +++ b/apps/desktop-ui/messages/ca.json @@ -195,6 +195,8 @@ "title": "Aparença", "description": "Personalitza com es veu l'aplicació al teu dispositiu.", "themePreference": "Preferència de tema", + "accentColor": "Color d'accent", + "customColor": "Color personalitzat", "themes": { "light": "Clar", "dark": "Fosc", diff --git a/apps/desktop-ui/messages/cs.json b/apps/desktop-ui/messages/cs.json index 6d9192b0..56840805 100644 --- a/apps/desktop-ui/messages/cs.json +++ b/apps/desktop-ui/messages/cs.json @@ -195,6 +195,8 @@ "title": "Vzhled", "description": "Přizpůsobte vzhled aplikace na vašem zařízení.", "themePreference": "Motiv", + "accentColor": "Barva zvýraznění", + "customColor": "Vlastní barva", "themes": { "light": "Světlý", "dark": "Tmavý", diff --git a/apps/desktop-ui/messages/da.json b/apps/desktop-ui/messages/da.json index 3a6c03b6..3e896a30 100644 --- a/apps/desktop-ui/messages/da.json +++ b/apps/desktop-ui/messages/da.json @@ -195,6 +195,8 @@ "title": "Udseende", "description": "Tilpas, hvordan applikationen ser ud på din enhed.", "themePreference": "Temapræference", + "accentColor": "Accentfarve", + "customColor": "Tilpasset farve", "themes": { "light": "Lys", "dark": "Mørk", diff --git a/apps/desktop-ui/messages/de.json b/apps/desktop-ui/messages/de.json index 006023a0..03d4599c 100644 --- a/apps/desktop-ui/messages/de.json +++ b/apps/desktop-ui/messages/de.json @@ -195,6 +195,8 @@ "title": "Erscheinungsbild", "description": "Passen Sie das Aussehen der Anwendung auf Ihrem Gerät an.", "themePreference": "Theme", + "accentColor": "Akzentfarbe", + "customColor": "Eigene Farbe", "themes": { "light": "Hell", "dark": "Dunkel", diff --git a/apps/desktop-ui/messages/el.json b/apps/desktop-ui/messages/el.json index 9e487d2b..5929babf 100644 --- a/apps/desktop-ui/messages/el.json +++ b/apps/desktop-ui/messages/el.json @@ -195,6 +195,8 @@ "title": "Εμφάνιση", "description": "Προσαρμόστε τον τρόπο εμφάνισης της εφαρμογής στη συσκευή σας.", "themePreference": "Προτίμηση θέματος", + "accentColor": "Χρώμα τόνου", + "customColor": "Προσαρμοσμένο χρώμα", "themes": { "light": "Φως", "dark": "Σκοτάδι", diff --git a/apps/desktop-ui/messages/en.json b/apps/desktop-ui/messages/en.json index 70e71380..9e1e77c2 100644 --- a/apps/desktop-ui/messages/en.json +++ b/apps/desktop-ui/messages/en.json @@ -195,6 +195,8 @@ "title": "Appearance", "description": "Customize how the application looks on your device.", "themePreference": "Theme Preference", + "accentColor": "Accent color", + "customColor": "Custom color", "themes": { "light": "Light", "dark": "Dark", diff --git a/apps/desktop-ui/messages/es.json b/apps/desktop-ui/messages/es.json index c8e95ef2..d0a87de8 100644 --- a/apps/desktop-ui/messages/es.json +++ b/apps/desktop-ui/messages/es.json @@ -195,6 +195,8 @@ "title": "Apariencia", "description": "Personaliza cómo se ve la aplicación en tu dispositivo.", "themePreference": "Preferencia de tema", + "accentColor": "Color de acento", + "customColor": "Color personalizado", "themes": { "light": "Claro", "dark": "Oscuro", diff --git a/apps/desktop-ui/messages/fa.json b/apps/desktop-ui/messages/fa.json index a7004c9c..7ffb07b0 100644 --- a/apps/desktop-ui/messages/fa.json +++ b/apps/desktop-ui/messages/fa.json @@ -195,6 +195,8 @@ "title": "ظاهر", "description": "نحوه ظاهر برنامه را در دستگاه خود سفارشی کنید.", "themePreference": "اولویت تم", + "accentColor": "رنگ تأکید", + "customColor": "رنگ سفارشی", "themes": { "light": "نور", "dark": "تاریک", diff --git a/apps/desktop-ui/messages/fr.json b/apps/desktop-ui/messages/fr.json index 4ff99b8e..6f5d7587 100644 --- a/apps/desktop-ui/messages/fr.json +++ b/apps/desktop-ui/messages/fr.json @@ -195,6 +195,8 @@ "title": "Apparence", "description": "Personnalisez l’apparence de l’application sur votre appareil.", "themePreference": "Thème", + "accentColor": "Couleur d'accentuation", + "customColor": "Couleur personnalisée", "themes": { "light": "Clair", "dark": "Sombre", diff --git a/apps/desktop-ui/messages/id.json b/apps/desktop-ui/messages/id.json index a4ab26a2..983241a5 100644 --- a/apps/desktop-ui/messages/id.json +++ b/apps/desktop-ui/messages/id.json @@ -195,6 +195,8 @@ "title": "Penampilan", "description": "Sesuaikan tampilan aplikasi di perangkat Anda.", "themePreference": "Preferensi Tema", + "accentColor": "Warna aksen", + "customColor": "Warna khusus", "themes": { "light": "Cahaya", "dark": "Gelap", diff --git a/apps/desktop-ui/messages/it.json b/apps/desktop-ui/messages/it.json index 01fd7ab2..0ada945d 100644 --- a/apps/desktop-ui/messages/it.json +++ b/apps/desktop-ui/messages/it.json @@ -195,6 +195,8 @@ "title": "Aspetto", "description": "Personalizza l'aspetto dell'applicazione sul tuo dispositivo.", "themePreference": "Preferenza tema", + "accentColor": "Colore d'accento", + "customColor": "Colore personalizzato", "themes": { "light": "Leggero", "dark": "Buio", diff --git a/apps/desktop-ui/messages/ja.json b/apps/desktop-ui/messages/ja.json index 10a1a710..beaa918c 100644 --- a/apps/desktop-ui/messages/ja.json +++ b/apps/desktop-ui/messages/ja.json @@ -195,6 +195,8 @@ "title": "外観", "description": "デバイスでのアプリケーションの表示をカスタマイズします。", "themePreference": "テーマ設定", + "accentColor": "アクセントカラー", + "customColor": "カスタムカラー", "themes": { "light": "ライト", "dark": "ダーク", diff --git a/apps/desktop-ui/messages/ko.json b/apps/desktop-ui/messages/ko.json index c0e0bdd9..e8d84aa8 100644 --- a/apps/desktop-ui/messages/ko.json +++ b/apps/desktop-ui/messages/ko.json @@ -195,6 +195,8 @@ "title": "외관", "description": "기기에서 애플리케이션의 모양을 사용자 지정하세요.", "themePreference": "테마 설정", + "accentColor": "강조 색상", + "customColor": "사용자 지정 색상", "themes": { "light": "라이트", "dark": "다크", diff --git a/apps/desktop-ui/messages/ms.json b/apps/desktop-ui/messages/ms.json index 86e51c8c..e0078eab 100644 --- a/apps/desktop-ui/messages/ms.json +++ b/apps/desktop-ui/messages/ms.json @@ -195,6 +195,8 @@ "title": "Penampilan", "description": "Sesuaikan rupa aplikasi pada peranti anda.", "themePreference": "Keutamaan Tema", + "accentColor": "Warna aksen", + "customColor": "Warna tersuai", "themes": { "light": "Cerah", "dark": "Gelap", diff --git a/apps/desktop-ui/messages/nb.json b/apps/desktop-ui/messages/nb.json index 245a74c8..7bcd27ff 100644 --- a/apps/desktop-ui/messages/nb.json +++ b/apps/desktop-ui/messages/nb.json @@ -195,6 +195,8 @@ "title": "Utseende", "description": "Tilpass hvordan applikasjonen ser ut på enheten din.", "themePreference": "Temapreferanse", + "accentColor": "Aksentfarge", + "customColor": "Egendefinert farge", "themes": { "light": "Lys", "dark": "Mørk", diff --git a/apps/desktop-ui/messages/nl.json b/apps/desktop-ui/messages/nl.json index c779b063..c7fcbfc5 100644 --- a/apps/desktop-ui/messages/nl.json +++ b/apps/desktop-ui/messages/nl.json @@ -195,6 +195,8 @@ "title": "Weergave", "description": "Pas het uiterlijk van de applicatie aan op je apparaat.", "themePreference": "Themavoorkeur", + "accentColor": "Accentkleur", + "customColor": "Aangepaste kleur", "themes": { "light": "Licht", "dark": "Donker", diff --git a/apps/desktop-ui/messages/pl.json b/apps/desktop-ui/messages/pl.json index 5c76f7ee..db4d07e2 100644 --- a/apps/desktop-ui/messages/pl.json +++ b/apps/desktop-ui/messages/pl.json @@ -195,6 +195,8 @@ "title": "Appearance", "description": "Customize how the application looks on your device.", "themePreference": "Theme Preference", + "accentColor": "Kolor akcentu", + "customColor": "Kolor własny", "themes": { "light": "Light", "dark": "Dark", diff --git a/apps/desktop-ui/messages/pt-BR.json b/apps/desktop-ui/messages/pt-BR.json index 074368c6..4782bbd3 100644 --- a/apps/desktop-ui/messages/pt-BR.json +++ b/apps/desktop-ui/messages/pt-BR.json @@ -195,6 +195,8 @@ "title": "Aparência", "description": "Personalize a aparência do aplicativo no seu dispositivo.", "themePreference": "Preferência de Tema", + "accentColor": "Cor de destaque", + "customColor": "Cor personalizada", "themes": { "light": "Claro", "dark": "Escuro", diff --git a/apps/desktop-ui/messages/pt.json b/apps/desktop-ui/messages/pt.json index 27cc1f75..9ee5761b 100644 --- a/apps/desktop-ui/messages/pt.json +++ b/apps/desktop-ui/messages/pt.json @@ -195,6 +195,8 @@ "title": "Aparência", "description": "Personaliza o aspeto da aplicação no teu dispositivo.", "themePreference": "Preferência de Tema", + "accentColor": "Cor de destaque", + "customColor": "Cor personalizada", "themes": { "light": "Claro", "dark": "Escuro", diff --git a/apps/desktop-ui/messages/ru.json b/apps/desktop-ui/messages/ru.json index a4496c43..6291421d 100644 --- a/apps/desktop-ui/messages/ru.json +++ b/apps/desktop-ui/messages/ru.json @@ -195,6 +195,8 @@ "title": "Внешний вид", "description": "Настройте внешний вид приложения на вашем устройстве.", "themePreference": "Тема оформления", + "accentColor": "Акцентный цвет", + "customColor": "Свой цвет", "themes": { "light": "Светлая", "dark": "Тёмная", diff --git a/apps/desktop-ui/messages/sv.json b/apps/desktop-ui/messages/sv.json index 03e1d940..f09d7dc8 100644 --- a/apps/desktop-ui/messages/sv.json +++ b/apps/desktop-ui/messages/sv.json @@ -195,6 +195,8 @@ "title": "Utseende", "description": "Anpassa hur applikationen ser ut på din enhet.", "themePreference": "Temainställning", + "accentColor": "Accentfärg", + "customColor": "Anpassad färg", "themes": { "light": "Ljust", "dark": "Mörkt", diff --git a/apps/desktop-ui/messages/tr.json b/apps/desktop-ui/messages/tr.json index 79b13ee1..fa6423d4 100644 --- a/apps/desktop-ui/messages/tr.json +++ b/apps/desktop-ui/messages/tr.json @@ -195,6 +195,8 @@ "title": "Görünüm", "description": "Uygulamanın cihazınızda nasıl göründüğünü özelleştirin.", "themePreference": "Tema Tercihi", + "accentColor": "Vurgu rengi", + "customColor": "Özel renk", "themes": { "light": "Açık", "dark": "Koyu", diff --git a/apps/desktop-ui/messages/uk.json b/apps/desktop-ui/messages/uk.json index d60c2b94..ad1dca1c 100644 --- a/apps/desktop-ui/messages/uk.json +++ b/apps/desktop-ui/messages/uk.json @@ -195,6 +195,8 @@ "title": "Зовнішній вигляд", "description": "Налаштуйте вигляд програми на вашому пристрої.", "themePreference": "Вибір теми", + "accentColor": "Акцентний колір", + "customColor": "Власний колір", "themes": { "light": "Світла", "dark": "Темна", diff --git a/apps/desktop-ui/messages/vi.json b/apps/desktop-ui/messages/vi.json index 8f682d2b..8167888f 100644 --- a/apps/desktop-ui/messages/vi.json +++ b/apps/desktop-ui/messages/vi.json @@ -195,6 +195,8 @@ "title": "Giao diện", "description": "Tùy chỉnh giao diện ứng dụng trên thiết bị của bạn.", "themePreference": "Tùy chọn giao diện", + "accentColor": "Màu nhấn", + "customColor": "Màu tùy chỉnh", "themes": { "light": "Sáng", "dark": "Tối", diff --git a/apps/desktop-ui/messages/zh.json b/apps/desktop-ui/messages/zh.json index 4237a585..7f879ef1 100644 --- a/apps/desktop-ui/messages/zh.json +++ b/apps/desktop-ui/messages/zh.json @@ -195,6 +195,8 @@ "title": "外观", "description": "自定义应用在你设备上的显示方式。", "themePreference": "主题偏好", + "accentColor": "强调色", + "customColor": "自定义颜色", "themes": { "light": "浅色", "dark": "深色", diff --git a/apps/desktop-ui/src/app/globals.css b/apps/desktop-ui/src/app/globals.css index 0ab5234a..99f3e29f 100644 --- a/apps/desktop-ui/src/app/globals.css +++ b/apps/desktop-ui/src/app/globals.css @@ -101,129 +101,9 @@ body { --sidebar-ring: 235 84% 67%; } - /* User-selectable accent overrides (ColorThemeProvider toggles these - classes on ). Light + dark variants; everything accent-colored - flows through --primary / --ring / --sidebar-primary. */ - :root.blue { - --primary: 217.2 91.2% 59.8%; - --primary-foreground: 0 0% 98%; - --ring: 217.2 91.2% 59.8%; - --sidebar-primary: 217.2 91.2% 59.8%; - --sidebar-ring: 217.2 91.2% 59.8%; - } - - :root.purple { - --primary: 258.3 89.5% 66.3%; - --primary-foreground: 0 0% 98%; - --ring: 258.3 89.5% 66.3%; - --sidebar-primary: 258.3 89.5% 66.3%; - --sidebar-ring: 258.3 89.5% 66.3%; - } - - :root.green { - --primary: 142.1 76.2% 36.3%; - --primary-foreground: 0 0% 98%; - --ring: 142.1 76.2% 36.3%; - --sidebar-primary: 142.1 76.2% 36.3%; - --sidebar-ring: 142.1 76.2% 36.3%; - } - - :root.orange { - --primary: 24.6 95% 53.1%; - --primary-foreground: 0 0% 98%; - --ring: 24.6 95% 53.1%; - --sidebar-primary: 24.6 95% 53.1%; - --sidebar-ring: 24.6 95% 53.1%; - } - - :root.red { - --primary: 0 72.2% 50.6%; - --primary-foreground: 0 0% 98%; - --ring: 0 72.2% 50.6%; - --sidebar-primary: 0 72.2% 50.6%; - --sidebar-ring: 0 72.2% 50.6%; - } - - :root.pink { - --primary: 340.7 82.1% 52.7%; - --primary-foreground: 0 0% 98%; - --ring: 340.7 82.1% 52.7%; - --sidebar-primary: 340.7 82.1% 52.7%; - --sidebar-ring: 340.7 82.1% 52.7%; - } - - :root.cyan { - --primary: 199.1 89.1% 48.2%; - --primary-foreground: 0 0% 98%; - --ring: 199.1 89.1% 48.2%; - --sidebar-primary: 199.1 89.1% 48.2%; - --sidebar-ring: 199.1 89.1% 48.2%; - } - - :root.indigo { - --primary: 235 84% 67%; - --primary-foreground: 0 0% 100%; - --ring: 235 84% 67%; - --sidebar-primary: 235 84% 67%; - --sidebar-ring: 235 84% 67%; - } - - /* Dark mode: same hues, tuned lightness where needed */ - .dark.blue { - --primary: 217.2 91.2% 59.8%; - --ring: 217.2 91.2% 59.8%; - --sidebar-primary: 217.2 91.2% 59.8%; - --sidebar-ring: 217.2 91.2% 59.8%; - } - - .dark.purple { - --primary: 258.3 89.5% 66.3%; - --ring: 258.3 89.5% 66.3%; - --sidebar-primary: 258.3 89.5% 66.3%; - --sidebar-ring: 258.3 89.5% 66.3%; - } - - .dark.green { - --primary: 142.1 70.6% 45.3%; - --ring: 142.1 70.6% 45.3%; - --sidebar-primary: 142.1 70.6% 45.3%; - --sidebar-ring: 142.1 70.6% 45.3%; - } - - .dark.orange { - --primary: 24.6 95% 53.1%; - --ring: 24.6 95% 53.1%; - --sidebar-primary: 24.6 95% 53.1%; - --sidebar-ring: 24.6 95% 53.1%; - } - - .dark.red { - --primary: 0 72.2% 50.6%; - --ring: 0 72.2% 50.6%; - --sidebar-primary: 0 72.2% 50.6%; - --sidebar-ring: 0 72.2% 50.6%; - } - - .dark.pink { - --primary: 340.7 82.1% 52.7%; - --ring: 340.7 82.1% 52.7%; - --sidebar-primary: 340.7 82.1% 52.7%; - --sidebar-ring: 340.7 82.1% 52.7%; - } - - .dark.cyan { - --primary: 199.1 89.1% 48.2%; - --ring: 199.1 89.1% 48.2%; - --sidebar-primary: 199.1 89.1% 48.2%; - --sidebar-ring: 199.1 89.1% 48.2%; - } - - .dark.indigo { - --primary: 235 84% 67%; - --ring: 235 84% 67%; - --sidebar-primary: 235 84% 67%; - --sidebar-ring: 235 84% 67%; - } + /* Accent overrides live in JS: ColorThemeProvider writes --primary / --ring / + --sidebar-primary inline on (see lib/accent-color.ts), which also + lets a custom hex from the picker work without a CSS class per color. */ } /* Hidden scrollbar (element still scrolls) — used by the top-nav strip. */ diff --git a/apps/desktop-ui/src/app/settings/page.tsx b/apps/desktop-ui/src/app/settings/page.tsx index ee664f2d..fd193c4b 100644 --- a/apps/desktop-ui/src/app/settings/page.tsx +++ b/apps/desktop-ui/src/app/settings/page.tsx @@ -4,7 +4,7 @@ import React, { useState, useEffect } from 'react' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Label } from '@/components/ui/label' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' -import { Sun, Globe, Palette, Check, Lock } from 'lucide-react' +import { Sun, Globe, Palette, Lock, Pipette } from 'lucide-react' import { IDLE_TIMEOUT_KEY, getIdleTimeoutMinutes } from '@/lib/use-idle-lock' import { Switch } from '@/components/ui/switch' import { getVaultIconsEnabled, setVaultIconsEnabled } from '@/lib/vault-icon-pref' @@ -13,21 +13,12 @@ import { useTheme } from 'next-themes' import { cn } from '@/lib/utils' import { useLocale, useTranslations } from 'next-intl' import { useRouter } from 'next/navigation' -import { COLOR_THEME_OPTIONS, type ColorTheme, useColorTheme } from '@/hooks/use-color-theme' +import { useColorTheme } from '@/hooks/use-color-theme' +import { ACCENT_PRESETS, accentHex, isCustomAccent } from '@/lib/accent-color' import { ProfileCard } from '@/components/settings/profile-card' import { AppVersionLabel } from '@/components/desktop/app-version-label' import { useActiveWorkspace } from '@/store/workspace-store' import { Briefcase } from 'lucide-react' -const colorDisplay: Record = { - cyan: { swatchClass: 'bg-cyan-500', name: 'Teal' }, - blue: { swatchClass: 'bg-blue-500', name: 'Blue' }, - indigo: { swatchClass: 'bg-indigo-500', name: 'Indigo' }, - purple: { swatchClass: 'bg-purple-500', name: 'Purple' }, - green: { swatchClass: 'bg-green-500', name: 'Green' }, - orange: { swatchClass: 'bg-orange-500', name: 'Orange' }, - red: { swatchClass: 'bg-red-500', name: 'Red' }, - pink: { swatchClass: 'bg-pink-500', name: 'Pink' }, -} export default function SettingsPage() { const t = useTranslations('SettingsPage') @@ -57,6 +48,8 @@ export default function SettingsPage() { return null // Avoid hydration mismatch } + const customAccent = isCustomAccent(colorTheme) + const handleLanguageChange = (value: string) => { document.cookie = `NEXT_LOCALE=${value}; path=/; max-age=31536000; SameSite=Lax` router.refresh() @@ -100,7 +93,11 @@ export default function SettingsPage() {
- + {/* Fixed-height label rows keep both dropdowns on the same baseline, + even though only the accent label carries an icon. */} +
+ +
-
-
- - +
+
+ +
-
- {COLOR_THEME_OPTIONS.map((color) => { - const isSelected = colorTheme === color - const { swatchClass, name } = colorDisplay[color] +
+ {/* Trigger is w-full by default: flex-1 keeps it from squeezing the + picker swatch out of the row. */} + - return ( - - ) - })} + {/* Native picker: no dependency, and the OS eyedropper comes free. */} +
diff --git a/apps/desktop-ui/src/components/shell/top-nav-strip.tsx b/apps/desktop-ui/src/components/shell/top-nav-strip.tsx index 11449bf9..975bd02c 100644 --- a/apps/desktop-ui/src/components/shell/top-nav-strip.tsx +++ b/apps/desktop-ui/src/components/shell/top-nav-strip.tsx @@ -178,7 +178,11 @@ export function TopNavStrip() { const activeWs = useActiveWorkspace() const pinned = buildPinnedNavItems(pinnedTools, activeWs) const togglePin = usePinnedToolsStore((s) => s.togglePin) - const { tabs, closeTab } = useTabStore() + const { tabs, closeTab, moveTab } = useTabStore() + + // Drag-to-reorder (native HTML5 DnD, same as the API-client tab bar). + const [draggedPath, setDraggedPath] = useState(null) + const [dragOverPath, setDragOverPath] = useState(null) const unpin = useCallback( (url: string) => { if (activeWs) togglePin(activeWs.id, url) @@ -234,7 +238,7 @@ export function TopNavStrip() { // Edge-fade affordance: show a fade only on the side that has more to scroll. const scrollRef = useRef(null) - const activeTabRef = useRef(null) + const activeTabRef = useRef(null) const [canScrollLeft, setCanScrollLeft] = useState(false) const [canScrollRight, setCanScrollRight] = useState(false) const updateScroll = useCallback(() => { @@ -341,17 +345,53 @@ export function TopNavStrip() { ) : null} - +
{title} diff --git a/apps/desktop-ui/src/components/user-preferences-sync.tsx b/apps/desktop-ui/src/components/user-preferences-sync.tsx index afb5f9e4..cb5a6ae4 100644 --- a/apps/desktop-ui/src/components/user-preferences-sync.tsx +++ b/apps/desktop-ui/src/components/user-preferences-sync.tsx @@ -5,7 +5,8 @@ import { useTheme } from "next-themes"; import { useLocale } from "next-intl"; import { useRouter } from "next/navigation"; import useAuth from "@/utils/useAuth"; -import { COLOR_THEME_OPTIONS, type ColorTheme, useColorTheme } from "@/hooks/use-color-theme"; +import { useColorTheme } from "@/hooks/use-color-theme"; +import { isAccentColor } from "@/lib/accent-color"; import { getUserPreferences, patchUserPreferences, type ThemePreference, type UserPreferencesOut } from "@/lib/user-preferences-api"; const SUPPORTED_LOCALES = [ @@ -50,9 +51,8 @@ function setLocaleCookie(locale: string) { document.cookie = `NEXT_LOCALE=${locale}; path=/; max-age=31536000; SameSite=Lax`; } -function isColorTheme(value: string | undefined): value is ColorTheme { - return !!value && (COLOR_THEME_OPTIONS as readonly string[]).includes(value); -} +/** Accepts preset ids and custom hex picks alike (see lib/accent-color.ts). */ +const isColorTheme = isAccentColor; export function UserPreferencesSync() { const { user, loading: authLoading } = useAuth(false); diff --git a/apps/desktop-ui/src/hooks/use-color-theme.tsx b/apps/desktop-ui/src/hooks/use-color-theme.tsx index db43a224..05513168 100644 --- a/apps/desktop-ui/src/hooks/use-color-theme.tsx +++ b/apps/desktop-ui/src/hooks/use-color-theme.tsx @@ -10,27 +10,17 @@ import { type ReactNode, } from "react" -export type ColorTheme = - | "blue" - | "purple" - | "green" - | "orange" - | "red" - | "pink" - | "cyan" - | "indigo" +import { + DEFAULT_ACCENT, + accentCssVars, + isAccentColor, + type AccentColor, +} from "@/lib/accent-color" + +/** Preset id (see ACCENT_PRESETS) or a custom `#rrggbb` from the color picker. */ +export type ColorTheme = AccentColor const COLOR_THEME_KEY = "app-color-theme" -export const COLOR_THEME_OPTIONS: ColorTheme[] = [ - "cyan", - "blue", - "indigo", - "purple", - "green", - "orange", - "red", - "pink", -] type ColorThemeContextValue = { colorTheme: ColorTheme @@ -40,28 +30,26 @@ type ColorThemeContextValue = { const ColorThemeContext = createContext(null) -function isValidColorTheme(theme: string): theme is ColorTheme { - return COLOR_THEME_OPTIONS.includes(theme as ColorTheme) -} - +/** Accent vars go inline on so custom hex picks work without new CSS. */ function applyColorTheme(theme: ColorTheme) { - const root = document.documentElement - root.classList.remove(...COLOR_THEME_OPTIONS) - root.classList.add(theme) + const { style } = document.documentElement + for (const [name, value] of Object.entries(accentCssVars(theme))) { + style.setProperty(name, value) + } } export function ColorThemeProvider({ children }: { children: ReactNode }) { - const [colorTheme, setColorThemeState] = useState("indigo") + const [colorTheme, setColorThemeState] = useState(DEFAULT_ACCENT) const [mounted, setMounted] = useState(false) useEffect(() => { setMounted(true) - const stored = localStorage.getItem(COLOR_THEME_KEY) as ColorTheme | null - if (stored && isValidColorTheme(stored)) { + const stored = localStorage.getItem(COLOR_THEME_KEY) + if (isAccentColor(stored)) { setColorThemeState(stored) applyColorTheme(stored) } else { - applyColorTheme("indigo") + applyColorTheme(DEFAULT_ACCENT) } }, []) diff --git a/apps/desktop-ui/src/lib/__tests__/accent-color.test.ts b/apps/desktop-ui/src/lib/__tests__/accent-color.test.ts new file mode 100644 index 00000000..f35dbd71 --- /dev/null +++ b/apps/desktop-ui/src/lib/__tests__/accent-color.test.ts @@ -0,0 +1,29 @@ +import { accentCssVars, accentHex, isAccentColor, isCustomAccent } from '@/lib/accent-color' + +describe('accent-color', () => { + it('resolves presets, custom hex, and garbage', () => { + expect(accentHex('indigo')).toBe('#6366f1') + expect(accentHex('#AABBCC')).toBe('#aabbcc') + expect(accentHex('not-a-color')).toBe('#6366f1') + }) + + it('validates stored values', () => { + expect(isAccentColor('blue')).toBe(true) + expect(isAccentColor('#f0f')).toBe(true) + expect(isAccentColor('rebeccapurple')).toBe(false) + expect(isAccentColor(undefined)).toBe(false) + expect(isCustomAccent('blue')).toBe(false) + expect(isCustomAccent('#123456')).toBe(true) + }) + + it('emits an HSL triple for --primary', () => { + expect(accentCssVars('red')['--primary']).toBe('0 84.2% 60.2%') + }) + + it('flips --primary-foreground to dark text on bright accents', () => { + expect(accentCssVars('yellow')['--primary-foreground']).toBe('222 47% 11%') + expect(accentCssVars('#ffff00')['--primary-foreground']).toBe('222 47% 11%') + expect(accentCssVars('indigo')['--primary-foreground']).toBe('0 0% 100%') + expect(accentCssVars('#000000')['--primary-foreground']).toBe('0 0% 100%') + }) +}) diff --git a/apps/desktop-ui/src/lib/accent-color.ts b/apps/desktop-ui/src/lib/accent-color.ts new file mode 100644 index 00000000..813ea703 --- /dev/null +++ b/apps/desktop-ui/src/lib/accent-color.ts @@ -0,0 +1,121 @@ +/** + * Accent palette + the CSS variables it drives. A stored accent is either a + * preset id or a custom `#rrggbb` chosen from the color picker, so everything + * downstream resolves through `accentCssVars`. + */ +export const ACCENT_PRESETS = [ + { id: 'indigo', name: 'Indigo', hex: '#6366f1' }, + { id: 'blue', name: 'Blue', hex: '#3b82f6' }, + { id: 'sky', name: 'Sky', hex: '#0ea5e9' }, + { id: 'cyan', name: 'Teal', hex: '#06b6d4' }, + { id: 'teal', name: 'Aqua', hex: '#14b8a6' }, + { id: 'emerald', name: 'Emerald', hex: '#10b981' }, + { id: 'green', name: 'Green', hex: '#22c55e' }, + { id: 'lime', name: 'Lime', hex: '#84cc16' }, + { id: 'yellow', name: 'Yellow', hex: '#eab308' }, + { id: 'amber', name: 'Amber', hex: '#f59e0b' }, + { id: 'orange', name: 'Orange', hex: '#f97316' }, + { id: 'red', name: 'Red', hex: '#ef4444' }, + { id: 'rose', name: 'Rose', hex: '#f43f5e' }, + { id: 'pink', name: 'Pink', hex: '#ec4899' }, + { id: 'fuchsia', name: 'Fuchsia', hex: '#d946ef' }, + { id: 'purple', name: 'Purple', hex: '#a855f7' }, + { id: 'violet', name: 'Violet', hex: '#8b5cf6' }, + { id: 'slate', name: 'Slate', hex: '#64748b' }, +] as const + +export type AccentPresetId = (typeof ACCENT_PRESETS)[number]['id'] + +/** Preset id, or a custom hex color. */ +export type AccentColor = AccentPresetId | (string & {}) + +export const DEFAULT_ACCENT: AccentPresetId = 'indigo' + +const HEX_RE = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i +const PRESET_HEX = new Map(ACCENT_PRESETS.map((p) => [p.id, p.hex])) + +/** Foreground pair: the HSL triple written to CSS + its hex, for contrast math. */ +const DARK_FG = { hsl: '222 47% 11%', hex: '#0f1729' } +const LIGHT_FG = { hsl: '0 0% 100%', hex: '#ffffff' } + +export function isAccentColor(value: string | undefined | null): value is AccentColor { + return !!value && (PRESET_HEX.has(value) || HEX_RE.test(value)) +} + +export function isCustomAccent(value: string): boolean { + return !PRESET_HEX.has(value) && HEX_RE.test(value) +} + +/** Resolved swatch color for a stored accent; falls back to the default preset. */ +export function accentHex(value: string): string { + const preset = PRESET_HEX.get(value) + if (preset) return preset + return HEX_RE.test(value) ? value.toLowerCase() : PRESET_HEX.get(DEFAULT_ACCENT)! +} + +const round = (n: number) => Math.round(n * 10) / 10 + +/** + * Local color math (rather than the `color` package wcag-contrast.ts uses) so the + * theme provider stays dependency-free and this stays plain-jest testable. + */ +function toRgb(hex: string): [number, number, number] { + const raw = hex.slice(1) + const full = raw.length === 3 ? raw.replace(/./g, (c) => c + c) : raw + const n = parseInt(full, 16) + return [(n >> 16) & 255, (n >> 8) & 255, n & 255] +} + +function toHsl(hex: string): [number, number, number] { + const [r, g, b] = toRgb(hex).map((c) => c / 255) + const max = Math.max(r, g, b) + const min = Math.min(r, g, b) + const d = max - min + const l = (max + min) / 2 + const s = d === 0 ? 0 : d / (1 - Math.abs(2 * l - 1)) + + let h = 0 + if (d !== 0) { + if (max === r) h = ((g - b) / d) % 6 + else if (max === g) h = (b - r) / d + 2 + else h = (r - g) / d + 4 + h = (h * 60 + 360) % 360 + } + + return [h, s * 100, l * 100] +} + +/** WCAG 2.1 relative luminance (0–1). */ +function luminance(hex: string): number { + const [r, g, b] = toRgb(hex).map((c) => { + const s = c / 255 + return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4) + }) + return 0.2126 * r + 0.7152 * g + 0.0722 * b +} + +function contrast(a: string, b: string): number { + const [la, lb] = [luminance(a), luminance(b)] + return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05) +} + +/** + * CSS custom properties for an accent. `--primary-foreground` follows whichever + * of dark/white text contrasts better, so bright accents (yellow, lime) stay + * readable instead of shipping white-on-yellow. + */ +export function accentCssVars(value: string): Record { + const hex = accentHex(value) + const [h, s, l] = toHsl(hex) + const triple = `${round(h)} ${round(s)}% ${round(l)}%` + const fg = contrast(hex, LIGHT_FG.hex) >= contrast(hex, DARK_FG.hex) ? LIGHT_FG : DARK_FG + + return { + '--primary': triple, + '--primary-foreground': fg.hsl, + '--ring': triple, + '--sidebar-primary': triple, + '--sidebar-primary-foreground': fg.hsl, + '--sidebar-ring': triple, + } +} diff --git a/apps/desktop-ui/src/store/__tests__/tab-store.test.ts b/apps/desktop-ui/src/store/__tests__/tab-store.test.ts new file mode 100644 index 00000000..31ce6b1f --- /dev/null +++ b/apps/desktop-ui/src/store/__tests__/tab-store.test.ts @@ -0,0 +1,27 @@ +import { useTabStore } from '../tab-store' + +const paths = ['/app/a', '/app/b', '/app/c'] + +beforeEach(() => { + useTabStore.setState({ tabs: paths.map(path => ({ path })), activeTabPath: '/app/a' }) +}) + +const order = () => useTabStore.getState().tabs.map(t => t.path) + +describe('moveTab', () => { + it('moves a tab forward', () => { + useTabStore.getState().moveTab('/app/a', '/app/c') + expect(order()).toEqual(['/app/b', '/app/c', '/app/a']) + }) + + it('moves a tab backward', () => { + useTabStore.getState().moveTab('/app/c', '/app/a') + expect(order()).toEqual(['/app/c', '/app/a', '/app/b']) + }) + + it('ignores unknown or identical paths', () => { + useTabStore.getState().moveTab('/app/a', '/app/a') + useTabStore.getState().moveTab('/app/a', '/app/nope') + expect(order()).toEqual(paths) + }) +}) diff --git a/apps/desktop-ui/src/store/tab-store.ts b/apps/desktop-ui/src/store/tab-store.ts index f2ea5ef3..0f7bfb3f 100644 --- a/apps/desktop-ui/src/store/tab-store.ts +++ b/apps/desktop-ui/src/store/tab-store.ts @@ -18,6 +18,7 @@ interface TabStore { closeTab: (path: string) => void setActiveTab: (path: string) => void closeAllTabs: () => void + moveTab: (fromPath: string, toPath: string) => void } export const useTabStore = create()( @@ -52,6 +53,18 @@ export const useTabStore = create()( setActiveTab: (path: string) => set({ activeTabPath: path }), closeAllTabs: () => set({ tabs: [], activeTabPath: null }), + + // Drag-reorder: pull the dragged tab out and drop it at the target's slot. + moveTab: (fromPath: string, toPath: string) => { + const { tabs } = get() + const from = tabs.findIndex(t => t.path === fromPath) + const to = tabs.findIndex(t => t.path === toPath) + if (from === -1 || to === -1 || from === to) return + const next = [...tabs] + const [moved] = next.splice(from, 1) + next.splice(to, 0, moved) + set({ tabs: next }) + }, }), { name: 'mdt-app-tabs', diff --git a/apps/desktop/src-tauri/tauri.conf.json b/apps/desktop/src-tauri/tauri.conf.json index d826e6ad..2c90224d 100644 --- a/apps/desktop/src-tauri/tauri.conf.json +++ b/apps/desktop/src-tauri/tauri.conf.json @@ -20,6 +20,7 @@ "center": true, "titleBarStyle": "Overlay", "hiddenTitle": true, + "dragDropEnabled": false, "trafficLightPosition": { "x": 14, "y": 27 } } ],