docs(ui): cookie-rendered sidebar state, data-collapsed CSS, working overview links + link-checker gate

Three problems, one root cause each:

1. Sidebar jump: the server rendered its own default (current section
   open, rest collapsed) and restoreDocsNav corrected it after paint —
   open-then-close. Fix: persistence moved server-side. toggleCollapsible
   now writes a docs_nav cookie (strictly-necessary UI state, no
   identifiers, SameSite=Lax); ApplyNavOverrides merges it into the nav
   before render, so first paint IS the visitor's state. The post-paint
   restore is deleted.

2. Arrows out of sync / double-click: two visibility mechanisms
   disagreed. There was NO data-collapsed CSS at all — collapsed sections
   were only ever hidden by JS after paint, and the arrow rotation came
   from a different (server) state. Now: style.css hides on
   [data-collapsed=true] (single source of truth, server-rendered),
   applyCollapsed sets only the attribute, arrows rotate from the same
   merged value. Live check: 37/37 collapsibles arrow-consistent.

3. api-reference.md links: all 20+ quick links were directory-style
   (authentication/ etc.) that 404 on the docs site, plus stale targets
   (books/, ../collections-api.md, websocket 'coming soon'). Rewritten to
   real per-file links — all 9 unique targets return 200 live. Same rot
   fixed in admin/hash-conflicts.md, development.md, android-app.md,
   create_media_item.md, koreader-protocol.md; docs/index.md redirected
   from the deleted collections-api monolith (stale 530-line duplicate of
   the gated api/collections docs — deleted, per the WO2 doctrine).

New gate: TestDocsRelativeLinks walks docs/developer/** and fails make
test on any relative link that is directory-style or unresolvable —
overview links can never silently rot again.
This commit is contained in:
John O'Keefe committed 2026-10-24 20:20:07 -04:00
1 parent f1a6de7cd7
commit a8f8c4def9
17 files changed
+241 -674

No files matched your search

+21 -39
View File
@@ -16,39 +16,38 @@ function toggleSidebar(): void {
}
// 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").
// renders what is expanded (navigation defaults + docs_nav cookie
// overrides merged in internal/docs), so first paint is already correct —
// no post-paint correction, no jumping. This module only records intent:
// every toggle rewrites the cookie with the full current state.
const DOCS_NAV_STORAGE_KEY = "docs-nav-state";
const DOCS_NAV_COOKIE = "docs_nav";
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 {};
// collectDocsNavState reads the current collapsed flag of every
// collapsible in the sidebar straight from the DOM.
function collectDocsNavState(): DocsNavState {
const state: DocsNavState = {};
document.querySelectorAll<HTMLElement>("[data-key][data-collapsed]").forEach((el) => {
const key = el.getAttribute("data-key");
if (key) state[key] = el.getAttribute("data-collapsed") === "true";
});
return state;
}
function writeDocsNavState(state: DocsNavState): void {
function persistDocsNavCookie(state: DocsNavState): void {
try {
localStorage.setItem(DOCS_NAV_STORAGE_KEY, JSON.stringify(state));
// Strictly-necessary UI state: first-party, no identifiers, one year.
document.cookie = `${DOCS_NAV_COOKIE}=${encodeURIComponent(JSON.stringify(state))}; path=/; max-age=31536000; SameSite=Lax`;
} catch {
// Storage full/blocked: persistence is best-effort.
// Storage blocked: the sidebar still works, state just doesn't persist.
}
}
function applyCollapsed(items: HTMLElement, collapsed: boolean): void {
items.classList.toggle("hidden", collapsed);
// The data-collapsed attribute is the single source of truth — the
// stylesheet hides on it, and the arrow is rotated relative to it.
items.setAttribute("data-collapsed", collapsed ? "true" : "false");
const arrow = items.previousElementSibling?.querySelector<HTMLElement>(".section-arrow");
if (arrow) {
@@ -56,16 +55,6 @@ function applyCollapsed(items: HTMLElement, collapsed: boolean): void {
}
}
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;
if (!items) return;
@@ -73,13 +62,7 @@ function toggleCollapsible(header: HTMLElement): void {
const isCollapsed = items.getAttribute("data-collapsed") === "true";
applyCollapsed(items, !isCollapsed);
const key = items.getAttribute("data-key");
if (key) {
const state = readDocsNavState();
state[key] = !isCollapsed;
writeDocsNavState(state);
}
persistDocsNavCookie(collectDocsNavState());
}
let docsSearchTimeout: ReturnType<typeof setTimeout> | null = null;
@@ -169,7 +152,6 @@ function initializeCodeCopyButtons(): void {
Alpine.data("docs", () => ({
toggleSidebar,
toggleCollapsible,
restoreDocsNav,
initializeSearch: initializeDocsSearch,
initializeCodeCopyButtons,
}));