From 73e8cdef4b33e552dc2a6ce5da68c243610746f7 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Sat, 24 Oct 2026 21:17:55 -0400 Subject: [PATCH] =?UTF-8?q?docs(ui):=20drop=20sidebar=20saved-state=20?= =?UTF-8?q?=E2=80=94=20server=20defaults=20already=20ARE=20the=20standard?= =?UTF-8?q?=20docs=20behavior?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- internal/docs/http_handler.go | 29 ---- internal/docs/navigation.go | 71 +++----- internal/docs/navigation_test.go | 11 +- templates/docs.templ | 4 +- templates/docs_templ.go | 274 ++++++++++++++----------------- web/src/docs.ts | 38 +---- 6 files changed, 155 insertions(+), 272 deletions(-) diff --git a/internal/docs/http_handler.go b/internal/docs/http_handler.go index 7bb4d21a..f18035f7 100644 --- a/internal/docs/http_handler.go +++ b/internal/docs/http_handler.go @@ -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 { diff --git a/internal/docs/navigation.go b/internal/docs/navigation.go index ed789290..299ea528 100644 --- a/internal/docs/navigation.go +++ b/internal/docs/navigation.go @@ -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 { diff --git a/internal/docs/navigation_test.go b/internal/docs/navigation_test.go index fd726e21..6583db83 100644 --- a/internal/docs/navigation_test.go +++ b/internal/docs/navigation_test.go @@ -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) } } diff --git a/templates/docs.templ b/templates/docs.templ index c5de629f..a3360a71 100644 --- a/templates/docs.templ +++ b/templates/docs.templ @@ -198,7 +198,7 @@ templ DocsSidebar(nav Navigation, currentPath string) { @Icon("chevron-down", "h-4 w-4") -