From 836272280cff899c5d2414f078c16d78e917c624 Mon Sep 17 00:00:00 2001 From: tayloraj10 Date: Tue, 14 Jul 2026 19:50:18 -0400 Subject: [PATCH 1/4] Add theming system and shared UI components Introduces a centralized MUI theme, dark/light mode provider, and shared components (ThemeToggle, WaveDivider, chip color helpers) used across the authenticated and unauthenticated layouts. --- app/app.css | 103 ++++++++++++++++++++++++- app/components/ThemeToggle.tsx | 34 ++++++++ app/components/WaveDivider.tsx | 43 +++++++++++ app/root.tsx | 22 +++++- app/theme/ThemeModeProvider.tsx | 63 +++++++++++++++ app/theme/theme.ts | 133 ++++++++++++++++++++++++++++++++ 6 files changed, 394 insertions(+), 4 deletions(-) create mode 100644 app/components/ThemeToggle.tsx create mode 100644 app/components/WaveDivider.tsx create mode 100644 app/theme/ThemeModeProvider.tsx create mode 100644 app/theme/theme.ts diff --git a/app/app.css b/app/app.css index 75c6338..ceb77af 100644 --- a/app/app.css +++ b/app/app.css @@ -1,16 +1,113 @@ @import 'tailwindcss'; +/* Dark mode is toggled by adding/removing `.dark` on (see ThemeModeProvider), + rather than following the OS preference, so it can stay in sync with the MUI theme. */ +@custom-variant dark (&:where(.dark, .dark *)); + @theme { --font-sans: 'Inter', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'; + + /* Water/brand blue, anchored on the PHLASK logo color (#10B6FF = brand-500) */ + --color-brand-50: #eafaff; + --color-brand-100: #d6f4ff; + --color-brand-200: #ade8ff; + --color-brand-300: #6fd6ff; + --color-brand-400: #38c1ff; + --color-brand-500: #10b6ff; + --color-brand-600: #0090de; + --color-brand-700: #0072b3; + --color-brand-800: #045c90; + --color-brand-900: #0a3a5c; + + /* Deep ocean navy, used for dark-mode surfaces */ + --color-navy-950: #071522; + --color-navy-900: #0a1929; + --color-navy-800: #10263a; + --color-navy-700: #17344c; + --color-navy-600: #1f4560; } html, body { - @apply bg-white dark:bg-gray-950; + @apply bg-brand-50 text-navy-900 dark:bg-navy-950 dark:text-brand-50; + color-scheme: light; +} + +html.dark { + color-scheme: dark; +} + +@keyframes wave-drift { + from { + transform: translateX(0); + } + to { + transform: translateX(-50%); + } +} + +@keyframes bubble-rise { + from { + transform: translateY(0) scale(1); + opacity: 0.5; + } + to { + transform: translateY(-140px) scale(1.15); + opacity: 0; + } +} + +/* Ambient per-type motion for resource-type chip icons (see chipColors.tsx) */ +@keyframes chip-icon-bob { + 0%, + 100% { + transform: translateY(0); + } + 50% { + transform: translateY(-2px); + } +} + +@keyframes chip-icon-pulse { + 0%, + 100% { + transform: scale(1); + } + 50% { + transform: scale(1.18); + } +} + +@keyframes chip-icon-sway { + 0%, + 100% { + transform: rotate(-10deg); + } + 50% { + transform: rotate(10deg); + } +} - @media (prefers-color-scheme: dark) { - color-scheme: dark; +@keyframes chip-icon-wiggle { + 0%, + 100% { + transform: rotate(0deg); + } + 25% { + transform: rotate(-12deg); + } + 75% { + transform: rotate(12deg); } } + +/* Water-themed scrollbar */ +* { + scrollbar-color: var(--color-brand-300) transparent; +} + +html.dark * { + scrollbar-color: var(--color-navy-600) transparent; +} diff --git a/app/components/ThemeToggle.tsx b/app/components/ThemeToggle.tsx new file mode 100644 index 0000000..c5ee989 --- /dev/null +++ b/app/components/ThemeToggle.tsx @@ -0,0 +1,34 @@ +import { DarkMode, LightMode } from "@mui/icons-material"; +import { IconButton, Tooltip } from "@mui/material"; +import { useThemeMode } from "~/theme/ThemeModeProvider"; +import { brand, navy } from "~/theme/theme"; + +// Styling and icon visibility are driven purely by the `.dark` class (via +// theme.applyStyles / Tailwind's dark: variant) rather than the `mode` value +// from React state, so this can never mismatch between SSR and hydration. +export function ThemeToggle() { + const { toggleMode } = useThemeMode(); + + return ( + + ({ + bgcolor: brand[100], + color: brand[700], + transition: "transform 0.35s ease, background-color 0.2s ease", + "&:hover": { bgcolor: brand[200], transform: "rotate(-14deg)" }, + ...theme.applyStyles("dark", { + bgcolor: navy[800], + color: brand[300], + "&:hover": { bgcolor: navy[700] }, + }), + })} + > + + + + + ); +} diff --git a/app/components/WaveDivider.tsx b/app/components/WaveDivider.tsx new file mode 100644 index 0000000..06057c5 --- /dev/null +++ b/app/components/WaveDivider.tsx @@ -0,0 +1,43 @@ +// Two periods of the same wave back-to-back so a -50% translateX loops seamlessly. +const WAVE_PATH = + "M0,40 C150,90 350,0 600,40 C850,80 1050,0 1200,40 C1350,80 1550,0 1800,40 C1950,80 2150,0 2400,40 L2400,120 L0,120 Z"; + +type WaveDividerProps = { + color: string; + height?: number; + opacity?: number; + duration?: number; + reverse?: boolean; + className?: string; +}; + +export function WaveDivider({ + color, + height = 60, + opacity = 1, + duration = 18, + reverse = false, + className, +}: WaveDividerProps) { + return ( +
+ +
+ ); +} diff --git a/app/root.tsx b/app/root.tsx index 9fc6636..346688c 100644 --- a/app/root.tsx +++ b/app/root.tsx @@ -9,6 +9,20 @@ import { import type { Route } from "./+types/root"; import "./app.css"; +import { ThemeModeProvider } from "./theme/ThemeModeProvider"; + +// Runs before hydration to apply the persisted theme as a class on , +// so the very first paint is already in the right mode. Defaults to light +// unless the user has previously chosen dark mode (no OS-preference fallback). +const THEME_INIT_SCRIPT = ` +(function () { + try { + if (localStorage.getItem("phlask-theme-mode") === "dark") { + document.documentElement.classList.add("dark"); + } + } catch (e) {} +})(); +`; export const links: Route.LinksFunction = () => [ { rel: "preconnect", href: "https://fonts.googleapis.com" }, @@ -31,6 +45,8 @@ export function Layout({ children }: { children: React.ReactNode }) { + {/* biome-ignore lint/security/noDangerouslySetInnerHtml: static inline script, no user input */} +