feat: add animated theme toggler and refine dark mode styling

This commit is contained in:
Shine1i 2026-02-25 00:18:20 +01:00
commit 5a87ee40aa
9 changed files with 119 additions and 45 deletions

View file

@ -43,7 +43,7 @@
"dexie": "^4.3.0",
"framer-motion": "^11.18.2",
"katex": "^0.16.28",
"lucide-react": "^0.563.0",
"lucide-react": "^0.575.0",
"mammoth": "^1.11.0",
"motion": "^12.34.0",
"next": "^16.1.6",
@ -1451,7 +1451,7 @@
"lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
"lucide-react": ["lucide-react@0.563.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-8dXPB2GI4dI8jV4MgUDGBeLdGk8ekfqVZ0BdLcrRzocGgG75ltNEmWS+gE7uokKF/0oSUuczNDT+g9hFJ23FkA=="],
"lucide-react": ["lucide-react@0.575.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-VuXgKZrk0uiDlWjGGXmKV6MSk9Yy4l10qgVvzGn2AWBx1Ylt0iBexKOAoA6I7JO3m+M9oeovJd3yYENfkUbOeg=="],
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],

View file

@ -51,7 +51,7 @@
"dexie": "^4.3.0",
"framer-motion": "^11.18.2",
"katex": "^0.16.28",
"lucide-react": "^0.563.0",
"lucide-react": "^0.575.0",
"mammoth": "^1.11.0",
"motion": "^12.34.0",
"next": "^16.1.6",

Binary file not shown.

After

Width:  |  Height:  |  Size: 162 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 159 KiB

View file

@ -3,6 +3,7 @@ import {
HoverCardContent,
HoverCardTrigger,
} from "@/components/ui/hover-card";
import { AnimatedThemeToggler } from "@/components/ui/animated-theme-toggler";
import {
Sheet,
SheetContent,
@ -160,6 +161,11 @@ export function Navbar() {
{/* Right: docs/tour desktop */}
<div className="hidden items-center gap-2 md:flex">
<AnimatedThemeToggler
className="flex h-9 w-9 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground [&_svg]:size-4"
title="Toggle theme"
aria-label="Toggle theme"
/>
<HoverCard openDelay={200} closeDelay={100}>
<HoverCardTrigger asChild={true}>
<a

View file

@ -0,0 +1,82 @@
import { useCallback, useEffect, useRef, useState } from "react"
import { Moon, Sun } from "lucide-react"
import { flushSync } from "react-dom"
import { cn } from "@/lib/utils"
interface AnimatedThemeTogglerProps extends React.ComponentPropsWithoutRef<"button"> {
duration?: number
}
export const AnimatedThemeToggler = ({
className,
duration = 400,
...props
}: AnimatedThemeTogglerProps) => {
const [isDark, setIsDark] = useState(false)
const buttonRef = useRef<HTMLButtonElement>(null)
useEffect(() => {
const updateTheme = () => {
setIsDark(document.documentElement.classList.contains("dark"))
}
updateTheme()
const observer = new MutationObserver(updateTheme)
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ["class"],
})
return () => observer.disconnect()
}, [])
const toggleTheme = useCallback(async () => {
if (!buttonRef.current) return
await document.startViewTransition(() => {
flushSync(() => {
const newTheme = !isDark
setIsDark(newTheme)
document.documentElement.classList.toggle("dark")
localStorage.setItem("theme", newTheme ? "dark" : "light")
})
}).ready
const { top, left, width, height } =
buttonRef.current.getBoundingClientRect()
const x = left + width / 2
const y = top + height / 2
const maxRadius = Math.hypot(
Math.max(left, window.innerWidth - left),
Math.max(top, window.innerHeight - top)
)
document.documentElement.animate(
{
clipPath: [
`circle(0px at ${x}px ${y}px)`,
`circle(${maxRadius}px at ${x}px ${y}px)`,
],
},
{
duration,
easing: "ease-in-out",
pseudoElement: "::view-transition-new(root)",
}
)
}, [isDark, duration])
return (
<button
ref={buttonRef}
onClick={toggleTheme}
className={cn(className)}
{...props}
>
{isDark ? <Sun /> : <Moon />}
<span className="sr-only">Toggle theme</span>
</button>
)
}

View file

@ -1,5 +1,4 @@
import { Button } from "@/components/ui/button";
import { LightRays } from "@/components/ui/light-rays";
import {
shouldShowTrainingView,
useDatasetPreviewDialogStore,
@ -73,14 +72,6 @@ export function StudioPage(): ReactElement {
return (
<div className="relative min-h-screen overflow-hidden bg-background">
<LightRays
count={6}
color="rgba(34, 197, 94, 0.14)"
blur={30}
speed={18}
length="62vh"
style={{ opacity: 0.45 }}
/>
<main className="relative z-10 mx-auto max-w-7xl px-4 py-4 sm:px-6">
<GuidedTour {...tour.tourProps} celebrate={isConfigTour} />

View file

@ -1,5 +1,4 @@
import { cn } from "@/lib/utils";
import { LightRays } from "@/components/ui/light-rays";
import { useTrainingRuntimeStore } from "@/features/training";
import type { ReactElement } from "react";
import { useShallow } from "zustand/react/shallow";
@ -33,14 +32,6 @@ export function TrainingView(): ReactElement {
return (
<div className={cn("relative", showOverlay && "min-h-[72vh]")}>
<LightRays
count={4}
color="rgba(16, 185, 129, 0.13)"
blur={28}
speed={20}
length="52vh"
style={{ opacity: 0.35 }}
/>
<div
className={cn(
"relative z-10 flex flex-col gap-6 transition-[filter]",

View file

@ -93,38 +93,37 @@
/*--shadow-2xl: 0px 0px 0px 0px hsl(0 0% 0% / 0);*/
--tracking-normal: 0em;
}
.dark {
--background: oklch(0.1014 0.0229 160.0332);
--foreground: oklch(0.9808 0.0059 170.4505);
--card: oklch(0.1216 0.02 169.327);
--card-foreground: oklch(0.9808 0.0059 170.4505);
--popover: oklch(0.1216 0.02 169.327);
--popover-foreground: oklch(0.9808 0.0059 170.4505);
--background: oklch(0.24 0 0);
--foreground: oklch(0.98 0 0);
--card: oklch(0.28 0 0);
--card-foreground: oklch(0.98 0 0);
--popover: oklch(0.28 0 0);
--popover-foreground: oklch(0.98 0 0);
--primary: oklch(0.6929 0.1396 166.5513);
--primary-foreground: oklch(1 0 0);
--secondary: oklch(0.2015 0.0403 165.8147);
--secondary-foreground: oklch(0.9808 0.0059 170.4505);
--muted: oklch(0.2002 0 0);
--muted-foreground: oklch(0.6993 0 0);
--accent: oklch(0.2015 0.0403 165.8147);
--accent-foreground: oklch(0.9808 0.0059 170.4505);
--secondary: oklch(0.33 0 0);
--secondary-foreground: oklch(0.98 0 0);
--muted: oklch(0.33 0 0);
--muted-foreground: oklch(0.70 0 0);
--accent: oklch(0.33 0 0);
--accent-foreground: oklch(0.98 0 0);
--destructive: oklch(0.6368 0.2078 25.3313);
--border: oklch(0.2502 0.0213 164.5852);
--input: oklch(0.2502 0.0213 164.5852);
--border: oklch(0.38 0 0);
--input: oklch(0.38 0 0);
--ring: oklch(0.6929 0.1396 166.5513);
--chart-1: oklch(0.7511 0.1407 166.2284);
--chart-2: oklch(0.75 0.14 136.5572);
--chart-3: oklch(0.7554 0.1285 197.339);
--chart-4: oklch(0.7503 0.1199 346.7805);
--chart-5: oklch(0.799 0.1196 84.6633);
--sidebar: oklch(0.15 0 0);
--sidebar-foreground: oklch(0.9808 0.0059 170.4505);
--sidebar: oklch(0.24 0 0);
--sidebar-foreground: oklch(0.98 0 0);
--sidebar-primary: oklch(0.6929 0.1396 166.5513);
--sidebar-primary-foreground: oklch(1 0 0);
--sidebar-accent: oklch(0.2015 0.0403 165.8147);
--sidebar-accent-foreground: oklch(0.9808 0.0059 170.4505);
--sidebar-border: oklch(0.2502 0.0213 164.5852);
--sidebar-accent: oklch(0.33 0 0);
--sidebar-accent-foreground: oklch(0.98 0 0);
--sidebar-border: oklch(0.38 0 0);
--sidebar-ring: oklch(0.6929 0.1396 166.5513);
--destructive-foreground: oklch(1 0 0);
--radius: 1.2rem;
@ -142,14 +141,14 @@
--shadow-2xs: 0px 0px 0px 0px hsl(0 0% 0% / 0);
--shadow-xs: 0px 0px 0px 0px hsl(0 0% 0% / 0);
--shadow-sm: 0px 0px 0px 0px hsl(0 0% 0% / 0), 0px 1px 2px 0px
hsl(0 0% 0% / 0);
hsl(0 0% 0% / 0);
--shadow: 0px 0px 0px 0px hsl(0 0% 0% / 0), 0px 1px 2px 0px hsl(0 0% 0% / 0);
--shadow-md: 0px 0px 0px 0px hsl(0 0% 0% / 0), 0px 2px 4px 0px
hsl(0 0% 0% / 0);
hsl(0 0% 0% / 0);
--shadow-lg: 0px 0px 0px 0px hsl(0 0% 0% / 0), 0px 4px 6px 0px
hsl(0 0% 0% / 0);
hsl(0 0% 0% / 0);
--shadow-xl: 0px 0px 0px 0px hsl(0 0% 0% / 0), 0px 8px 10px 0px
hsl(0 0% 0% / 0);
hsl(0 0% 0% / 0);
--shadow-2xl: 0px 0px 0px 0px hsl(0 0% 0% / 0);
}
@ -359,3 +358,8 @@
@apply bg-background text-foreground;
}
}
::view-transition-old(root), ::view-transition-new(root) {
animation: none;
mix-blend-mode: normal;
}