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:
John O'Keefe committed 2026-10-24 19:44:34 -04:00
1 parent 983980d07a
commit f1a6de7cd7
50 files changed
+915 -803

No files matched your search

+59 -5
View File
@@ -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,
}));