ui: fix admin/docs theme flash + persist sidebar open/closed state
Theme flash (admin pages, docs, stats, progress — anywhere SSR'd):
the JWT middlewares built the context user WITHOUT the Theme column,
so every page rendering theme-{user.Theme} from c.Get("user") painted
an empty class (:root tokyo-night default) and only settled on the real
theme after initializeTheme -> loadUserTheme round-tripped the server.
Both middlewares now carry liveUser.Theme into the context user;
verified live: /admin /stats /progress /docs /docs-deep /dashboard
/collections all SSR theme-hackerman.
Defense in depth for the same bug: loadTheme() no longer re-applies
localStorage over a server-rendered body theme (applyTheme records even
the default, so any signed-out visit baked tokyo-night into localStorage
and every signed-in page flipped to it post-paint). Public auth/landing
bodies dropped their hardcoded theme classes so ThemeFlashGuard's
pre-paint html class actually governs them; docs theme <select> now
syncs from the body class and re-syncs after the profile fetch.
Sidebar state (the unpleasant part): the app sidebar's openPanel was
reinitialized from the pathname on every load; it now restores from
localStorage ('sidebar-open-panel') with the pathname default as
fallback and -persists every toggle. Docs sidebar sections/groups
carry data-key attributes and toggleCollapsible persists collapsed
state per key ('docs-nav-state'); restoreDocsNav() applies it at init —
server-rendered defaults still govern first visit.
make test green; rendering ownership OK (no new inline scripts); 146
vitests pass; live-verified SSR classes on 7 pages.
This commit is contained in:
1 parent
983980d07a
commit
f1a6de7cd7
50 files changed
+915
-803
No files matched your search
+59
-5
@@ -15,17 +15,70 @@ function toggleSidebar(): void {
|
||||
overlay.classList.toggle("hidden");
|
||||
}
|
||||
|
||||
// Docs sidebar collapse persistence. Rendering ownership: the server
|
||||
// renders what is expanded on FIRST paint (navigation.go derives it from
|
||||
// the current path); after that, open/closed is client-local state and
|
||||
// survives navigation via localStorage. Keys are the data-key attributes
|
||||
// rendered by DocsSidebar ("Section" / "Section / Group").
|
||||
|
||||
const DOCS_NAV_STORAGE_KEY = "docs-nav-state";
|
||||
|
||||
type DocsNavState = Record<string, boolean>;
|
||||
|
||||
function readDocsNavState(): DocsNavState {
|
||||
try {
|
||||
const raw = localStorage.getItem(DOCS_NAV_STORAGE_KEY);
|
||||
if (!raw) return {};
|
||||
const parsed = JSON.parse(raw);
|
||||
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
||||
return parsed as DocsNavState;
|
||||
}
|
||||
} catch {
|
||||
// Corrupt state falls back to the server-rendered defaults.
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
function writeDocsNavState(state: DocsNavState): void {
|
||||
try {
|
||||
localStorage.setItem(DOCS_NAV_STORAGE_KEY, JSON.stringify(state));
|
||||
} catch {
|
||||
// Storage full/blocked: persistence is best-effort.
|
||||
}
|
||||
}
|
||||
|
||||
function applyCollapsed(items: HTMLElement, collapsed: boolean): void {
|
||||
items.classList.toggle("hidden", collapsed);
|
||||
items.setAttribute("data-collapsed", collapsed ? "true" : "false");
|
||||
const arrow = items.previousElementSibling?.querySelector<HTMLElement>(".section-arrow");
|
||||
if (arrow) {
|
||||
arrow.style.transform = collapsed ? "rotate(-90deg)" : "rotate(0deg)";
|
||||
}
|
||||
}
|
||||
|
||||
function restoreDocsNav(): void {
|
||||
const state = readDocsNavState();
|
||||
document.querySelectorAll<HTMLElement>("[data-key][data-collapsed]").forEach((items) => {
|
||||
const key = items.getAttribute("data-key");
|
||||
if (key && key in state) {
|
||||
applyCollapsed(items, state[key]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function toggleCollapsible(header: HTMLElement): void {
|
||||
const items = header.nextElementSibling as HTMLElement | null;
|
||||
const arrow = header.querySelector<HTMLElement>(".section-arrow");
|
||||
if (!items) return;
|
||||
|
||||
const isCollapsed = items.getAttribute("data-collapsed") === "true";
|
||||
|
||||
items.classList.toggle("hidden", !isCollapsed);
|
||||
items.setAttribute("data-collapsed", isCollapsed ? "false" : "true");
|
||||
if (arrow) {
|
||||
arrow.style.transform = isCollapsed ? "rotate(0deg)" : "rotate(-90deg)";
|
||||
applyCollapsed(items, !isCollapsed);
|
||||
|
||||
const key = items.getAttribute("data-key");
|
||||
if (key) {
|
||||
const state = readDocsNavState();
|
||||
state[key] = !isCollapsed;
|
||||
writeDocsNavState(state);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,6 +169,7 @@ function initializeCodeCopyButtons(): void {
|
||||
Alpine.data("docs", () => ({
|
||||
toggleSidebar,
|
||||
toggleCollapsible,
|
||||
restoreDocsNav,
|
||||
initializeSearch: initializeDocsSearch,
|
||||
initializeCodeCopyButtons,
|
||||
}));
|
||||
+35
-10
@@ -66,11 +66,24 @@ const applyTheme = (theme: string): void => {
|
||||
localStorage.setItem(THEME_STORAGE_KEY, theme);
|
||||
};
|
||||
|
||||
// Load theme from localStorage or use default
|
||||
// Load theme from localStorage or use default.
|
||||
//
|
||||
// Do NOT blindly applyTheme() here: signed-in pages server-render the
|
||||
// user's theme into the body class, and re-applying the localStorage
|
||||
// value caused the admin/docs "flash to tokyo-night then settle" bug —
|
||||
// applyTheme records even the default, so any signed-out visit bakes
|
||||
// "tokyo-night" into localStorage and every signed-in page painted the
|
||||
// SSR theme, flipped to the stale stored one, then loadUserTheme()
|
||||
// flipped back. The server-rendered class is authoritative; the async
|
||||
// loadUserTheme() re-syncs localStorage to it. Signed-out pages carry
|
||||
// no meaningful body theme ("theme-" with an empty user) and fall back
|
||||
// to localStorage — which ThemeFlashGuard has already applied to
|
||||
// <html> pre-paint, so this is visually a no-op for them.
|
||||
const loadTheme = (): void => {
|
||||
const storedTheme = localStorage.getItem(THEME_STORAGE_KEY) as ThemeType | null;
|
||||
const theme = storedTheme || DEFAULT_THEME;
|
||||
applyTheme(theme);
|
||||
if (!/(?:^|\s)theme-[\w-]+/.test(document.body.className)) {
|
||||
const storedTheme = localStorage.getItem(THEME_STORAGE_KEY);
|
||||
applyTheme(storedTheme || DEFAULT_THEME);
|
||||
}
|
||||
};
|
||||
|
||||
// Change: Remove DOM element lookup, accept theme as parameter
|
||||
@@ -115,6 +128,7 @@ const loadUserTheme = async (): Promise<void> => {
|
||||
if (data.theme) {
|
||||
applyTheme(data.theme as string);
|
||||
updateThemeIndicators();
|
||||
syncThemeSelect(data.theme as string);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
@@ -122,6 +136,15 @@ const loadUserTheme = async (): Promise<void> => {
|
||||
}
|
||||
};
|
||||
|
||||
// Keep the docs-topbar theme <select> pointing at the given theme when it
|
||||
// exists on the page.
|
||||
const syncThemeSelect = (theme: string): void => {
|
||||
const themeSelect = document.getElementById("theme-select") as HTMLSelectElement | null;
|
||||
if (themeSelect) {
|
||||
themeSelect.value = theme;
|
||||
}
|
||||
};
|
||||
|
||||
// Update the active-theme checkmark in the Appearance menu to follow the
|
||||
// theme currently applied to <body>. Each theme button carries its theme name
|
||||
// in data-theme and a .theme-check element that is shown for the active theme
|
||||
@@ -155,12 +178,14 @@ const initializeTheme = (): void => {
|
||||
loadUserTheme();
|
||||
updateThemeIndicators();
|
||||
|
||||
// Set theme select value to current theme
|
||||
const themeSelect = document.getElementById("theme-select") as HTMLSelectElement;
|
||||
if (themeSelect) {
|
||||
const currentTheme = localStorage.getItem(THEME_STORAGE_KEY) || DEFAULT_THEME;
|
||||
themeSelect.value = currentTheme;
|
||||
}
|
||||
// Set theme select value from the BODY class — the authoritative painted
|
||||
// theme (server-rendered when signed in; localStorage is only a fallback
|
||||
// and may be stale until loadUserTheme() re-syncs it).
|
||||
syncThemeSelect(
|
||||
(document.body.className.match(/theme-([\w-]+)/) || [])[1] ||
|
||||
localStorage.getItem(THEME_STORAGE_KEY) ||
|
||||
DEFAULT_THEME,
|
||||
);
|
||||
|
||||
// Setup smooth scroll for anchor links
|
||||
setupSmoothScroll();
|
||||
|
||||
Reference in new issue
Block a user