Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -282,3 +282,30 @@
::-webkit-scrollbar-thumb:hover {
background: oklch(0.4 0.02 260);
}

/* ── Legal Skin Theme Tokens ───────────────────────────────────────────────── */
.skin-legal {
--background: oklch(0.09 0.03 250); /* deep navy */
--foreground: oklch(0.92 0.02 55); /* warm cream */
--card: oklch(0.12 0.04 250);
--card-foreground: oklch(0.92 0.02 55);
--popover: oklch(0.11 0.04 250);
--popover-foreground: oklch(0.92 0.02 55);
--primary: oklch(0.72 0.15 75); /* gold */
--primary-foreground: oklch(0.09 0.03 250);
--secondary: oklch(0.16 0.04 250);
--secondary-foreground:oklch(0.88 0.02 55);
--muted: oklch(0.15 0.035 250);
--muted-foreground: oklch(0.55 0.02 55);
--accent: oklch(0.16 0.04 250);
--accent-foreground: oklch(0.92 0.02 55);
--border: oklch(0.22 0.04 250);
--input: oklch(0.16 0.04 250);
--ring: oklch(0.65 0.13 75); /* gold ring */
--cyan: oklch(0.72 0.15 75); /* remap cyan → gold */
--cyan-muted: oklch(0.45 0.08 75);
--sidebar: oklch(0.07 0.03 250);
--sidebar-foreground: oklch(0.88 0.02 55);
--sidebar-primary: oklch(0.72 0.15 75);
--sidebar-border: oklch(0.20 0.04 250);
}
146 changes: 146 additions & 0 deletions src/app/settings/appearance-settings.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
"use client"

import { useState, useCallback } from "react"
import { Loader2 } from "lucide-react"
import { Button } from "@/components/ui/button"
import { useAppStore } from "@/stores/app-store"
import { useUserStore } from "@/stores/user-store"
import { api } from "@/lib/api"
import { SKINS, type SkinId } from "@/skins/index"
import { cn } from "@/lib/utils"

// Hardcoded swatch colours per skin — do not rely on CSS vars here because
// the active skin overrides them, which would make all swatch previews look
// identical when a skin is already active.
const SKIN_SWATCHES: Record<SkinId, { bg: string; primary: string; fg: string }> = {
default: {
bg: "oklch(0.08 0.022 260)",
primary: "oklch(0.72 0.14 200)",
fg: "oklch(0.92 0.015 260)",
},
legal: {
bg: "oklch(0.09 0.03 250)",
primary: "oklch(0.72 0.15 75)",
fg: "oklch(0.92 0.02 55)",
},
}

export function AppearanceSettings({ open }: { open: boolean }) {
const isAdmin = useUserStore((s) => s.isAdmin)
const activeSkin = useAppStore((s) => s.activeSkin)
const setActiveSkin = useAppStore((s) => s.setActiveSkin)
const graphName = useAppStore((s) => s.graphName)
const graphDescription = useAppStore((s) => s.graphDescription)

const [selectedSkin, setSelectedSkin] = useState<SkinId>(activeSkin)
const [saving, setSaving] = useState(false)
const [saved, setSaved] = useState(false)

const handleSave = useCallback(async () => {
if (!isAdmin) return
setSaving(true)
setSaved(false)
try {
await api.post("/about", {
title: graphName,
description: graphDescription,
ui_skin: selectedSkin,
})
setActiveSkin(selectedSkin)
setSaved(true)
} catch (err) {
console.error("[appearance-settings] save failed:", err)
} finally {
setSaving(false)
}
}, [selectedSkin, graphName, graphDescription, setActiveSkin])

if (!open) return null

const skinEntries = Object.values(SKINS)

return (
<div className="space-y-6">
<div>
<h2 className="text-sm font-heading font-semibold uppercase tracking-wider text-foreground mb-1">
Interface Skin
</h2>
<p className="text-xs text-muted-foreground">
Choose a visual theme for the graph explorer. Saved server-side — all users see the selected skin.
</p>
</div>

{/* Skin cards — 2-column grid */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{skinEntries.map((skin) => {
const swatches = SKIN_SWATCHES[skin.id]
const isSelected = selectedSkin === skin.id
return (
<button
key={skin.id}
type="button"
onClick={() => {
setSelectedSkin(skin.id)
setSaved(false)
}}
className={cn(
"text-left rounded-xl border p-4 transition-all focus:outline-none focus:ring-2 focus:ring-primary/50",
isSelected
? "border-primary ring-2 ring-primary bg-primary/5"
: "border-border/50 hover:border-primary/40 hover:bg-muted/30"
)}
>
{/* Color swatch preview */}
<div
className="mb-3 flex h-10 items-center justify-center gap-2 rounded-lg border border-border/30"
style={{ backgroundColor: swatches.bg }}
>
<span
className="h-4 w-4 rounded-full border border-white/10"
style={{ backgroundColor: swatches.bg }}
title="Background"
/>
<span
className="h-4 w-4 rounded-full border border-white/10"
style={{ backgroundColor: swatches.primary }}
title="Primary / Accent"
/>
<span
className="h-4 w-4 rounded-full border border-white/10"
style={{ backgroundColor: swatches.fg }}
title="Foreground"
/>
</div>

<p className="text-sm font-heading font-semibold text-foreground">{skin.label}</p>
<p className="mt-0.5 text-xs text-muted-foreground leading-snug">{skin.description}</p>
</button>
)
})}
</div>

{/* Save row */}
<div className="flex items-center justify-end gap-3 pt-2">
{saved && (
<span className="text-xs text-primary font-mono uppercase tracking-widest">
Saved ✓
</span>
)}
<Button
onClick={handleSave}
disabled={saving || selectedSkin === activeSkin}
className="text-xs bg-primary text-primary-foreground hover:bg-primary/90"
>
{saving ? (
<>
<Loader2 className="mr-1.5 h-3 w-3 animate-spin" />
Saving…
</>
) : (
"Save Appearance"
)}
</Button>
</div>
</div>
)
}
16 changes: 14 additions & 2 deletions src/app/settings/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,13 @@ const SchemaAuditSettings = dynamic(
{ ssr: false, loading: () => <p className="text-sm text-muted-foreground">Loading…</p> }
)

const VALID_TABS = ["general", "radar", "janitor", "domains", "audit"] as const
const ADMIN_ONLY_TABS = ["radar", "janitor", "domains", "audit"] as const
const AppearanceSettings = dynamic(
() => import("@/app/settings/appearance-settings").then((m) => m.AppearanceSettings),
{ ssr: false, loading: () => <p className="text-sm text-muted-foreground">Loading…</p> }
)

const VALID_TABS = ["general", "radar", "janitor", "domains", "audit", "appearance"] as const
const ADMIN_ONLY_TABS = ["radar", "janitor", "domains", "audit", "appearance"] as const
type TabId = (typeof VALID_TABS)[number]

function resolveTab(raw: string | null, isAdmin: boolean): TabId {
Expand Down Expand Up @@ -124,6 +129,7 @@ function SettingsContent() {
{isAdmin && <TabsTrigger value="janitor">Janitors</TabsTrigger>}
{isAdmin && <TabsTrigger value="domains">Domains</TabsTrigger>}
{isAdmin && <TabsTrigger value="audit">Audit</TabsTrigger>}
{isAdmin && <TabsTrigger value="appearance">Appearance</TabsTrigger>}
</TabsList>

<TabsContent value="general" className="space-y-4 pt-4">
Expand Down Expand Up @@ -205,6 +211,12 @@ function SettingsContent() {
<SchemaAuditSettings open={activeTab === "audit"} />
</TabsContent>
)}

{isAdmin && (
<TabsContent value="appearance" className="pt-4">
<AppearanceSettings open={activeTab === "appearance"} />
</TabsContent>
)}
</Tabs>
</div>
</div>
Expand Down
5 changes: 4 additions & 1 deletion src/components/auth/auth-guard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export function AuthGuard({ children }: { children: React.ReactNode }) {
const [loading, setLoading] = useState(true)
const { setBudget, setIsAdmin, setPubKey, setRouteHint, setIsAuthenticated } = useUserStore()
const setGraphMeta = useAppStore((s) => s.setGraphMeta)
const setActiveSkin = useAppStore((s) => s.setActiveSkin)

const handleAuth = useCallback(async () => {
try {
Expand Down Expand Up @@ -101,12 +102,14 @@ export function AuthGuard({ children }: { children: React.ReactNode }) {
depth?: number
sort_by?: string
top_node_count?: number
ui_skin?: string
}>("/about")
setGraphMeta(res.title ?? "Knowledge Graph", res.description ?? "")
setActiveSkin((res.ui_skin as import("@/skins/index").SkinId) ?? "default")
} catch {
setGraphMeta("Knowledge Graph", "")
}
}, [setGraphMeta])
}, [setGraphMeta, setActiveSkin])

useEffect(() => {
const init = async () => {
Expand Down
39 changes: 22 additions & 17 deletions src/components/layout/app-layout.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,21 @@
"use client"

import { useEffect } from "react"
import { LeftPane } from "./left-pane"
import { GraphPane } from "@/components/universe/graph-pane"
import { AddModal } from "@/components/modals/add-modal"
import { BudgetModal } from "@/components/modals/budget-modal"
import { EditNodeModal } from "@/components/modals/edit-node-modal"
import { MediaPlayer } from "@/components/player/media-player"
import { useDefaultLayout } from "react-resizable-panels"
import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from "@/components/ui/resizable"
import { useSchemaStore } from "@/stores/schema-store"
import { useAppStore } from "@/stores/app-store"
import { useNeighborFetch } from "@/hooks/use-neighbor-fetch"
import { useDeepLink } from "@/hooks/use-deep-link"
import { usePanelGraphSync } from "@/hooks/use-panel-graph-sync"
import { isMocksEnabled } from "@/lib/mock-data"
import { SMALL_SCHEMAS } from "@/app/ontology/mock-small"
import { SKINS } from "@/skins/index"
import { cn } from "@/lib/utils"

export function AppLayout() {
useDeepLink()
Expand All @@ -23,6 +24,8 @@ export function AppLayout() {
const { defaultLayout, onLayoutChanged } = useDefaultLayout({ id: "graphmindset-main-layout" })
const schemas = useSchemaStore((s) => s.schemas)
const fetchSchemas = useSchemaStore((s) => s.fetchAll)
const activeSkin = useAppStore((s) => s.activeSkin)
const skin = SKINS[activeSkin] ?? SKINS.default

useEffect(() => {
if (schemas.length > 0) return
Expand All @@ -35,21 +38,23 @@ export function AppLayout() {

return (
<>
<ResizablePanelGroup
id="main-layout"
orientation="horizontal"
defaultLayout={defaultLayout ?? { "left-pane": 33, "right-pane": 67 }}
onLayoutChanged={onLayoutChanged}
className="h-screen w-screen overflow-hidden"
>
<ResizablePanel id="left-pane" defaultSize="33%" minSize="20%" maxSize="60%">
<LeftPane />
</ResizablePanel>
<ResizableHandle withHandle />
<ResizablePanel id="right-pane" defaultSize="67%" minSize="40%">
<GraphPane />
</ResizablePanel>
</ResizablePanelGroup>
<div className={cn("h-screen w-screen overflow-hidden", skin.themeClass)}>
<ResizablePanelGroup
id="main-layout"
orientation="horizontal"
defaultLayout={defaultLayout ?? { "left-pane": 33, "right-pane": 67 }}
onLayoutChanged={onLayoutChanged}
className="h-full w-full"
>
<ResizablePanel id="left-pane" defaultSize="33%" minSize="20%" maxSize="60%">
<skin.LeftPane />
</ResizablePanel>
<ResizableHandle withHandle />
<ResizablePanel id="right-pane" defaultSize="67%" minSize="40%">
<skin.GraphPane />
</ResizablePanel>
</ResizablePanelGroup>
</div>

<AddModal />
<EditNodeModal />
Expand Down
55 changes: 54 additions & 1 deletion src/lib/__tests__/auth-guard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,16 @@ vi.mock("@/stores/user-store", () => ({
}))

const mockSetGraphMeta = vi.fn()
const mockSetActiveSkin = vi.fn()

const appStoreMockState = {
setGraphMeta: mockSetGraphMeta,
setActiveSkin: mockSetActiveSkin,
}
vi.mock("@/stores/app-store", () => ({
useAppStore: vi.fn(() => mockSetGraphMeta),
useAppStore: vi.fn((sel?: (s: typeof appStoreMockState) => unknown) =>
sel ? sel(appStoreMockState) : appStoreMockState
),
}))

const mockApiGet = vi.fn()
Expand Down Expand Up @@ -143,3 +151,48 @@ describe("AuthGuard – handleIsAdmin", () => {
expect(mockSetIsAuthenticated).toHaveBeenCalledWith(true)
})
})

describe("AuthGuard – fetchGraphMeta ui_skin", () => {
beforeEach(() => {
vi.clearAllMocks()
})

it("calls setActiveSkin('legal') when /about returns ui_skin: 'legal'", async () => {
mockApiGet.mockImplementation((path: string) => {
if (path === "/about") return Promise.resolve({ title: "T", description: "", ui_skin: "legal" })
return Promise.resolve({ data: { isPublic: true, isAdmin: false, isMember: false } })
})

render(<AuthGuard><div>App</div></AuthGuard>)

await waitFor(() => {
expect(mockSetActiveSkin).toHaveBeenCalledWith("legal")
})
})

it("calls setActiveSkin('default') when /about returns no ui_skin", async () => {
mockApiGet.mockImplementation((path: string) => {
if (path === "/about") return Promise.resolve({ title: "T", description: "" })
return Promise.resolve({ data: { isPublic: true, isAdmin: false, isMember: false } })
})

render(<AuthGuard><div>App</div></AuthGuard>)

await waitFor(() => {
expect(mockSetActiveSkin).toHaveBeenCalledWith("default")
})
})

it("calls setActiveSkin('default') when /about returns ui_skin: null", async () => {
mockApiGet.mockImplementation((path: string) => {
if (path === "/about") return Promise.resolve({ title: "T", description: "", ui_skin: null })
return Promise.resolve({ data: { isPublic: true, isAdmin: false, isMember: false } })
})

render(<AuthGuard><div>App</div></AuthGuard>)

await waitFor(() => {
expect(mockSetActiveSkin).toHaveBeenCalledWith("default")
})
})
})
Loading
Loading