style: GUI polish — visibility-aware polling, CSS vars for dark mode (code-review.md § Phase 2)

- panel.js: skip HTMX polling requests while the tab is hidden, via
  htmx:beforeRequest rather than htmx's eval-based trigger filter (the
  panel's CSP allows no unsafe-eval).
- panel.css: replace all dark-mode !important overrides with CSS custom
  properties reassigned once under prefers-color-scheme: dark.
- panel.css: consolidate the duplicate main{max-width} rule.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-06 16:57:05 +03:00
parent 6a7d010868
commit 0093878eea
2 changed files with 94 additions and 72 deletions
+78 -72
View File
@@ -4,18 +4,57 @@
added here must therefore stay here: an inline style="..." attribute in a added here must therefore stay here: an inline style="..." attribute in a
template is blocked by that policy and silently does nothing. */ template is blocked by that policy and silently does nothing. */
:root { color-scheme: light dark; } /* Colour tokens. Light values live on :root; the dark media query below
reassigns the same names rather than re-declaring every rule that uses
them, so a rule needs !important nowhere in this file — the custom
property already carries the right value for the active scheme. */
:root {
color-scheme: light dark;
--bg: #f6f7f9; --fg: #1b1f24;
--card-bg: #fff;
--border: #e2e5e9; /* dividers: card, table, app, nav, code, encrypt-fields */
--control-border: #cfd4da; /* input, select, textarea, action buttons */
--input-bg: #fff;
--code-bg: #f0f2f4;
--surface-bg: #eef1f5; --surface-bg-hover: #e2e7ee; --surface-open-bg: #dde3ec;
--nav-active-bg: #e6ebf5;
--flash-bg: #ecfdf3; --flash-border: #abefc6; --flash-fg: #067647;
--credential-bg: #fffbeb; --credential-border: #f5c518;
--danger-bg: #fef3f2; --danger-border: #fecdca; --danger-fg: #b42318; --danger-bg-hover: #fee4e2;
--st-ok-bg: #ecfdf3; --st-ok-fg: #067647; --st-ok-border: #abefc6;
--st-warn-bg: #fffaeb; --st-warn-fg: #b54708; --st-warn-border: #fedf89;
--st-error-bg: #fef3f2; --st-error-fg: #b42318; --st-error-border: #fecdca;
--st-unknown-bg: #f0f2f4; --st-unknown-fg: #6b7280; --st-unknown-border: #e2e5e9;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #14171a; --fg: #e6e8eb;
--card-bg: #1d2125;
--border: #2b3138;
--control-border: #2b3138;
--input-bg: #14171a;
--code-bg: #14171a;
--surface-bg: #22262b; --surface-bg-hover: #2b3138; --surface-open-bg: #313841;
--nav-active-bg: #22303f;
--flash-bg: #0d2818; --flash-border: #1a5336; --flash-fg: #75d99b;
--credential-bg: #2a2408; --credential-border: #6b5a10;
--danger-bg: #2d1211; --danger-border: #6b201a; --danger-fg: #f5a29b; --danger-bg-hover: #3d1a18;
--st-ok-bg: #0d2818; --st-ok-fg: #75d99b; --st-ok-border: #1a5336;
--st-warn-bg: #2e2308; --st-warn-fg: #f5c86b; --st-warn-border: #6b5210;
--st-error-bg: #2d1211; --st-error-fg: #f5a29b; --st-error-border: #6b201a;
--st-unknown-bg: #22262b; --st-unknown-fg: #9aa3ad; --st-unknown-border: #2b3138;
}
}
* { box-sizing: border-box; } * { box-sizing: border-box; }
body { body {
font: 15px/1.5 system-ui, -apple-system, Segoe UI, Roboto, sans-serif; font: 15px/1.5 system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
margin: 0; padding: 2rem 1rem; background: #f6f7f9; color: #1b1f24; margin: 0; padding: 2rem 1rem; background: var(--bg); color: var(--fg);
} }
@media (prefers-color-scheme: dark) { /* 48rem is a reading measure: right for the forms and prose that make up most
body { background: #14171a; color: #e6e8eb; } of the panel, and the width the navigation bar's two rows were sized for.
.card { background: #1d2125 !important; border-color: #2b3138 !important; } Page-specific overrides below widen or narrow it for the pages that need
input { background: #14171a !important; color: inherit !important; border-color: #2b3138 !important; } something else. */
} main { max-width: 48rem; margin: 0 auto; }
main { max-width: 42rem; margin: 0 auto; }
h1 { font-size: 1.4rem; margin: 0 0 1rem; } h1 { font-size: 1.4rem; margin: 0 0 1rem; }
/* The full mark, on the two pages that have no navigation bar to carry the /* The full mark, on the two pages that have no navigation bar to carry the
compact one. It takes the column's width so its edges line up with the card compact one. It takes the column's width so its edges line up with the card
@@ -25,14 +64,14 @@ h1 { font-size: 1.4rem; margin: 0 0 1rem; }
once the body's padding is taken off. */ once the body's padding is taken off. */
.mark { display: block; width: 100%; max-width: 24rem; height: auto; margin-bottom: 1.4rem; } .mark { display: block; width: 100%; max-width: 24rem; height: auto; margin-bottom: 1.4rem; }
.card { .card {
background: #fff; border: 1px solid #e2e5e9; border-radius: 10px; background: var(--card-bg); border: 1px solid var(--border); border-radius: 10px;
padding: 1.5rem; margin: 0 auto; padding: 1.5rem; margin: 0 auto;
} }
.card.narrow { max-width: 24rem; } .card.narrow { max-width: 24rem; }
label { display: block; font-weight: 600; margin: 0.9rem 0 0.3rem; } label { display: block; font-weight: 600; margin: 0.9rem 0 0.3rem; }
input { input {
width: 100%; padding: 0.55rem 0.7rem; font-size: 1rem; width: 100%; padding: 0.55rem 0.7rem; font-size: 1rem;
border: 1px solid #cfd4da; border-radius: 6px; background: #fff; border: 1px solid var(--control-border); border-radius: 6px; background: var(--input-bg); color: inherit;
} }
/* One vocabulary for actions. Anything that performs an action looks like a /* One vocabulary for actions. Anything that performs an action looks like a
button: a <button>, or an <a> carrying .btn/.danger where the action is a button: a <button>, or an <a> carrying .btn/.danger where the action is a
@@ -56,14 +95,12 @@ button:hover, a.btn:hover { background: #1d4ed8; }
/* form.inline only exists so a POST can sit next to other content without a /* form.inline only exists so a POST can sit next to other content without a
form's block layout; its button is styled like any other. */ form's block layout; its button is styled like any other. */
form.inline { display: inline; margin: 0; } form.inline { display: inline; margin: 0; }
main { max-width: 48rem; } /* The three monitoring pages hold data instead of prose — seven columns of
/* 48rem is a reading measure: right for the forms and prose that make up most send-log, and raw mail.log lines that are long by nature — and at the
of the panel, and the width the navigation bar's two rows were sized for. The default measure the send-log's Subject and Status were fighting over the
three monitoring pages hold data instead — seven columns of send-log, and raw last inch while the log pages wrapped every second line. They get a wider
mail.log lines that are long by nature — and at that width the send-log's measure; every other page keeps the default one. The class comes from the
Subject and Status were fighting over the last inch while the log pages wrapped layout, which stamps the page name onto <main>. */
every second line. They get a wider measure; every other page keeps the narrow
one. The class comes from the layout, which stamps the page name onto <main>. */
main.page-deliveries, main.page-mail_queue, main.page-system_log { max-width: 64rem; } main.page-deliveries, main.page-mail_queue, main.page-system_log { max-width: 64rem; }
/* The signed-out pages are a single card and nothing else, and .card.narrow /* The signed-out pages are a single card and nothing else, and .card.narrow
centres itself inside whatever holds it — so at the panel's usual width the centres itself inside whatever holds it — so at the panel's usual width the
@@ -73,19 +110,17 @@ main.page-deliveries, main.page-mail_queue, main.page-system_log { max-width: 64
the middle of the page. */ the middle of the page. */
main.page-login, main.page-setup { max-width: 24rem; } main.page-login, main.page-setup { max-width: 24rem; }
.card + .card { margin-top: 1.2rem; } .card + .card { margin-top: 1.2rem; }
.flash { background: #ecfdf3; border: 1px solid #abefc6; color: #067647; padding: 0.7rem 1rem; border-radius: 8px; margin-bottom: 1.2rem; } .flash { background: var(--flash-bg); border: 1px solid var(--flash-border); color: var(--flash-fg); padding: 0.7rem 1rem; border-radius: 8px; margin-bottom: 1.2rem; }
@media (prefers-color-scheme: dark) { .flash { background: #0d2818 !important; border-color: #1a5336 !important; color: #75d99b !important; } }
table { width: 100%; border-collapse: collapse; } table { width: 100%; border-collapse: collapse; }
/* A table column is at least as wide as the longest unbreakable run inside it, /* A table column is at least as wide as the longest unbreakable run inside it,
and the panel's tables are full of runs with nothing to break on: email and the panel's tables are full of runs with nothing to break on: email
addresses, domains, queue ids. One 40-character recipient was enough to widen addresses, domains, queue ids. One 40-character recipient was enough to widen
the send-log past its card and hang Status over the edge. Cells may break mid the send-log past its card and hang Status over the edge. Cells may break mid
word, so a column can always be squeezed to the width available. */ word, so a column can always be squeezed to the width available. */
th, td { text-align: left; padding: 0.5rem 0.4rem; border-bottom: 1px solid #e2e5e9; overflow-wrap: anywhere; } th, td { text-align: left; padding: 0.5rem 0.4rem; border-bottom: 1px solid var(--border); overflow-wrap: anywhere; }
/* The exception: a timestamp broken across two lines is unreadable, and it is /* The exception: a timestamp broken across two lines is unreadable, and it is
short enough to never be the reason a row does not fit. */ short enough to never be the reason a row does not fit. */
td.time { white-space: nowrap; } td.time { white-space: nowrap; }
@media (prefers-color-scheme: dark) { th, td { border-color: #2b3138 !important; } }
th { font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.03em; color: #6b7280; } th { font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.03em; color: #6b7280; }
td.actions { text-align: right; } td.actions { text-align: right; }
/* Subject is the one cell whose text we do not control. Breaking mid word (the /* Subject is the one cell whose text we do not control. Breaking mid word (the
@@ -99,8 +134,7 @@ td.subject span {
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
} }
.code { display: block; white-space: pre-wrap; word-break: break-all; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; .code { display: block; white-space: pre-wrap; word-break: break-all; font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 0.85rem; background: #f0f2f4; border: 1px solid #e2e5e9; border-radius: 6px; padding: 0.7rem 0.8rem; margin: 0.3rem 0 0; } font-size: 0.85rem; background: var(--code-bg); border: 1px solid var(--border); border-radius: 6px; padding: 0.7rem 0.8rem; margin: 0.3rem 0 0; }
@media (prefers-color-scheme: dark) { .code { background: #14171a !important; border-color: #2b3138 !important; } }
h2 { font-size: 1.05rem; margin: 0 0 0.4rem; } h2 { font-size: 1.05rem; margin: 0 0 0.4rem; }
.back { display: inline-block; margin-bottom: 1rem; } .back { display: inline-block; margin-bottom: 1rem; }
/* Build version, closing every authenticated page. Quiet on purpose: it is /* Build version, closing every authenticated page. Quiet on purpose: it is
@@ -108,13 +142,10 @@ h2 { font-size: 1.05rem; margin: 0 0 0.4rem; }
.version { margin-top: 1.6rem; text-align: right; font-size: 0.8rem; color: #6b7280; } .version { margin-top: 1.6rem; text-align: right; font-size: 0.8rem; color: #6b7280; }
select, textarea { select, textarea {
width: 100%; padding: 0.55rem 0.7rem; font-size: 1rem; width: 100%; padding: 0.55rem 0.7rem; font-size: 1rem;
border: 1px solid #cfd4da; border-radius: 6px; background: #fff; color: inherit; border: 1px solid var(--control-border); border-radius: 6px; background: var(--input-bg); color: inherit;
font-family: inherit; font-family: inherit;
} }
textarea { resize: vertical; } textarea { resize: vertical; }
@media (prefers-color-scheme: dark) {
select, textarea { background: #14171a !important; color: inherit !important; border-color: #2b3138 !important; }
}
button.danger, a.danger { background: #b42318; } button.danger, a.danger { background: #b42318; }
button.danger:hover, a.danger:hover { background: #912018; } button.danger:hover, a.danger:hover { background: #912018; }
/* The disclosure toggle is an action too, so it is drawn as a button (see the /* The disclosure toggle is an action too, so it is drawn as a button (see the
@@ -124,15 +155,14 @@ button.danger:hover, a.danger:hover { background: #912018; }
.actions summary::-webkit-details-marker { display: none; } .actions summary::-webkit-details-marker { display: none; }
/* Applications are a list of blocks, not table rows. As a table it fell apart: /* 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> four columns of which the last held six controls — two of them <details>
panels with textareas — never fit the panel's 48rem. The controls wrapped panels with textareas — never fit the panel's default width. The controls
into a staircase, .code on the login cell grew into a slab as tall as the wrapped into a staircase, .code on the login cell grew into a slab as tall
row, and the two text cells sat on the baseline halfway down it. One block as the row, and the two text cells sat on the baseline halfway down it. One
per application gives the identity a line of its own and the controls a row block per application gives the identity a line of its own and the
of their own, at the width they actually need. */ controls a row of their own, at the width they actually need. */
.apps { list-style: none; margin: 1.2rem 0 0; padding: 0; } .apps { list-style: none; margin: 1.2rem 0 0; padding: 0; }
.app { padding: 0.9rem 0; border-top: 1px solid #e2e5e9; } .app { padding: 0.9rem 0; border-top: 1px solid var(--border); }
.app:last-child { padding-bottom: 0; } .app:last-child { padding-bottom: 0; }
@media (prefers-color-scheme: dark) { .app { border-color: #2b3138 !important; } }
.app-login { margin: 0; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-weight: 600; } .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-addr { margin: 0.15rem 0 0; word-break: break-all; }
.app .actions { display: flex; flex-wrap: wrap; gap: 0.4rem; margin-top: 0.7rem; } .app .actions { display: flex; flex-wrap: wrap; gap: 0.4rem; margin-top: 0.7rem; }
@@ -140,8 +170,7 @@ button.danger:hover, a.danger:hover { background: #912018; }
the block's full width, not the width of the summary that opened them. */ the block's full width, not the width of the summary that opened them. */
.app .actions > details[open] { flex: 1 0 100%; } .app .actions > details[open] { flex: 1 0 100%; }
details form { margin-top: 0.6rem; } details form { margin-top: 0.6rem; }
.credential { border-color: #f5c518; background: #fffbeb; } .credential { border-color: var(--credential-border); background: var(--credential-bg); }
@media (prefers-color-scheme: dark) { .credential { background: #2a2408 !important; border-color: #6b5a10 !important; } }
/* Panel navigation: rendered once from the layout, so it is present on every /* Panel navigation: rendered once from the layout, so it is present on every
authenticated page without each content template having to include it. */ authenticated page without each content template having to include it. */
/* Two deliberate rows, not one. The six page entries need about 660px and the /* Two deliberate rows, not one. The six page entries need about 660px and the
@@ -159,9 +188,8 @@ details form { margin-top: 0.6rem; }
.nav { .nav {
display: flex; flex-direction: column; align-items: stretch; display: flex; flex-direction: column; align-items: stretch;
gap: 0.5rem; margin-bottom: 1.2rem; padding-bottom: 0.6rem; gap: 0.5rem; margin-bottom: 1.2rem; padding-bottom: 0.6rem;
border-bottom: 1px solid #e2e5e9; border-bottom: 1px solid var(--border);
} }
@media (prefers-color-scheme: dark) { .nav { border-color: #2b3138 !important; } }
.nav .links, .nav .session { display: flex; flex-wrap: wrap; gap: 0.2rem 0.9rem; align-items: center; } .nav .links, .nav .session { display: flex; flex-wrap: wrap; gap: 0.2rem 0.9rem; align-items: center; }
/* The top row: mark at one edge, session block at the other. Splitting them to /* The top row: mark at one edge, session block at the other. Splitting them to
the edges is what makes the two rows read as two blocks rather than as one the edges is what makes the two rows read as two blocks rather than as one
@@ -187,10 +215,7 @@ details form { margin-top: 0.6rem; }
padding: 0.2rem 0.5rem; border-radius: 6px; padding: 0.2rem 0.5rem; border-radius: 6px;
} }
.nav [aria-current] { .nav [aria-current] {
font-weight: 600; color: #1b1f24; background: #e6ebf5; box-shadow: inset 0 -2px 0 #2563eb; font-weight: 600; color: var(--fg); background: var(--nav-active-bg); box-shadow: inset 0 -2px 0 #2563eb;
}
@media (prefers-color-scheme: dark) {
.nav [aria-current] { color: #e6e8eb !important; background: #22303f !important; }
} }
/* Sign out carries an icon too, so it needs the same row layout; its padding /* Sign out carries an icon too, so it needs the same row layout; its padding
and colours come from the compact button rule further down. */ and colours come from the compact button rule further down. */
@@ -206,16 +231,10 @@ details form { margin-top: 0.6rem; }
font-size: 0.7rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em; font-size: 0.7rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em;
vertical-align: middle; border: 1px solid transparent; vertical-align: middle; border: 1px solid transparent;
} }
.st-ok { background: #ecfdf3; color: #067647; border-color: #abefc6; } .st-ok { background: var(--st-ok-bg); color: var(--st-ok-fg); border-color: var(--st-ok-border); }
.st-warn { background: #fffaeb; color: #b54708; border-color: #fedf89; } .st-warn { background: var(--st-warn-bg); color: var(--st-warn-fg); border-color: var(--st-warn-border); }
.st-error { background: #fef3f2; color: #b42318; border-color: #fecdca; } .st-error { background: var(--st-error-bg); color: var(--st-error-fg); border-color: var(--st-error-border); }
.st-unknown { background: #f0f2f4; color: #6b7280; border-color: #e2e5e9; } .st-unknown { background: var(--st-unknown-bg); color: var(--st-unknown-fg); border-color: var(--st-unknown-border); }
@media (prefers-color-scheme: dark) {
.st-ok { background: #0d2818 !important; color: #75d99b !important; border-color: #1a5336 !important; }
.st-warn { background: #2e2308 !important; color: #f5c86b !important; border-color: #6b5210 !important; }
.st-error { background: #2d1211 !important; color: #f5a29b !important; border-color: #6b201a !important; }
.st-unknown { background: #22262b !important; color: #9aa3ad !important; border-color: #2b3138 !important; }
}
.code-row { display: flex; align-items: flex-start; gap: 0.5rem; } .code-row { display: flex; align-items: flex-start; gap: 0.5rem; }
.code-row .code { flex: 1; min-width: 0; } .code-row .code { flex: 1; min-width: 0; }
/* Compact outlined button: same affordance as the filled one but quiet enough /* Compact outlined button: same affordance as the filled one but quiet enough
@@ -227,28 +246,16 @@ details form { margin-top: 0.6rem; }
button.copy, .actions button, .actions summary, .actions a.danger, .nav button { button.copy, .actions button, .actions summary, .actions a.danger, .nav button {
margin: 0; padding: 0.45rem 0.7rem; font-size: 0.8rem; font-weight: 600; margin: 0; padding: 0.45rem 0.7rem; font-size: 0.8rem; font-weight: 600;
border-radius: 6px; white-space: nowrap; border-radius: 6px; white-space: nowrap;
background: #eef1f5; color: #2563eb; border: 1px solid #cfd4da; 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 summary:hover,
.actions a.danger:hover, .nav button:hover { background: #e2e7ee; } .actions a.danger:hover, .nav button:hover { background: var(--surface-bg-hover); }
button.copy { flex: none; margin-top: 0.3rem; } button.copy { flex: none; margin-top: 0.3rem; }
.actions details[open] > summary { background: #dde3ec; } .actions details[open] > summary { background: var(--surface-open-bg); }
.actions button.danger, .actions a.danger, .nav button.danger { .actions button.danger, .actions a.danger, .nav button.danger {
color: #b42318; background: #fef3f2; border-color: #fecdca; color: var(--danger-fg); background: var(--danger-bg); border-color: var(--danger-border);
}
.actions button.danger:hover, .actions a.danger:hover, .nav button.danger:hover { background: #fee4e2; }
@media (prefers-color-scheme: dark) {
button.copy, .actions button, .actions summary, .actions a.danger, .nav button {
background: #22262b !important; border-color: #2b3138 !important;
}
button.copy:hover, .actions button:hover, .actions summary:hover,
.actions a.danger:hover, .nav button:hover { background: #2b3138 !important; }
.actions details[open] > summary { background: #313841 !important; }
.actions button.danger, .actions a.danger, .nav button.danger {
color: #f5a29b !important; background: #2d1211 !important; border-color: #6b201a !important;
}
.actions button.danger:hover, .actions a.danger:hover, .nav button.danger:hover { background: #3d1a18 !important; }
} }
.actions button.danger:hover, .actions a.danger:hover, .nav button.danger:hover { background: var(--danger-bg-hover); }
/* The optional "encrypt this download" block on the backup, export and import /* The optional "encrypt this download" block on the backup, export and import
forms. Its label is the one checkbox in the panel, so it opts out of the forms. Its label is the one checkbox in the panel, so it opts out of the
@@ -263,8 +270,7 @@ button.copy { flex: none; margin-top: 0.3rem; }
} }
.encrypt label.check input { width: auto; margin: 0; } .encrypt label.check input { width: auto; margin: 0; }
.encrypt-fields { .encrypt-fields {
margin-left: 1.6rem; padding-left: 0.9rem; border-left: 2px solid #e2e5e9; margin-left: 1.6rem; padding-left: 0.9rem; border-left: 2px solid var(--border);
} }
.encrypt-fields label { margin-top: 0.7rem; } .encrypt-fields label { margin-top: 0.7rem; }
.encrypt-fields .muted { margin: 0.5rem 0 0; font-size: 0.85rem; } .encrypt-fields .muted { margin: 0.5rem 0 0; font-size: 0.85rem; }
@media (prefers-color-scheme: dark) { .encrypt-fields { border-color: #2b3138 !important; } }
+16
View File
@@ -106,4 +106,20 @@
initAddressFields(document); initAddressFields(document);
initEncryptFields(document); initEncryptFields(document);
}); });
// --- Skip polling while the tab is hidden ------------------------------
// The monitoring pages (status, mail queue, system log, deliveries) poll
// every 5s via hx-trigger="every 5s". htmx has a built-in way to make that
// conditional (an event filter, hx-trigger="every 5s [expr]"), but it
// evaluates the filter with `new Function`, which the panel's CSP
// (default-src 'self', no 'unsafe-eval') would silently break. Skipping the
// request here instead needs nothing beyond what the CSP already allows: a
// request due while the tab is hidden is simply not sent, and the next
// request after it becomes visible again picks up on schedule as usual.
document.body.addEventListener("htmx:beforeRequest", function (ev) {
var trigger = ev.target.getAttribute && ev.target.getAttribute("hx-trigger");
if (document.hidden && trigger && trigger.indexOf("every") !== -1) {
ev.preventDefault();
}
});
})(); })();