diff --git a/README.md b/README.md index 641f1ec..bd969c5 100644 --- a/README.md +++ b/README.md @@ -1414,10 +1414,9 @@ Things I chose while building, with the reasoning: usual ceiling. 63. **Frutiger Aero is the third theme.** Settings → Theme offers Light, Dark and Frutiger Aero. - Aero is not a dark/light variant — it replaces the palette and the scene: the wallpaper is - *“The S Aesthetic That You Definitely Recognize Frutiger Aero”* via WallpaperSafari - (, re-encoded to a 237 kB WebP in `public/aero/` and - credited in Settings), with ten drawn bubbles rising over it (`.ice-bubble` — `display: none` + Aero is not a dark/light variant — it replaces the palette and the scene: the wallpaper is a + Frutiger Aero scene made with Gemini (re-encoded to WebP in `public/aero/`), with ten drawn + bubbles rising over it (`.ice-bubble` — `display: none` outside aero so they cost nothing elsewhere, still under reduced motion). Buttons and segments are **liquid glass** — transparent, glossed, with a bright rim — and every surface shows a **pre-blurred copy of the wallpaper** (`public/aero/background-blur.webp`, 3 kB, glued to the diff --git a/app/globals.css b/app/globals.css index f46650a..de6e249 100644 --- a/app/globals.css +++ b/app/globals.css @@ -257,10 +257,9 @@ --ice-grain-opacity: 0.02; } -/* The scene is the wallpaper itself: Frutiger Aero 4k Wallpaper (1920x1200) - via WallpaperSafari — https://wallpapersafari.com/w/sgI2Dq — credited in - Settings and the README. Only the moving bubbles are drawn on top; the - photo already has grass, sun, water and clouds. */ +/* The scene is the wallpaper itself (a Frutiger Aero scene made with Gemini, + in public/aero/). Only the moving bubbles are drawn on top; the image + already has grass, sun, water and clouds. */ .aero .ice-scene { background-image: url("/aero/background.webp"); background-size: cover; @@ -361,6 +360,24 @@ @apply backdrop-blur-md backdrop-saturate-150; } +/* The thin glass the Ask / Import JSON buttons use: a 34% white tint over the + sharp wallpaper with a bright rim — no pre-blurred layer, so it reads as + glass rather than as a white chip. Rail rows, the active nav item and the + active segment all match it. */ +.aero .nav-action, +.aero .nav-row[aria-current="page"], +.aero .nav-row[data-active="true"], +.aero .segmented > button[aria-pressed="true"], +.aero .segmented > a[aria-current="page"] { + background-image: none; + background-color: rgb(255 255 255 / 0.34); + border: 1px solid rgb(255 255 255 / 0.9); +} +.aero .segmented > button, +.aero .segmented > a { + border-color: rgb(255 255 255 / 0.5); +} + /* Liquid glass buttons: translucent, glossed top half, bright rim — the Vista/Aero button, not a solid fill. */ .aero .grad-ice, @@ -396,11 +413,8 @@ .aero .glass-solid, .aero .sidebar-surface, .aero .gate-card, -.aero .nav-action, .aero .btn-primary, .aero .grad-ice, -.aero .segmented > button[aria-pressed="true"], -.aero .segmented > a[aria-current="page"], .aero .hint-line { background-image: linear-gradient( diff --git a/components/ApiKeyGate.tsx b/components/ApiKeyGate.tsx index 2bd9e4d..fe2f1a7 100644 --- a/components/ApiKeyGate.tsx +++ b/components/ApiKeyGate.tsx @@ -3,7 +3,7 @@ import { AnimatePresence, motion, useReducedMotion } from "framer-motion"; import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react"; -import { useTheme } from "@/components/ThemeToggle"; +import { useTheme } from "@/lib/theme"; import { CheckCircleIcon, ExternalLinkIcon, diff --git a/components/Sidebar.tsx b/components/Sidebar.tsx index 8ea79ae..9589e52 100644 --- a/components/Sidebar.tsx +++ b/components/Sidebar.tsx @@ -20,7 +20,7 @@ import { SparklesIcon, SunIcon, } from "@/components/icons"; -import { useTheme } from "@/components/ThemeToggle"; +import { useTheme } from "@/lib/theme"; import { createFolder, folderStore } from "@/lib/folders"; import { haptic } from "@/lib/haptics"; import { IS_DEV_INSTANCE } from "@/lib/dev-mode"; diff --git a/components/ThemeSection.tsx b/components/ThemeSection.tsx index 6cfb9fa..aa7ee9f 100644 --- a/components/ThemeSection.tsx +++ b/components/ThemeSection.tsx @@ -1,6 +1,6 @@ "use client"; -import { useTheme, type Theme } from "@/components/ThemeToggle"; +import { useTheme, type Theme } from "@/lib/theme"; import { haptic } from "@/lib/haptics"; import { useI18n } from "@/lib/i18n"; @@ -40,17 +40,7 @@ export function ThemeSection() { {theme === "aero" ? ( <>

{t("theme.aeroNote")}

-

- {t("theme.aeroCredit")}{" "} - - wallpapersafari.com/w/IlWuz7 - -

+ ) : null} diff --git a/components/ThemeToggle.tsx b/components/ThemeToggle.tsx index 6a0fbac..ff800d1 100644 --- a/components/ThemeToggle.tsx +++ b/components/ThemeToggle.tsx @@ -1,87 +1,22 @@ "use client"; -import { useEffect, useState } from "react"; - -import { useI18n } from "@/lib/i18n"; -import { haptic } from "@/lib/haptics"; -import { cn } from "@/lib/utils"; import { MoonIcon, SunIcon } from "@/components/icons"; - -const STORAGE_KEY = "freezmo.theme"; - -const THEME_COLORS = { light: "#f4f7fd", dark: "#0e1424", aero: "#2fa7e6" } as const; - -/** The three themes. Aero is not a dark/light variant: it replaces the palette - * and the scene, and the light/dark toggles hide while it is on. */ -export type Theme = "light" | "dark" | "aero"; +import { haptic } from "@/lib/haptics"; +import { useI18n } from "@/lib/i18n"; +import { useTheme } from "@/lib/theme"; +import { cn } from "@/lib/utils"; /** - * Keeps the first `theme-color` meta (the one the app owns) in step with the - * in-app theme. The SSR metas follow the OS preference; this one always wins - * because it is media-less and first in the head. + * The light/dark flip. Frutiger Aero has no such switch (the theme picker in + * Settings owns it), so this renders nothing while aero is on — the state + * comes from the shared store in lib/theme, so a change in Settings hides + * this immediately. */ -export function syncThemeColor(theme: Theme) { - try { - let meta = document.querySelector('meta[name="theme-color"][data-app]'); - if (!meta) { - meta = document.createElement("meta"); - meta.setAttribute("name", "theme-color"); - meta.setAttribute("data-app", "1"); - document.head.insertBefore(meta, document.head.firstChild); - } - meta.setAttribute("content", THEME_COLORS[theme]); - } catch { - /* private mode / odd embedded browsers */ - } -} - -export function applyTheme(next: Theme) { - document.documentElement.classList.toggle("dark", next === "dark"); - document.documentElement.classList.toggle("aero", next === "aero"); - syncThemeColor(next); - try { - window.localStorage.setItem(STORAGE_KEY, next); - } catch { - /* private mode */ - } -} - -export function useTheme() { - const [theme, setTheme] = useState("light"); - - useEffect(() => { - const stored = window.localStorage.getItem(STORAGE_KEY); - const next: Theme = - stored === "dark" || stored === "aero" - ? stored - : stored === "light" - ? "light" - : window.matchMedia("(prefers-color-scheme: dark)").matches - ? "dark" - : "light"; - setTheme(next); - syncThemeColor(next); - }, []); - - const apply = (next: Theme) => { - setTheme(next); - applyTheme(next); - }; - - return { - theme, - setTheme: apply, - /** The light/dark flip; aero is chosen in Settings. */ - toggle: () => apply(theme === "dark" ? "light" : "dark"), - }; -} - export function ThemeToggle({ className, plain = false }: { className?: string; plain?: boolean }) { const { theme, toggle } = useTheme(); const { t } = useI18n(); const dark = theme === "dark"; - // Frutiger Aero has no light/dark switch (see the theme picker in Settings). if (theme === "aero") return null; return ( diff --git a/lib/backup.ts b/lib/backup.ts index e27cdfc..e2493ce 100644 --- a/lib/backup.ts +++ b/lib/backup.ts @@ -11,7 +11,7 @@ import { z } from "zod"; -import { applyTheme } from "@/components/ThemeToggle"; +import { applyTheme } from "./theme"; import { chatHistoryStore, ensureChatsHydrated, replaceChats } from "./chat-store"; import { replaceFolders, folderStore } from "./folders"; import { homeworkStore, replaceHomework } from "./homework"; diff --git a/lib/i18n-dict.ts b/lib/i18n-dict.ts index 8a472e7..a7d2291 100644 --- a/lib/i18n-dict.ts +++ b/lib/i18n-dict.ts @@ -1004,7 +1004,6 @@ const en: Record = { "settings.theme": "Theme", "theme.aero": "Frutiger Aero", "theme.aeroNote": "Aero has no light/dark switch — pick Light or Dark to get it back.", - "theme.aeroCredit": "Background: “The S Aesthetic That You Definitely Recognize Frutiger Aero” via WallpaperSafari.", "settings.legal": "Legal", "consent.title": "About cookies", "consent.body": "Freezmo uses no cookies and no trackers — your decks, homework and settings stay in this browser.", @@ -1960,7 +1959,6 @@ const cs: Record = { "settings.theme": "Téma", "theme.aero": "Frutiger Aero", "theme.aeroNote": "Aero nemá přepínač světlý/tmavý — vyber Světlý nebo Tmavý, pokud ho chceš zpět.", - "theme.aeroCredit": "Pozadí: „The S Aesthetic That You Definitely Recognize Frutiger Aero“ přes WallpaperSafari.", "settings.legal": "Právní informace", "consent.title": "O cookies", "consent.body": "Freezmo nepoužívá cookies ani sledování — sady, úkoly a nastavení zůstávají v tomto prohlížeči.", diff --git a/lib/theme.ts b/lib/theme.ts new file mode 100644 index 0000000..cd6ad13 --- /dev/null +++ b/lib/theme.ts @@ -0,0 +1,92 @@ +"use client"; + +import { useSyncExternalStore } from "react"; + +/** + * The theme, as a shared store. It used to be component state inside + * `useTheme()`, which meant every caller (header, rail, chat, Settings) had + * its own copy: switching to Aero in Settings left the rail's light/dark row + * on screen until a reload. One store, one snapshot, everyone re-renders. + */ + +const STORAGE_KEY = "freezmo.theme"; + +const THEME_COLORS = { light: "#f4f7fd", dark: "#0e1424", aero: "#2fa7e6" } as const; + +/** The three themes. Aero is not a dark/light variant: it replaces the palette + * and the scene, and the light/dark toggles hide while it is on. */ +export type Theme = "light" | "dark" | "aero"; + +let cached: Theme | null = null; +const listeners = new Set<() => void>(); + +function read(): Theme { + try { + const stored = window.localStorage.getItem(STORAGE_KEY); + if (stored === "light" || stored === "dark" || stored === "aero") return stored; + return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"; + } catch { + /* private mode */ + return "light"; + } +} + +function snapshot(): Theme { + cached ??= read(); + return cached; +} + +function serverSnapshot(): Theme { + return "light"; +} + +/** + * Keeps the first `theme-color` meta (the one the app owns) in step with the + * in-app theme. The SSR metas follow the OS preference; this one always wins + * because it is media-less and first in the head. + */ +export function syncThemeColor(theme: Theme) { + try { + let meta = document.querySelector('meta[name="theme-color"][data-app]'); + if (!meta) { + meta = document.createElement("meta"); + meta.setAttribute("name", "theme-color"); + meta.setAttribute("data-app", "1"); + document.head.insertBefore(meta, document.head.firstChild); + } + meta.setAttribute("content", THEME_COLORS[theme]); + } catch { + /* private mode / odd embedded browsers */ + } +} + +export function applyTheme(next: Theme) { + document.documentElement.classList.toggle("dark", next === "dark"); + document.documentElement.classList.toggle("aero", next === "aero"); + syncThemeColor(next); + cached = next; + try { + window.localStorage.setItem(STORAGE_KEY, next); + } catch { + /* private mode */ + } + listeners.forEach((listener) => listener()); +} + +export function useTheme() { + const theme = useSyncExternalStore( + (listener) => { + listeners.add(listener); + return () => listeners.delete(listener); + }, + snapshot, + serverSnapshot, + ); + + return { + theme, + setTheme: applyTheme, + /** The light/dark flip; aero is chosen in Settings. */ + toggle: () => applyTheme(theme === "dark" ? "light" : "dark"), + }; +} diff --git a/package.json b/package.json index 4e17e9f..49f7f9c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "freezmo", - "version": "0.2.5", + "version": "0.2.8", "private": true, "license": "GPL-3.0-only", "scripts": { diff --git a/public/aero/background-blur.webp b/public/aero/background-blur.webp index 7ccbe04..c96340e 100644 Binary files a/public/aero/background-blur.webp and b/public/aero/background-blur.webp differ diff --git a/public/aero/background.webp b/public/aero/background.webp index eddfa81..629e17b 100644 Binary files a/public/aero/background.webp and b/public/aero/background.webp differ diff --git a/public/sw.js b/public/sw.js index 48753e6..c8bfadb 100644 --- a/public/sw.js +++ b/public/sw.js @@ -9,7 +9,7 @@ * * Bumping CACHE_VERSION is how a new deploy invalidates old assets. */ -const CACHE_VERSION = "freezmo-v58"; +const CACHE_VERSION = "freezmo-v60"; const SHELL_CACHE = `${CACHE_VERSION}-shell`; const ASSET_CACHE = `${CACHE_VERSION}-assets`; diff --git a/tests/mobile-test.mjs b/tests/mobile-test.mjs index 5c9e287..8e2d45b 100644 --- a/tests/mobile-test.mjs +++ b/tests/mobile-test.mjs @@ -266,6 +266,27 @@ const AXE = axeSource(); await context.close(); } +/* 6b. picking Aero hides the light/dark switches immediately ---------- */ +{ + const { context, page } = await makePage(browser, { width: 1280, height: 900 }); + await seed(page, { decks: [DECK], theme: "dark" }); + await page.goto(BASE, { waitUntil: "networkidle" }); + await page.waitForTimeout(1000); + const toggleInRail = () => + page.evaluate(() => [...document.querySelectorAll("aside button")].some((b) => /Light mode|Dark mode/.test(b.textContent ?? ""))); + check("theme: the rail has a light/dark row while dark", await toggleInRail()); + await page.locator("aside button").filter({ hasText: /Settings|Nastavení/ }).first().click(); + await page.getByRole("dialog").waitFor({ timeout: 6000 }); + await page.waitForTimeout(500); + await page.getByRole("group", { name: "Theme" }).getByRole("button", { name: "Frutiger Aero" }).click(); + await page.waitForTimeout(600); + check( + "theme: picking Aero hides it without a reload", + (await page.evaluate(() => document.documentElement.classList.contains("aero"))) && !(await toggleInRail()), + ); + await context.close(); +} + async function audit(label, { width = 390, locale, theme, openSettingsNow }) { const { context, page } = await makePage(browser, { width, height: width >= 1280 ? 900 : 800, deviceScaleFactor: width >= 1280 ? 1 : 2 }); await seed(page, { locale, theme, decks: [DECK] });