diff --git a/docs/design.md b/docs/design.md index 7199899..95f9be9 100644 --- a/docs/design.md +++ b/docs/design.md @@ -630,6 +630,23 @@ Times stringify into either YYYY-MM-DD (date-only) or full RFC 3339 UTC (timed), **Auth:** same `Authorization: Bearer ${PROJAX_MCP_TOKEN}` as the rest of `/mcp/rpc`. No CORS allowlist needed — consumers (PWA backend, future agents) call projax server-to-server. +## Collapsible detail-page sections (Phase 4e) + +The `/i/{path}` detail page wraps each major section in a native `
` element so long Issues / Documents lists don't dominate a project page. Three section keys today (`tasks`, `issues`, `documents`, `public`); `
` survives HTMX swaps because the wrappers live in `detail.tmpl`, not inside the swap targets. + +**Smart defaults** (server-side `open` attribute): + +| Section | Open when | +|---|---| +| Tasks | any linked calendar has at least one open VTODO | +| Issues | total open issues ≤ 10 | +| Documents | dated link count ≤ 5 | +| Public listing | always closed (toggle is rarely flipped) | + +**Persistence**: inline JS reads `localStorage["projax.section." + itemID + "." + section]` on boot — `"open"` or `"closed"` — and writes it back on every `toggle`. User choice wins over the server default. A `reset section state` link in the form actions wipes every `projax.section..*` key for the current item and reloads, restoring the smart-default behaviour. + +**What's NOT collapsible**: title + status/tag/management chip line (always visible breadcrumb), the inline edit form's standard fields (title/slug/parents/content). Only the auxiliary sections collapse — m always needs to see what an item *is* without expanding anything. + ## 15. Public listing (Phase 4d) projax becomes the source of truth for which items go on m's public portfolio (flexsiebels.de today; any future renderer via MCP). Five new columns on `projax.items`, all default-safe — 95% of items stay private and the partial index keeps the "show me everything public" query cheap. diff --git a/web/collapsibles_test.go b/web/collapsibles_test.go new file mode 100644 index 0000000..62d189a --- /dev/null +++ b/web/collapsibles_test.go @@ -0,0 +1,93 @@ +package web_test + +import ( + "strings" + "testing" +) + +// TestDetailIncludesSectionToggleScript proves the inline JS that powers +// the per-item localStorage persistence ships on the detail page. Without +// it, the smart defaults would render but toggles wouldn't survive a +// reload — silent UX regression that's worth a guard. +func TestDetailIncludesSectionToggleScript(t *testing.T) { + srv, pool := mustServer(t) + defer pool.Close() + h := srv.Routes() + code, body := get(t, h, "/i/dev") + if code != 200 { + t.Fatalf("GET /i/dev → %d", code) + } + for _, want := range []string{ + `details.proj-section[data-section][data-item-id]`, + `projax.section.`, + `proj-section-reset`, + `localStorage.setItem`, + } { + if !strings.Contains(body, want) { + t.Errorf("detail page missing collapsible-script fragment %q", want) + } + } +} + +// TestDetailSectionsWrappedInDetails proves every section on /i/{path} +// gets a
wrapper, regardless of count. We +// can't easily set up dozens of issues for the count-driven default +// assertion in a unit test (Gitea is mocked), so this just verifies the +// wrapper exists and the data-section attribute is correct. +func TestDetailSectionsWrappedInDetails(t *testing.T) { + srv, pool := mustServer(t) + defer pool.Close() + h := srv.Routes() + code, body := get(t, h, "/i/dev") + if code != 200 { + t.Fatalf("GET /i/dev → %d", code) + } + // Documents always renders (no integration deps); Public listing + // always renders inside the form. + for _, want := range []string{ + `data-section="documents"`, + `data-section="public"`, + `class="proj-section-summary"`, + } { + if !strings.Contains(body, want) { + t.Errorf("detail page missing section wrapper %q", want) + } + } +} + +// TestDetailDocumentsClosedDefaultsWhenManyItems is the threshold check +// for the Documents section (default open when ≤5). With a fresh item +// holding 0 dated links, the wrapper should be open. We can't easily +// seed >5 in a fast unit test against the live DB, so this just probes +// the open-state baseline; the count-driven branch is exercised by the +// template logic and visually verified during the deploy probe. +func TestDetailDocumentsClosedDefaultsWhenManyItems(t *testing.T) { + srv, pool := mustServer(t) + defer pool.Close() + h := srv.Routes() + _, body := get(t, h, "/i/dev") + // Find the documents section's opening tag and check for ` open` attr. + idx := strings.Index(body, `data-section="documents"`) + if idx < 0 { + t.Fatalf("documents section not found in body") + } + // Slice the surrounding 200 chars to look for the `open` attribute. + slice := body[max0(idx-100):min(len(body), idx+200)] + if !strings.Contains(slice, "open") { + t.Errorf("expected documents section to be open by default with 0 docs (≤5 threshold), got:\n%s", slice) + } +} + +func max0(x int) int { + if x < 0 { + return 0 + } + return x +} + +func min(a, b int) int { + if a < b { + return a + } + return b +} diff --git a/web/static/style.css b/web/static/style.css index 95c73f9..5e6d5ee 100644 --- a/web/static/style.css +++ b/web/static/style.css @@ -700,3 +700,52 @@ fieldset.public-listing label { margin-top: 8px; } padding: 4px 10px; font-size: 0.9em; cursor: pointer; min-height: 0; } .public-screenshot-add:hover { background: var(--accent); color: var(--accent-fg); } + +/* --- Detail-page collapsibles (Phase 4e) --- */ +details.proj-section { + margin: 16px 0; + border-top: 1px solid var(--border); + padding-top: 8px; +} +details.proj-section[open] { + border-bottom: 1px dotted var(--border); + padding-bottom: 8px; +} +details.proj-section > summary.proj-section-summary { + cursor: pointer; + list-style: none; + font-size: 1.05em; + font-weight: 600; + padding: 4px 0 4px 22px; + position: relative; + user-select: none; +} +details.proj-section > summary.proj-section-summary::-webkit-details-marker { + display: none; +} +details.proj-section > summary.proj-section-summary::before { + content: "▸"; + position: absolute; + left: 4px; + color: var(--muted); + font-size: 0.9em; + transition: transform 0.12s; + display: inline-block; +} +details.proj-section[open] > summary.proj-section-summary::before { + transform: rotate(90deg); +} +details.proj-section > summary.proj-section-summary:hover { color: var(--accent); } +details.proj-section > summary.proj-section-summary small { font-weight: 400; } + +.proj-section-reset { + color: var(--muted); + font-size: 0.85em; + text-decoration: none; + margin-left: auto; +} +.proj-section-reset:hover { color: var(--accent); text-decoration: underline; } +.visually-hidden { + position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; + overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; +} diff --git a/web/templates/detail.tmpl b/web/templates/detail.tmpl index ca62593..4e1415f 100644 --- a/web/templates/detail.tmpl +++ b/web/templates/detail.tmpl @@ -13,15 +13,42 @@

Also at: {{range $i, $p := .Item.OtherPaths}}{{if $i}}, {{end}}{{$p}}{{end}}

{{end}} +{{/* + Phase 4e: collapsible sections. Each detail-page section is wrapped in a +
element with a smart default for the `open` attribute based on + the count of items inside. The inline JS at the bottom of the page + overrides those defaults from localStorage so m's per-item collapse + state survives reloads. Data-section keys are stable strings; data-count + surfaces the count so the JS doesn't have to re-walk children to label + the summary. +*/}} +{{$itemID := .Item.ID}} + {{if .CalDAVOn}} -{{template "tasks-section" .}} +{{/* Tasks section opens by default when any linked calendar has at least + one open VTODO. hasOpenTasks template helper would be cleaner but the + range-with-flag style avoids registering a new func. */}} +{{$tasksOpen := false}} +{{range .Tasks}}{{if .Open}}{{$tasksOpen = true}}{{end}}{{end}} +
+ Tasks {{if $tasksOpen}}(open){{end}} + {{template "tasks-section" .}} +
{{end}} {{if and .GiteaOn .Issues}} -{{template "issues-section" .}} +{{$open := le .IssuesOpenTotal 10}} +
+ Issues ({{.IssuesOpenTotal}} open) + {{template "issues-section" .}} +
{{end}} -{{template "documents-section" .}} +{{$docOpen := le (len .Documents) 5}} +
+ Documents ({{len .Documents}}) + {{template "documents-section" .}} +
@@ -56,8 +83,10 @@ +
+ Public listing {{if .Item.Public}}(on){{end}}
- Public listing + Public listing

When public is on, flexsiebels.de (and any other portfolio consumer) can pull these fields via the projax MCP. The values are preserved when public is off — toggling never destroys them.

@@ -89,12 +118,51 @@
+
+