Merge branch 'mai/knuth/detail-page-order' (feat: detail-page field ordering + auxiliary section break)

This commit is contained in:
mAi
2026-05-26 13:15:43 +02:00
3 changed files with 301 additions and 95 deletions

134
web/detail_order_test.go Normal file
View File

@@ -0,0 +1,134 @@
package web_test
import (
"strings"
"testing"
)
// TestDetailFieldsRenderInOrder pins m's requested top-to-bottom flow on
// the /i/{path} edit form: form-then-auxiliaries, with form fields
// grouped general → specific. The test slices the rendered body into the
// documented anchor strings (form labels, group headings, divider, aux
// heading, first auxiliary <details>) and confirms each anchor's first
// index is strictly greater than the previous one.
//
// Anchors deliberately picked to be robust:
// - Form field markup (name="title" / name="slug" / etc.) — won't drift
// unless the form is re-architected.
// - Group-heading IDs (hdr-general / hdr-classification / hdr-flags /
// hdr-content / hdr-aux) — emitted by the Phase-5i template.
// - The aux-divider <hr> — the explicit visual break m asked for.
//
// If a future refactor moves fields around inside a group, this test
// still passes as long as the cross-group order holds.
func TestDetailFieldsRenderInOrder(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)
}
anchors := []struct {
label string
needle string
}{
{"General heading", `id="hdr-general"`},
{"Title field", `name="title"`},
{"Slug field", `name="slug"`},
{"Parents field", `name="parent_ids"`},
{"Status field", `name="status"`},
{"Classification heading", `id="hdr-classification"`},
{"Tags field", `name="tags"`},
{"Management field", `name="management"`},
{"Flags heading", `id="hdr-flags"`},
{"pinned field", `name="pinned"`},
{"archived field", `name="archived"`},
{"Content heading", `id="hdr-content"`},
{"Content textarea", `name="content_md"`},
{"Save button", `<button type="submit">Save</button>`},
{"Auxiliary divider", `class="aux-divider"`},
{"Auxiliary heading", `id="hdr-aux"`},
{"Documents section", `data-section="documents"`},
}
prevIdx := -1
prevLabel := "(start)"
for _, a := range anchors {
idx := strings.Index(body, a.needle)
if idx < 0 {
t.Errorf("%s anchor %q not found in body", a.label, a.needle)
continue
}
if idx <= prevIdx {
t.Errorf("order violation: %s (idx %d) must come AFTER %s (idx %d)",
a.label, idx, prevLabel, prevIdx)
}
prevIdx = idx
prevLabel = a.label
}
}
// TestDetailFormGroupHeadings proves the three group subheadings render
// with the expected human-readable copy. Hardcoded so a future "clean
// up the strings" pass doesn't silently strip the visual hierarchy m
// asked for.
func TestDetailFormGroupHeadings(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
_, body := get(t, h, "/i/dev")
for _, want := range []string{
`>General</h2>`,
`>Classification</h2>`,
`>Flags</h2>`,
`>Content</h2>`,
`>Related</h2>`,
} {
if !strings.Contains(body, want) {
t.Errorf("detail page missing group heading %q", want)
}
}
}
// TestDetailAuxSectionsAfterForm proves the read-only auxiliary
// <details> sections (Tasks / Issues / Documents) live BELOW the
// form's </form> tag — that's the load-bearing visual contract from
// m's report. Public-listing + Timeline-behaviour stay INSIDE the form
// (form-bound, saved by the main Save) — this test asserts only the
// purely read-only sections moved.
func TestDetailAuxSectionsAfterForm(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
_, body := get(t, h, "/i/dev")
// The layout has a <form action="/logout"> in the sidebar — its </form>
// would match first. Anchor on the detail form's start tag, then look
// for </form> AFTER that point so we're measuring the right boundary.
formStart := strings.Index(body, `<form method="post" action="/i/dev"`)
if formStart < 0 {
t.Fatalf("detail form start tag not found in body")
}
formEnd := strings.Index(body[formStart:], "</form>")
if formEnd < 0 {
t.Fatalf("</form> tag not found after detail form start")
}
formEnd += formStart
docs := strings.Index(body, `data-section="documents"`)
if docs < 0 {
t.Fatalf("documents section not found")
}
if docs <= formEnd {
t.Errorf("Documents section (idx %d) must appear AFTER </form> (idx %d)", docs, formEnd)
}
// Public listing stays inside the form — confirm the contract holds.
publicSection := strings.Index(body, `data-section="public"`)
if publicSection < 0 {
t.Fatalf("public section not found")
}
if publicSection >= formEnd {
t.Errorf("Public listing section (idx %d) should remain INSIDE the form (before </form> at idx %d) for save coherence",
publicSection, formEnd)
}
}

View File

@@ -1304,3 +1304,42 @@ html[data-sidebar-collapsed="true"] .projax-sidebar .collapse-icon {
padding-bottom: calc(56px + 1rem + env(safe-area-inset-bottom, 0px));
}
}
/* --- Detail page: form-group ordering polish (Phase 5i, detail-page-order) --- */
.detail-form { display: flex; flex-direction: column; gap: 20px; max-width: 720px; }
.detail-form .form-group { display: flex; flex-direction: column; gap: 12px; margin: 0; padding: 0; }
.detail-form .form-group-heading {
margin: 0;
font-size: 0.78em;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--muted);
border-bottom: 1px dotted var(--border);
padding-bottom: 4px;
}
.detail-form .form-group-flags { flex-direction: row; flex-wrap: wrap; gap: 18px 24px; align-items: baseline; }
.detail-form .form-group-flags .form-group-heading { flex-basis: 100%; }
.detail-form .form-group-content-label > textarea { font-family: ui-monospace, SFMono-Regular, monospace; }
.detail-form .form-group-content-label { gap: 0; }
.detail-form > details.proj-section { margin-top: 4px; }
/* Divider between the editable form and the read-only auxiliary
collapsibles. <hr> is semantically a thematic break — matches the
intent. The "Related" heading below it makes the change-of-mode
obvious without leaning on the line alone. */
.aux-divider {
border: 0;
border-top: 1px solid var(--border);
margin: 32px 0 16px;
}
.aux-sections { display: flex; flex-direction: column; gap: 4px; max-width: 720px; }
.aux-heading {
margin: 0 0 8px;
font-size: 0.95em;
font-weight: 600;
color: var(--muted);
}
.aux-reset { margin: 12px 0 0; font-size: 0.85em; }
.aux-reset .proj-section-reset { color: var(--muted); }
.aux-reset .proj-section-reset:hover { color: var(--bad); }

View File

@@ -13,111 +13,109 @@
<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}}
{{/* 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}}
{{/*
Phase 5i: reordered general → specific. Form first (Title → Slug →
Parents → Status → Classification → Flags → Content → form-bound
collapsibles) so the always-edit fields sit at the top, then a divider
before the auxiliary read-only collapsibles (Tasks / Issues /
Documents). Field grouping (General / Classification / Flags) reads as
three groups instead of nine flat labels per m's "pimped a bit" ask.
{{if and .GiteaOn .Issues}}
{{$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}}
Public listing + Timeline behaviour stay INSIDE the form so they save
with the main Save button — moving them out would require a separate
POST endpoint, which is out of scope for this pass.
{{$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>
Phase 4e collapsibles smart-default + localStorage state preserved
exactly: same data-section keys, same proj-section CSS class, same
inline JS.
*/}}
<form method="post" action="/i/{{.Item.PrimaryPath}}" class="edit">
<label>Title <input name="title" value="{{.Item.Title}}" required></label>
<label>Slug <input name="slug" value="{{.Item.Slug}}" required pattern="[^.]+"></label>
<label>Parents <small class="muted">(hold Ctrl/Cmd to pick multiple — same row can live under several branches)</small>
<select name="parent_ids" multiple size="6">
{{range .ParentOptions}}
<option value="{{.ID}}" {{if contains $.Item.ParentIDs .ID}}selected{{end}}>{{.Path}}</option>
{{end}}
</select>
</label>
<label>Status
<select name="status">
{{range $opt := .StatusOptions}}
<option value="{{$opt}}" {{if eq $opt $.Item.Status}}selected{{end}}>{{$opt}}</option>
{{end}}
</select>
</label>
<label>Tags
<input name="tags" value="{{join "," .Item.Tags}}" placeholder="comma-separated, e.g. work, dev">
</label>
<label>Management
<input name="management" value="{{join "," .Item.Management}}" placeholder="comma-separated: self, mai, external">
</label>
<label class="checkbox">
<input type="checkbox" name="pinned" value="1" {{if .Item.Pinned}}checked{{end}}> pinned
</label>
<label class="checkbox">
<input type="checkbox" name="archived" value="1" {{if .Item.Archived}}checked{{end}}> archived
</label>
<label>Content
<textarea name="content_md" rows="14">{{.Item.ContentMD}}</textarea>
</label>
<form method="post" action="/i/{{.Item.PrimaryPath}}" class="edit detail-form">
<section class="form-group" aria-labelledby="hdr-general">
<h2 id="hdr-general" class="form-group-heading">General</h2>
<label>Title <input name="title" value="{{.Item.Title}}" required></label>
<label>Slug <input name="slug" value="{{.Item.Slug}}" required pattern="[^.]+"></label>
<label>Parents <small class="muted">(hold Ctrl/Cmd to pick multiple — same row can live under several branches)</small>
<select name="parent_ids" multiple size="6">
{{range .ParentOptions}}
<option value="{{.ID}}" {{if contains $.Item.ParentIDs .ID}}selected{{end}}>{{.Path}}</option>
{{end}}
</select>
</label>
<label>Status
<select name="status">
{{range $opt := .StatusOptions}}
<option value="{{$opt}}" {{if eq $opt $.Item.Status}}selected{{end}}>{{$opt}}</option>
{{end}}
</select>
</label>
</section>
<section class="form-group" aria-labelledby="hdr-classification">
<h2 id="hdr-classification" class="form-group-heading">Classification</h2>
<label>Tags
<input name="tags" value="{{join "," .Item.Tags}}" placeholder="comma-separated, e.g. work, dev">
</label>
<label>Management
<input name="management" value="{{join "," .Item.Management}}" placeholder="comma-separated: self, mai, external">
</label>
</section>
<section class="form-group form-group-flags" aria-labelledby="hdr-flags">
<h2 id="hdr-flags" class="form-group-heading">Flags</h2>
<label class="checkbox">
<input type="checkbox" name="pinned" value="1" {{if .Item.Pinned}}checked{{end}}> pinned
</label>
<label class="checkbox">
<input type="checkbox" name="archived" value="1" {{if .Item.Archived}}checked{{end}}> archived
</label>
</section>
<section class="form-group" aria-labelledby="hdr-content">
<h2 id="hdr-content" class="form-group-heading">Content</h2>
<label class="form-group-content-label">
<textarea name="content_md" rows="14">{{.Item.ContentMD}}</textarea>
</label>
</section>
<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 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>
<label class="checkbox">
<input type="checkbox" name="public" value="1" {{if .Item.Public}}checked{{end}}> Make this public
</label>
<label>Public description
<textarea name="public_description" rows="4" placeholder="What visitors see on flexsiebels. Markdown allowed.">{{.Item.PublicDescription}}</textarea>
</label>
<label>Live URL
<input name="public_live_url" type="url" value="{{.Item.PublicLiveURL}}" placeholder="https://racetrack.dev">
</label>
<label>Source URL
<input name="public_source_url" type="url" value="{{.Item.PublicSourceURL}}" placeholder="https://mgit.msbls.de/m/racetrack">
</label>
<label>Screenshots <small class="muted">(one URL per row; order is the display order)</small>
<div class="public-screenshots" id="public-screenshots">
{{range .Item.PublicScreenshots}}
<summary class="proj-section-summary">Public listing {{if .Item.Public}}<small class="muted">(on)</small>{{end}}</summary>
<fieldset class="public-listing">
<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>
<label class="checkbox">
<input type="checkbox" name="public" value="1" {{if .Item.Public}}checked{{end}}> Make this public
</label>
<label>Public description
<textarea name="public_description" rows="4" placeholder="What visitors see on flexsiebels. Markdown allowed.">{{.Item.PublicDescription}}</textarea>
</label>
<label>Live URL
<input name="public_live_url" type="url" value="{{.Item.PublicLiveURL}}" placeholder="https://racetrack.dev">
</label>
<label>Source URL
<input name="public_source_url" type="url" value="{{.Item.PublicSourceURL}}" placeholder="https://mgit.msbls.de/m/racetrack">
</label>
<label>Screenshots <small class="muted">(one URL per row; order is the display order)</small>
<div class="public-screenshots" id="public-screenshots">
{{range .Item.PublicScreenshots}}
<div class="public-screenshot-row">
<input name="public_screenshots" type="url" value="{{.}}" placeholder="https://…">
<button type="button" class="public-screenshot-remove" aria-label="Remove screenshot">×</button>
</div>
{{end}}
<div class="public-screenshot-row">
<input name="public_screenshots" type="url" value="{{.}}" placeholder="https://…">
<input name="public_screenshots" type="url" value="" placeholder="https://…">
<button type="button" class="public-screenshot-remove" aria-label="Remove screenshot">×</button>
</div>
{{end}}
<div class="public-screenshot-row">
<input name="public_screenshots" type="url" value="" placeholder="https://…">
<button type="button" class="public-screenshot-remove" aria-label="Remove screenshot">×</button>
</div>
</div>
<button type="button" id="public-screenshot-add" class="public-screenshot-add">+ Add screenshot</button>
</label>
</fieldset>
<button type="button" id="public-screenshot-add" class="public-screenshot-add">+ Add screenshot</button>
</label>
</fieldset>
</details>
<details class="proj-section" data-section="timeline-behaviour" data-item-id="{{$itemID}}"{{if .Item.TimelineExclude}} open{{end}}>
@@ -136,9 +134,44 @@
<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>
<hr class="aux-divider" aria-hidden="true">
<section class="aux-sections" aria-labelledby="hdr-aux">
<h2 id="hdr-aux" class="aux-heading">Related</h2>
{{if .CalDAVOn}}
{{/* Tasks section opens by default when any linked calendar has at least
one open VTODO. */}}
{{$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}}
{{$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}}
{{$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>
<p class="aux-reset">
<a class="proj-section-reset muted" href="#" data-item-id="{{$itemID}}">reset section state</a>
</p>
</section>
<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