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)); 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> <p class="meta muted">Also at: {{range $i, $p := .Item.OtherPaths}}{{if $i}}, {{end}}<a href="/i/{{$p}}">{{$p}}</a>{{end}}</p>
{{end}} {{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}} {{$itemID := .Item.ID}}
{{if .CalDAVOn}} {{/*
{{/* Tasks section opens by default when any linked calendar has at least Phase 5i: reordered general → specific. Form first (Title → Slug →
one open VTODO. hasOpenTasks template helper would be cleaner but the Parents → Status → Classification → Flags → Content → form-bound
range-with-flag style avoids registering a new func. */}} collapsibles) so the always-edit fields sit at the top, then a divider
{{$tasksOpen := false}} before the auxiliary read-only collapsibles (Tasks / Issues /
{{range .Tasks}}{{if .Open}}{{$tasksOpen = true}}{{end}}{{end}} Documents). Field grouping (General / Classification / Flags) reads as
<details class="proj-section" data-section="tasks" data-item-id="{{$itemID}}"{{if $tasksOpen}} open{{end}}> three groups instead of nine flat labels per m's "pimped a bit" ask.
<summary class="proj-section-summary">Tasks {{if $tasksOpen}}<small class="muted">(open)</small>{{end}}</summary>
{{template "tasks-section" .}}
</details>
{{end}}
{{if and .GiteaOn .Issues}} Public listing + Timeline behaviour stay INSIDE the form so they save
{{$open := le .IssuesOpenTotal 10}} with the main Save button — moving them out would require a separate
<details class="proj-section" data-section="issues" data-item-id="{{$itemID}}"{{if $open}} open{{end}}> POST endpoint, which is out of scope for this pass.
<summary class="proj-section-summary">Issues <small class="muted">({{.IssuesOpenTotal}} open)</small></summary>
{{template "issues-section" .}}
</details>
{{end}}
{{$docOpen := le (len .Documents) 5}} Phase 4e collapsibles smart-default + localStorage state preserved
<details class="proj-section" data-section="documents" data-item-id="{{$itemID}}"{{if $docOpen}} open{{end}}> exactly: same data-section keys, same proj-section CSS class, same
<summary class="proj-section-summary">Documents <small class="muted">({{len .Documents}})</small></summary> inline JS.
{{template "documents-section" .}} */}}
</details>
<form method="post" action="/i/{{.Item.PrimaryPath}}" class="edit"> <form method="post" action="/i/{{.Item.PrimaryPath}}" class="edit detail-form">
<label>Title <input name="title" value="{{.Item.Title}}" required></label>
<label>Slug <input name="slug" value="{{.Item.Slug}}" required pattern="[^.]+"></label> <section class="form-group" aria-labelledby="hdr-general">
<label>Parents <small class="muted">(hold Ctrl/Cmd to pick multiple — same row can live under several branches)</small> <h2 id="hdr-general" class="form-group-heading">General</h2>
<select name="parent_ids" multiple size="6"> <label>Title <input name="title" value="{{.Item.Title}}" required></label>
{{range .ParentOptions}} <label>Slug <input name="slug" value="{{.Item.Slug}}" required pattern="[^.]+"></label>
<option value="{{.ID}}" {{if contains $.Item.ParentIDs .ID}}selected{{end}}>{{.Path}}</option> <label>Parents <small class="muted">(hold Ctrl/Cmd to pick multiple — same row can live under several branches)</small>
{{end}} <select name="parent_ids" multiple size="6">
</select> {{range .ParentOptions}}
</label> <option value="{{.ID}}" {{if contains $.Item.ParentIDs .ID}}selected{{end}}>{{.Path}}</option>
<label>Status {{end}}
<select name="status"> </select>
{{range $opt := .StatusOptions}} </label>
<option value="{{$opt}}" {{if eq $opt $.Item.Status}}selected{{end}}>{{$opt}}</option> <label>Status
{{end}} <select name="status">
</select> {{range $opt := .StatusOptions}}
</label> <option value="{{$opt}}" {{if eq $opt $.Item.Status}}selected{{end}}>{{$opt}}</option>
<label>Tags {{end}}
<input name="tags" value="{{join "," .Item.Tags}}" placeholder="comma-separated, e.g. work, dev"> </select>
</label> </label>
<label>Management </section>
<input name="management" value="{{join "," .Item.Management}}" placeholder="comma-separated: self, mai, external">
</label> <section class="form-group" aria-labelledby="hdr-classification">
<label class="checkbox"> <h2 id="hdr-classification" class="form-group-heading">Classification</h2>
<input type="checkbox" name="pinned" value="1" {{if .Item.Pinned}}checked{{end}}> pinned <label>Tags
</label> <input name="tags" value="{{join "," .Item.Tags}}" placeholder="comma-separated, e.g. work, dev">
<label class="checkbox"> </label>
<input type="checkbox" name="archived" value="1" {{if .Item.Archived}}checked{{end}}> archived <label>Management
</label> <input name="management" value="{{join "," .Item.Management}}" placeholder="comma-separated: self, mai, external">
<label>Content </label>
<textarea name="content_md" rows="14">{{.Item.ContentMD}}</textarea> </section>
</label>
<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}}"> <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> <summary class="proj-section-summary">Public listing {{if .Item.Public}}<small class="muted">(on)</small>{{end}}</summary>
<fieldset class="public-listing"> <fieldset class="public-listing">
<legend class="visually-hidden">Public listing</legend> <legend class="visually-hidden">Public listing</legend>
<p class="muted">When public is on, flexsiebels.de (and any other portfolio <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 consumer) can pull these fields via the projax MCP. The values are
preserved when public is off — toggling never destroys them.</p> preserved when public is off — toggling never destroys them.</p>
<label class="checkbox"> <label class="checkbox">
<input type="checkbox" name="public" value="1" {{if .Item.Public}}checked{{end}}> Make this public <input type="checkbox" name="public" value="1" {{if .Item.Public}}checked{{end}}> Make this public
</label> </label>
<label>Public description <label>Public description
<textarea name="public_description" rows="4" placeholder="What visitors see on flexsiebels. Markdown allowed.">{{.Item.PublicDescription}}</textarea> <textarea name="public_description" rows="4" placeholder="What visitors see on flexsiebels. Markdown allowed.">{{.Item.PublicDescription}}</textarea>
</label> </label>
<label>Live URL <label>Live URL
<input name="public_live_url" type="url" value="{{.Item.PublicLiveURL}}" placeholder="https://racetrack.dev"> <input name="public_live_url" type="url" value="{{.Item.PublicLiveURL}}" placeholder="https://racetrack.dev">
</label> </label>
<label>Source URL <label>Source URL
<input name="public_source_url" type="url" value="{{.Item.PublicSourceURL}}" placeholder="https://mgit.msbls.de/m/racetrack"> <input name="public_source_url" type="url" value="{{.Item.PublicSourceURL}}" placeholder="https://mgit.msbls.de/m/racetrack">
</label> </label>
<label>Screenshots <small class="muted">(one URL per row; order is the display order)</small> <label>Screenshots <small class="muted">(one URL per row; order is the display order)</small>
<div class="public-screenshots" id="public-screenshots"> <div class="public-screenshots" id="public-screenshots">
{{range .Item.PublicScreenshots}} {{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"> <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> <button type="button" class="public-screenshot-remove" aria-label="Remove screenshot">×</button>
</div> </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>
</div> <button type="button" id="public-screenshot-add" class="public-screenshot-add">+ Add screenshot</button>
<button type="button" id="public-screenshot-add" class="public-screenshot-add">+ Add screenshot</button> </label>
</label> </fieldset>
</fieldset>
</details> </details>
<details class="proj-section" data-section="timeline-behaviour" data-item-id="{{$itemID}}"{{if .Item.TimelineExclude}} open{{end}}> <details class="proj-section" data-section="timeline-behaviour" data-item-id="{{$itemID}}"{{if .Item.TimelineExclude}} open{{end}}>
@@ -136,9 +134,44 @@
<div class="actions"> <div class="actions">
<button type="submit">Save</button> <button type="submit">Save</button>
<a class="cancel" href="/">Cancel</a> <a class="cancel" href="/">Cancel</a>
<a class="proj-section-reset" href="#" data-item-id="{{$itemID}}">reset section state</a>
</div> </div>
</form> </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> <script>
// Phase 4e collapsible-section persistence. Each <details data-section data-item-id> // 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 // reads its open state from localStorage on boot (user choice wins over the