diff --git a/packages/app/cypress/component/mode-toggle.cy.tsx b/packages/app/cypress/component/mode-toggle.cy.tsx index bb2e9ba6..581c5922 100644 --- a/packages/app/cypress/component/mode-toggle.cy.tsx +++ b/packages/app/cypress/component/mode-toggle.cy.tsx @@ -7,7 +7,7 @@ describe('ModeToggle', () => { @@ -28,13 +28,22 @@ describe('ModeToggle', () => { cy.get('html').should('have.class', 'minecraft'); }); - it('clicking toggle three times returns to light mode', () => { + it('clicking toggle three times cycles light → dark → minecraft → rick-morty', () => { cy.get('[data-testid="theme-toggle"]').click(); cy.get('html').should('have.class', 'dark'); cy.get('[data-testid="theme-toggle"]').click(); cy.get('html').should('have.class', 'minecraft'); cy.get('[data-testid="theme-toggle"]').click(); + cy.get('html').should('have.class', 'rick-morty'); + }); + + it('clicking toggle four times returns to light mode', () => { + cy.get('[data-testid="theme-toggle"]').click(); + cy.get('[data-testid="theme-toggle"]').click(); + cy.get('[data-testid="theme-toggle"]').click(); + cy.get('[data-testid="theme-toggle"]').click(); cy.get('html').should('not.have.class', 'dark'); cy.get('html').should('not.have.class', 'minecraft'); + cy.get('html').should('not.have.class', 'rick-morty'); }); }); diff --git a/packages/app/public/decorative/rick-morty/jerry-falling.gif b/packages/app/public/decorative/rick-morty/jerry-falling.gif new file mode 100644 index 00000000..016c53f4 Binary files /dev/null and b/packages/app/public/decorative/rick-morty/jerry-falling.gif differ diff --git a/packages/app/public/decorative/rick-morty/pickle-rick.png b/packages/app/public/decorative/rick-morty/pickle-rick.png new file mode 100644 index 00000000..95dd8f5c Binary files /dev/null and b/packages/app/public/decorative/rick-morty/pickle-rick.png differ diff --git a/packages/app/public/decorative/rick-morty/rick-morty-dance.png b/packages/app/public/decorative/rick-morty/rick-morty-dance.png new file mode 100644 index 00000000..8423665d Binary files /dev/null and b/packages/app/public/decorative/rick-morty/rick-morty-dance.png differ diff --git a/packages/app/public/decorative/rick-morty/rick-morty-inc.png b/packages/app/public/decorative/rick-morty/rick-morty-inc.png new file mode 100644 index 00000000..238ead0a Binary files /dev/null and b/packages/app/public/decorative/rick-morty/rick-morty-inc.png differ diff --git a/packages/app/public/decorative/rick-morty/rick-morty-portal.png b/packages/app/public/decorative/rick-morty/rick-morty-portal.png new file mode 100644 index 00000000..b9080177 Binary files /dev/null and b/packages/app/public/decorative/rick-morty/rick-morty-portal.png differ diff --git a/packages/app/public/decorative/rick-morty/rick-smile.png b/packages/app/public/decorative/rick-morty/rick-smile.png new file mode 100644 index 00000000..1238f407 Binary files /dev/null and b/packages/app/public/decorative/rick-morty/rick-smile.png differ diff --git a/packages/app/src/app/globals.css b/packages/app/src/app/globals.css index 50849070..8380958d 100644 --- a/packages/app/src/app/globals.css +++ b/packages/app/src/app/globals.css @@ -2,8 +2,9 @@ @import 'tw-animate-css'; @plugin '@tailwindcss/typography'; -@custom-variant dark (&:is(.dark *, .minecraft *)); +@custom-variant dark (&:is(.dark *, .minecraft *, .rick-morty *)); @custom-variant minecraft (&:is(.minecraft *)); +@custom-variant rick-morty (&:is(.rick-morty *)); /* Allow hash navigation to clear the fixed navbar */ .blog-prose h2[id], @@ -240,6 +241,70 @@ --overlay-run-7: oklch(0.78 0.18 60); } +/* Rick and Morty theme — portal-green / Morty-yellow on deep space dark. + * Anchor colors are pulled from the canonical R&M palette: portal green + * #97ce4c, Morty yellow #f0e14a, Rick lab-coat blue #0bb4e4, Summer pink + * #e89ac7, with Beth purple / Birdperson red rounding out the 8 overlay + * slots. References: + * - color-hex.com/color-palette/9134 + * - colorswall.com/palette/243091 (schwifty greens) + * - colorswall.com/palette/242810 (Rick blue) + */ +.rick-morty { + --background: #0d1525; + --foreground: #dceea0; + --card: #131c33; + --card-foreground: #f5fbe1; + --popover: #1a2440; + --popover-foreground: #f5fbe1; + --primary: #97ce4c; + --primary-foreground: #0d1525; + --secondary: #f0e14a; + --secondary-foreground: #0d1525; + --brand: var(--primary); + --muted: #1f2a47; + --muted-foreground: rgba(220, 238, 160, 0.65); + --accent: #0bb4e4; + --accent-foreground: #0d1525; + --destructive: #e63946; + --border: #2f4a3e; + --border-alt: #1d2a4b; + --input: rgba(151, 206, 76, 0.18); + --ring: #97ce4c; + + /* Sidebar */ + --sidebar: #0a1020; + --sidebar-foreground: #dceea0; + --sidebar-primary: #97ce4c; + --sidebar-primary-foreground: #0d1525; + --sidebar-accent: #1a2440; + --sidebar-accent-foreground: #f5fbe1; + --sidebar-border: #2f4a3e; + --sidebar-ring: #97ce4c; + + /* Overlay-run palette: main R&M characters / iconic colors, tuned for + * legibility on the deep-space dark background. */ + --overlay-run-0: #97ce4c; /* portal green */ + --overlay-run-1: #f0e14a; /* Morty yellow */ + --overlay-run-2: #0bb4e4; /* Rick lab coat blue */ + --overlay-run-3: #e89ac7; /* Summer pink */ + --overlay-run-4: #c1f762; /* schwifty bright green */ + --overlay-run-5: #ffb78c; /* Jerry skin */ + --overlay-run-6: #b388eb; /* Beth purple */ + --overlay-run-7: #ff6e54; /* Birdperson red */ +} + +/* Subtle portal-green glow on rick-morty headings + the brand name so the + * theme has visual identity beyond the palette swap. */ +.rick-morty h1, +.rick-morty h2, +.rick-morty h3, +.rick-morty .brand-name { + text-shadow: + 0 0 6px rgba(151, 206, 76, 0.45), + 0 0 14px rgba(151, 206, 76, 0.18); +} + /* Force pixel font on everything in minecraft mode */ .minecraft, .minecraft * { @@ -319,6 +384,49 @@ display: none; } +/* Rick-morty: keep the circuit background, but shift its tone toward portal + * green so it sits on the deep-space dark surface coherently. */ +.rick-morty .circuit-bg { + filter: hue-rotate(60deg) saturate(1.4) brightness(0.85); +} + +/* Jerry-falling — one-shot top→bottom fall on rick-morty theme activation, + * triggered from rick-morty-decorations.tsx. Mirrors the minecraft Ender + * Dragon fly-across pattern but oriented vertically. Plays once and lands + * Jerry off-screen below so the GIF stops being rendered. The drift + + * rotation give the fall a flailing-arms cartoon feel. */ +@keyframes rm-jerry-fall { + 0% { + transform: translate(0, -30vh) rotate(-6deg); + opacity: 0; + } + 10% { + opacity: 0.95; + } + 50% { + transform: translate(3vw, 40vh) rotate(10deg); + } + 90% { + opacity: 0.95; + } + 100% { + transform: translate(-2vw, 130vh) rotate(-4deg); + opacity: 0; + } +} + +.rm-jerry-fall { + animation: rm-jerry-fall 7s cubic-bezier(0.4, 0, 0.6, 1) 1 forwards; + will-change: transform, opacity; +} + +@media (prefers-reduced-motion: reduce) { + .rm-jerry-fall { + animation: none; + display: none; + } +} + /* Splash text — yellow bouncing rotated text (Minecraft title screen style) */ @keyframes splash-bounce { 0%, diff --git a/packages/app/src/app/layout.tsx b/packages/app/src/app/layout.tsx index 22bda276..d4c758dc 100644 --- a/packages/app/src/app/layout.tsx +++ b/packages/app/src/app/layout.tsx @@ -11,6 +11,8 @@ import { Footer } from '@/components/footer/footer'; import { Header } from '@/components/header/header'; import { CircuitBackground } from '@/components/circuit-background'; import { MinecraftBackgroundLazy } from '@/components/minecraft/minecraft-background-lazy'; +import { RickMortyAudioLazy } from '@/components/rick-morty/rick-morty-audio-lazy'; +import { RickMortyDecorations } from '@/components/rick-morty/rick-morty-decorations'; import { ThemeProvider } from '@/components/ui/theme-provider'; import { AUTHOR_HANDLE, @@ -178,13 +180,15 @@ export default async function RootLayout({ + + diff --git a/packages/app/src/components/header/header.tsx b/packages/app/src/components/header/header.tsx index d59bcb3e..b80c19ff 100644 --- a/packages/app/src/components/header/header.tsx +++ b/packages/app/src/components/header/header.tsx @@ -8,6 +8,7 @@ import { track } from '@/lib/analytics'; import { ModeToggle } from '@/components/ui/mode-toggle'; import { MinecraftToggles } from '@/components/minecraft/minecraft-toggles'; +import { RickMortyToggles } from '@/components/rick-morty/rick-morty-toggles'; import { navigateInApp } from '@/lib/client-navigation'; import { cn } from '@/lib/utils'; @@ -134,6 +135,7 @@ export const Header = ({ starCount }: { starCount?: number | null }) => {
+ {/* Mobile hamburger */} diff --git a/packages/app/src/components/inference/ui/GPUGraph.tsx b/packages/app/src/components/inference/ui/GPUGraph.tsx index cafd3a82..06a62daf 100644 --- a/packages/app/src/components/inference/ui/GPUGraph.tsx +++ b/packages/app/src/components/inference/ui/GPUGraph.tsx @@ -109,7 +109,10 @@ const GPUGraph = React.memo( const gpuDateColorMap = useMemo(() => { const { dates, sortedGPUs } = gpuDatePairs; if (sortedGPUs.length === 0 || dates.length === 0) return {}; - const theme = resolvedTheme === 'dark' || resolvedTheme === 'minecraft' ? 'dark' : 'light'; + const theme = + resolvedTheme === 'dark' || resolvedTheme === 'minecraft' || resolvedTheme === 'rick-morty' + ? 'dark' + : 'light'; return generateGpuDateColors(sortedGPUs, dates.length, theme); }, [gpuDatePairs, resolvedTheme]); diff --git a/packages/app/src/components/inference/ui/ModelArchitectureDiagram.tsx b/packages/app/src/components/inference/ui/ModelArchitectureDiagram.tsx index e79f057a..623049c5 100644 --- a/packages/app/src/components/inference/ui/ModelArchitectureDiagram.tsx +++ b/packages/app/src/components/inference/ui/ModelArchitectureDiagram.tsx @@ -2004,7 +2004,7 @@ export default function ModelArchitectureDiagram({ renderDiagram( svgRef.current, arch, - resolvedTheme === 'dark' || resolvedTheme === 'minecraft', + resolvedTheme === 'dark' || resolvedTheme === 'minecraft' || resolvedTheme === 'rick-morty', expandedBlocks, toggleBlock, ); @@ -2019,7 +2019,9 @@ export default function ModelArchitectureDiagram({ renderDiagram( svgRef.current, arch, - resolvedTheme === 'dark' || resolvedTheme === 'minecraft', + resolvedTheme === 'dark' || + resolvedTheme === 'minecraft' || + resolvedTheme === 'rick-morty', expandedBlocks, toggleBlock, ); diff --git a/packages/app/src/components/rick-morty/rick-morty-audio-lazy.tsx b/packages/app/src/components/rick-morty/rick-morty-audio-lazy.tsx new file mode 100644 index 00000000..21f7f85d --- /dev/null +++ b/packages/app/src/components/rick-morty/rick-morty-audio-lazy.tsx @@ -0,0 +1,12 @@ +'use client'; + +import dynamic from 'next/dynamic'; + +const RickMortyAudio = dynamic( + () => import('./rick-morty-audio').then((mod) => mod.RickMortyAudio), + { ssr: false }, +); + +export function RickMortyAudioLazy() { + return ; +} diff --git a/packages/app/src/components/rick-morty/rick-morty-audio.tsx b/packages/app/src/components/rick-morty/rick-morty-audio.tsx new file mode 100644 index 00000000..01856e28 --- /dev/null +++ b/packages/app/src/components/rick-morty/rick-morty-audio.tsx @@ -0,0 +1,235 @@ +'use client'; + +import { useEffect, useRef, useState } from 'react'; + +/** Adult Swim's official upload of the Rick and Morty Seasons 1-3 opening credits. + * Short enough that a plain loop covers the whole audio bed — no song-picker + * needed (unlike the Minecraft OST compilation). */ +const RM_INTRO_VIDEO_ID = 'DLaqu2QJYPY'; + +let userHasInteracted = false; +if (typeof document !== 'undefined') { + const markInteracted = () => { + userHasInteracted = true; + }; + document.addEventListener('pointerdown', markInteracted, { capture: true, once: true }); + document.addEventListener('keydown', markInteracted, { capture: true, once: true }); +} + +function getInitialMusicStart(): number { + try { + const raw = sessionStorage.getItem('rick-morty-music-pos'); + if (raw) { + const parsed = JSON.parse(raw); + if (Date.now() - parsed.ts < 30_000 && typeof parsed.time === 'number' && parsed.time > 0) { + return Math.floor(parsed.time); + } + } + } catch { + /* ignore parse errors */ + } + return 0; +} + +/** + * Streams the Rick and Morty intro song from YouTube whenever the + * rick-morty theme is active and the user hasn't muted music. Mirrors + * the audio path of `minecraft-background.tsx` (invisible 1×1 iframe, + * autoplay nudge on first user gesture, sessionStorage position save) + * but without the 3D scene — rick-morty's visuals are static cutouts + * plus the Jerry-fall GIF, both of which live in + * `rick-morty-decorations.tsx`. + */ +export function RickMortyAudio() { + const [isRickMorty, setIsRickMorty] = useState(false); + const [musicEnabled, setMusicEnabled] = useState(true); + const playerRef = useRef(null); + const wrapperRef = useRef(null); + const nudgeRef = useRef<(() => void) | null>(null); + + useEffect(() => { + function check() { + setIsRickMorty(document.documentElement.classList.contains('rick-morty')); + } + check(); + const observer = new MutationObserver(check); + observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] }); + return () => observer.disconnect(); + }, []); + + useEffect(() => { + function check() { + setMusicEnabled(localStorage.getItem('rick-morty-music') !== 'false'); + } + check(); + window.addEventListener('rick-morty-music-toggle', check); + return () => window.removeEventListener('rick-morty-music-toggle', check); + }, []); + + const showMusic = isRickMorty && musicEnabled; + + useEffect(() => { + if (!showMusic) return; + if (document.querySelector('#yt-iframe-api')) return; + const tag = document.createElement('script'); + tag.id = 'yt-iframe-api'; + tag.src = 'https://www.youtube.com/iframe_api'; + document.head.append(tag); + }, [showMusic]); + + useEffect(() => { + if (!showMusic) return; + const interval = setInterval(() => { + const player = playerRef.current; + if (!player) return; + try { + const time = player.getCurrentTime(); + if (time > 0) { + sessionStorage.setItem('rick-morty-music-pos', JSON.stringify({ time, ts: Date.now() })); + } + } catch { + /* player may not be ready */ + } + }, 2000); + return () => clearInterval(interval); + }, [showMusic]); + + useEffect(() => { + if (!showMusic) return; + function save() { + const player = playerRef.current; + if (!player) return; + try { + const time = player.getCurrentTime(); + if (time > 0) { + sessionStorage.setItem('rick-morty-music-pos', JSON.stringify({ time, ts: Date.now() })); + } + } catch { + /* ignore */ + } + } + window.addEventListener('beforeunload', save); + return () => window.removeEventListener('beforeunload', save); + }, [showMusic]); + + useEffect(() => { + if (!showMusic) { + playerRef.current?.destroy(); + playerRef.current = null; + if (wrapperRef.current) wrapperRef.current.innerHTML = ''; + return; + } + + function createPlayer() { + if (playerRef.current || !wrapperRef.current) return; + const el = document.createElement('div'); + wrapperRef.current.append(el); + + let started = false; + const startSeconds = getInitialMusicStart(); + + function nudge() { + if (started) return; + playerRef.current?.playVideo(); + } + nudgeRef.current = nudge; + function onStarted() { + started = true; + document.removeEventListener('pointerdown', nudge, true); + document.removeEventListener('keydown', nudge, true); + nudgeRef.current = null; + } + + try { + playerRef.current = new YT.Player(el, { + height: '1', + width: '1', + videoId: RM_INTRO_VIDEO_ID, + playerVars: { + autoplay: 1, + loop: 1, + playlist: RM_INTRO_VIDEO_ID, + start: startSeconds, + controls: 0, + disablekb: 1, + modestbranding: 1, + }, + events: { + onReady: (e: YT.PlayerEvent) => { + e.target.setVolume(35); + e.target.playVideo(); + document.addEventListener('pointerdown', nudge, true); + document.addEventListener('keydown', nudge, true); + if (userHasInteracted) { + let retries = 0; + const retry = setInterval(() => { + if (started || retries++ > 10) { + clearInterval(retry); + return; + } + playerRef.current?.playVideo(); + }, 300); + } + }, + onStateChange: (e: YT.PlayerEvent & { data: number }) => { + if (e.data === 1 && !started) onStarted(); + if (e.data === 0) e.target.playVideo(); + }, + }, + }); + } catch { + // YouTube API failed (blocked) — degrade silently + } + } + + let installedCallback = false; + if (typeof YT !== 'undefined' && typeof YT.Player === 'function') { + createPlayer(); + } else { + installedCallback = true; + const prev = window.onYouTubeIframeAPIReady; + window.onYouTubeIframeAPIReady = () => { + prev?.(); + createPlayer(); + }; + } + + return () => { + if (installedCallback) { + window.onYouTubeIframeAPIReady = undefined; + } + if (nudgeRef.current) { + document.removeEventListener('pointerdown', nudgeRef.current, true); + document.removeEventListener('keydown', nudgeRef.current, true); + nudgeRef.current = null; + } + const player = playerRef.current; + if (player) { + try { + const time = player.getCurrentTime(); + if (time > 0) { + sessionStorage.setItem( + 'rick-morty-music-pos', + JSON.stringify({ time, ts: Date.now() }), + ); + } + } catch { + /* ignore */ + } + } + playerRef.current?.destroy(); + playerRef.current = null; + if (wrapperRef.current) wrapperRef.current.innerHTML = ''; + }; + }, [showMusic]); + + if (!isRickMorty) return null; + + return ( +
+ ); +} diff --git a/packages/app/src/components/rick-morty/rick-morty-decorations.tsx b/packages/app/src/components/rick-morty/rick-morty-decorations.tsx new file mode 100644 index 00000000..8863b339 --- /dev/null +++ b/packages/app/src/components/rick-morty/rick-morty-decorations.tsx @@ -0,0 +1,143 @@ +'use client'; + +import { useEffect, useState } from 'react'; + +/** + * Decorative Rick & Morty character PNGs scattered around the viewport + * corners, plus a one-shot Jerry top→bottom fall at theme activation. Only + * renders while the rick-morty theme is active. Watches + * `document.documentElement` class changes via a MutationObserver so it + * appears / disappears live with the mode-toggle (mirrors the pattern used + * by `minecraft-toggles.tsx` for header buttons, and the Ender Dragon + * fly-across in `minecraft-decorations.tsx`). + * + * All static images are `pointer-events: none` and sit at low z-index so + * they never interfere with chart hover, tooltips, or scroll. The Jerry + * GIF is absolutely positioned and falls top-to-bottom once per theme + * activation (animation-iteration-count: 1). + * + * Asset provenance: pngimg.com (CC BY-NC 4.0, decorative use only) for + * static cutouts; jerry-falling.gif from a Reddit-hosted animated GIF. + * pickle-rick.png was alpha-cut locally from a flat opaque source whose + * "transparent" background was a baked-in checkerboard rendered in + * pixels — flood-fill from the borders restored real alpha. + */ +const DECORATIONS = [ + // Top-left: Rick smiling — small, peeking from the corner. + { + src: '/decorative/rick-morty/rick-smile.png', + alt: 'Rick Sanchez', + style: { + top: '5rem', + left: '0.5rem', + width: 'min(110px, 9vw)', + transform: 'rotate(-8deg)', + }, + }, + // Top-right: Pickle Rick — taller than wide, slight tilt. + { + src: '/decorative/rick-morty/pickle-rick.png', + alt: 'Pickle Rick', + style: { + top: '5rem', + right: '0.5rem', + width: 'min(110px, 9vw)', + transform: 'rotate(8deg)', + }, + }, + // Mid-left: Morty Inc Rick — vertical, character-rich. + { + src: '/decorative/rick-morty/rick-morty-inc.png', + alt: 'Rick with Morty Inc.', + style: { + top: '40%', + left: '0', + width: 'min(140px, 11vw)', + transform: 'rotate(-4deg)', + }, + }, + // Bottom-left: Rick + Morty dancing. + { + src: '/decorative/rick-morty/rick-morty-dance.png', + alt: 'Rick and Morty dancing', + style: { + bottom: '4rem', + left: '0.5rem', + width: 'min(150px, 12vw)', + transform: 'rotate(4deg)', + }, + }, + // Bottom-right: Rick + Morty stepping out of a portal. + { + src: '/decorative/rick-morty/rick-morty-portal.png', + alt: 'Rick and Morty in portal', + style: { + bottom: '4rem', + right: '0.5rem', + width: 'min(160px, 12vw)', + transform: 'rotate(-3deg)', + }, + }, +] as const; + +export function RickMortyDecorations() { + const [active, setActive] = useState(false); + // Bumps each time the theme is (re)activated, used as the React key on + // the Jerry `` to force-remount and re-trigger the CSS fall. + const [jerryNonce, setJerryNonce] = useState(0); + + useEffect(() => { + let wasRickMorty = document.documentElement.classList.contains('rick-morty'); + setActive(wasRickMorty); + if (wasRickMorty) setJerryNonce((n) => n + 1); + + const check = () => { + const isRickMorty = document.documentElement.classList.contains('rick-morty'); + setActive(isRickMorty); + // Re-trigger Jerry only on a transition off→on, not on every + // unrelated class change. + if (isRickMorty && !wasRickMorty) setJerryNonce((n) => n + 1); + wasRickMorty = isRickMorty; + }; + + const observer = new MutationObserver(check); + observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] }); + return () => observer.disconnect(); + }, []); + + if (!active) return null; + + return ( + <> + + + {/* One-shot Jerry top→bottom fall. Keyed on jerryNonce so the `` + * remounts (and the CSS animation re-plays) every theme activation. */} + + + ); +} diff --git a/packages/app/src/components/rick-morty/rick-morty-toggles.tsx b/packages/app/src/components/rick-morty/rick-morty-toggles.tsx new file mode 100644 index 00000000..4297c960 --- /dev/null +++ b/packages/app/src/components/rick-morty/rick-morty-toggles.tsx @@ -0,0 +1,69 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { Music } from 'lucide-react'; +import { track } from '@/lib/analytics'; +import { cn } from '@/lib/utils'; + +const toggleClasses = cn( + 'inline-flex items-center justify-center rounded-md p-2', + 'text-muted-foreground hover:text-foreground hover:bg-accent', + 'transition-colors duration-200', + 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2', +); + +/** Header music toggle for the rick-morty theme — only renders when the + * theme is active. Mirrors `MinecraftToggles` but ships only the music + * button (no click-sound counterpart). */ +export function RickMortyToggles() { + const [isRickMorty, setIsRickMorty] = useState(false); + const [musicOn, setMusicOn] = useState(true); + + useEffect(() => { + function checkTheme() { + setIsRickMorty(document.documentElement.classList.contains('rick-morty')); + } + function checkMusic() { + setMusicOn(localStorage.getItem('rick-morty-music') !== 'false'); + } + checkTheme(); + checkMusic(); + + const observer = new MutationObserver(checkTheme); + observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] }); + window.addEventListener('rick-morty-music-toggle', checkMusic); + return () => { + observer.disconnect(); + window.removeEventListener('rick-morty-music-toggle', checkMusic); + }; + }, []); + + function toggleMusic() { + const next = !musicOn; + setMusicOn(next); + localStorage.setItem('rick-morty-music', String(next)); + window.dispatchEvent(new CustomEvent('rick-morty-music-toggle')); + track('rick_morty_music_toggled', { enabled: next }); + } + + if (!isRickMorty) return null; + + return ( + + ); +} diff --git a/packages/app/src/components/ui/mode-toggle.tsx b/packages/app/src/components/ui/mode-toggle.tsx index f216624f..f5757735 100644 --- a/packages/app/src/components/ui/mode-toggle.tsx +++ b/packages/app/src/components/ui/mode-toggle.tsx @@ -1,14 +1,14 @@ 'use client'; import { track } from '@/lib/analytics'; -import { Pickaxe, Sun } from 'lucide-react'; +import { Atom, Pickaxe, Sun } from 'lucide-react'; import { useTheme } from 'next-themes'; import * as React from 'react'; import { cn } from '@/lib/utils'; -type Theme = 'light' | 'dark' | 'minecraft'; -const THEME_CYCLE: Theme[] = ['light', 'dark', 'minecraft']; +type Theme = 'light' | 'dark' | 'minecraft' | 'rick-morty'; +const THEME_CYCLE: Theme[] = ['light', 'dark', 'minecraft', 'rick-morty']; export function ModeToggle() { const { setTheme, theme } = useTheme(); @@ -61,6 +61,9 @@ export function ModeToggle() { > {theme === 'minecraft' ? (