fix(css): raw buttons honour their text and font classes

An unlayered `button { font: inherit }` outranked every Tailwind
utility (they live in @layer), so each raw <button> ignored its
text-/font- classes: sidebar headers, tabs, Refresh and the Install
Package header actions rendered at body size. Tailwind's own base layer
already sets it. Checked before/after on 16 screens at desktop and
phone widths; nothing overflows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
phantomptrandClaude Opus 5.5 committed 2026-09-28 11:32:56 -07:00
1 parent 05d6fa3db1
commit fedaba42e8
4 files changed
+37 -208

No files matched your search

-4
View File
@@ -425,10 +425,6 @@ body {
box-sizing: border-box;
}
button {
font: inherit;
}
::selection {
background: var(--color-accent);
color: var(--color-accent-contrast);
-122
View File
@@ -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<string, string> = {}) {
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([]);
});
});
-82
View File
@@ -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<NavFavoritesState>((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 };
}),
}));
+37
View File
@@ -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/);
});
});