docs(ui): drop sidebar saved-state — server defaults already ARE the standard docs behavior

Simplification per spec: the menu tracks state through the URL alone.
Land on /docs: fully closed, headers only (an explicit home rule now
overrides the contains-URL rule, since the root overview doc lives in
Getting Started and would otherwise auto-open it). Land on any doc page:
its containing section + group render open at first paint, current link
bold — no JS, no jump, arrows consistent. Navigate elsewhere: old
closes, new opens.

Deleted: docs_nav cookie const/parse, ApplyNavOverrides, cookie write +
collect/persist helpers in web/src/docs.ts, data-key attributes in the
sidebar templ, and the first-section-open fallback (docs home used to
force-open the first section — spec'd as closed).

toggleCollapsible remains for in-page peeking; purely ephemeral.
The [data-collapsed=true] CSS from the previous round stays — it is what
makes the server-rendered state actually hide sections at paint (the
original bug was that this CSS never existed).

Tests: TestBuildNavigationNothingCurrent now asserts zero sections open
on unmatched paths; TestBuildNavigation asserts Getting Started closed
on a device page (no site-wide pin). make test green; 146 vitests; live
verified: home 0 expanded, doc pages exactly 2 (section+group).
This commit is contained in:
John O'Keefe committed 2026-10-24 21:17:55 -04:00
1 parent a8f8c4def9
commit 73e8cdef4b
6 files changed
+155 -272

No files matched your search

-29
View File
@@ -2,10 +2,8 @@ package docs
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"net/url"
"strings"
"bookhoard/internal/database"
@@ -48,37 +46,10 @@ func (h *HTTPHandler) currentUser(c *echo.Context) templates.User {
return templates.User{Theme: "tokyo-night"}
}
// docsNavCookie is the browser-saved collapse state of the docs sidebar
// (set by web/src/docs.ts on every toggle). Strictly-necessary UI state:
// first-party, no identifiers, lets the server render the sidebar in the
// visitor's chosen state so there is no post-paint jump.
const docsNavCookie = "docs_nav"
// docsNavOverrides reads the saved collapse state from the cookie.
// Keys are the sidebar's data-key values ("Section", "Section / Group");
// values are the collapsed flag. Malformed cookies are ignored — the
// server-rendered defaults win.
func docsNavOverrides(c *echo.Context) map[string]bool {
cookie, err := c.Cookie(docsNavCookie)
if err != nil || cookie.Value == "" {
return nil
}
raw, err := url.QueryUnescape(cookie.Value)
if err != nil {
return nil
}
var overrides map[string]bool
if err := json.Unmarshal([]byte(raw), &overrides); err != nil {
return nil
}
return overrides
}
// renderDocPage renders a document inside the docs layout.
func (h *HTTPHandler) renderDocPage(c *echo.Context, doc *templates.Document) error {
currentPath := c.Request().URL.Path
nav := h.docs.BuildNavigation(currentPath)
ApplyNavOverrides(nav, docsNavOverrides(c))
var buf bytes.Buffer
if err := templates.DocsLayout(*nav, *doc, h.currentUser(c), currentPath).Render(c.Request().Context(), &buf); err != nil {
+20 -51
View File
@@ -178,8 +178,8 @@ func docURL(docPath string) string {
// BuildNavigation creates the category → subcategory navigation from the
// docs filesystem. The section and group containing currentPath render
// expanded; everything else starts collapsed (with Getting Started as the
// fallback when nothing matches).
// expanded; everything else starts collapsed. When nothing matches (docs
// home, 404), the sidebar renders fully closed — headers only.
func (h *DocsHandler) BuildNavigation(currentPath string) *templates.Navigation {
docs, err := h.ListDocuments()
if err != nil {
@@ -198,16 +198,17 @@ func (h *DocsHandler) BuildNavigation(currentPath string) *templates.Navigation
}
type groupBuild struct {
items []templates.NavItem
urls []string
overview bool // has a pinned index page
items []templates.NavItem
urls []string
// overview: the group leads with its "#"Overview landing doc (pinned
// subdirectory index), so it sorts first.
overview bool
}
type sectionBuild struct {
items []templates.NavItem
urls []string
pinned map[string]bool // landing pages lead their section
groups map[string]*groupBuild
overview bool
items []templates.NavItem
urls []string
pinned map[string]bool // landing pages lead their section
groups map[string]*groupBuild
}
builds := make(map[string]*sectionBuild, len(sectionOrder))
@@ -231,10 +232,6 @@ func (h *DocsHandler) BuildNavigation(currentPath string) *templates.Navigation
if place.pin < 0 {
build.pinned[item.URL] = true
}
if place.pin == -100 {
// Only the root Overview forces its section open.
build.overview = true
}
} else {
group, ok := build.groups[place.group]
if !ok {
@@ -301,52 +298,24 @@ func (h *DocsHandler) BuildNavigation(currentPath string) *templates.Navigation
}
}
}
if build.overview {
section.Collapsed = false
}
nav.Sections = append(nav.Sections, section)
}
// Nothing matches the current page (404, search landing): open the
// first section so the sidebar doesn't render fully closed.
anyOpen := false
for _, s := range nav.Sections {
if !s.Collapsed {
anyOpen = true
break
// No default expansion when nothing matches the current page (404,
// search landing): the sidebar renders fully closed. Same for the docs
// HOME: although the root overview doc lives there (its contains-URL
// rule would open "Getting Started"), the spec is "home = all closed,
// headers only" — the standard docs-site behavior.
if strings.TrimSuffix(currentPath, "/") == "/docs" {
for si := range nav.Sections {
nav.Sections[si].Collapsed = true
}
}
if !anyOpen && len(nav.Sections) > 0 {
nav.Sections[0].Collapsed = false
return nav
}
return nav
}
// ApplyNavOverrides merges the browser-saved collapse state (docs_nav
// cookie) into the nav so the sidebar first-paints in the visitor's chosen
// state — no post-paint JS correction, no jumping. Keys mirror the
// data-key attributes DocsSidebar renders: section titles, and
// "Section / Group" for groups. Entries for unknown keys (renamed or
// removed sections) are ignored; keys absent from the map keep the
// path-derived default.
func ApplyNavOverrides(nav *templates.Navigation, overrides map[string]bool) {
if len(overrides) == 0 {
return
}
for si := range nav.Sections {
s := &nav.Sections[si]
if v, ok := overrides[s.Title]; ok {
s.Collapsed = v
}
for gi := range s.Groups {
if v, ok := overrides[s.Title+" / "+s.Groups[gi].Title]; ok {
s.Groups[gi].Collapsed = v
}
}
}
}
// containsURL reports whether currentPath is one of the given doc URLs.
func containsURL(currentPath string, urls []string) bool {
for _, u := range urls {
+4 -7
View File
@@ -127,8 +127,8 @@ func TestBuildNavigation(t *testing.T) {
}
getting := nav.Sections[0]
if getting.Collapsed {
t.Error("Getting Started should be expanded (root overview pinned)")
if !getting.Collapsed {
t.Error("Getting Started should be collapsed: only sections containing the current page open")
}
if len(getting.Items) != 1 || getting.Items[0].Title != "Overview" || getting.Items[0].URL != "/docs" {
t.Errorf("Getting Started items = %+v, want single Overview → /docs", getting.Items)
@@ -196,10 +196,7 @@ func TestBuildNavigationNothingCurrent(t *testing.T) {
open++
}
}
if open != 1 {
t.Errorf("expected exactly one section open on a 404 path, got %d", open)
}
if nav.Sections[0].Collapsed {
t.Error("fallback should open the first section")
if open != 0 {
t.Errorf("expected zero sections open when nothing matches (docs home / 404 renders fully closed), got %d", open)
}
}