From 1af09901080d3b0179afe7a9e78546dc337c2c96 Mon Sep 17 00:00:00 2001 From: mAi Date: Tue, 26 May 2026 13:15:39 +0200 Subject: [PATCH] =?UTF-8?q?feat(detail):=20reorder=20fields=20general?= =?UTF-8?q?=E2=86=92specific,=20divider=20before=20auxiliary?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit m's report: detail page (/i/{path}) shows Tasks / Issues / Documents above the edit form, and the form's 9 flat fields read as a wall of labels rather than a flow. He wants the form first, fields grouped, then auxiliary read-only sections below a clear visual break. Reordered top-to-bottom flow: h1 + meta ▸ form General — Title → Slug → Parents → Status Classification — Tags → Management Flags — pinned + archived (inline pair) Content — markdown textarea Public listing
(stays inside form: save coherence) Timeline behaviour
(stays inside form: save coherence) Save / Cancel actions ◂ /form
▸ section.aux-sections "Related" Tasks
(was above form) Issues
(was above form) Documents
(was above form) reset section state link web/templates/detail.tmpl: - Three
blocks each with a

ID-anchored for aria-labelledby + the ordering test. The headings render as small uppercase muted labels — visual hierarchy without screaming "FORM". - Form-bound collapsibles (Public Listing + Timeline behaviour) stay inside the form; moving them out would require a separate POST endpoint, which the brief explicitly puts out of scope. - Tasks / Issues / Documents collapsibles moved out of the form, into a new
after a thematic
. - Reset-section-state link relocated to .aux-reset under the auxiliary section since that's where most collapsible state lives now. - All data-section / data-item-id / proj-section class hooks preserved exactly — Phase 4e smart-default + localStorage state semantics unchanged. web/static/style.css: - .detail-form: column flex, gap 20px between groups for breathing room. - .form-group-heading: 0.78em uppercase muted with dotted-border-bottom separator — looks like an admin-form group header without being shouty. - .form-group-flags: row-flex so pinned + archived sit inline. - .aux-divider: full-width 1px solid border-top with 32px margin above, 16px below — the explicit "this is where editable ends" break. - .aux-sections + .aux-heading + .aux-reset: matched flex layout + small "Related" header so the change-of-mode reads without squinting. Tests: - TestDetailFieldsRenderInOrder (new) — strict-greater index walk through every documented anchor: General → Title → Slug → Parents → Status → Classification → Tags → Management → Flags → pinned → archived → Content → content_md → Save → aux-divider → Related → Documents. Catches any future regression that re-tangles the order. - TestDetailFormGroupHeadings (new) — pins the five visible group headings (General / Classification / Flags / Content / Related) so a string-cleanup pass can't silently strip them. - TestDetailAuxSectionsAfterForm (new) — Documents
lives AFTER the detail form's , while Public listing stays INSIDE the form for save-coherence. Skips the sidebar's logout-form by anchoring on the detail-form's action="/i/dev" start tag. - TestDetailIncludesSectionToggleScript / TestDetailSectionsWrappedInDetails / TestDetailDocumentsClosedDefaultsWhenManyItems still pass — the Phase 4e collapsible semantics are untouched. Net: +298 / -92. --- web/detail_order_test.go | 134 +++++++++++++++++++++++ web/static/style.css | 39 +++++++ web/templates/detail.tmpl | 223 ++++++++++++++++++++++---------------- 3 files changed, 301 insertions(+), 95 deletions(-) create mode 100644 web/detail_order_test.go diff --git a/web/detail_order_test.go b/web/detail_order_test.go new file mode 100644 index 0000000..96f1263 --- /dev/null +++ b/web/detail_order_test.go @@ -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
) 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
— 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", ``}, + {"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

`, + `>Classification`, + `>Flags`, + `>Content`, + `>Related`, + } { + if !strings.Contains(body, want) { + t.Errorf("detail page missing group heading %q", want) + } + } +} + +// TestDetailAuxSectionsAfterForm proves the read-only auxiliary +//
sections (Tasks / Issues / Documents) live BELOW the +// form's 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
in the sidebar — its
+ // would match first. Anchor on the detail form's start tag, then look + // for AFTER that point so we're measuring the right boundary. + formStart := strings.Index(body, `
") + if formEnd < 0 { + t.Fatalf("
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 (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 at idx %d) for save coherence", + publicSection, formEnd) + } +} diff --git a/web/static/style.css b/web/static/style.css index cdc16f7..b25825b 100644 --- a/web/static/style.css +++ b/web/static/style.css @@ -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.
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); } diff --git a/web/templates/detail.tmpl b/web/templates/detail.tmpl index 7a79ddf..e5b4838 100644 --- a/web/templates/detail.tmpl +++ b/web/templates/detail.tmpl @@ -13,111 +13,109 @@

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}} -{{/* 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}} +{{/* + 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}} -
- Issues ({{.IssuesOpenTotal}} open) - {{template "issues-section" .}} -
-{{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}} -
- Documents ({{len .Documents}}) - {{template "documents-section" .}} -
+ Phase 4e collapsibles smart-default + localStorage state preserved + exactly: same data-section keys, same proj-section CSS class, same + inline JS. +*/}} -
- - - - - - - - - + + +
+

General

+ + + + +
+ +
+

Classification

+ + +
+ +
+

Flags

+ + +
+ +
+

Content

+ +
- Public listing {{if .Item.Public}}(on){{end}} -
- 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.

- - - - -
@@ -136,9 +134,44 @@ + + + +
+

Related

+ + {{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}} +
+ Tasks {{if $tasksOpen}}(open){{end}} + {{template "tasks-section" .}} +
+ {{end}} + + {{if and .GiteaOn .Issues}} + {{$open := le .IssuesOpenTotal 10}} +
+ Issues ({{.IssuesOpenTotal}} open) + {{template "issues-section" .}} +
+ {{end}} + + {{$docOpen := le (len .Documents) 5}} +
+ Documents ({{len .Documents}}) + {{template "documents-section" .}} +
+ +

+ reset section state +

+
+