diff --git a/apps/web/src/app.pcss b/apps/web/src/app.pcss index 0b46222f..ace6af4b 100644 --- a/apps/web/src/app.pcss +++ b/apps/web/src/app.pcss @@ -1,17 +1,91 @@ -@import url("https://fonts.googleapis.com/css2?family=Lato:ital,wght@0,100;0,300;0,400;0,700;0,900;1,100;1,300;1,400;1,700;1,900&display=swap"); +@import url("https://fonts.googleapis.com/css2?family=Lato:wght@300;400;700&family=Roboto:wght@300;400;700&family=Playfair+Display:wght@400;700&family=JetBrains+Mono:wght@400;700&display=swap"); @tailwind base; @tailwind components; @tailwind utilities; +:root { + --bg-light: hsl(0, 0%, 100%); + --bg-dark: hsl(240, 6%, 10%); + --noise-opacity: 0; + --noise-frequency: 1.5; + --gradient-background: none; + --app-font: "Lato", sans-serif; +} + +body { + font-family: var(--app-font); +} + .std-area { - @apply overflow-auto + @apply overflow-auto focus:outline-std-blue - bg-white dark:bg-zinc-900 text-zinc-900 dark:text-zinc-100 - border-zinc-200 dark:border-zinc-700; + border-zinc-200 dark:border-zinc-700; + background-color: var(--bg-light); border-width: 1px; } +.dark .std-area { + background-color: var(--bg-dark); +} + +@layer components { + /* Themed panel/control backgrounds - slightly offset from main bg */ + .themed-panel { + background-color: color-mix(in srgb, var(--bg-light) 90%, #000); + } + .dark .themed-panel { + background-color: color-mix(in srgb, var(--bg-dark) 85%, #fff); + } + + /* Lighter variant for nested panels */ + .themed-panel-light { + background-color: color-mix(in srgb, var(--bg-light) 95%, #000); + } + .dark .themed-panel-light { + background-color: color-mix(in srgb, var(--bg-dark) 90%, #fff); + } +} + +/* Background effects class for noise and glows */ +.bg-effects { + position: relative; + isolation: isolate; +} + +/* Gradient glows layer */ +.bg-effects::before { + content: ""; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: var(--gradient-background); + pointer-events: none; + z-index: -2; +} + +/* Noise texture layer */ +.bg-effects::after { + content: ""; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.8); + filter: url(#pageNoise); + opacity: var(--noise-opacity); + pointer-events: none; + z-index: -1; +} + +/* Subtler noise background for dark mode */ +.dark .bg-effects::after { + background: rgba(0, 0, 0, 0.1); +} + body.waiting { cursor: wait; } @@ -31,3 +105,13 @@ body.waiting { ::-webkit-scrollbar-thumb { @apply bg-zinc-400/40; } + +/* Playfair Display: lowercase course codes/titles */ +body.font-playfair .serif-lowercase { + text-transform: lowercase; +} + +/* JetBrains Mono: smaller section headers */ +body.font-jetbrains .section-header { + font-size: 0.8rem; +} diff --git a/apps/web/src/lib/components/general/style/GradientCanvas.svelte b/apps/web/src/lib/components/general/style/GradientCanvas.svelte new file mode 100644 index 00000000..03a1d85f --- /dev/null +++ b/apps/web/src/lib/components/general/style/GradientCanvas.svelte @@ -0,0 +1,161 @@ + + + + +
+ +
+ + + {#each gradients as gradient (gradient.id)} + + {/each} +
+ + diff --git a/apps/web/src/lib/components/general/style/GradientEditor.svelte b/apps/web/src/lib/components/general/style/GradientEditor.svelte new file mode 100644 index 00000000..c1d202ec --- /dev/null +++ b/apps/web/src/lib/components/general/style/GradientEditor.svelte @@ -0,0 +1,250 @@ + + +
+
+ Edit Gradient + +
+ + +
+ Color + +
+ + +
+ Position X + +
+ + +
+ Position Y + +
+ + +
+ Size + +
+ + +
+ Opacity + +
+ + +
+ Blur + +
+ + +
+ Shape +
+ + +
+
+
+ + diff --git a/apps/web/src/lib/components/general/style/GradientList.svelte b/apps/web/src/lib/components/general/style/GradientList.svelte new file mode 100644 index 00000000..97b5084f --- /dev/null +++ b/apps/web/src/lib/components/general/style/GradientList.svelte @@ -0,0 +1,119 @@ + + +
+
+ Gradients + {gradients.length}/{MAX_GRADIENTS} +
+ +
+ {#each gradients as gradient, index (gradient.id)} + + {/each} + + {#if canAdd} + + {/if} +
+
+ + diff --git a/apps/web/src/lib/components/general/style/ThemePanel.svelte b/apps/web/src/lib/components/general/style/ThemePanel.svelte index 92fec56a..30ff093b 100644 --- a/apps/web/src/lib/components/general/style/ThemePanel.svelte +++ b/apps/web/src/lib/components/general/style/ThemePanel.svelte @@ -5,10 +5,25 @@ darkTheme, calColors, DEFAULT_RCARD_COLORS, - type CalColors + bgColors, + DEFAULT_BG_COLORS, + bgEffects, + DEFAULT_BG_EFFECTS, + appFont, + FONT_OPTIONS, + DEFAULT_FONT, + createGradient, + MAX_GRADIENTS, + type CalColors, + type BgColors, + type BackgroundEffects, + type GradientConfig } from "$lib/stores/styles"; - import { colorPalettes } from "$lib/scripts/ReCal+/palettes"; + import { colorPalettes, type Palette } from "$lib/scripts/ReCal+/palettes"; import { rgbToHSL, hslToRGB } from "$lib/scripts/convert"; + import GradientCanvas from "./GradientCanvas.svelte"; + import GradientEditor from "./GradientEditor.svelte"; + import GradientList from "./GradientList.svelte"; $: open = $panelStore === "theme"; @@ -37,14 +52,38 @@ // Track the last selected theme for "Reset to Theme" functionality // Load from localStorage on init - let lastSelectedTheme: { name: string; colors: CalColors } | null = null; + let lastSelectedTheme: { + name: string; + colors: CalColors; + bgColors: BgColors; + } | null = null; // Load last selected theme from localStorage on mount if (typeof window !== "undefined") { const stored = localStorage.getItem(LAST_THEME_KEY); if (stored) { try { - lastSelectedTheme = JSON.parse(stored); + const parsed = JSON.parse(stored); + // Handle migration from old format without bgColors + if (parsed.bgColors) { + lastSelectedTheme = parsed; + } else if (parsed.name && colorPalettes[parsed.name]) { + // Reconstruct bgColors from palette + const palette = colorPalettes[parsed.name]; + lastSelectedTheme = { + ...parsed, + bgColors: { + light: rgbToHSL(palette.bgLight), + dark: rgbToHSL(palette.bgDark) + } + }; + localStorage.setItem( + LAST_THEME_KEY, + JSON.stringify(lastSelectedTheme) + ); + } else { + lastSelectedTheme = parsed; + } } catch { localStorage.removeItem(LAST_THEME_KEY); } @@ -54,8 +93,8 @@ /** * Apply a preset palette */ - const applyPalette = (name: string, colors: CalColors) => { - const hslColors: CalColors = Object.entries(colors) + const applyPalette = (name: string, palette: Palette) => { + const hslColors: CalColors = Object.entries(palette.colors) .map(([key, value]) => [key, rgbToHSL(value)]) .reduce( (acc, [key, value]) => ({ ...acc, [key]: value }), @@ -63,7 +102,20 @@ ) as CalColors; calColors.set(hslColors); - lastSelectedTheme = { name, colors: hslColors }; + + // Apply background colors from palette + const themeBgColors: BgColors = { + light: rgbToHSL(palette.bgLight), + dark: rgbToHSL(palette.bgDark) + }; + bgColors.set(themeBgColors); + + // Store theme with both colors and bgColors + lastSelectedTheme = { + name, + colors: hslColors, + bgColors: themeBgColors + }; // Persist to localStorage if (typeof window !== "undefined") { @@ -90,11 +142,23 @@ }; /** - * Reset to last selected theme + * Reset to last selected theme (both colors and background) */ const resetToTheme = () => { if (lastSelectedTheme) { calColors.set(lastSelectedTheme.colors); + if (lastSelectedTheme.bgColors) { + bgColors.set(lastSelectedTheme.bgColors); + } + } + }; + + /** + * Reset background colors to last selected theme + */ + const resetBgToTheme = () => { + if (lastSelectedTheme?.bgColors) { + bgColors.set(lastSelectedTheme.bgColors); } }; @@ -103,8 +167,12 @@ */ const resetToDefault = () => { calColors.set(DEFAULT_RCARD_COLORS); + bgColors.set(DEFAULT_BG_COLORS); + bgEffects.set(DEFAULT_BG_EFFECTS); + appFont.set(DEFAULT_FONT); darkTheme.set(false); lastSelectedTheme = null; + effectsExpanded = false; // Remove from localStorage if (typeof window !== "undefined") { @@ -112,6 +180,110 @@ } }; + /** + * Update background color + */ + const updateBgColor = (mode: "light" | "dark", rgbValue: string) => { + const hslValue = rgbToHSL(rgbValue); + bgColors.set({ ...$bgColors, [mode]: hslValue }); + }; + + /** + * Reset background colors to default + */ + const resetBgColors = () => { + bgColors.set(DEFAULT_BG_COLORS); + }; + + /** + * Reset effects to default + */ + const resetEffects = () => { + bgEffects.set(DEFAULT_BG_EFFECTS); + }; + + /** + * Update noise settings + */ + const updateNoise = (key: keyof BackgroundEffects["noise"], value: any) => { + bgEffects.set({ + ...$bgEffects, + noise: { ...$bgEffects.noise, [key]: value } + }); + }; + + /** + * Update glow settings + */ + const updateGlow = (key: keyof BackgroundEffects["glows"], value: any) => { + bgEffects.set({ + ...$bgEffects, + glows: { ...$bgEffects.glows, [key]: value } + }); + }; + + // Selected gradient for editing + let selectedGradientId: string | null = null; + + $: selectedGradient = $bgEffects.glows.gradients.find( + g => g.id === selectedGradientId + ); + + /** + * Handle gradient selection + */ + const handleGradientSelect = (e: CustomEvent<{ id: string }>) => { + selectedGradientId = e.detail.id || null; + }; + + /** + * Handle gradient position change (from canvas drag) + */ + const handleGradientMove = ( + e: CustomEvent<{ id: string; x: number; y: number }> + ) => { + const { id, x, y } = e.detail; + const updatedGradients = $bgEffects.glows.gradients.map(g => + g.id === id ? { ...g, x, y } : g + ); + updateGlow("gradients", updatedGradients); + }; + + /** + * Handle gradient update (from editor) + */ + const handleGradientUpdate = (e: CustomEvent) => { + const updated = e.detail; + const updatedGradients = $bgEffects.glows.gradients.map(g => + g.id === updated.id ? updated : g + ); + updateGlow("gradients", updatedGradients); + }; + + /** + * Handle gradient delete + */ + const handleGradientDelete = (e: CustomEvent<{ id: string }>) => { + const { id } = e.detail; + const updatedGradients = $bgEffects.glows.gradients.filter( + g => g.id !== id + ); + updateGlow("gradients", updatedGradients); + if (selectedGradientId === id) { + selectedGradientId = null; + } + }; + + /** + * Add a new gradient + */ + const handleAddGradient = () => { + if ($bgEffects.glows.gradients.length >= MAX_GRADIENTS) return; + const newGradient = createGradient(); + updateGlow("gradients", [...$bgEffects.glows.gradients, newGradient]); + selectedGradientId = newGradient.id; + }; + /** * Get the display label for a color key */ @@ -122,8 +294,8 @@ }; // Sort palette colors for display: E first, then 0-6, then -1 last - const sortPaletteColors = (colors: CalColors): string[] => { - return Object.entries(colors) + const sortPaletteColors = (palette: Palette): string[] => { + return Object.entries(palette.colors) .sort(([a], [b]) => { if (a === "E") return -1; if (b === "E") return 1; @@ -133,6 +305,9 @@ }) .map(([_, value]) => value); }; + + // State for collapsible sections - auto-expand if any effects are enabled + let effectsExpanded = $bgEffects.noise.enabled || $bgEffects.glows.enabled; + +
+

+ Typography +

+
+ {#each FONT_OPTIONS as font} + + {/each} +
+
+

- Preset Themes + Themes

- {#each Object.entries(colorPalettes) as [name, colors]} + {#each Object.entries(colorPalettes) as [name, palette]} + {/if} + +
+
+

+ +
+ + + {#if effectsExpanded} +
+ +
+
+ + Noise Texture + + +
+ + {#if $bgEffects.noise.enabled} +
+
+ + Opacity: {Math.round( + $bgEffects.noise.opacity * 100 + )}% + + + updateNoise( + "opacity", + parseFloat( + e.currentTarget.value + ) + )} + class="slider" /> +
+
+ + Grain Size: {$bgEffects.noise.baseFrequency.toFixed( + 1 + )} + + + updateNoise( + "baseFrequency", + parseFloat( + e.currentTarget.value + ) + )} + class="slider" /> +
+
+ {/if} +
+ + +
+
+ + Gradient Glows + + +
+ + {#if $bgEffects.glows.enabled} + + + + + + + + {#if selectedGradient} + + {/if} + + +
+ + Global Intensity: {Math.round( + $bgEffects.glows.globalOpacity * 100 + )}% + + + updateGlow( + "globalOpacity", + parseFloat(e.currentTarget.value) + )} + class="slider" /> +
+ {/if} +
+ + +
+ {/if} +
+
{#if lastSelectedTheme} @@ -289,6 +759,18 @@ transition-colors cursor-pointer; } + .font-button { + @apply flex flex-col items-center justify-center + p-2 rounded-lg border-2 + bg-zinc-50 dark:bg-zinc-800/50 + transition-all cursor-pointer; + aspect-ratio: 1; + } + + .selected-item { + --tw-ring-color: var(--ring-color); + } + .color-swatch { @apply flex flex-col items-center gap-0.5 cursor-pointer; } @@ -323,4 +805,19 @@ transform: rotate(360deg); } } + + .slider { + @apply w-full h-1.5 rounded-full appearance-none cursor-pointer + bg-zinc-300 dark:bg-zinc-600; + } + + .slider::-webkit-slider-thumb { + @apply appearance-none w-3.5 h-3.5 rounded-full + bg-blue-600 cursor-pointer; + } + + .slider::-moz-range-thumb { + @apply w-3.5 h-3.5 rounded-full border-0 + bg-blue-600 cursor-pointer; + } diff --git a/apps/web/src/lib/components/recal/Top.svelte b/apps/web/src/lib/components/recal/Top.svelte index d042db78..bfec7021 100644 --- a/apps/web/src/lib/components/recal/Top.svelte +++ b/apps/web/src/lib/components/recal/Top.svelte @@ -164,7 +164,7 @@ dark:text-zinc-100 text-sm">
{#each Object.keys(ACTIVE_TERMS).map( x => parseInt(x) ) as activeTerm} {:catch}
@@ -164,7 +164,7 @@ diff --git a/apps/web/src/lib/components/recal/left/SearchResults.svelte b/apps/web/src/lib/components/recal/left/SearchResults.svelte index 5e19a329..2fb4e58b 100644 --- a/apps/web/src/lib/components/recal/left/SearchResults.svelte +++ b/apps/web/src/lib/components/recal/left/SearchResults.svelte @@ -26,7 +26,7 @@ {#if $searchResults.length > 0}
+ class="text-base font-normal dark:text-zinc-100 ml-1 serif-lowercase"> {$searchResults.length} Search {$searchResults.length === 1 ? "Result" : "Results"}
diff --git a/apps/web/src/lib/components/recal/left/elements/CourseCard.svelte b/apps/web/src/lib/components/recal/left/elements/CourseCard.svelte index 25f0976b..35b82679 100644 --- a/apps/web/src/lib/components/recal/left/elements/CourseCard.svelte +++ b/apps/web/src/lib/components/recal/left/elements/CourseCard.svelte @@ -154,10 +154,10 @@