feat(phase 4e): collapsible detail-page sections with smart defaults + localStorage
Each major section on /i/{path} is now wrapped in a native <details>
element with a smart-default `open` attribute. The inline JS overrides
the default from localStorage so m's per-item collapse state survives
reloads.
## Smart defaults (server-rendered open attr)
- Tasks: open if any linked calendar has >=1 open VTODO
- Issues: open if total open issues <= 10
- Documents: open if dated link count <= 5
- Public listing: closed by default
## Persistence
localStorage["projax.section." + item_id + "." + section] = "open" | "closed".
Inline JS reads on boot, writes on toggle. The "reset section state" link
in the form actions wipes every key for the current item and reloads —
smart defaults take over again.
## What's not collapsed
- Title + status/tags chip line (always visible breadcrumb)
- The inline edit form's standard fields (title/slug/parents/content)
Only the auxiliary sections — Tasks, Issues, Documents, Public listing —
collapse. m always sees what an item *is* without expanding anything.
## Tests
- TestDetailIncludesSectionToggleScript — script fragments ship
- TestDetailSectionsWrappedInDetails — every section has its wrapper
- TestDetailDocumentsClosedDefaultsWhenManyItems — 0-doc baseline is open
## docs/design.md
New section before §15 documents thresholds, persistence semantics, and
the non-collapsible carve-outs.
This commit is contained in:
@@ -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 `<details>` element so long Issues / Documents lists don't dominate a project page. Three section keys today (`tasks`, `issues`, `documents`, `public`); `<details class="proj-section" data-section data-item-id>` 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.<itemID>.*` 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.
|
||||
|
||||
93
web/collapsibles_test.go
Normal file
93
web/collapsibles_test.go
Normal file
@@ -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 <details data-section="..."> 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
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -13,15 +13,42 @@
|
||||
<p class="meta muted">Also at: {{range $i, $p := .Item.OtherPaths}}{{if $i}}, {{end}}<a href="/i/{{$p}}">{{$p}}</a>{{end}}</p>
|
||||
{{end}}
|
||||
|
||||
{{/*
|
||||
Phase 4e: collapsible sections. Each detail-page section is wrapped in a
|
||||
<details> 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}}
|
||||
<details class="proj-section" data-section="tasks" data-item-id="{{$itemID}}"{{if $tasksOpen}} open{{end}}>
|
||||
<summary class="proj-section-summary">Tasks {{if $tasksOpen}}<small class="muted">(open)</small>{{end}}</summary>
|
||||
{{template "tasks-section" .}}
|
||||
</details>
|
||||
{{end}}
|
||||
|
||||
{{if and .GiteaOn .Issues}}
|
||||
{{template "issues-section" .}}
|
||||
{{$open := le .IssuesOpenTotal 10}}
|
||||
<details class="proj-section" data-section="issues" data-item-id="{{$itemID}}"{{if $open}} open{{end}}>
|
||||
<summary class="proj-section-summary">Issues <small class="muted">({{.IssuesOpenTotal}} open)</small></summary>
|
||||
{{template "issues-section" .}}
|
||||
</details>
|
||||
{{end}}
|
||||
|
||||
{{template "documents-section" .}}
|
||||
{{$docOpen := le (len .Documents) 5}}
|
||||
<details class="proj-section" data-section="documents" data-item-id="{{$itemID}}"{{if $docOpen}} open{{end}}>
|
||||
<summary class="proj-section-summary">Documents <small class="muted">({{len .Documents}})</small></summary>
|
||||
{{template "documents-section" .}}
|
||||
</details>
|
||||
|
||||
<form method="post" action="/i/{{.Item.PrimaryPath}}" class="edit">
|
||||
<label>Title <input name="title" value="{{.Item.Title}}" required></label>
|
||||
@@ -56,8 +83,10 @@
|
||||
<textarea name="content_md" rows="14">{{.Item.ContentMD}}</textarea>
|
||||
</label>
|
||||
|
||||
<details class="proj-section" data-section="public" data-item-id="{{$itemID}}">
|
||||
<summary class="proj-section-summary">Public listing {{if .Item.Public}}<small class="muted">(on)</small>{{end}}</summary>
|
||||
<fieldset class="public-listing">
|
||||
<legend>Public listing</legend>
|
||||
<legend class="visually-hidden">Public listing</legend>
|
||||
<p class="muted">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.</p>
|
||||
@@ -89,12 +118,51 @@
|
||||
<button type="button" id="public-screenshot-add" class="public-screenshot-add">+ Add screenshot</button>
|
||||
</label>
|
||||
</fieldset>
|
||||
</details>
|
||||
|
||||
<div class="actions">
|
||||
<button type="submit">Save</button>
|
||||
<a class="cancel" href="/">Cancel</a>
|
||||
<a class="proj-section-reset" href="#" data-item-id="{{$itemID}}">reset section state</a>
|
||||
</div>
|
||||
</form>
|
||||
<script>
|
||||
// Phase 4e collapsible-section persistence. Each <details data-section data-item-id>
|
||||
// reads its open state from localStorage on boot (user choice wins over the
|
||||
// server-rendered default), writes back on toggle. The reset link wipes
|
||||
// every projax.section.{item}.* key so smart defaults take over again.
|
||||
(function() {
|
||||
var details = document.querySelectorAll("details.proj-section[data-section][data-item-id]");
|
||||
function keyFor(d) {
|
||||
return "projax.section." + d.getAttribute("data-item-id") + "." + d.getAttribute("data-section");
|
||||
}
|
||||
details.forEach(function(d) {
|
||||
try {
|
||||
var saved = localStorage.getItem(keyFor(d));
|
||||
if (saved === "open") d.setAttribute("open", "");
|
||||
else if (saved === "closed") d.removeAttribute("open");
|
||||
} catch (e) { /* localStorage blocked — fall through to default */ }
|
||||
d.addEventListener("toggle", function() {
|
||||
try { localStorage.setItem(keyFor(d), d.open ? "open" : "closed"); } catch (e) {}
|
||||
});
|
||||
});
|
||||
var reset = document.querySelector(".proj-section-reset");
|
||||
if (reset) reset.addEventListener("click", function(e) {
|
||||
e.preventDefault();
|
||||
var itemID = reset.getAttribute("data-item-id");
|
||||
if (!itemID) return;
|
||||
try {
|
||||
var prefix = "projax.section." + itemID + ".";
|
||||
for (var i = localStorage.length - 1; i >= 0; i--) {
|
||||
var k = localStorage.key(i);
|
||||
if (k && k.indexOf(prefix) === 0) localStorage.removeItem(k);
|
||||
}
|
||||
} catch (e) {}
|
||||
// Reload so the server's smart defaults take effect again.
|
||||
location.reload();
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
<script>
|
||||
// Phase 4d screenshot list editor. Small inline JS — no framework. Rows
|
||||
// are simple <input name="public_screenshots"> entries; the server's
|
||||
|
||||
Reference in New Issue
Block a user