feat: production-harden app and finalize merge-ready UX/theme baseline

- complete tokenized theme preset system and admin style guide visibility
- refine dashboard layout/scroll behavior and shared async/request UI states
- add user nav-visibility settings across API, migration, and frontend stores
- harden security headers/CSP handling and related test coverage
- enforce CI repository hygiene + frontend contract/theme/build quality gates
- update docs/env references and make migration smoke test head-aware
This commit is contained in:
Justin Visser 2026-02-19 15:43:20 +01:00
parent ab1d29b203
commit ae2ca8f149
66 changed files with 5655 additions and 853 deletions

View file

@ -4,6 +4,7 @@ import { ApiRequestError } from "@/lib/api/errors";
import { fetchCsrfBootstrap } from "@/lib/api/csrf";
import { fetchMe, loginSession, logoutSession, type SessionUser } from "@/lib/auth/session";
import { useUiStore } from "@/stores/ui";
import { useUserSettingsStore } from "@/stores/user_settings";
export type AuthState = "unknown" | "authenticated" | "anonymous";
@ -53,12 +54,16 @@ export const useAuthStore = defineStore("auth", {
this.state = "authenticated";
this.user = response.data.user;
this.csrfToken = response.data.csrf_token;
const userSettings = useUserSettingsStore();
await userSettings.bootstrap();
},
async logout(): Promise<void> {
await logoutSession();
this.state = "anonymous";
this.user = null;
this.csrfToken = null;
const userSettings = useUserSettingsStore();
userSettings.reset();
try {
const csrf = await fetchCsrfBootstrap();

View file

@ -1,45 +1,87 @@
import { defineStore } from "pinia";
import {
DEFAULT_THEME_PRESET,
THEME_PRESET_OPTIONS,
getThemePreset,
isThemePresetId,
themeCssVariables,
type ThemePresetId,
} from "@/theme/presets";
export type ThemePreference = "system" | "light" | "dark";
export type ThemeValue = "light" | "dark";
const STORAGE_KEY = "scholarr-theme-preference";
const PRESET_STORAGE_KEY = "scholarr-theme-preset";
function systemTheme(): ThemeValue {
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
}
function applyTheme(theme: ThemeValue, preference: ThemePreference): void {
function applyTheme(theme: ThemeValue, preference: ThemePreference, preset: ThemePresetId): void {
const root = document.documentElement;
root.classList.toggle("dark", theme === "dark");
root.setAttribute("data-theme-preference", preference);
root.setAttribute("data-theme-preset", preset);
const variables = themeCssVariables(preset, theme);
Object.entries(variables).forEach(([name, value]) => {
root.style.setProperty(name, value);
});
}
export const useThemeStore = defineStore("theme", {
state: () => ({
preference: "system" as ThemePreference,
active: "light" as ThemeValue,
preset: DEFAULT_THEME_PRESET as ThemePresetId,
}),
getters: {
availablePresets: () => THEME_PRESET_OPTIONS,
presetLabel: (state) => getThemePreset(state.preset).label,
},
actions: {
initialize(): void {
let stored: string | null = null;
let storedPreference: string | null = null;
let storedPreset: string | null = null;
try {
stored = localStorage.getItem(STORAGE_KEY);
storedPreference = localStorage.getItem(STORAGE_KEY);
storedPreset = localStorage.getItem(PRESET_STORAGE_KEY);
} catch (_err) {
stored = null;
storedPreference = null;
storedPreset = null;
}
if (stored === "light" || stored === "dark" || stored === "system") {
this.preference = stored;
if (storedPreference === "light" || storedPreference === "dark" || storedPreference === "system") {
this.preference = storedPreference;
}
if (storedPreset && isThemePresetId(storedPreset)) {
this.preset = storedPreset;
}
this.active = this.preference === "system" ? systemTheme() : this.preference;
applyTheme(this.active, this.preference);
applyTheme(this.active, this.preference, this.preset);
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
const handleSystemChange = (): void => {
if (this.preference !== "system") {
return;
}
this.active = systemTheme();
applyTheme(this.active, this.preference, this.preset);
};
if (typeof mediaQuery.addEventListener === "function") {
mediaQuery.addEventListener("change", handleSystemChange);
} else if (typeof mediaQuery.addListener === "function") {
mediaQuery.addListener(handleSystemChange);
}
},
setPreference(preference: ThemePreference): void {
this.preference = preference;
this.active = preference === "system" ? systemTheme() : preference;
applyTheme(this.active, this.preference);
applyTheme(this.active, this.preference, this.preset);
try {
localStorage.setItem(STORAGE_KEY, preference);
@ -47,5 +89,19 @@ export const useThemeStore = defineStore("theme", {
// Ignore storage failures in private contexts.
}
},
setPreset(preset: ThemePresetId): void {
if (!isThemePresetId(preset)) {
return;
}
this.preset = preset;
applyTheme(this.active, this.preference, this.preset);
try {
localStorage.setItem(PRESET_STORAGE_KEY, preset);
} catch (_err) {
// Ignore storage failures in private contexts.
}
},
},
});

View file

@ -0,0 +1,87 @@
import { defineStore } from "pinia";
import { fetchSettings, type UserSettings } from "@/features/settings";
export const REQUIRED_NAV_PAGES = ["dashboard", "scholars", "settings"] as const;
export const DEFAULT_NAV_VISIBLE_PAGES = [
"dashboard",
"scholars",
"publications",
"settings",
"style-guide",
"runs",
"users",
] as const;
const ALLOWED_NAV_PAGES = new Set<string>(DEFAULT_NAV_VISIBLE_PAGES);
const REQUIRED_NAV_PAGES_SET = new Set<string>(REQUIRED_NAV_PAGES);
function normalizeNavVisiblePages(value: unknown): string[] {
if (!Array.isArray(value)) {
return [...DEFAULT_NAV_VISIBLE_PAGES];
}
const deduped: string[] = [];
const seen = new Set<string>();
for (const candidate of value) {
if (typeof candidate !== "string") {
continue;
}
const pageId = candidate.trim();
if (!ALLOWED_NAV_PAGES.has(pageId) || seen.has(pageId)) {
continue;
}
seen.add(pageId);
deduped.push(pageId);
}
for (const requiredPage of REQUIRED_NAV_PAGES) {
if (!seen.has(requiredPage)) {
deduped.push(requiredPage);
seen.add(requiredPage);
}
}
return deduped;
}
export const useUserSettingsStore = defineStore("userSettings", {
state: () => ({
navVisiblePages: [...DEFAULT_NAV_VISIBLE_PAGES] as string[],
}),
getters: {
visiblePageSet: (state) => new Set(state.navVisiblePages),
},
actions: {
setNavVisiblePages(value: unknown): void {
this.navVisiblePages = normalizeNavVisiblePages(value);
},
applySettings(settings: UserSettings): void {
this.setNavVisiblePages(settings.nav_visible_pages);
},
reset(): void {
this.navVisiblePages = [...DEFAULT_NAV_VISIBLE_PAGES];
},
isPageVisible(pageId: string): boolean {
if (REQUIRED_NAV_PAGES_SET.has(pageId)) {
return true;
}
return this.visiblePageSet.has(pageId);
},
async bootstrap(): Promise<void> {
try {
const settings = await fetchSettings();
this.applySettings(settings);
} catch {
this.reset();
}
},
},
});
export function normalizeUserNavVisiblePages(value: unknown): string[] {
return normalizeNavVisiblePages(value);
}