mirror of
https://github.com/phantomptr/ps5upload.git
synced 2026-10-21 19:33:08 -05:00
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:
1 parent
05d6fa3db1
commit
fedaba42e8
4 files changed
+37
-208
No files matched your search
@@ -425,10 +425,6 @@ body {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
button {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: var(--color-accent);
|
||||
color: var(--color-accent-contrast);
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}),
|
||||
}));
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user