diff --git a/client/src/index.css b/client/src/index.css index a056e4ea..2a3a6315 100644 --- a/client/src/index.css +++ b/client/src/index.css @@ -425,10 +425,6 @@ body { box-sizing: border-box; } -button { - font: inherit; -} - ::selection { background: var(--color-accent); color: var(--color-accent-contrast); diff --git a/client/src/state/navFavorites.test.ts b/client/src/state/navFavorites.test.ts deleted file mode 100644 index 793f03fc..00000000 --- a/client/src/state/navFavorites.test.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; - -const FAVORITES_KEY = "ps5upload.desktop-sidebar.favorites.v1"; -const HINT_KEY = "ps5upload.desktop-sidebar.favorites-hint.v1"; - -// These tests run in the repo's plain-node environment (there is no jsdom), -// so `window.localStorage` has to be stood up by hand. A tiny in-memory Map -// is enough: `safeStorage` only ever calls get/set/remove, and using the real -// wrappers keeps the persistence path under test rather than mocked out. -function installStorage(seed: Record = {}) { - const map = new Map(Object.entries(seed)); - (globalThis as { window?: unknown }).window = { - localStorage: { - getItem: (k: string) => map.get(k) ?? null, - setItem: (k: string, v: string) => void map.set(k, v), - removeItem: (k: string) => void map.delete(k), - clear: () => map.clear(), - }, - }; - return map; -} - -/** The store snapshots localStorage at module load, so a test that needs a - * specific starting state must seed storage and then re-import the module. */ -async function freshStore() { - vi.resetModules(); - const mod = await import("./navFavorites"); - return mod.useNavFavoritesStore; -} - -beforeEach(() => { - installStorage(); - vi.resetModules(); -}); - -describe("navFavorites persistence", () => { - it("writes the pinned list to storage so it survives a relaunch", async () => { - const storage = installStorage(); - const store = await freshStore(); - - store.getState().toggle("/games"); - store.getState().toggle("/files"); - - expect(JSON.parse(storage.get(FAVORITES_KEY) ?? "null")).toEqual([ - "/games", - "/files", - ]); - // Pinning a first screen answers the hint's question, so it retires - // itself rather than making the user dismiss it as well. - expect(storage.get(HINT_KEY)).toBe("1"); - }); - - it("un-pins on a second toggle and persists the removal", async () => { - const storage = installStorage({ - [FAVORITES_KEY]: JSON.stringify(["/games", "/files"]), - }); - const store = await freshStore(); - - store.getState().toggle("/games"); - - expect(store.getState().favorites).toEqual(["/files"]); - expect(JSON.parse(storage.get(FAVORITES_KEY) ?? "null")).toEqual([ - "/files", - ]); - }); - - it("reads a previously saved list back at startup", async () => { - // The regression this guards: Favorites was the one preference that was - // never mirrored to settings.json, so it was the one users lost. - installStorage({ - [FAVORITES_KEY]: JSON.stringify(["/games", "/files"]), - [HINT_KEY]: "1", - }); - const store = await freshStore(); - - expect(store.getState().favorites).toEqual(["/games", "/files"]); - expect(store.getState().hintDismissed).toBe(true); - }); - - it("setFavorites replaces the list and persists it", async () => { - // This is the path the settings.json mirror hydrates through. - const storage = installStorage({ - [FAVORITES_KEY]: JSON.stringify(["/old"]), - }); - const store = await freshStore(); - - store.getState().setFavorites(["/games", "/console"], true); - - expect(store.getState().favorites).toEqual(["/games", "/console"]); - expect(store.getState().hintDismissed).toBe(true); - expect(JSON.parse(storage.get(FAVORITES_KEY) ?? "null")).toEqual([ - "/games", - "/console", - ]); - }); - - it("setFavorites drops non-string entries from a hand-edited file", async () => { - // settings.json is documented as hand-editable, so its array can hold - // anything. A number in the list must not become a nav item. - const store = await freshStore(); - - store - .getState() - .setFavorites(["/games", 42, null, "/files"] as unknown as string[]); - - expect(store.getState().favorites).toEqual(["/games", "/files"]); - }); - - it("treats a corrupt stored value as no favorites rather than crashing", async () => { - installStorage({ [FAVORITES_KEY]: "{not json" }); - const store = await freshStore(); - - expect(store.getState().favorites).toEqual([]); - }); - - it("ignores a stored value that is valid JSON but not an array", async () => { - installStorage({ [FAVORITES_KEY]: '"/games"' }); - const store = await freshStore(); - - expect(store.getState().favorites).toEqual([]); - }); -}); diff --git a/client/src/state/navFavorites.ts b/client/src/state/navFavorites.ts deleted file mode 100644 index ae8a8a61..00000000 --- a/client/src/state/navFavorites.ts +++ /dev/null @@ -1,82 +0,0 @@ -// Which screens the user has pinned to the desktop sidebar. -// -// The sidebar used to be a hardcoded five-item list. It now holds Home -// (permanent) plus whatever the user has starred in More, so nobody is -// handed an assumption about which four screens matter to them. -// -// Persisted per-machine in localStorage via safeStorage — the same -// storage the sidebar's collapsed state uses, so a browser that blocks -// site data degrades to "no favorites" instead of throwing. - -import { create } from "zustand"; - -import { safeGetItem, safeSetItem } from "../lib/safeStorage"; - -const FAVORITES_KEY = "ps5upload.desktop-sidebar.favorites.v1"; -const HINT_DISMISSED_KEY = "ps5upload.desktop-sidebar.favorites-hint.v1"; - -/** Parse the stored list defensively: anything that is not an array of - * strings is treated as absent rather than crashing the sidebar. The - * value is user-editable on disk and survives downgrades. */ -function loadFavorites(): string[] { - const raw = safeGetItem(FAVORITES_KEY); - if (!raw) return []; - try { - const parsed: unknown = JSON.parse(raw); - if (!Array.isArray(parsed)) return []; - return parsed.filter((v): v is string => typeof v === "string"); - } catch { - return []; - } -} - -interface NavFavoritesState { - /** Route paths, in the order the user starred them. Never contains Home. */ - favorites: string[]; - /** True once the user dismisses the "star screens in More" hint. */ - hintDismissed: boolean; - isFavorite: (to: string) => boolean; - toggle: (to: string) => void; - dismissHint: () => void; - /** Replace the whole list — used when hydrating from the - * `~/.ps5upload/settings.json` mirror at startup. Non-string entries are - * dropped for the same reason `loadFavorites` filters them: the value is - * hand-editable on disk. */ - setFavorites: (paths: string[], hintDismissed?: boolean) => void; -} - -export const useNavFavoritesStore = create((set, get) => ({ - favorites: loadFavorites(), - hintDismissed: safeGetItem(HINT_DISMISSED_KEY) === "1", - - isFavorite: (to) => get().favorites.includes(to), - - toggle: (to) => - set((s) => { - const next = s.favorites.includes(to) - ? s.favorites.filter((p) => p !== to) - : [...s.favorites, to]; - safeSetItem(FAVORITES_KEY, JSON.stringify(next)); - // Adding a first favorite answers the hint's question, so retire it - // without making the user also dismiss it by hand. - const hintDismissed = s.hintDismissed || next.length > 0; - if (hintDismissed && !s.hintDismissed) { - safeSetItem(HINT_DISMISSED_KEY, "1"); - } - return { favorites: next, hintDismissed }; - }), - - dismissHint: () => { - safeSetItem(HINT_DISMISSED_KEY, "1"); - set({ hintDismissed: true }); - }, - - setFavorites: (paths, hintDismissed) => - set((s) => { - const next = paths.filter((p): p is string => typeof p === "string"); - safeSetItem(FAVORITES_KEY, JSON.stringify(next)); - const nextHint = hintDismissed ?? (s.hintDismissed || next.length > 0); - safeSetItem(HINT_DISMISSED_KEY, nextHint ? "1" : "0"); - return { favorites: next, hintDismissed: nextHint }; - }), -})); diff --git a/client/src/styles.test.ts b/client/src/styles.test.ts new file mode 100644 index 00000000..65b777c5 --- /dev/null +++ b/client/src/styles.test.ts @@ -0,0 +1,37 @@ +// @ts-expect-error -- the app tsconfig has no node types; Vitest runs this under Node. +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +// From disk: Vitest turns a CSS import, even `?raw`, into an empty string. +const CSS: string = readFileSync(new URL("./index.css", import.meta.url), "utf8"); + +/** The CSS outside every @layer block: unlayered rules outrank all of Tailwind's layers. */ +function unlayered(css: string): string { + let out = ""; + let depth = 0; + let layerDepth = -1; + for (let i = 0; i < css.length; i++) { + if (layerDepth < 0 && css.startsWith("@layer", i) && /^@layer[^;{]*\{/.test(css.slice(i, i + 80))) { + layerDepth = depth; + } + const c = css[i]; + if (c === "{") depth++; + if (c === "}") { + depth--; + if (depth === layerDepth) { + layerDepth = -1; + continue; + } + } + if (layerDepth < 0) out += c; + } + return out; +} + +describe("global CSS", () => { + it("never sets a button's font outside a layer, which would beat every text-/font- class", () => { + expect(CSS.length).toBeGreaterThan(1000); + const top = unlayered(CSS.replace(/\/\*[\s\S]*?\*\//g, "")); + expect(top).not.toMatch(/(^|[\s,}])button\s*\{[^}]*font/); + }); +});