phone-desktop-mode: a Menu with folded sections below 1024 px
The owner, 2026-10-16: "Вариант 1. Но меню выглядит длинной простынёй. Там
бы сделать сворачивание разделов." As the mockup of e0e30e2: the bar's
navbar-menu loses is-active, so Bulma hides it below 1024 px, and the new
touchnav partial (layout.html) takes its place in the brand - Menu, a
<details> whose panel holds Overview, each group as a fold (the current
one open, its page aria-current), and the user's group last with Account,
Help and Sign out (the same POST form as the bar's). The same Show* flags
as the bar decide what is there. panel.css takes the mockup's rules (the
touch navigation, a 1rem gutter for the page and the sibling strip below
1024 px). No script.
The cause, measured, is written in the roadmap row: every width under
1024 px had the always-open menu; "desktop site" (~980 px) added Bulma's
side-by-side columns from 769 px; Bulma's container has no side margin
below 1024 px.
Tests: the Menu marks the current group and page and the bar's menu has
no is-active (TestMenuHighlightsTheCurrentGroupEntryAndSibling), it offers
nothing the bar does not to a domain administrator (TestTouchMenuFollowsTheBar),
the shell's Sign out is counted twice on the password page;
TestPanelCSSIsTheMockupStylesheet. go vet + go test (26 packages) and make
e2e on the test server for this tree. CHANGELOG [Unreleased]; roadmap row
done.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -5,6 +5,16 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Changed
|
||||
|
||||
- **A narrow screen gets a Menu** (phone-desktop-mode): below 1024 px - a
|
||||
phone, a phone in "desktop site" mode, a tablet, a narrow window - the
|
||||
navigation was a sheet of every section and page, always open, filling
|
||||
the first screen. It is now *Menu* at the right of the header, which opens
|
||||
the sections as folds, the current one open, the account's entries last;
|
||||
no script. The page no longer meets the screen's edge. From 1024 px
|
||||
nothing changes.
|
||||
|
||||
## [2.10.1] - 2026-10-16
|
||||
|
||||
Postfix's queue ids no longer repeat. No migration; schema 12.
|
||||
|
||||
+18
-1
@@ -47,7 +47,7 @@ in `git log` and [CHANGELOG.md](../CHANGELOG.md).
|
||||
| port25-bare-newline | The inbound relay's port 25 refuses bare newlines in DATA (`smtpd_forbid_bare_newline=normalize`) | shipped `2.6.1` | — | — |
|
||||
| base-trixie | The image's base from Debian bookworm (LTS since 2026-07-12) to trixie: Postfix 3.10 with the 2026 security batches | shipped `2.7.0` | — | — |
|
||||
| panel-dkim-signing | DKIM signing in the panel's own milter; OpenDKIM, the syslog relay and the parser port removed | shipped `2.8.0` | 14/14 | [plans/panel-dkim-signing.md](plans/panel-dkim-signing.md) |
|
||||
| phone-desktop-mode | A phone browser in "desktop site" mode gets the phone navigation | agreed (2026-10-13: after stages 1 and 2 of audit-2026-10-12) | — | — |
|
||||
| phone-desktop-mode | A phone browser in "desktop site" mode gets the phone navigation | done 2026-10-16, unreleased | — | — |
|
||||
| from-header-binding | Hold a message's `From:` header against the application that sends it | shipped `2.4.0` | 9/9 | [plans/from-header-binding.md](plans/from-header-binding.md) |
|
||||
| panel-redesign | Panel redesign (accepted mockups, design contract) | shipped `2.0.0` | 27/27 | [plans/panel-redesign.md](plans/panel-redesign.md) |
|
||||
| schema-squash | Squash SQLite migrations into a 2.x baseline | built in panel-redesign (stage 1) | — | [plans/panel-redesign.md](plans/panel-redesign.md) |
|
||||
@@ -526,6 +526,23 @@ expected there. A design change is mockup-first
|
||||
has one coherent layout, checked on a phone in "desktop site" mode and in a
|
||||
980 px window.
|
||||
|
||||
**Cause (2026-10-16):** not the mode - every width under 1024 px. The bar's
|
||||
menu was always open (`navbar-menu is-active`, and no burger to close it),
|
||||
and Bulma lays a navbar menu out below 1024 px as a block with every
|
||||
dropdown open: on any phone the sections and all their pages filled the
|
||||
first screen. "Desktop site" makes the page about 980 px wide, where Bulma
|
||||
already puts columns side by side (from 769 px) but not yet the bar - the
|
||||
mix in the screenshot. And a Bulma `.container` has side margins only from
|
||||
1024 px, so below it the page met the screen's edge.
|
||||
|
||||
**Done (2026-10-16):** the owner chose "Вариант 1" - a menu behind a button
|
||||
- with "сворачивание разделов". Below 1024 px the bar's menu is hidden and
|
||||
*Menu* at the right of the brand opens a panel over the page: Overview, each
|
||||
section a fold with the current one open, the user's last (Account, Help,
|
||||
Sign out); `<details>` all through, no script. The page and the strip of
|
||||
sibling pages have a 1rem gutter. From 1024 px nothing changed. Mockup
|
||||
`e0e30e2`.
|
||||
|
||||
**Version:** `2.x` PATCH or MINOR; `agreed` (owner, 2026-10-13), taken up
|
||||
after stages 1 and 2 of [audit-2026-10-12](audit-2026-10-12.md) - mail shown
|
||||
wrong and the tenant boundary come before layout.
|
||||
|
||||
@@ -496,9 +496,18 @@ func TestMenuHighlightsTheCurrentGroupEntryAndSibling(t *testing.T) {
|
||||
if n := strings.Count(out, "sp-current"); n != 1 {
|
||||
t.Errorf("%d current groups, want 1", n)
|
||||
}
|
||||
if n := strings.Count(out, "aria-current"); n != 1 {
|
||||
t.Errorf("%d current siblings, want 1", n)
|
||||
// The current page is marked twice: in the strip of siblings and in the
|
||||
// Menu of a narrow screen, whose current group is the one open.
|
||||
if n := strings.Count(out, "aria-current"); n != 2 {
|
||||
t.Errorf("%d current entries, want the sibling strip's and the Menu's", n)
|
||||
}
|
||||
mustContain(t, out,
|
||||
`<details class="sp-touchnav"><summary>Menu</summary>`,
|
||||
`<details class="sp-touchnav-group" open><summary>Outbound</summary>`,
|
||||
`<a href="/outbound/log" aria-current="page"><i class="ti ti-list-details"></i>Log</a>`,
|
||||
`<details class="sp-touchnav-group"><summary>Inbound</summary>`,
|
||||
`<div class="navbar-menu">`)
|
||||
mustNotContain(t, out, `navbar-menu is-active`)
|
||||
|
||||
// The user menu is a group of its own and has no sibling strip.
|
||||
out = renderShell(t, kitEngine(t), Meta{Title: "t", User: "admin", IsGlobal: true, Section: "user", Page: "account"})
|
||||
@@ -613,3 +622,18 @@ func TestPollAndOOBAttributes(t *testing.T) {
|
||||
got = partial(t, "page_head", Head{Title: "T", ID: "h", OOB: true})
|
||||
mustContain(t, got, `<div class="sp-head" id="h" hx-swap-oob="true">`)
|
||||
}
|
||||
|
||||
// The Menu of a narrow screen offers what the bar offers, no more: a domain
|
||||
// administrator has no Server group and no Overview there either.
|
||||
func TestTouchMenuFollowsTheBar(t *testing.T) {
|
||||
out := renderShell(t, kitEngine(t), Meta{Title: "t", User: "ops", Section: "outbound", Page: "domains"})
|
||||
for _, group := range []string{"Overview", "Server", "Queue", "DMARC reports"} {
|
||||
inBar := strings.Contains(out, `">`+group+`</a>`) || strings.Contains(out, `>`+group+`</a>`)
|
||||
inMenu := strings.Contains(out, `<summary>`+group+`</summary>`) || strings.Contains(out, `></i>`+group+`</a>`) || strings.Contains(out, `<a href="/overview">`+group)
|
||||
if inMenu && !inBar {
|
||||
t.Errorf("the Menu offers %s, which the bar does not", group)
|
||||
}
|
||||
}
|
||||
mustContain(t, out, `<summary><i class="ti ti-user-circle"></i>ops</summary>`, `<form method="post" action="/logout"><button type="submit"><i class="ti ti-logout"></i>Sign out</button></form>`)
|
||||
mustNotContain(t, out, `<summary>Server</summary>`)
|
||||
}
|
||||
|
||||
@@ -408,9 +408,10 @@ func TestOutAppCreatedShowsLoginAndPasswordOnce(t *testing.T) {
|
||||
if got := strings.Count(out, `data-copy`); got != 2 {
|
||||
t.Errorf("%d copy buttons, want one for the login and one for the password", got)
|
||||
}
|
||||
// nothing on the page changes anything: Done is a link, so the only form is the shell's Sign out
|
||||
if got := strings.Count(out, `<form`); got != 1 || !strings.Contains(out, `action="/logout"`) {
|
||||
t.Errorf("%d forms on the password page, want the shell's Sign out alone", got)
|
||||
// nothing on the page changes anything: Done is a link, so the only forms
|
||||
// are the shell's Sign out - in the bar and in the Menu of a narrow screen
|
||||
if got := strings.Count(out, `<form`); got != 2 || strings.Count(out, `<form method="post" action="/logout">`) != 2 {
|
||||
t.Errorf("%d forms on the password page, want the shell's two Sign outs alone", got)
|
||||
}
|
||||
|
||||
// An application created just now had no password before.
|
||||
|
||||
@@ -130,6 +130,27 @@ code { background: var(--sp-well); color: var(--sp-ink); border-radius: 3px; pad
|
||||
.navbar-dropdown button.navbar-item:hover, .navbar-dropdown button.navbar-item:focus { --bulma-navbar-item-background-l-delta: var(--bulma-navbar-item-hover-background-l-delta); }
|
||||
.navbar-dropdown button.navbar-item:active { --bulma-navbar-item-background-l-delta: var(--bulma-navbar-item-active-background-l-delta); }
|
||||
.navbar.is-primary .sp-current > .navbar-link, .navbar.is-primary a.navbar-item.sp-current { background: rgb(0 0 0 / .18); }
|
||||
/* Below 1024 px Bulma hides the bar's menu (navbar-menu without is-active)
|
||||
and the touch navigation takes its place: Menu at the right of the brand
|
||||
opens a panel over the page, its sections folded but the current one.
|
||||
<details> all through, so it needs no script (owner, 2026-10-16: the menu
|
||||
had been a sheet of every section open, filling the first screen). */
|
||||
.sp-touchnav { display: none; }
|
||||
.sp-touchnav summary { list-style: none; cursor: pointer; }
|
||||
.sp-touchnav summary::-webkit-details-marker { display: none; }
|
||||
.sp-touchnav summary::after { content: "›"; display: inline-block; margin-left: .5rem; font-size: 1.3rem; line-height: 1; transform: rotate(90deg); transition: transform .15s; }
|
||||
.sp-touchnav[open] > summary::after, .sp-touchnav-group[open] > summary::after { transform: rotate(-90deg); }
|
||||
.sp-touchnav > summary { display: flex; align-items: center; padding: 0 1rem; color: var(--sp-on-brick); font-weight: 500; }
|
||||
.sp-touchnav-panel { position: absolute; top: 100%; left: 0; right: 0; z-index: 30; background: var(--sp-brick); box-shadow: 0 12px 28px rgb(18 22 28 / .22); padding-bottom: .4rem; }
|
||||
.sp-touchnav-panel > a, .sp-touchnav-group > summary { display: flex; align-items: center; gap: .45rem; padding: .75rem 1rem; color: var(--sp-on-brick); font-weight: 500; }
|
||||
.sp-touchnav-group > summary::after { margin-left: auto; }
|
||||
.sp-touchnav-panel > a[aria-current], .sp-touchnav-group[open] > summary { background: rgb(0 0 0 / .18); }
|
||||
.sp-touchnav-group a, .sp-touchnav-group button { display: flex; align-items: center; gap: .6rem; padding: .65rem 1rem .65rem 2rem; background: #fff; color: var(--sp-ink); border: 0; font: inherit; text-align: left; cursor: pointer; }
|
||||
.sp-touchnav-group form { display: flex; }
|
||||
.sp-touchnav-group button { flex: 1; }
|
||||
.sp-touchnav-group .ti { color: var(--sp-muted); }
|
||||
.sp-touchnav-group a[aria-current] { color: var(--sp-brick); font-weight: 600; background: var(--sp-brick-tint); }
|
||||
.sp-touchnav-group a[aria-current] .ti { color: var(--sp-brick); }
|
||||
/* The header is built like the mark: a brick field, then the stamp's paper
|
||||
margin, and the perforation runs along the outer edge of that margin — round
|
||||
holes, flat teeth, the mark's ink hairline (perf-edge.svg is one tooth and
|
||||
@@ -334,7 +355,13 @@ details.sp-record[open] .sp-record-toggle span + span { display: inline; }
|
||||
.sp-signin-form .sp-foot { text-align: center; margin-top: 2.5rem; }
|
||||
|
||||
@media (max-width: 1407px) { .sp-health { grid-template-columns: repeat(3, 1fr); } }
|
||||
@media (max-width: 1023px) { .sp-box-head form.sp-end { flex-wrap: wrap; } }
|
||||
@media (max-width: 1023px) {
|
||||
.sp-box-head form.sp-end { flex-wrap: wrap; }
|
||||
.sp-touchnav { display: flex; margin-left: auto; }
|
||||
/* Bulma gives a container side margins only from 1024 px: below it the
|
||||
page met the screen's edge. */
|
||||
.sp-subnav .container, .container.sp-page { padding-inline: 1rem; }
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.sp-health { grid-template-columns: repeat(2, 1fr); }
|
||||
.sp-row, .sp-pair { grid-template-columns: 1fr; }
|
||||
|
||||
@@ -92,11 +92,13 @@
|
||||
|
||||
{{/* nav is the navbar and the perforated edge under it (input: Shell). A group
|
||||
with nothing in it for this user is absent; the group of the current page
|
||||
carries sp-current and its current entry is-active. */}}
|
||||
carries sp-current and its current entry is-active. The bar's menu shows
|
||||
from 1024 px (Bulma hides a navbar-menu without is-active below it);
|
||||
below, touchnav takes its place. */}}
|
||||
{{define "nav"}}<nav class="navbar is-primary" aria-label="main navigation">
|
||||
<div class="container">
|
||||
<div class="navbar-brand"><a class="navbar-item" href="{{.Home}}"><img src="/static/wordmark.svg" alt="SelfPost"></a></div>
|
||||
<div class="navbar-menu is-active">
|
||||
<div class="navbar-brand"><a class="navbar-item" href="{{.Home}}"><img src="/static/wordmark.svg" alt="SelfPost"></a>{{template "touchnav" .}}</div>
|
||||
<div class="navbar-menu">
|
||||
<div class="navbar-start">
|
||||
{{if .ShowOverview}} <a class="navbar-item{{if eq .Section "overview"}} sp-current{{end}}" href="/overview">Overview</a>
|
||||
{{end}}{{if .ShowOutbound}} <div class="navbar-item has-dropdown is-hoverable{{if eq .Section "outbound"}} sp-current{{end}}">
|
||||
@@ -149,6 +151,18 @@
|
||||
</nav>
|
||||
<div class="sp-perf" aria-hidden="true"></div>{{end}}
|
||||
|
||||
{{/* touchnav is the navigation below 1024 px (input: Shell; mockup
|
||||
build.py, touchnav()): Menu opens a panel over the page with the same
|
||||
groups as the bar, each a fold - the current one open - and the user's
|
||||
last. <details> all through, so it works without a script. */}}
|
||||
{{define "touchnav"}}<details class="sp-touchnav"><summary>Menu</summary><div class="sp-touchnav-panel">
|
||||
{{if .ShowOverview}}<a href="/overview"{{if eq .Section "overview"}} aria-current="page"{{end}}>Overview</a>
|
||||
{{end}}{{if .ShowOutbound}}<details class="sp-touchnav-group"{{if eq .Section "outbound"}} open{{end}}><summary>Outbound</summary><a href="/outbound/domains"{{if and (eq .Section "outbound") (eq .Page "domains")}} aria-current="page"{{end}}><i class="ti ti-world-upload"></i>Domains</a><a href="/outbound/log"{{if and (eq .Section "outbound") (eq .Page "log")}} aria-current="page"{{end}}><i class="ti ti-list-details"></i>Log</a>{{if .ShowQueue}}<a href="/outbound/queue"{{if and (eq .Section "outbound") (eq .Page "queue")}} aria-current="page"{{end}}><i class="ti ti-stack-2"></i>Queue</a>{{end}}{{if .ShowDMARC}}<a href="/outbound/dmarc"{{if and (eq .Section "outbound") (eq .Page "dmarc")}} aria-current="page"{{end}}><i class="ti ti-report-analytics"></i>DMARC reports</a>{{end}}</details>
|
||||
{{end}}{{if .ShowInbound}}<details class="sp-touchnav-group"{{if eq .Section "inbound"}} open{{end}}><summary>Inbound</summary><a href="/inbound/domains"{{if and (eq .Section "inbound") (eq .Page "domains")}} aria-current="page"{{end}}><i class="ti ti-world-download"></i>Domains</a><a href="/inbound/log"{{if and (eq .Section "inbound") (eq .Page "log")}} aria-current="page"{{end}}><i class="ti ti-list-details"></i>Log</a>{{if .ShowFilterLists}}<a href="/inbound/filter-lists"{{if and (eq .Section "inbound") (eq .Page "filter-lists")}} aria-current="page"{{end}}><i class="ti ti-filter"></i>Filter lists</a>{{end}}</details>
|
||||
{{end}}{{if .ShowServer}}<details class="sp-touchnav-group"{{if eq .Section "server"}} open{{end}}><summary>Server</summary><a href="/server/health"{{if and (eq .Section "server") (eq .Page "health")}} aria-current="page"{{end}}><i class="ti ti-heartbeat"></i>Health</a><a href="/server/log"{{if and (eq .Section "server") (eq .Page "log")}} aria-current="page"{{end}}><i class="ti ti-file-text"></i>System log</a><a href="/server/backup"{{if and (eq .Section "server") (eq .Page "backup")}} aria-current="page"{{end}}><i class="ti ti-archive"></i>Backup</a><a href="/server/users"{{if and (eq .Section "server") (eq .Page "users")}} aria-current="page"{{end}}><i class="ti ti-users"></i>Users</a><a href="/server/settings"{{if and (eq .Section "server") (eq .Page "settings")}} aria-current="page"{{end}}><i class="ti ti-settings"></i>Settings</a></details>
|
||||
{{end}}<details class="sp-touchnav-group"{{if eq .Section "user"}} open{{end}}><summary><i class="ti ti-user-circle"></i>{{.User}}</summary><a href="/account"><i class="ti ti-key"></i>Account</a><a href="/help"><i class="ti ti-help"></i>Help</a><form method="post" action="/logout"><button type="submit"><i class="ti ti-logout"></i>Sign out</button></form></details>
|
||||
</div></details>{{end}}
|
||||
|
||||
{{/* subnav is the strip of sibling pages under the edge (input: Shell): the
|
||||
pages of the current group, the current one marked. A page outside every
|
||||
group (the user menu, the kit page's neighbours) has none. */}}
|
||||
|
||||
Reference in New Issue
Block a user