Skip to content
Open
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
20 changes: 20 additions & 0 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,26 @@
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>GIT GUD GET ROFLBOX</title>
<!-- FOUC prevention: apply color-mode before React hydrates -->
<script>
(function () {
try {
var saved = localStorage.getItem('roflbox.colorMode');
var mode;
if (saved === 'light' || saved === 'dark') {
mode = saved;
} else if (typeof window.matchMedia === 'function' && window.matchMedia('(prefers-color-scheme: dark)').matches) {
mode = 'dark';
} else {
mode = 'light';
}
document.documentElement.setAttribute('data-color-mode', mode);
} catch (e) {
// If localStorage or matchMedia are unavailable, leave the attribute unset
// (CSS :root defaults apply).
}
})();
</script>
</head>
<body>
<div id="root"></div>
Expand Down
14 changes: 12 additions & 2 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { BackgroundSwitcher } from '@/components/BackgroundSwitcher'
import { BG_THEMES, DEFAULT_THEME_ID, type BgTheme } from '@/lib/themes'
import { useColorMode } from '@/lib/colorMode'
import { TutorialProvider } from '@/components/TutorialProvider'
import { TutorialOverlay } from '@/components/TutorialOverlay'
import { useTutorial } from '@/lib/tutorial'
Expand All @@ -29,6 +30,9 @@ function App() {
const [matrixText, setMatrixText] = useState('INITIALIZING...')
const [chaosMode, setChaosMode] = useState(false)

// Color mode state – persisted in localStorage under 'roflbox.colorMode'
const { preference: colorModePreference, resolvedMode: resolvedColorMode, setColorMode } = useColorMode()

// Background theme state – persisted in localStorage
const [currentTheme, setCurrentTheme] = useState<BgTheme>(() => {
const saved = localStorage.getItem(STORAGE_KEY)
Expand Down Expand Up @@ -102,8 +106,14 @@ function App() {
className={`min-h-screen flex flex-col items-center justify-start p-4 relative ${chaosClass('perspective-crazy')}`}
style={{ overflow: 'visible', background: currentTheme.value }}
>
{/* Background theme switcher */}
<BackgroundSwitcher currentThemeId={currentTheme.id} onThemeChange={handleThemeChange} />
{/* Background theme + dark mode switcher */}
<BackgroundSwitcher
currentThemeId={currentTheme.id}
onThemeChange={handleThemeChange}
colorModePreference={colorModePreference}
resolvedColorMode={resolvedColorMode}
onColorModeChange={setColorMode}
/>

{/* FLOATING CHAOS - ABSOLUTE POSITIONED MADNESS - Only in chaos mode */}
{chaosMode && (
Expand Down
49 changes: 48 additions & 1 deletion src/components/BackgroundSwitcher.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,23 @@ import { useState } from 'react'
import { Button } from '@/components/ui/button'
import { BG_THEMES, type BgTheme } from '@/lib/themes'
import { useTutorial } from '@/lib/tutorial'
import { type ColorModePreference, type ColorMode } from '@/lib/colorMode'

interface BackgroundSwitcherProps {
currentThemeId: string
onThemeChange: (theme: BgTheme) => void
colorModePreference: ColorModePreference
resolvedColorMode: ColorMode
onColorModeChange: (pref: ColorModePreference) => void
}

export function BackgroundSwitcher({ currentThemeId, onThemeChange }: BackgroundSwitcherProps) {
export function BackgroundSwitcher({
currentThemeId,
onThemeChange,
colorModePreference,
resolvedColorMode,
onColorModeChange,
}: BackgroundSwitcherProps) {
const [open, setOpen] = useState(false)
const { start: startTutorial, completed: tutorialCompleted } = useTutorial()

Expand Down Expand Up @@ -62,6 +72,43 @@ export function BackgroundSwitcher({ currentThemeId, onThemeChange }: Background
</button>
))}
</div>

{/* Color mode selector */}
<div className="mt-2 pt-2 border-t border-white/10">
<div className="text-white/40 font-mono text-xs mb-1.5 tracking-wider">COLOR MODE:</div>
<div
role="radiogroup"
aria-label="Color mode"
className="flex gap-1"
>
{(['system', 'light', 'dark'] as const).map((pref) => {
const labels = { system: 'System', light: 'Light', dark: 'Dark' }
const isSelected = colorModePreference === pref
return (
<button
key={pref}
role="radio"
aria-checked={isSelected}
aria-label={`${labels[pref]} color mode${pref === 'system' ? ` (currently ${resolvedColorMode})` : ''}`}
onClick={() => onColorModeChange(pref)}
className={`flex-1 text-xs font-mono py-1 rounded transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/50 ${
isSelected
? 'bg-white/25 text-white border border-white/40'
: 'text-white/50 hover:text-white/80 hover:bg-white/10 border border-transparent'
}`}
>
{labels[pref]}
</button>
)
})}
</div>
{colorModePreference === 'system' && (
<div className="text-white/30 font-mono text-xs mt-1">
effective: {resolvedColorMode}
</div>
)}
</div>

<div className="mt-2 pt-2 border-t border-white/10">
<button
onClick={() => {
Expand Down
59 changes: 59 additions & 0 deletions src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,65 @@
outline-offset: 2px;
}

/* =====================================================================
Color-mode tokens
Applied when data-color-mode="dark|light" is set on <html>.
These rules are intentionally placed AFTER all [data-bg-theme] blocks
in this file. Both selectors have attribute-selector specificity (0,1,0),
so CSS cascade order guarantees the color-mode rules win when both
attributes are present on <html> — no combined selector overrides needed.

Dark mode — coherent dark UI; mirrors the existing :root palette so
the default experience is unchanged until the user toggles.
===================================================================== */
[data-color-mode="dark"] {
--text-primary: hsl(120 100% 75%);
--text-secondary: hsl(300 70% 80%);
--surface-1: hsl(280 80% 12%);
--surface-2: hsl(270 100% 8%);
--border-color: hsl(300 100% 75%);
--button-bg: hsl(320 100% 60%);
--button-text: hsl(120 100% 95%);
}

/* Light mode — readable light surfaces for prolonged use. */
[data-color-mode="light"] {
--text-primary: hsl(240 10% 10%);
--text-secondary: hsl(240 5% 40%);
--surface-1: hsl(0 0% 98%);
--surface-2: hsl(240 5% 93%);
--border-color: hsl(240 5% 75%);
--button-bg: hsl(240 5% 20%);
--button-text: hsl(0 0% 98%);

/* Override Shadcn/Tailwind design-system HSL tokens so that every
component using bg-card, text-card-foreground, text-muted-foreground,
border, etc. automatically adapts to the light theme. */
--background: 0 0% 96%;
--foreground: 240 10% 10%;
--card: 0 0% 98%;
--card-foreground: 240 10% 10%;
--popover: 0 0% 98%;
--popover-foreground: 240 10% 10%;
--primary: 240 5% 20%;
--primary-foreground: 0 0% 98%;
--secondary: 240 5% 90%;
--secondary-foreground: 240 10% 10%;
--muted: 240 5% 90%;
--muted-foreground: 240 5% 40%;
--accent: 240 5% 85%;
--accent-foreground: 240 10% 10%;
--border: 240 5% 75%;
--input: 0 0% 93%;
--ring: 240 5% 30%;
}

/* Visible focus ring in light mode */
[data-color-mode="light"] :focus-visible {
outline: 2px solid hsl(240 5% 30%);
outline-offset: 2px;
}

@layer utilities {
.border-rainbow {
border-image: linear-gradient(45deg, #ff00ff, #00ff00, #ff8000, #ffff00, #00ffff, #ff0080, #8000ff, #ff00ff) 1;
Expand Down
61 changes: 61 additions & 0 deletions src/lib/colorMode.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { useState, useEffect } from 'react'

export type ColorModePreference = 'light' | 'dark' | 'system'
export type ColorMode = 'light' | 'dark'

const STORAGE_KEY = 'roflbox.colorMode'

function getSystemMode(): ColorMode {
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
}

function resolveMode(preference: ColorModePreference): ColorMode {
if (preference === 'system') return getSystemMode()
return preference
}

/** Apply data-color-mode attribute to <html>. Called both from the hook and
* from the FOUC-prevention inline script in index.html. */
export function applyColorMode(mode: ColorMode): void {
document.documentElement.setAttribute('data-color-mode', mode)
}

/**
* Manages the UI color mode (light / dark / system).
*
* - Persists the user's explicit choice in localStorage under `roflbox.colorMode`.
* - Defaults to `system` (follows `prefers-color-scheme`) when no preference is
* stored yet.
* - Applies `data-color-mode="light|dark"` on `document.documentElement` so that
* CSS token overrides in `index.css` take effect.
*/
export function useColorMode() {
const [preference, setPreference] = useState<ColorModePreference>(() => {
const saved = localStorage.getItem(STORAGE_KEY)
if (saved === 'light' || saved === 'dark' || saved === 'system') return saved
return 'system'
})

const resolvedMode: ColorMode = resolveMode(preference)

// Keep the attribute in sync whenever the resolved mode changes.
useEffect(() => {
applyColorMode(resolvedMode)
}, [resolvedMode])

// When the user's preference is "system", listen for OS-level changes.
useEffect(() => {
if (preference !== 'system') return
const mq = window.matchMedia('(prefers-color-scheme: dark)')
const handler = () => applyColorMode(getSystemMode())
mq.addEventListener('change', handler)
return () => mq.removeEventListener('change', handler)
}, [preference])

const setColorMode = (pref: ColorModePreference): void => {
setPreference(pref)
localStorage.setItem(STORAGE_KEY, pref)
}

return { preference, resolvedMode, setColorMode }
}