fix(panel): open an application's panels under its row of controls
test / test (push) Has been cancelled

Mode and rate limit were <details>, so each opened where its own toggle sat
and split the row of four controls in two, pushing New password and Delete
below a block of fields. The toggle is now a hidden checkbox with its label
drawn as the button and the panel is the last child of the row, so the
controls keep their places and the fields are laid out beneath all of them.
Pure CSS, so it still works with JavaScript blocked, as the disclosure did.

The panel buttons take back the form spacing the compact row style zeroes
out, and Save limit and Remove limit share one row: two posts mean two
forms, so the first button is bound to its form by the form attribute
instead of sitting inside it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
mixeme
2026-08-07 22:23:28 +03:00
parent db56fcc3f3
commit a8e28e1921
3 changed files with 91 additions and 34 deletions
+13
View File
@@ -45,6 +45,19 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
wrapping rows it used to be — no drawer and no hamburger, since six entries
fit. The markup now lists the blocks in the order they are drawn, so the tab
order follows the eye instead of starting at Sign out.
- An application's mode and rate-limit fields open under its row of controls
instead of inside it. Both panels were `<details>`, so each opened where its
own toggle sat and cut the row of four in half, pushing New password and
Delete below a block of fields — the buttons moved every time a panel was
opened or closed. The toggle is now a hidden checkbox with its label drawn as
the button, and the panel is the last child of the row, so the four controls
keep their places and what a panel reveals is laid out beneath all of them.
It stays keyboard-reachable and, being pure CSS, still works with JavaScript
blocked, as the disclosure did. Inside a panel the submit buttons take the
ordinary form spacing back from the compact row style that was leaving them
flush against the field above, and Save limit and Remove limit — two posts,
hence two forms — share one row, the first button bound to its form by the
`form` attribute rather than by sitting inside it.
## [0.5.0] - 2026-08-06
+40 -11
View File
@@ -82,7 +82,7 @@ input {
button: a <button>, or an <a> carrying .btn/.danger where the action is a
plain navigation (the delete confirmation page, the full queue view).
Several of these used to render as bold blue text instead — a POST wrapped
in form.inline, a <details> toggle, the delete links — which read as links
in form.inline, a disclosure toggle, the delete links — which read as links
and left two appearances for the same kind of control. They all get the
button look now: filled for a card's own action, and the compact outlined
variant further down where actions cluster (table rows, the nav bar). Bare
@@ -154,10 +154,9 @@ textarea { resize: vertical; }
button.danger, a.danger { background: #b42318; }
button.danger:hover, a.danger:hover { background: #912018; }
/* The disclosure toggle is an action too, so it is drawn as a button (see the
compact rule below); the marker is dropped because the pressed background
compact rule below); it carries no marker because the pressed background
already shows the open state. */
.actions summary { display: inline-block; list-style: none; cursor: pointer; }
.actions summary::-webkit-details-marker { display: none; }
.actions > label.toggle { display: inline-block; cursor: pointer; }
/* Applications are a list of blocks, not table rows. As a table it fell apart:
four columns of which the last held six controls — two of them <details>
panels with textareas — never fit the panel's default width. The controls
@@ -171,10 +170,41 @@ button.danger:hover, a.danger:hover { background: #912018; }
.app-login { margin: 0; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-weight: 600; }
.app-addr { margin: 0.15rem 0 0; word-break: break-all; }
.app .actions { display: flex; flex-wrap: wrap; gap: 0.4rem; margin-top: 0.7rem; }
/* An open panel claims a row to itself: its textareas and number inputs want
the block's full width, not the width of the summary that opened them. */
.app .actions > details[open] { flex: 1 0 100%; }
details form { margin-top: 0.6rem; }
/* Mode and rate limit open under the whole row rather than inside it. As
<details> the panel sat where its summary was, so opening one cut the row of
four controls in half and pushed the rest below a block of fields — the
buttons moved every time a panel opened or closed. The panels are therefore
the last children of the row, and the checkbox that opens each one is
visually hidden earlier in the row with its label drawn as the button. The
checkbox stays in the tab order and keeps its focus ring on the label, so it
works from the keyboard, and being pure CSS it also works with JavaScript
blocked, as <details> did. */
.app .actions > .panel-toggle {
position: absolute; width: 1px; height: 1px; margin: 0; opacity: 0; pointer-events: none;
}
.app .actions > .panel { display: none; flex: 1 0 100%; }
.app .actions > .t-mode:checked ~ .panel-mode,
.app .actions > .t-limit:checked ~ .panel-limit { display: block; }
.app .actions > .t-mode:checked ~ .for-mode,
.app .actions > .t-limit:checked ~ .for-limit { background: var(--surface-open-bg); }
.app .actions > .t-mode:focus-visible ~ .for-mode,
.app .actions > .t-limit:focus-visible ~ .for-limit { outline: 2px solid #2563eb; outline-offset: 2px; }
.panel form { margin-top: 0.6rem; }
/* A panel's own submit is a form button, not one of the controls in the row
above, so it takes back the spacing the compact .actions rule zeroes out:
without it Save sits flush against the field it saves and against the
Remove button under it, reading as one stack of edges. */
.app .actions > .panel button { margin-top: 0.9rem; }
/* Where a panel has two of them (save the limit, remove it) they share one row
under the fields, which the row itself spaces off instead of each button.
The buttons align on their tops rather than stretching: one of the two is
wrapped in a form, and that wrapper's margin would otherwise make the row
taller than a button and stretch the unwrapped one to match it. */
.app .actions > .panel .panel-buttons {
display: flex; flex-wrap: wrap; align-items: flex-start; gap: 0.4rem; margin-top: 0.9rem;
}
.app .actions > .panel .panel-buttons button,
.app .actions > .panel .panel-buttons form { margin-top: 0; }
.credential { border-color: var(--credential-border); background: var(--credential-bg); }
/* Panel navigation: rendered once from the layout, so it is present on every
authenticated page without each content template having to include it. */
@@ -321,15 +351,14 @@ meter { width: 5rem; height: 0.7rem; vertical-align: middle; margin-right: 0.4re
overrides this with .danger below since signing out is a deliberate,
singular action. .actions is the shared hook: a cell that holds controls, or
the control row of an application. */
button.copy, .actions button, .actions summary, .actions a.danger, .nav button {
button.copy, .actions button, .actions > label.toggle, .actions a.danger, .nav button {
margin: 0; padding: 0.45rem 0.7rem; font-size: 0.8rem; font-weight: 600;
border-radius: 6px; white-space: nowrap;
background: var(--surface-bg); color: #2563eb; border: 1px solid var(--control-border);
}
button.copy:hover, .actions button:hover, .actions summary:hover,
button.copy:hover, .actions button:hover, .actions > label.toggle:hover,
.actions a.danger:hover, .nav button:hover { background: var(--surface-bg-hover); }
button.copy { flex: none; margin-top: 0.3rem; }
.actions details[open] > summary { background: var(--surface-open-bg); }
.actions button.danger, .actions a.danger, .nav button.danger {
color: var(--danger-fg); background: var(--danger-bg); border-color: var(--danger-border);
}
+38 -23
View File
@@ -188,9 +188,26 @@
{{if eq .AddressMode $.Wildcard}}Any address of the domain — *@{{$.Domain.Name}}
{{else}}Fixed list — {{range $i, $a := .Addresses}}{{if $i}}, {{end}}{{$a}}{{end}}{{end}}
</p>
<!-- The two panels are opened by a checkbox and a label rather than by
<details>, so that the controls stay a single row of buttons and
what a panel reveals is laid out under all four of them instead of
splitting the row in two. The checkbox precedes both its label and
its panel, which is what lets the CSS alone show them (no script
involved) — see .panel-toggle in panel.css. -->
<div class="actions">
<details>
<summary>Edit mode</summary>
<input class="panel-toggle t-mode" id="mode-{{.ID}}" type="checkbox">
<label class="toggle for-mode" for="mode-{{.ID}}">Edit mode</label>
<input class="panel-toggle t-limit" id="limit-{{.ID}}" type="checkbox">
<label class="toggle for-limit" for="limit-{{.ID}}">Rate limit{{if .HasLimit}} (active){{end}}</label>
<form class="inline" method="post" action="/applications/{{.ID}}/password"
data-confirm="Regenerate the password for {{.Login}}? The current password stops working immediately.">
<button type="submit">New password</button>
</form>
<form class="inline" method="post" action="/applications/{{.ID}}/delete"
data-confirm="Delete application {{.Login}}? Its credentials stop working immediately.">
<button type="submit" class="danger">Delete</button>
</form>
<div class="panel panel-mode">
<form method="post" action="/applications/{{.ID}}/mode">
<label>Address mode</label>
<select name="mode" data-list-mode="{{$.List}}">
@@ -204,34 +221,32 @@
</div>
<button type="submit">Save mode</button>
</form>
</details>
<details>
<summary>Rate limit{{if .HasLimit}} (active){{end}}</summary>
<form method="post" action="/applications/{{.ID}}/ratelimit">
</div>
<div class="panel panel-limit">
<form id="rl-{{.ID}}" method="post" action="/applications/{{.ID}}/ratelimit">
<label>Expected client IPs (one per line or comma-separated)</label>
<textarea name="allowed_ips" rows="2" placeholder="203.0.113.10">{{.IPsText}}</textarea>
<label>Message limit</label>
<input name="max_messages" type="number" min="1" value="{{.MaxText}}" placeholder="500">
<label>Window (seconds)</label>
<input name="window_seconds" type="number" min="1" value="{{.WindowVal}}">
<button type="submit">Save limit</button>
</form>
{{if .HasLimit}}
<form class="inline" method="post" action="/applications/{{.ID}}/ratelimit"
data-confirm="Remove the rate limit for {{.Login}}? Only the global level-1 limit will apply.">
<input type="hidden" name="clear" value="1">
<button type="submit" class="danger">Remove limit</button>
</form>
{{end}}
</details>
<form class="inline" method="post" action="/applications/{{.ID}}/password"
data-confirm="Regenerate the password for {{.Login}}? The current password stops working immediately.">
<button type="submit">New password</button>
</form>
<form class="inline" method="post" action="/applications/{{.ID}}/delete"
data-confirm="Delete application {{.Login}}? Its credentials stop working immediately.">
<button type="submit" class="danger">Delete</button>
</form>
<!-- Saving and removing the limit are two posts, so they are two
forms and could not share a line while the Save button sat
inside the block form above. It is bound to that form by id
instead (the form attribute, no script involved), which lets
both buttons stand together in one row under the fields. -->
<div class="panel-buttons">
<button type="submit" form="rl-{{.ID}}">Save limit</button>
{{if .HasLimit}}
<form class="inline" method="post" action="/applications/{{.ID}}/ratelimit"
data-confirm="Remove the rate limit for {{.Login}}? Only the global level-1 limit will apply.">
<input type="hidden" name="clear" value="1">
<button type="submit" class="danger">Remove limit</button>
</form>
{{end}}
</div>
</div>
</div>
</li>
{{end}}